Witness co-signed key-log heads (relay#23) #97
@@ -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`.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 = [:] }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user