mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 13:05:21 +00:00
Prekey bundles: forward-secret async first contact for courier mail (#1381)
* Add capability bits to announce TLV Announces now carry an optional capabilities TLV (0x05): a little-endian bitfield with named bits for upcoming features (prekeys, wifiBulk, gateway, groups, board, vouch, meshDiagnostics). Old clients skip the unknown TLV; peers without it decode as nil so features can distinguish "legacy peer" from "advertises nothing". PeerCapabilities lives in BitFoundation with a minimal-length encoding that preserves unknown bits for forward compatibility. Peer capabilities are stored in the BLE peer registry on verified announce and exposed via BLEService.peerCapabilities(_:). The local advertisement set is empty until each feature ships its bit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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> * Prekeys: authenticate bundle packets, fix consume-republish, deflake CI Fixes the prekey-bundle PR review + CI failure: - CI root cause: the receive queue (mesh.message) is concurrent, so a gossiped prekey bundle can be processed before the announce that binds its owner's signing key. The old handler dropped such bundles outright, so under CI parallel load the bundle was permanently lost and the cache/gossip tests flaked (verifiedBundleEntersGossipStore, prekeySealedMailTravelsViaCourierAndOpens). Bundles that arrive before their binding are now retained per-owner (bounded) and re-attempted when the verified announce lands, atomically to avoid a check-then-act race. - Authenticate the OUTER prekey-bundle packet (Codex P2 / review MEDIUM): require senderID == PeerID(bundle.noiseStaticPublicKey) and verify the packet's Ed25519 signature (covers senderID + timestamp) against the owner's bound signing key, in addition to the inner bundle signature. Stops replay under a fresh timestamp / fake senderID. - Key the gossip prekey-bundle store/dedup by the bundle's authenticated identity (noiseStaticPublicKey), not the unauthenticated packet senderID, so one valid bundle sprayed under many fabricated sender IDs can't multiply entries and exhaust the 200-owner cap. - Bump published-bundle generatedAt strictly on consume (Codex P1): consuming a prekey shrinks the published bundle, so it now republishes with a strictly newer generatedAt and re-gossips, so peers replace the cached copy and stop assigning the consumed ID before its 48h grace. - Guard the panic/clear detached Application Support tree-deletes behind TestEnvironment.isRunningTests: the SPM test process shares that tree, so the wipe could land mid-test and flake file-dependent tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Update sync tests for prekeyBundle as bit 9 / default sync round Prekeys makes bit 9 (prekeyBundle) a known SyncTypeFlags bit and enables a prekey sync round by default. That broke tests authored by other PRs that assumed bit 9 was phantom or that only their own sync round fires: - SyncTypeFlags(Board)Tests: move the "unknown bits" probes to bits 10+ (0xFE -> 0xFC / 0xFD), since bit 9 is now assigned. - GossipSync(Board)Tests + GossipSyncManagerTests: disable the prekey sync round in configs that run maintenance (as they already do for message/ fragment/fileTransfer), so they isolate the behavior under test. Full app suite (1301 tests) green locally via SPM. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
jack
Claude Fable 5
parent
f9032cf2b9
commit
87910541ef
@@ -172,6 +172,10 @@ final class NoiseEncryptionService {
|
||||
// Security components
|
||||
private let rateLimiter = NoiseRateLimiter()
|
||||
private let keychain: KeychainManagerProtocol
|
||||
|
||||
// One-time prekeys for forward-secret courier sealing (lazy generation
|
||||
// inside the store; the batch is minted on first bundle build).
|
||||
private let localPrekeys: LocalPrekeyStore
|
||||
|
||||
// Session maintenance
|
||||
private var rekeyTimer: Timer?
|
||||
@@ -200,6 +204,7 @@ final class NoiseEncryptionService {
|
||||
|
||||
init(keychain: KeychainManagerProtocol) {
|
||||
self.keychain = keychain
|
||||
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
|
||||
|
||||
// BCH-01-009: Load or create static identity key with proper error handling
|
||||
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
||||
@@ -412,13 +417,111 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
return (payload: payload, senderStaticKey: senderKey.rawRepresentation)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - One-Time Prekey Envelopes (forward-secret Noise X)
|
||||
|
||||
/// Domain separation for prekey-sealed envelopes: distinct from both the
|
||||
/// interactive XX transcripts and static-sealed courier envelopes, and
|
||||
/// bound to the specific prekey ID so a ciphertext cannot be replayed
|
||||
/// against a different prekey.
|
||||
private static let prekeyProloguePrefix = Data("bitchat-prekey-v1".utf8)
|
||||
|
||||
private static func prekeyPrologue(for prekeyID: UInt32) -> Data {
|
||||
var prologue = prekeyProloguePrefix
|
||||
var big = prekeyID.bigEndian
|
||||
withUnsafeBytes(of: &big) { prologue.append(contentsOf: $0) }
|
||||
return prologue
|
||||
}
|
||||
|
||||
/// Encrypt a payload to one of the recipient's gossiped one-time prekeys
|
||||
/// (Noise X where the responder static is the prekey, not the identity
|
||||
/// key). Unlike `sealCourierPayload`, this is forward secret: once the
|
||||
/// recipient consumes the prekey and its grace window lapses, the private
|
||||
/// key is deleted and captured ciphertext becomes undecryptable even if
|
||||
/// the recipient's identity key is later compromised. The initiator's
|
||||
/// static still rides inside (encrypted), so the recipient authenticates
|
||||
/// the sender exactly as with static-sealed envelopes.
|
||||
func sealPrekeyPayload(_ payload: Data, recipientPrekey: PrekeyBundle.Prekey) throws -> Data {
|
||||
let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientPrekey.publicKey)
|
||||
let handshake = NoiseHandshakeState(
|
||||
role: .initiator,
|
||||
pattern: .X,
|
||||
keychain: keychain,
|
||||
localStaticKey: staticIdentityKey,
|
||||
remoteStaticKey: remoteKey,
|
||||
prologue: Self.prekeyPrologue(for: recipientPrekey.id)
|
||||
)
|
||||
return try handshake.writeMessage(payload: payload)
|
||||
}
|
||||
|
||||
/// Decrypt an envelope sealed to one of our one-time prekeys. On success
|
||||
/// the prekey is marked consumed (its private key survives a 48h grace
|
||||
/// window for spray-and-wait redeliveries, then is deleted for good).
|
||||
/// Returns the payload, the sender's authenticated static key (same
|
||||
/// contract as `openCourierPayload`), and whether this open actually
|
||||
/// retired the prekey — false for a redelivery of already-consumed mail —
|
||||
/// so the caller can re-gossip the shrunken bundle only when it changed.
|
||||
func openPrekeyPayload(_ envelopeCiphertext: Data, prekeyID: UInt32) throws -> (payload: Data, senderStaticKey: Data, consumedPrekey: Bool) {
|
||||
guard let prekeyPrivate = localPrekeys.privateKey(for: prekeyID) else {
|
||||
throw NoiseEncryptionError.unknownPrekey
|
||||
}
|
||||
let handshake = NoiseHandshakeState(
|
||||
role: .responder,
|
||||
pattern: .X,
|
||||
keychain: keychain,
|
||||
localStaticKey: prekeyPrivate,
|
||||
prologue: Self.prekeyPrologue(for: prekeyID)
|
||||
)
|
||||
let payload = try handshake.readMessage(envelopeCiphertext)
|
||||
guard let senderKey = handshake.getRemoteStaticPublicKey() else {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let consumedPrekey = localPrekeys.markConsumed(prekeyID)
|
||||
return (payload: payload, senderStaticKey: senderKey.rawRepresentation, consumedPrekey: consumedPrekey)
|
||||
}
|
||||
|
||||
/// Current signed prekey bundle for gossip, minting the initial batch on
|
||||
/// first use. Nil only when signing fails.
|
||||
func currentPrekeyBundle() -> PrekeyBundle? {
|
||||
let (prekeys, generatedAt) = localPrekeys.currentBundlePrekeys()
|
||||
guard !prekeys.isEmpty else { return nil }
|
||||
let unsigned = PrekeyBundle(
|
||||
noiseStaticPublicKey: getStaticPublicKeyData(),
|
||||
prekeys: prekeys,
|
||||
generatedAt: generatedAt,
|
||||
signature: Data(count: PrekeyBundle.signatureLength)
|
||||
)
|
||||
guard let signature = signData(unsigned.signableBytes()) else { return nil }
|
||||
return PrekeyBundle(
|
||||
noiseStaticPublicKey: unsigned.noiseStaticPublicKey,
|
||||
prekeys: prekeys,
|
||||
generatedAt: generatedAt,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
|
||||
/// Verify a peer's bundle signature against their announce-bound Ed25519
|
||||
/// signing key.
|
||||
func verifyPrekeyBundleSignature(_ bundle: PrekeyBundle, signingPublicKey: Data) -> Bool {
|
||||
verifySignature(bundle.signature, for: bundle.signableBytes(), publicKey: signingPublicKey)
|
||||
}
|
||||
|
||||
/// Prune dead prekeys and top the batch back up when consumption runs it
|
||||
/// low. Returns true when the published bundle changed and should be
|
||||
/// re-gossiped.
|
||||
@discardableResult
|
||||
func replenishPrekeysIfNeeded() -> Bool {
|
||||
localPrekeys.replenishIfNeeded()
|
||||
}
|
||||
|
||||
/// Clear persistent identity (for panic mode)
|
||||
func clearPersistentIdentity() {
|
||||
// Clear from keychain
|
||||
let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey")
|
||||
let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey")
|
||||
SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning)
|
||||
// One-time prekey privates go with the identity they were bound to.
|
||||
localPrekeys.wipe()
|
||||
SecureLogger.warning("Panic mode activated - identity cleared", category: .security)
|
||||
// Stop rekey timer
|
||||
stopRekeyTimer()
|
||||
@@ -812,4 +915,7 @@ struct NoiseMessage: Codable {
|
||||
enum NoiseEncryptionError: Error {
|
||||
case handshakeRequired
|
||||
case sessionNotEstablished
|
||||
/// Envelope references a prekey ID we don't hold (never ours, already
|
||||
/// deleted after its grace window, or wiped in a panic).
|
||||
case unknownPrekey
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user