From 1d2065ada30a02e55c507897e6d9f7b334882577 Mon Sep 17 00:00:00 2001 From: Jodson Graves Date: Sun, 12 Jul 2026 21:39:23 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(record):=20Merkle=20proof=20shape=20?= =?UTF-8?q?=E2=80=94=20RFC-6962=20tree=20replaces=20the=20linear=20fold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Executes the Merkle-now decision (Jodson, 2026-07-12), the stack plan's highest-rework item, inside the only cheap window it will ever have: no durable logs, no external consumers, no live witnesses. - Head is now the RFC-6962 Merkle tree head over leaf IDs (leaves keep drops/leaf/v0; interior nodes carry drops/node/v1 — the 0x00/0x01 discipline in canon-domain form, so leaves never replay as nodes). - Inclusion proofs are audit paths: ~log2(size) hashes however large the log grows. This is the open-problem-8 floor made real — an entry-level phone verifies its covenant in a million-entry log with ~20 hashes. - Consistency proofs are RFC-6962 subproofs; Log.ProveConsistency serves the witness's extension evidence, and Witness.Countersign consumes the proof instead of raw leaves. - LogID moves to its own drops/logid/v1 tag (the chain-fold arity is gone); checkpoint and witness payloads bump to v1. Entry/leaf/content stay v0 — byte-identical derivations keep their tags. - Exhaustive property suite: every leaf of every tree size to 33, every consistency pair to 33, tamper/truncation/padding/replay rejection, and the Log-level tie-in over a real appended log. The un-checkpointed-order residual is unchanged (exactly CT's), and the witness amnesia residual is unchanged; neither is papered over. Signed-off-by: Jodson Graves --- internal/record/checkpoint.go | 8 +- internal/record/doc.go | 46 +++++----- internal/record/entry.go | 12 ++- internal/record/log.go | 73 ++++++++------- internal/record/log_test.go | 79 ++++++++-------- internal/record/tree.go | 150 ++++++++++++++++++++++++++++++ internal/record/tree_test.go | 158 ++++++++++++++++++++++++++++++++ internal/record/verify.go | 62 +++++++------ internal/record/witness.go | 15 +-- internal/record/witness_test.go | 6 +- 10 files changed, 472 insertions(+), 137 deletions(-) create mode 100644 internal/record/tree.go create mode 100644 internal/record/tree_test.go diff --git a/internal/record/checkpoint.go b/internal/record/checkpoint.go index 94c9d0f..569b484 100644 --- a/internal/record/checkpoint.go +++ b/internal/record/checkpoint.go @@ -10,14 +10,16 @@ import ( // domainCheckpoint tags checkpoint signing payloads; an operator's // checkpoint signature is never valid under any other tag, so it can never // pose as a member seal or a witness countersignature. -const domainCheckpoint = "drops/checkpoint/v0" +const domainCheckpoint = "drops/checkpoint/v1" // Checkpoint is an operator's signed, monotonic commitment to its log head — -// the Certificate Transparency signed-tree-head analogue for a linear chain. +// the Certificate Transparency signed tree head. v1: Head is the RFC-6962 +// Merkle tree head over the leaf IDs (the v0 linear fold is gone, decided +// 2026-07-12 before any durable log existed). type Checkpoint struct { Log Hash // LogID of the operator's log Size uint64 // number of entries committed - Head Hash // chain hash after entry Size-1 (fold seeded with Log); the LogID seed when Size == 0 + Head Hash // Merkle tree head over leaves [0, Size); the LogID seed when Size == 0 IssuedAt time.Time // UTC Signature []byte // ed25519 by the operator; excluded from CanonicalBytes } diff --git a/internal/record/doc.go b/internal/record/doc.go index 7b11fd8..b144143 100644 --- a/internal/record/doc.go +++ b/internal/record/doc.go @@ -1,7 +1,7 @@ // Package record is Drops — Cloudy's dialog-sealed, append-only, witnessed // record: the durable account of what two members agreed to, kept as -// per-operator hash-chained logs whose integrity any member can verify -// offline. Drops is the product name (and the domain-tag namespace, drops/*); +// per-operator Merkle-tree logs (RFC 6962 shape) whose integrity any member +// can verify offline with logarithmic-size proofs. Drops is the product name (and the domain-tag namespace, drops/*); // record stays the Go package name per the 2026-07-09 naming decision. It is a // JFA member-economy layer the substrate coordination protocol does not // define, and the member-facing analogue of the protocol's anchor/ package: @@ -55,21 +55,21 @@ // is an ordinary new Entry whose Corrects field references the entry it // corrects while removing nothing. Runtime second: OpenLog replays and // cryptographically re-verifies the entire store (seals, log binding, -// corrections, duplicates, chain fold), rejecting invalid, foreign, -// duplicate, and dangling-correction entries — but the ORDER of independent -// un-checkpointed entries is NOT fixed by replay alone: a store with two -// such entries swapped replays cleanly to a different head. That order is -// protected by the next rung, and the gap is exactly CT's residual. Append -// rejects a nonzero Corrects that names no in-log entry. Detectable third: -// operator-signed monotonic Checkpoints plus +// corrections, duplicates) and rebuilds the Merkle tree, rejecting invalid, +// foreign, duplicate, and dangling-correction entries — but the ORDER of +// independent un-checkpointed entries is NOT fixed by replay alone: a store +// with two such entries swapped replays cleanly to a different head. That +// order is protected by the next rung, and the gap is exactly CT's residual. +// Append rejects a nonzero Corrects that names no in-log entry. Detectable +// third: operator-signed monotonic Checkpoints plus // VerifyConsistency make any rewrite of checkpointed history // cryptographically visible to anyone holding an older checkpoint; the // checkpoint pair is itself portable fork evidence. // // The record MUST NOT be a single global ledger over unrelated exchanges. // Structural: a Log is constructed from exactly one operator key, and its -// derived identity LogID(operator) seeds the chain fold, so every position -// is scoped to one log. Each Entry carries its LogID INSIDE the dual-signed +// derived identity LogID(operator) is the empty log's head and the scope of +// every checkpoint, so every position is scoped to one log. Each Entry carries its LogID INSIDE the dual-signed // bytes, so a sealed covenant can never migrate or be replayed into another // operator's log, and Checkpoint.Verify binds cp.Log == LogID(operator) so // commitments cannot be replayed either. The package exports no merge, no @@ -110,27 +110,29 @@ // agreements distinct and makes double-appends detectable. // // Signing bytes are canonical, domain-separated, and UTC. All signing and -// hashing payloads are built with the protocol's canon package under the six -// distinct tags below; canon.Time normalizes every signed instant to UTC -// nanoseconds and drops monotonic and location components. +// hashing payloads are built with the protocol's canon package under the +// seven distinct tags below; canon.Time normalizes every signed instant to +// UTC nanoseconds and drops monotonic and location components. // // # Domain tags // // Every hash and signature in the package is computed over canonical bytes -// beginning with one of six distinct, unexported domain tags: +// beginning with one of seven distinct, unexported domain tags: // // drops/content/v0 HashContent digests // drops/entry/v0 Entry signing payloads (both seals) // drops/leaf/v0 Entry.ID leaf hashes -// drops/chain/v0 the chain derivation: LogID seed and fold steps -// drops/checkpoint/v0 Checkpoint signing payloads -// drops/witness/v0 witness countersignature payloads +// drops/logid/v1 LogID derivation (the empty log's head) +// drops/node/v1 interior Merkle tree nodes +// drops/checkpoint/v1 Checkpoint signing payloads +// drops/witness/v1 witness countersignature payloads // // so no artifact is transferable between message types, between a hash role -// and a signature role, or back into sohocloud-protocol messages. The chain -// tag covers the two arities of the one chain derivation — the single-field -// seed (LogID over the operator key) and the two-field fold step — which -// canon's length prefixes keep disjoint. +// and a signature role, or back into sohocloud-protocol messages. Leaves and +// interior nodes carry different tags — the RFC 6962 0x00/0x01 discipline in +// canon form — so a leaf can never be replayed as a node or vice versa. The +// v1 tags mark the 2026-07-12 Merkle decision (proof shape settled before +// any durable log existed); the v0 chain tag is retired, not reused. // // # Named residuals // diff --git a/internal/record/entry.go b/internal/record/entry.go index 2bb4944..49ad5fd 100644 --- a/internal/record/entry.go +++ b/internal/record/entry.go @@ -39,11 +39,17 @@ func HashContent(content []byte) Hash { return Hash(sha256.Sum256(canon.New(domainContent).Bytes(content).Sum())) } +// domainLogID tags the LogID derivation — a log's identity, derived from +// its operator key, never chosen text; it is the empty log's head and scopes +// every entry and checkpoint. v1: the v0 chain-fold arity is gone (the tree +// replaced it), so the derivation carries its own single-purpose tag. +const domainLogID = "drops/logid/v1" + // LogID derives a log's identity from its operator's public key — derived, -// never chosen, so it cannot carry text and cannot be squatted; it seeds the -// log's hash chain and scopes every entry and checkpoint. +// never chosen, so it cannot carry text and cannot be squatted; it is the +// head of the empty log and scopes every entry and checkpoint. func LogID(operator ed25519.PublicKey) Hash { - return Hash(sha256.Sum256(canon.New(domainChain).Bytes(operator).Sum())) + return Hash(sha256.Sum256(canon.New(domainLogID).Bytes(operator).Sum())) } // Entry is one dialog-sealed covenant between two members: fixed-size diff --git a/internal/record/log.go b/internal/record/log.go index 482fd04..d2d1bc1 100644 --- a/internal/record/log.go +++ b/internal/record/log.go @@ -2,25 +2,11 @@ package record import ( "crypto/ed25519" - "crypto/sha256" "errors" "fmt" "time" - - "github.com/NTARI-RAND/sohocloud-protocol/canon" ) -// domainChain tags the one chain derivation in both its arities: the -// single-field seed (LogID over the operator key) and the two-field fold -// step (previous head, leaf). Canon's length prefixes keep the two shapes -// disjoint, and neither is a signature payload. -const domainChain = "drops/chain/v0" - -// chainStep folds one leaf hash into the running chain head. -func chainStep(prev, leaf Hash) Hash { - return Hash(sha256.Sum256(canon.New(domainChain).Bytes(prev[:]).Bytes(leaf[:]).Sum())) -} - // Store is the minimal append-only persistence surface: update and delete // are absent verbs, not forbidden ones. Log never trusts a Store — chains // are re-verified on open. @@ -73,16 +59,16 @@ func (s *MemStore) Len() (uint64, error) { return uint64(len(s.entries)), nil } -// Log is ONE operator's record: a LogID-seeded hash chain over dialog-sealed -// entries. No package-level log, no merge, no cross-log query or ordering -// exists anywhere in the surface. The operator's entire power is assigning -// sequence numbers: it holds no key that can produce a member seal, so it -// can order covenants but never author, amend, or remove one. +// Log is ONE operator's record: an RFC-6962-shaped Merkle tree over +// dialog-sealed entries, identified by LogID. No package-level log, no +// merge, no cross-log query or ordering exists anywhere in the surface. The +// operator's entire power is assigning sequence numbers: it holds no key +// that can produce a member seal, so it can order covenants but never +// author, amend, or remove one. type Log struct { id Hash store Store - heads []Hash // heads[i] is the chain head before entry i; heads[len(leaves)] is the current head - leafs []Hash // leaf IDs in sequence order + leafs []Hash // leaf IDs (Entry.ID) in sequence order — the tree's leaves index map[Hash]uint64 // leaf ID -> sequence, for dedup and Corrects resolution } @@ -99,11 +85,9 @@ func OpenLog(operator ed25519.PublicKey, s Store) (*Log, error) { if s == nil { return nil, errors.New("record: nil store") } - id := LogID(operator) l := &Log{ - id: id, + id: LogID(operator), store: s, - heads: []Hash{id}, index: make(map[Hash]uint64), } n, err := s.Len() @@ -145,12 +129,10 @@ func (l *Log) check(e Entry) (Hash, error) { return leaf, nil } -// admit folds an already-checked leaf into the in-memory chain state. +// admit adds an already-checked leaf to the in-memory tree state. func (l *Log) admit(leaf Hash) { - seq := uint64(len(l.leafs)) - l.index[leaf] = seq + l.index[leaf] = uint64(len(l.leafs)) l.leafs = append(l.leafs, leaf) - l.heads = append(l.heads, chainStep(l.heads[seq], leaf)) } // Append admits e only if e.Verify() passes, e.Log equals this log's LogID, @@ -170,33 +152,56 @@ func (l *Log) Append(e Entry) (uint64, error) { return seq, nil } -// Checkpoint returns the unsigned checkpoint over the current head at UTC -// instant at; the operator seals it with Checkpoint.Sign. Sizes are +// Checkpoint returns the unsigned checkpoint over the current tree head at +// UTC instant at; the operator seals it with Checkpoint.Sign. Sizes are // monotonic because the log only appends. The head of an empty log is the -// LogID seed. +// LogID seed; otherwise it is the Merkle tree head over the leaf IDs. func (l *Log) Checkpoint(at time.Time) Checkpoint { size := uint64(len(l.leafs)) + head := l.id + if size > 0 { + head = mth(l.leafs) + } return Checkpoint{ Log: l.id, Size: size, - Head: l.heads[size], + Head: head, IssuedAt: at, } } // Prove returns the inclusion proof for the entry at seq relative to the // current head; issue the matching Checkpoint in the same quiescent state. +// The proof is an RFC-6962 audit path: ~log2(size) sibling hashes, small +// enough to hold and verify on an entry-level device regardless of how +// large the log grows. func (l *Log) Prove(seq uint64) (Proof, error) { size := uint64(len(l.leafs)) if seq >= size { return Proof{}, fmt.Errorf("record: sequence %d out of range (size %d)", seq, size) } return Proof{ - Prior: l.heads[seq], - Links: append([]Hash(nil), l.leafs[seq+1:]...), + Seq: seq, + Path: provePath(seq, l.leafs), }, nil } +// ProveConsistency returns the RFC-6962 consistency proof showing the +// current tree extends the tree that had `size` entries — the extension +// evidence a witness needs before countersigning a newer checkpoint. A size +// of zero needs no proof (everything extends the empty log), and the +// current size yields the empty proof (a tree extends itself). +func (l *Log) ProveConsistency(size uint64) ([]Hash, error) { + current := uint64(len(l.leafs)) + if size > current { + return nil, fmt.Errorf("record: size %d exceeds log size %d", size, current) + } + if size == 0 || size == current { + return []Hash{}, nil + } + return proveConsistency(size, l.leafs), nil +} + // LeavesSince returns the leaf hashes of entries at positions // [size, current size) — the extension evidence a witness or member needs // for VerifyConsistency. diff --git a/internal/record/log_test.go b/internal/record/log_test.go index 062f7e1..168c0ca 100644 --- a/internal/record/log_test.go +++ b/internal/record/log_test.go @@ -302,8 +302,8 @@ func TestInclusionExhaustive(t *testing.T) { if !VerifyInclusion(entries[seq], p, tc.cp, op.pub) { t.Fatalf("inclusion of entry %d under size-%d checkpoint must verify", seq, tc.cp.Size) } - if got := tc.cp.Size - 1 - uint64(len(p.Links)); got != uint64(seq) { - t.Fatalf("proof position = %d, want %d", got, seq) + if p.Seq != uint64(seq) { + t.Fatalf("proof position = %d, want %d", p.Seq, seq) } } } @@ -317,18 +317,23 @@ func TestInclusionExhaustive(t *testing.T) { if err := half.Seal(a.priv); err != nil { t.Fatalf("Seal: %v", err) } + dishonestLeaves := make([]Hash, 0, 6) + for _, e := range entries { + dishonestLeaves = append(dishonestLeaves, e.ID()) + } + dishonestLeaves = append(dishonestLeaves, half.ID()) dishonest := Checkpoint{ Log: id, Size: cp5.Size + 1, - Head: chainStep(cp5.Head, half.ID()), + Head: mth(dishonestLeaves), IssuedAt: testInstant, } dishonest.Sign(op.priv) if !dishonest.Verify(op.pub) { t.Fatal("setup: dishonest checkpoint should carry a valid operator signature") } - if VerifyInclusion(half, Proof{Prior: cp5.Head}, dishonest, op.pub) { - t.Fatal("a half-sealed entry must never verify as included, even hand-chained by the operator") + if VerifyInclusion(half, Proof{Seq: 5, Path: provePath(5, dishonestLeaves)}, dishonest, op.pub) { + t.Fatal("a half-sealed entry must never verify as included, even hand-treed by the operator") } // A foreign entry (another operator's log) never verifies here. @@ -337,17 +342,23 @@ func TestInclusionExhaustive(t *testing.T) { t.Fatal("a foreign-log entry must not verify as included") } - // Truncated links. - trunc := Proof{Prior: proofs5[0].Prior, Links: proofs5[0].Links[:len(proofs5[0].Links)-1]} + // Truncated audit path. + trunc := Proof{Seq: proofs5[0].Seq, Path: proofs5[0].Path[:len(proofs5[0].Path)-1]} if VerifyInclusion(entries[0], trunc, cp5, op.pub) { - t.Fatal("a truncated Links slice must not verify") + t.Fatal("a truncated audit path must not verify") + } + + // Tampered sibling hash. + tampered := Proof{Seq: proofs5[1].Seq, Path: append([]Hash(nil), proofs5[1].Path...)} + tampered.Path[0][0] ^= 1 + if VerifyInclusion(entries[1], tampered, cp5, op.pub) { + t.Fatal("a tampered audit path must not verify") } - // Wrong prior. - wrongPrior := proofs5[1] - wrongPrior.Prior[0] ^= 1 - if VerifyInclusion(entries[1], wrongPrior, cp5, op.pub) { - t.Fatal("a wrong Prior must not verify") + // Wrong claimed position. + misplaced := Proof{Seq: proofs5[1].Seq + 1, Path: proofs5[1].Path} + if VerifyInclusion(entries[1], misplaced, cp5, op.pub) { + t.Fatal("a proof at the wrong position must not verify") } // Wrong operator key. @@ -408,25 +419,24 @@ func TestConsistency(t *testing.T) { } cp5 := sign(l.Checkpoint(testInstant)) - links, err := l.LeavesSince(2) + links, err := l.ProveConsistency(2) if err != nil { - t.Fatalf("LeavesSince(2): %v", err) + t.Fatalf("ProveConsistency(2): %v", err) } if !VerifyConsistency(cp2, cp5, links, op.pub) { t.Fatal("honest extension from size 2 to 5 must verify") } - all, err := l.LeavesSince(0) - if err != nil { - t.Fatalf("LeavesSince(0): %v", err) + if !VerifyConsistency(cp0, cp5, nil, op.pub) { + t.Fatal("consistency from the empty-log checkpoint needs no proof") } - if !VerifyConsistency(cp0, cp5, all, op.pub) { - t.Fatal("consistency from the empty-log checkpoint must accept the full leaf sequence") + if VerifyConsistency(cp0, cp5, links, op.pub) { + t.Fatal("a non-empty proof against the empty-log checkpoint must not verify") } if VerifyConsistency(cp5, cp2, nil, op.pub) { t.Fatal("newer.Size < older.Size must never verify") } if VerifyConsistency(cp5, cp2, links, op.pub) { - t.Fatal("newer.Size < older.Size must never verify regardless of links") + t.Fatal("newer.Size < older.Size must never verify regardless of proof") } // Fork: the operator rewrites entry 1 (inside cp2's history) and @@ -446,18 +456,22 @@ func TestConsistency(t *testing.T) { } } cpFork5 := sign(lf.Checkpoint(testInstant)) - forkSince2, err := lf.LeavesSince(2) + forkSince2, err := lf.ProveConsistency(2) if err != nil { - t.Fatalf("LeavesSince: %v", err) + t.Fatalf("ProveConsistency: %v", err) + } + garbage := make([]Hash, len(links)) + for i := range garbage { + garbage[i] = HashContent([]byte{byte(200 + i)}) } for name, candidate := range map[string][]Hash{ - "fork LeavesSince(2)": forkSince2, - "honest LeavesSince(2)": links, - "empty": nil, - "full fork leaves": mustLeaves(t, lf, 0), + "fork ProveConsistency(2)": forkSince2, + "honest ProveConsistency(2)": links, + "empty": nil, + "garbage hashes": garbage, } { if VerifyConsistency(cp2, cpFork5, candidate, op.pub) { - t.Fatalf("fork rewriting checkpointed history must fail VerifyConsistency (links: %s)", name) + t.Fatalf("fork rewriting checkpointed history must fail VerifyConsistency (proof: %s)", name) } } // Same-size fork against the honest size-5 checkpoint. @@ -465,12 +479,3 @@ func TestConsistency(t *testing.T) { t.Fatal("a same-size fork must fail VerifyConsistency") } } - -func mustLeaves(t *testing.T, l *Log, since uint64) []Hash { - t.Helper() - leaves, err := l.LeavesSince(since) - if err != nil { - t.Fatalf("LeavesSince(%d): %v", since, err) - } - return leaves -} diff --git a/internal/record/tree.go b/internal/record/tree.go new file mode 100644 index 0000000..d126a89 --- /dev/null +++ b/internal/record/tree.go @@ -0,0 +1,150 @@ +package record + +import ( + "crypto/sha256" + + "github.com/NTARI-RAND/sohocloud-protocol/canon" +) + +// domainNode tags interior Merkle nodes. Leaves keep their own domain +// (drops/leaf/v0, via Entry.ID), so a leaf hash can never be replayed as an +// interior node or vice versa — the RFC 6962 0x00/0x01 prefix discipline, +// expressed with canon domain tags like everything else in the package. +const domainNode = "drops/node/v1" + +// nodeHash combines two subtree hashes into their parent. +func nodeHash(left, right Hash) Hash { + return Hash(sha256.Sum256(canon.New(domainNode).Bytes(left[:]).Bytes(right[:]).Sum())) +} + +// largestPow2LT returns the largest power of two strictly less than n; +// n must be >= 2. +func largestPow2LT(n uint64) uint64 { + k := uint64(1) + for k<<1 < n { + k <<= 1 + } + return k +} + +// mth is the Merkle tree head over leaves (RFC 6962 shape): a single leaf is +// its own subtree hash, and a larger tree splits at the largest power of two +// strictly below its size. mth requires at least one leaf; the empty log's +// head is the LogID seed, handled by Log.Checkpoint. +func mth(leaves []Hash) Hash { + if len(leaves) == 1 { + return leaves[0] + } + k := largestPow2LT(uint64(len(leaves))) + return nodeHash(mth(leaves[:k]), mth(leaves[k:])) +} + +// provePath returns the audit path for the leaf at index m within leaves: +// the sibling subtree hashes from leaf-adjacent first to root-adjacent last. +func provePath(m uint64, leaves []Hash) []Hash { + if len(leaves) == 1 { + return nil + } + k := largestPow2LT(uint64(len(leaves))) + if m < k { + return append(provePath(m, leaves[:k]), mth(leaves[k:])) + } + return append(provePath(m-k, leaves[k:]), mth(leaves[:k])) +} + +// rootFromPath recomputes the tree head implied by placing leaf at index seq +// of a size-entry tree with the given audit path; ok is false when the path +// length does not match the tree shape. Nothing in the path is trusted — +// every hash is recomputed. +func rootFromPath(leaf Hash, seq, size uint64, path []Hash) (Hash, bool) { + if size == 0 || seq >= size { + return Hash{}, false + } + if size == 1 { + if len(path) != 0 { + return Hash{}, false + } + return leaf, true + } + if len(path) == 0 { + return Hash{}, false + } + k := largestPow2LT(size) + sibling := path[len(path)-1] + rest := path[:len(path)-1] + if seq < k { + sub, ok := rootFromPath(leaf, seq, k, rest) + if !ok { + return Hash{}, false + } + return nodeHash(sub, sibling), true + } + sub, ok := rootFromPath(leaf, seq-k, size-k, rest) + if !ok { + return Hash{}, false + } + return nodeHash(sibling, sub), true +} + +// proveConsistency returns the RFC 6962 consistency proof between the tree +// over leaves[:m] and the tree over all leaves (m entries -> n entries, +// 0 < m < n <= len(leaves)). +func proveConsistency(m uint64, leaves []Hash) []Hash { + return subProof(m, leaves, true) +} + +func subProof(m uint64, leaves []Hash, complete bool) []Hash { + n := uint64(len(leaves)) + if m == n { + if complete { + // The old tree is exactly this subtree; the verifier already + // holds its hash (the older checkpoint's head). + return nil + } + return []Hash{mth(leaves)} + } + k := largestPow2LT(n) + if m <= k { + return append(subProof(m, leaves[:k], complete), mth(leaves[k:])) + } + return append(subProof(m-k, leaves[k:], false), mth(leaves[:k])) +} + +// consRoots recomputes, from a consistency proof, the (older, newer) tree +// heads it implies. seed is the older checkpoint's head, standing in for the +// omitted subtree hash along the complete-prefix spine (RFC 6962's b=true). +// The proof is consumed root-adjacent end first; ok is false on any shape +// mismatch. +func consRoots(m, n uint64, complete bool, proof []Hash, seed Hash) (older, newer Hash, rest []Hash, ok bool) { + if m == n { + if complete { + return seed, seed, proof, true + } + if len(proof) == 0 { + return Hash{}, Hash{}, nil, false + } + h := proof[len(proof)-1] + return h, h, proof[:len(proof)-1], true + } + if m > n || len(proof) == 0 { + return Hash{}, Hash{}, nil, false + } + k := largestPow2LT(n) + last := proof[len(proof)-1] + rest = proof[:len(proof)-1] + if m <= k { + oldR, newR, remaining, subOK := consRoots(m, k, complete, rest, seed) + if !subOK { + return Hash{}, Hash{}, nil, false + } + // last is the hash of the new entries' subtree (leaves[k:n]); the + // old tree lies entirely in the left subtree. + return oldR, nodeHash(newR, last), remaining, true + } + oldR, newR, remaining, subOK := consRoots(m-k, n-k, false, rest, seed) + if !subOK { + return Hash{}, Hash{}, nil, false + } + // last is the full left subtree (leaves[:k]), shared by both trees. + return nodeHash(last, oldR), nodeHash(last, newR), remaining, true +} diff --git a/internal/record/tree_test.go b/internal/record/tree_test.go new file mode 100644 index 0000000..73d8d80 --- /dev/null +++ b/internal/record/tree_test.go @@ -0,0 +1,158 @@ +package record + +import ( + "fmt" + "testing" +) + +// syntheticLeaves returns n distinct deterministic leaf hashes. +func syntheticLeaves(n int) []Hash { + leaves := make([]Hash, n) + for i := range leaves { + leaves[i] = HashContent([]byte(fmt.Sprintf("leaf-%d", i))) + } + return leaves +} + +// TestMerkleInclusionExhaustive proves every leaf of every tree size up to 33 +// (crossing several power-of-two boundaries) against the tree head, and +// rejects every single-bit tamper of position, path, and leaf. +func TestMerkleInclusionExhaustive(t *testing.T) { + for n := 1; n <= 33; n++ { + leaves := syntheticLeaves(n) + root := mth(leaves) + for m := 0; m < n; m++ { + path := provePath(uint64(m), leaves) + got, ok := rootFromPath(leaves[m], uint64(m), uint64(n), path) + if !ok || got != root { + t.Fatalf("n=%d m=%d: honest audit path does not recompute the head", n, m) + } + // Wrong position never verifies (every other position). + for wrong := 0; wrong < n; wrong++ { + if wrong == m { + continue + } + if got, ok := rootFromPath(leaves[m], uint64(wrong), uint64(n), path); ok && got == root { + t.Fatalf("n=%d m=%d: path verified at wrong position %d", n, m, wrong) + } + } + // Tampered path element never verifies. + if len(path) > 0 { + bad := append([]Hash(nil), path...) + bad[0][0] ^= 1 + if got, ok := rootFromPath(leaves[m], uint64(m), uint64(n), bad); ok && got == root { + t.Fatalf("n=%d m=%d: tampered path verified", n, m) + } + } + // Wrong leaf never verifies. + wrongLeaf := HashContent([]byte("not a leaf")) + if got, ok := rootFromPath(wrongLeaf, uint64(m), uint64(n), path); ok && got == root { + t.Fatalf("n=%d m=%d: foreign leaf verified", n, m) + } + // Truncated and padded paths never verify. + if len(path) > 0 { + if got, ok := rootFromPath(leaves[m], uint64(m), uint64(n), path[:len(path)-1]); ok && got == root { + t.Fatalf("n=%d m=%d: truncated path verified", n, m) + } + } + padded := append(append([]Hash(nil), path...), Hash{}) + if got, ok := rootFromPath(leaves[m], uint64(m), uint64(n), padded); ok && got == root { + t.Fatalf("n=%d m=%d: padded path verified", n, m) + } + } + } +} + +// TestMerkleConsistencyExhaustive proves every (older, newer) size pair up to +// 33 and rejects tampers, prefix rewrites, and cross-pair replays. +func TestMerkleConsistencyExhaustive(t *testing.T) { + const max = 33 + leaves := syntheticLeaves(max) + roots := make([]Hash, max+1) // roots[k] = MTH(leaves[:k]), k >= 1 + for k := 1; k <= max; k++ { + roots[k] = mth(leaves[:k]) + } + verify := func(m, n uint64, oldRoot, newRoot Hash, proof []Hash) bool { + oldR, newR, rest, ok := consRoots(m, n, true, proof, oldRoot) + return ok && len(rest) == 0 && oldR == oldRoot && newR == newRoot + } + for n := 2; n <= max; n++ { + for m := 1; m < n; m++ { + proof := proveConsistency(uint64(m), leaves[:n]) + if !verify(uint64(m), uint64(n), roots[m], roots[n], proof) { + t.Fatalf("m=%d n=%d: honest consistency proof rejected", m, n) + } + // Tampered proof element. + if len(proof) > 0 { + bad := append([]Hash(nil), proof...) + bad[len(bad)/2][0] ^= 1 + if verify(uint64(m), uint64(n), roots[m], roots[n], bad) { + t.Fatalf("m=%d n=%d: tampered consistency proof accepted", m, n) + } + } + // A rewritten prefix (different old root) must not verify with any + // honest proof for this pair. + forged := roots[m] + forged[0] ^= 1 + if verify(uint64(m), uint64(n), forged, roots[n], proof) { + t.Fatalf("m=%d n=%d: rewritten prefix accepted", m, n) + } + // A different new root must not verify. + forgedNew := roots[n] + forgedNew[0] ^= 1 + if verify(uint64(m), uint64(n), roots[m], forgedNew, proof) { + t.Fatalf("m=%d n=%d: forged new head accepted", m, n) + } + } + } + // Cross-pair replay: a proof for (m1,n) never verifies as (m2,n), m1 != m2. + n := uint64(21) + for m1 := uint64(1); m1 < n; m1++ { + proof := proveConsistency(m1, leaves[:n]) + for m2 := uint64(1); m2 < n; m2++ { + if m1 == m2 { + continue + } + if verify(m2, n, roots[m2], roots[n], proof) { + t.Fatalf("proof for m=%d replayed as m=%d (n=%d)", m1, m2, n) + } + } + } +} + +// TestLogProveConsistencyMatchesTree ties Log.ProveConsistency to the tree +// primitives through the public API, over a real appended log. +func TestLogProveConsistencyMatchesTree(t *testing.T) { + op := newParty(t) + a, b := newParty(t), newParty(t) + id := LogID(op.pub) + l, err := OpenLog(op.pub, NewMemStore()) + if err != nil { + t.Fatalf("OpenLog: %v", err) + } + sign := func(cp Checkpoint) Checkpoint { + cp.Sign(op.priv) + return cp + } + var cps []Checkpoint + cps = append(cps, sign(l.Checkpoint(testInstant))) + for i := 0; i < 9; i++ { + e := sealedEntry(t, id, a, b, contentN(byte(40+i)), Hash{}, testInstant) + if _, err := l.Append(e); err != nil { + t.Fatalf("Append: %v", err) + } + cps = append(cps, sign(l.Checkpoint(testInstant))) + } + for older := 0; older <= 9; older++ { + proof, err := l.ProveConsistency(uint64(older)) + if err != nil { + t.Fatalf("ProveConsistency(%d): %v", older, err) + } + if !VerifyConsistency(cps[older], cps[9], proof, op.pub) { + t.Fatalf("consistency %d -> 9 rejected", older) + } + } + if _, err := l.ProveConsistency(10); err == nil { + t.Fatal("ProveConsistency beyond the log size must error") + } +} diff --git a/internal/record/verify.go b/internal/record/verify.go index 1008703..23ae15c 100644 --- a/internal/record/verify.go +++ b/internal/record/verify.go @@ -5,19 +5,22 @@ import ( ) // Proof is what a member keeps beside an entry to later prove its inclusion -// under a checkpoint, offline and without the operator. Nothing in it is -// trusted: verification recomputes every hash. +// under a checkpoint, offline and without the operator: the entry's sequence +// number and its RFC-6962 audit path — ~log2(size) hashes however large the +// log grows, which is what makes verification feasible on an entry-level +// device (the open-problem-8 floor). Nothing in it is trusted: verification +// recomputes every hash. type Proof struct { - Prior Hash // chain head immediately before the entry (the LogID seed for the first entry) - Links []Hash // leaf hashes (Entry.ID) of every later entry up to the checkpointed head + Seq uint64 // the entry's position in the log + Path []Hash // sibling subtree hashes, leaf-adjacent first, root-adjacent last } // VerifyInclusion reports whether e is a fully sealed covenant committed at -// position cp.Size-1-len(p.Links) of cp's log: it requires e.Verify(), -// cp.Verify(operator), e.Log == cp.Log, and that folding e.ID() over p.Prior -// and p.Links yields cp.Head. One call checks everything; there is no -// half-verification to forget. It does NOT prove non-inclusion or liveness: -// an operator that never appended the entry simply cannot produce a proof. +// position p.Seq of cp's log: it requires e.Verify(), cp.Verify(operator), +// e.Log == cp.Log, and that the audit path recomputes cp.Head from e.ID(). +// One call checks everything; there is no half-verification to forget. It +// does NOT prove non-inclusion or liveness: an operator that never appended +// the entry simply cannot produce a proof. func VerifyInclusion(e Entry, p Proof, cp Checkpoint, operator ed25519.PublicKey) bool { if !e.Verify() { return false @@ -28,36 +31,35 @@ func VerifyInclusion(e Entry, p Proof, cp Checkpoint, operator ed25519.PublicKey if e.Log != cp.Log { return false } - if cp.Size < uint64(len(p.Links))+1 { - return false - } - h := chainStep(p.Prior, e.ID()) - for _, link := range p.Links { - h = chainStep(h, link) - } - return h == cp.Head + root, ok := rootFromPath(e.ID(), p.Seq, cp.Size, p.Path) + return ok && root == cp.Head } // VerifyConsistency reports whether newer extends older without rewrite: -// both checkpoints verify under operator, same Log, -// newer.Size == older.Size+len(links), and folding links over older.Head -// yields newer.Head. Failure against two operator-signed checkpoints is -// portable fork evidence — this is how any member or witness detects a -// silent edit or deletion. It proves nothing about entries the older -// checkpoint never covered. -func VerifyConsistency(older, newer Checkpoint, links []Hash, operator ed25519.PublicKey) bool { +// both checkpoints verify under operator, same Log, and the RFC-6962 +// consistency proof recomputes BOTH heads — older.Head from its components +// and newer.Head from the same components plus the extension. Failure +// against two operator-signed checkpoints is portable fork evidence — this +// is how any member or witness detects a silent edit or deletion. It proves +// nothing about entries the older checkpoint never covered. +// +// The empty older log (Size 0) is consistent with anything on an empty +// proof; equal sizes are consistent only when the heads are equal. +func VerifyConsistency(older, newer Checkpoint, proof []Hash, operator ed25519.PublicKey) bool { if !older.Verify(operator) || !newer.Verify(operator) { return false } if older.Log != newer.Log { return false } - if newer.Size != older.Size+uint64(len(links)) { + switch { + case older.Size > newer.Size: return false + case older.Size == 0: + return len(proof) == 0 + case older.Size == newer.Size: + return len(proof) == 0 && older.Head == newer.Head } - h := older.Head - for _, link := range links { - h = chainStep(h, link) - } - return h == newer.Head + oldR, newR, rest, ok := consRoots(older.Size, newer.Size, true, proof, older.Head) + return ok && len(rest) == 0 && oldR == older.Head && newR == newer.Head } diff --git a/internal/record/witness.go b/internal/record/witness.go index a07783c..b7778c9 100644 --- a/internal/record/witness.go +++ b/internal/record/witness.go @@ -11,7 +11,7 @@ import ( // domainWitness tags witness countersignature payloads, distinct from the // checkpoint tag, so an operator signature can never pose as a witness // cosignature or vice versa. -const domainWitness = "drops/witness/v0" +const domainWitness = "drops/witness/v1" // witnessPayload is the canonical payload a witness countersigns: the // checkpoint's canonical bytes wrapped under the witness domain tag. @@ -78,11 +78,12 @@ func (w *Witness) Key() ed25519.PublicKey { // Countersign verifies cp under operator, refuses when operator equals the // witness's own key, refuses any rollback or fork against the last -// checkpoint it cosigned for cp.Log (using sinceLeaves as extension evidence -// via VerifyConsistency), then returns its cosignature and remembers cp. -// Countersigning a rewritten head is the one thing a witness exists to never -// do, and here it is enforced, not advised. -func (w *Witness) Countersign(cp Checkpoint, operator ed25519.PublicKey, sinceLeaves []Hash) (Countersignature, error) { +// checkpoint it cosigned for cp.Log (using the RFC-6962 consistency proof as +// extension evidence via VerifyConsistency; Log.ProveConsistency produces +// it), then returns its cosignature and remembers cp. Countersigning a +// rewritten head is the one thing a witness exists to never do, and here it +// is enforced, not advised. +func (w *Witness) Countersign(cp Checkpoint, operator ed25519.PublicKey, consistencyProof []Hash) (Countersignature, error) { if len(w.priv) != ed25519.PrivateKeySize { return Countersignature{}, errors.New("record: witness key is malformed") } @@ -93,7 +94,7 @@ func (w *Witness) Countersign(cp Checkpoint, operator ed25519.PublicKey, sinceLe return Countersignature{}, errors.New("record: checkpoint does not verify under operator") } if prev, seen := w.last[cp.Log]; seen { - if !VerifyConsistency(prev, cp, sinceLeaves, operator) { + if !VerifyConsistency(prev, cp, consistencyProof, operator) { return Countersignature{}, errors.New("record: checkpoint is not a consistent extension of the last cosigned checkpoint (rollback or fork refused)") } } diff --git a/internal/record/witness_test.go b/internal/record/witness_test.go index 5b9f5d9..0344a29 100644 --- a/internal/record/witness_test.go +++ b/internal/record/witness_test.go @@ -40,7 +40,11 @@ func newWitnessFixture(t *testing.T) witnessFixture { } } f.cp5 = sign(l.Checkpoint(testInstant)) - f.links2to5 = mustLeaves(t, l, 2) + proof, err := l.ProveConsistency(2) + if err != nil { + t.Fatalf("ProveConsistency(2): %v", err) + } + f.links2to5 = proof fork := f.cp2 fork.Head[0] ^= 1 From 9742665554cc625d78b442628c9757edef178439 Mon Sep 17 00:00:00 2001 From: Jodson Graves Date: Sun, 12 Jul 2026 21:51:01 -0400 Subject: [PATCH 2/2] feat(record): lifecycle witnessing, filing intake, witness relay, member witnesses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Part-IV plumbing, built as code with every stand-in labeled: - Lifecycle log: a second per-operator Merkle log (domain-disjoint from the dialog log at every derivation) whose leaves are claim transitions — file -> adjudicate -> resolve -> seal, monotonic per claim, filed-first, seal-only-after-resolve (a clock never force-seals; dwell is a readable fact, never a verdict). Checkpointed and countersigned by the same witness machinery via the new VerifyAs/CountersignAs generic bindings. - Filing intake: FilingCommitment (hash, type, timestamp, exchange ref — PII discipline at its hardest) + FilingReceipt, the witness's ONE write. Receipts carry an explicit independence label: the consumer API's in-process intake is operator-run and says so (independent=false), the stand-in until real intake witnesses run. - Consumer API: dispute filing now lodges the commitment at the intake BEFORE the registry acts (the operator-absent-at-birth ordering, kept in miniature); every dispute transition appends to the lifecycle log; new surfaces for lifecycle checkpoints/claims, drops inclusion proofs (member-verifiable offline, ~log2(n) hashes), and the consistency proofs witnesses need. - internal/relay: the witness relay — caches, relays, serves, fans out filings; decides nothing (which witnesses count, which checkpoint is official, and settlement all stay with readers); bypassable by design and tested as such. - internal/witnesskit + cmd/witnessd: membership-as-witnessing (the problem-8 lever). Any member runs a witness: poll, verify (log identity re-derived, never taken on the relay's word), countersign, host the one filing write. cmd/witness-relay is the thin relay shell. - Hermetic federation test: two member witnesses federate over the relay, the stand-in label DROPS, a forked checkpoint gathers no honest countersignature (refused by witnesses, not the relay), and filing fan-out yields two verifying, operator-independent receipts. Named residuals, unchanged and unhidden: witness amnesia (restart = trust-on-first-checkpoint), single-operator in-process intake labeling, and no adjudicator key in cloudyd (no ruling transitions producible). Signed-off-by: Jodson Graves --- cmd/witness-relay/main.go | 34 +++ cmd/witnessd/main.go | 69 ++++++ internal/consumerapi/api_slice2_test.go | 126 +++++++++- internal/consumerapi/disputes.go | 97 +++++++- internal/consumerapi/lifecycle_api.go | 194 +++++++++++++++ internal/consumerapi/server.go | 23 ++ internal/record/checkpoint.go | 20 +- internal/record/filing.go | 147 +++++++++++ internal/record/lifecycle.go | 317 ++++++++++++++++++++++++ internal/record/lifecycle_test.go | 238 ++++++++++++++++++ internal/record/verify.go | 8 +- internal/record/witness.go | 13 +- internal/relay/federation_test.go | 292 ++++++++++++++++++++++ internal/relay/relay.go | 229 +++++++++++++++++ internal/witnesskit/witnesskit.go | 263 ++++++++++++++++++++ 15 files changed, 2057 insertions(+), 13 deletions(-) create mode 100644 cmd/witness-relay/main.go create mode 100644 cmd/witnessd/main.go create mode 100644 internal/consumerapi/lifecycle_api.go create mode 100644 internal/record/filing.go create mode 100644 internal/record/lifecycle.go create mode 100644 internal/record/lifecycle_test.go create mode 100644 internal/relay/federation_test.go create mode 100644 internal/relay/relay.go create mode 100644 internal/witnesskit/witnesskit.go diff --git a/cmd/witness-relay/main.go b/cmd/witness-relay/main.go new file mode 100644 index 0000000..1f0a52b --- /dev/null +++ b/cmd/witness-relay/main.go @@ -0,0 +1,34 @@ +// Command witness-relay runs the witness relay: it caches operator +// checkpoints, collects witness countersignatures, serves assembled +// bundles, and fans filing commitments out to witness intakes. It decides +// nothing, ranks nothing, and gates nothing; everything it does remains +// possible without it (operators serve their own checkpoints; filers can +// hit intakes directly). Kill it and the record's guarantees are unchanged +// — only the convenience is gone. Its cache is in-memory and +// reconstructible by design. +package main + +import ( + "flag" + "log" + "net/http" + "strings" + + "github.com/NTARI-RAND/Cloudy/internal/relay" +) + +func main() { + addr := flag.String("addr", ":8090", "listen address") + intakes := flag.String("intakes", "", "comma-separated witness intake base URLs for filing fan-out") + flag.Parse() + + var urls []string + if *intakes != "" { + urls = strings.Split(*intakes, ",") + } + rl := relay.New(urls) + log.Printf("witness-relay: listening on %s; %d filing intakes configured; caches and serves, never adjudicates", *addr, len(urls)) + if err := http.ListenAndServe(*addr, rl.Handler()); err != nil { + log.Fatalf("witness-relay: %v", err) + } +} diff --git a/cmd/witnessd/main.go b/cmd/witnessd/main.go new file mode 100644 index 0000000..37497be --- /dev/null +++ b/cmd/witnessd/main.go @@ -0,0 +1,69 @@ +// Command witnessd is a MEMBER's witness daemon — membership-as-witnessing, +// runnable by anyone with a key and a connection: it polls a relay, refuses +// rollbacks and forks by construction, countersigns honest extensions, and +// hosts the filing intake (the one witness write). Two or more of these, +// run by independent members, are what turns every stand-in label in the +// stack into real federation. +// +// Named residual (the amnesia gap, open problem 2): this process's rollback +// memory starts empty, so its first sight of each log is +// trust-on-first-checkpoint. Run it long-lived; durable witness state is a +// deployment concern the record layer names rather than hides. +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "flag" + "log" + "net/http" + "os" + "time" + + "github.com/NTARI-RAND/Cloudy/internal/witnesskit" +) + +func main() { + relayURL := flag.String("relay", "http://localhost:8090", "witness relay base URL") + intakeAddr := flag.String("intake", ":8091", "listen address for the filing intake") + interval := flag.Duration("interval", 30*time.Second, "poll interval") + seedHex := flag.String("seed", "", "hex ed25519 seed (32 bytes); ephemeral when empty — ephemeral witnesses forget, use a persistent seed in anything but a demo") + flag.Parse() + + var priv ed25519.PrivateKey + if *seedHex == "" { + _, p, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + log.Fatalf("witnessd: generating key: %v", err) + } + priv = p + log.Printf("witnessd: EPHEMERAL key (amnesia on restart is total); pass -seed for anything beyond a demo") + } else { + seed, err := hex.DecodeString(*seedHex) + if err != nil || len(seed) != ed25519.SeedSize { + log.Fatalf("witnessd: -seed must be %d hex bytes", ed25519.SeedSize) + } + priv = ed25519.NewKeyFromSeed(seed) + } + + w := witnesskit.NewWorker(priv, *relayURL) + log.Printf("witnessd: witness key %s; polling %s every %s; filing intake on %s (the one write)", + hex.EncodeToString(w.Key()), *relayURL, *interval, *intakeAddr) + + go func() { + if err := http.ListenAndServe(*intakeAddr, w.IntakeHandler()); err != nil { + log.Printf("witnessd: intake server exited: %v", err) + os.Exit(1) + } + }() + for { + n, err := w.RunOnce() + if err != nil { + log.Printf("witnessd: poll: %v", err) + } else if n > 0 { + log.Printf("witnessd: countersigned %d log(s)", n) + } + time.Sleep(*interval) + } +} diff --git a/internal/consumerapi/api_slice2_test.go b/internal/consumerapi/api_slice2_test.go index 1beea40..d9ee333 100644 --- a/internal/consumerapi/api_slice2_test.go +++ b/internal/consumerapi/api_slice2_test.go @@ -282,7 +282,19 @@ func TestDisputeFileWithdrawLifecycle(t *testing.T) { if err := o.Sign(privA); err != nil { t.Fatal(err) } - rec, body := do(t, h, "POST", "/api/v1/disputes", openDisputeRequest{ + // The filing commitment is signed CLIENT-side over the claim the opening + // will create, and lodged at the intake before the registry acts. + wantID := o.ID() + typeHash := sha256.Sum256([]byte("trade-harm")) + commitment := record.FilingCommitment{ + Claim: record.Hash(wantID), + Exchange: record.Hash(ex), + TypeHash: record.Hash(typeHash), + At: o.OpenedAt, + Filer: pubA, + } + commitment.Sign(privA) + req := openDisputeRequest{ Complainant: hex.EncodeToString(pubA), Respondent: hex.EncodeToString(pubB), Exchange: id, @@ -290,15 +302,33 @@ func TestDisputeFileWithdrawLifecycle(t *testing.T) { Nonce: hex.EncodeToString(o.Nonce[:]), OpenedAt: o.OpenedAt.Format(time.RFC3339Nano), Signature: hex.EncodeToString(o.Signature), - }) + } + req.Filing.TypeHash = hex.EncodeToString(typeHash[:]) + req.Filing.At = o.OpenedAt.Format(time.RFC3339Nano) + req.Filing.Signature = hex.EncodeToString(commitment.Signature) + rec, body := do(t, h, "POST", "/api/v1/disputes", req) if rec.Code != http.StatusOK { t.Fatalf("open dispute: code %d body %s", rec.Code, rec.Body.String()) } dID, _ := body["dispute_id"].(string) - wantID := o.ID() if dID != hex.EncodeToString(wantID[:]) { t.Fatalf("dispute_id %q, want Opening.ID() %q", dID, hex.EncodeToString(wantID[:])) } + // The receipt is present and HONESTLY labeled: the intake runs in the + // operator's process, so it must say independent=false. + receipt, _ := body["filing_receipt"].(map[string]any) + if receipt == nil { + t.Fatal("open dispute must return the filing receipt") + } + if receipt["independent"] != false { + t.Fatal("an operator-run intake must label its receipts non-independent") + } + + // The lifecycle log shows the claim filed, as it happened. + rec, body = do(t, h, "GET", "/api/v1/lifecycle/claims/"+dID, nil) + if rec.Code != http.StatusOK || body["state"] != "filed" { + t.Fatalf("lifecycle claim after open: code %d state %v, want filed", rec.Code, body["state"]) + } rec, body = do(t, h, "GET", "/api/v1/disputes/"+dID, nil) if rec.Code != http.StatusOK || body["state"] != "open" { @@ -329,9 +359,99 @@ func TestDisputeFileWithdrawLifecycle(t *testing.T) { t.Fatalf("case after withdrawal: code %d state %v, want withdrawn", rec.Code, body["state"]) } + // The lifecycle log recorded the resolution as its own transition, and + // the lifecycle checkpoint (stand-in labeled) covers both transitions. + rec, body = do(t, h, "GET", "/api/v1/lifecycle/claims/"+dID, nil) + if rec.Code != http.StatusOK || body["state"] != "resolved" { + t.Fatalf("lifecycle claim after withdrawal: code %d state %v, want resolved", rec.Code, body["state"]) + } + if n := len(body["transitions"].([]any)); n != 2 { + t.Fatalf("lifecycle transitions = %d, want 2 (filed, resolved)", n) + } + rec, body = do(t, h, "GET", "/api/v1/lifecycle/checkpoints", nil) + if rec.Code != http.StatusOK || body["stand_in"] != true || body["size"].(float64) != 2 { + t.Fatalf("lifecycle checkpoint: code %d body %v, want stand_in=true size=2", rec.Code, body) + } + unknown := sha256.Sum256([]byte("no such case")) rec, _ = do(t, h, "GET", "/api/v1/disputes/"+hex.EncodeToString(unknown[:]), nil) if rec.Code != http.StatusNotFound { t.Fatalf("unknown dispute: code %d, want 404", rec.Code) } } + +// TestDropProofVerifiesOffline drives the full member-verification story: +// seal a dialog, fetch its proof and checkpoint, and verify inclusion with +// nothing but the record package's public verifier — no operator trust. +func TestDropProofVerifiesOffline(t *testing.T) { + h := newTestServer(t) + pubA, privA := key(1) + pubB, privB := key(2) + registerMember(t, h, pubA, privA) + registerMember(t, h, pubB, privB) + + // Seal via the API, keeping the client-side entry for verification. + rec, body := do(t, h, "GET", "/api/v1/drops/log", nil) + if rec.Code != http.StatusOK { + t.Fatalf("drops/log: %d", rec.Code) + } + logRaw, _ := hex.DecodeString(body["log_id"].(string)) + opKeyRaw, _ := hex.DecodeString(body["operator_key"].(string)) + var logID record.Hash + copy(logID[:], logRaw) + e, err := record.NewEntry(logID, pubA, pubB, record.HashContent([]byte("n")), record.Hash{}, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + _ = e.Seal(privA) + _ = e.Seal(privB) + rec, body = do(t, h, "POST", "/api/v1/drops", dropRequest{ + Log: body["log_id"].(string), + Proposer: hex.EncodeToString(pubA), + Acceptor: hex.EncodeToString(pubB), + Content: hex.EncodeToString(e.Content[:]), + Nonce: hex.EncodeToString(e.Nonce[:]), + SealedAt: e.SealedAt.Format(time.RFC3339Nano), + ProposerSeal: hex.EncodeToString(e.ProposerSeal), + AcceptorSeal: hex.EncodeToString(e.AcceptorSeal), + }) + if rec.Code != http.StatusOK { + t.Fatalf("POST drops: %d %s", rec.Code, rec.Body.String()) + } + id := body["id"].(string) + + rec, body = do(t, h, "GET", "/api/v1/drops/"+id+"/proof", nil) + if rec.Code != http.StatusOK { + t.Fatalf("proof: %d %s", rec.Code, rec.Body.String()) + } + var p record.Proof + p.Seq = uint64(body["seq"].(float64)) + for _, hstr := range body["path"].([]any) { + raw, _ := hex.DecodeString(hstr.(string)) + var hh record.Hash + copy(hh[:], raw) + p.Path = append(p.Path, hh) + } + cpBody := body["checkpoint"].(map[string]any) + var cp record.Checkpoint + lr, _ := hex.DecodeString(cpBody["log"].(string)) + hr, _ := hex.DecodeString(cpBody["head"].(string)) + copy(cp.Log[:], lr) + copy(cp.Head[:], hr) + cp.Size = uint64(cpBody["size"].(float64)) + cp.IssuedAt, err = time.Parse(time.RFC3339Nano, cpBody["issued_at"].(string)) + if err != nil { + t.Fatal(err) + } + cp.Signature, _ = hex.DecodeString(cpBody["signature"].(string)) + + if !record.VerifyInclusion(e, p, cp, ed25519.PublicKey(opKeyRaw)) { + t.Fatal("the served proof must verify offline against the served checkpoint and operator key") + } + // And the tamper story holds end to end. + bad := e + bad.Content[0] ^= 1 + if record.VerifyInclusion(bad, p, cp, ed25519.PublicKey(opKeyRaw)) { + t.Fatal("a tampered entry must not verify") + } +} diff --git a/internal/consumerapi/disputes.go b/internal/consumerapi/disputes.go index 2d2f9fb..3232b9f 100644 --- a/internal/consumerapi/disputes.go +++ b/internal/consumerapi/disputes.go @@ -1,10 +1,13 @@ package consumerapi import ( + "crypto/sha256" "encoding/json" "net/http" + "time" "github.com/NTARI-RAND/Cloudy/internal/dispute" + "github.com/NTARI-RAND/Cloudy/internal/record" ) type openDisputeRequest struct { @@ -15,6 +18,28 @@ type openDisputeRequest struct { Nonce string `json:"nonce"` // hex 32-byte client-drawn nonce OpenedAt string `json:"opened_at"` Signature string `json:"signature"` // hex ed25519 by the complainant + + // Filing is the complainant-signed FilingCommitment over the claim this + // opening will create: the structural fact lodged at the intake witness + // BEFORE the operator acts. Its fields commit to a hash, a type, a + // timestamp, and an exchange reference — never narrative or identity. + Filing struct { + TypeHash string `json:"type_hash"` // hex digest of the dispute-type label + At string `json:"at"` + Signature string `json:"signature"` // hex ed25519 by the complainant over the commitment + } `json:"filing"` +} + +type filingReceiptView struct { + Witness string `json:"witness"` + ReceivedAt string `json:"received_at"` + Signature string `json:"signature"` + // Independent is false whenever the intake witness is the operator — + // this process's permanent condition until real independent intake + // witnesses exist. False here means: the operator was NOT absent from + // this claim's birth, and the receipt proves intake ordering only + // against the operator's own honesty. Label, not decoration. + Independent bool `json:"independent"` } // handleOpenDispute admits one complainant-signed Opening. The Registry is @@ -71,6 +96,22 @@ func (s *Server) handleOpenDispute(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusBadRequest, "signature must be a 64-byte hex signature") return } + typeHash, ok := decodeHex32(req.Filing.TypeHash) + if !ok { + writeErr(w, http.StatusBadRequest, "filing.type_hash must be a 32-byte hex digest") + return + } + filedAt, ok := parseUTC(req.Filing.At) + if !ok { + writeErr(w, http.StatusBadRequest, "filing.at must be RFC3339") + return + } + filingSig, ok := decodeSig(req.Filing.Signature) + if !ok { + writeErr(w, http.StatusBadRequest, "filing.signature must be a 64-byte hex signature") + return + } + o := dispute.Opening{ Platform: s.platform, Complainant: complainant, @@ -81,14 +122,52 @@ func (s *Server) handleOpenDispute(w http.ResponseWriter, r *http.Request) { OpenedAt: openedAt, Signature: sig, } + claimID := o.ID() + + // Intake FIRST: the filing commitment is acknowledged before the + // operator's registry acts on the opening — the Part-IV ordering, kept + // even while both roles run in one process. + commitment := record.FilingCommitment{ + Claim: record.Hash(claimID), + Exchange: record.Hash(exchange), + TypeHash: record.Hash(typeHash), + At: filedAt, + Filer: complainant, + Signature: filingSig, + } + receipt, err := s.intake.Accept(commitment, time.Now().UTC()) + if err != nil { + writeErr(w, http.StatusBadRequest, "filing commitment refused: "+err.Error()) + return + } + s.mu.Lock() id, err := s.registry.Open(o) + if err == nil { + artifact := sha256.Sum256(o.CanonicalBytes()) + _, err = s.lifeLog.Append(record.Transition{ + Log: s.lifeID, + Claim: record.Hash(id), + Kind: record.KindFiled, + Artifact: record.Hash(artifact), + Exchange: record.Hash(exchange), + At: time.Now().UTC(), + }) + } s.mu.Unlock() if err != nil { writeErr(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"dispute_id": hx(id[:])}) + writeJSON(w, http.StatusOK, map[string]any{ + "dispute_id": hx(id[:]), + "filing_receipt": filingReceiptView{ + Witness: hx(receipt.Witness), + ReceivedAt: receipt.ReceivedAt.UTC().Format(time.RFC3339Nano), + Signature: hx(receipt.Signature), + Independent: receipt.IndependentOf(s.operatorPub), + }, + }) } type disputeView struct { @@ -163,6 +242,22 @@ func (s *Server) handleWithdrawDispute(w http.ResponseWriter, r *http.Request) { } s.mu.Lock() err := s.registry.Withdraw(wd) + if err == nil { + var c dispute.Case + c, cerr := s.registry.Case(dispute.DisputeID(id)) + if cerr == nil { + exchange := c.Exchange() + artifact := sha256.Sum256(wd.CanonicalBytes()) + _, err = s.lifeLog.Append(record.Transition{ + Log: s.lifeID, + Claim: record.Hash(id), + Kind: record.KindResolved, + Artifact: record.Hash(artifact), + Exchange: record.Hash(exchange), + At: time.Now().UTC(), + }) + } + } s.mu.Unlock() if err != nil { writeErr(w, http.StatusBadRequest, err.Error()) diff --git a/internal/consumerapi/lifecycle_api.go b/internal/consumerapi/lifecycle_api.go new file mode 100644 index 0000000..74f4649 --- /dev/null +++ b/internal/consumerapi/lifecycle_api.go @@ -0,0 +1,194 @@ +package consumerapi + +import ( + "fmt" + "net/http" + "time" + + "github.com/NTARI-RAND/Cloudy/internal/record" +) + +// handleLifecycleCheckpoints serves the operator's signed checkpoint over its +// claim-LIFECYCLE log — the "witnessed as it happens" half of Part IV. Like +// the dialog checkpoint surface it carries the honest stand-in label until +// two or more independent witnesses countersign (Phase-3 federation). +func (s *Server) handleLifecycleCheckpoints(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + cp := s.lifeLog.Checkpoint(time.Now().UTC()) + cp.Sign(s.operatorPriv) + s.mu.Unlock() + wc := record.WitnessedCheckpoint{Checkpoint: cp} + writeJSON(w, http.StatusOK, checkpointResponse{ + Log: hx(cp.Log[:]), + Size: cp.Size, + Head: hx(cp.Head[:]), + IssuedAt: cp.IssuedAt.UTC().Format(time.RFC3339Nano), + Signature: hx(cp.Signature), + Countersignatures: []string{}, + StandIn: wc.StandIn(s.operatorPub), + }) +} + +type transitionView struct { + Seq uint64 `json:"seq"` + Kind string `json:"kind"` // filed | adjudicated | resolved | sealed + Artifact string `json:"artifact"` + Exchange string `json:"exchange"` + At string `json:"at"` +} + +type lifecycleClaimResponse struct { + ClaimID string `json:"claim_id"` + State string `json:"state"` + FiledAt string `json:"filed_at,omitempty"` + Transitions []transitionView `json:"transitions"` + // Dwell is deliberately NOT computed here: an unresolved claim's age is + // a readable fact the reader derives from filed_at; the surface renders + // facts, never verdicts (flag-not-finding discipline). +} + +// handleLifecycleClaim serves one claim's transition history — structural +// facts only: kinds, digests, references, instants. The narrative stays +// member-local; the reader may compute dwell but is handed no judgment. +func (s *Server) handleLifecycleClaim(w http.ResponseWriter, r *http.Request) { + id, ok := decodeHex32(r.PathValue("id")) + if !ok { + writeErr(w, http.StatusBadRequest, "id must be a 32-byte hex claim ID") + return + } + claim := record.Hash(id) + s.mu.Lock() + seqs := s.lifeLog.Claim(claim) + state, seen := s.lifeLog.State(claim) + views := make([]transitionView, 0, len(seqs)) + var filedAt string + for _, seq := range seqs { + tr, err := s.lifeStore.At(seq) + if err != nil { + continue + } + v := transitionView{ + Seq: seq, + Kind: tr.Kind.String(), + Artifact: hx(tr.Artifact[:]), + Exchange: hx(tr.Exchange[:]), + At: tr.At.UTC().Format(time.RFC3339Nano), + } + if tr.Kind == record.KindFiled { + filedAt = v.At + } + views = append(views, v) + } + s.mu.Unlock() + if !seen { + writeErr(w, http.StatusNotFound, "no lifecycle transitions for this claim") + return + } + writeJSON(w, http.StatusOK, lifecycleClaimResponse{ + ClaimID: r.PathValue("id"), + State: state.String(), + FiledAt: filedAt, + Transitions: views, + }) +} + +type consistencyResponse struct { + From uint64 `json:"from"` + Size uint64 `json:"size"` + Proof []string `json:"proof"` // RFC-6962 consistency proof hashes +} + +// handleDropsConsistency serves the extension evidence a witness needs +// before countersigning a newer dialog-log checkpoint: the consistency +// proof from its last-seen size to the current tree. +func (s *Server) handleDropsConsistency(w http.ResponseWriter, r *http.Request) { + s.serveConsistency(w, r, func(from uint64) ([]record.Hash, uint64, error) { + s.mu.Lock() + defer s.mu.Unlock() + proof, err := s.opLog.ProveConsistency(from) + return proof, s.opLog.Checkpoint(time.Now().UTC()).Size, err + }) +} + +// handleLifecycleConsistency is the lifecycle-log twin. +func (s *Server) handleLifecycleConsistency(w http.ResponseWriter, r *http.Request) { + s.serveConsistency(w, r, func(from uint64) ([]record.Hash, uint64, error) { + s.mu.Lock() + defer s.mu.Unlock() + proof, err := s.lifeLog.ProveConsistency(from) + return proof, s.lifeLog.Checkpoint(time.Now().UTC()).Size, err + }) +} + +func (s *Server) serveConsistency(w http.ResponseWriter, r *http.Request, prove func(uint64) ([]record.Hash, uint64, error)) { + var from uint64 + if q := r.URL.Query().Get("from"); q != "" { + if _, err := fmt.Sscanf(q, "%d", &from); err != nil { + writeErr(w, http.StatusBadRequest, "from must be a non-negative integer") + return + } + } + proof, size, err := prove(from) + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + out := consistencyResponse{From: from, Size: size, Proof: make([]string, len(proof))} + for i, h := range proof { + out.Proof[i] = hx(h[:]) + } + writeJSON(w, http.StatusOK, out) +} + +type proofResponse struct { + ID string `json:"id"` + Seq uint64 `json:"seq"` + Path []string `json:"path"` // sibling hashes, leaf-adjacent first + Checkpoint checkpointResponse `json:"checkpoint"` +} + +// handleDropProof serves the RFC-6962 inclusion proof for one sealed dialog +// under a fresh signed checkpoint — everything a member needs to verify, +// offline, that their covenant is in the log: ~log2(size) hashes, feasible +// on an entry-level device however large the log grows. +func (s *Server) handleDropProof(w http.ResponseWriter, r *http.Request) { + id, ok := decodeHex32(r.PathValue("id")) + if !ok { + writeErr(w, http.StatusBadRequest, "id must be a 32-byte hex leaf ID") + return + } + s.mu.Lock() + seq, found := s.index[record.Hash(id)] + var p record.Proof + var cp record.Checkpoint + var err error + if found { + p, err = s.opLog.Prove(seq) + cp = s.opLog.Checkpoint(time.Now().UTC()) + cp.Sign(s.operatorPriv) + } + s.mu.Unlock() + if !found || err != nil { + writeErr(w, http.StatusNotFound, "no entry with this leaf ID") + return + } + path := make([]string, len(p.Path)) + for i, h := range p.Path { + path[i] = hx(h[:]) + } + wc := record.WitnessedCheckpoint{Checkpoint: cp} + writeJSON(w, http.StatusOK, proofResponse{ + ID: r.PathValue("id"), + Seq: p.Seq, + Path: path, + Checkpoint: checkpointResponse{ + Log: hx(cp.Log[:]), + Size: cp.Size, + Head: hx(cp.Head[:]), + IssuedAt: cp.IssuedAt.UTC().Format(time.RFC3339Nano), + Signature: hx(cp.Signature), + Countersignatures: []string{}, + StandIn: wc.StandIn(s.operatorPub), + }, + }) +} diff --git a/internal/consumerapi/server.go b/internal/consumerapi/server.go index 8bea3d1..5811017 100644 --- a/internal/consumerapi/server.go +++ b/internal/consumerapi/server.go @@ -89,6 +89,17 @@ type Server struct { ecoStore *economy.MemStore registry *dispute.Registry + // The claim-lifecycle log (Part IV): every dispute transition commits + // here as it happens, checkpointed and witnessable exactly like the + // dialog log. The filing intake is the ONE witness write; in this + // process it is operator-run, so every receipt it issues is honestly + // labeled non-independent — the stand-in until the witness relay and + // real independent intake witnesses exist (Phase 3). + lifeLog *record.LifecycleLog + lifeStore *record.MemTransitionStore + lifeID record.Hash + intake *record.FilingIntake + // manifest maps a commons artifact id (hex) to the Locker hashes of its // member-local narrative — the front-end-local index that lets a reader // fetch narrative the commons deliberately does not carry. @@ -261,6 +272,13 @@ func NewServer(platform string) (*Server, error) { if err != nil { return nil, err } + s.lifeStore = record.NewMemTransitionStore() + s.lifeLog, err = record.OpenLifecycleLog(operatorPub, s.lifeStore) + if err != nil { + return nil, err + } + s.lifeID = record.LifecycleLogID(operatorPub) + s.intake = record.NewFilingIntake(operatorPriv) return s, nil } @@ -288,6 +306,11 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/v1/disputes", s.handleOpenDispute) mux.HandleFunc("GET /api/v1/disputes/{id}", s.handleGetDispute) mux.HandleFunc("POST /api/v1/disputes/{id}/withdraw", s.handleWithdrawDispute) + mux.HandleFunc("GET /api/v1/lifecycle/checkpoints", s.handleLifecycleCheckpoints) + mux.HandleFunc("GET /api/v1/drops/checkpoints/consistency", s.handleDropsConsistency) + mux.HandleFunc("GET /api/v1/lifecycle/checkpoints/consistency", s.handleLifecycleConsistency) + mux.HandleFunc("GET /api/v1/lifecycle/claims/{id}", s.handleLifecycleClaim) + mux.HandleFunc("GET /api/v1/drops/{id}/proof", s.handleDropProof) return mux } diff --git a/internal/record/checkpoint.go b/internal/record/checkpoint.go index 569b484..1c09ca4 100644 --- a/internal/record/checkpoint.go +++ b/internal/record/checkpoint.go @@ -41,11 +41,21 @@ func (c *Checkpoint) Sign(priv ed25519.PrivateKey) { } // Verify reports whether Signature is a valid operator signature AND -// c.Log == LogID(operator), so a checkpoint can never be replayed against -// another operator's log. +// c.Log == LogID(operator) — the operator's DIALOG log. A checkpoint can +// never be replayed against another operator's log, and (because the +// lifecycle log has a domain-distinct identity) never against the same +// operator's lifecycle log either. func (c Checkpoint) Verify(operator ed25519.PublicKey) bool { - return len(operator) == ed25519.PublicKeySize && + return c.VerifyAs(operator, LogID(operator)) +} + +// VerifyAs reports whether Signature is a valid signature by signer AND +// c.Log equals the expected log identity — the generic binding used for the +// lifecycle log (VerifyAs(operator, LifecycleLogID(operator))) and any +// future per-operator log kind. +func (c Checkpoint) VerifyAs(signer ed25519.PublicKey, wantLog Hash) bool { + return len(signer) == ed25519.PublicKeySize && len(c.Signature) == ed25519.SignatureSize && - c.Log == LogID(operator) && - ed25519.Verify(operator, c.CanonicalBytes(), c.Signature) + c.Log == wantLog && + ed25519.Verify(signer, c.CanonicalBytes(), c.Signature) } diff --git a/internal/record/filing.go b/internal/record/filing.go new file mode 100644 index 0000000..333e647 --- /dev/null +++ b/internal/record/filing.go @@ -0,0 +1,147 @@ +package record + +import ( + "crypto/ed25519" + "errors" + "time" + + "github.com/NTARI-RAND/sohocloud-protocol/canon" +) + +// Filing intake — the deliberate, bounded exception to the witness-as- +// observer role (architecture, Record invariants): a harm claim's filing +// commitment is made AT AN INDEPENDENT WITNESS, upstream of the operator +// that will adjudicate it, so the operator is absent from its own claim's +// birth — it cannot add filing friction, shape a claim, or shed it before +// the claim exists in the record. The witness accepts exactly ONE write — +// claim-creation — and nothing else; it MUST NOT become a second log for any +// other event. +// +// PII discipline binds hardest here: a commitment is a claim id, an exchange +// reference, a TYPE hash, an instant, and the filer's key — the structural +// fact that a claim of a kind exists. The narrative and the identities stay +// front-end-local and erasable. +const ( + domainFiling = "drops/lifecycle-filing/v1" + domainFilingReceipt = "drops/lifecycle-filing-receipt/v1" +) + +// FilingCommitment is the filer-signed structural fact lodged at a witness +// before the operator acts. +type FilingCommitment struct { + Claim Hash // the claim id the dispute layer will use (opaque here) + Exchange Hash // leaf ID of the disputed sealed dialog + TypeHash Hash // digest of the dispute-type label; a hash, never text + At time.Time // UTC instant of filing + Filer ed25519.PublicKey // who files; signs the commitment + Signature []byte // ed25519 by Filer; excluded from CanonicalBytes +} + +// CanonicalBytes returns the deterministic signing payload. +func (f FilingCommitment) CanonicalBytes() []byte { + b := canon.New(domainFiling) + b.Bytes(f.Claim[:]) + b.Bytes(f.Exchange[:]) + b.Bytes(f.TypeHash[:]) + b.Time(f.At) + b.Bytes(f.Filer) + return b.Sum() +} + +// Sign seals the commitment with the filer's key. +func (f *FilingCommitment) Sign(priv ed25519.PrivateKey) { + f.Signature = ed25519.Sign(priv, f.CanonicalBytes()) +} + +// Verify reports whether the commitment is well-formed and filer-signed. +func (f FilingCommitment) Verify() bool { + return len(f.Filer) == ed25519.PublicKeySize && + len(f.Signature) == ed25519.SignatureSize && + f.Claim != zeroHash && + f.Exchange != zeroHash && + ed25519.Verify(f.Filer, f.CanonicalBytes(), f.Signature) +} + +// FilingReceipt is the witness's countersigned acknowledgment that the +// commitment existed at ReceivedAt — the evidence an operator cannot erase a +// filing it never saw or backdate one it sat on. A receipt confers no +// authority: it proves intake, not merit. +type FilingReceipt struct { + Commitment FilingCommitment + Witness ed25519.PublicKey // the intake witness + ReceivedAt time.Time // UTC instant of intake at the witness + Signature []byte // ed25519 by Witness over the receipt payload +} + +// CanonicalBytes returns the witness's signing payload: the commitment's +// canonical bytes wrapped with the intake instant under the receipt domain. +func (r FilingReceipt) CanonicalBytes() []byte { + b := canon.New(domainFilingReceipt) + b.Bytes(r.Commitment.CanonicalBytes()) + b.Bytes(r.Witness) + b.Time(r.ReceivedAt) + return b.Sum() +} + +// Verify reports whether the receipt is a valid witness acknowledgment of a +// valid commitment, and that the witness is not the filer (an intake witness +// acknowledging its own filing proves nothing). +func (r FilingReceipt) Verify() bool { + if !r.Commitment.Verify() { + return false + } + if len(r.Witness) != ed25519.PublicKeySize || len(r.Signature) != ed25519.SignatureSize { + return false + } + if string(r.Witness) == string(r.Commitment.Filer) { + return false + } + return ed25519.Verify(r.Witness, r.CanonicalBytes(), r.Signature) +} + +// IndependentOf reports whether the receipt's witness is independent of the +// given operator key — the label a surface must carry when it is not: an +// operator-run intake is a stand-in, exactly like a single-witness +// checkpoint, and must present itself as one. +func (r FilingReceipt) IndependentOf(operator ed25519.PublicKey) bool { + return len(operator) == ed25519.PublicKeySize && string(r.Witness) != string(operator) +} + +// FilingIntake is the witness-side acceptor of the ONE write. It is +// stateless beyond its key: durability of accepted receipts is the relay's +// concern (cache-and-serve), and the intake never reads, orders, or judges — +// it acknowledges existence at an instant. +type FilingIntake struct { + priv ed25519.PrivateKey + pub ed25519.PublicKey +} + +// NewFilingIntake returns an intake acceptor for the witness key. +func NewFilingIntake(priv ed25519.PrivateKey) *FilingIntake { + fi := &FilingIntake{priv: priv} + if len(priv) == ed25519.PrivateKeySize { + fi.pub = priv.Public().(ed25519.PublicKey) + } + return fi +} + +// Key returns the intake witness's public key. +func (fi *FilingIntake) Key() ed25519.PublicKey { return fi.pub } + +// Accept validates the commitment and returns the signed receipt. It is the +// witness's only verb here — there is no read-back, no veto beyond +// malformedness, and no second write. +func (fi *FilingIntake) Accept(f FilingCommitment, at time.Time) (FilingReceipt, error) { + if len(fi.priv) != ed25519.PrivateKeySize { + return FilingReceipt{}, errors.New("record: intake key is malformed") + } + if !f.Verify() { + return FilingReceipt{}, errors.New("record: filing commitment does not verify") + } + if string(f.Filer) == string(fi.pub) { + return FilingReceipt{}, errors.New("record: an intake witness never accepts its own filing") + } + r := FilingReceipt{Commitment: f, Witness: fi.pub, ReceivedAt: at} + r.Signature = ed25519.Sign(fi.priv, r.CanonicalBytes()) + return r, nil +} diff --git a/internal/record/lifecycle.go b/internal/record/lifecycle.go new file mode 100644 index 0000000..1312a02 --- /dev/null +++ b/internal/record/lifecycle.go @@ -0,0 +1,317 @@ +package record + +import ( + "crypto/ed25519" + "crypto/sha256" + "errors" + "fmt" + "time" + + "github.com/NTARI-RAND/sohocloud-protocol/canon" +) + +// The claim-lifecycle log. Part IV of the architecture: "the witnessed unit +// is the claim lifecycle, not only the sealed verdict" — a harm claim's +// filing, adjudication, resolution, and seal each commit to the witnessed +// record AS THEY HAPPEN, a sequence of monotonic, independently-witnessed +// transitions, never one terminal block delivered whole. +// +// The lifecycle log is a SECOND per-operator Merkle log, wholly disjoint +// from the dialog log by domain tags: its log identity, leaves, and +// checkpoint payloads all carry lifecycle-specific tags, so no lifecycle +// artifact can ever pose as a dialog artifact or vice versa. Transitions are +// operator-RECORDED structural facts (claim id, kind, artifact hash, +// exchange ref, instant) — no narrative, no party keys beyond what the +// dispute registry already carries, no PII. Their integrity comes from the +// same place the dialog log's does: operator-signed monotonic checkpoints, +// independently countersigned. The FILING transition additionally has an +// upstream twin — the FilingCommitment lodged at an independent witness +// BEFORE the operator acts (filing.go) — so an operator that never logs a +// filed claim is caught by cross-checking witness receipts against its +// lifecycle log, and an operator that files late is caught by the receipt's +// earlier instant. +const ( + domainLifeLogID = "drops/lifecycle-logid/v1" + domainLifeTransition = "drops/lifecycle-transition/v1" + domainLifeLeaf = "drops/lifecycle-leaf/v1" +) + +// LifecycleLogID derives the identity of an operator's lifecycle log — +// distinct by domain from the same operator's dialog LogID, so checkpoints +// can never be replayed between the two logs. +func LifecycleLogID(operator ed25519.PublicKey) Hash { + return Hash(sha256.Sum256(canon.New(domainLifeLogID).Bytes(operator).Sum())) +} + +// TransitionKind is a claim's position in the lifecycle pipeline. +type TransitionKind uint8 + +const ( + // KindFiled: the claim exists. Its upstream twin is the FilingCommitment + // at an independent witness; the operator's own Filed transition is the + // downstream acknowledgment. + KindFiled TransitionKind = iota + 1 + // KindAdjudicated: the operator's panel produced a ruling artifact. + KindAdjudicated + // KindResolved: the claim reached an outcome (ruling accepted, or the + // complainant withdrew). Dwell — the age of a claim not yet resolved — + // is read from the gap after KindFiled; a clock never force-seals it. + KindResolved + // KindSealed: the underlying dialog sealed with the claim answered. + KindSealed +) + +// String names the kind for legible surfaces. +func (k TransitionKind) String() string { + switch k { + case KindFiled: + return "filed" + case KindAdjudicated: + return "adjudicated" + case KindResolved: + return "resolved" + case KindSealed: + return "sealed" + } + return "invalid" +} + +// Transition is one structural fact about one claim: fixed-size hashes, a +// kind, and a UTC instant — no string field, no narrative, no identity +// beyond opaque references. The artifact hash commits to the dispute-layer +// artifact (opening, ruling, withdrawal) whose bytes live in the dispute +// registry's own append-only store; the lifecycle log holds only the digest. +type Transition struct { + Log Hash // LifecycleLogID of the one operator lifecycle log this may enter + Claim Hash // opaque claim id (the dispute layer's DisputeID, held by value) + Kind TransitionKind // filed | adjudicated | resolved | sealed + Artifact Hash // digest of the transition's artifact bytes; opaque here + Exchange Hash // leaf ID of the disputed sealed dialog + At time.Time // UTC instant the transition was recorded +} + +// CanonicalBytes returns the deterministic encoding of the transition. +func (t Transition) CanonicalBytes() []byte { + b := canon.New(domainLifeTransition) + b.Bytes(t.Log[:]) + b.Bytes(t.Claim[:]) + b.Uint64(uint64(t.Kind)) + b.Bytes(t.Artifact[:]) + b.Bytes(t.Exchange[:]) + b.Time(t.At) + return b.Sum() +} + +// ID returns the transition's leaf hash in the lifecycle tree. +func (t Transition) ID() Hash { + return Hash(sha256.Sum256(canon.New(domainLifeLeaf).Bytes(t.CanonicalBytes()).Sum())) +} + +// TransitionStore is the lifecycle log's append-only persistence surface; +// update and delete are absent verbs, exactly as in Store. +type TransitionStore interface { + Append(t Transition) error + At(seq uint64) (Transition, error) + Len() (uint64, error) +} + +// MemTransitionStore is the in-memory TransitionStore. +type MemTransitionStore struct { + transitions []Transition +} + +// NewMemTransitionStore returns an empty in-memory transition store. +func NewMemTransitionStore() *MemTransitionStore { + return &MemTransitionStore{} +} + +// Append implements TransitionStore. +func (s *MemTransitionStore) Append(t Transition) error { + s.transitions = append(s.transitions, t) + return nil +} + +// At implements TransitionStore. +func (s *MemTransitionStore) At(seq uint64) (Transition, error) { + if seq >= uint64(len(s.transitions)) { + return Transition{}, fmt.Errorf("record: transition %d out of range (len %d)", seq, len(s.transitions)) + } + return s.transitions[seq], nil +} + +// Len implements TransitionStore. +func (s *MemTransitionStore) Len() (uint64, error) { + return uint64(len(s.transitions)), nil +} + +// LifecycleLog is one operator's claim-lifecycle record: a Merkle tree over +// transition facts, with per-claim monotonic ordering enforced on admission. +// Like the dialog Log it exports no update, no delete, no merge, and no +// cross-log surface. +type LifecycleLog struct { + id Hash + store TransitionStore + leafs []Hash + index map[Hash]uint64 // transition leaf -> seq + state map[Hash]TransitionKind // claim -> furthest kind admitted + claim map[Hash][]uint64 // claim -> sequences, in order + kinds map[Hash]map[TransitionKind]int // claim -> kind -> count (adjudication may repeat) +} + +// OpenLifecycleLog opens the operator's lifecycle log over s, replaying and +// re-verifying every stored transition through the same admission gates +// Append applies. +func OpenLifecycleLog(operator ed25519.PublicKey, s TransitionStore) (*LifecycleLog, error) { + if len(operator) != ed25519.PublicKeySize { + return nil, errors.New("record: operator key is malformed") + } + if s == nil { + return nil, errors.New("record: nil transition store") + } + l := &LifecycleLog{ + id: LifecycleLogID(operator), + store: s, + index: make(map[Hash]uint64), + state: make(map[Hash]TransitionKind), + claim: make(map[Hash][]uint64), + kinds: make(map[Hash]map[TransitionKind]int), + } + n, err := s.Len() + if err != nil { + return nil, fmt.Errorf("record: reading transition store length: %w", err) + } + for seq := uint64(0); seq < n; seq++ { + t, err := s.At(seq) + if err != nil { + return nil, fmt.Errorf("record: reading stored transition %d: %w", seq, err) + } + if err := l.check(t); err != nil { + return nil, fmt.Errorf("record: stored transition %d does not re-verify: %w", seq, err) + } + l.admit(t) + } + return l, nil +} + +// check applies the admission gates without mutating state. +func (l *LifecycleLog) check(t Transition) error { + if t.Log != l.id { + return errors.New("record: transition is bound to a different lifecycle log") + } + if t.Kind < KindFiled || t.Kind > KindSealed { + return errors.New("record: invalid transition kind") + } + if t.Claim == zeroHash { + return errors.New("record: transition names no claim") + } + if t.Exchange == zeroHash { + return errors.New("record: transition names no exchange") + } + if _, dup := l.index[t.ID()]; dup { + return errors.New("record: transition already appended (duplicate leaf)") + } + last, seen := l.state[t.Claim] + switch { + case !seen: + if t.Kind != KindFiled { + return errors.New("record: a claim's first transition must be its filing") + } + case t.Kind == KindFiled: + return errors.New("record: a claim files exactly once") + case t.Kind == KindAdjudicated: + // Adjudication activity may recur (each ruling artifact is a new + // fact) but never after the claim resolved or sealed. + if last >= KindResolved { + return errors.New("record: adjudication after resolution is out of order") + } + case t.Kind <= last: + return errors.New("record: lifecycle transitions are monotonic per claim") + case t.Kind == KindSealed && last < KindResolved: + return errors.New("record: a claim seals only after it resolves — a clock never force-seals") + } + return nil +} + +// admit adds a checked transition to the in-memory state. +func (l *LifecycleLog) admit(t Transition) { + seq := uint64(len(l.leafs)) + leaf := t.ID() + l.index[leaf] = seq + l.leafs = append(l.leafs, leaf) + l.claim[t.Claim] = append(l.claim[t.Claim], seq) + if t.Kind > l.state[t.Claim] { + l.state[t.Claim] = t.Kind + } + kc := l.kinds[t.Claim] + if kc == nil { + kc = make(map[TransitionKind]int) + l.kinds[t.Claim] = kc + } + kc[t.Kind]++ +} + +// Append admits t through the same gates replay uses and persists it. +func (l *LifecycleLog) Append(t Transition) (uint64, error) { + if err := l.check(t); err != nil { + return 0, err + } + if err := l.store.Append(t); err != nil { + return 0, fmt.Errorf("record: persisting transition: %w", err) + } + seq := uint64(len(l.leafs)) + l.admit(t) + return seq, nil +} + +// Checkpoint returns the unsigned checkpoint over the lifecycle tree at UTC +// instant at. It is the SAME Checkpoint type the dialog log uses — a witness +// countersigns both the same way — but its Log field is the lifecycle log's +// identity and verifiers bind it with VerifyAs(operator, +// LifecycleLogID(operator)), so the two logs' checkpoints can never stand in +// for each other. +func (l *LifecycleLog) Checkpoint(at time.Time) Checkpoint { + size := uint64(len(l.leafs)) + head := l.id + if size > 0 { + head = mth(l.leafs) + } + return Checkpoint{Log: l.id, Size: size, Head: head, IssuedAt: at} +} + +// Prove returns the inclusion proof for the transition at seq. +func (l *LifecycleLog) Prove(seq uint64) (Proof, error) { + size := uint64(len(l.leafs)) + if seq >= size { + return Proof{}, fmt.Errorf("record: sequence %d out of range (size %d)", seq, size) + } + return Proof{Seq: seq, Path: provePath(seq, l.leafs)}, nil +} + +// ProveConsistency returns the consistency proof from `size` to the current +// tree — the witness's extension evidence, identical in shape to the dialog +// log's. +func (l *LifecycleLog) ProveConsistency(size uint64) ([]Hash, error) { + current := uint64(len(l.leafs)) + if size > current { + return nil, fmt.Errorf("record: size %d exceeds log size %d", size, current) + } + if size == 0 || size == current { + return []Hash{}, nil + } + return proveConsistency(size, l.leafs), nil +} + +// Claim returns the sequences of a claim's transitions in log order; the +// empty slice means the log has never seen the claim. +func (l *LifecycleLog) Claim(claim Hash) []uint64 { + return append([]uint64(nil), l.claim[claim]...) +} + +// State returns the furthest lifecycle kind a claim has reached; ok is false +// when the log has never seen the claim. An unresolved claim's DWELL — its +// age past filing — is a readable fact for any scan; it is never a verdict, +// and nothing here closes a claim by clock. +func (l *LifecycleLog) State(claim Hash) (TransitionKind, bool) { + k, ok := l.state[claim] + return k, ok +} diff --git a/internal/record/lifecycle_test.go b/internal/record/lifecycle_test.go new file mode 100644 index 0000000..76b3f3f --- /dev/null +++ b/internal/record/lifecycle_test.go @@ -0,0 +1,238 @@ +package record + +import ( + "testing" + "time" +) + +var lifeInstant = time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC) + +func lifeTransition(log Hash, claim byte, kind TransitionKind, at time.Time) Transition { + return Transition{ + Log: log, + Claim: HashContent([]byte{claim}), + Kind: kind, + Artifact: HashContent([]byte{claim, byte(kind)}), + Exchange: HashContent([]byte{claim, 0xEE}), + At: at, + } +} + +func TestLifecycleStateMachine(t *testing.T) { + op := newParty(t) + id := LifecycleLogID(op.pub) + l, err := OpenLifecycleLog(op.pub, NewMemTransitionStore()) + if err != nil { + t.Fatalf("OpenLifecycleLog: %v", err) + } + + // A claim's first transition must be its filing. + if _, err := l.Append(lifeTransition(id, 1, KindAdjudicated, lifeInstant)); err == nil { + t.Fatal("adjudication before filing must be refused") + } + if _, err := l.Append(lifeTransition(id, 1, KindFiled, lifeInstant)); err != nil { + t.Fatalf("filing: %v", err) + } + // A claim files exactly once. + if _, err := l.Append(lifeTransition(id, 1, KindFiled, lifeInstant.Add(time.Minute))); err == nil { + t.Fatal("double filing must be refused") + } + // Adjudication may recur before resolution. + if _, err := l.Append(lifeTransition(id, 1, KindAdjudicated, lifeInstant.Add(2*time.Minute))); err != nil { + t.Fatalf("first adjudication: %v", err) + } + if _, err := l.Append(lifeTransition(id, 1, KindAdjudicated, lifeInstant.Add(3*time.Minute))); err != nil { + t.Fatalf("second adjudication: %v", err) + } + // Seal before resolution must be refused — a clock never force-seals. + if _, err := l.Append(lifeTransition(id, 1, KindSealed, lifeInstant.Add(4*time.Minute))); err == nil { + t.Fatal("seal before resolution must be refused") + } + if _, err := l.Append(lifeTransition(id, 1, KindResolved, lifeInstant.Add(5*time.Minute))); err != nil { + t.Fatalf("resolution: %v", err) + } + // Adjudication after resolution must be refused. + if _, err := l.Append(lifeTransition(id, 1, KindAdjudicated, lifeInstant.Add(6*time.Minute))); err == nil { + t.Fatal("adjudication after resolution must be refused") + } + if _, err := l.Append(lifeTransition(id, 1, KindSealed, lifeInstant.Add(7*time.Minute))); err != nil { + t.Fatalf("seal: %v", err) + } + if k, ok := l.State(HashContent([]byte{1})); !ok || k != KindSealed { + t.Fatalf("state = %v ok=%v, want sealed", k, ok) + } + if got := len(l.Claim(HashContent([]byte{1}))); got != 5 { + t.Fatalf("claim transition count = %d, want 5", got) + } + + // A second claim's dwell is readable: filed, never resolved. + if _, err := l.Append(lifeTransition(id, 2, KindFiled, lifeInstant)); err != nil { + t.Fatalf("second claim filing: %v", err) + } + if k, ok := l.State(HashContent([]byte{2})); !ok || k != KindFiled { + t.Fatalf("unresolved claim state = %v, want filed (dwell readable, never force-sealed)", k) + } + + // Structural gates. + bad := lifeTransition(id, 3, KindFiled, lifeInstant) + bad.Claim = Hash{} + if _, err := l.Append(bad); err == nil { + t.Fatal("zero claim must be refused") + } + bad = lifeTransition(id, 3, KindFiled, lifeInstant) + bad.Exchange = Hash{} + if _, err := l.Append(bad); err == nil { + t.Fatal("zero exchange must be refused") + } + foreign := lifeTransition(LifecycleLogID(newParty(t).pub), 3, KindFiled, lifeInstant) + if _, err := l.Append(foreign); err == nil { + t.Fatal("foreign-log transition must be refused") + } + dup := lifeTransition(id, 2, KindFiled, lifeInstant) + if _, err := l.Append(dup); err == nil { + t.Fatal("duplicate transition (same claim files once) must be refused") + } + + // Replay re-verifies: the same store reopens to the same tree. + // (State-machine order is enforced on replay through the same gates.) + reopened, err := OpenLifecycleLog(op.pub, l.store.(*MemTransitionStore)) + if err != nil { + t.Fatalf("reopen: %v", err) + } + if reopened.Checkpoint(lifeInstant).Head != l.Checkpoint(lifeInstant).Head { + t.Fatal("replayed lifecycle log head differs") + } +} + +func TestLifecycleCheckpointDisjointFromDialog(t *testing.T) { + op := newParty(t) + a, b := newParty(t), newParty(t) + + dialog, err := OpenLog(op.pub, NewMemStore()) + if err != nil { + t.Fatalf("OpenLog: %v", err) + } + e := sealedEntry(t, LogID(op.pub), a, b, contentN(7), Hash{}, lifeInstant) + if _, err := dialog.Append(e); err != nil { + t.Fatalf("Append: %v", err) + } + life, err := OpenLifecycleLog(op.pub, NewMemTransitionStore()) + if err != nil { + t.Fatalf("OpenLifecycleLog: %v", err) + } + if _, err := life.Append(lifeTransition(LifecycleLogID(op.pub), 9, KindFiled, lifeInstant)); err != nil { + t.Fatalf("Append transition: %v", err) + } + + dcp := dialog.Checkpoint(lifeInstant) + dcp.Sign(op.priv) + lcp := life.Checkpoint(lifeInstant) + lcp.Sign(op.priv) + + if !dcp.Verify(op.pub) || !lcp.VerifyAs(op.pub, LifecycleLogID(op.pub)) { + t.Fatal("both checkpoints must verify under their own identities") + } + // Cross-binding is dead in both directions. + if dcp.VerifyAs(op.pub, LifecycleLogID(op.pub)) { + t.Fatal("a dialog checkpoint must never verify as a lifecycle checkpoint") + } + if lcp.Verify(op.pub) { + t.Fatal("a lifecycle checkpoint must never verify as a dialog checkpoint") + } + + // A witness serves both logs, keyed separately, and refuses cross-log use. + w := NewWitness(newParty(t).priv) + if _, err := w.CountersignAs(lcp, op.pub, LifecycleLogID(op.pub), nil); err != nil { + t.Fatalf("CountersignAs lifecycle: %v", err) + } + if _, err := w.Countersign(dcp, op.pub, nil); err != nil { + t.Fatalf("Countersign dialog: %v", err) + } + if _, err := w.CountersignAs(dcp, op.pub, LifecycleLogID(op.pub), nil); err == nil { + t.Fatal("a witness must refuse a dialog checkpoint presented as lifecycle") + } + + // Lifecycle consistency rides the same machinery. + if _, err := life.Append(lifeTransition(LifecycleLogID(op.pub), 9, KindResolved, lifeInstant.Add(time.Minute))); err != nil { + t.Fatalf("Append: %v", err) + } + lcp2 := life.Checkpoint(lifeInstant.Add(time.Minute)) + lcp2.Sign(op.priv) + proof, err := life.ProveConsistency(1) + if err != nil { + t.Fatalf("ProveConsistency: %v", err) + } + if !VerifyConsistencyAs(lcp, lcp2, proof, op.pub, LifecycleLogID(op.pub)) { + t.Fatal("lifecycle consistency must verify") + } + // Inclusion of a transition under the lifecycle checkpoint. + p, err := life.Prove(0) + if err != nil { + t.Fatalf("Prove: %v", err) + } + tr, err := life.store.At(0) + if err != nil { + t.Fatalf("At: %v", err) + } + root, ok := rootFromPath(tr.ID(), p.Seq, lcp2.Size, p.Path) + if !ok || root != lcp2.Head { + t.Fatal("lifecycle inclusion proof must recompute the head") + } +} + +func TestFilingIntake(t *testing.T) { + filer := newParty(t) + witness := newParty(t) + operator := newParty(t) + + f := FilingCommitment{ + Claim: HashContent([]byte("claim")), + Exchange: HashContent([]byte("exchange")), + TypeHash: HashContent([]byte("trade-harm")), + At: lifeInstant, + Filer: filer.pub, + } + f.Sign(filer.priv) + if !f.Verify() { + t.Fatal("signed commitment must verify") + } + + intake := NewFilingIntake(witness.priv) + r, err := intake.Accept(f, lifeInstant.Add(time.Second)) + if err != nil { + t.Fatalf("Accept: %v", err) + } + if !r.Verify() { + t.Fatal("receipt must verify") + } + if !r.IndependentOf(operator.pub) { + t.Fatal("a third-party witness is independent of the operator") + } + // The operator running its own intake is the labeled stand-in. + opIntake := NewFilingIntake(operator.priv) + r2, err := opIntake.Accept(f, lifeInstant.Add(time.Second)) + if err != nil { + t.Fatalf("Accept: %v", err) + } + if r2.IndependentOf(operator.pub) { + t.Fatal("an operator-run intake must label itself non-independent") + } + + // Tampering is caught. + bad := r + bad.Commitment.Exchange[0] ^= 1 + if bad.Verify() { + t.Fatal("tampered receipt must not verify") + } + // The intake refuses the filer's own key as witness. + selfIntake := NewFilingIntake(filer.priv) + if _, err := selfIntake.Accept(f, lifeInstant); err == nil { + t.Fatal("an intake witness never accepts its own filing") + } + // An unsigned commitment is refused. + unsigned := f + unsigned.Signature = nil + if _, err := intake.Accept(unsigned, lifeInstant); err == nil { + t.Fatal("an unsigned commitment must be refused") + } +} diff --git a/internal/record/verify.go b/internal/record/verify.go index 23ae15c..29494bd 100644 --- a/internal/record/verify.go +++ b/internal/record/verify.go @@ -46,7 +46,13 @@ func VerifyInclusion(e Entry, p Proof, cp Checkpoint, operator ed25519.PublicKey // The empty older log (Size 0) is consistent with anything on an empty // proof; equal sizes are consistent only when the heads are equal. func VerifyConsistency(older, newer Checkpoint, proof []Hash, operator ed25519.PublicKey) bool { - if !older.Verify(operator) || !newer.Verify(operator) { + return VerifyConsistencyAs(older, newer, proof, operator, LogID(operator)) +} + +// VerifyConsistencyAs is VerifyConsistency with an explicit log identity — +// used for the lifecycle log and any future per-operator log kind. +func VerifyConsistencyAs(older, newer Checkpoint, proof []Hash, signer ed25519.PublicKey, wantLog Hash) bool { + if !older.VerifyAs(signer, wantLog) || !newer.VerifyAs(signer, wantLog) { return false } if older.Log != newer.Log { diff --git a/internal/record/witness.go b/internal/record/witness.go index b7778c9..0f52b57 100644 --- a/internal/record/witness.go +++ b/internal/record/witness.go @@ -84,17 +84,24 @@ func (w *Witness) Key() ed25519.PublicKey { // rewritten head is the one thing a witness exists to never do, and here it // is enforced, not advised. func (w *Witness) Countersign(cp Checkpoint, operator ed25519.PublicKey, consistencyProof []Hash) (Countersignature, error) { + return w.CountersignAs(cp, operator, LogID(operator), consistencyProof) +} + +// CountersignAs is Countersign with an explicit log identity, so the same +// witness (and the same per-log rollback memory, keyed by log identity) +// serves dialog logs and lifecycle logs alike. +func (w *Witness) CountersignAs(cp Checkpoint, operator ed25519.PublicKey, wantLog Hash, consistencyProof []Hash) (Countersignature, error) { if len(w.priv) != ed25519.PrivateKeySize { return Countersignature{}, errors.New("record: witness key is malformed") } if bytes.Equal(operator, w.pub) { return Countersignature{}, errors.New("record: a witness never countersigns its own operator") } - if !cp.Verify(operator) { - return Countersignature{}, errors.New("record: checkpoint does not verify under operator") + if !cp.VerifyAs(operator, wantLog) { + return Countersignature{}, errors.New("record: checkpoint does not verify under operator for this log") } if prev, seen := w.last[cp.Log]; seen { - if !VerifyConsistency(prev, cp, consistencyProof, operator) { + if !VerifyConsistencyAs(prev, cp, consistencyProof, operator, wantLog) { return Countersignature{}, errors.New("record: checkpoint is not a consistent extension of the last cosigned checkpoint (rollback or fork refused)") } } diff --git a/internal/relay/federation_test.go b/internal/relay/federation_test.go new file mode 100644 index 0000000..ab07187 --- /dev/null +++ b/internal/relay/federation_test.go @@ -0,0 +1,292 @@ +package relay_test + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/NTARI-RAND/Cloudy/internal/record" + "github.com/NTARI-RAND/Cloudy/internal/relay" + "github.com/NTARI-RAND/Cloudy/internal/witnesskit" +) + +func key(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return pub, priv +} + +func sealed(t *testing.T, logID record.Hash, aPub ed25519.PublicKey, aPriv ed25519.PrivateKey, bPub ed25519.PublicKey, bPriv ed25519.PrivateKey, n byte) record.Entry { + t.Helper() + e, err := record.NewEntry(logID, aPub, bPub, record.HashContent([]byte{n}), record.Hash{}, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if err := e.Seal(aPriv); err != nil { + t.Fatal(err) + } + if err := e.Seal(bPriv); err != nil { + t.Fatal(err) + } + return e +} + +func postJSON(t *testing.T, url string, v any) *http.Response { + t.Helper() + body, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + resp, err := http.Post(url, "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + return resp +} + +// TestFederationDropsTheStandInLabel is the record layer's whole point run +// hermetically: an operator publishes checkpoints; two independent MEMBER +// witnesses (witnesskit workers) poll the relay, verify, refuse nothing +// honest, and countersign; the assembled bundle verifies with independence +// and the stand-in label drops. The relay decides nothing anywhere in the +// flow. "Federation is structurally cheap: an independent witness joins by +// appending a countersignature" — here are two, joining by exactly that. +func TestFederationDropsTheStandInLabel(t *testing.T) { + opPub, opPriv := key(t) + aPub, aPriv := key(t) + bPub, bPriv := key(t) + + // Operator side: a real dialog log with two sealed covenants. + log, err := record.OpenLog(opPub, record.NewMemStore()) + if err != nil { + t.Fatal(err) + } + if _, err := log.Append(sealed(t, record.LogID(opPub), aPub, aPriv, bPub, bPriv, 1)); err != nil { + t.Fatal(err) + } + + rl := relay.New(nil) + srv := httptest.NewServer(rl.Handler()) + defer srv.Close() + + publish := func(from uint64) record.Checkpoint { + cp := log.Checkpoint(time.Now().UTC()) + cp.Sign(opPriv) + proof, err := log.ProveConsistency(from) + if err != nil { + t.Fatal(err) + } + msg := witnesskit.EncodeCheckpoint(cp, opPub, "dialog", from, proof) + resp := postJSON(t, srv.URL+"/v1/logs/"+msg.Log+"/checkpoints", msg) + if resp.StatusCode != http.StatusOK { + t.Fatalf("publish: %d", resp.StatusCode) + } + resp.Body.Close() + return cp + } + cp1 := publish(0) + + // Two member witnesses join by running the boring worker. + w1 := witnesskit.NewWorker(mustPriv(t), srv.URL) + w2 := witnesskit.NewWorker(mustPriv(t), srv.URL) + for _, w := range []*witnesskit.Worker{w1, w2} { + n, err := w.RunOnce() + if err != nil || n != 1 { + t.Fatalf("RunOnce: n=%d err=%v", n, err) + } + } + + // The reader assembles the bundle and applies ITS OWN independence + // checks — the relay handed over everything unranked. + bundle := fetchBundle(t, srv.URL, hex.EncodeToString(cp1.Log[:])) + wc := reconstruct(t, bundle) + if !wc.Verify(opPub) { + t.Fatal("federated bundle must verify") + } + if wc.StandIn(opPub) { + t.Fatal("two independent member witnesses countersigned; the stand-in label must drop") + } + + // The log grows; witnesses countersign the extension (their rollback + // memory verifying consistency), and the new bundle federates too. + if _, err := log.Append(sealed(t, record.LogID(opPub), aPub, aPriv, bPub, bPriv, 2)); err != nil { + t.Fatal(err) + } + cp2 := publish(cp1.Size) + for _, w := range []*witnesskit.Worker{w1, w2} { + if n, err := w.RunOnce(); err != nil || n != 1 { + t.Fatalf("RunOnce after extension: n=%d err=%v", n, err) + } + } + bundle = fetchBundle(t, srv.URL, hex.EncodeToString(cp2.Log[:])) + wc = reconstruct(t, bundle) + if wc.Checkpoint.Size != 2 || !wc.Verify(opPub) || wc.StandIn(opPub) { + t.Fatal("extended checkpoint must federate the same way") + } + + // A rewriting operator is refused BY THE WITNESSES, not the relay: a + // forked checkpoint at the same size, published to the relay (which + // caches shape without judgment), gathers no honest countersignature. + fork := cp2 + fork.Head[0] ^= 1 + fork.Sign(opPriv) + forkMsg := witnesskit.EncodeCheckpoint(fork, opPub, "dialog", cp2.Size, nil) + forkMsg.Size = cp2.Size + 1 // lie about growth so the relay caches it + resp := postJSON(t, srv.URL+"/v1/logs/"+forkMsg.Log+"/checkpoints", forkMsg) + resp.Body.Close() + for _, w := range []*witnesskit.Worker{w1, w2} { + if n, _ := w.RunOnce(); n != 0 { + t.Fatal("a witness countersigned a fork; the one thing it exists to never do") + } + } +} + +// TestFilingFanOut: the relay forwards the one witness write to every +// intake and returns receipts; each receipt verifies and is independent of +// the operator. A filer without a relay hits an intake directly — same +// result, proving the relay is convenience, not dependency. +func TestFilingFanOut(t *testing.T) { + opPub, _ := key(t) + filerPub, filerPriv := key(t) + + w1 := witnesskit.NewWorker(mustPriv(t), "") + w2 := witnesskit.NewWorker(mustPriv(t), "") + i1 := httptest.NewServer(w1.IntakeHandler()) + defer i1.Close() + i2 := httptest.NewServer(w2.IntakeHandler()) + defer i2.Close() + + rl := relay.New([]string{i1.URL, i2.URL}) + srv := httptest.NewServer(rl.Handler()) + defer srv.Close() + + f := record.FilingCommitment{ + Claim: record.HashContent([]byte("claim")), + Exchange: record.HashContent([]byte("exchange")), + TypeHash: record.HashContent([]byte("trade-harm")), + At: time.Now().UTC(), + Filer: filerPub, + } + f.Sign(filerPriv) + msg := witnesskit.FilingMsg{ + Claim: hex.EncodeToString(f.Claim[:]), + Exchange: hex.EncodeToString(f.Exchange[:]), + TypeHash: hex.EncodeToString(f.TypeHash[:]), + At: f.At.Format(time.RFC3339Nano), + Filer: hex.EncodeToString(filerPub), + Signature: hex.EncodeToString(f.Signature), + } + + resp := postJSON(t, srv.URL+"/v1/filings", msg) + defer resp.Body.Close() + var out struct { + Receipts []witnesskit.ReceiptMsg `json:"receipts"` + Intakes int `json:"intakes"` + Failures int `json:"failures"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + if out.Intakes != 2 || out.Failures != 0 || len(out.Receipts) != 2 { + t.Fatalf("fan-out: %+v", out) + } + seen := map[string]bool{} + for _, r := range out.Receipts { + receipt := reconstructReceipt(t, f, r) + if !receipt.Verify() { + t.Fatal("fan-out receipt must verify") + } + if !receipt.IndependentOf(opPub) { + t.Fatal("member-witness receipts are independent of the operator") + } + seen[r.Witness] = true + } + if len(seen) != 2 { + t.Fatal("receipts must come from two distinct witnesses") + } + + // Direct-to-intake works without the relay. + resp = postJSON(t, i1.URL+"/v1/filings", msg) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatal("a filer must be able to bypass the relay entirely") + } +} + +func mustPriv(t *testing.T) ed25519.PrivateKey { + t.Helper() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return priv +} + +func fetchBundle(t *testing.T, base, logID string) relay.BundleMsg { + t.Helper() + resp, err := http.Get(base + "/v1/logs/" + logID + "/checkpoints/latest") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var bundle relay.BundleMsg + if err := json.NewDecoder(resp.Body).Decode(&bundle); err != nil { + t.Fatal(err) + } + return bundle +} + +func reconstruct(t *testing.T, bundle relay.BundleMsg) record.WitnessedCheckpoint { + t.Helper() + cp, _, _, _, err := witnesskit.DecodeCheckpoint(bundle.Checkpoint) + if err != nil { + t.Fatal(err) + } + wc := record.WitnessedCheckpoint{Checkpoint: cp} + for _, cs := range bundle.Countersignatures { + wRaw, err := hex.DecodeString(cs.Witness) + if err != nil { + t.Fatal(err) + } + sRaw, err := hex.DecodeString(cs.Signature) + if err != nil { + t.Fatal(err) + } + wc.Countersignatures = append(wc.Countersignatures, record.Countersignature{ + Witness: ed25519.PublicKey(wRaw), + Signature: sRaw, + }) + } + return wc +} + +func reconstructReceipt(t *testing.T, f record.FilingCommitment, r witnesskit.ReceiptMsg) record.FilingReceipt { + t.Helper() + wRaw, err := hex.DecodeString(r.Witness) + if err != nil { + t.Fatal(err) + } + sRaw, err := hex.DecodeString(r.Signature) + if err != nil { + t.Fatal(err) + } + at, err := time.Parse(time.RFC3339Nano, r.ReceivedAt) + if err != nil { + t.Fatal(err) + } + return record.FilingReceipt{ + Commitment: f, + Witness: ed25519.PublicKey(wRaw), + ReceivedAt: at.UTC(), + Signature: sRaw, + } +} diff --git a/internal/relay/relay.go b/internal/relay/relay.go new file mode 100644 index 0000000..65fb598 --- /dev/null +++ b/internal/relay/relay.go @@ -0,0 +1,229 @@ +// Package relay is the witness relay: it schedules nothing it decides, +// relays checkpoints and filings, caches countersignatures, and serves the +// assembled WitnessedCheckpoint bundles. Its non-authority is structural +// and load-bearing (the architecture's witness-relay rule): +// +// - it MUST NOT decide which witnesses count: it caches every +// structurally valid countersignature and serves them all; readers +// apply their own independence checks (WitnessedCheckpoint.Verify and +// StandIn do exactly that); +// - it MUST NOT decide which checkpoint is official: it caches what +// operators publish, keyed by log, keeping only monotonic growth per +// log (a relay that let a log's size run backward would be a rollback +// amplifier, so it refuses regressions — refusing to UNPUBLISH is not +// authority); +// - it MUST NOT gate settlement: nothing consults the relay to decide +// anything; and +// - everything it does must remain possible without it: operators serve +// their own checkpoints, witnesses can poll operators directly, and +// filers can lodge commitments at any witness intake directly. The +// relay is convenience topology, not a dependency. +// +// For filings the relay only FORWARDS: the one witness write (claim +// creation) happens at witness intakes; the relay fans a commitment out to +// the configured intake URLs and returns whatever receipts come back. It +// holds no intake key and can mint no receipt. +package relay + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "sync" + "time" +) + +// CheckpointMsg is the wire form of a checkpoint plus its countersignatures. +// All fields are hex strings; the relay treats them as opaque beyond shape. +type CheckpointMsg struct { + Log string `json:"log"` + Size uint64 `json:"size"` + Head string `json:"head"` + IssuedAt string `json:"issued_at"` + Signature string `json:"signature"` + // Operator is the operator's public key (hex) — carried so witnesses + // polling the relay know what to verify against; the relay itself + // never verifies trust, only shape. + Operator string `json:"operator"` + // Kind names which of the operator's logs this checkpoint commits + // ("dialog" or "lifecycle"); the log id already binds it + // cryptographically, the kind is legibility for pollers. + Kind string `json:"kind"` + // ConsistencyFrom/Proof carry the operator-supplied extension evidence + // from the previous published size, so witnesses can verify without a + // second round trip. A witness MAY ignore this and fetch its own. + ConsistencyFrom uint64 `json:"consistency_from"` + ConsistencyProof []string `json:"consistency_proof"` +} + +// CountersigMsg is one witness countersignature over the checkpoint the +// relay currently caches for a log at a given size. +type CountersigMsg struct { + Log string `json:"log"` + Size uint64 `json:"size"` + Witness string `json:"witness"` + Signature string `json:"signature"` +} + +// BundleMsg is what readers get: the cached checkpoint and every cached +// countersignature for it. The relay does not rank, filter, or count them. +type BundleMsg struct { + Checkpoint CheckpointMsg `json:"checkpoint"` + Countersignatures []CountersigMsg `json:"countersignatures"` +} + +type logState struct { + cp CheckpointMsg + cosign map[string]CountersigMsg // witness hex -> countersig for cp.Size +} + +// Relay is the in-memory relay. Durability is a deployment concern; the +// relay's cache is reconstructible from operators and witnesses by design. +type Relay struct { + mu sync.Mutex + logs map[string]*logState + intakes []string // witness intake base URLs for filing fan-out + client *http.Client +} + +// New returns a relay that fans filings out to the given witness intake +// URLs (each expecting POST {url}/v1/filings). +func New(intakes []string) *Relay { + return &Relay{ + logs: make(map[string]*logState), + intakes: append([]string(nil), intakes...), + client: &http.Client{Timeout: 10 * time.Second}, + } +} + +// Handler mounts the relay's HTTP surface. +func (rl *Relay) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /v1/logs/{log}/checkpoints", rl.handlePublish) + mux.HandleFunc("POST /v1/logs/{log}/countersignatures", rl.handleCountersig) + mux.HandleFunc("GET /v1/logs/{log}/checkpoints/latest", rl.handleLatest) + mux.HandleFunc("GET /v1/logs", rl.handleLogs) + mux.HandleFunc("POST /v1/filings", rl.handleFiling) + return mux +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func writeErr(w http.ResponseWriter, code int, msg string) { + writeJSON(w, code, map[string]string{"error": msg}) +} + +// handlePublish caches an operator-published checkpoint. Monotonic per log: +// a smaller-or-equal size does not replace a larger one (refusing rollback +// amplification), but is not an error — the operator may be re-publishing. +func (rl *Relay) handlePublish(w http.ResponseWriter, r *http.Request) { + var msg CheckpointMsg + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + writeErr(w, http.StatusBadRequest, "invalid JSON body") + return + } + if msg.Log == "" || msg.Log != r.PathValue("log") { + writeErr(w, http.StatusBadRequest, "log path and body disagree") + return + } + rl.mu.Lock() + defer rl.mu.Unlock() + st, ok := rl.logs[msg.Log] + if !ok || msg.Size > st.cp.Size { + rl.logs[msg.Log] = &logState{cp: msg, cosign: make(map[string]CountersigMsg)} + } + writeJSON(w, http.StatusOK, map[string]string{"status": "cached"}) +} + +// handleCountersig caches a witness countersignature for the checkpoint the +// relay currently holds at that log and size. The relay verifies SHAPE only; +// readers verify signatures — the relay counting or vetting witnesses would +// be the authority it must not be. +func (rl *Relay) handleCountersig(w http.ResponseWriter, r *http.Request) { + var msg CountersigMsg + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + writeErr(w, http.StatusBadRequest, "invalid JSON body") + return + } + if msg.Log != r.PathValue("log") { + writeErr(w, http.StatusBadRequest, "log path and body disagree") + return + } + rl.mu.Lock() + defer rl.mu.Unlock() + st, ok := rl.logs[msg.Log] + if !ok || st.cp.Size != msg.Size { + writeErr(w, http.StatusConflict, "no cached checkpoint at this size; poll latest and countersign that") + return + } + st.cosign[msg.Witness] = msg + writeJSON(w, http.StatusOK, map[string]string{"status": "cached"}) +} + +// handleLatest serves the cached bundle for one log. +func (rl *Relay) handleLatest(w http.ResponseWriter, r *http.Request) { + rl.mu.Lock() + st, ok := rl.logs[r.PathValue("log")] + var bundle BundleMsg + if ok { + bundle.Checkpoint = st.cp + for _, cs := range st.cosign { + bundle.Countersignatures = append(bundle.Countersignatures, cs) + } + } + rl.mu.Unlock() + if !ok { + writeErr(w, http.StatusNotFound, "no checkpoint cached for this log") + return + } + writeJSON(w, http.StatusOK, bundle) +} + +// handleLogs lists the log ids the relay currently caches — discovery for +// witnesses that countersign everything they can see. +func (rl *Relay) handleLogs(w http.ResponseWriter, r *http.Request) { + rl.mu.Lock() + ids := make([]string, 0, len(rl.logs)) + for id := range rl.logs { + ids = append(ids, id) + } + rl.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"logs": ids}) +} + +// handleFiling fans a filing commitment out to every configured witness +// intake and returns the receipts that came back. The relay holds no intake +// key: an empty intake list yields an empty receipt list, honestly. +func (rl *Relay) handleFiling(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<16)) + if err != nil { + writeErr(w, http.StatusBadRequest, "reading body") + return + } + receipts := make([]json.RawMessage, 0, len(rl.intakes)) + failures := 0 + for _, base := range rl.intakes { + resp, err := rl.client.Post(base+"/v1/filings", "application/json", bytes.NewReader(body)) + if err != nil { + failures++ + continue + } + rb, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + resp.Body.Close() + if err != nil || resp.StatusCode != http.StatusOK { + failures++ + continue + } + receipts = append(receipts, json.RawMessage(rb)) + } + writeJSON(w, http.StatusOK, map[string]any{ + "receipts": receipts, + "intakes": len(rl.intakes), + "failures": failures, + }) +} diff --git a/internal/witnesskit/witnesskit.go b/internal/witnesskit/witnesskit.go new file mode 100644 index 0000000..25b4eff --- /dev/null +++ b/internal/witnesskit/witnesskit.go @@ -0,0 +1,263 @@ +// Package witnesskit is what a MEMBER runs to be a witness — the +// membership-as-witnessing seam (open problem 8's most architecture-shaped +// lever): distribute witness capacity across the membership so a node can be +// governed-honest without being witness-rich. A worker holds one witness +// key, polls a relay (or an operator directly — the relay is convenience, +// not dependency), refuses rollbacks and forks by construction +// (record.Witness's memory), countersigns what extends honestly, and posts +// the countersignature back. It also hosts the filing intake: the one +// witness write. +// +// Running one is deliberately boring: any member, any machine, one key. +// Federation is the plural of this package. +package witnesskit + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/NTARI-RAND/Cloudy/internal/record" + "github.com/NTARI-RAND/Cloudy/internal/relay" +) + +// EncodeCheckpoint builds the relay wire form of a checkpoint. +func EncodeCheckpoint(cp record.Checkpoint, operator ed25519.PublicKey, kind string, from uint64, proof []record.Hash) relay.CheckpointMsg { + msg := relay.CheckpointMsg{ + Log: hex.EncodeToString(cp.Log[:]), + Size: cp.Size, + Head: hex.EncodeToString(cp.Head[:]), + IssuedAt: cp.IssuedAt.UTC().Format(time.RFC3339Nano), + Signature: hex.EncodeToString(cp.Signature), + Operator: hex.EncodeToString(operator), + Kind: kind, + ConsistencyFrom: from, + } + msg.ConsistencyProof = make([]string, len(proof)) + for i, h := range proof { + msg.ConsistencyProof[i] = hex.EncodeToString(h[:]) + } + return msg +} + +func decodeHash(s string) (record.Hash, error) { + var h record.Hash + raw, err := hex.DecodeString(s) + if err != nil || len(raw) != 32 { + return h, errors.New("witnesskit: not a 32-byte hex hash") + } + copy(h[:], raw) + return h, nil +} + +// DecodeCheckpoint parses the wire form, derives the expected log identity +// from the operator key and kind, and REFUSES a message whose log id does +// not match the derivation — a witness never takes the relay's word for +// which log a checkpoint belongs to. +func DecodeCheckpoint(msg relay.CheckpointMsg) (cp record.Checkpoint, operator ed25519.PublicKey, wantLog record.Hash, proof []record.Hash, err error) { + logID, err := decodeHash(msg.Log) + if err != nil { + return cp, nil, wantLog, nil, err + } + head, err := decodeHash(msg.Head) + if err != nil { + return cp, nil, wantLog, nil, err + } + opRaw, err := hex.DecodeString(msg.Operator) + if err != nil || len(opRaw) != ed25519.PublicKeySize { + return cp, nil, wantLog, nil, errors.New("witnesskit: malformed operator key") + } + operator = ed25519.PublicKey(opRaw) + switch msg.Kind { + case "dialog": + wantLog = record.LogID(operator) + case "lifecycle": + wantLog = record.LifecycleLogID(operator) + default: + return cp, nil, wantLog, nil, fmt.Errorf("witnesskit: unknown log kind %q", msg.Kind) + } + if logID != wantLog { + return cp, nil, wantLog, nil, errors.New("witnesskit: log id does not derive from operator key and kind") + } + sig, err := hex.DecodeString(msg.Signature) + if err != nil { + return cp, nil, wantLog, nil, errors.New("witnesskit: malformed signature") + } + issuedAt, err := time.Parse(time.RFC3339Nano, msg.IssuedAt) + if err != nil { + return cp, nil, wantLog, nil, errors.New("witnesskit: malformed issued_at") + } + cp = record.Checkpoint{Log: logID, Size: msg.Size, Head: head, IssuedAt: issuedAt.UTC(), Signature: sig} + proof = make([]record.Hash, 0, len(msg.ConsistencyProof)) + for _, s := range msg.ConsistencyProof { + h, err := decodeHash(s) + if err != nil { + return cp, nil, wantLog, nil, err + } + proof = append(proof, h) + } + return cp, operator, wantLog, proof, nil +} + +// Worker is one member witness: a key, a rollback memory, and a relay to +// poll. Its state is process-volatile (the named amnesia residual: a +// restarted witness reverts to trust-on-first-checkpoint); run it long-lived. +type Worker struct { + witness *record.Witness + intake *record.FilingIntake + pub ed25519.PublicKey + relay string + client *http.Client +} + +// NewWorker returns a worker holding priv, polling relayURL. +func NewWorker(priv ed25519.PrivateKey, relayURL string) *Worker { + w := &Worker{ + witness: record.NewWitness(priv), + intake: record.NewFilingIntake(priv), + relay: relayURL, + client: &http.Client{Timeout: 10 * time.Second}, + } + if len(priv) == ed25519.PrivateKeySize { + w.pub = priv.Public().(ed25519.PublicKey) + } + return w +} + +// Key returns the worker's witness public key. +func (w *Worker) Key() ed25519.PublicKey { return w.pub } + +// RunOnce polls every log the relay knows, countersigns each checkpoint +// that verifies and consistently extends what this witness last cosigned, +// and posts the countersignatures back. It returns how many logs it +// countersigned. Failures on one log never block another. +func (w *Worker) RunOnce() (int, error) { + resp, err := w.client.Get(w.relay + "/v1/logs") + if err != nil { + return 0, err + } + var logs struct { + Logs []string `json:"logs"` + } + err = json.NewDecoder(resp.Body).Decode(&logs) + resp.Body.Close() + if err != nil { + return 0, err + } + signed := 0 + for _, logID := range logs.Logs { + if w.countersignLog(logID) == nil { + signed++ + } + } + return signed, nil +} + +func (w *Worker) countersignLog(logID string) error { + resp, err := w.client.Get(w.relay + "/v1/logs/" + logID + "/checkpoints/latest") + if err != nil { + return err + } + var bundle relay.BundleMsg + err = json.NewDecoder(resp.Body).Decode(&bundle) + resp.Body.Close() + if err != nil { + return err + } + cp, operator, wantLog, proof, err := DecodeCheckpoint(bundle.Checkpoint) + if err != nil { + return err + } + cs, err := w.witness.CountersignAs(cp, operator, wantLog, proof) + if err != nil { + return err + } + msg := relay.CountersigMsg{ + Log: logID, + Size: cp.Size, + Witness: hex.EncodeToString(cs.Witness), + Signature: hex.EncodeToString(cs.Signature), + } + body, err := json.Marshal(msg) + if err != nil { + return err + } + post, err := w.client.Post(w.relay+"/v1/logs/"+logID+"/countersignatures", "application/json", bytes.NewReader(body)) + if err != nil { + return err + } + post.Body.Close() + if post.StatusCode != http.StatusOK { + return fmt.Errorf("witnesskit: relay refused countersignature: %d", post.StatusCode) + } + return nil +} + +// FilingMsg is the wire form of a FilingCommitment. +type FilingMsg struct { + Claim string `json:"claim"` + Exchange string `json:"exchange"` + TypeHash string `json:"type_hash"` + At string `json:"at"` + Filer string `json:"filer"` + Signature string `json:"signature"` +} + +// ReceiptMsg is the wire form of a FilingReceipt. +type ReceiptMsg struct { + Claim string `json:"claim"` + Witness string `json:"witness"` + ReceivedAt string `json:"received_at"` + Signature string `json:"signature"` +} + +// IntakeHandler mounts the one witness write: POST /v1/filings. Everything +// else about a claim happens elsewhere; this endpoint acknowledges +// existence at an instant and nothing more. +func (w *Worker) IntakeHandler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /v1/filings", func(rw http.ResponseWriter, r *http.Request) { + var msg FilingMsg + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + http.Error(rw, `{"error":"invalid JSON body"}`, http.StatusBadRequest) + return + } + claim, err1 := decodeHash(msg.Claim) + exchange, err2 := decodeHash(msg.Exchange) + typeHash, err3 := decodeHash(msg.TypeHash) + filerRaw, err4 := hex.DecodeString(msg.Filer) + sig, err5 := hex.DecodeString(msg.Signature) + at, err6 := time.Parse(time.RFC3339Nano, msg.At) + if err1 != nil || err2 != nil || err3 != nil || err4 != nil || err5 != nil || err6 != nil || + len(filerRaw) != ed25519.PublicKeySize { + http.Error(rw, `{"error":"malformed filing commitment"}`, http.StatusBadRequest) + return + } + f := record.FilingCommitment{ + Claim: claim, + Exchange: exchange, + TypeHash: typeHash, + At: at.UTC(), + Filer: ed25519.PublicKey(filerRaw), + Signature: sig, + } + receipt, err := w.intake.Accept(f, time.Now().UTC()) + if err != nil { + http.Error(rw, `{"error":"filing refused"}`, http.StatusBadRequest) + return + } + rw.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(rw).Encode(ReceiptMsg{ + Claim: msg.Claim, + Witness: hex.EncodeToString(receipt.Witness), + ReceivedAt: receipt.ReceivedAt.UTC().Format(time.RFC3339Nano), + Signature: hex.EncodeToString(receipt.Signature), + }) + }) + return mux +}