Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/add-opens-header-probe.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions Sources/TwoMLSPQ/PQSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions Tests/TwoMLSPQTests/OpensHeaderProbeTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading