Skip to content

Commit 8351d07

Browse files
committed
Add OMEMO stale-bundle pruning, dropped-recipient XMPPEvent, fixture JID-mismatch hardening, ChatService.clearRoomState unifier, and notConnectedDescription helper
1 parent d476e9c commit 8351d07

28 files changed

Lines changed: 1931 additions & 128 deletions

IntegrationTests/Tests/DuckoIntegrationTests/OMEMOFixtureFormat.swift

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,44 @@ struct FixtureOMEMOIdentity: Codable {
1616
/// Ed25519 signature over the signed pre-key (64 bytes).
1717
let signedPreKeySignature: [UInt8]
1818
let preKeys: [PreKey]
19+
/// Bare JID this fixture was captured for. Optional with `decodeIfPresent`
20+
/// so legacy fixtures (written before this field existed) keep loading;
21+
/// `loadOMEMOFixture` refuses a fixture whose JID disagrees with the
22+
/// credential at load time so identity material cannot be silently reused
23+
/// across accounts.
24+
let accountJID: String?
25+
26+
init(
27+
deviceID: UInt32,
28+
identityKeyRaw: [UInt8],
29+
signedPreKeyID: UInt32,
30+
signedPreKeyRaw: [UInt8],
31+
signedPreKeySignature: [UInt8],
32+
preKeys: [PreKey],
33+
accountJID: String? = nil
34+
) {
35+
self.deviceID = deviceID
36+
self.identityKeyRaw = identityKeyRaw
37+
self.signedPreKeyID = signedPreKeyID
38+
self.signedPreKeyRaw = signedPreKeyRaw
39+
self.signedPreKeySignature = signedPreKeySignature
40+
self.preKeys = preKeys
41+
self.accountJID = accountJID
42+
}
43+
44+
/// Custom decode so legacy fixtures — written before `accountJID` existed —
45+
/// deserialize cleanly with `accountJID = nil` rather than throwing
46+
/// `keyNotFound`. Mirrors `PreKey.init(from:)` for the same reason.
47+
init(from decoder: Decoder) throws {
48+
let container = try decoder.container(keyedBy: CodingKeys.self)
49+
self.deviceID = try container.decode(UInt32.self, forKey: .deviceID)
50+
self.identityKeyRaw = try container.decode([UInt8].self, forKey: .identityKeyRaw)
51+
self.signedPreKeyID = try container.decode(UInt32.self, forKey: .signedPreKeyID)
52+
self.signedPreKeyRaw = try container.decode([UInt8].self, forKey: .signedPreKeyRaw)
53+
self.signedPreKeySignature = try container.decode([UInt8].self, forKey: .signedPreKeySignature)
54+
self.preKeys = try container.decode([PreKey].self, forKey: .preKeys)
55+
self.accountJID = try container.decodeIfPresent(String.self, forKey: .accountJID)
56+
}
1957

2058
struct PreKey: Codable {
2159
let keyID: UInt32

IntegrationTests/Tests/DuckoIntegrationTests/OMEMOFixtureFormatTests.swift

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import DuckoCore
12
import Foundation
23
import Testing
34

@@ -108,5 +109,121 @@ enum OMEMOFixtureFormatTests {
108109
let decoded = try JSONDecoder().decode(FixtureOMEMOIdentity.self, from: encoded)
109110
#expect(decoded.preKeys.map(\.isUsed) == [true, false])
110111
}
112+
113+
@Test func `legacy fixture without accountJID decodes with accountJID nil`() throws {
114+
let json = """
115+
{
116+
"deviceID": 1,
117+
"identityKeyRaw": \(Array(repeating: 17, count: 32)),
118+
"signedPreKeyID": 7,
119+
"signedPreKeyRaw": \(Array(repeating: 34, count: 32)),
120+
"signedPreKeySignature": \(Array(repeating: 51, count: 64)),
121+
"preKeys": [
122+
{"keyID": 1, "keyRaw": \(Array(repeating: 68, count: 32))}
123+
]
124+
}
125+
"""
126+
let data = try #require(json.data(using: .utf8))
127+
let decoded = try JSONDecoder().decode(FixtureOMEMOIdentity.self, from: data)
128+
#expect(decoded.accountJID == nil)
129+
}
130+
131+
@Test func `fixture round-trips accountJID`() throws {
132+
let fixture = FixtureOMEMOIdentity(
133+
deviceID: 1,
134+
identityKeyRaw: Array(repeating: 0x11, count: 32),
135+
signedPreKeyID: 7,
136+
signedPreKeyRaw: Array(repeating: 0x22, count: 32),
137+
signedPreKeySignature: Array(repeating: 0x33, count: 64),
138+
preKeys: [
139+
FixtureOMEMOIdentity.PreKey(keyID: 1, keyRaw: Array(repeating: 0x44, count: 32))
140+
],
141+
accountJID: "alice@example.com"
142+
)
143+
let encoded = try JSONEncoder().encode(fixture)
144+
let decoded = try JSONDecoder().decode(FixtureOMEMOIdentity.self, from: encoded)
145+
#expect(decoded.accountJID == "alice@example.com")
146+
}
147+
}
148+
149+
/// Locks the JID-mismatch refusal contract that protects against silent
150+
/// identity reuse across recycled fixture files.
151+
struct JIDMatchGate {
152+
@Test func `legacy fixture (no accountJID) is allowed`() {
153+
#expect(TestHarness.fixtureJIDMatchesCredential(nil, credentialJID: "alice@example.com"))
154+
}
155+
156+
@Test func `same JID matches`() {
157+
#expect(TestHarness.fixtureJIDMatchesCredential("alice@example.com", credentialJID: "alice@example.com"))
158+
}
159+
160+
@Test func `different JID is refused`() {
161+
#expect(!TestHarness.fixtureJIDMatchesCredential("alice@example.com", credentialJID: "bob@example.com"))
162+
}
163+
164+
@Test func `case-different localpart matches via BareJID normalization`() {
165+
#expect(TestHarness.fixtureJIDMatchesCredential("Alice@example.com", credentialJID: "alice@example.com"))
166+
}
167+
}
168+
169+
/// Locks the byte-stability invariants in `captureOMEMOFixture` so a
170+
/// regression that drops the prekey sort or changes the encoder output
171+
/// formatting can't silently re-introduce the spurious-rewrite class of
172+
/// bug.
173+
struct ByteStability {
174+
@Test func `sortedFixturePreKeys orders by keyID regardless of input order`() {
175+
let unsorted = [
176+
OMEMOStoredPreKey(accountJID: "alice@example.com", keyID: 7, keyData: Data(repeating: 0x07, count: 32), isUsed: false),
177+
OMEMOStoredPreKey(accountJID: "alice@example.com", keyID: 3, keyData: Data(repeating: 0x03, count: 32), isUsed: true),
178+
OMEMOStoredPreKey(accountJID: "alice@example.com", keyID: 5, keyData: Data(repeating: 0x05, count: 32), isUsed: false)
179+
]
180+
let sorted = TestHarness.sortedFixturePreKeys(unsorted)
181+
#expect(sorted.map(\.keyID) == [3, 5, 7])
182+
#expect(sorted.map(\.isUsed) == [true, false, false])
183+
}
184+
185+
@Test func `encodeFixture is byte-stable across two encodes of the same value`() throws {
186+
let fixture = FixtureOMEMOIdentity(
187+
deviceID: 1,
188+
identityKeyRaw: Array(repeating: 0x11, count: 32),
189+
signedPreKeyID: 7,
190+
signedPreKeyRaw: Array(repeating: 0x22, count: 32),
191+
signedPreKeySignature: Array(repeating: 0x33, count: 64),
192+
preKeys: [
193+
FixtureOMEMOIdentity.PreKey(keyID: 1, keyRaw: Array(repeating: 0x44, count: 32)),
194+
FixtureOMEMOIdentity.PreKey(keyID: 2, keyRaw: Array(repeating: 0x55, count: 32), isUsed: true)
195+
],
196+
accountJID: "alice@example.com"
197+
)
198+
let first = try TestHarness.encodeFixture(fixture)
199+
let second = try TestHarness.encodeFixture(fixture)
200+
#expect(first == second)
201+
}
202+
203+
@Test func `encodeFixture differs when only isUsed flips`() throws {
204+
let unused = FixtureOMEMOIdentity.PreKey(keyID: 1, keyRaw: Array(repeating: 0x44, count: 32), isUsed: false)
205+
let used = FixtureOMEMOIdentity.PreKey(keyID: 1, keyRaw: Array(repeating: 0x44, count: 32), isUsed: true)
206+
let baseline = FixtureOMEMOIdentity(
207+
deviceID: 1,
208+
identityKeyRaw: Array(repeating: 0x11, count: 32),
209+
signedPreKeyID: 7,
210+
signedPreKeyRaw: Array(repeating: 0x22, count: 32),
211+
signedPreKeySignature: Array(repeating: 0x33, count: 64),
212+
preKeys: [unused],
213+
accountJID: "alice@example.com"
214+
)
215+
let consumed = FixtureOMEMOIdentity(
216+
deviceID: 1,
217+
identityKeyRaw: Array(repeating: 0x11, count: 32),
218+
signedPreKeyID: 7,
219+
signedPreKeyRaw: Array(repeating: 0x22, count: 32),
220+
signedPreKeySignature: Array(repeating: 0x33, count: 64),
221+
preKeys: [used],
222+
accountJID: "alice@example.com"
223+
)
224+
let baselineBytes = try TestHarness.encodeFixture(baseline)
225+
let consumedBytes = try TestHarness.encodeFixture(consumed)
226+
#expect(baselineBytes != consumedBytes)
227+
}
111228
}
112229
}

IntegrationTests/Tests/DuckoIntegrationTests/TestHarness.swift

Lines changed: 109 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,13 @@ final class TestHarness {
318318
log.warning("OMEMO fixture for \(credential.label) is malformed; ignoring and allowing fresh identity generation")
319319
return false
320320
}
321+
// Refuse to seed identity material that was captured for a different
322+
// JID — silent reuse across accounts would mask a renamed/recycled
323+
// fixture file.
324+
if !Self.fixtureJIDMatchesCredential(fixture.accountJID, credentialJID: credential.jid) {
325+
log.warning("OMEMO fixture for \(credential.label) was captured for a different JID; ignoring and allowing fresh identity generation")
326+
return false
327+
}
321328

322329
let identity = OMEMOStoredIdentity(
323330
accountJID: credential.jid,
@@ -349,8 +356,11 @@ final class TestHarness {
349356

350357
/// Captures the freshly-generated OMEMO identity for `credential` to the
351358
/// path resolved by `fixtureURL(for:)` so later runs can reuse it via
352-
/// `loadOMEMOFixture`. No-ops when a well-formed fixture is already on
353-
/// disk; overwrites a malformed one.
359+
/// `loadOMEMOFixture`. Writes only when the encoded bytes differ from
360+
/// the on-disk file: idempotent runs stay byte-stable (no spurious git
361+
/// diffs in CI), but a test that consumes prekeys flips an `isUsed`
362+
/// flag, the encoded bytes diverge from disk, and the fixture is
363+
/// rewritten. A malformed fixture is also overwritten and logged.
354364
///
355365
/// `OMEMOService.handleConnected` persists identity, prekeys, and signed
356366
/// prekey via a detached task that outlives `.rosterLoaded`, so this method
@@ -359,64 +369,135 @@ final class TestHarness {
359369
/// run regenerates.
360370
private func captureOMEMOFixture(for credential: TestCredentials.Credential) async throws {
361371
let fixtureURL = Self.fixtureURL(for: credential.label)
362-
363-
if let existing = try? Data(contentsOf: fixtureURL) {
364-
if let decoded = try? JSONDecoder().decode(FixtureOMEMOIdentity.self, from: existing),
365-
decoded.passesShapeInvariants {
372+
// Distinguish "file not present" (continue and write fresh) from
373+
// "file present but unreadable" (bail rather than clobber). A
374+
// transient I/O error reading a well-formed fixture must not result
375+
// in silently overwriting it on disk.
376+
let fileExists = FileManager.default.fileExists(atPath: fixtureURL.path)
377+
let existing: Data?
378+
if fileExists {
379+
do {
380+
existing = try Data(contentsOf: fixtureURL)
381+
} catch {
382+
log.warning("OMEMO fixture for \(credential.label) at \(fixtureURL.path) is present but unreadable (\(error.localizedDescription)); skipping capture to avoid clobbering it")
366383
return
367384
}
368-
log.info("Overwriting malformed OMEMO fixture for \(credential.label) at \(fixtureURL.path)")
385+
} else {
386+
existing = nil
387+
}
388+
let existingDecoded = existing.flatMap { try? JSONDecoder().decode(FixtureOMEMOIdentity.self, from: $0) }
389+
let existingIsMalformed = existing != nil && existingDecoded?.passesShapeInvariants != true
390+
// Refuse to overwrite a well-formed fixture whose accountJID disagrees
391+
// with the credential — `loadOMEMOFixture` already rejected it as a
392+
// mismatch and let connect generate fresh material; silently writing
393+
// the new identity over the existing file would permanently destroy
394+
// the original-account fixture without warning.
395+
if let existingDecoded,
396+
existingDecoded.passesShapeInvariants,
397+
!Self.fixtureJIDMatchesCredential(existingDecoded.accountJID, credentialJID: credential.jid) {
398+
log.warning("OMEMO fixture for \(credential.label) at \(fixtureURL.path) was captured for a different JID; preserving existing file. Move or delete it manually if you intend to reuse the path for \(credential.jid).")
399+
return
369400
}
370401

371-
guard await waitForSignedPreKey(for: credential) else {
372-
log.warning("OMEMO signed prekey for \(credential.label) did not land in store within 10s; skipping fixture capture")
402+
guard let fixture = try await buildFixture(for: credential) else { return }
403+
let encoded = try Self.encodeFixture(fixture)
404+
405+
// Idempotency guard: if the on-disk bytes already match the fixture we
406+
// would write, skip the write. Idempotent test runs stay byte-stable
407+
// (no spurious git diffs in CI), but a test that consumes prekeys —
408+
// flipping their `isUsed` flags in memory — will diff against the
409+
// on-disk copy and trigger a rewrite.
410+
if existing == encoded {
411+
log.debug("OMEMO fixture for \(credential.label) unchanged; skipping write at \(fixtureURL.path)")
373412
return
374413
}
375414

415+
try writeFixture(encoded, to: fixtureURL)
416+
if existingIsMalformed {
417+
log.info("Overwrote malformed OMEMO fixture for \(credential.label) at \(fixtureURL.path)")
418+
} else {
419+
log.debug("OMEMO fixture for \(credential.label) changed; wrote at \(fixtureURL.path)")
420+
}
421+
}
422+
423+
/// Builds a `FixtureOMEMOIdentity` from the OMEMO store after polling the
424+
/// detached persistence task. Returns `nil` when the store has not
425+
/// finished writing within 10s — the next run will regenerate.
426+
private func buildFixture(
427+
for credential: TestCredentials.Credential
428+
) async throws -> FixtureOMEMOIdentity? {
429+
guard await waitForSignedPreKey(for: credential) else {
430+
log.warning("OMEMO signed prekey for \(credential.label) did not land in store within 10s; skipping fixture capture")
431+
return nil
432+
}
376433
guard let storedIdentity = try await omemoStore.loadIdentity(for: credential.jid) else {
377434
log.warning("OMEMO identity for \(credential.label) missing at capture time; skipping fixture capture")
378-
return
435+
return nil
379436
}
380437
let storedPreKeys = try await omemoStore.loadPreKeys(for: credential.jid)
381438
guard let storedSignedPreKey = try await omemoStore.loadSignedPreKey(for: credential.jid) else {
382439
log.warning("OMEMO signed prekey for \(credential.label) missing at capture time; skipping fixture capture")
383-
return
440+
return nil
384441
}
385-
386-
let fixture = FixtureOMEMOIdentity(
442+
return FixtureOMEMOIdentity(
387443
deviceID: storedIdentity.deviceID,
388444
identityKeyRaw: Array(storedIdentity.identityKeyData),
389445
signedPreKeyID: storedSignedPreKey.keyID,
390446
signedPreKeyRaw: Array(storedSignedPreKey.keyData),
391447
signedPreKeySignature: Array(storedSignedPreKey.signature),
392-
preKeys: storedPreKeys.map {
393-
FixtureOMEMOIdentity.PreKey(keyID: $0.keyID, keyRaw: Array($0.keyData), isUsed: $0.isUsed)
394-
}
448+
preKeys: Self.sortedFixturePreKeys(storedPreKeys),
449+
accountJID: credential.jid
395450
)
451+
}
396452

397-
// Create the directory with owner-only access (0700) and write the
398-
// fixture with owner-only permissions (0600) so the long-term identity
399-
// keys don't land at the umask default (0755/0644) on multi-user hosts.
400-
// `createDirectory(attributes:)` is a no-op when the directory already
401-
// exists, so re-apply 0700 explicitly afterward — otherwise a dir
402-
// created by an older harness version at default 0755 stays that way.
453+
/// Writes the encoded fixture to disk with owner-only directory and
454+
/// file permissions so the long-term identity keys don't land at the
455+
/// umask default (0755/0644) on multi-user hosts.
456+
/// `createDirectory(attributes:)` is a no-op when the directory already
457+
/// exists, so re-apply 0700 explicitly afterward — otherwise a dir
458+
/// created by an older harness version at default 0755 stays that way.
459+
private func writeFixture(_ encoded: Data, to fixtureURL: URL) throws {
403460
let directory = fixtureURL.deletingLastPathComponent()
404461
try FileManager.default.createDirectory(
405462
at: directory,
406463
withIntermediateDirectories: true,
407464
attributes: [.posixPermissions: 0o700]
408465
)
409466
try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path)
467+
try encoded.write(to: fixtureURL, options: .atomic)
468+
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fixtureURL.path)
469+
}
410470

471+
/// Deterministic JSON encoding for `FixtureOMEMOIdentity`. Used both for
472+
/// writing the fixture and for the on-disk-vs-in-memory diff in
473+
/// `captureOMEMOFixture` — both sides must agree on key ordering and
474+
/// formatting, otherwise a byte-stable run would false-positive the diff.
475+
/// Pretty-printed JSON honors `OMEMOFixtureFormat`'s "visible to the
476+
/// naked eye when inspecting fixture drift" docstring.
477+
nonisolated static func encodeFixture(_ fixture: FixtureOMEMOIdentity) throws -> Data {
411478
let encoder = JSONEncoder()
412-
// Pretty-printed JSON honors `OMEMOFixtureFormat`'s "visible to the
413-
// naked eye when inspecting fixture drift" docstring.
414479
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
415-
let encoded = try encoder.encode(fixture)
416-
try encoded.write(to: fixtureURL, options: .atomic)
417-
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fixtureURL.path)
480+
return try encoder.encode(fixture)
481+
}
482+
483+
/// Per-keyID-sorted serialization of stored prekeys, matching
484+
/// `captureOMEMOFixture`'s in-memory-side serialization. Exposed so unit
485+
/// tests can assert byte-stability without driving the whole capture flow.
486+
nonisolated static func sortedFixturePreKeys(
487+
_ storedPreKeys: [OMEMOStoredPreKey]
488+
) -> [FixtureOMEMOIdentity.PreKey] {
489+
storedPreKeys
490+
.sorted { $0.keyID < $1.keyID }
491+
.map { FixtureOMEMOIdentity.PreKey(keyID: $0.keyID, keyRaw: Array($0.keyData), isUsed: $0.isUsed) }
492+
}
418493

419-
log.info("Captured generated OMEMO identity for \(credential.label) at \(fixtureURL.path)")
494+
/// Treats a missing (legacy) `captured` as a match (legacy fixtures
495+
/// pre-date the field and load until rewritten). When present, compares
496+
/// as parsed `BareJID` so localpart/domainpart case normalization doesn't
497+
/// trigger a spurious mismatch.
498+
nonisolated static func fixtureJIDMatchesCredential(_ captured: String?, credentialJID: String) -> Bool {
499+
guard let captured else { return true }
500+
return BareJID.parse(captured) == BareJID.parse(credentialJID)
420501
}
421502

422503
/// Polls the OMEMO store for `credential`'s signed-prekey up to 10 s.

Sources/DuckoCLI/Events/CLIEventHandler.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ actor CLIEventHandler {
4444
.jingleContentAddReceived, .jingleContentAccepted,
4545
.jingleContentRejected, .jingleContentRemoved,
4646
.blockListLoaded, .contactBlocked, .contactUnblocked,
47-
.omemoEncryptedMessageReceived,
47+
.omemoEncryptedMessageReceived, .omemoRecipientsPartial,
4848
.serviceOutageReceived:
4949
break
5050
}

0 commit comments

Comments
 (0)