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)
98 lines
5.1 KiB
JSON
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" }
|
|
}
|
|
]
|
|
}
|