From 40b07e05c2060e18a86bb5d44c410bd70f9741bf Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 15:42:21 -0400 Subject: [PATCH 1/3] test(anchor): did:key resolver + real-world LEI conformance vectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two test improvements per external (Grok) testing guidance: 1. did:key resolver (the second 0.B sub-profile, fully offline): - Multibase prefix validation (base58btc 'z' only). - Pure-Go base58btc decoder using math/big; preserves leading zero-byte semantics (each '1' character at the start of the suffix becomes a leading 0x00 byte). - Multicodec varint parser (LEB128) per the multiformats spec. - JWK conversion for Ed25519 (multicodec 0xED) and secp256k1 (multicodec 0xE7), with secp256k1 point decompression via Tonelli-Shanks (p ≡ 3 mod 4 short path) so the JWK carries both x and y coordinates as required for kty=EC. - X25519 (0xEC) and P-256 (0x1200) explicitly stub with DID_KEY_TYPE_NOT_IMPLEMENTED so future profile work has a clear migration boundary. - Tests cover: 2 spec-cited W3C did:key Ed25519 vectors with verified x values, all five DID_BAD_FORMAT / DID_KEY_* reject paths, multicodec varint single + two-byte cases, truncation rejection, leading-zero base58btc round-trip, unsupported-key-type errors, length validation, secp256k1 happy path through the curve generator G. This is the first ANS-0 profile that is fully offline: a CI run produces identical results without external infrastructure, and external implementations can validate their decoder against our vectors without a network call. 2. Real-world LEI conformance vectors (anchor-0c-lei): - docs/tests/conformance/anchor-0c-lei/lei-public-examples.json carries 4 LEIs verified against ISO 17442 mod-97: the GLEIF documentation example plus three real public LEIs from the GLEIF Global LEI Index (Apple, Microsoft, ECB) chosen to exercise alphabetic-prefix LOUs and varied body shapes. - Lowercase + whitespace canonicalization vectors confirm the resolver's input normalization. - 8 reject vectors covering empty, too-short, too-long, hyphen, embedded-space, special-character, and two perturbed-check- digit cases. - lei/conformance_test.go consumes the JSON fixture and runs three table-driven test functions. The fixture file is language-agnostic; an external Rust or Python implementation can consume the same JSON to validate its conformance against the same data the Go implementation runs against. Coverage at 90.2% (held). All RA tests still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../anchor-0c-lei/lei-public-examples.json | 88 +++++ internal/adapter/anchor/did/key.go | 335 ++++++++++++++++++ internal/adapter/anchor/did/key_test.go | 318 +++++++++++++++++ .../adapter/anchor/lei/conformance_test.go | 120 +++++++ 4 files changed, 861 insertions(+) create mode 100644 docs/tests/conformance/anchor-0c-lei/lei-public-examples.json create mode 100644 internal/adapter/anchor/did/key.go create mode 100644 internal/adapter/anchor/did/key_test.go create mode 100644 internal/adapter/anchor/lei/conformance_test.go 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/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) + } + }) + } +} From 1fc544377e69663f2dbebe7a4521e8a024911e63 Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 15:52:28 -0400 Subject: [PATCH 2/3] fix(uniqueness): scope base-only conflict by (host, anchor_type) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan G test improvement 3 (multi-anchor live scenario) caught a real bug: registering the same operational FQDN under three different anchor profiles (FQDN, DID, LEI) failed at the second registration with BASE_ONLY_FQDN_TAKEN. The pre-fix uniqueness check fired on agent_host alone, contradicting the proposal's ANS-0 §7 cross-anchor coexistence rule. Fix: widen the predicate from agent_host alone to (agent_host, anchor_type). Each (host, anchor_type) tuple gets its own uniqueness slot. The legacy "anchor unspecified" path maps to empty anchor_type and occupies its own implicit slot, distinct from any explicit anchor profile. Storage: - ExistsActiveBaseOnlyByAgentHost gains an anchorType parameter. - Empty anchorType matches rows with anchor_type IS NULL or empty (legacy implicit-FQDN slot). - Non-empty anchorType matches rows where anchor_type equals the argument exactly. Service: - checkRegistrationUniqueness reads req.AnchorClaim.AnchorType to scope the query. checkBaseOnlyUniqueness was extracted as a separate helper to keep the nestif-friendly shape. - The conflict error now identifies the scope ("anchor=did") so an operator hitting the conflict knows which uniqueness slot collided. Tests: - TestAgentStore_ExistsActiveBaseOnly_AnchorScoped pins the new rule: same host different anchor types coexist; same (host, anchor) conflicts; legacy implicit-FQDN slot is independent of any explicit anchor slot. - The middleware test fake updated to match the new signature. Live verification: /tmp/plan-g-multi-anchor.sh registers FQDN + DID + LEI for multi.plang.example.com against the demo RA, lists back three rows sharing the same agentHost with three distinct anchorResolvedId values. Cross-anchor consistency check confirms the (3 anchor types, 1 host, 3 resolved IDs) shape. This is also a cleaner expression of ANS's operational semantics: the agent's operational endpoint (FQDN where it terminates TLS) can carry multiple identity claims simultaneously, and the registration store records each claim independently. The EquivalenceLink event from the proposal §7 will eventually link these registrations explicitly; until it lands, a verifier constructs the equivalence graph by scoping the V2 list to a single agentHost. Coverage holds at 90.1%. All 222 internal tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/adapter/store/sqlite/agent.go | 41 +++++++--- internal/adapter/store/sqlite/agent_test.go | 84 +++++++++++++++++++-- internal/port/store.go | 15 +++- internal/ra/middleware/ownership_test.go | 2 +- internal/ra/service/helpers.go | 46 +++++++---- 5 files changed, 155 insertions(+), 33 deletions(-) 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 From 574d9f06f78f702298802c9df0ed82835389530c Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 15:55:00 -0400 Subject: [PATCH 3/3] feat(anchor/did): did:pkh resolver skeleton (CAIP-10 + ERC-8004 path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan G test improvement 4: did:pkh anchor profile per the W3C CCG did:pkh method spec and CAIP-10 account identifiers. The path toward ERC-8004 on-chain agent identity per anchor-0b-did.md §6. The resolver's offline layer is fully implemented; the on-chain lookup is delegated to a ChainResolver interface mirroring the LEI resolver's GLEIFClient pattern. The split keeps the package useful for testbeds (CAIP-10 validation works without an Ethereum RPC endpoint) and switches to live resolution by configuration when an ERC-8004 testnet wiring lands in CI. Lexical layer (slice-internal): - did:pkh:::
parsing into a typed CAIPAccount (Namespace, Reference, Address). - Namespace is lowercased per CAIP-2. - Per-namespace address validation. eip155 enforces the "0x" + 40 hex characters shape (case-insensitive); other namespaces pass through to the chain resolver implementation which owns those rules. - canonicalizeDIDPkh emits the canonical lowercase URI for the IdentityClaim's ResolvedID. Chain layer (interface only): - ChainResolver.LookupKey(ctx, CAIPAccount) -> JWK bytes. A production implementation will wrap an Ethereum JSON-RPC client reading the ERC-8004 IdentityRegistry contract; that lands once testnet plumbing is in place. - Without a ChainResolver injected, Resolve returns DID_PKH_CHAIN_NOT_CONFIGURED after lexical validation passes. The error code is the migration boundary an operator hits if they configure an RA to accept did:pkh without wiring a chain client. Tests cover: - CAIP-10 parsing across Sepolia, Ethereum mainnet, Anvil chain 31337, uppercase namespace. - DID_PKH_BAD_FORMAT for wrong prefix, missing prefix, too few parts, empty namespace/reference/address. - validateEIP155Address against good (canonical, lowercase, uppercase, 0X prefix) and bad (missing prefix, wrong length, non-hex) inputs. - DID_PKH_CHAIN_NOT_CONFIGURED when no ChainResolver injected. - Bad-address propagates DID_PKH_BAD_ADDRESS even when a ChainResolver is present (lexical layer fires first). - Non-eip155 namespace (Solana-style) passes lexical validation through to the chain layer (the namespace is unknown to this resolver but the parsed shape is admissible). - Happy path with an injected fake ChainResolver: claim shape, IssuedAt/ExpiresAt budget (1h), JWK pass-through. - Chain-lookup error propagates as DID_PKH_CHAIN_LOOKUP_FAILED. - Nil JWK from chain resolver propagates as DID_PKH_NO_KEY. - SupportedProfiles returns ["0.B-did:pkh"]. - CAIPAccount.String round-trip for the canonical wire form. The Anvil pre-funded address (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266) is used as the canonical test address throughout, matching the foundry/hardhat documentation conventions called out in Grok's testing guidance. Coverage holds at 90.2%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/adapter/anchor/did/pkh.go | 259 +++++++++++++++++++++++ internal/adapter/anchor/did/pkh_test.go | 270 ++++++++++++++++++++++++ 2 files changed, 529 insertions(+) create mode 100644 internal/adapter/anchor/did/pkh.go create mode 100644 internal/adapter/anchor/did/pkh_test.go 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) + } +}