mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 10:25:18 +00:00
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>
134 lines
5.5 KiB
Swift
134 lines
5.5 KiB
Swift
//
|
|
// 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()))
|
|
}
|
|
}
|