diff --git a/log.go b/log.go index f0531f0..8e438c1 100644 --- a/log.go +++ b/log.go @@ -1,174 +1,12 @@ package didplc import ( - "errors" + "context" "fmt" - "sync" - "time" "github.com/bluesky-social/indigo/atproto/syntax" ) -type opStatus struct { - DID string - CreatedAt time.Time // fields below this line may be mutated - Nullified bool - LastChild string // CID - AllowedKeys []string // the set of public did:keys currently allowed to update from this op -} - -// Note: logValidationContext is designed such that it could later be turned into an interface, -// optionally backed by a db rather than in-memory -// Note: ops are globally unique by CID, so opStatus map can be shared across all DIDs -type logValidationContext struct { - head map[string]string // DID -> CID, tracks most recent valid op for a particular DID - opStatus map[string]*opStatus // CID -> OpStatus - lock sync.RWMutex -} - -var errLogValidationUnrecoverableInternalError = errors.New("logValidationContext internal state has become inconsistent. This is very bad and should be impossible") - -func newLogValidationContext() *logValidationContext { - return &logValidationContext{ - head: make(map[string]string), - opStatus: make(map[string]*opStatus), - } -} - -// Retrieve the information required to validate a signature for a particular operation, where `cidStr` -// corresponds to the `prev` field of the operation you're trying to validate. -// If you're validating a genesis op (i.e. prev==nil), pass cidStr=="" -// -// The returned string is the current "head" CID of the passed DID. -// Any subsequent calls to CommitValidOperation must pass the corresponding head, opStatus values. -// -// This method may also be used to inspect the nullification status and/or createdAt timestamp for a particular op (by did+cid) -func (lvc *logValidationContext) GetValidationContext(did string, cidStr string) (string, *opStatus, error) { - lvc.lock.RLock() - defer lvc.lock.RUnlock() - - head, exists := lvc.head[did] - if !exists { - if cidStr != "" { - return "", nil, fmt.Errorf("DID not found") - } - return "", nil, nil // Not an error condition! just means DID is not created yet - } - status := lvc.opStatus[cidStr] - if status == nil { - return "", nil, fmt.Errorf("CID not found") - } - if status.DID != did { - return "", nil, fmt.Errorf("op belongs to a different DID") - } - - // make a deep copy of the status struct so that concurrent mutations are safe - statusCopy := *status - statusCopy.AllowedKeys = make([]string, len(status.AllowedKeys)) - copy(statusCopy.AllowedKeys, status.AllowedKeys) - - return head, &statusCopy, nil -} - -// `head` and `prevStatus` MUST be values that were returned from a previous call to GetValidationContext, with the same `did`. -// The caller is responsible for syntax validation and signature verification of the Operation. -// CommitValidOperation will ensure that: -// 1. If this is the first operation for a particular DID, it must be a genesis operation -// 2. Else, it must not be a genesis operation. -// 3. The passed `createdAt` timestamp is greater than that of the current `head` op -// 4. If the operation nullifies a previous operation, the nullified op is less than (or exactly equal to) 72h old -// 5. This DID has not been updated since the corresponding GetValidationContext call -// -// Additionally, the lvc head+opStatus maps are updated to reflect the changes (including updating nullification status if applicable). -// -// Although it should be unreachable, errLogValidationUnrecoverableInternalError -// may be returned if the logValidationContext internal state has become inconsistent. -// This could happen due to an implementation bug, or if an invalid prevStatus is passed -// (one not produced by an earlier call to GetValidationContext). -func (lvc *logValidationContext) CommitValidOperation(did string, head string, prevStatus *opStatus, op Operation, createdAt time.Time, keyIndex int) error { - thisCid := op.CID().String() // CID() involves expensive-ish serialisation/hashing, best to keep out of the critical section - - lvc.lock.Lock() - defer lvc.lock.Unlock() - - if head != lvc.head[did] { - return fmt.Errorf("head CID mismatch") - } - if head == "" { - if !op.IsGenesis() { - return fmt.Errorf("expected genesis op") - } - } else { - if op.IsGenesis() { - return fmt.Errorf("unexpected genesis op") - } - if prevStatus == nil { - return fmt.Errorf("invalid prevStatus") - } - if prevStatus.Nullified { - return fmt.Errorf("prev CID is nullified") - } - if prevStatus.LastChild == "" { // regular update (not a nullification) - // note: prevStatus == c.opStatus[head] - if createdAt.Sub(prevStatus.CreatedAt) <= 0 { - return fmt.Errorf("invalid operation timestamp order") - } - } else { // this is a nullification. prevStatus.LastChild is the CID of the op being nullified - // note: prevStatus != c.opStatus[head] - headStatus := lvc.opStatus[head] - if headStatus == nil { - return errLogValidationUnrecoverableInternalError - } - if createdAt.Sub(headStatus.CreatedAt) <= 0 { - return fmt.Errorf("invalid operation timestamp order") - } - lastChildStatus := lvc.opStatus[prevStatus.LastChild] - if lastChildStatus == nil { - return errLogValidationUnrecoverableInternalError - } - if createdAt.Sub(lastChildStatus.CreatedAt) > 72*time.Hour { - return fmt.Errorf("cannot nullify op after 72h (%s - %s = %s)", createdAt, prevStatus.CreatedAt, createdAt.Sub(prevStatus.CreatedAt)) - } - err := lvc.markNullifiedOp(did, prevStatus.LastChild) // recursive - if err != nil { - return err // should never happen, if it does we're in a broken state - } - } - prevStatus.AllowedKeys = prevStatus.AllowedKeys[:keyIndex] - prevStatus.LastChild = thisCid - lvc.opStatus[op.PrevCIDStr()] = prevStatus // prevStatus was a copy so we need to write it back - } - lvc.head[did] = thisCid - lvc.opStatus[thisCid] = &opStatus{ - DID: did, - CreatedAt: createdAt, - Nullified: false, - LastChild: "", - AllowedKeys: op.EquivalentRotationKeys(), - } - return nil -} - -// Recurses if more than one op needs to be nullified (if the nullified op has descendents) -// Note: lvc.lock is expected to be held by caller -func (lvc *logValidationContext) markNullifiedOp(did string, cidStr string) error { - if cidStr == "" { - return nil - } - op := lvc.opStatus[cidStr] - if op == nil { // this *should* be unreachable - return errLogValidationUnrecoverableInternalError - } - if op.DID != did { // likewise - return errLogValidationUnrecoverableInternalError - } - if op.Nullified { - return nil - } - op.Nullified = true - return lvc.markNullifiedOp(did, op.LastChild) -} - type LogEntry struct { DID string `json:"did"` Operation OpEnum `json:"operation"` @@ -213,7 +51,8 @@ func VerifyOpLog(entries []LogEntry) error { } did := entries[0].DID - lvc := newLogValidationContext() + mos := NewMemOpStore() + ctx := context.Background() for _, oe := range entries { if oe.DID != did { @@ -221,14 +60,11 @@ func VerifyOpLog(entries []LogEntry) error { } // NOTE: we do not call oe.Validate() here because we'd end up verifying // genesis op signatures twice. - // We check for CID consistency here, and will verify signatures (for all op types) later. + // All validation is performed inside VerifyOperation() op := oe.Operation.AsOperation() if op == nil { return fmt.Errorf("invalid operation type") } - if op.CID().String() != oe.CID { - return fmt.Errorf("inconsistent CID") - } datetime, err := syntax.ParseDatetime(oe.CreatedAt) if err != nil { @@ -236,30 +72,16 @@ func VerifyOpLog(entries []LogEntry) error { } timestamp := datetime.Time() - head, prevStatus, err := lvc.GetValidationContext(did, op.PrevCIDStr()) + po, err := VerifyOperation(ctx, mos, did, op, timestamp) if err != nil { return err } - - var allowedKeys *[]string - if op.IsGenesis() { - calcDid, err := op.DID() - if err != nil { - return err - } - if calcDid != did { - return fmt.Errorf("genesis DID does not match") - } - rotationKeys := op.EquivalentRotationKeys() - allowedKeys = &rotationKeys - } else { // not-genesis - allowedKeys = &prevStatus.AllowedKeys - } - keyIdx, err := VerifySignatureAny(op, *allowedKeys) - if err != nil { - return err + // extra CID check (since oe.CID is not checked inside VerifyOperation) + if po.OpCid != oe.CID { + return fmt.Errorf("inconsistent CID") } - err = lvc.CommitValidOperation(did, head, prevStatus, op, timestamp, keyIdx) + + err = mos.CommitOperations(ctx, []*PreparedOperation{po}) if err != nil { return err } @@ -273,7 +95,7 @@ func VerifyOpLog(entries []LogEntry) error { return fmt.Errorf("genesis op cannot be nullified") } } - _, status, err := lvc.GetValidationContext(did, oe.CID) + status, err := mos.GetEntry(ctx, did, oe.CID) if err != nil { return err } diff --git a/operation.go b/operation.go index fcf785d..4e19d24 100644 --- a/operation.go +++ b/operation.go @@ -40,6 +40,8 @@ type Operation interface { EquivalentRotationKeys() []string // CID of the previous operation ("" for genesis ops) PrevCIDStr() string + // converts this operation to an OpEnum + AsOpEnum() *OpEnum } type OpService struct { @@ -267,6 +269,10 @@ func (op *RegularOp) PrevCIDStr() string { return *op.Prev } +func (op *RegularOp) AsOpEnum() *OpEnum { + return &OpEnum{Regular: op} +} + func (op *LegacyOp) CID() cid.Cid { return computeCID(op.SignedCBORBytes()) } @@ -385,6 +391,10 @@ func (op *LegacyOp) PrevCIDStr() string { return *op.Prev } +func (op *LegacyOp) AsOpEnum() *OpEnum { + return &OpEnum{Legacy: op} +} + func (op *TombstoneOp) CID() cid.Cid { return computeCID(op.SignedCBORBytes()) } @@ -447,6 +457,10 @@ func (op *TombstoneOp) PrevCIDStr() string { return op.Prev } +func (op *TombstoneOp) AsOpEnum() *OpEnum { + return &OpEnum{Tombstone: op} +} + func (o *OpEnum) MarshalJSON() ([]byte, error) { if o.Regular != nil { return json.Marshal(o.Regular) @@ -528,3 +542,4 @@ func (oe *OpEnum) AsOperation() Operation { return nil } } + diff --git a/operation_test.go b/operation_test.go index 37cef38..5fd00ac 100644 --- a/operation_test.go +++ b/operation_test.go @@ -110,13 +110,13 @@ func TestAuditLogInvalidSigEncoding(t *testing.T) { assert.ErrorContains(VerifyOpLog(entries), "CRLF") entries = loadTestLogEntries(t, "testdata/log_invalid_sig_der.json") - assert.EqualError(VerifyOpLog(entries), "crytographic signature invalid") // Note: there is no reliable way to detect DER-encoded signatures syntactically, so a generic invalid signature error is expected + assert.ErrorContains(VerifyOpLog(entries), "crytographic signature invalid") // Note: there is no reliable way to detect DER-encoded signatures syntactically, so a generic invalid signature error is expected entries = loadTestLogEntries(t, "testdata/log_invalid_sig_p256_high_s.json") - assert.EqualError(VerifyOpLog(entries), "crytographic signature invalid") + assert.ErrorContains(VerifyOpLog(entries), "crytographic signature invalid") entries = loadTestLogEntries(t, "testdata/log_invalid_sig_k256_high_s.json") - assert.EqualError(VerifyOpLog(entries), "crytographic signature invalid") + assert.ErrorContains(VerifyOpLog(entries), "crytographic signature invalid") } @@ -124,20 +124,20 @@ func TestAuditLogInvalidNullification(t *testing.T) { assert := assert.New(t) entries := loadTestLogEntries(t, "testdata/log_invalid_nullification_reused_key.json") - assert.EqualError(VerifyOpLog(entries), "crytographic signature invalid") // TODO: This is the expected error message for the current impl logic. This could be improved. + assert.ErrorContains(VerifyOpLog(entries), "crytographic signature invalid") // TODO: This is the expected error message for the current impl logic. This could be improved. entries = loadTestLogEntries(t, "testdata/log_invalid_nullification_too_slow.json") assert.ErrorContains(VerifyOpLog(entries), "cannot nullify op after 72h") entries = loadTestLogEntries(t, "testdata/log_invalid_update_nullified.json") - assert.EqualError(VerifyOpLog(entries), "prev CID is nullified") + assert.ErrorContains(VerifyOpLog(entries), "prev CID is nullified") } func TestAuditLogInvalidTombstoneUpdate(t *testing.T) { assert := assert.New(t) entries := loadTestLogEntries(t, "testdata/log_invalid_update_tombstoned.json") - assert.EqualError(VerifyOpLog(entries), "no keys to verify against") // TODO: This is the expected error message for the current impl logic. This could be improved. + assert.ErrorContains(VerifyOpLog(entries), "no keys to verify against") // TODO: This is the expected error message for the current impl logic. This could be improved. } func TestCreatePLC(t *testing.T) { diff --git a/opstore.go b/opstore.go new file mode 100644 index 0000000..2f78a0c --- /dev/null +++ b/opstore.go @@ -0,0 +1,326 @@ +package didplc + +import ( + "context" + "errors" + "fmt" + "sync" + "time" +) + +var ( + // May be returned by GetAllEntries if unsupported by an implementation + ErrNotImplemented = errors.New("not implemented") + + // May be returned by VerifyOperation (as a wrapped error) + ErrInvalidOperation = errors.New("invalid PLC operation") + + // May be returned by CommitOperations (as a wrapped error) + ErrHeadMismatch = errors.New("head mismatch") +) + +type OpEntry struct { + DID string + CreatedAt time.Time + Nullified bool + LastChild string // CID of most recent operation with `prev` referencing this op + AllowedKeys []string // the set of public did:keys currently allowed to update from this op + Op Operation + OpCid string +} + +// PreparedOperation contains all the information needed to commit a validated operation. +type PreparedOperation struct { + DID string + PrevHead string + NullifiedOps []string // CIDs of any operations being nullified + KeyIndex int + CreatedAt time.Time + Op Operation + OpCid string +} + +type OpStore interface { + // GetEntry returns metadata about a specific operation, plus the operation itself. + // Returns nil if the DID does not exist. + GetEntry(ctx context.Context, did string, cid string) (*OpEntry, error) + + // Like GetEntry, but returns the data for the most recent valid operation for a DID. + // Returns nil if the DID does not exist. + GetLatest(ctx context.Context, did string) (*OpEntry, error) + + // Returns all entries for a given DID, including those which are nullified. + // Returns nil or empty slice if the DID does not exist. + // An implementation may choose not to implement this method, returning ErrNotImplemented if so. + GetAllEntries(ctx context.Context, did string) ([]*OpEntry, error) + + // CommitOperations atomically commits a batch of prepared operations to the store. + // All operations in the batch are committed, or none are (all-or-nothing). + // It is invalid to have multiple operations for the same DID in the same batch. + // + // For each PreparedOperation, `PrevHead` MUST match the `OpCid` value returned by an earlier call to GetLatest (Or "" if GetLatest returned nil). + // PreparedOperations returned by VerifyOperation() will always have `PrevHead` set appropriately. + // + // If any updates are made to a particular DID in the time between VerifyOperation() and a corresponding call to CommitOperations(), + // then CommitOperations() will error due to head mismatch (ErrHeadMismatch). + CommitOperations(ctx context.Context, ops []*PreparedOperation) error +} + +// VerifyOperation validates and prepares a single operation for commit. +// It verifies the signature, validates timestamp consistency, and computes the nullification list. +// On success, returns a PreparedOperation ready to be committed to the store. +// Errors wrapping ErrInvalidOperation indicate the operation is *definitely* invalid. +// Other errors are OpStore-related (e.g. transient database connection issue) and *may* be resolved by retrying. +func VerifyOperation(ctx context.Context, store OpStore, did string, op Operation, createdAt time.Time) (*PreparedOperation, error) { + head, prevStatus, err := getValidationContext(ctx, store, did, op.PrevCIDStr()) + if err != nil { + return nil, err + } + + // Determine allowed keys for signature verification + var allowedKeys []string + if op.IsGenesis() { + calcDid, err := op.DID() + if err != nil { + return nil, fmt.Errorf("%w: failed to calculate DID string: %v", ErrInvalidOperation, err) + } + if calcDid != did { + return nil, fmt.Errorf("%w: genesis DID does not match", ErrInvalidOperation) + } + allowedKeys = op.EquivalentRotationKeys() + } else { + if prevStatus == nil { + return nil, fmt.Errorf("%w: prevStatus required for non-genesis operation", ErrInvalidOperation) + } + allowedKeys = prevStatus.AllowedKeys + } + + // Verify signature + keyIdx, err := VerifySignatureAny(op, allowedKeys) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidOperation, err) + } + + // Create the prepared operation + prepOp := PreparedOperation{ + DID: did, + PrevHead: head, + KeyIndex: keyIdx, + CreatedAt: createdAt, + Op: op, + OpCid: op.CID().String(), + } + + // Genesis operations don't have nullifications or timestamp constraints + if head == "" { + prepOp.NullifiedOps = nil + return &prepOp, nil + } + + if prevStatus.Nullified { + return nil, fmt.Errorf("%w: prev CID is nullified", ErrInvalidOperation) + } + + if prevStatus.LastChild == "" { + // Regular update (not a nullification) + // Validate timestamp order + if createdAt.Sub(prevStatus.CreatedAt) <= 0 { + return nil, fmt.Errorf("%w: invalid operation timestamp order", ErrInvalidOperation) + } + prepOp.NullifiedOps = nil + } else { + // This is a nullification - validate timestamp against head + headStatus, err := store.GetEntry(ctx, did, head) + if err != nil { + return nil, err + } + if headStatus == nil { // should be unreachable, implies invalid db state + return nil, fmt.Errorf("failed to retrieve head") + } + if createdAt.Sub(headStatus.CreatedAt) <= 0 { + return nil, fmt.Errorf("%w: invalid operation timestamp order", ErrInvalidOperation) + } + + // Validate 72h constraint and build nullification list + nullifiedOps := []string{} + currentCid := prevStatus.LastChild + + for currentCid != "" { + status, err := store.GetEntry(ctx, did, currentCid) + if err != nil { + return nil, err + } + if status == nil { // should be unreachable, implies invalid db state + return nil, fmt.Errorf("failed to walk nullification chain") + } + + // Check 72h constraint + // (this check is only relevant on the first iteration, since each + // subsequent iteration should be even more recent) + if createdAt.Sub(status.CreatedAt) > 72*time.Hour { + return nil, fmt.Errorf("%w: cannot nullify op after 72h (%s - %s = %s)", + ErrInvalidOperation, createdAt, status.CreatedAt, createdAt.Sub(status.CreatedAt)) + } + + nullifiedOps = append(nullifiedOps, currentCid) + currentCid = status.LastChild + } + + prepOp.NullifiedOps = nullifiedOps + } + + return &prepOp, nil +} + +// getValidationContext retrieves the initial information required to validate a signature for a particular operation. +// `cidStr` corresponds to the `prev` field of the operation you're trying to validate. +// For genesis ops (i.e. prev==nil), pass cidStr=="". +// +// Returns the current "head" CID of the passed DID and the OpStatus for the previous operation. +func getValidationContext(ctx context.Context, store OpStore, did string, cidStr string) (string, *OpEntry, error) { + head, err := store.GetLatest(ctx, did) + if err != nil { + return "", nil, err + } + + if head == nil { + if cidStr != "" { + return "", nil, fmt.Errorf("%w: DID not found", ErrInvalidOperation) + } + return "", nil, nil // Not an error condition! just means DID is not created yet + } + + if cidStr == "" { + return "", nil, fmt.Errorf("%w: expected genesis op but DID already exists", ErrInvalidOperation) + } + + if head.OpCid == cidStr { + // shortcut: prev == head + return head.OpCid, head, nil + } + + status, err := store.GetEntry(ctx, did, cidStr) + if err != nil { + return "", nil, err + } + if status == nil { + return "", nil, fmt.Errorf("%w: prev cid does not exist %s", ErrInvalidOperation, cidStr) + } + + return head.OpCid, status, nil +} + +// MemOpStore is an in-memory implementation of the OpStore interface +type MemOpStore struct { + head map[string]string // DID -> CID (head) + entries map[string]*OpEntry // CID -> OpEntry + lock sync.RWMutex +} + +var _ OpStore = (*MemOpStore)(nil) + +func NewMemOpStore() *MemOpStore { + return &MemOpStore{ + head: make(map[string]string), + entries: make(map[string]*OpEntry), + } +} + +// GetLatest returns the entry for the most recent valid operation for a DID. +// Returns nil if the DID does not exist. +func (store *MemOpStore) GetLatest(ctx context.Context, did string) (*OpEntry, error) { + store.lock.RLock() + defer store.lock.RUnlock() + + head, exists := store.head[did] + if !exists { + return nil, nil + } + return store.GetEntry(ctx, did, head) +} + +// GetEntry returns the entry for a specific operation. +// Returns nil if the operation does not exist. +func (store *MemOpStore) GetEntry(ctx context.Context, did string, cid string) (*OpEntry, error) { + store.lock.RLock() + defer store.lock.RUnlock() + + status, exists := store.entries[cid] + if !exists { + return nil, fmt.Errorf("operation not found") + } + + if status.DID != did { + // This implies an implementation bug, should be unreachable + return nil, fmt.Errorf("operation belongs to a different DID") + } + + return status, nil +} + +func (store *MemOpStore) GetAllEntries(ctx context.Context, did string) ([]*OpEntry, error) { + // MemOpStore's storage format is not suited to implementing this method + return nil, ErrNotImplemented +} + +// CommitOperations atomically commits a batch of prepared operations to the store. +// All operations in the batch are committed or none are (all-or-nothing). +func (store *MemOpStore) CommitOperations(ctx context.Context, ops []*PreparedOperation) error { + store.lock.Lock() + defer store.lock.Unlock() + + // Verify all heads upfront before making any modifications + // (a db implementation can do this in the main loop and roll back the tx on mismatch) + for _, prepOp := range ops { + currentHead := store.head[prepOp.DID] + if currentHead != prepOp.PrevHead { + return fmt.Errorf("%w: head CID mismatch for DID %s", ErrHeadMismatch, prepOp.DID) + } + } + + // Now apply all modifications + for _, prepOp := range ops { + // Handle nullifications + for _, nullifiedCid := range prepOp.NullifiedOps { + status := store.entries[nullifiedCid] + if status == nil { + // This implies an implementation bug, should be unreachable + return fmt.Errorf("operation not found during nullification: %s", nullifiedCid) + } + if status.DID != prepOp.DID { + // This implies an implementation bug, should be unreachable + return fmt.Errorf("operation belongs to different DID during nullification") + } + status.Nullified = true + } + + // Update previous operation's metadata if not a genesis op + if prepOp.PrevHead != "" { + prevCidStr := prepOp.Op.PrevCIDStr() + prevStatus := store.entries[prevCidStr] + if prevStatus == nil { + // This implies an implementation bug, should be unreachable + return fmt.Errorf("previous operation not found: %s", prevCidStr) + } + + // Trim allowed keys and set last child + prevStatus.AllowedKeys = prevStatus.AllowedKeys[:prepOp.KeyIndex] + prevStatus.LastChild = prepOp.OpCid + } + + store.entries[prepOp.OpCid] = &OpEntry{ + DID: prepOp.DID, + CreatedAt: prepOp.CreatedAt, + Nullified: false, + LastChild: "", + AllowedKeys: prepOp.Op.EquivalentRotationKeys(), + Op: prepOp.Op, + OpCid: prepOp.OpCid, + } + + // Update head + store.head[prepOp.DID] = prepOp.OpCid + } + + return nil +}