Rotate identity keys, not only prekeys (relay#25) #98
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user