Skip to content
Merged
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
200 changes: 11 additions & 189 deletions log.go
Original file line number Diff line number Diff line change
@@ -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"`
Expand Down Expand Up @@ -213,53 +51,37 @@ func VerifyOpLog(entries []LogEntry) error {
}

did := entries[0].DID
lvc := newLogValidationContext()
mos := NewMemOpStore()
ctx := context.Background()

for _, oe := range entries {
if oe.DID != did {
return fmt.Errorf("inconsistent DID")
}
// 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 {
return err
}
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
}
Expand All @@ -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
}
Expand Down
15 changes: 15 additions & 0 deletions operation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -528,3 +542,4 @@ func (oe *OpEnum) AsOperation() Operation {
return nil
}
}

12 changes: 6 additions & 6 deletions operation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,34 +110,34 @@ 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")

}

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) {
Expand Down
Loading