-
Notifications
You must be signed in to change notification settings - Fork 4
WIP did-plc controller #47
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: crypto-selection
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mutating paths skip chain validationMedium Severity
Reviewed by Cursor Bugbot for commit e9ae33f. Configure here. |
||


Uh oh!
There was an error while loading. Please reload this page.