Prekey bundles: forward-secret async first contact for courier mail

Courier envelopes were sealed with one-way Noise X to the recipient's
long-lived static key, so a later compromise of that key exposed every
envelope captured in transit. This adds one-time prekey bundles:

- PrekeyBundle (MessageType 0x24): 8 one-time Curve25519 public prekeys
  bound to the owner's Noise static key by an Ed25519 signature over
  "bitchat-prekey-bundle-v1" canonical bytes; gossiped mesh-wide on its
  own 60s sync round (SyncTypeFlags bit 9, 200-peer cap, 24h freshness)
  and verified against the announce-bound signing key before caching.
- Sealed envelope v2: Noise X where the responder static is the one-time
  prekey, prologue "bitchat-prekey-v1" || prekeyID. Sender identity rides
  encrypted inside and is authenticated exactly like v1 (blocked-sender
  check included). CourierEnvelope gains an optional prekeyID TLV that
  v1 decoders skip as unknown.
- Local prekeys live in the Keychain; consumed privates survive a 48h
  grace window for spray-and-wait redeliveries, then are deleted (the
  forward-secrecy clock starts at deletion). The batch tops back up and
  re-gossips when unconsumed count drops below 3, and everything is
  wiped in panic mode.
- Routing: courier sealing picks a cached verified bundle when one
  exists (one prekey per message, reused across deposit retries), with
  the advertised .prekeys capability as a veto for on-mesh peers, and
  falls back to static sealing otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-06 20:14:05 +02:00
co-authored by Claude Fable 5
parent 688b954fb8
commit ee148a018b
19 changed files with 1968 additions and 25 deletions
+234
View File
@@ -0,0 +1,234 @@
//
// NoisePrekeyTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
import CryptoKit
import BitFoundation
@testable import bitchat
/// Forward-secret one-way Noise X envelopes sealed to one-time prekeys
/// instead of the recipient's identity static key.
struct NoisePrekeyTests {
@Test func sealAndOpenRoundTrip() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(bob.currentPrekeyBundle())
let prekey = try #require(bundle.prekeys.first)
let payload = Data("meet at the north gate".utf8)
let sealed = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey)
let opened = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id)
#expect(opened.payload == payload)
// The X pattern authenticates the sender: Bob learns Alice's real static key.
#expect(opened.senderStaticKey == alice.getStaticPublicKeyData())
}
@Test func wrongPrekeyIDCannotOpen() throws {
// The prologue binds the ciphertext to a specific prekey ID; opening
// with a different (existing) prekey must fail.
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(bob.currentPrekeyBundle())
#expect(bundle.prekeys.count >= 2)
let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: bundle.prekeys[0])
#expect(throws: (any Error).self) {
_ = try bob.openPrekeyPayload(sealed, prekeyID: bundle.prekeys[1].id)
}
}
@Test func unknownPrekeyIDThrows() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(bob.currentPrekeyBundle())
let prekey = try #require(bundle.prekeys.first)
let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey)
#expect(throws: NoiseEncryptionError.unknownPrekey) {
_ = try bob.openPrekeyPayload(sealed, prekeyID: 0xDEAD_BEEF)
}
}
@Test func wrongRecipientCannotOpen() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let carol = NoiseEncryptionService(keychain: MockKeychain())
let bobBundle = try #require(bob.currentPrekeyBundle())
// Ensure Carol holds a prekey under the same ID as Bob's.
_ = try #require(carol.currentPrekeyBundle())
let prekey = try #require(bobBundle.prekeys.first)
let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey)
#expect(throws: (any Error).self) {
_ = try carol.openPrekeyPayload(sealed, prekeyID: prekey.id)
}
}
@Test func tamperedCiphertextFailsToOpen() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(bob.currentPrekeyBundle())
let prekey = try #require(bundle.prekeys.first)
var sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey)
sealed[sealed.count - 1] ^= 0x01
#expect(throws: (any Error).self) {
_ = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id)
}
}
@Test func consumedPrekeyStillOpensRedeliveredCiphertext() throws {
// Spray-and-wait can deliver the same ciphertext via several couriers
// days apart; the consumed private survives a grace window for that.
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(bob.currentPrekeyBundle())
let prekey = try #require(bundle.prekeys.first)
let sealed = try alice.sealPrekeyPayload(Data("hello".utf8), recipientPrekey: prekey)
let first = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id)
let second = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id)
#expect(first.payload == second.payload)
}
@Test func prekeyAndStaticSealsAreNotInterchangeable() throws {
// Domain-separated prologues: a static-sealed envelope must not open
// via the prekey path and vice versa, even with matching key material.
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(bob.currentPrekeyBundle())
let prekey = try #require(bundle.prekeys.first)
let staticSealed = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: bob.getStaticPublicKeyData())
#expect(throws: (any Error).self) {
_ = try bob.openPrekeyPayload(staticSealed, prekeyID: prekey.id)
}
let prekeySealed = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: prekey)
#expect(throws: (any Error).self) {
_ = try bob.openCourierPayload(prekeySealed)
}
}
@Test func sealRejectsInvalidPrekeyPublicKey() {
let alice = NoiseEncryptionService(keychain: MockKeychain())
#expect(throws: (any Error).self) {
_ = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: PrekeyBundle.Prekey(id: 1, publicKey: Data(repeating: 0, count: 32)))
}
#expect(throws: (any Error).self) {
_ = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: PrekeyBundle.Prekey(id: 1, publicKey: Data(repeating: 1, count: 8)))
}
}
@Test func sealsAreNotLinkableAcrossSends() throws {
// Fresh ephemeral per seal even to the same prekey.
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(bob.currentPrekeyBundle())
let prekey = try #require(bundle.prekeys.first)
let payload = Data("same message".utf8)
let a = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey)
let b = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey)
#expect(a != b)
#expect(a.prefix(32) != b.prefix(32))
}
}
/// Local one-time prekey lifecycle: batch generation, consumption, the 48h
/// redelivery grace window, replenishment, and the panic wipe.
struct LocalPrekeyStoreTests {
private final class Clock {
var now: Date
init(_ now: Date = Date()) { self.now = now }
}
private func makeStore(clock: Clock, keychain: MockKeychain = MockKeychain()) -> LocalPrekeyStore {
LocalPrekeyStore(keychain: keychain, now: { clock.now })
}
@Test func mintsFullBatchOnFirstUse() {
let store = makeStore(clock: Clock())
let (prekeys, generatedAt) = store.currentBundlePrekeys()
#expect(prekeys.count == LocalPrekeyStore.Policy.batchSize)
#expect(generatedAt > 0)
#expect(Set(prekeys.map(\.id)).count == prekeys.count)
}
@Test func consumptionBelowThresholdTriggersReplenishAndBumpsGeneration() {
let clock = Clock()
let store = makeStore(clock: clock)
let (initial, firstGeneratedAt) = store.currentBundlePrekeys()
// Consuming down to the threshold does not regenerate...
let keepUnconsumed = LocalPrekeyStore.Policy.replenishThreshold
for prekey in initial.dropLast(keepUnconsumed) {
store.markConsumed(prekey.id)
}
#expect(!store.replenishIfNeeded())
#expect(store.unconsumedCount == keepUnconsumed)
// ...one more consumption does, topping back up to a full batch with
// a newer generation stamp.
clock.now = clock.now.addingTimeInterval(60)
store.markConsumed(initial[initial.count - keepUnconsumed].id)
#expect(store.replenishIfNeeded())
let (replenished, secondGeneratedAt) = store.currentBundlePrekeys()
#expect(replenished.count == LocalPrekeyStore.Policy.batchSize)
#expect(secondGeneratedAt > firstGeneratedAt)
// Surviving unconsumed prekeys stay in the fresh bundle.
let survivorIDs = Set(initial.suffix(keepUnconsumed - 1).map(\.id))
#expect(survivorIDs.isSubset(of: Set(replenished.map(\.id))))
}
@Test func consumedPrivateSurvivesGraceWindowThenDies() {
let clock = Clock()
let store = makeStore(clock: clock)
let (prekeys, _) = store.currentBundlePrekeys()
let id = prekeys[0].id
store.markConsumed(id)
// Within the grace window: still retrievable for redeliveries.
clock.now = clock.now.addingTimeInterval(LocalPrekeyStore.Policy.consumedGraceSeconds - 60)
#expect(store.privateKey(for: id) != nil)
// Past the grace window: gone (even before replenish prunes it).
clock.now = clock.now.addingTimeInterval(120)
#expect(store.privateKey(for: id) == nil)
store.replenishIfNeeded()
#expect(store.privateKey(for: id) == nil)
}
@Test func persistsAcrossInstances() {
let keychain = MockKeychain()
let clock = Clock()
let first = LocalPrekeyStore(keychain: keychain, now: { clock.now })
let (prekeys, generatedAt) = first.currentBundlePrekeys()
first.markConsumed(prekeys[0].id)
let second = LocalPrekeyStore(keychain: keychain, now: { clock.now })
let (reloaded, reloadedGeneratedAt) = second.currentBundlePrekeys()
#expect(reloadedGeneratedAt == generatedAt)
#expect(Set(reloaded.map(\.id)) == Set(prekeys.dropFirst().map(\.id)))
// The consumed key is still openable within grace after a relaunch.
#expect(second.privateKey(for: prekeys[0].id) != nil)
}
@Test func wipeRemovesEverything() {
let keychain = MockKeychain()
let store = LocalPrekeyStore(keychain: keychain)
let (prekeys, _) = store.currentBundlePrekeys()
store.wipe()
#expect(store.privateKey(for: prekeys[0].id) == nil)
#expect(keychain.getIdentityKey(forKey: "prekeysV1") == nil)
}
}
@@ -0,0 +1,197 @@
//
// PrekeyBundleStoreTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
import CryptoKit
import BitFoundation
@testable import bitchat
/// Sender-side cache of peers' verified prekey bundles: latest-wins ingest,
/// per-message prekey assignment (never reused across messages), expiry, and
/// the peer cap.
struct PrekeyBundleStoreTests {
private func makeBundle(
noiseKey: Data = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation,
ids: [UInt32] = [0, 1, 2],
generatedAt: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000)
) -> PrekeyBundle {
PrekeyBundle(
noiseStaticPublicKey: noiseKey,
prekeys: ids.map { PrekeyBundle.Prekey(id: $0, publicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation) },
generatedAt: generatedAt,
signature: Data(count: PrekeyBundle.signatureLength)
)
}
@Test func ingestKeepsLatestByGeneratedAt() {
let store = PrekeyBundleStore(persistsToDisk: false)
let noiseKey = Data(repeating: 0xB0, count: 32)
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
let old = makeBundle(noiseKey: noiseKey, ids: [0, 1], generatedAt: nowMs - 1000)
let new = makeBundle(noiseKey: noiseKey, ids: [2, 3], generatedAt: nowMs)
#expect(store.ingest(new))
// Older (and equal) bundles never displace a newer one.
#expect(!store.ingest(old))
#expect(!store.ingest(new))
let assigned = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)
#expect(assigned?.id == 2)
}
@Test func assignmentsConsumeDistinctPrekeysPerMessage() {
let store = PrekeyBundleStore(persistsToDisk: false)
let noiseKey = Data(repeating: 0xB1, count: 32)
#expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [10, 11])))
let first = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)
let second = store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)
#expect(first?.id == 10)
#expect(second?.id == 11)
// Exhausted: fall back to static sealing.
#expect(store.assignPrekey(messageID: "m3", recipientNoiseKey: noiseKey) == nil)
#expect(!store.hasUsableBundle(for: noiseKey))
}
@Test func redepositOfSameMessageReusesItsPrekey() {
let store = PrekeyBundleStore(persistsToDisk: false)
let noiseKey = Data(repeating: 0xB2, count: 32)
#expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [5, 6, 7])))
let first = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)
let retry = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)
#expect(first?.id == retry?.id)
#expect(first?.publicKey == retry?.publicKey)
// Only one prekey was burned.
let next = store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)
#expect(next?.id == 6)
}
@Test func topUpBundleKeepsConsumptionStateForSurvivingIDs() {
let store = PrekeyBundleStore(persistsToDisk: false)
let noiseKey = Data(repeating: 0xB3, count: 32)
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
#expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [0, 1], generatedAt: nowMs - 1000)))
#expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 0)
// The owner topped up: ID 1 survives (still unconsumed on their side),
// ID 0 rotated out, new IDs appear.
#expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [1, 8, 9], generatedAt: nowMs)))
// m1's assignment referenced a rotated-out ID; a re-deposit picks a
// fresh one rather than sealing to a dead key.
#expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 1)
#expect(store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id == 8)
}
@Test func expiredBundleIsNeverUsed() {
var current = Date()
let store = PrekeyBundleStore(persistsToDisk: false, now: { current })
let noiseKey = Data(repeating: 0xB4, count: 32)
#expect(store.ingest(makeBundle(noiseKey: noiseKey, generatedAt: UInt64(current.timeIntervalSince1970 * 1000))))
#expect(store.hasUsableBundle(for: noiseKey))
current = current.addingTimeInterval(PrekeyBundleStore.Limits.maxBundleAgeForSealingSeconds + 60)
#expect(!store.hasUsableBundle(for: noiseKey))
#expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) == nil)
}
@Test func peerCapEvictsLeastRecentlyUpdated() {
var current = Date()
let store = PrekeyBundleStore(persistsToDisk: false, maxPeers: 2, now: { current })
let keys = (0..<3).map { Data(repeating: UInt8(0xC0 + $0), count: 32) }
for key in keys {
#expect(store.ingest(makeBundle(noiseKey: key, generatedAt: UInt64(current.timeIntervalSince1970 * 1000))))
current = current.addingTimeInterval(1)
}
// Oldest entry evicted; the two most recent survive.
#expect(!store.hasUsableBundle(for: keys[0]))
#expect(store.hasUsableBundle(for: keys[1]))
#expect(store.hasUsableBundle(for: keys[2]))
}
@Test func persistsAcrossInstancesAndWipes() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("prekey-bundle-store-tests-\(UUID().uuidString)", isDirectory: true)
let fileURL = dir.appendingPathComponent("bundles.json")
defer { try? FileManager.default.removeItem(at: dir) }
let noiseKey = Data(repeating: 0xB5, count: 32)
let first = PrekeyBundleStore(fileURL: fileURL)
#expect(first.ingest(makeBundle(noiseKey: noiseKey, ids: [1, 2])))
#expect(first.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 1)
// Consumption state survives a relaunch, so a restart can't reuse a prekey.
let second = PrekeyBundleStore(fileURL: fileURL)
#expect(second.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id == 2)
second.wipe()
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
let third = PrekeyBundleStore(fileURL: fileURL)
#expect(!third.hasUsableBundle(for: noiseKey))
}
}
/// Envelope v2 wire compatibility: the prekey ID rides an optional TLV that
/// v1 decoders skip as unknown.
struct CourierEnvelopeV2Tests {
@Test func prekeyIDRoundTrips() throws {
let envelope = CourierEnvelope(
recipientTag: Data(repeating: 0x11, count: CourierEnvelope.tagLength),
expiry: UInt64(Date().timeIntervalSince1970 * 1000) + 60_000,
ciphertext: Data("ciphertext".utf8),
copies: 4,
prekeyID: 0xAABB_CCDD
)
let encoded = try #require(envelope.encode())
let decoded = try #require(CourierEnvelope.decode(encoded))
#expect(decoded == envelope)
#expect(decoded.prekeyID == 0xAABB_CCDD)
}
@Test func v1EnvelopeDecodesWithNilPrekeyID() throws {
let envelope = CourierEnvelope(
recipientTag: Data(repeating: 0x22, count: CourierEnvelope.tagLength),
expiry: UInt64(Date().timeIntervalSince1970 * 1000) + 60_000,
ciphertext: Data("legacy".utf8)
)
let encoded = try #require(envelope.encode())
let decoded = try #require(CourierEnvelope.decode(encoded))
#expect(decoded.prekeyID == nil)
}
@Test func v1EncodingIsByteIdenticalWithoutPrekeyID() throws {
// Static-sealed envelopes must stay on the pre-prekey wire format.
let tag = Data(repeating: 0x33, count: CourierEnvelope.tagLength)
let expiry: UInt64 = 1_800_000_000_000
let ciphertext = Data("same".utf8)
let v1 = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext).encode())
let v1Explicit = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext, prekeyID: nil).encode())
#expect(v1 == v1Explicit)
// And a v2 envelope is the v1 bytes plus one trailing TLV a v1
// decoder skips as unknown.
let v2 = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext, prekeyID: 7).encode())
#expect(v2.prefix(v1.count) == v1)
#expect(v2.count == v1.count + 3 + 4)
}
@Test func withCopiesPreservesPrekeyID() {
let envelope = CourierEnvelope(
recipientTag: Data(repeating: 0x44, count: CourierEnvelope.tagLength),
expiry: 1,
ciphertext: Data([0x01]),
copies: 4,
prekeyID: 9
)
#expect(envelope.withCopies(2).prekeyID == 9)
}
}
@@ -0,0 +1,133 @@
//
// PrekeyBundleTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
import CryptoKit
import BitFoundation
@testable import bitchat
/// Wire format and signature binding for gossiped one-time prekey bundles.
struct PrekeyBundleTests {
private func makePrekeys(_ count: Int) -> [PrekeyBundle.Prekey] {
(0..<count).map { index in
PrekeyBundle.Prekey(
id: UInt32(index),
publicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
)
}
}
// MARK: - Wire format
@Test func encodeDecodeRoundTrip() throws {
let bundle = PrekeyBundle(
noiseStaticPublicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation,
prekeys: makePrekeys(8),
generatedAt: 1_234_567_890_123,
signature: Data(repeating: 0xAB, count: PrekeyBundle.signatureLength)
)
let encoded = try #require(bundle.encode())
let decoded = try #require(PrekeyBundle.decode(encoded))
#expect(decoded == bundle)
}
@Test func decodeSkipsUnknownTLVs() throws {
let bundle = PrekeyBundle(
noiseStaticPublicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation,
prekeys: makePrekeys(2),
generatedAt: 42,
signature: Data(repeating: 0x01, count: PrekeyBundle.signatureLength)
)
var encoded = try #require(bundle.encode())
// Unknown TLV 0x7F appended by a future client.
encoded.append(contentsOf: [0x7F, 0x00, 0x03, 0x01, 0x02, 0x03])
let decoded = try #require(PrekeyBundle.decode(encoded))
#expect(decoded == bundle)
}
@Test func encodeRejectsInvalidShapes() {
let key = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
let signature = Data(repeating: 0, count: PrekeyBundle.signatureLength)
// No prekeys.
#expect(PrekeyBundle(noiseStaticPublicKey: key, prekeys: [], generatedAt: 1, signature: signature).encode() == nil)
// Too many prekeys.
#expect(PrekeyBundle(noiseStaticPublicKey: key, prekeys: makePrekeys(PrekeyBundle.maxPrekeys + 1), generatedAt: 1, signature: signature).encode() == nil)
// Wrong owner key length.
#expect(PrekeyBundle(noiseStaticPublicKey: Data(repeating: 1, count: 8), prekeys: makePrekeys(1), generatedAt: 1, signature: signature).encode() == nil)
// Wrong signature length.
#expect(PrekeyBundle(noiseStaticPublicKey: key, prekeys: makePrekeys(1), generatedAt: 1, signature: Data(repeating: 0, count: 32)).encode() == nil)
}
@Test func decodeRejectsDuplicatePrekeyIDs() throws {
let key = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
let prekey = makePrekeys(1)[0]
let bundle = PrekeyBundle(
noiseStaticPublicKey: key,
prekeys: [prekey, PrekeyBundle.Prekey(id: prekey.id, publicKey: key)],
generatedAt: 1,
signature: Data(repeating: 0, count: PrekeyBundle.signatureLength)
)
let encoded = try #require(bundle.encode())
#expect(PrekeyBundle.decode(encoded) == nil)
}
// MARK: - Signature binding
@Test func signVerifyRoundTrip() throws {
let owner = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(owner.currentPrekeyBundle())
#expect(bundle.noiseStaticPublicKey == owner.getStaticPublicKeyData())
#expect(bundle.prekeys.count == PrekeyBundle.maxPrekeys)
#expect(owner.verifyPrekeyBundleSignature(bundle, signingPublicKey: owner.getSigningPublicKeyData()))
}
@Test func forgedSignatureFailsVerification() throws {
let owner = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(owner.currentPrekeyBundle())
var forgedSignature = bundle.signature
forgedSignature[0] ^= 0x01
let forged = PrekeyBundle(
noiseStaticPublicKey: bundle.noiseStaticPublicKey,
prekeys: bundle.prekeys,
generatedAt: bundle.generatedAt,
signature: forgedSignature
)
#expect(!owner.verifyPrekeyBundleSignature(forged, signingPublicKey: owner.getSigningPublicKeyData()))
}
@Test func tamperedContentsFailVerification() throws {
let owner = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(owner.currentPrekeyBundle())
// Mallory swaps in her own prekey but keeps the owner's signature.
var prekeys = bundle.prekeys
prekeys[0] = PrekeyBundle.Prekey(
id: prekeys[0].id,
publicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
)
let tampered = PrekeyBundle(
noiseStaticPublicKey: bundle.noiseStaticPublicKey,
prekeys: prekeys,
generatedAt: bundle.generatedAt,
signature: bundle.signature
)
#expect(!owner.verifyPrekeyBundleSignature(tampered, signingPublicKey: owner.getSigningPublicKeyData()))
}
@Test func wrongSigningKeyFailsVerification() throws {
let owner = NoiseEncryptionService(keychain: MockKeychain())
let other = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(owner.currentPrekeyBundle())
#expect(!owner.verifyPrekeyBundleSignature(bundle, signingPublicKey: other.getSigningPublicKeyData()))
}
}