From cde60ed4adaa79ee0f503ed3547b310a28c0c303 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Sun, 4 Oct 2026 20:05:05 -0700 Subject: [PATCH] Add PQSession.opensHeader ownership probe A pure read over the existing openIncoming path: true when one of the session's header receive-window keys authenticates the blob, false for a foreign or out-of-window frame, true on a post-auth throw. Ownership equals openability, so this is the engine's ownership signal for a pooled session. Co-Authored-By: Claude Code --- .changeset/add-opens-header-probe.md | 8 ++ Sources/TwoMLSPQ/PQSession.swift | 21 +++++ .../TwoMLSPQTests/OpensHeaderProbeTests.swift | 85 +++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 .changeset/add-opens-header-probe.md create mode 100644 Tests/TwoMLSPQTests/OpensHeaderProbeTests.swift diff --git a/.changeset/add-opens-header-probe.md b/.changeset/add-opens-header-probe.md new file mode 100644 index 0000000..a8a3e28 --- /dev/null +++ b/.changeset/add-opens-header-probe.md @@ -0,0 +1,8 @@ +--- +"@germ-network/two-mls-pq": minor +--- + +Add a public additive `PQSession.opensHeader(_:)` probe that reports whether one +of the session's header receive-window keys authenticates a blob — the engine's +ownership signal for a pooled session. It is a pure read over the existing +`openIncoming` path: no state mutation, no persistence push, and never throws. diff --git a/Sources/TwoMLSPQ/PQSession.swift b/Sources/TwoMLSPQ/PQSession.swift index b2db3f0..3e888b5 100644 --- a/Sources/TwoMLSPQ/PQSession.swift +++ b/Sources/TwoMLSPQ/PQSession.swift @@ -745,6 +745,27 @@ public struct PQSession { } } + /// Whether one of this session's header receive-window keys (either family) + /// authenticated `blob` — i.e. the frame was sealed by the peer of THIS + /// session. `false` for an out-of-window or foreign frame (a sibling's frame + /// never authenticates, deterministically). A PURE READ: no state mutation, no + /// persistence push. Never throws. + /// + /// Every blob-dependent throw on this path fires only AFTER a receive-window + /// key authenticated the blob (an unrecognized inner tag is `DecryptionFailed`; + /// a malformed length prefix is `Mls`) — the ownership evidence is the + /// authenticated header, not the classified inner frame, so such a throw + /// answers `true`. The one blob-independent pre-auth throw (a header-AEAD + /// config failure) would answer `true` for every blob and is caught by the + /// owner/sibling tests. + public func opensHeader(_ blob: Data) -> Bool { + do { + return try base.openIncoming(blob: blob) != nil + } catch { + return true + } + } + /// Approve a staged remote proposal by its digest. /// /// `digest` must be the bytes this package emitted (`PQQueuedRemoteProposal.digest`, or diff --git a/Tests/TwoMLSPQTests/OpensHeaderProbeTests.swift b/Tests/TwoMLSPQTests/OpensHeaderProbeTests.swift new file mode 100644 index 0000000..e5e48f7 --- /dev/null +++ b/Tests/TwoMLSPQTests/OpensHeaderProbeTests.swift @@ -0,0 +1,85 @@ +// +// OpensHeaderProbeTests.swift +// TwoMLSPQ +// +// Pins `PQSession.opensHeader(_:)`: the header receive-window ownership probe. +// Ownership == header openability, so the owner's peer frame probes true, an +// unrelated session probes false, and the probe consumes nothing. The throw→true +// branch is unreachable from the public API with an honest peer (a post-auth +// throw needs a key-authenticated blob with a bad inner tag) and is not +// exercised here. +// + +import CommProtocol +import Foundation +import Testing + +@testable import TwoMLSPQ + +struct OpensHeaderProbeTests { + + /// A fully classically-established pair: `initiator` and `acceptor` exchange + /// message frames. Mirrors LifecycleTests' establishment through the shared + /// TestSupport helpers rather than inventing a new flow. + private func makeEstablishedPair() throws -> ( + initiator: PQSession, acceptor: PQSession + ) { + let local = try ClientWrapper() + let remote = try ClientWrapper() + + let (initiator, welcome, myKeyPackage, bootstrapKpCommitment) = + try local.client.reply( + keyPackageMessage: remote.currentInvitation.encodedKeyPackage + ) + let dedicatedId: ClientID = .mock() + let (acceptor, stapled) = try remote.currentInvitation.receive( + sendGroupWelcome: welcome, + remoteKeyPackage: myKeyPackage, + bootstrapKpCommitment: bootstrapKpCommitment, + remoteClientId: try local.clientId, + welcomeToken: WelcomeToken(PQDigest.over(welcome)), + stapledMessage: nil, + newClientId: dedicatedId + ) + #expect(stapled == nil) + try acceptor.installMockEstablishmentEnvelope() + // The acceptor's first frame staples its enveloped return welcome; the + // initiator pauses, verifies, and resumes — both sides now exchange messages. + try initiator.acceptEstablishment(from: acceptor, dedicatedId: dedicatedId) + return (initiator, acceptor) + } + + /// A message frame the acceptor sealed — the initiator's peer, so the + /// initiator's header receive window owns it. + private func acceptorFrame(from acceptor: PQSession, message: Data) throws -> Data { + _ = try acceptor.prepareToEncrypt(proposing: nil) + return try acceptor.encrypt(appMessage: message).cipherText + } + + @Test func ownerProbesTrue() throws { + let (initiator, acceptor) = try makeEstablishedPair() + let frame = try acceptorFrame(from: acceptor, message: Data("owner".utf8)) + #expect(initiator.opensHeader(frame)) + } + + @Test func siblingProbesFalse() throws { + let (initiator, acceptor) = try makeEstablishedPair() + let (siblingInitiator, _) = try makeEstablishedPair() + let frame = try acceptorFrame(from: acceptor, message: Data("owner".utf8)) + // The owner's own frame probes true; an unrelated, independently-established + // session holds no receive-window key for it — deterministically false. + #expect(initiator.opensHeader(frame)) + #expect(!siblingInitiator.opensHeader(frame)) + } + + @Test func probeIsPureRead() throws { + let (initiator, acceptor) = try makeEstablishedPair() + let message = Data("still-consumable".utf8) + let frame = try acceptorFrame(from: acceptor, message: message) + + // Probing does not consume the frame: the same blob still decrypts after. + #expect(initiator.opensHeader(frame)) + let decrypted = try #require(try initiator.decrypt(frame)) + #expect(decrypted.applicationMessage?.appMessageData == message) + } +}