diff --git a/Sources/FlamenetRelay/IdentityRotation.swift b/Sources/FlamenetRelay/IdentityRotation.swift new file mode 100644 index 0000000..8bb0420 --- /dev/null +++ b/Sources/FlamenetRelay/IdentityRotation.swift @@ -0,0 +1,117 @@ +import Crypto +import Foundation + +/// Changing a device's identity key, with evidence (relay#25). +/// +/// ๐Ÿ”ด **Rotation already worked, and that was the problem.** A device +/// re-registering with a different `ik_sig` simply overwrote the old one. The +/// call is authorised by the OLD key โ€” the token's `cnf` must match what is on +/// file โ€” so the caller had to hold the key being replaced. What nothing +/// established was that the NEW key's holder had anything to do with it. +/// +/// That gap matters because of who can reach it. Anyone able to mint a token +/// for a device and sign one request with the old key โ€” a stolen device in the +/// window before revocation, a compromised issuer โ€” could point the identity at +/// a key they control, and every future correspondent would fetch the +/// substitute as though it were a rotation the owner performed. +/// +/// So a rotation may now carry a **continuity proof**: the old key's signature +/// over a statement naming both keys and the device. That is the thing only +/// somebody with the old key can produce and only for one specific successor โ€” +/// it cannot be replayed onto a different new key. +/// +/// โš ๏ธ **Optional on the wire, and deliberately so.** Every client that predates +/// this sends nothing, and refusing them would break rotation for everybody +/// already installed. What changes instead is what the log records: a proven +/// rotation and an unproven one are written as different kinds, so a monitor โ€” +/// or the other party's client โ€” can tell them apart and decide for itself. +/// Silently accepting both as equal is the thing worth not doing. +public enum IdentityRotation { + + /// The exact bytes a continuity proof covers. + /// + /// ๐Ÿ”ด Names the device AND both keys. Without the successor in the + /// statement, a signature captured from one rotation could be replayed to + /// authorise a different one; without the device id it could be moved + /// between devices of the same account. + public static func statement(deviceID: String, + oldIkSig: String, + newIkSig: String) -> Data { + Data("fnrot-v1|\(deviceID)|\(oldIkSig)|\(newIkSig)".utf8) + } + + /// What the relay decided about a rotation, and what it will record. + public enum Verdict: Equatable { + /// Not a rotation at all โ€” the identity is unchanged. + case unchanged + /// A brand new device. There is no predecessor to prove continuity + /// from, so there is nothing to prove. + case firstRegistration + /// The old key signed for its successor. + case proven + /// ๐Ÿ”ด The identity changed with no proof offered. Accepted, because + /// refusing would break every client that predates the field โ€” but + /// recorded as what it is. + case unproven + /// A proof was offered and did not verify. Refused: a bad proof is + /// worse than none, because somebody meant it to be believed. + case badProof + } + + public static func judge(existingIkSig: String?, + newIkSig: String, + deviceID: String, + proofBase64: String?) -> Verdict { + guard let existingIkSig else { return .firstRegistration } + guard existingIkSig != newIkSig else { return .unchanged } + guard let proofBase64, !proofBase64.isEmpty else { return .unproven } + guard let signature = Data(base64Encoded: proofBase64), + let oldKeyRaw = Data(base64Encoded: existingIkSig), + let oldKey = try? Curve25519.Signing.PublicKey(rawRepresentation: oldKeyRaw) + else { return .badProof } + let covered = statement(deviceID: deviceID, + oldIkSig: existingIkSig, newIkSig: newIkSig) + return oldKey.isValidSignature(signature, for: covered) ? .proven : .badProof + } + + /// Whether the registration may proceed. + /// + /// โš ๏ธ Only a BAD proof is refused. An absent one is accepted and recorded, + /// for compatibility; a present-and-wrong one is somebody asking to be + /// believed and failing, which is a different thing entirely. + public static func mayProceed(_ verdict: Verdict) -> Bool { + verdict != .badProof + } + + /// What the key log should call this entry. + /// + /// ๐Ÿ”ด A proven rotation and an unproven one are different kinds, not one + /// kind with a flag. The log is the record somebody audits later, and the + /// distinction has to survive into it. + public static func logKind(_ verdict: Verdict) -> String? { + switch verdict { + case .unchanged, .badProof: return nil + case .firstRegistration: return "publish" + case .proven: return "rotate" + case .unproven: return "rotate_unproven" + } + } + + /// What to say about it. Nil when it is ordinary. + public static func report(_ verdict: Verdict, deviceID: String) -> (isError: Bool, String)? { + switch verdict { + case .unchanged, .firstRegistration, .proven: + return nil + case .unproven: + return (false, "device \(deviceID) changed its identity key without a " + + "continuity proof. Accepted for compatibility and logged as " + + "rotate_unproven โ€” a client watching the log can tell this " + + "apart from a rotation the previous key vouched for.") + case .badProof: + return (true, "device \(deviceID) offered a continuity proof that does " + + "not verify against the identity key on file. REFUSED. A " + + "wrong proof is worse than none: somebody meant it to be " + + "believed.") + } + } +} diff --git a/Sources/FlamenetRelay/Routes.swift b/Sources/FlamenetRelay/Routes.swift index 30c796f..4c71aad 100644 --- a/Sources/FlamenetRelay/Routes.swift +++ b/Sources/FlamenetRelay/Routes.swift @@ -227,6 +227,26 @@ struct Routes: Sendable { || existing?.ikSig != input.ik_sig || existing?.ikDH != input.ik_dh + // relay#25 โ€” a rotation authorised only by the old key establishes + // that the caller HELD it, never that the new key's holder had + // anything to do with the change. A continuity proof is the thing + // only the old key can produce, and only for one named successor. + let rotation = IdentityRotation.judge( + existingIkSig: existing?.ikSig, + newIkSig: input.ik_sig, + deviceID: input.device_id, + proofBase64: input.ik_rotation_sig) + if let (isError, complaint) = IdentityRotation.report( + rotation, deviceID: input.device_id) { + if isError { request.logger.error("\(complaint)") } + else { request.logger.notice("\(complaint)") } + } + guard IdentityRotation.mayProceed(rotation) else { + throw WireError("bad_rotation_proof", + "The continuity proof for this identity rotation does " + + "not verify against the key on file.", 400) + } + if existing == nil { try await device.create(on: db) } else { try await device.update(on: db) } // ยง19: a key the relay will serve must first be a key the relay has @@ -239,9 +259,13 @@ struct Routes: Sendable { if identityChanged { let subject = try await KeyLog.subject( forUserID: caller.userID, audience: config.audience, on: db) + // ๐Ÿ”ด A proven rotation and an unproven one are different + // kinds, not one kind with a flag. The log is what somebody + // audits later and the distinction has to survive into it. try await KeyLog.append( userID: caller.userID, subject: subject, deviceID: input.device_id, - ikSig: input.ik_sig, ikDH: input.ik_dh, kind: "publish", on: db) + ikSig: input.ik_sig, ikDH: input.ik_dh, + kind: IdentityRotation.logKind(rotation) ?? "publish", on: db) } try await Self.insertPreKeys(input.prekeys, input.pq_prekeys, diff --git a/Sources/FlamenetRelay/Wire.swift b/Sources/FlamenetRelay/Wire.swift index 227efd0..af2538c 100644 --- a/Sources/FlamenetRelay/Wire.swift +++ b/Sources/FlamenetRelay/Wire.swift @@ -170,6 +170,13 @@ struct RegisterDeviceRequest: Decodable { let ik_pq: String? /// ยง20 โ€” ML-DSA-65 signature over the same bytes `spk_sig` covers. let spk_pq_sig: String? + /// A continuity proof for an identity rotation (relay#25): the OLD + /// identity key's signature over `fnrot-v1|||`. + /// + /// โš ๏ธ Optional. Clients that predate it send nothing and still rotate; + /// what changes is which kind the key log records. See + /// `IdentityRotation`. + let ik_rotation_sig: String? } struct PreKeyIn: Decodable { let id: Int; let pub: String } diff --git a/Tests/FlamenetRelayTests/IdentityRotationTests.swift b/Tests/FlamenetRelayTests/IdentityRotationTests.swift new file mode 100644 index 0000000..df66cf9 --- /dev/null +++ b/Tests/FlamenetRelayTests/IdentityRotationTests.swift @@ -0,0 +1,157 @@ +import Crypto +import Foundation +import Testing +@testable import FlamenetRelay + +/// Changing a device's identity key, with evidence (relay#25). +/// +/// ๐Ÿ”ด **Rotation already worked, and that was the problem.** A device +/// re-registering with a different `ik_sig` overwrote the old one. The call is +/// authorised by the OLD key, so the caller had to hold the key being +/// replaced โ€” but nothing established that the NEW key's holder had anything +/// to do with it. Anyone able to sign one request with the old key could point +/// the identity at a key they control, and every future correspondent would +/// fetch the substitute as though the owner had rotated. +@Suite("identity rotation") +struct IdentityRotationTests { + + private func key() -> Curve25519.Signing.PrivateKey { .init() } + private func pub(_ k: Curve25519.Signing.PrivateKey) -> String { + k.publicKey.rawRepresentation.base64EncodedString() + } + + @Test("a first registration has no predecessor to prove") + func firstRegistration() { + let verdict = IdentityRotation.judge( + existingIkSig: nil, newIkSig: pub(key()), + deviceID: "dev-1", proofBase64: nil) + #expect(verdict == .firstRegistration) + #expect(IdentityRotation.mayProceed(verdict)) + #expect(IdentityRotation.logKind(verdict) == "publish") + } + + /// โš ๏ธ Re-registering the same identity โ€” a prekey replenishment, an SPK + /// rotation โ€” is not a rotation and must not be logged as one. + @Test("an unchanged identity is not a rotation") + func unchanged() { + let same = pub(key()) + let verdict = IdentityRotation.judge( + existingIkSig: same, newIkSig: same, deviceID: "dev-1", proofBase64: nil) + #expect(verdict == .unchanged) + #expect(IdentityRotation.logKind(verdict) == nil) + } + + /// ๐Ÿ”ด The proof only the old key can make, and only for this successor. + @Test("the old key signing for its successor is a proven rotation") + func proven() { + let old = key(), new = key() + let statement = IdentityRotation.statement( + deviceID: "dev-1", oldIkSig: pub(old), newIkSig: pub(new)) + let proof = try! old.signature(for: statement).base64EncodedString() + + let verdict = IdentityRotation.judge( + existingIkSig: pub(old), newIkSig: pub(new), + deviceID: "dev-1", proofBase64: proof) + #expect(verdict == .proven) + #expect(IdentityRotation.logKind(verdict) == "rotate") + } + + /// ๐Ÿ”ด **Replay is what the statement's shape prevents.** A proof captured + /// from one rotation must not authorise a different successor. + @Test("a proof cannot be replayed onto a different new key") + func notReplayableToAnotherKey() { + let old = key(), new = key(), attacker = key() + let statement = IdentityRotation.statement( + deviceID: "dev-1", oldIkSig: pub(old), newIkSig: pub(new)) + let proof = try! old.signature(for: statement).base64EncodedString() + + let verdict = IdentityRotation.judge( + existingIkSig: pub(old), newIkSig: pub(attacker), + deviceID: "dev-1", proofBase64: proof) + #expect(verdict == .badProof, "a proof was replayed onto a different key") + #expect(!IdentityRotation.mayProceed(verdict)) + } + + /// ๐Ÿ”ด And not onto another device of the same account. + @Test("a proof cannot be replayed onto a different device") + func notReplayableToAnotherDevice() { + let old = key(), new = key() + let statement = IdentityRotation.statement( + deviceID: "dev-1", oldIkSig: pub(old), newIkSig: pub(new)) + let proof = try! old.signature(for: statement).base64EncodedString() + + let verdict = IdentityRotation.judge( + existingIkSig: pub(old), newIkSig: pub(new), + deviceID: "dev-2", proofBase64: proof) + #expect(verdict == .badProof) + } + + /// ๐Ÿ”ด A proof made by the wrong key is not a proof. + @Test("a proof from any other key is refused") + func wrongSigner() { + let old = key(), new = key(), impostor = key() + let statement = IdentityRotation.statement( + deviceID: "dev-1", oldIkSig: pub(old), newIkSig: pub(new)) + let proof = try! impostor.signature(for: statement).base64EncodedString() + + #expect(IdentityRotation.judge( + existingIkSig: pub(old), newIkSig: pub(new), + deviceID: "dev-1", proofBase64: proof) == .badProof) + } + + @Test("rubbish in the proof field is refused, not ignored") + func rubbishProof() { + let old = key(), new = key() + #expect(IdentityRotation.judge( + existingIkSig: pub(old), newIkSig: pub(new), + deviceID: "dev-1", proofBase64: "!!!not base64!!!") == .badProof) + } + + /// โš ๏ธ **Absent is accepted; wrong is refused.** Every client predating the + /// field sends nothing, and refusing them would break rotation for + /// everybody already installed. A present-and-wrong proof is somebody + /// asking to be believed and failing, which is a different thing. + @Test("an absent proof is accepted but recorded as unproven") + func absentProof() { + let old = key(), new = key() + let verdict = IdentityRotation.judge( + existingIkSig: pub(old), newIkSig: pub(new), + deviceID: "dev-1", proofBase64: nil) + #expect(verdict == .unproven) + #expect(IdentityRotation.mayProceed(verdict), "compatibility was broken") + #expect(IdentityRotation.logKind(verdict) == "rotate_unproven") + } + + @Test("an empty proof counts as absent rather than as rubbish") + func emptyProof() { + let old = key(), new = key() + #expect(IdentityRotation.judge( + existingIkSig: pub(old), newIkSig: pub(new), + deviceID: "dev-1", proofBase64: "") == .unproven) + } + + /// ๐Ÿ”ด The log kinds must differ. It is the record somebody audits later, + /// and one kind with a flag would lose the distinction on the way in. + @Test("proven and unproven are different log kinds") + func distinctKinds() { + #expect(IdentityRotation.logKind(.proven) != IdentityRotation.logKind(.unproven)) + #expect(IdentityRotation.logKind(.badProof) == nil, "a refusal must not be logged as a key") + } + + @Test("an ordinary rotation says nothing and a bad one is an error") + func reporting() throws { + #expect(IdentityRotation.report(.proven, deviceID: "d") == nil) + #expect(IdentityRotation.report(.unchanged, deviceID: "d") == nil) + + let (unprovenIsError, unprovenText) = + try #require(IdentityRotation.report(.unproven, deviceID: "dev-9")) + #expect(!unprovenIsError, "compatibility is not an error") + #expect(unprovenText.contains("rotate_unproven")) + + let (badIsError, badText) = + try #require(IdentityRotation.report(.badProof, deviceID: "dev-9")) + #expect(badIsError) + #expect(badText.contains("REFUSED")) + #expect(badText.contains("worse than none")) + } +}