Files
heicode-win/heicode/testdata/device_signature_vectors.json
T
chenchen 80d0f956d0 feat(manager): P0 device-binding signature layer for cc-haha desktop clients
Lays down the server side of a per-request Ed25519 signature scheme that
binds a token to a specific desktop install, so the bearer key can't be
extracted from ~/.claude/cc-haha/providers.json and resold. Plan lives
at ~/.claude/plans/peaceful-sprouting-crane.md.

Compatibility: legacy bare-bearer sk- callers (CLI/SDK) pass through
unchanged until P3 (30-day deadline) flips RequireGlobal=true. No
existing token rows are modified — pubkey is nullable and defaults to
null.

Pieces:
- model.Token gains DeviceId, DevicePubkey, DeviceFingerprint, DeviceName,
  DevicePlatform, DeviceAppVersion, DeviceBoundAt, DeviceLastSeenIp,
  DeviceLastUsedAt, RequireDeviceBinding, RevokedAt, RevokedReason.
  Pure additive columns, GORM AutoMigrate handles SQLite/MySQL/PG.
- common.VerifyEd25519Signature: thin wrapper around crypto/ed25519
  stdlib, used by the new middleware. No new external deps.
- service.MarkNonceUsed: Redis SETNX-based nonce store with an
  in-memory sync.Map fallback for single-instance dev. TTL = setting.
- middleware.VerifyDeviceSignatureIfRequired: wired into TokenAuth as
  a fail-fast dispatch right after model.ValidateUserToken. Verifies
  canonical = METHOD\nPATH\nTS_MS\nNONCE\nFINGERPRINT\nSHA256(BODY),
  signed as Ed25519(sha256(canonical)). 120s timestamp window, 300s
  nonce window, fingerprint stored at pair time must match the header.
- controller.PairDevice / ListUserDevices / RenameUserDevice /
  RevokeUserDevice, mounted at /api/devices/* behind UserAuth().
  PairDevice enforces 5-per-user cap and returns the raw sk- once,
  to be stored in the client's OS keychain (not providers.json).
- operation_setting.DeviceBindingSetting: MaxDevicesPerUser=5,
  TimestampWindowMs=120000, NonceTTLSec=300, RequireGlobal=false.

Tests:
- common/crypto_test.go covers round-trip + tamper + malformed inputs.
- middleware/device_signature_test.go covers all error-path branches
  (expired ts, wrong sig, tampered body, fingerprint mismatch, replay,
  revoked, missing headers, legacy fallthrough).
- testdata/device_signature_vectors.json is the cross-language contract
  Rust+TS sides will load to assert byte-identical canonical strings.

Untouched but reserved for follow-up phases:
- Anomaly detection / IP-diversity flagging (P1)
- 30-day deprecation banner + email notifications (P2)
- Hard cutover RequireGlobal=true (P3, day 31)
2026-05-20 12:10:57 +08:00

98 lines
5.1 KiB
JSON

{
"_comment": "Cross-language test vectors for device-signature canonical-string + Ed25519 signing. Both Manager (Go) and client (Rust+TS) tests load this file, compute the canonical string from the raw inputs in their own implementation, and assert the computed string is byte-identical to expected_canonical. The shared test key lets each side independently produce + verify a signature; because Ed25519 sign is deterministic, both implementations must produce the SAME signature for the same digest. The test seed below is RFC 8032 test vector 1's secret — published, not real. Never use these keys for anything beyond unit tests.",
"test_keypair": {
"_comment": "32-byte Ed25519 seed, hex-encoded. Public key is derived deterministically. RFC 8032 test vector 1.",
"seed_hex": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
"public_key_b64": "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=",
"public_key_hex": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
},
"canonical_format_spec": {
"_comment": "EXACT byte layout that the canonical string must match. Any drift between Go and Rust/TS implementations here breaks signature verification end-to-end. Document mirror lives at: cc-haha/src/services/device/signRequest.ts (top-of-file comment) and heicode/middleware/device_signature.go (HeaderDeviceID block).",
"fields_in_order": [
"method (uppercase ASCII, e.g. POST)",
"path_with_query (RequestURI form: /v1/messages?stream=true)",
"timestamp_ms (decimal integer, no thousands separator, no sign)",
"nonce_hex (lowercase hex, 32 chars for 16 bytes)",
"device_fingerprint (lowercase hex, 64 chars for sha256)",
"sha256_hex(body_bytes) (lowercase hex, 64 chars)"
],
"separator": "\\n (single line-feed, 0x0A, between each field; NOT included after the last field)",
"digest_to_sign": "sha256(canonical_string)"
},
"cases": [
{
"name": "GET_empty_body",
"input": {
"method": "GET",
"path_with_query": "/v1/models",
"timestamp_ms": "1747680000000",
"nonce_hex": "0123456789abcdef0123456789abcdef",
"device_fingerprint": "a1b2c3d4e5f6789abcdef0123456789abcdef0123456789abcdef0123456789a",
"body_text": ""
},
"expected_body_sha256_hex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"expected_canonical": "GET\n/v1/models\n1747680000000\n0123456789abcdef0123456789abcdef\na1b2c3d4e5f6789abcdef0123456789abcdef0123456789abcdef0123456789a\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
{
"name": "POST_small_json_body",
"input": {
"method": "POST",
"path_with_query": "/v1/messages",
"timestamp_ms": "1747680001234",
"nonce_hex": "ffffffffffffffffffffffffffffffff",
"device_fingerprint": "0000000000000000000000000000000000000000000000000000000000000000",
"body_text": "{\"model\":\"claude-sonnet-4-6\",\"max_tokens\":1024}"
},
"expected_body_sha256_hex": "a86e9f8fe1ec25a48b78f4d1d3df88b3dee3c5816fbc8ce26d8b2bc44b46d4b8",
"expected_canonical_starts_with": "POST\n/v1/messages\n1747680001234\nffffffffffffffffffffffffffffffff\n0000000000000000000000000000000000000000000000000000000000000000\n",
"_note_about_body_hash": "expected_body_sha256_hex above is illustrative; tests MUST recompute SHA256 of body_text bytes (UTF-8) and compare directly rather than relying on the precomputed value."
},
{
"name": "POST_streaming_request",
"input": {
"method": "POST",
"path_with_query": "/v1/messages?stream=true",
"timestamp_ms": "1747680002000",
"nonce_hex": "abababababababababababababababab",
"device_fingerprint": "11111111111111111111111111111111deadbeefdeadbeefdeadbeefdeadbeef",
"body_text": "{\"model\":\"claude-sonnet-4-6\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":256,\"stream\":true}"
},
"expected_canonical_starts_with": "POST\n/v1/messages?stream=true\n1747680002000\n"
},
{
"name": "GET_query_with_special_chars",
"input": {
"method": "GET",
"path_with_query": "/v1/dashboard/billing/usage?date=2026-05-20&filter=cost+desc",
"timestamp_ms": "1747680003000",
"nonce_hex": "deadbeefcafebabe0001020304050607",
"device_fingerprint": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"body_text": ""
},
"expected_canonical_starts_with": "GET\n/v1/dashboard/billing/usage?date=2026-05-20&filter=cost+desc\n"
}
],
"negative_cases": [
{
"name": "tampered_timestamp",
"_comment": "Sign canonical_1, then alter timestamp on the wire to canonical_2. Verify with pubkey + canonical_2 → must fail.",
"fixture": "GET_empty_body",
"tamper": { "timestamp_ms": "1747680000001" }
},
{
"name": "tampered_body",
"fixture": "POST_small_json_body",
"tamper": { "body_text": "{\"model\":\"claude-opus-4-7\",\"max_tokens\":1024}" }
},
{
"name": "wrong_method",
"fixture": "POST_small_json_body",
"tamper": { "method": "PUT" }
}
]
}