Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions controller/did-plc/audit.go
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")
}
51 changes: 51 additions & 0 deletions controller/did-plc/cid.go
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
}
115 changes: 115 additions & 0 deletions controller/did-plc/controller.go
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
}
Comment thread
cursor[bot] marked this conversation as resolved.
return *e.Op, nil
}
}
return Op{}, fmt.Errorf("operation with CID %s not found for %s", cid, c.didStr)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mutating paths skip chain validation

Medium Severity

Update, Tombstone, and Recover load the audit log via fetchHead and fetchOpAtCID without calling validateChain, while Audit does. A tampered or internally inconsistent log can still supply a head CID and document state, so signed submissions may be built on unverified history until the registry rejects them or the caller runs Audit separately.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e9ae33f. Configure here.

Loading
Loading