Files
heicode-win/heicode/common/crypto_test.go
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

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[:])
}