diff --git a/docs/tests/conformance/anchor-0c-lei/lei-public-examples.json b/docs/tests/conformance/anchor-0c-lei/lei-public-examples.json new file mode 100644 index 0000000..1aed013 --- /dev/null +++ b/docs/tests/conformance/anchor-0c-lei/lei-public-examples.json @@ -0,0 +1,88 @@ +{ + "$schema": "../schemas/anchor-vectors.schema.json", + "comment": "Real public LEIs from the GLEIF Global LEI Index (https://search.gleif.org). Each LEI passes the ISO 17442 §5.1 mod-97 check by construction; these vectors validate the AnchorResolver's Canonicalize against actual entity identifiers in production use, not just synthetic ones. Add new vectors by querying the public GLEIF API at https://api.gleif.org/api/v1/lei-records?filter[lei]= and confirming the entity status.", + "vectors": [ + { + "label": "GLEIF documentation example (often cited; smoke test)", + "lei": "529900T8BM49AURSDO55", + "louPrefix": "5299", + "expectedCanonicalize": "ok", + "notes": "Used throughout the ANS reference implementation as a base smoke test. Documented widely in GLEIF ISO 17442 reference material." + }, + { + "label": "Apple Inc. (US)", + "lei": "HWUPKR0MPOU8FGXBT394", + "louPrefix": "HWUP", + "expectedCanonicalize": "ok", + "notes": "Tests an alphabetic-prefix LOU, exercising the letter→digit-pair expansion path in the mod-97 check." + }, + { + "label": "Microsoft Corporation (US)", + "lei": "INR2EJN1ERAN0W5ZP974", + "louPrefix": "INR2", + "expectedCanonicalize": "ok", + "notes": "Mixed alphanumeric LOU prefix. Confirms that the resolver lowercases input before validation does not corrupt the canonical form (the resolver uppercases, but operator submission may be lowercase)." + }, + { + "label": "European Central Bank (DE)", + "lei": "549300DTUYXVMJXZNY75", + "louPrefix": "5493", + "expectedCanonicalize": "ok", + "notes": "Numeric-prefix LOU with mostly alphabetic body. Exercises a different mod-97 path than the doc example." + } + ], + "lowercaseInputs": [ + { + "label": "Lowercase input is canonicalized to uppercase", + "input": "529900t8bm49aursdo55", + "expectedCanonical": "529900T8BM49AURSDO55" + }, + { + "label": "Whitespace trimmed", + "input": " HWUPKR0MPOU8FGXBT394 ", + "expectedCanonical": "HWUPKR0MPOU8FGXBT394" + } + ], + "rejectVectors": [ + { + "label": "Empty input", + "input": "", + "expectedCode": "LEI_BAD_FORMAT" + }, + { + "label": "19 characters (too short)", + "input": "529900T8BM49AURSDO5", + "expectedCode": "LEI_BAD_FORMAT" + }, + { + "label": "21 characters (too long)", + "input": "529900T8BM49AURSDO551", + "expectedCode": "LEI_BAD_FORMAT" + }, + { + "label": "Embedded hyphen", + "input": "5299-00T8BM49AURSDO55", + "expectedCode": "LEI_BAD_FORMAT" + }, + { + "label": "Embedded space", + "input": "529900 T8BM49AURSDO55", + "expectedCode": "LEI_BAD_FORMAT" + }, + { + "label": "Special character", + "input": "529900T8BM49AURSDO5!", + "expectedCode": "LEI_BAD_FORMAT" + }, + { + "label": "Doc example with last digit perturbed (mod-97 fails)", + "input": "529900T8BM49AURSDO56", + "expectedCode": "LEI_BAD_CHECK_DIGITS" + }, + { + "label": "Apple LEI with last digit perturbed (mod-97 fails)", + "input": "HWUPKR0MPOU8FGXBT395", + "expectedCode": "LEI_BAD_CHECK_DIGITS" + } + ] +} diff --git a/internal/adapter/anchor/did/key.go b/internal/adapter/anchor/did/key.go new file mode 100644 index 0000000..b19b7ce --- /dev/null +++ b/internal/adapter/anchor/did/key.go @@ -0,0 +1,335 @@ +package did + +// did:key resolution per the W3C did:key method specification +// (https://w3c-ccg.github.io/did-method-key/). +// +// did:key is the only DID method that requires no network call: the +// DID URI itself encodes the verification key. Resolution decodes +// the multibase-encoded suffix, validates the multicodec key-type +// prefix, and emits a JWK. This makes did:key the cheapest and most +// deterministic anchor profile to test against — every CI run +// produces identical results without external infrastructure. +// +// Currently supported key types (per the W3C method spec): +// - Ed25519 (multicodec 0xED): 32-byte public key, JWK kty=OKP/crv=Ed25519. +// - secp256k1 (multicodec 0xE7): 33-byte compressed public key, JWK kty=EC/crv=secp256k1. +// +// X25519 (0xEC) and P-256 (0x1200) are not yet supported; the +// resolver returns DID_KEY_TYPE_NOT_IMPLEMENTED for them. Adding +// them is mechanical: extend keyTypeFromMulticodec with the new +// codec and the conversion logic in keyToJWK. The two listed are the +// dominant types in production deployments today. + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "strings" + "time" + + "github.com/godaddy/ans/internal/domain" +) + +// KeyProfileID is the canonical identifier for did:key in the +// AnchorResolverRegistry advertised through SupportedProfiles(). +const KeyProfileID = "0.B-did:key" + +// keyFreshnessBudget is fixed at the resolver's clock precision for +// did:key: the DID is the key, so a "stale" claim is a contradiction +// in terms. The freshness budget is set to 24 hours to match did:web +// for cache-management consistency, but a verifier MAY treat did:key +// claims as never expiring. +const keyFreshnessBudget = 24 * time.Hour + +// Key resolves did:key identifiers by decoding the multibase-encoded +// public key embedded in the DID itself. No HTTP, no chain, no +// directory lookup — the decode is the resolution. +type Key struct { + clock func() time.Time +} + +// NewKey constructs a Key resolver. +func NewKey() *Key { + return &Key{clock: time.Now} +} + +// WithClock returns a copy with a deterministic clock for tests. +func (k *Key) WithClock(clock func() time.Time) *Key { + return &Key{clock: clock} +} + +// SupportedProfiles satisfies port.AnchorResolver. +func (k *Key) SupportedProfiles() []string { + return []string{KeyProfileID} +} + +// Resolve decodes the input did:key URI to an IdentityClaim. +// +// Pipeline: +// 1. Lexical validation: did:key prefix; multibase prefix `z` +// (base58btc). +// 2. Base58btc decode. +// 3. Multicodec varint decode → key-type code. +// 4. Validate the remaining bytes match the key type's expected +// length. +// 5. JWK conversion per the key type. +// 6. Construct IdentityClaim. +func (k *Key) Resolve(_ context.Context, input string) (*domain.IdentityClaim, error) { + suffix, err := parseDIDKey(input) + if err != nil { + return nil, err + } + keyBytes, err := decodeBase58btc(suffix) + if err != nil { + return nil, err + } + codec, body, err := decodeMulticodecVarint(keyBytes) + if err != nil { + return nil, err + } + jwk, err := keyToJWK(codec, body) + if err != nil { + return nil, err + } + now := k.clock().UTC() + canonical := canonicalizeDIDKey(input) + return &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeDID, + ResolvedID: canonical, + PublicKeyJWK: jwk, + IssuedAt: now, + ExpiresAt: now.Add(keyFreshnessBudget), + }, nil +} + +// parseDIDKey strips did:key: prefix, validates the multibase prefix, +// returns the base58btc-encoded suffix. +func parseDIDKey(input string) (string, error) { + trimmed := strings.TrimSpace(input) + if !strings.HasPrefix(trimmed, "did:key:") { + return "", domain.NewValidationError( + "DID_BAD_FORMAT", + "expected did:key prefix", + ) + } + rest := strings.TrimPrefix(trimmed, "did:key:") + if rest == "" { + return "", domain.NewValidationError( + "DID_BAD_FORMAT", + "did:key URI missing identifier body", + ) + } + // Multibase prefix per IETF multibase: 'z' = base58btc, the only + // shape the W3C did:key v0.7 spec emits. + if rest[0] != 'z' { + return "", domain.NewValidationError( + "DID_KEY_BAD_MULTIBASE", + fmt.Sprintf("expected base58btc multibase prefix 'z', got %q", rest[:1]), + ) + } + return rest[1:], nil +} + +func canonicalizeDIDKey(input string) string { + // The spec mandates no canonicalization beyond stripping fragment + // (no fragments are admitted on did:key). Strip whitespace. + return strings.TrimSpace(input) +} + +// base58btcAlphabet matches the Bitcoin base58 alphabet exactly. +const base58btcAlphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +// decodeBase58btc decodes the input string to bytes per the Bitcoin +// base58 alphabet. +// +// Algorithm: accumulate the base-58 number into a math/big.Int, then +// extract big-endian bytes; preserve leading-zero handling by +// counting leading '1' characters in the input (each is a leading +// zero byte). math/big keeps the implementation short; performance +// is fine for the ≤80-byte payloads this profile produces. +func decodeBase58btc(s string) ([]byte, error) { + if s == "" { + return nil, domain.NewValidationError( + "DID_KEY_DECODE", + "base58btc input is empty", + ) + } + // Leading '1' characters in base58 represent leading zero bytes. + leadingZeros := 0 + for i := range len(s) { + if s[i] != '1' { + break + } + leadingZeros++ + } + num := big.NewInt(0) + base := big.NewInt(58) + for i := range len(s) { + idx := strings.IndexByte(base58btcAlphabet, s[i]) + if idx < 0 { + return nil, domain.NewValidationError( + "DID_KEY_DECODE", + fmt.Sprintf("invalid base58btc character %q at position %d", s[i], i), + ) + } + num.Mul(num, base) + num.Add(num, big.NewInt(int64(idx))) + } + body := num.Bytes() + if leadingZeros == 0 { + return body, nil + } + out := make([]byte, leadingZeros+len(body)) + copy(out[leadingZeros:], body) + return out, nil +} + +// decodeMulticodecVarint reads an unsigned LEB128 varint from the +// front of buf and returns (code, remainingBytes). The varint +// identifies the multicodec key type per +// https://github.com/multiformats/multicodec. +func decodeMulticodecVarint(buf []byte) (uint64, []byte, error) { + var code uint64 + var shift uint + for i, b := range buf { + code |= uint64(b&0x7f) << shift + shift += 7 + if b&0x80 == 0 { + return code, buf[i+1:], nil + } + if shift >= 64 { + return 0, nil, domain.NewValidationError( + "DID_KEY_DECODE", + "multicodec varint overflow", + ) + } + } + return 0, nil, domain.NewValidationError( + "DID_KEY_DECODE", + "multicodec varint truncated", + ) +} + +// keyToJWK converts a multicodec key (code + body bytes) into a JWK. +// Returned bytes are the canonical (sorted-key) JSON encoding so the +// hash chain stays deterministic. +func keyToJWK(code uint64, body []byte) ([]byte, error) { + switch code { + case 0xED: // Ed25519 + if len(body) != 32 { + return nil, domain.NewValidationError( + "DID_KEY_BAD_LENGTH", + fmt.Sprintf("Ed25519 public key MUST be 32 bytes, got %d", len(body)), + ) + } + jwk := map[string]string{ + "kty": "OKP", + "crv": "Ed25519", + "x": base64.RawURLEncoding.EncodeToString(body), + } + return marshalJWK(jwk) + case 0xE7: // secp256k1 compressed + if len(body) != 33 { + return nil, domain.NewValidationError( + "DID_KEY_BAD_LENGTH", + fmt.Sprintf("secp256k1 public key MUST be 33 bytes (compressed form), got %d", len(body)), + ) + } + x, y, err := decompressSecp256k1(body) + if err != nil { + return nil, err + } + jwk := map[string]string{ + "kty": "EC", + "crv": "secp256k1", + "x": base64.RawURLEncoding.EncodeToString(x), + "y": base64.RawURLEncoding.EncodeToString(y), + } + return marshalJWK(jwk) + case 0xEC: // X25519 + return nil, domain.NewValidationError( + "DID_KEY_TYPE_NOT_IMPLEMENTED", + "X25519 (multicodec 0xEC) decoding is not yet implemented in this resolver", + ) + case 0x1200: // P-256 + return nil, domain.NewValidationError( + "DID_KEY_TYPE_NOT_IMPLEMENTED", + "P-256 (multicodec 0x1200) decoding is not yet implemented in this resolver", + ) + default: + return nil, domain.NewValidationError( + "DID_KEY_TYPE_UNKNOWN", + fmt.Sprintf("unknown multicodec key type 0x%X", code), + ) + } +} + +// marshalJWK emits a JSON object in canonical (sorted-key) order so +// downstream hashing is deterministic. Map iteration in Go is random; +// json.Marshal on a map sorts keys, which is exactly what we want. +func marshalJWK(jwk map[string]string) ([]byte, error) { + // Convert to map[string]any so json.Marshal sees it as an object + // not specific to string-only values; it preserves alphabetical + // key order regardless. + out, err := json.Marshal(jwk) + if err != nil { + return nil, domain.NewInternalError("DID_KEY_MARSHAL", "marshal JWK", err) + } + return out, nil +} + +// decompressSecp256k1 decodes a 33-byte compressed point into raw +// big-endian X and Y coordinate byte slices. +// +// The compressed format per SEC 1 §2.3.3: +// - Byte 0: 0x02 (y is even) or 0x03 (y is odd). +// - Bytes 1..32: X coordinate, big-endian. +// +// Y is computed as the modular square root of (x³ + ax + b) mod p +// where (a, b, p) are the secp256k1 curve parameters. Since this +// resolver only emits the JWK for use in higher-spec verification +// rather than performing ECDSA itself, full point validation is not +// strictly required at the resolver layer; the simpler compressed- +// form check below catches malformed input. Point-on-curve validation +// should run in the Layer 1 cert-validator path before any signature +// is trusted. +func decompressSecp256k1(body []byte) ([]byte, []byte, error) { + if body[0] != 0x02 && body[0] != 0x03 { + return nil, nil, domain.NewValidationError( + "DID_KEY_DECODE", + fmt.Sprintf("secp256k1 compressed point MUST start with 0x02 or 0x03, got 0x%X", body[0]), + ) + } + x := body[1:] + // secp256k1 curve parameters. + // p = 2^256 - 2^32 - 977 + p, _ := new(big.Int).SetString("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F", 16) + // a = 0, b = 7 for secp256k1. + b := big.NewInt(7) + + xBig := new(big.Int).SetBytes(x) + // y^2 = x^3 + b mod p (a=0) + rhs := new(big.Int).Exp(xBig, big.NewInt(3), p) + rhs.Add(rhs, b) + rhs.Mod(rhs, p) + // Compute y = sqrt(rhs) mod p using Tonelli-Shanks. For secp256k1's + // p ≡ 3 (mod 4), the square root is rhs^((p+1)/4) mod p. + exp := new(big.Int).Add(p, big.NewInt(1)) + exp.Rsh(exp, 2) + y := new(big.Int).Exp(rhs, exp, p) + // Pick the parity matching the compressed form's leading byte. + if (y.Bit(0) == 1) != (body[0] == 0x03) { + y.Sub(p, y) + y.Mod(y, p) + } + yBytes := y.Bytes() + // Pad y to 32 bytes (big-endian) so JWK x and y are uniform width. + if len(yBytes) < 32 { + padded := make([]byte, 32) + copy(padded[32-len(yBytes):], yBytes) + yBytes = padded + } + return x, yBytes, nil +} diff --git a/internal/adapter/anchor/did/key_test.go b/internal/adapter/anchor/did/key_test.go new file mode 100644 index 0000000..43435d7 --- /dev/null +++ b/internal/adapter/anchor/did/key_test.go @@ -0,0 +1,318 @@ +package did + +import ( + "context" + "encoding/hex" + "errors" + "math/big" + "strings" + "testing" + "time" + + "github.com/godaddy/ans/internal/domain" +) + +// W3C did:key v0.7 test vectors. Source: spec text + examples from +// implementations like did-method-key (Digital Bazaar). Each vector +// is verified offline — these tests exercise the multibase + multicodec +// path end-to-end without external infrastructure. +var ed25519Vectors = []struct { + label string + did string + x string // base64url no-pad of the 32-byte public key +}{ + { + // W3C did:key spec-cited example. The "x" field below is the + // base64url-no-pad encoding of the 32-byte Ed25519 public key + // embedded in the multibase suffix; verified by resolving the + // DID through this resolver (round-trip with NewKey().Resolve). + label: "Ed25519 vector A (W3C spec example DID)", + did: "did:key:z6MkpTHR8VNsBxRbh2AsP615Cqc9GQQvd7b4S4ZZmsK6SjD1", + x: "lJZrfAjkBWcSC2XttzY5kRSyI3NW0YieBBml2xr2qZg", + }, + { + label: "Ed25519 vector B (Digital Bazaar example DID)", + did: "did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp", + x: "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik", + }, +} + +func TestKey_Resolve_Ed25519Vectors(t *testing.T) { + fixed := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC) + for _, v := range ed25519Vectors { + t.Run(v.label, func(t *testing.T) { + r := NewKey().WithClock(func() time.Time { return fixed }) + claim, err := r.Resolve(context.Background(), v.did) + if err != nil { + t.Fatalf("Resolve(%s): %v", v.did, err) + } + if claim.AnchorType != domain.AnchorTypeDID { + t.Errorf("AnchorType = %q", claim.AnchorType) + } + if claim.ResolvedID != v.did { + t.Errorf("ResolvedID = %q, want %q", claim.ResolvedID, v.did) + } + jwk := string(claim.PublicKeyJWK) + if !strings.Contains(jwk, `"kty":"OKP"`) { + t.Errorf("JWK missing kty=OKP: %s", jwk) + } + if !strings.Contains(jwk, `"crv":"Ed25519"`) { + t.Errorf("JWK missing crv=Ed25519: %s", jwk) + } + if !strings.Contains(jwk, `"x":"`+v.x+`"`) { + t.Errorf("JWK x mismatch: %s\n want x=%q", jwk, v.x) + } + if !claim.IssuedAt.Equal(fixed) { + t.Errorf("IssuedAt = %v", claim.IssuedAt) + } + if claim.ExpiresAt.Sub(claim.IssuedAt) != keyFreshnessBudget { + t.Errorf("ExpiresAt - IssuedAt = %v, want %v", + claim.ExpiresAt.Sub(claim.IssuedAt), keyFreshnessBudget) + } + if err := claim.Validate(); err != nil { + t.Errorf("returned claim fails Validate: %v", err) + } + }) + } +} + +func TestKey_Resolve_BadFormat(t *testing.T) { + cases := []struct { + name string + input string + wantCode string + }{ + { + name: "wrong method", + input: "did:web:agent.example.com", + wantCode: "DID_BAD_FORMAT", + }, + { + name: "missing prefix", + input: "z6MkpTHR8VNsBxRbh2AsP615Cqc9GQQvd7b4S4ZZmsK6SjD1", + wantCode: "DID_BAD_FORMAT", + }, + { + name: "empty body", + input: "did:key:", + wantCode: "DID_BAD_FORMAT", + }, + { + name: "wrong multibase prefix (e.g., uppercase Z)", + input: "did:key:Z6MkpTHR8VNsBxRbh2AsP615Cqc9GQQvd7b4S4ZZmsK6SjD1", + wantCode: "DID_KEY_BAD_MULTIBASE", + }, + { + name: "non-base58 character in body", + input: "did:key:z!nval!d", + wantCode: "DID_KEY_DECODE", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := NewKey().Resolve(context.Background(), c.input) + if err == nil { + t.Fatal("expected error, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) { + t.Fatalf("not *domain.Error: %T", err) + } + if dErr.Code != c.wantCode { + t.Errorf("code = %q, want %q (msg: %s)", dErr.Code, c.wantCode, dErr.Message) + } + }) + } +} + +func TestKey_SupportedProfiles(t *testing.T) { + got := NewKey().SupportedProfiles() + if len(got) != 1 || got[0] != KeyProfileID { + t.Errorf("SupportedProfiles = %v", got) + } + if KeyProfileID != "0.B-did:key" { + t.Errorf("KeyProfileID = %q", KeyProfileID) + } +} + +// TestDecodeBase58btc_LeadingZeros pins the leading-1-character +// preservation rule. Each leading '1' in base58 becomes a leading +// 0x00 byte in the decoded output. +func TestDecodeBase58btc_LeadingZeros(t *testing.T) { + cases := []struct { + input string + want []byte + }{ + {"1", []byte{0}}, + {"11", []byte{0, 0}}, + {"112", []byte{0, 0, 1}}, + } + for _, c := range cases { + got, err := decodeBase58btc(c.input) + if err != nil { + t.Errorf("decodeBase58btc(%q): %v", c.input, err) + continue + } + if string(got) != string(c.want) { + t.Errorf("decodeBase58btc(%q) = %v, want %v", c.input, got, c.want) + } + } +} + +func TestDecodeMulticodecVarint_SingleByte(t *testing.T) { + // 0x01 = unsigned varint for 1 + code, rest, err := decodeMulticodecVarint([]byte{0x01, 0xFF, 0xEE}) + if err != nil { + t.Fatalf("err: %v", err) + } + if code != 1 { + t.Errorf("code = %d, want 1", code) + } + if string(rest) != string([]byte{0xFF, 0xEE}) { + t.Errorf("rest = %v", rest) + } +} + +func TestDecodeMulticodecVarint_TwoByte(t *testing.T) { + // Ed25519 multicodec 0xED encoded as varint = [0xED, 0x01] + // 0xED = 0b11101101 → low 7 bits = 0b1101101 = 0x6D, continuation + // 0x01 = 0b00000001 → no continuation, so high bits = 1 + // Combined: (1 << 7) | 0x6D = 0xED + code, rest, err := decodeMulticodecVarint([]byte{0xED, 0x01, 0xAA}) + if err != nil { + t.Fatalf("err: %v", err) + } + if code != 0xED { + t.Errorf("code = 0x%X, want 0xED", code) + } + if string(rest) != string([]byte{0xAA}) { + t.Errorf("rest = %v", rest) + } +} + +func TestDecodeMulticodecVarint_Truncated(t *testing.T) { + // Continuation bit set on the last byte means truncated input. + _, _, err := decodeMulticodecVarint([]byte{0xED}) + if err == nil { + t.Fatal("expected truncation error") + } +} + +func TestKeyToJWK_X25519NotImplemented(t *testing.T) { + _, err := keyToJWK(0xEC, make([]byte, 32)) + if err == nil { + t.Fatal("expected DID_KEY_TYPE_NOT_IMPLEMENTED") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "DID_KEY_TYPE_NOT_IMPLEMENTED" { + t.Errorf("expected DID_KEY_TYPE_NOT_IMPLEMENTED, got %v", err) + } +} + +func TestKeyToJWK_P256NotImplemented(t *testing.T) { + _, err := keyToJWK(0x1200, make([]byte, 33)) + if err == nil { + t.Fatal("expected DID_KEY_TYPE_NOT_IMPLEMENTED") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "DID_KEY_TYPE_NOT_IMPLEMENTED" { + t.Errorf("expected DID_KEY_TYPE_NOT_IMPLEMENTED, got %v", err) + } +} + +func TestKeyToJWK_UnknownKeyType(t *testing.T) { + _, err := keyToJWK(0xDEAD, make([]byte, 32)) + if err == nil { + t.Fatal("expected DID_KEY_TYPE_UNKNOWN") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "DID_KEY_TYPE_UNKNOWN" { + t.Errorf("expected DID_KEY_TYPE_UNKNOWN, got %v", err) + } +} + +func TestKeyToJWK_Ed25519BadLength(t *testing.T) { + // 31 bytes — wrong length for Ed25519 (must be 32). + _, err := keyToJWK(0xED, make([]byte, 31)) + if err == nil { + t.Fatal("expected DID_KEY_BAD_LENGTH") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "DID_KEY_BAD_LENGTH" { + t.Errorf("expected DID_KEY_BAD_LENGTH, got %v", err) + } +} + +func TestKey_Resolve_Secp256k1(t *testing.T) { + // Construct a valid did:key for a secp256k1 key by encoding the + // generator point G of secp256k1 in compressed form (which is a + // well-known valid point), prepending the 0xE7 multicodec varint, + // then base58btc-encoding the result. + // + // secp256k1 G compressed = 0x02 || x_G(32 bytes) + gxHex := "79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798" + body := append([]byte{0x02}, decodeHexT(t, gxHex)...) + // multicodec varint for 0xE7 = [0xE7, 0x01] + prefixed := append([]byte{0xE7, 0x01}, body...) + encoded := encodeBase58btc(prefixed) + did := "did:key:z" + encoded + + r := NewKey() + claim, err := r.Resolve(context.Background(), did) + if err != nil { + t.Fatalf("Resolve(%s): %v", did, err) + } + jwk := string(claim.PublicKeyJWK) + if !strings.Contains(jwk, `"kty":"EC"`) { + t.Errorf("JWK missing kty=EC: %s", jwk) + } + if !strings.Contains(jwk, `"crv":"secp256k1"`) { + t.Errorf("JWK missing crv=secp256k1: %s", jwk) + } + // Y must be present (decompression succeeded). + if !strings.Contains(jwk, `"y":"`) { + t.Errorf("JWK missing y coordinate: %s", jwk) + } +} + +// decodeHexT is a tiny test helper for hex-byte fixtures. +func decodeHexT(t *testing.T, h string) []byte { + t.Helper() + out, err := hex.DecodeString(h) + if err != nil { + t.Fatalf("invalid hex %q: %v", h, err) + } + return out +} + +// encodeBase58btc is the inverse of decodeBase58btc; used in tests +// to construct a did:key for the secp256k1 round-trip. +func encodeBase58btc(buf []byte) string { + if len(buf) == 0 { + return "" + } + leadingZeros := 0 + for _, b := range buf { + if b != 0 { + break + } + leadingZeros++ + } + num := new(big.Int).SetBytes(buf) + out := []byte{} + base := big.NewInt(58) + zero := big.NewInt(0) + mod := new(big.Int) + for num.Cmp(zero) > 0 { + num.DivMod(num, base, mod) + out = append(out, base58btcAlphabet[mod.Int64()]) + } + for range leadingZeros { + out = append(out, '1') + } + // reverse + for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { + out[i], out[j] = out[j], out[i] + } + return string(out) +} diff --git a/internal/adapter/anchor/did/pkh.go b/internal/adapter/anchor/did/pkh.go new file mode 100644 index 0000000..6b842d1 --- /dev/null +++ b/internal/adapter/anchor/did/pkh.go @@ -0,0 +1,259 @@ +package did + +// did:pkh resolution per the [W3C CCG did:pkh method specification] +// (https://github.com/w3c-ccg/did-pkh) and CAIP-10 account identifiers. +// +// did:pkh is the path the proposal at anchor-0b-did.md §6 names for +// ERC-8004 on-chain agent identity. The DID URI carries a CAIP-10 +// account identifier (e.g., "eip155:1:0x...") that resolves to an +// on-chain controller address. The resolver's job is lexical +// validation + chain identifier parsing; the actual on-chain lookup +// (reading the ERC-8004 IdentityRegistry, decoding the controller's +// verification key) is delegated to a ChainResolver injected via +// WithChainResolver. +// +// This slice ships the lexical layer in full plus the ChainResolver +// interface; the production HTTP-RPC implementation against an +// Ethereum node lands when an actual ERC-8004 testnet deployment +// is wired into CI. The pattern mirrors the LEI resolver's +// GLEIFClient injection: the package is useful for unit tests and +// CAIP-10 validation today, and switches to live resolution by +// configuration without code changes. + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/godaddy/ans/internal/domain" +) + +// PkhProfileID is the canonical identifier for did:pkh in the +// AnchorResolverRegistry advertised through SupportedProfiles(). +const PkhProfileID = "0.B-did:pkh" + +// pkhFreshnessBudget bounds the cache lifetime for a did:pkh claim. +// 1 hour to match the FQDN profile and to keep on-chain state +// monitoring tight; verifiers MAY shorten further when a chain +// reorganization is observed. +const pkhFreshnessBudget = 1 * time.Hour + +// CAIPAccount is the parsed form of a CAIP-10 account identifier +// per [CAIP-10] (https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-10.md). +// +// Fields: +// +// Namespace the chain-agnostic family (eip155, solana, cosmos, ...) +// Reference the network identifier within the namespace (chain ID +// for eip155, ledger hash prefix for solana, etc.) +// Address the account-level identifier on that network +// +// Example: did:pkh:eip155:1:0xa1b2... → CAIPAccount{Namespace: +// "eip155", Reference: "1", Address: "0xa1b2..."}. +type CAIPAccount struct { + Namespace string + Reference string + Address string +} + +// String returns the canonical CAIP-10 form: ::
. +func (c CAIPAccount) String() string { + return c.Namespace + ":" + c.Reference + ":" + c.Address +} + +// ChainResolver abstracts the chain-specific lookup the did:pkh +// resolver needs to convert an account identifier to a verification +// key. Implementations live outside this package so the resolver +// does not link an Ethereum RPC client (or any other chain library) +// at compile time. +// +// The resolver passes a parsed CAIPAccount; the implementation +// returns the verification key in JWK form. ERC-8004 implementations +// MAY also return additional metadata (controller address change +// history, on-chain reputation snapshot) but the resolver only +// needs the JWK for the IdentityClaim. +type ChainResolver interface { + // LookupKey returns the verification key for the given account. + // Implementations MUST validate the account's controller is + // active and reject revoked or transferred controllers per the + // chain's semantics. + LookupKey(ctx context.Context, account CAIPAccount) ([]byte, error) +} + +// Pkh implements port.AnchorResolver for did:pkh anchors. +type Pkh struct { + chain ChainResolver + clock func() time.Time +} + +// NewPkh constructs a Pkh resolver with no chain client. Lexical +// validation runs offline; full resolution returns +// DID_PKH_CHAIN_NOT_CONFIGURED until WithChainResolver is used. +// This keeps the resolver useful for testbeds without forcing every +// downstream environment to wire an Ethereum RPC endpoint. +func NewPkh() *Pkh { + return &Pkh{clock: time.Now} +} + +// WithChainResolver injects a chain client and returns a copy of +// the resolver. Different chains plug in through different +// ChainResolver implementations; a single resolver MAY route by +// CAIPAccount.Namespace internally. +func (p *Pkh) WithChainResolver(c ChainResolver) *Pkh { + return &Pkh{chain: c, clock: p.clock} +} + +// WithClock returns a copy with a deterministic clock for tests. +func (p *Pkh) WithClock(clock func() time.Time) *Pkh { + return &Pkh{chain: p.chain, clock: clock} +} + +// SupportedProfiles satisfies port.AnchorResolver. +func (p *Pkh) SupportedProfiles() []string { + return []string{PkhProfileID} +} + +// Resolve validates the input did:pkh URI and (when a ChainResolver +// is configured) fetches the verification key. +// +// Pipeline: +// 1. Lexical validation: did:pkh: prefix + CAIP-10 shape. +// 2. CAIP-10 parsing into namespace, reference, address. +// 3. Per-namespace address validation (eip155: 0x + 40 hex). +// 4. If no ChainResolver is configured: return +// DID_PKH_CHAIN_NOT_CONFIGURED. This is the slice boundary; the +// production client lands once the testnet wiring is in place. +// 5. ChainResolver.LookupKey -> JWK. +// 6. Construct IdentityClaim with ExpiresAt = now + 1h. +func (p *Pkh) Resolve(ctx context.Context, input string) (*domain.IdentityClaim, error) { + account, err := parseDIDPkh(input) + if err != nil { + return nil, err + } + if err := validateAccountAddress(account); err != nil { + return nil, err + } + canonical := canonicalizeDIDPkh(account) + if p.chain == nil { + return nil, domain.NewInternalError( + "DID_PKH_CHAIN_NOT_CONFIGURED", + "did:pkh resolver has no ChainResolver configured; lexical validation passed but full resolution requires WithChainResolver", + nil, + ) + } + jwk, err := p.chain.LookupKey(ctx, account) + if err != nil { + return nil, domain.NewValidationError( + "DID_PKH_CHAIN_LOOKUP_FAILED", + "chain lookup failed: "+err.Error(), + ) + } + if len(jwk) == 0 { + return nil, domain.NewValidationError( + "DID_PKH_NO_KEY", + "chain returned no key for "+canonical, + ) + } + now := p.clock().UTC() + return &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeDID, + ResolvedID: canonical, + PublicKeyJWK: jwk, + IssuedAt: now, + ExpiresAt: now.Add(pkhFreshnessBudget), + }, nil +} + +// parseDIDPkh splits did:pkh:::
+// into a CAIPAccount. +func parseDIDPkh(input string) (CAIPAccount, error) { + trimmed := strings.TrimSpace(input) + if !strings.HasPrefix(trimmed, "did:pkh:") { + return CAIPAccount{}, domain.NewValidationError( + "DID_BAD_FORMAT", + "expected did:pkh prefix", + ) + } + rest := strings.TrimPrefix(trimmed, "did:pkh:") + parts := strings.SplitN(rest, ":", 3) + if len(parts) != 3 { + return CAIPAccount{}, domain.NewValidationError( + "DID_PKH_BAD_FORMAT", + "did:pkh body must be ::
", + ) + } + ns := strings.ToLower(parts[0]) + ref := parts[1] + addr := parts[2] + if ns == "" || ref == "" || addr == "" { + return CAIPAccount{}, domain.NewValidationError( + "DID_PKH_BAD_FORMAT", + "did:pkh body parts must all be non-empty", + ) + } + return CAIPAccount{Namespace: ns, Reference: ref, Address: addr}, nil +} + +// canonicalizeDIDPkh reconstructs the canonical lowercase did:pkh +// URI for the IdentityClaim's ResolvedID. The namespace is lowercased +// per CAIP-2; the address case is namespace-specific (eip155 +// preserves the EIP-55 checksum form when supplied; lowercase +// otherwise). +func canonicalizeDIDPkh(a CAIPAccount) string { + addr := a.Address + if a.Namespace == "eip155" { + // EIP-55 checksum addresses are mixed-case; preserve as-is. + // Pure-lowercase addresses are also valid; preserve those too. + // A future implementation MAY enforce checksum validity here + // (rejecting mixed-case addresses whose checksum does not + // validate); today we admit both shapes. + _ = addr + } + return "did:pkh:" + a.Namespace + ":" + a.Reference + ":" + addr +} + +// validateAccountAddress applies per-namespace address-shape rules. +// Namespaces beyond eip155 admit any non-empty address at this slice; +// add per-namespace validators here as profiles expand. +func validateAccountAddress(a CAIPAccount) error { + switch a.Namespace { + case "eip155": + return validateEIP155Address(a.Address) + default: + // Unknown namespace: accept the lexical shape; the chain + // resolver implementation owns the per-chain rules. + return nil + } +} + +// validateEIP155Address checks the Ethereum address shape: +// "0x" prefix + 40 hex characters (case-insensitive). +func validateEIP155Address(addr string) error { + if !strings.HasPrefix(addr, "0x") && !strings.HasPrefix(addr, "0X") { + return domain.NewValidationError( + "DID_PKH_BAD_ADDRESS", + fmt.Sprintf("eip155 address must start with 0x, got %q", addr), + ) + } + body := addr[2:] + if len(body) != 40 { + return domain.NewValidationError( + "DID_PKH_BAD_ADDRESS", + fmt.Sprintf("eip155 address body must be 40 hex chars, got %d", len(body)), + ) + } + for i, c := range body { + switch { + case c >= '0' && c <= '9': + case c >= 'a' && c <= 'f': + case c >= 'A' && c <= 'F': + default: + return domain.NewValidationError( + "DID_PKH_BAD_ADDRESS", + fmt.Sprintf("eip155 address body must be hex, got %q at position %d", c, i), + ) + } + } + return nil +} diff --git a/internal/adapter/anchor/did/pkh_test.go b/internal/adapter/anchor/did/pkh_test.go new file mode 100644 index 0000000..663e1e1 --- /dev/null +++ b/internal/adapter/anchor/did/pkh_test.go @@ -0,0 +1,270 @@ +package did + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/godaddy/ans/internal/domain" +) + +// Anvil's first pre-funded account address (the canonical Ethereum +// testnet address used widely in foundry/hardhat documentation). +const anvilAddress = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + +func TestParseDIDPkh_Eip155(t *testing.T) { + cases := []struct { + name string + input string + wantNS string + wantRef string + wantAddr string + }{ + { + name: "Sepolia testnet", + input: "did:pkh:eip155:11155111:" + anvilAddress, + wantNS: "eip155", + wantRef: "11155111", + wantAddr: anvilAddress, + }, + { + name: "Ethereum mainnet", + input: "did:pkh:eip155:1:" + anvilAddress, + wantNS: "eip155", + wantRef: "1", + wantAddr: anvilAddress, + }, + { + name: "Local Anvil chain (chain id 31337)", + input: "did:pkh:eip155:31337:" + anvilAddress, + wantNS: "eip155", + wantRef: "31337", + wantAddr: anvilAddress, + }, + { + name: "uppercase namespace lowercased", + input: "did:pkh:EIP155:1:" + anvilAddress, + wantNS: "eip155", + wantRef: "1", + wantAddr: anvilAddress, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := parseDIDPkh(c.input) + if err != nil { + t.Fatalf("err: %v", err) + } + if got.Namespace != c.wantNS { + t.Errorf("Namespace = %q, want %q", got.Namespace, c.wantNS) + } + if got.Reference != c.wantRef { + t.Errorf("Reference = %q, want %q", got.Reference, c.wantRef) + } + if got.Address != c.wantAddr { + t.Errorf("Address = %q, want %q", got.Address, c.wantAddr) + } + }) + } +} + +func TestParseDIDPkh_BadShape(t *testing.T) { + cases := []struct { + name string + input string + wantCode string + }{ + {"wrong prefix", "did:web:agent.example.com", "DID_BAD_FORMAT"}, + {"missing prefix", "eip155:1:0x1234", "DID_BAD_FORMAT"}, + {"too few parts", "did:pkh:eip155:1", "DID_PKH_BAD_FORMAT"}, + {"empty namespace", "did:pkh::1:0x1234", "DID_PKH_BAD_FORMAT"}, + {"empty reference", "did:pkh:eip155::0x1234", "DID_PKH_BAD_FORMAT"}, + {"empty address", "did:pkh:eip155:1:", "DID_PKH_BAD_FORMAT"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := parseDIDPkh(c.input) + if err == nil { + t.Fatal("expected error, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != c.wantCode { + t.Errorf("expected %q, got %v", c.wantCode, err) + } + }) + } +} + +func TestValidateEIP155Address(t *testing.T) { + good := []string{ + anvilAddress, + "0x" + strings.Repeat("a", 40), + "0x" + strings.Repeat("F", 40), + "0X" + strings.Repeat("0", 40), // uppercase 0X also accepted + } + for _, a := range good { + if err := validateEIP155Address(a); err != nil { + t.Errorf("validateEIP155Address(%q) rejected: %v", a, err) + } + } + + bad := []string{ + "", + "not-an-address", + "f39Fd6e51aad88F6F4ce6aB8827279cffFb92266", // missing 0x + "0x" + strings.Repeat("a", 39), // 39 chars + "0x" + strings.Repeat("a", 41), // 41 chars + "0x" + strings.Repeat("g", 40), // non-hex + } + for _, a := range bad { + if err := validateEIP155Address(a); err == nil { + t.Errorf("validateEIP155Address(%q) should reject", a) + } + } +} + +func TestPkh_Resolve_NoChainConfigured(t *testing.T) { + r := NewPkh() + _, err := r.Resolve(context.Background(), + "did:pkh:eip155:1:"+anvilAddress) + if err == nil { + t.Fatal("expected DID_PKH_CHAIN_NOT_CONFIGURED, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "DID_PKH_CHAIN_NOT_CONFIGURED" { + t.Errorf("expected DID_PKH_CHAIN_NOT_CONFIGURED, got %v", err) + } +} + +func TestPkh_Resolve_BadAddressPropagates(t *testing.T) { + r := NewPkh() + _, err := r.Resolve(context.Background(), + "did:pkh:eip155:1:not-hex") + if err == nil { + t.Fatal("expected DID_PKH_BAD_ADDRESS, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "DID_PKH_BAD_ADDRESS" { + t.Errorf("expected DID_PKH_BAD_ADDRESS, got %v", err) + } +} + +func TestPkh_Resolve_BadFormatPropagates(t *testing.T) { + r := NewPkh() + _, err := r.Resolve(context.Background(), "did:web:agent.example.com") + if err == nil { + t.Fatal("expected DID_BAD_FORMAT, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "DID_BAD_FORMAT" { + t.Errorf("expected DID_BAD_FORMAT, got %v", err) + } +} + +func TestPkh_Resolve_NonEip155NamespaceAcceptedLexically(t *testing.T) { + // Solana-style account: did:pkh:solana::. + // The resolver does no per-namespace shape check beyond eip155 + // today; the chain resolver implementation owns that. + r := NewPkh() + _, err := r.Resolve(context.Background(), + "did:pkh:solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc6wjsmkddF:8FE27ioQh3T7o22QsYVT5Re8NhTiUuoBM5kbsW1ZqhB1") + // Should fail with DID_PKH_CHAIN_NOT_CONFIGURED, not a parsing error, + // because the namespace passes lexical validation. + if err == nil { + t.Fatal("expected DID_PKH_CHAIN_NOT_CONFIGURED, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "DID_PKH_CHAIN_NOT_CONFIGURED" { + t.Errorf("non-eip155 namespace should pass lexical validation; got %v", err) + } +} + +// fakeChainResolver is a test double for the chain client. Mirrors +// the LEI resolver's fakeGLEIFClient pattern. +type fakeChainResolver struct { + jwk []byte + err error +} + +func (f *fakeChainResolver) LookupKey(_ context.Context, _ CAIPAccount) ([]byte, error) { + return f.jwk, f.err +} + +func TestPkh_Resolve_HappyPath(t *testing.T) { + fixed := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC) + jwk := []byte(`{"kty":"EC","crv":"secp256k1","x":"ANVIL-X","y":"ANVIL-Y"}`) + r := NewPkh(). + WithClock(func() time.Time { return fixed }). + WithChainResolver(&fakeChainResolver{jwk: jwk}) + + claim, err := r.Resolve(context.Background(), + "did:pkh:eip155:31337:"+anvilAddress) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if claim.AnchorType != domain.AnchorTypeDID { + t.Errorf("AnchorType = %q", claim.AnchorType) + } + if claim.ResolvedID != "did:pkh:eip155:31337:"+anvilAddress { + t.Errorf("ResolvedID = %q", claim.ResolvedID) + } + if string(claim.PublicKeyJWK) != string(jwk) { + t.Errorf("PublicKeyJWK was mutated") + } + if !claim.IssuedAt.Equal(fixed) { + t.Errorf("IssuedAt = %v", claim.IssuedAt) + } + if claim.ExpiresAt.Sub(claim.IssuedAt) != pkhFreshnessBudget { + t.Errorf("ExpiresAt - IssuedAt = %v, want %v", + claim.ExpiresAt.Sub(claim.IssuedAt), pkhFreshnessBudget) + } + if err := claim.Validate(); err != nil { + t.Errorf("Validate: %v", err) + } +} + +func TestPkh_Resolve_ChainLookupError(t *testing.T) { + r := NewPkh().WithChainResolver(&fakeChainResolver{err: errors.New("rpc unavailable")}) + _, err := r.Resolve(context.Background(), + "did:pkh:eip155:1:"+anvilAddress) + if err == nil { + t.Fatal("expected DID_PKH_CHAIN_LOOKUP_FAILED, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "DID_PKH_CHAIN_LOOKUP_FAILED" { + t.Errorf("expected DID_PKH_CHAIN_LOOKUP_FAILED, got %v", err) + } +} + +func TestPkh_Resolve_EmptyJWKReturned(t *testing.T) { + r := NewPkh().WithChainResolver(&fakeChainResolver{jwk: nil}) + _, err := r.Resolve(context.Background(), + "did:pkh:eip155:1:"+anvilAddress) + if err == nil { + t.Fatal("expected DID_PKH_NO_KEY, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "DID_PKH_NO_KEY" { + t.Errorf("expected DID_PKH_NO_KEY, got %v", err) + } +} + +func TestPkh_SupportedProfiles(t *testing.T) { + got := NewPkh().SupportedProfiles() + if len(got) != 1 || got[0] != PkhProfileID { + t.Errorf("SupportedProfiles = %v", got) + } + if PkhProfileID != "0.B-did:pkh" { + t.Errorf("PkhProfileID = %q", PkhProfileID) + } +} + +func TestCAIPAccount_String(t *testing.T) { + a := CAIPAccount{Namespace: "eip155", Reference: "1", Address: anvilAddress} + want := "eip155:1:" + anvilAddress + if got := a.String(); got != want { + t.Errorf("String() = %q, want %q", got, want) + } +} diff --git a/internal/adapter/anchor/lei/conformance_test.go b/internal/adapter/anchor/lei/conformance_test.go new file mode 100644 index 0000000..9f4c52b --- /dev/null +++ b/internal/adapter/anchor/lei/conformance_test.go @@ -0,0 +1,120 @@ +package lei + +// Conformance tests driven by the JSON fixtures at +// docs/tests/conformance/anchor-0c-lei/. Validates the LEI +// resolver's Canonicalize against real public LEIs from the GLEIF +// Global LEI Index, lowercase + whitespace normalization, and the +// negative cases the resolver MUST reject. The fixture file is +// language-agnostic; an external Rust or Python implementation can +// consume the same JSON to validate its conformance. + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/godaddy/ans/internal/domain" +) + +type leiVector struct { + Label string `json:"label"` + LEI string `json:"lei"` + LOUPrefix string `json:"louPrefix"` + ExpectedCanonicalize string `json:"expectedCanonicalize"` + Notes string `json:"notes"` +} + +type lowercaseVector struct { + Label string `json:"label"` + Input string `json:"input"` + ExpectedCanonical string `json:"expectedCanonical"` +} + +type rejectVector struct { + Label string `json:"label"` + Input string `json:"input"` + ExpectedCode string `json:"expectedCode"` +} + +type conformanceFixture struct { + Vectors []leiVector `json:"vectors"` + LowercaseInputs []lowercaseVector `json:"lowercaseInputs"` + RejectVectors []rejectVector `json:"rejectVectors"` +} + +func loadConformanceFixture(t *testing.T) conformanceFixture { + t.Helper() + path := filepath.Join("..", "..", "..", "..", "docs", "tests", + "conformance", "anchor-0c-lei", "lei-public-examples.json") + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read fixture %s: %v", path, err) + } + var f conformanceFixture + if err := json.Unmarshal(body, &f); err != nil { + t.Fatalf("parse fixture: %v", err) + } + return f +} + +func TestConformance_PublicLEIs(t *testing.T) { + fixture := loadConformanceFixture(t) + if len(fixture.Vectors) < 4 { + t.Fatalf("expected at least 4 public-LEI vectors, got %d", len(fixture.Vectors)) + } + for _, v := range fixture.Vectors { + t.Run(v.Label, func(t *testing.T) { + got, err := Canonicalize(v.LEI) + if err != nil { + t.Fatalf("Canonicalize(%q) failed: %v\n notes: %s", v.LEI, err, v.Notes) + } + if got != v.LEI { + t.Errorf("Canonicalize(%q) = %q (already-canonical input should pass through)", + v.LEI, got) + } + // Cross-check the LOU prefix: first 4 characters of the canonical form. + if len(got) >= 4 && v.LOUPrefix != "" { + if got[:4] != v.LOUPrefix { + t.Errorf("LOU prefix mismatch: got %q, want %q", got[:4], v.LOUPrefix) + } + } + }) + } +} + +func TestConformance_LowercaseAndWhitespace(t *testing.T) { + fixture := loadConformanceFixture(t) + for _, v := range fixture.LowercaseInputs { + t.Run(v.Label, func(t *testing.T) { + got, err := Canonicalize(v.Input) + if err != nil { + t.Fatalf("Canonicalize(%q) failed: %v", v.Input, err) + } + if got != v.ExpectedCanonical { + t.Errorf("Canonicalize(%q) = %q, want %q", v.Input, got, v.ExpectedCanonical) + } + }) + } +} + +func TestConformance_RejectVectors(t *testing.T) { + fixture := loadConformanceFixture(t) + for _, v := range fixture.RejectVectors { + t.Run(v.Label, func(t *testing.T) { + _, err := Canonicalize(v.Input) + if err == nil { + t.Fatalf("Canonicalize(%q) should reject, got success", v.Input) + } + var dErr *domain.Error + if !errors.As(err, &dErr) { + t.Fatalf("error is not *domain.Error: %T", err) + } + if dErr.Code != v.ExpectedCode { + t.Errorf("Canonicalize(%q) code = %q, want %q", + v.Input, dErr.Code, v.ExpectedCode) + } + }) + } +} diff --git a/internal/adapter/store/sqlite/agent.go b/internal/adapter/store/sqlite/agent.go index c81c85b..badcb0e 100644 --- a/internal/adapter/store/sqlite/agent.go +++ b/internal/adapter/store/sqlite/agent.go @@ -258,20 +258,43 @@ func (s *AgentStore) ExistsByAnsName(ctx context.Context, ansName domain.AnsName } // ExistsActiveBaseOnlyByAgentHost returns true if a non-revoked, -// non-failed base-only registration already claims the given FQDN. -// Migration 008 stores ans_name as NULL for base-only rows, but the -// pre-008 path could leave an empty string in place; the predicate -// matches both shapes so the check stays correct across an in-place -// upgrade. The §3.2.0 path lets the same FQDN host multiple -// registration rows over time (a base-only registration revoked, then -// re-registered) but only one can be live at any moment. -func (s *AgentStore) ExistsActiveBaseOnlyByAgentHost(ctx context.Context, host string) (bool, error) { +// non-failed base-only registration already claims the given FQDN +// under the given anchor type. +// +// Plan G note: the uniqueness scope is (agent_host, anchor_type), +// not agent_host alone. The same operational FQDN MAY carry multiple +// base-only registrations under different anchor profiles (FQDN, +// DID, LEI) per ANS-0 §7 cross-anchor equivalence. Within a single +// anchor profile, only one live base-only row per FQDN is admitted. +// +// anchorType is the empty string for the legacy "anchor unspecified" +// path (rows registered before Plan G's anchor block landed); these +// rows occupy their own implicit uniqueness slot so a fresh +// anchor-aware DID or LEI registration on the same host is allowed +// to land alongside a legacy FQDN-implicit row. +// +// Migration 008 stores ans_name as NULL for base-only rows; the +// predicate also accepts the pre-008 empty-string shape for +// in-place upgrade compatibility. +func (s *AgentStore) ExistsActiveBaseOnlyByAgentHost(ctx context.Context, host string, anchorType string) (bool, error) { var n int + if anchorType == "" { + const q = `SELECT COUNT(1) FROM agent_registrations + WHERE agent_host = ? + AND (ans_name IS NULL OR ans_name = '') + AND (anchor_type IS NULL OR anchor_type = '') + AND status NOT IN ('REVOKED', 'FAILED', 'EXPIRED')` + if err := s.db.extx(ctx).GetContext(ctx, &n, q, host); err != nil { + return false, err + } + return n > 0, nil + } const q = `SELECT COUNT(1) FROM agent_registrations WHERE agent_host = ? AND (ans_name IS NULL OR ans_name = '') + AND anchor_type = ? AND status NOT IN ('REVOKED', 'FAILED', 'EXPIRED')` - if err := s.db.extx(ctx).GetContext(ctx, &n, q, host); err != nil { + if err := s.db.extx(ctx).GetContext(ctx, &n, q, host, anchorType); err != nil { return false, err } return n > 0, nil diff --git a/internal/adapter/store/sqlite/agent_test.go b/internal/adapter/store/sqlite/agent_test.go index a77e28a..ee5c3b3 100644 --- a/internal/adapter/store/sqlite/agent_test.go +++ b/internal/adapter/store/sqlite/agent_test.go @@ -448,7 +448,7 @@ func TestAgentStore_ExistsActiveBaseOnlyByAgentHost(t *testing.T) { ctx := context.Background() // Empty store → false. - exists, err := store.ExistsActiveBaseOnlyByAgentHost(ctx, "skill-a.example.com") + exists, err := store.ExistsActiveBaseOnlyByAgentHost(ctx, "skill-a.example.com", "") if err != nil { t.Fatalf("empty: %v", err) } @@ -462,7 +462,7 @@ func TestAgentStore_ExistsActiveBaseOnlyByAgentHost(t *testing.T) { t.Fatalf("save pending: %v", err) } - exists, err = store.ExistsActiveBaseOnlyByAgentHost(ctx, "skill-a.example.com") + exists, err = store.ExistsActiveBaseOnlyByAgentHost(ctx, "skill-a.example.com", "") if err != nil { t.Fatalf("after pending: %v", err) } @@ -471,7 +471,7 @@ func TestAgentStore_ExistsActiveBaseOnlyByAgentHost(t *testing.T) { } // Different FQDN → still false. - exists, _ = store.ExistsActiveBaseOnlyByAgentHost(ctx, "skill-b.example.com") + exists, _ = store.ExistsActiveBaseOnlyByAgentHost(ctx, "skill-b.example.com", "") if exists { t.Error("unrelated FQDN should not match") } @@ -482,7 +482,7 @@ func TestAgentStore_ExistsActiveBaseOnlyByAgentHost(t *testing.T) { if err := store.Save(ctx, versioned); err != nil { t.Fatalf("save versioned: %v", err) } - exists, _ = store.ExistsActiveBaseOnlyByAgentHost(ctx, "skill-c.example.com") + exists, _ = store.ExistsActiveBaseOnlyByAgentHost(ctx, "skill-c.example.com", "") if exists { t.Error("versioned row must not count as a base-only claim") } @@ -492,12 +492,86 @@ func TestAgentStore_ExistsActiveBaseOnlyByAgentHost(t *testing.T) { if err := store.Save(ctx, pending); err != nil { t.Fatalf("revoke: %v", err) } - exists, _ = store.ExistsActiveBaseOnlyByAgentHost(ctx, "skill-a.example.com") + exists, _ = store.ExistsActiveBaseOnlyByAgentHost(ctx, "skill-a.example.com", "") if exists { t.Error("revoked base-only row should release the FQDN") } } +// TestAgentStore_ExistsActiveBaseOnly_AnchorScoped pins the Plan G +// cross-anchor coexistence rule: the same operational FQDN MAY carry +// multiple base-only registrations under different anchor profiles +// (FQDN, DID, LEI), and the uniqueness check fires only within the +// same anchor profile. Discovered as a live-test failure in the +// multi-anchor scenario; fixed by widening the predicate from +// (host) alone to (host, anchorType). +func TestAgentStore_ExistsActiveBaseOnly_AnchorScoped(t *testing.T) { + db := newTestDB(t) + store := NewAgentStore(db) + ctx := context.Background() + host := "multi.example.com" + + // Plant a base-only registration anchored to FQDN. + fqdnReg := newBaseOnlyFixture(t, "agent-fqdn", host) + fqdnReg.AnchorClaim = &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeFQDN, + ResolvedID: host, + } + if err := store.Save(ctx, fqdnReg); err != nil { + t.Fatalf("save fqdn: %v", err) + } + + // Same host, fqdn anchor → conflict. + exists, err := store.ExistsActiveBaseOnlyByAgentHost(ctx, host, "fqdn") + if err != nil { + t.Fatalf("err: %v", err) + } + if !exists { + t.Error("same (host, fqdn) should conflict") + } + + // Same host, did anchor → no conflict (different uniqueness slot). + exists, _ = store.ExistsActiveBaseOnlyByAgentHost(ctx, host, "did") + if exists { + t.Error("(host, did) should not conflict with an existing (host, fqdn) row") + } + + // Same host, lei anchor → no conflict. + exists, _ = store.ExistsActiveBaseOnlyByAgentHost(ctx, host, "lei") + if exists { + t.Error("(host, lei) should not conflict with an existing (host, fqdn) row") + } + + // Same host, empty anchor type (legacy implicit-FQDN slot) → no + // conflict either, because the FQDN row carries an explicit + // anchor_type and occupies a different slot. + exists, _ = store.ExistsActiveBaseOnlyByAgentHost(ctx, host, "") + if exists { + t.Error("(host, empty) should not conflict with an existing (host, fqdn) anchored row") + } + + // Plant a DID-anchored registration on the same host → succeeds. + didReg := newBaseOnlyFixture(t, "agent-did", host) + didReg.AnchorClaim = &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeDID, + ResolvedID: "did:web:" + host, + } + if err := store.Save(ctx, didReg); err != nil { + t.Fatalf("save did on same host: %v", err) + } + + // Now (host, did) conflicts. + exists, _ = store.ExistsActiveBaseOnlyByAgentHost(ctx, host, "did") + if !exists { + t.Error("after planting (host, did), same lookup should conflict") + } + // And (host, fqdn) still conflicts independently. + exists, _ = store.ExistsActiveBaseOnlyByAgentHost(ctx, host, "fqdn") + if !exists { + t.Error("(host, fqdn) should still conflict, independent of did row") + } +} + func TestAgentStore_AnchorClaim_RoundTrip(t *testing.T) { db := newTestDB(t) store := NewAgentStore(db) diff --git a/internal/port/store.go b/internal/port/store.go index 3898864..b381621 100644 --- a/internal/port/store.go +++ b/internal/port/store.go @@ -48,10 +48,17 @@ type AgentStore interface { ExistsByAnsName(ctx context.Context, ansName domain.AnsName) (bool, error) // ExistsActiveBaseOnlyByAgentHost returns true if a base-only - // registration (no ANSName) is currently active or pending for - // the given FQDN. Versioned registrations on the same FQDN do NOT - // trigger this check. - ExistsActiveBaseOnlyByAgentHost(ctx context.Context, host string) (bool, error) + // registration (no ANSName) under the given anchor type is + // currently active or pending for the given FQDN. Versioned + // registrations on the same FQDN do NOT trigger this check. + // + // Plan G: the uniqueness scope is (host, anchorType), not host + // alone. The same operational FQDN MAY carry multiple base-only + // registrations under different anchor profiles (FQDN, DID, LEI) + // per ANS-0 §7 cross-anchor equivalence. Within one anchor + // profile, only one live base-only row per FQDN is admitted. + // Pass empty anchorType for the legacy "anchor unspecified" path. + ExistsActiveBaseOnlyByAgentHost(ctx context.Context, host string, anchorType string) (bool, error) // FindAllByAgentHost returns every registration (any version, any status) // for the given FQDN, newest first. diff --git a/internal/ra/middleware/ownership_test.go b/internal/ra/middleware/ownership_test.go index 67c6275..f584e7e 100644 --- a/internal/ra/middleware/ownership_test.go +++ b/internal/ra/middleware/ownership_test.go @@ -270,7 +270,7 @@ func (f *fakeAgentStore) FindByAnsName(_ context.Context, _ domain.AnsName) (*do func (f *fakeAgentStore) ExistsByAnsName(_ context.Context, _ domain.AnsName) (bool, error) { return false, nil } -func (f *fakeAgentStore) ExistsActiveBaseOnlyByAgentHost(_ context.Context, _ string) (bool, error) { +func (f *fakeAgentStore) ExistsActiveBaseOnlyByAgentHost(_ context.Context, _ string, _ string) (bool, error) { return false, nil } func (f *fakeAgentStore) FindAllByAgentHost(_ context.Context, _ string) ([]*domain.AgentRegistration, error) { diff --git a/internal/ra/service/helpers.go b/internal/ra/service/helpers.go index 1c2c9cd..1982ff3 100644 --- a/internal/ra/service/helpers.go +++ b/internal/ra/service/helpers.go @@ -18,22 +18,14 @@ import ( // checkRegistrationUniqueness gates RegisterAgent on the appropriate // uniqueness scope. Versioned registrations conflict by ANSName; -// base-only registrations (§3.2.0) conflict by agent FQDN since they -// have no ANSName. Splitting the two paths into a helper keeps the -// nested-if depth out of RegisterAgent. +// base-only registrations (§3.2.0) conflict by (agent FQDN, anchor +// type) since they have no ANSName. The (host, anchorType) scope +// admits ANS-0 §7 cross-anchor coexistence: the same operational +// FQDN MAY carry multiple base-only registrations under different +// anchor profiles (FQDN, DID, LEI), one per profile. func (s *RegistrationService) checkRegistrationUniqueness(ctx context.Context, req RegisterRequest, fqdn string) error { if req.AnsName.IsZero() { - baseExists, err := s.agents.ExistsActiveBaseOnlyByAgentHost(ctx, fqdn) - if err != nil { - return err - } - if baseExists { - return domain.NewConflictError( - "BASE_ONLY_FQDN_TAKEN", - fmt.Sprintf("a base-only registration for %q is already active or pending", fqdn), - ) - } - return nil + return s.checkBaseOnlyUniqueness(ctx, req, fqdn) } exists, err := s.agents.ExistsByAnsName(ctx, req.AnsName) if err != nil { @@ -48,6 +40,32 @@ func (s *RegistrationService) checkRegistrationUniqueness(ctx context.Context, r return nil } +// checkBaseOnlyUniqueness handles the (host, anchorType)-scoped +// rule for base-only registrations. Split out of +// checkRegistrationUniqueness to keep the latter's nested-if depth +// low. +func (s *RegistrationService) checkBaseOnlyUniqueness(ctx context.Context, req RegisterRequest, fqdn string) error { + anchorType := "" + if req.AnchorClaim != nil { + anchorType = string(req.AnchorClaim.AnchorType) + } + baseExists, err := s.agents.ExistsActiveBaseOnlyByAgentHost(ctx, fqdn, anchorType) + if err != nil { + return err + } + if !baseExists { + return nil + } + scope := "implicit FQDN" + if anchorType != "" { + scope = "anchor=" + anchorType + } + return domain.NewConflictError( + "BASE_ONLY_FQDN_TAKEN", + fmt.Sprintf("a base-only registration for %q (%s) is already active or pending", fqdn, scope), + ) +} + // buildOptionalIdentityCSR returns a fresh *AgentCSR when the // caller supplied a non-empty PEM, or nil when they didn't. Base-only // registrations (§3.2.0) submit no Identity CSR; the domain layer