diff --git a/controller/did-plc/audit.go b/controller/did-plc/audit.go new file mode 100644 index 0000000..1b3d3c9 --- /dev/null +++ b/controller/did-plc/audit.go @@ -0,0 +1,187 @@ +package did_plc + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/MetaMask/go-did-it/crypto" +) + +// AuditEntry is one record from the did:plc audit log +// (GET https://plc.directory/:did/log/audit). +type AuditEntry struct { + DID string + CID string + CreatedAt time.Time + // Nullified is true when this operation was invalidated by a recovery + // operation from a higher-authority rotation key within the 72-hour window. + Nullified bool + // Op is the decoded document content of the operation, or nil for tombstones. + Op *Op + prepared *auditPrepared +} + +// auditPrepared holds the precomputed CBOR bytes and metadata needed to +// validate a single audit log entry. Built once in fetchAuditLog and consumed +// by validateChain without any further JSON parsing. +type auditPrepared struct { + signed []byte + unsigned []byte + prevCID *string + signature string + rotKeys []string // did:key strings; nil for plc_tombstone +} + +// parseAuditEntry parses a raw audit log JSON entry into the public Op and the +// internal auditPrepared. Returns (nil, prepared, nil) for tombstone operations. +// For plc_operation (the common case) only a single JSON unmarshal is performed. +func parseAuditEntry(data json.RawMessage) (*Op, *auditPrepared, error) { + // Unmarshal into opJSON first: it carries the Type field and all plc_operation + // fields, so the common case needs no second parse. + var fields opJSON + if err := json.Unmarshal(data, &fields); err != nil { + return nil, nil, err + } + switch fields.Type { + case "plc_operation": + op, err := opFromJSON(fields) + if err != nil { + return nil, nil, fmt.Errorf("plc_operation keys: %w", err) + } + prep, err := buildPreparedOp(fields, data) + if err != nil { + return nil, nil, fmt.Errorf("plc_operation CBOR: %w", err) + } + return op, &auditPrepared{ + signed: prep.signed, + unsigned: prep.unsigned, + prevCID: prep.prevCID, + signature: prep.signature, + rotKeys: prep.rotKeys, + }, nil + + case "plc_tombstone": + ts, err := parseSignedTombstone(data) + if err != nil { + return nil, nil, err + } + return nil, &auditPrepared{ + signed: ts.signed, + unsigned: ts.unsigned, + prevCID: &ts.prevCID, + signature: ts.signature, + // rotKeys is nil: tombstone carries no keys of its own + }, nil + + case "create": + var leg legacyCreateOp + if err := json.Unmarshal(data, &leg); err != nil { + return nil, nil, fmt.Errorf("legacy create: %w", err) + } + op, err := leg.toUnsignedOp() + if err != nil { + return nil, nil, fmt.Errorf("legacy create keys: %w", err) + } + signed, unsigned, err := leg.encode() + if err != nil { + return nil, nil, fmt.Errorf("legacy create CBOR: %w", err) + } + return op, &auditPrepared{ + signed: signed, + unsigned: unsigned, + prevCID: leg.Prev, + signature: leg.Sig, + rotKeys: []string{leg.RecoveryKey, leg.SigningKey}, + }, nil + + default: + return nil, nil, fmt.Errorf("unknown operation type %q", fields.Type) + } +} + +// validateChain verifies a sequence of audit log entries for a single DID. +func (r *Registry) validateChain(entries []AuditEntry) error { + var prevCID *string + var prevRotKeys []string + for i, entry := range entries { + if entry.Nullified { + continue + } + p := entry.prepared + // Tombstone (rotKeys == nil): signature must verify against the previous op's keys. + verifyKeys := p.rotKeys + if verifyKeys == nil { + verifyKeys = prevRotKeys + } + if err := r.verifyEntry(entry.CID, prevCID, p.signed, p.unsigned, p.prevCID, p.signature, verifyKeys); err != nil { + return fmt.Errorf("entry %d (CID %s): %w", i, entry.CID, err) + } + cid := entry.CID + prevCID = &cid + if p.rotKeys != nil { + prevRotKeys = p.rotKeys + } + } + return nil +} + +// verifyEntry runs the three invariants common to all operation types: +// CID integrity, prev-chain continuity, and signature validity. +func (r *Registry) verifyEntry( + entryCID string, + expectedPrev *string, + signed, unsigned []byte, + prevCID *string, + signature string, + rotKeys []string, +) error { + // 1. CID + computed, err := computeCID(signed) + if err != nil { + return fmt.Errorf("computing CID: %w", err) + } + if computed != entryCID { + return fmt.Errorf("CID mismatch: entry reports %s, computed %s", entryCID, computed) + } + + // 2. Prev chain + switch { + case expectedPrev == nil && prevCID == nil: // genesis ✓ + case expectedPrev == nil && prevCID != nil: + return fmt.Errorf("expected genesis (prev=nil), got prev=%s", *prevCID) + case expectedPrev != nil && prevCID == nil: + return fmt.Errorf("expected prev=%s, got nil (genesis)", *expectedPrev) + case *expectedPrev != *prevCID: + return fmt.Errorf("prev mismatch: expected %s, got %s", *expectedPrev, *prevCID) + } + + // 3. Signature + rawSig, err := base64.RawURLEncoding.DecodeString(signature) + if err != nil { + return fmt.Errorf("decoding signature: %w", err) + } + if len(rawSig) != 64 { + return fmt.Errorf("signature must be 64 bytes, got %d", len(rawSig)) + } + const prefix = "did:key:" + for _, didKey := range rotKeys { + if !strings.HasPrefix(didKey, prefix) { + continue + } + pub, err := r.rotationKeySet.PublicKeyFromMultibase(didKey[len(prefix):]) + if err != nil { + continue + } + v, ok := pub.(crypto.PublicKeySigningBytes) + if !ok { + continue + } + if v.VerifyBytes(unsigned, rawSig, crypto.WithEcdsaLowSSig()) { + return nil + } + } + return fmt.Errorf("signature does not match any rotation key") +} diff --git a/controller/did-plc/cid.go b/controller/did-plc/cid.go new file mode 100644 index 0000000..05ebe90 --- /dev/null +++ b/controller/did-plc/cid.go @@ -0,0 +1,51 @@ +package did_plc + +import ( + "crypto/sha256" + "encoding/base32" + "fmt" + + gocid "github.com/ipfs/go-cid" + mc "github.com/multiformats/go-multicodec" + mh "github.com/multiformats/go-multihash" +) + +// base32Enc is the lowercase base32 alphabet used by multiformats (RFC 4648, no padding). +// Used for the DID MSI (method-specific identifier) derivation, which hashes directly without a CID wrapper. +var base32Enc = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding) + +// computeCID computes the CIDv1 (dag-cbor, sha2-256) of data and returns it +// as a multibase base32 string (prefix 'b'). +func computeCID(data []byte) (string, error) { + digest, err := mh.Sum(data, mh.SHA2_256, -1) + if err != nil { + return "", fmt.Errorf("computing multihash: %w", err) + } + return gocid.NewCidV1(uint64(mc.DagCbor), digest).String(), nil +} + +// deriveMSI derives the 24-character method-specific identifier for a did:plc DID +// from the signed genesis operation's DAG-CBOR bytes. +// +// The MSI is the first 24 characters of the lowercase base32 encoding of the +// SHA-256 digest of the genesis operation. +func deriveMSI(genesisBytes []byte) string { + h := sha256.Sum256(genesisBytes) + return base32Enc.EncodeToString(h[:])[:24] +} + +// validateCID checks that cidStr is a CIDv1 with the dag-cbor codec. +func validateCID(cidStr string) error { + c, err := gocid.Decode(cidStr) + if err != nil { + return fmt.Errorf("invalid CID %q: %w", cidStr, err) + } + if c.Version() != 1 { + return fmt.Errorf("CID %q: expected CIDv1, got v%d", cidStr, c.Version()) + } + if c.Type() != uint64(mc.DagCbor) { + return fmt.Errorf("CID %q: expected dag-cbor codec (0x%x), got 0x%x", + cidStr, mc.DagCbor, c.Type()) + } + return nil +} diff --git a/controller/did-plc/controller.go b/controller/did-plc/controller.go new file mode 100644 index 0000000..a17b0f4 --- /dev/null +++ b/controller/did-plc/controller.go @@ -0,0 +1,115 @@ +package did_plc + +import ( + "context" + "fmt" +) + +// Controller is a handle for a specific DID within a Registry. +type Controller struct { + registry *Registry + didStr string +} + +// DidStr returns the did:plc string this controller manages. +func (c *Controller) DidStr() string { return c.didStr } + +// Update fetches the current document state, passes it to fn, and submits the result. +func (c *Controller) Update(ctx context.Context, signer Signer, fn func(Op) (Op, error)) error { + headCID, current, err := c.fetchHead(ctx) + if err != nil { + return err + } + next, err := fn(current) + if err != nil { + return err + } + signed, err := next.sign(c.registry, signer, &headCID) + if err != nil { + return err + } + return c.registry.submit(ctx, c.didStr, signed) +} + +// Tombstone permanently deactivates the DID. +func (c *Controller) Tombstone(ctx context.Context, signer Signer) error { + headCID, _, err := c.fetchHead(ctx) + if err != nil { + return err + } + signed, err := signTombstone(signer, headCID) + if err != nil { + return err + } + return c.registry.submit(ctx, c.didStr, signed) +} + +// Recover forks the operation chain back to forkCID. It fetches the document +// state at that point, passes it to fn, and submits the result. +func (c *Controller) Recover(ctx context.Context, signer Signer, forkCID string, fn func(Op) (Op, error)) error { + forkOp, err := c.fetchOpAtCID(ctx, forkCID) + if err != nil { + return err + } + next, err := fn(forkOp) + if err != nil { + return err + } + signed, err := next.sign(c.registry, signer, &forkCID) + if err != nil { + return err + } + return c.registry.submit(ctx, c.didStr, signed) +} + +// Audit fetches and validates the full operation history of the DID. +func (c *Controller) Audit(ctx context.Context) ([]AuditEntry, error) { + entries, err := c.registry.fetchAuditLog(ctx, c.didStr) + if err != nil { + return nil, err + } + if err := c.registry.validateChain(entries); err != nil { + return nil, fmt.Errorf("chain validation failed: %w", err) + } + return entries, nil +} + +// fetchHead returns the CID and document state of the latest non-nullified operation. +func (c *Controller) fetchHead(ctx context.Context) (string, Op, error) { + entries, err := c.registry.fetchAuditLog(ctx, c.didStr) + if err != nil { + return "", Op{}, fmt.Errorf("fetching audit log: %w", err) + } + var headCID string + var headOp *Op + for i := range entries { + if !entries[i].Nullified { + headCID = entries[i].CID + headOp = entries[i].Op + } + } + if headCID == "" { + return "", Op{}, fmt.Errorf("no valid operations found for %s", c.didStr) + } + if headOp == nil { + return "", Op{}, fmt.Errorf("DID %s is tombstoned", c.didStr) + } + return headCID, *headOp, nil +} + +// fetchOpAtCID finds and returns the document state at a specific CID in the audit log. +func (c *Controller) fetchOpAtCID(ctx context.Context, cid string) (Op, error) { + entries, err := c.registry.fetchAuditLog(ctx, c.didStr) + if err != nil { + return Op{}, fmt.Errorf("fetching audit log: %w", err) + } + for _, e := range entries { + if e.CID == cid { + if e.Op == nil { + return Op{}, nil + } + return *e.Op, nil + } + } + return Op{}, fmt.Errorf("operation with CID %s not found for %s", cid, c.didStr) +} diff --git a/controller/did-plc/example_test.go b/controller/did-plc/example_test.go new file mode 100644 index 0000000..8df5488 --- /dev/null +++ b/controller/did-plc/example_test.go @@ -0,0 +1,74 @@ +package did_plc_test + +import ( + "context" + "fmt" + "log" + + did_plc "github.com/MetaMask/go-did-it/controller/did-plc" + "github.com/MetaMask/go-did-it/crypto" + "github.com/MetaMask/go-did-it/crypto/secp256k1" +) + +// Example demonstrates the full did:plc workflow: create, update, and audit a DID. +// +// In production use did_plc.NewRegistry() (no options) to target https://plc.directory. +// The example calls a live registry; run it with a real key and network access. +func Example() { + ctx := context.Background() + + // Generate a secp256k1 rotation key. P-256 is also accepted by default. + pub, priv, err := secp256k1.GenerateKeyPair() + if err != nil { + log.Fatal(err) + } + + reg := did_plc.NewRegistry() + + // Create a new DID. RotationKeys control who may sign future updates. + ctrl, err := reg.Create(ctx, priv, did_plc.Op{ + RotationKeys: []crypto.PublicKey{pub}, + VerificationMethods: map[string]crypto.PublicKey{ + "atproto": pub, + }, + AlsoKnownAs: []string{"at://alice.example.com"}, + Services: map[string]did_plc.Service{ + "atproto_pds": { + Type: "AtprotoPersonalDataServer", + Endpoint: "https://pds.example.com", + }, + }, + }) + if err != nil { + log.Fatal(err) + } + fmt.Println("created:", ctrl.DidStr()) + + // Update uses a read-modify-write callback: the current document state is + // passed in, the caller mutates it, and the result is signed and submitted. + err = ctrl.Update(ctx, priv, func(op did_plc.Op) (did_plc.Op, error) { + op.AlsoKnownAs = append(op.AlsoKnownAs, "at://alice.new.example.com") + return op, nil + }) + if err != nil { + log.Fatal(err) + } + + // Obtain a Controller for an existing DID (e.g. loaded from a database). + ctrl2 := reg.Controller(ctrl.DidStr()) + fmt.Println("same DID:", ctrl2.DidStr() == ctrl.DidStr()) + + // Audit fetches the full operation history and validates CID integrity, + // low-S signatures, and prev-pointer continuity. + entries, err := ctrl.Audit(ctx) + if err != nil { + log.Fatal(err) + } + for _, e := range entries { + if e.Op == nil { + fmt.Println("tombstone at", e.CID) + } else { + fmt.Println("op at", e.CID, "handles:", e.Op.AlsoKnownAs) + } + } +} diff --git a/controller/did-plc/internal/dagcbor/dagcbor.go b/controller/did-plc/internal/dagcbor/dagcbor.go new file mode 100644 index 0000000..c3aa6ad --- /dev/null +++ b/controller/did-plc/internal/dagcbor/dagcbor.go @@ -0,0 +1,142 @@ +// Package dagcbor provides a minimal DAG-CBOR encoder for did:plc operations. +// +// DAG-CBOR (https://ipld.io/specs/codecs/dag-cbor/spec/) is a deterministic +// subset of CBOR (RFC 8949) used by the did:plc protocol to encode operations +// before signing and hashing. +// +// This encoder supports only the value types present in did:plc operations: +// null, strings, string arrays, string maps, nested any-value maps, and CID links. +// Map keys are sorted by the canonical DAG-CBOR ordering (shorter CBOR-encoded +// key first, ties broken lexicographically). +package dagcbor + +import ( + "fmt" + "sort" + + cid "github.com/ipfs/go-cid" +) + +// Encode serializes v to DAG-CBOR bytes. +// +// Supported Go types: +// +// - nil → CBOR null (0xf6) +// - string → CBOR text string +// - []string → CBOR array of text strings +// - map[string]string → CBOR map with canonical key ordering +// - map[string]any → CBOR map; values may be any supported type +// - cid.Cid → CBOR tag 42 byte-string (CID link) +func Encode(v any) ([]byte, error) { + return appendValue(make([]byte, 0, 256), v) +} + +func appendValue(buf []byte, v any) ([]byte, error) { + switch v := v.(type) { + case nil: + return append(buf, 0xf6), nil + + case string: + return appendString(buf, v), nil + + case []string: + buf = appendHead(buf, majorArray, uint64(len(v))) + for _, s := range v { + buf = appendString(buf, s) + } + return buf, nil + + case map[string]string: + keys := sortedKeys(v) + buf = appendHead(buf, majorMap, uint64(len(keys))) + for _, k := range keys { + buf = appendString(buf, k) + buf = appendString(buf, v[k]) + } + return buf, nil + + case map[string]any: + keys := sortedKeys(v) + buf = appendHead(buf, majorMap, uint64(len(keys))) + var err error + for _, k := range keys { + buf = appendString(buf, k) + if buf, err = appendValue(buf, v[k]); err != nil { + return nil, err + } + } + return buf, nil + + case cid.Cid: + // CBOR tag 42, followed by a byte-string prefixed with 0x00 (the "identity" multibase byte + // required by the DAG-CBOR spec to distinguish CID links from raw bytes). + buf = append(buf, 0xd8, 0x2a) // tag(42) + raw := v.Bytes() + cidWithPrefix := make([]byte, 1+len(raw)) + cidWithPrefix[0] = 0x00 + copy(cidWithPrefix[1:], raw) + return appendBytes(buf, cidWithPrefix), nil + + default: + return nil, fmt.Errorf("dagcbor: unsupported type %T", v) + } +} + +const ( + majorBytes byte = 2 + majorString byte = 3 + majorArray byte = 4 + majorMap byte = 5 +) + +// appendHead encodes a CBOR head: the major type (upper 3 bits) combined with +// the additional info / argument (lower 5 bits or following bytes). +func appendHead(buf []byte, major byte, n uint64) []byte { + major <<= 5 + switch { + case n <= 23: + return append(buf, major|byte(n)) + case n <= 0xff: + return append(buf, major|24, byte(n)) + case n <= 0xffff: + return append(buf, major|25, byte(n>>8), byte(n)) + case n <= 0xffffffff: + return append(buf, major|26, byte(n>>24), byte(n>>16), byte(n>>8), byte(n)) + default: + return append(buf, major|27, + byte(n>>56), byte(n>>48), byte(n>>40), byte(n>>32), + byte(n>>24), byte(n>>16), byte(n>>8), byte(n)) + } +} + +func appendString(buf []byte, s string) []byte { + buf = appendHead(buf, majorString, uint64(len(s))) + return append(buf, s...) +} + +func appendBytes(buf []byte, b []byte) []byte { + buf = appendHead(buf, majorBytes, uint64(len(b))) + return append(buf, b...) +} + +// keyLess implements the DAG-CBOR canonical key ordering (RFC 7049 §3.9): +// sort by the byte length of the CBOR-encoded key first, then lexicographically. +// +// For text-string keys shorter than 24 bytes (which covers all did:plc operation keys), +// the CBOR encoding is a 1-byte head followed by the string bytes, so the encoded length +// equals 1 + len(key). Equal-length keys are resolved lexicographically. +func keyLess(a, b string) bool { + if len(a) != len(b) { + return len(a) < len(b) + } + return a < b +} + +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return keyLess(keys[i], keys[j]) }) + return keys +} diff --git a/controller/did-plc/internal/dagcbor/dagcbor_test.go b/controller/did-plc/internal/dagcbor/dagcbor_test.go new file mode 100644 index 0000000..bdaf440 --- /dev/null +++ b/controller/did-plc/internal/dagcbor/dagcbor_test.go @@ -0,0 +1,216 @@ +package dagcbor_test + +import ( + "encoding/hex" + "testing" + + cid "github.com/ipfs/go-cid" + mh "github.com/multiformats/go-multihash" + "github.com/stretchr/testify/require" + + "github.com/MetaMask/go-did-it/controller/did-plc/internal/dagcbor" +) + +func mustHex(t *testing.T, s string) []byte { + t.Helper() + // strip spaces so hex literals can be written with spacing for readability + clean := "" + for _, c := range s { + if c != ' ' { + clean += string(c) + } + } + b, err := hex.DecodeString(clean) + require.NoError(t, err) + return b +} + +func encode(t *testing.T, v any) []byte { + t.Helper() + b, err := dagcbor.Encode(v) + require.NoError(t, err) + return b +} + +func TestNull(t *testing.T) { + require.Equal(t, mustHex(t, "f6"), encode(t, nil)) +} + +func TestString(t *testing.T) { + // Empty string: major(3)<<5 | length 0 = 0x60 + require.Equal(t, mustHex(t, "60"), encode(t, "")) + + // "hello" (5 bytes): 0x65 + UTF-8 + require.Equal(t, mustHex(t, "65 68656c6c6f"), encode(t, "hello")) + + // 23-byte string: single-byte head still fits (0x60|23 = 0x77) + s23 := "abcdefghijklmnopqrstuvw" + require.Len(t, s23, 23) + got := encode(t, s23) + require.Equal(t, byte(0x77), got[0]) + require.Equal(t, []byte(s23), got[1:]) + + // 24-byte string: two-byte head (major|24, length) + s24 := "abcdefghijklmnopqrstuvwx" + require.Len(t, s24, 24) + got = encode(t, s24) + require.Equal(t, []byte{0x60 | 24, 24}, got[:2]) + require.Equal(t, []byte(s24), got[2:]) +} + +func TestStringArray(t *testing.T) { + // Empty array: 0x80 + require.Equal(t, mustHex(t, "80"), encode(t, []string{})) + require.Equal(t, mustHex(t, "80"), encode(t, ([]string)(nil))) + + // ["a", "b"]: 0x82 + "a" + "b" + require.Equal(t, mustHex(t, "82 6161 6162"), encode(t, []string{"a", "b"})) +} + +func TestStringMapKeyOrdering(t *testing.T) { + // Keys sorted by encoded length first, then lexicographically: + // "a"(len 1) < "bb"(len 2) < "ccc"(len 3) + m := map[string]string{"ccc": "3", "a": "1", "bb": "2"} + got := encode(t, m) + expected := mustHex(t, + "a3"+ // map(3) + "6161"+"6131"+ // "a" → "1" + "626262"+"6132"+ // "bb" → "2" + "63636363"+"6133") // "ccc" → "3" + require.Equal(t, expected, got) +} + +func TestStringMapSameLengthLexOrder(t *testing.T) { + // "ab" and "ba" have equal length; "ab" < "ba" lexicographically + m := map[string]string{"ba": "2", "ab": "1"} + got := encode(t, m) + expected := mustHex(t, + "a2"+ // map(2) + "626162"+"6131"+ // "ab" → "1" + "626261"+"6132") // "ba" → "2" + require.Equal(t, expected, got) +} + +func TestNestedMap(t *testing.T) { + m := map[string]any{ + "x": map[string]any{"b": "2", "a": "1"}, + } + got := encode(t, m) + expected := mustHex(t, + "a1"+ // map(1) + "6178"+ // key "x" + "a2"+ // map(2) + "6161"+"6131"+ // "a"→"1" + "6162"+"6132") // "b"→"2" + require.Equal(t, expected, got) +} + +func TestCIDLink(t *testing.T) { + // Construct a CIDv1 dag-cbor sha2-256 from a known digest (32 zero bytes). + digest := make([]byte, 32) + multihash, err := mh.Encode(digest, mh.SHA2_256) + require.NoError(t, err) + c := cid.NewCidV1(cid.DagCBOR, multihash) + + got := encode(t, c) + + // Expected encoding: + // tag(42): d8 2a + // bytes(1+N): 58 (1+len(c.Bytes())) then content + // prefix byte: 00 + // CID bytes: c.Bytes() + raw := c.Bytes() + prefixed := append([]byte{0x00}, raw...) + want := []byte{0xd8, 0x2a} + want = append(want, 0x58, byte(len(prefixed))) + want = append(want, prefixed...) + require.Equal(t, want, got) +} + +func TestPlcOperationKeyOrder(t *testing.T) { + // Canonical order for the 6 unsigned plc_operation keys: + // "prev"(4) < "type"(4,lex) < "services"(8) < "alsoKnownAs"(11) < "rotationKeys"(12) < "verificationMethods"(19) + m := map[string]any{ + "type": "plc_operation", + "rotationKeys": []string{}, + "verificationMethods": map[string]string{}, + "alsoKnownAs": []string{}, + "services": map[string]any{}, + "prev": nil, + } + got := encode(t, m) + keys := extractMapKeys(t, got) + require.Equal(t, + []string{"prev", "type", "services", "alsoKnownAs", "rotationKeys", "verificationMethods"}, + keys) +} + +func TestUnsupportedType(t *testing.T) { + _, err := dagcbor.Encode(42) + require.Error(t, err) + require.Contains(t, err.Error(), "unsupported type") +} + +// extractMapKeys decodes text-string map keys from a CBOR-encoded top-level map. +func extractMapKeys(t *testing.T, b []byte) []string { + t.Helper() + require.NotEmpty(t, b) + require.Equal(t, byte(0xa0), b[0]&0xe0, "expected major type 5 (map)") + n := int(b[0] & 0x1f) + require.Less(t, n, 24, "multi-byte map lengths not handled in this helper") + + pos := 1 + keys := make([]string, 0, n) + for i := 0; i < n; i++ { + require.Equal(t, byte(0x60), b[pos]&0xe0, "expected text-string key") + klen := int(b[pos] & 0x1f) + pos++ + keys = append(keys, string(b[pos:pos+klen])) + pos += klen + pos = skipValue(t, b, pos) + } + return keys +} + +func skipValue(t *testing.T, b []byte, pos int) int { + t.Helper() + require.Less(t, pos, len(b)) + head := b[pos] + major := head >> 5 + info := head & 0x1f + pos++ + var length int + switch { + case info <= 23: + length = int(info) + case info == 24: + length = int(b[pos]) + pos++ + case info == 25: + length = int(b[pos])<<8 | int(b[pos+1]) + pos += 2 + default: + t.Fatalf("unexpected additional info %d", info) + } + switch major { + case 0: // uint + case 2: // bytes + pos += length + case 3: // string + pos += length + case 4: // array + for i := 0; i < length; i++ { + pos = skipValue(t, b, pos) + } + case 5: // map + for i := 0; i < length*2; i++ { + pos = skipValue(t, b, pos) + } + case 6: // tag — skip tag number (already consumed as length) then the tagged value + pos = skipValue(t, b, pos) + case 7: // simple / float; null = 0xf6, no following bytes + default: + t.Fatalf("unexpected major type %d", major) + } + return pos +} diff --git a/controller/did-plc/legacy.go b/controller/did-plc/legacy.go new file mode 100644 index 0000000..5f866c6 --- /dev/null +++ b/controller/did-plc/legacy.go @@ -0,0 +1,58 @@ +package did_plc + +import ( + "fmt" + + "github.com/MetaMask/go-did-it/controller/did-plc/internal/dagcbor" + "github.com/MetaMask/go-did-it/crypto" +) + +// legacyCreateOp represents a deprecated genesis operation. +// Specification: https://web.plc.directory/spec/v0.1/did-plc (§ Legacy operations) +// +// Field key ordering in DAG-CBOR (canonical by encoded length, then lex): +// "sig"(3) < "prev"(4) < "type"(4,lex) < "handle"(6) < "service"(7) < "signingKey"(10) < "recoveryKey"(11) +type legacyCreateOp struct { + Type string `json:"type"` + SigningKey string `json:"signingKey"` + RecoveryKey string `json:"recoveryKey"` + Handle string `json:"handle"` + Service string `json:"service"` + Prev *string `json:"prev"` + Sig string `json:"sig"` +} + +func (l *legacyCreateOp) encode() (signed, unsigned []byte, err error) { + m := map[string]any{ + "type": "create", + "signingKey": l.SigningKey, + "recoveryKey": l.RecoveryKey, + "handle": l.Handle, + "service": l.Service, + "prev": nil, + } + unsigned, err = dagcbor.Encode(m) + if err != nil { + return nil, nil, err + } + m["sig"] = l.Sig + signed, err = dagcbor.Encode(m) + return signed, unsigned, err +} + +func (l *legacyCreateOp) toUnsignedOp() (*Op, error) { + recoveryPub, err := didKeyToPublicKey(l.RecoveryKey) + if err != nil { + return nil, fmt.Errorf("recoveryKey: %w", err) + } + signingPub, err := didKeyToPublicKey(l.SigningKey) + if err != nil { + return nil, fmt.Errorf("signingKey: %w", err) + } + return &Op{ + RotationKeys: []crypto.PublicKey{recoveryPub}, + VerificationMethods: map[string]crypto.PublicKey{"atproto": signingPub}, + AlsoKnownAs: []string{"at://" + l.Handle}, + Services: map[string]Service{"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: l.Service}}, + }, nil +} diff --git a/controller/did-plc/operation.go b/controller/did-plc/operation.go new file mode 100644 index 0000000..edfd942 --- /dev/null +++ b/controller/did-plc/operation.go @@ -0,0 +1,227 @@ +package did_plc + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + + "github.com/MetaMask/go-did-it/controller/did-plc/internal/dagcbor" + "github.com/MetaMask/go-did-it/crypto" +) + +// Signer can produce raw-bytes ECDSA signatures. Any key that implements +// crypto.PrivateKeySigningBytes satisfies this interface directly. +// +// did:plc requires low-S signatures; this package always passes +// crypto.WithEcdsaLowSSig() when calling SignToBytes. +type Signer interface { + SignToBytes(message []byte, opts ...crypto.SigningOption) ([]byte, error) +} + +// Op holds the document content of a did:plc operation. +type Op struct { + RotationKeys []crypto.PublicKey + VerificationMethods map[string]crypto.PublicKey + AlsoKnownAs []string + Services map[string]Service +} + +// Service is a did:plc service endpoint. +type Service struct { + Type string `json:"type"` + Endpoint string `json:"endpoint"` +} + +// sign validates op and produces a fully encoded preparedOp. +// prevCID is nil for a genesis operation. +func (op Op) sign(r *Registry, signer Signer, prevCID *string) (*preparedOp, error) { + if prevCID != nil { + if err := validateCID(*prevCID); err != nil { + return nil, fmt.Errorf("invalid prevCID: %w", err) + } + } + if err := r.validateRotationKeys(op.RotationKeys); err != nil { + return nil, err + } + if len(op.VerificationMethods) > 10 { + return nil, fmt.Errorf("verificationMethods: at most 10 entries allowed, got %d", len(op.VerificationMethods)) + } + + rotKeys := make([]string, len(op.RotationKeys)) + for i, k := range op.RotationKeys { + rotKeys[i] = "did:key:" + k.ToPublicKeyMultibase() + } + vms := make(map[string]string, len(op.VerificationMethods)) + for name, k := range op.VerificationMethods { + vms[name] = "did:key:" + k.ToPublicKeyMultibase() + } + + m, err := opCBORMap(rotKeys, vms, op.AlsoKnownAs, op.Services, prevCID) + if err != nil { + return nil, err + } + unsignedBytes, err := dagcbor.Encode(m) + if err != nil { + return nil, err + } + sig, err := signToBase64URL(signer, unsignedBytes) + if err != nil { + return nil, err + } + m["sig"] = sig + signedBytes, err := dagcbor.Encode(m) + if err != nil { + return nil, err + } + jsonBytes, err := json.Marshal(opJSON{ + Type: "plc_operation", + RotationKeys: rotKeys, + VerificationMethods: vms, + AlsoKnownAs: op.AlsoKnownAs, + Services: op.Services, + Prev: prevCID, + Sig: sig, + }) + if err != nil { + return nil, err + } + return &preparedOp{ + unsigned: unsignedBytes, + signed: signedBytes, + jsonBytes: jsonBytes, + rotKeys: rotKeys, + prevCID: prevCID, + signature: sig, + }, nil +} + +// preparedOp is a fully signed plc_operation carrying precomputed encodings. +type preparedOp struct { + unsigned []byte // unsigned DAG-CBOR: used to verify the signature + signed []byte // signed DAG-CBOR: used to compute/verify the CID + jsonBytes []byte // JSON wire format: submitted to the registry + rotKeys []string + prevCID *string + signature string +} + +// buildPreparedOp encodes raw (already-parsed) opJSON into CBOR and returns a preparedOp. +// jsonBytes is the original JSON, stored verbatim for MarshalJSON. +func buildPreparedOp(raw opJSON, jsonBytes json.RawMessage) (*preparedOp, error) { + m, err := opCBORMap(raw.RotationKeys, raw.VerificationMethods, raw.AlsoKnownAs, raw.Services, raw.Prev) + if err != nil { + return nil, fmt.Errorf("building CBOR map: %w", err) + } + unsignedBytes, err := dagcbor.Encode(m) + if err != nil { + return nil, err + } + m["sig"] = raw.Sig + signedBytes, err := dagcbor.Encode(m) + if err != nil { + return nil, err + } + return &preparedOp{ + unsigned: unsignedBytes, + signed: signedBytes, + jsonBytes: jsonBytes, + rotKeys: raw.RotationKeys, + prevCID: raw.Prev, + signature: raw.Sig, + }, nil +} + +func parseSignedOp(data json.RawMessage) (*preparedOp, error) { + var raw opJSON + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + if raw.Type != "plc_operation" { + return nil, fmt.Errorf("expected type %q, got %q", "plc_operation", raw.Type) + } + return buildPreparedOp(raw, data) +} + +func (s *preparedOp) MarshalJSON() ([]byte, error) { return s.jsonBytes, nil } + +func (s *preparedOp) deriveID() (string, error) { + if s.prevCID != nil { + return "", fmt.Errorf("deriveID requires a genesis operation (prevCID must be nil)") + } + return deriveMSI(s.signed), nil +} + +// opFromJSON converts the JSON fields of a plc_operation into a public Op, +// decoding did:key strings into crypto.PublicKey values. +func opFromJSON(raw opJSON) (*Op, error) { + rotKeys := make([]crypto.PublicKey, len(raw.RotationKeys)) + for i, dk := range raw.RotationKeys { + pub, err := didKeyToPublicKey(dk) + if err != nil { + return nil, fmt.Errorf("rotation key %d: %w", i, err) + } + rotKeys[i] = pub + } + vms := make(map[string]crypto.PublicKey, len(raw.VerificationMethods)) + for name, dk := range raw.VerificationMethods { + pub, err := didKeyToPublicKey(dk) + if err != nil { + return nil, fmt.Errorf("verification method %q: %w", name, err) + } + vms[name] = pub + } + return &Op{ + RotationKeys: rotKeys, + VerificationMethods: vms, + AlsoKnownAs: raw.AlsoKnownAs, + Services: raw.Services, + }, nil +} + +func opCBORMap(rotKeys []string, vms map[string]string, akas []string, svcs map[string]Service, prevCID *string) (map[string]any, error) { + svcsAny := make(map[string]any, len(svcs)) + for id, svc := range svcs { + svcsAny[id] = map[string]any{"type": svc.Type, "endpoint": svc.Endpoint} + } + m := map[string]any{ + "type": "plc_operation", + "rotationKeys": rotKeys, + "verificationMethods": vms, + "alsoKnownAs": akas, + "services": svcsAny, + } + // Per the spec, prev is string-encoded in DAG-CBOR, not a binary CID link. + if prevCID == nil { + m["prev"] = nil + } else { + m["prev"] = *prevCID + } + return m, nil +} + +func didKeyToPublicKey(didKey string) (crypto.PublicKey, error) { + const prefix = "did:key:" + if !strings.HasPrefix(didKey, prefix) { + return nil, fmt.Errorf("not a did:key: %q", didKey) + } + return crypto.DefaultKeySet.PublicKeyFromMultibase(didKey[len(prefix):]) +} + +func signToBase64URL(signer Signer, message []byte) (string, error) { + rawSig, err := signer.SignToBytes(message, crypto.WithEcdsaLowSSig()) + if err != nil { + return "", fmt.Errorf("signing: %w", err) + } + return base64.RawURLEncoding.EncodeToString(rawSig), nil +} + +type opJSON struct { + Type string `json:"type"` + RotationKeys []string `json:"rotationKeys"` + VerificationMethods map[string]string `json:"verificationMethods"` + AlsoKnownAs []string `json:"alsoKnownAs"` + Services map[string]Service `json:"services"` + Prev *string `json:"prev"` + Sig string `json:"sig"` +} diff --git a/controller/did-plc/options.go b/controller/did-plc/options.go new file mode 100644 index 0000000..40cea8c --- /dev/null +++ b/controller/did-plc/options.go @@ -0,0 +1,26 @@ +package did_plc + +import ( + "net/http" + + "github.com/MetaMask/go-did-it/crypto" +) + +// Option configures a Registry. +type Option func(*Registry) + +// WithURL sets the PLC registry base URL. DefaultRegistry: https://plc.directory. +func WithURL(url string) Option { + return func(r *Registry) { r.url = url } +} + +// WithHTTPClient sets the HTTP client used for registry requests. +func WithHTTPClient(client *http.Client) Option { + return func(r *Registry) { r.httpClient = client } +} + +// WithRotationKeySet sets the allowed rotation key algorithms. +// DefaultRegistry: secp256k1 and P-256. +func WithRotationKeySet(ks *crypto.KeySet) Option { + return func(r *Registry) { r.rotationKeySet = ks } +} diff --git a/controller/did-plc/registry.go b/controller/did-plc/registry.go new file mode 100644 index 0000000..08a374c --- /dev/null +++ b/controller/did-plc/registry.go @@ -0,0 +1,187 @@ +// Package did_plc implements the controller side of the did:plc method. +// +// # Overview +// +// did:plc is a self-authenticating, recoverable DID method backed by a public +// append-only log hosted at https://plc.directory. +// Specification: https://web.plc.directory/spec/v0.1/did-plc +// +// # Usage +// +// Configure a [Registry] with [NewRegistry] and call [Registry.Create] to +// register a new DID, which returns a [Controller]. To operate on an existing +// DID, obtain a controller with [Registry.Controller]. +// +// reg := did_plc.NewRegistry() +// ctrl, err := reg.Create(ctx, signer, did_plc.Op{ +// RotationKeys: []crypto.PublicKey{myKey}, +// AlsoKnownAs: []string{"at://alice.example.com"}, +// }) +// +// ctrl := reg.Controller("did:plc:...") +// ctrl.Update(ctx, signer, func(op did_plc.Op) (did_plc.Op, error) { +// op.AlsoKnownAs = append(op.AlsoKnownAs, "at://alice.new.example.com") +// return op, nil +// }) +// +// # Key types +// +// Rotation keys must be secp256k1 or P-256 by default. Additional algorithms +// can be allowed via [WithRotationKeySet] (e.g. for a custom registry). +// Verification-method keys may be any type supported by the did:key method. +// +// # Chain validation +// +// [Controller.Audit] fetches the full operation history and validates CID +// integrity, low-S signatures, and prev-pointer continuity. +package did_plc + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/MetaMask/go-did-it/crypto" + "github.com/MetaMask/go-did-it/crypto/p256" + "github.com/MetaMask/go-did-it/crypto/secp256k1" +) + +// DefaultURL is the canonical PLC registry URL. +const DefaultURL = "https://plc.directory" + +// Registry is a client for the did:plc HTTP registry. +type Registry struct { + url string + httpClient *http.Client + rotationKeySet *crypto.KeySet +} + +// NewRegistry returns a Registry configured by opts. +func NewRegistry(opts ...Option) *Registry { + r := &Registry{ + url: DefaultURL, + httpClient: http.DefaultClient, + rotationKeySet: crypto.NewKeySet(secp256k1.KeyType(), p256.KeyType()), + } + for _, opt := range opts { + opt(r) + } + return r +} + +// Controller returns a Controller for an existing DID. +func (r *Registry) Controller(didStr string) *Controller { + return &Controller{registry: r, didStr: didStr} +} + +// Create registers a new DID and returns a Controller for it. +func (r *Registry) Create(ctx context.Context, signer Signer, op Op) (*Controller, error) { + signed, err := op.sign(r, signer, nil) + if err != nil { + return nil, err + } + msi, err := signed.deriveID() + if err != nil { + return nil, err + } + didStr := "did:plc:" + msi + if err := r.submit(ctx, didStr, signed); err != nil { + return nil, fmt.Errorf("submitting genesis operation: %w", err) + } + return r.Controller(didStr), nil +} + +func (r *Registry) validateRotationKeys(keys []crypto.PublicKey) error { + if len(keys) < 1 || len(keys) > 5 { + return fmt.Errorf("rotation keys: need 1–5 keys, got %d", len(keys)) + } + for i, key := range keys { + if !r.rotationKeySet.Accepts(key) { + return fmt.Errorf("rotation key %d: key type %T not allowed for rotation keys", i, key) + } + } + return nil +} + +func (r *Registry) submit(ctx context.Context, didStr string, op json.Marshaler) error { + body, err := op.MarshalJSON() + if err != nil { + return fmt.Errorf("marshalling operation: %w", err) + } + u, err := url.JoinPath(r.url, didStr) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "go-did-it") + resp, err := r.httpClient.Do(req) + if err != nil { + return fmt.Errorf("registry request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10)) + return fmt.Errorf("registry returned HTTP %d: %s", resp.StatusCode, msg) + } + return nil +} + +func (r *Registry) fetchAuditLog(ctx context.Context, didStr string) ([]AuditEntry, error) { + u, err := url.JoinPath(r.url, didStr, "log", "audit") + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "go-did-it") + resp, err := r.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching audit log: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("registry returned HTTP %d", resp.StatusCode) + } + var raw []struct { + DID string `json:"did"` + CID string `json:"cid"` + CreatedAt string `json:"createdAt"` + Nullified bool `json:"nullified"` + Operation json.RawMessage `json:"operation"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&raw); err != nil { + return nil, fmt.Errorf("decoding audit log: %w", err) + } + entries := make([]AuditEntry, len(raw)) + for i, e := range raw { + t, err := time.Parse(time.RFC3339, e.CreatedAt) + if err != nil { + return nil, fmt.Errorf("entry %d: invalid timestamp %q: %w", i, e.CreatedAt, err) + } + op, prepared, err := parseAuditEntry(e.Operation) + if err != nil { + return nil, fmt.Errorf("entry %d: %w", i, err) + } + entries[i] = AuditEntry{ + DID: e.DID, + CID: e.CID, + CreatedAt: t, + Nullified: e.Nullified, + Op: op, + prepared: prepared, + } + } + return entries, nil +} diff --git a/controller/did-plc/registry_test.go b/controller/did-plc/registry_test.go new file mode 100644 index 0000000..c1e2703 --- /dev/null +++ b/controller/did-plc/registry_test.go @@ -0,0 +1,316 @@ +package did_plc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/MetaMask/go-did-it/crypto" + _ "github.com/MetaMask/go-did-it/crypto/all" // populate DefaultKeySet used by didKeyToPublicKey + "github.com/MetaMask/go-did-it/crypto/ed25519" + "github.com/MetaMask/go-did-it/crypto/secp256k1" +) + +// fakeRegistry is a minimal in-memory did:plc registry for testing. +// It computes real CIDs from submitted operations so that chain validation passes. +type fakeRegistry struct { + mu sync.Mutex + ops map[string][]fakeEntry // keyed by full DID string +} + +type fakeEntry struct { + DID string `json:"did"` + CID string `json:"cid"` + CreatedAt string `json:"createdAt"` + Nullified bool `json:"nullified"` + Operation json.RawMessage `json:"operation"` +} + +func newFakeRegistry(t *testing.T) (*fakeRegistry, *Registry) { + t.Helper() + fr := &fakeRegistry{ops: make(map[string][]fakeEntry)} + srv := httptest.NewServer(http.HandlerFunc(fr.handle)) + t.Cleanup(srv.Close) + return fr, NewRegistry(WithURL(srv.URL)) +} + +func (fr *fakeRegistry) handle(w http.ResponseWriter, r *http.Request) { + // Paths: /{did} or /{did}/log/audit + path := strings.TrimPrefix(r.URL.Path, "/") + did, subpath, _ := strings.Cut(path, "/") + + fr.mu.Lock() + defer fr.mu.Unlock() + + switch { + case r.Method == http.MethodPost && subpath == "": + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + cid, err := cidFromOpJSON(body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + fr.ops[did] = append(fr.ops[did], fakeEntry{ + DID: did, + CID: cid, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + Operation: json.RawMessage(body), + }) + w.WriteHeader(http.StatusOK) + + case r.Method == http.MethodGet && subpath == "log/audit": + entries := fr.ops[did] + if entries == nil { + entries = []fakeEntry{} + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(entries) + + default: + http.NotFound(w, r) + } +} + +// cidFromOpJSON computes the dag-cbor CIDv1 for a submitted JSON operation. +// Uses the same internal helpers as the client so CIDs match during Audit validation. +func cidFromOpJSON(raw json.RawMessage) (string, error) { + var typed struct { + Type string `json:"type"` + } + if err := json.Unmarshal(raw, &typed); err != nil { + return "", err + } + switch typed.Type { + case "plc_operation": + op, err := parseSignedOp(raw) + if err != nil { + return "", err + } + return computeCID(op.signed) + case "plc_tombstone": + ts, err := parseSignedTombstone(raw) + if err != nil { + return "", err + } + return computeCID(ts.signed) + default: + return "", fmt.Errorf("unknown operation type %q", typed.Type) + } +} + +// helpers + +func genSecp256k1(t *testing.T) (crypto.PublicKey, *secp256k1.PrivateKey) { + t.Helper() + pub, priv, err := secp256k1.GenerateKeyPair() + require.NoError(t, err) + return pub, priv +} + +func createDID(t *testing.T, reg *Registry, pub crypto.PublicKey, priv *secp256k1.PrivateKey) *Controller { + t.Helper() + ctrl, err := reg.Create(context.Background(), priv, Op{ + RotationKeys: []crypto.PublicKey{pub}, + AlsoKnownAs: []string{"at://alice.example.com"}, + Services: map[string]Service{ + "atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://pds.example.com"}, + }, + }) + require.NoError(t, err) + return ctrl +} + +// tests + +func TestRegistryCreate(t *testing.T) { + _, reg := newFakeRegistry(t) + pub, priv := genSecp256k1(t) + + ctrl, err := reg.Create(context.Background(), priv, Op{ + RotationKeys: []crypto.PublicKey{pub}, + }) + require.NoError(t, err) + + did := ctrl.DidStr() + assert.True(t, strings.HasPrefix(did, "did:plc:"), "DID must start with did:plc:") + assert.Equal(t, 24, len(strings.TrimPrefix(did, "did:plc:")), "MSI must be 24 chars") +} + +func TestRegistryController(t *testing.T) { + _, reg := newFakeRegistry(t) + pub, priv := genSecp256k1(t) + ctrl := createDID(t, reg, pub, priv) + + ctrl2 := reg.Controller(ctrl.DidStr()) + assert.Equal(t, ctrl.DidStr(), ctrl2.DidStr()) +} + +func TestControllerUpdate(t *testing.T) { + fr, reg := newFakeRegistry(t) + pub, priv := genSecp256k1(t) + ctrl := createDID(t, reg, pub, priv) + + err := ctrl.Update(context.Background(), priv, func(op Op) (Op, error) { + op.AlsoKnownAs = append(op.AlsoKnownAs, "at://alice.new.example.com") + return op, nil + }) + require.NoError(t, err) + + fr.mu.Lock() + ops := fr.ops[ctrl.DidStr()] + fr.mu.Unlock() + require.Len(t, ops, 2) + + // Second op must reference the first op's CID. + var second opJSON + require.NoError(t, json.Unmarshal(ops[1].Operation, &second)) + assert.Equal(t, ops[0].CID, *second.Prev) +} + +func TestControllerTombstone(t *testing.T) { + fr, reg := newFakeRegistry(t) + pub, priv := genSecp256k1(t) + ctrl := createDID(t, reg, pub, priv) + + err := ctrl.Tombstone(context.Background(), priv) + require.NoError(t, err) + + fr.mu.Lock() + ops := fr.ops[ctrl.DidStr()] + fr.mu.Unlock() + require.Len(t, ops, 2) + + var typed struct{ Type string `json:"type"` } + require.NoError(t, json.Unmarshal(ops[1].Operation, &typed)) + assert.Equal(t, "plc_tombstone", typed.Type) +} + +func TestControllerAudit(t *testing.T) { + _, reg := newFakeRegistry(t) + pub, priv := genSecp256k1(t) + ctrl := createDID(t, reg, pub, priv) + + err := ctrl.Update(context.Background(), priv, func(op Op) (Op, error) { + op.AlsoKnownAs = append(op.AlsoKnownAs, "at://alice.new.example.com") + return op, nil + }) + require.NoError(t, err) + + entries, err := ctrl.Audit(context.Background()) + require.NoError(t, err) + require.Len(t, entries, 2) + + assert.Equal(t, ctrl.DidStr(), entries[0].DID) + assert.False(t, entries[0].Nullified) + require.NotNil(t, entries[0].Op) + assert.Equal(t, []string{"at://alice.example.com"}, entries[0].Op.AlsoKnownAs) + + assert.False(t, entries[1].Nullified) + require.NotNil(t, entries[1].Op) + assert.Contains(t, entries[1].Op.AlsoKnownAs, "at://alice.new.example.com") +} + +func TestControllerAuditTombstone(t *testing.T) { + _, reg := newFakeRegistry(t) + pub, priv := genSecp256k1(t) + ctrl := createDID(t, reg, pub, priv) + + require.NoError(t, ctrl.Tombstone(context.Background(), priv)) + + entries, err := ctrl.Audit(context.Background()) + require.NoError(t, err) + require.Len(t, entries, 2) + assert.NotNil(t, entries[0].Op) + assert.Nil(t, entries[1].Op, "tombstone entry must have nil Op") +} + +func TestControllerRecover(t *testing.T) { + fr, reg := newFakeRegistry(t) + pub, priv := genSecp256k1(t) + ctrl := createDID(t, reg, pub, priv) + + // Record the genesis CID before updating. + fr.mu.Lock() + genesisCID := fr.ops[ctrl.DidStr()][0].CID + fr.mu.Unlock() + + // Normal update: builds on genesis. + require.NoError(t, ctrl.Update(context.Background(), priv, func(op Op) (Op, error) { + op.AlsoKnownAs = append(op.AlsoKnownAs, "at://alice.example.com/v2") + return op, nil + })) + + // Recovery: fork back to genesis, producing an op with prev=genesisCID. + err := ctrl.Recover(context.Background(), priv, genesisCID, func(op Op) (Op, error) { + op.AlsoKnownAs = []string{"at://alice.recovered.example.com"} + return op, nil + }) + require.NoError(t, err) + + fr.mu.Lock() + ops := fr.ops[ctrl.DidStr()] + fr.mu.Unlock() + require.Len(t, ops, 3) + + var recovery opJSON + require.NoError(t, json.Unmarshal(ops[2].Operation, &recovery)) + assert.Equal(t, genesisCID, *recovery.Prev, "recovery must fork from genesis CID") +} + +func TestValidationErrors(t *testing.T) { + _, reg := newFakeRegistry(t) + pub, _ := genSecp256k1(t) + ctx := context.Background() + + t.Run("no rotation keys", func(t *testing.T) { + _, priv := genSecp256k1(t) + _, err := reg.Create(ctx, priv, Op{RotationKeys: nil}) + require.ErrorContains(t, err, "rotation keys") + }) + + t.Run("too many rotation keys", func(t *testing.T) { + _, priv := genSecp256k1(t) + keys := make([]crypto.PublicKey, 6) + for i := range keys { + k, _, _ := secp256k1.GenerateKeyPair() + keys[i] = k + } + _, err := reg.Create(ctx, priv, Op{RotationKeys: keys}) + require.ErrorContains(t, err, "rotation keys") + }) + + t.Run("disallowed rotation key type", func(t *testing.T) { + _, priv := genSecp256k1(t) + edPub, _, err := ed25519.GenerateKeyPair() + require.NoError(t, err) + _, err = reg.Create(ctx, priv, Op{RotationKeys: []crypto.PublicKey{edPub}}) + require.ErrorContains(t, err, "not allowed for rotation keys") + }) + + t.Run("too many verification methods", func(t *testing.T) { + _, priv := genSecp256k1(t) + vms := make(map[string]crypto.PublicKey, 11) + for i := range 11 { + vms[fmt.Sprintf("key%d", i)] = pub + } + _, err := reg.Create(ctx, priv, Op{ + RotationKeys: []crypto.PublicKey{pub}, + VerificationMethods: vms, + }) + require.ErrorContains(t, err, "verificationMethods") + }) +} diff --git a/controller/did-plc/tombstone.go b/controller/did-plc/tombstone.go new file mode 100644 index 0000000..28c3f39 --- /dev/null +++ b/controller/did-plc/tombstone.go @@ -0,0 +1,87 @@ +package did_plc + +import ( + "encoding/json" + "fmt" + + "github.com/MetaMask/go-did-it/controller/did-plc/internal/dagcbor" +) + +type signedTombstone struct { + unsigned []byte + signed []byte + jsonBytes []byte + prevCID string + signature string +} + +type signedTombstoneJSON struct { + Type string `json:"type"` + Prev string `json:"prev"` + Sig string `json:"sig"` +} + +func signTombstone(signer Signer, prevCID string) (*signedTombstone, error) { + // Validate CID format without converting to a link; prev is string-encoded per spec. + if err := validateCID(prevCID); err != nil { + return nil, fmt.Errorf("invalid prevCID: %w", err) + } + m := map[string]any{"type": "plc_tombstone", "prev": prevCID} + unsignedBytes, err := dagcbor.Encode(m) + if err != nil { + return nil, err + } + sig, err := signToBase64URL(signer, unsignedBytes) + if err != nil { + return nil, err + } + m["sig"] = sig + signedBytes, err := dagcbor.Encode(m) + if err != nil { + return nil, err + } + jsonBytes, err := json.Marshal(signedTombstoneJSON{Type: "plc_tombstone", Prev: prevCID, Sig: sig}) + if err != nil { + return nil, err + } + return &signedTombstone{ + unsigned: unsignedBytes, + signed: signedBytes, + jsonBytes: jsonBytes, + prevCID: prevCID, + signature: sig, + }, nil +} + +func parseSignedTombstone(data json.RawMessage) (*signedTombstone, error) { + var raw signedTombstoneJSON + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + if raw.Type != "plc_tombstone" { + return nil, fmt.Errorf("expected type %q, got %q", "plc_tombstone", raw.Type) + } + // Validate CID format; prev is string-encoded per spec, not a binary link. + if err := validateCID(raw.Prev); err != nil { + return nil, fmt.Errorf("invalid prev CID: %w", err) + } + m := map[string]any{"type": "plc_tombstone", "prev": raw.Prev} + unsignedBytes, err := dagcbor.Encode(m) + if err != nil { + return nil, err + } + m["sig"] = raw.Sig + signedBytes, err := dagcbor.Encode(m) + if err != nil { + return nil, err + } + return &signedTombstone{ + unsigned: unsignedBytes, + signed: signedBytes, + jsonBytes: data, + prevCID: raw.Prev, + signature: raw.Sig, + }, nil +} + +func (ts *signedTombstone) MarshalJSON() ([]byte, error) { return ts.jsonBytes, nil } diff --git a/go.mod b/go.mod index 53531a9..29d9c0d 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,12 @@ go 1.25.0 require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 - github.com/mr-tron/base58 v1.1.0 - github.com/multiformats/go-multibase v0.2.0 - github.com/multiformats/go-varint v0.0.7 + github.com/ipfs/go-cid v0.6.1 + github.com/mr-tron/base58 v1.3.0 + github.com/multiformats/go-multibase v0.3.0 + github.com/multiformats/go-multicodec v0.10.0 + github.com/multiformats/go-multihash v0.2.3 + github.com/multiformats/go-varint v0.1.0 github.com/stretchr/testify v1.10.0 github.com/ucan-wg/go-varsig v1.0.0 golang.org/x/crypto v0.50.0 @@ -14,9 +17,13 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/multiformats/go-base32 v0.0.3 // indirect - github.com/multiformats/go-base36 v0.1.0 // indirect + github.com/klauspost/cpuid/v2 v2.0.9 // indirect + github.com/minio/sha256-simd v1.0.0 // indirect + github.com/multiformats/go-base32 v0.1.0 // indirect + github.com/multiformats/go-base36 v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect golang.org/x/sys v0.43.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + lukechampine.com/blake3 v1.1.6 // indirect ) diff --git a/go.sum b/go.sum index c2a3c42..f456d8d 100644 --- a/go.sum +++ b/go.sum @@ -4,18 +4,31 @@ github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/mr-tron/base58 v1.1.0 h1:Y51FGVJ91WBqCEabAi5OPUz38eAx8DakuAm5svLcsfQ= -github.com/mr-tron/base58 v1.1.0/go.mod h1:xcD2VGqlgYjBdcBLw+TuYLr8afG+Hj8g2eTVqeSzSU8= -github.com/multiformats/go-base32 v0.0.3 h1:tw5+NhuwaOjJCC5Pp82QuXbrmLzWg7uxlMFp8Nq/kkI= -github.com/multiformats/go-base32 v0.0.3/go.mod h1:pLiuGC8y0QR3Ue4Zug5UzK9LjgbkL8NSQj0zQ5Nz/AA= -github.com/multiformats/go-base36 v0.1.0 h1:JR6TyF7JjGd3m6FbLU2cOxhC0Li8z8dLNGQ89tUg4F4= -github.com/multiformats/go-base36 v0.1.0/go.mod h1:kFGE83c6s80PklsHO9sRn2NCoffoRdUUOENyW/Vv6sM= -github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= -github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= -github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= -github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= +github.com/ipfs/go-cid v0.6.1 h1:T5TnNb08+ueovG76Z5gx1L4Y7QOaGTXHg1F6raWFxIc= +github.com/ipfs/go-cid v0.6.1/go.mod h1:zrY0SwOhjrrIdfPQ/kf+k1sXyJ0QE7cMxfCployLBs0= +github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= +github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI= +github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8= +github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= +github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= +github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= +github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= +github.com/multiformats/go-multibase v0.3.0 h1:8helZD2+4Db7NNWFiktk2NePbF0boolBe6bDQvM4r68= +github.com/multiformats/go-multibase v0.3.0/go.mod h1:MoBLQPCkRTOL3eveIPO81860j2AQY8JwcnNlRkGRUfI= +github.com/multiformats/go-multicodec v0.10.0 h1:UpP223cig/Cx8J76jWt91njpK3GTAO1w02sdcjZDSuc= +github.com/multiformats/go-multicodec v0.10.0/go.mod h1:wg88pM+s2kZJEQfRCKBNU+g32F5aWBEjyFHXvZLTcLI= +github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= +github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= +github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= +github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ucan-wg/go-varsig v1.0.0 h1:Hrc437Zg+B5Eoajg+qZQZI3Q3ocPyjlnp3/Bz9ZnlWw= @@ -28,3 +41,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c= +lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA=