diff --git a/Sources/FlamenetRelay/RoutesKeyLog.swift b/Sources/FlamenetRelay/RoutesKeyLog.swift index f865989..08a1140 100644 --- a/Sources/FlamenetRelay/RoutesKeyLog.swift +++ b/Sources/FlamenetRelay/RoutesKeyLog.swift @@ -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`. diff --git a/Sources/FlamenetRelay/Wire.swift b/Sources/FlamenetRelay/Wire.swift index b8e2c8a..227efd0 100644 --- a/Sources/FlamenetRelay/Wire.swift +++ b/Sources/FlamenetRelay/Wire.swift @@ -718,6 +718,17 @@ struct LogHeadResponse: Content { /// Ed25519 over `"fnkt-v1||"`, 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 { diff --git a/Sources/FlamenetRelay/Witness.swift b/Sources/FlamenetRelay/Witness.swift new file mode 100644 index 0000000..f0ddd12 --- /dev/null +++ b/Sources/FlamenetRelay/Witness.swift @@ -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)) + } +} diff --git a/Sources/FlamenetRelay/WitnessStore.swift b/Sources/FlamenetRelay/WitnessStore.swift new file mode 100644 index 0000000..f3c3227 --- /dev/null +++ b/Sources/FlamenetRelay/WitnessStore.swift @@ -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 = [:] } +} diff --git a/Tests/FlamenetRelayTests/WitnessTests.swift b/Tests/FlamenetRelayTests/WitnessTests.swift new file mode 100644 index 0000000..73600f5 --- /dev/null +++ b/Tests/FlamenetRelayTests/WitnessTests.swift @@ -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) + } +}