Rotate identity keys, not only prekeys (relay#25) #98

Merged
paulhitt merged 1 commits from feat/rotate-identity-keys into main 2026-09-04 20:40:47 +00:00
4 changed files with 306 additions and 1 deletions
@@ -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.")
}
}
}
+25 -1
View File
@@ -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,
+7
View File
@@ -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|<device>|<old>|<new>`.
///
/// ⚠️ 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 }
@@ -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"))
}
}