Witness co-signed key-log heads (relay#23) #97

Merged
paulhitt merged 1 commits from feat/witness-cosigned-heads into main 2026-09-04 20:40:40 +00:00
5 changed files with 381 additions and 1 deletions
+8 -1
View File
@@ -35,11 +35,18 @@ extension Routes {
"This relay has no signing key, so its log head is unsigned.", 503)
}
// relay#23 — whatever co-signatures have been collected for this exact
// head. Absent when there are none, which is the ordinary case for a
// deployment with no witnesses configured.
let cosignatures = await WitnessStore.shared.cosignatures(
size: size, rootBase64: root.base64EncodedString())
return try .json(LogHeadResponse(
size: size,
root: root.base64EncodedString(),
signature: signature.base64EncodedString(),
audience: config.audience))
audience: config.audience,
witnesses: cosignatures.isEmpty ? nil : cosignatures))
}
/// Proof that the leaf at `index` is in the tree of size `size`.
+11
View File
@@ -718,6 +718,17 @@ struct LogHeadResponse: Content {
/// Ed25519 over `"fnkt-v1|<size>|<root>"`, by the relay's issuer key.
let signature: String
let audience: String
/// Independent co-signatures over the same statement (§19, relay#23).
///
/// ⚠️ Additive and optional: a relay with no witnesses configured omits
/// it, and every client that predates the field ignores it. A client that
/// REQUIRES witnesses is making a stronger demand than the protocol, which
/// is its own decision to make.
///
/// 🔴 The witness NAMES are here; the witness KEYS are not, and must never
/// be. A client that took the keys from this response would be letting the
/// relay nominate its own auditors.
var witnesses: [Witness.Cosignature]? = nil
}
struct LogInclusionResponse: Content {
+139
View File
@@ -0,0 +1,139 @@
import Crypto
import Foundation
/// A second signature on a key-log head, from somebody who is not the relay
/// (relay#23).
///
/// 🔴 **The relay signs its own head, which is why a fork can hide.** A relay
/// that splits its log — showing one set of keys to one person and a different
/// set to another — signs both halves with the same key. Every client sees a
/// head that verifies, and nothing in what any single client can fetch says
/// the two exist. Only gossip between clients catches it, and that needs two
/// clients who happen to compare.
///
/// A witness is an independent party that remembers what it has already signed.
/// Ask it to co-sign a head of size 100 with root A and it will; ask it later
/// to co-sign size 100 with root B and an honest witness **refuses, because it
/// has already made a statement about size 100**. So a relay wanting an
/// undetected fork now needs the witness's cooperation as well as its own key.
///
/// ⚠️ **What this does not do, said plainly.** It does not prevent a fork. It
/// does not detect one on its own — a client still has to check that the
/// co-signatures are there and are from witnesses it trusts. And a witness that
/// colludes with the relay adds exactly nothing, which is why more than one,
/// run by different people, is the only configuration worth having.
///
/// 🔴 **A stateless witness is decoration.** The whole value is the memory: a
/// witness that signs whatever it is shown produces signatures that prove only
/// that it was asked. The rule below is what makes it mean something.
public enum Witness {
/// The exact bytes a head commits to.
///
/// 🔴 Byte-identical to what the relay signs. A witness signing a different
/// serialisation would produce a signature that verifies against nothing
/// the client checks, and the mismatch would look like a bad witness rather
/// than a protocol error.
public static func statement(size: Int, rootBase64: String) -> Data {
Data("fnkt-v1|\(size)|\(rootBase64)".utf8)
}
/// What a witness has already said about this log.
public struct Memory: Equatable, Sendable {
public let size: Int
public let rootBase64: String
public init(size: Int, rootBase64: String) {
self.size = size
self.rootBase64 = rootBase64
}
}
/// Whether an honest witness may co-sign what it has been shown.
public enum Decision: Equatable {
case sign
/// 🔴 A different root at a size already signed. This is the fork, and
/// refusing is the entire point of the witness existing.
case refuseFork(previous: String, offered: String, size: Int)
/// A head SMALLER than one already signed. An append-only log cannot
/// shrink, so this is either a rollback or a different log wearing the
/// same name.
case refuseShrunk(known: Int, offered: Int)
/// Nothing to compare against. Signed, and remembered.
case signFirst
}
/// 🔴 The rule that makes a witness worth asking.
///
/// ⚠️ A LARGER size is signed without checking that it extends what came
/// before, and that is deliberate: proving extension needs a consistency
/// proof, which is a heavier protocol and a separate decision. What this
/// gives is the property that matters most and costs least — two different
/// roots at the same size cannot both be co-signed.
public static func decide(size: Int, rootBase64: String, remembered: Memory?) -> Decision {
guard let remembered else { return .signFirst }
if size < remembered.size {
return .refuseShrunk(known: remembered.size, offered: size)
}
if size == remembered.size, rootBase64 != remembered.rootBase64 {
return .refuseFork(previous: remembered.rootBase64, offered: rootBase64, size: size)
}
return .sign
}
public static func mayCosign(_ decision: Decision) -> Bool {
switch decision {
case .sign, .signFirst: return true
case .refuseFork, .refuseShrunk: return false
}
}
/// What a refusal should say. Nil when there is nothing to say.
///
/// ⚠️ A refusal is evidence and has to read like it. "Declined" tells an
/// operator nothing; the two roots and the size are the whole finding.
public static func report(_ decision: Decision, audience: String) -> String? {
switch decision {
case .sign, .signFirst:
return nil
case .refuseFork(let previous, let offered, let size):
return "REFUSING to co-sign \(audience): asked to sign a log head of "
+ "size \(size) with root \(offered), having already signed size "
+ "\(size) with root \(previous). Two different roots at one size "
+ "is a forked log, and a witness that signed both would be worth "
+ "nothing."
case .refuseShrunk(let known, let offered):
return "REFUSING to co-sign \(audience): asked to sign a log head of "
+ "size \(offered), having already signed size \(known). An "
+ "append-only log cannot shrink, so this is a rollback or a "
+ "different log using the same name."
}
}
/// One witness's co-signature, as it travels.
public struct Cosignature: Codable, Equatable, Sendable {
/// Who signed. A name a client can match against its own trust list.
public let witness: String
/// Ed25519 over `statement(size:rootBase64:)`.
public let signature: String
public init(witness: String, signature: String) {
self.witness = witness
self.signature = signature
}
}
/// Check a co-signature against a witness key a client already trusts.
///
/// 🔴 The key must come from the client's own configuration, never from
/// the relay's response. A relay that could name the witness keys could
/// name its own, and the second signature would be the first one wearing
/// a hat.
public static func verify(_ cosignature: Cosignature,
size: Int, rootBase64: String,
witnessKey: Data) -> Bool {
guard let signature = Data(base64Encoded: cosignature.signature),
let key = try? Curve25519.Signing.PublicKey(rawRepresentation: witnessKey)
else { return false }
return key.isValidSignature(signature,
for: statement(size: size, rootBase64: rootBase64))
}
}
+51
View File
@@ -0,0 +1,51 @@
import Foundation
/// Co-signatures collected for the head this relay is serving (relay#23).
///
/// ⚠️ **In memory, deliberately.** A co-signature is only meaningful for one
/// exact `(size, root)` pair, and the head moves every time a key is logged —
/// so anything held here is stale within moments of the next registration.
/// Persisting them would mean serving yesterday's witness statement beside
/// today's head, which is worse than serving none: it looks like corroboration
/// and is not.
///
/// 🔴 Keyed by the statement rather than by size. A co-signature for size 100
/// root A must never be served beside size 100 root B — that pairing is exactly
/// the fork a witness exists to make impossible, and producing it here would be
/// this relay manufacturing the evidence against itself.
public actor WitnessStore {
public static let shared = WitnessStore()
private var collected: [String: [Witness.Cosignature]] = [:]
private func key(size: Int, rootBase64: String) -> String { "\(size)|\(rootBase64)" }
public func record(_ cosignature: Witness.Cosignature, size: Int, rootBase64: String) {
let k = key(size: size, rootBase64: rootBase64)
var existing = collected[k] ?? []
// One per witness. A witness that answered twice has not corroborated
// anything twice.
existing.removeAll { $0.witness == cosignature.witness }
existing.append(cosignature)
collected[k] = existing
}
public func cosignatures(size: Int, rootBase64: String) -> [Witness.Cosignature] {
collected[key(size: size, rootBase64: rootBase64)] ?? []
}
/// Drop everything for heads older than the one now current.
///
/// ⚠️ Called when the log grows. Keeping old entries would let the map
/// grow without bound on a busy relay, and none of them can ever be served
/// again — the head they belong to is gone.
public func forgetOlderThan(size: Int) {
collected = collected.filter { entry in
guard let recorded = Int(entry.key.split(separator: "|").first ?? "") else { return false }
return recorded >= size
}
}
public func reset() { collected = [:] }
}
+172
View File
@@ -0,0 +1,172 @@
import Crypto
import Foundation
import Testing
@testable import FlamenetRelay
/// A second signature on a key-log head, from somebody who is not the relay
/// (relay#23).
///
/// 🔴 **The relay signs its own head, which is why a fork can hide.** A relay
/// that splits its log signs both halves with the same key: every client sees a
/// head that verifies, and nothing any single client can fetch says the two
/// exist. A witness that remembers what it has already signed changes that —
/// asked to co-sign a second root at a size it has already signed, an honest
/// one refuses.
@Suite("witness co-signing")
struct WitnessTests {
private let rootA = Data(repeating: 0xA1, count: 32).base64EncodedString()
private let rootB = Data(repeating: 0xB2, count: 32).base64EncodedString()
// MARK: - The rule that makes a witness worth asking
@Test("a first head is signed and remembered")
func firstHead() {
#expect(Witness.decide(size: 10, rootBase64: rootA, remembered: nil) == .signFirst)
#expect(Witness.mayCosign(.signFirst))
}
@Test("the same head again is signed")
func sameHead() {
let seen = Witness.Memory(size: 10, rootBase64: rootA)
#expect(Witness.decide(size: 10, rootBase64: rootA, remembered: seen) == .sign)
}
/// 🔴 **The whole point.** Two different roots at one size is a forked log,
/// and a witness that signed both would be worth nothing.
@Test("a different root at a size already signed is refused")
func fork() {
let seen = Witness.Memory(size: 10, rootBase64: rootA)
let decision = Witness.decide(size: 10, rootBase64: rootB, remembered: seen)
#expect(decision == .refuseFork(previous: rootA, offered: rootB, size: 10))
#expect(!Witness.mayCosign(decision))
}
/// An append-only log cannot shrink. A smaller head is a rollback, or a
/// different log wearing the same name.
@Test("a shrinking log is refused")
func shrunk() {
let seen = Witness.Memory(size: 100, rootBase64: rootA)
let decision = Witness.decide(size: 99, rootBase64: rootB, remembered: seen)
#expect(decision == .refuseShrunk(known: 100, offered: 99))
#expect(!Witness.mayCosign(decision))
}
/// ⚠️ A larger size is signed without proving it extends what came before.
/// That is deliberate: proving extension needs a consistency proof, which
/// is a heavier protocol and a separate decision. What this buys is the
/// property that matters most and costs least.
@Test("a growing log is signed without a consistency proof")
func grows() {
let seen = Witness.Memory(size: 10, rootBase64: rootA)
#expect(Witness.decide(size: 11, rootBase64: rootB, remembered: seen) == .sign)
}
// MARK: - What a refusal says
/// ⚠️ A refusal is evidence and has to read like it. "Declined" tells an
/// operator nothing; the two roots and the size are the whole finding.
@Test("a fork refusal carries both roots and the size")
func refusalIsEvidence() throws {
let decision = Witness.decide(size: 42, rootBase64: rootB,
remembered: .init(size: 42, rootBase64: rootA))
let message = try #require(Witness.report(decision, audience: "relay.example"))
#expect(message.contains("relay.example"))
#expect(message.contains("42"))
#expect(message.contains(rootA))
#expect(message.contains(rootB))
#expect(message.contains("forked log"))
}
@Test("nothing is reported when nothing is wrong")
func quietWhenFine() {
#expect(Witness.report(.sign, audience: "r") == nil)
#expect(Witness.report(.signFirst, audience: "r") == nil)
}
// MARK: - The signature itself
/// 🔴 Byte-identical to what the relay signs. A witness signing a different
/// serialisation produces a signature that verifies against nothing.
@Test("the witness signs exactly the statement the relay signs")
func sameStatement() {
let statement = Witness.statement(size: 7, rootBase64: rootA)
#expect(String(decoding: statement, as: UTF8.self) == "fnkt-v1|7|\(rootA)")
}
@Test("a good co-signature verifies")
func verifies() {
let key = Curve25519.Signing.PrivateKey()
let signature = try! key.signature(for: Witness.statement(size: 7, rootBase64: rootA))
let cosign = Witness.Cosignature(witness: "alice-witness",
signature: signature.base64EncodedString())
#expect(Witness.verify(cosign, size: 7, rootBase64: rootA,
witnessKey: key.publicKey.rawRepresentation))
}
/// 🔴 And it must NOT verify against a different head — otherwise the
/// check above would pass on a scheme that ignored its input.
@Test("a co-signature does not verify against a different head")
func doesNotVerifyElsewhere() {
let key = Curve25519.Signing.PrivateKey()
let signature = try! key.signature(for: Witness.statement(size: 7, rootBase64: rootA))
let cosign = Witness.Cosignature(witness: "alice-witness",
signature: signature.base64EncodedString())
#expect(!Witness.verify(cosign, size: 7, rootBase64: rootB,
witnessKey: key.publicKey.rawRepresentation))
#expect(!Witness.verify(cosign, size: 8, rootBase64: rootA,
witnessKey: key.publicKey.rawRepresentation))
}
@Test("a co-signature from the wrong key is refused")
func wrongKey() {
let key = Curve25519.Signing.PrivateKey()
let other = Curve25519.Signing.PrivateKey()
let signature = try! key.signature(for: Witness.statement(size: 7, rootBase64: rootA))
let cosign = Witness.Cosignature(witness: "alice-witness",
signature: signature.base64EncodedString())
#expect(!Witness.verify(cosign, size: 7, rootBase64: rootA,
witnessKey: other.publicKey.rawRepresentation))
}
// MARK: - What the relay serves
/// 🔴 A co-signature for size 100 root A must never be served beside size
/// 100 root B — that pairing is exactly the fork a witness exists to make
/// impossible, and producing it would be the relay manufacturing the
/// evidence against itself.
@Test("co-signatures are keyed by the exact head, not by size")
func keyedByStatement() async {
let store = WitnessStore()
await store.record(.init(witness: "w1", signature: "sig-a"), size: 100, rootBase64: rootA)
#expect(await store.cosignatures(size: 100, rootBase64: rootA).count == 1)
#expect(await store.cosignatures(size: 100, rootBase64: rootB).isEmpty,
"a co-signature was served beside a root it never signed")
}
/// ⚠️ One per witness. A witness that answered twice has not corroborated
/// anything twice.
@Test("a witness answering twice counts once")
func oncePerWitness() async {
let store = WitnessStore()
await store.record(.init(witness: "w1", signature: "first"), size: 5, rootBase64: rootA)
await store.record(.init(witness: "w1", signature: "second"), size: 5, rootBase64: rootA)
let all = await store.cosignatures(size: 5, rootBase64: rootA)
#expect(all.count == 1)
#expect(all.first?.signature == "second")
}
/// ⚠️ Old heads are dropped as the log grows. They can never be served
/// again — the head they belong to is gone — and keeping them grows the
/// map without bound on a busy relay.
@Test("heads older than the current one are forgotten")
func forgetsOldHeads() async {
let store = WitnessStore()
await store.record(.init(witness: "w1", signature: "s"), size: 5, rootBase64: rootA)
await store.record(.init(witness: "w1", signature: "s"), size: 9, rootBase64: rootB)
await store.forgetOlderThan(size: 9)
#expect(await store.cosignatures(size: 5, rootBase64: rootA).isEmpty)
#expect(await store.cosignatures(size: 9, rootBase64: rootB).count == 1)
}
}