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)
93 lines
2.6 KiB
Go
93 lines
2.6 KiB
Go
package common
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestVerifyEd25519Signature_RoundTrip(t *testing.T) {
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generate key: %v", err)
|
|
}
|
|
msg := []byte("hello world")
|
|
sig := ed25519.Sign(priv, msg)
|
|
|
|
pubB64 := base64.StdEncoding.EncodeToString(pub)
|
|
sigB64 := base64.StdEncoding.EncodeToString(sig)
|
|
|
|
if err := VerifyEd25519Signature(pubB64, msg, sigB64); err != nil {
|
|
t.Fatalf("expected verify to pass, got error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyEd25519Signature_WrongMessage(t *testing.T) {
|
|
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
|
sig := ed25519.Sign(priv, []byte("real"))
|
|
|
|
err := VerifyEd25519Signature(
|
|
base64.StdEncoding.EncodeToString(pub),
|
|
[]byte("forged"),
|
|
base64.StdEncoding.EncodeToString(sig),
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected verify to fail on tampered message, got nil")
|
|
}
|
|
}
|
|
|
|
func TestVerifyEd25519Signature_TamperedSignature(t *testing.T) {
|
|
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
|
sig := ed25519.Sign(priv, []byte("hello"))
|
|
sig[0] ^= 0xff
|
|
|
|
err := VerifyEd25519Signature(
|
|
base64.StdEncoding.EncodeToString(pub),
|
|
[]byte("hello"),
|
|
base64.StdEncoding.EncodeToString(sig),
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected verify to fail on tampered signature, got nil")
|
|
}
|
|
}
|
|
|
|
func TestVerifyEd25519Signature_MalformedInputs(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
pubkey string
|
|
message []byte
|
|
sig string
|
|
wantSub string // substring that must appear in the error message
|
|
}{
|
|
{"empty_pubkey", "", []byte("x"), "AAAA", "empty public key"},
|
|
{"empty_sig", base64Of32Zeros(), []byte("x"), "", "empty signature"},
|
|
{"bad_pubkey_b64", "!!!not-base64!!!", []byte("x"), base64Of64Zeros(), "valid base64"},
|
|
{"short_pubkey", base64.StdEncoding.EncodeToString([]byte("short")), []byte("x"), base64Of64Zeros(), "wrong length"},
|
|
{"bad_sig_b64", base64Of32Zeros(), []byte("x"), "!!!not-base64!!!", "valid base64"},
|
|
{"short_sig", base64Of32Zeros(), []byte("x"), base64.StdEncoding.EncodeToString([]byte("short")), "wrong length"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := VerifyEd25519Signature(tc.pubkey, tc.message, tc.sig)
|
|
if err == nil {
|
|
t.Fatalf("expected error containing %q, got nil", tc.wantSub)
|
|
}
|
|
if !strings.Contains(err.Error(), tc.wantSub) {
|
|
t.Fatalf("expected error to contain %q, got %q", tc.wantSub, err.Error())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func base64Of32Zeros() string {
|
|
var b [32]byte
|
|
return base64.StdEncoding.EncodeToString(b[:])
|
|
}
|
|
|
|
func base64Of64Zeros() string {
|
|
var b [64]byte
|
|
return base64.StdEncoding.EncodeToString(b[:])
|
|
}
|