Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Mass-reformat with swift-format; use this file to skip it in blame.
# git config blame.ignoreRevsFile .git-blame-ignore-revs
ca4d5e37c1ca313471f7b5634093c74784a8452f
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -334,3 +334,17 @@ jobs:
env:
TWOMLSPQ_LOCAL_XCFRAMEWORK: "1"
run: swift test

swift-lint:
name: Swift format lint
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: swift-actions/setup-swift@v2
with:
swift-version: "6.1"
# --strict: warnings fail the job too, not only errors. Scoped to the hand-written
# target + tests — Sources/TwoMLSPQBinding/two_mls_pq.swift is uniffi-autogenerated
# ("don't mess with it", swiftlint disabled) and gets re-synced from the Rust build
# on every release, so linting it here would just drift again on the next re-sync.
- run: swift format lint --recursive --configuration .swift-format --strict Sources/TwoMLSPQ Tests
691 changes: 352 additions & 339 deletions Sources/TwoMLSPQ/PQSession.swift

Large diffs are not rendered by default.

18 changes: 12 additions & 6 deletions Sources/TwoMLSPQ/SessionErrorBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,16 @@ extension SessionError {
case .receive:
// Establishment door: the commitment the host threaded in from
// the signed AppWelcome is not a valid H(A.3 key package).
detail = "bootstrap-KP commitment is not H(the initiator's PQ "
detail =
"bootstrap-KP commitment is not H(the initiator's PQ "
+ "key package): a malformed or mis-read 32-byte value, or a "
+ "tampered AppWelcome. The invitation is NOT consumed — "
+ "re-read the commitment from the signed envelope and retry."
default:
// A.3 side-band: a KP′ that hashes to something other than the
// commitment the signed envelope pinned.
detail = "A.3 bootstrap key package (KP′) does not hash to the "
detail =
"A.3 bootstrap key package (KP′) does not hash to the "
+ "commitment the signed establishment envelope carried — a "
+ "substituted or tampered KP′. Discard the frame; the genuine "
+ "re-stapled KP′ still applies, session state untouched."
Expand Down Expand Up @@ -142,25 +144,29 @@ extension SessionError {
// Initiator: a bare welcome whose creator differs from the invitation
// identity — a born-dedicated establishment must arrive enveloped.
code = .establishmentEnvelopeRequired
detail = "a born-dedicated establishment arrived un-enveloped (creator "
detail =
"a born-dedicated establishment arrived un-enveloped (creator "
+ "leaf differs from the invitation identity); refused so an "
+ "undelegated credential cannot be admitted on the weld alone."
default:
// Acceptor: an emission door was driven before the signed delegation
// was installed — a caller-sequencing bug.
code = .sequenceViolation
detail = "born-dedicated session is non-emittable until "
detail =
"born-dedicated session is non-emittable until "
+ "installEstablishmentEnvelope supplies the signed delegation; "
+ "mint and install it before sending."
}
case .EstablishmentCreatorMismatch:
code = .establishmentCreatorMismatch
detail = "the admitted creator id does not match the welcome's creator leaf: "
detail =
"the admitted creator id does not match the welcome's creator leaf: "
+ "the delegation is genuine but names a different key. The join was "
+ "discarded whole; do not retry with the same admittedCreator."
case .EstablishmentEnvelopeConflict:
code = .establishmentEnvelopeConflict
detail = "a different establishment envelope is already installed on this "
detail =
"a different establishment envelope is already installed on this "
+ "session; one session binds exactly one envelope."
case .AttachmentComponentUnavailable:
code = .attachmentComponentUnavailable
Expand Down
10 changes: 6 additions & 4 deletions Tests/TwoMLSPQTests/AttachmentCEKTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import CommProtocol
import Foundation
import Testing

import TwoMLSPQBinding

@testable import TwoMLSPQ
Expand Down Expand Up @@ -71,11 +70,14 @@ struct AttachmentCEKTests {
let (local, _) = try establishedPair()
_ = try local.prepareToEncrypt(proposing: nil)

let cekA = try local.exportAttachmentCEKSend(keyId: Data(repeating: 0x01, count: 32))
let cekB = try local.exportAttachmentCEKSend(keyId: Data(repeating: 0x02, count: 32))
let cekA = try local.exportAttachmentCEKSend(
keyId: Data(repeating: 0x01, count: 32))
let cekB = try local.exportAttachmentCEKSend(
keyId: Data(repeating: 0x02, count: 32))
#expect(cekA != cekB, "distinct key ids must not collide within one epoch")

let cekAAgain = try local.exportAttachmentCEKSend(keyId: Data(repeating: 0x01, count: 32))
let cekAAgain = try local.exportAttachmentCEKSend(
keyId: Data(repeating: 0x01, count: 32))
#expect(cekA == cekAAgain, "the same (epoch, keyId) must re-derive identically")
}

Expand Down
5 changes: 3 additions & 2 deletions Tests/TwoMLSPQTests/ErrorContractTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

import Foundation
import Testing

import TwoMLSPQBinding // public TwoMlsPqError cases

@testable import TwoMLSPQ // internal SessionError(pqError:at:) + PQErrorSurface
Expand Down Expand Up @@ -124,7 +123,9 @@ struct ErrorContractTests {
let e = SessionError(pqError: TwoMlsPqError.SessionNotReady, at: surface)
#expect(e.code == .misroutedFrame, "\(surface)")
}
let sequencing: [PQErrorSurface] = [.prepareToEncrypt, .encrypt, .pqOperation, .receive]
let sequencing: [PQErrorSurface] = [
.prepareToEncrypt, .encrypt, .pqOperation, .receive,
]
for surface in sequencing {
let e = SessionError(pqError: TwoMlsPqError.SessionNotReady, at: surface)
#expect(e.code == .sequenceViolation, "\(surface)")
Expand Down
30 changes: 21 additions & 9 deletions Tests/TwoMLSPQTests/LifecycleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import CommProtocol
import Foundation
import Testing

import TwoMLSPQBinding

@testable import TwoMLSPQ
Expand Down Expand Up @@ -198,7 +197,9 @@ struct LifecycleTests {
let kp = try localSession.finishBootstrap(rotating: nil)
#expect(kp.kind == .finishBootstrap)
// Bootstrap key-package frame — classify by opening the seal (wire tag sealed, v7).
#expect(try remoteBase.openIncoming(blob: kp.payload)?.kind == .pqSideBand(kind: .bootstrapKeyPackage))
#expect(
try remoteBase.openIncoming(blob: kp.payload)?.kind
== .pqSideBand(kind: .bootstrapKeyPackage))

let remoteClassicalBefore = remoteBase.epochs().classicalEpoch
let remoteListenBeforeBootstrap = try remoteBase.shouldListenOn()
Expand All @@ -225,13 +226,17 @@ struct LifecycleTests {
let peek1 = try #require(remoteSession.pendingSideBand(sealing: .fresh))
let peek2 = try #require(remoteSession.pendingSideBand(sealing: .fresh))
#expect(peek1 != peek2)
#expect(try localBase.openIncoming(blob: peek1)?.kind == .pqSideBand(kind: .bootstrapWelcome))
#expect(
try localBase.openIncoming(blob: peek1)?.kind
== .pqSideBand(kind: .bootstrapWelcome))

let reply = try #require(remoteSession.advance(after: inbound))
#expect(reply.kind == .finishBootstrap)
// The responder's reply is the new PQ group's Welcome' (v18: the bind is no
// longer a side-band frame kind — it rides the message-frame staple).
#expect(try localBase.openIncoming(blob: reply.payload)?.kind == .pqSideBand(kind: .bootstrapWelcome))
#expect(
try localBase.openIncoming(blob: reply.payload)?.kind
== .pqSideBand(kind: .bootstrapWelcome))
// The consuming take hands the frame out exactly once — retention included.
#expect(remoteSession.advance(after: inbound) == nil)
#expect(remoteSession.pendingSideBand(sealing: .fresh) == nil)
Expand Down Expand Up @@ -272,18 +277,22 @@ struct LifecycleTests {
// (A.5 as a rotation credential catch-up is exercised in the Rust crate suite.)
#expect(remoteSession.turn == .weInitiate)
let remotePqBeforeRatchet = remoteBase.epochs().pqEpoch
try remoteSession.send(to: localSession) // opener — auto-stages Remote's A.4 EK
try remoteSession.send(to: localSession) // opener — auto-stages Remote's A.4 EK
let ratchetEk = try #require(remoteSession.pendingSideBand(sealing: .fresh))
// EK frame — classify by opening the seal (wire tag sealed, v7).
#expect(try localBase.openIncoming(blob: ratchetEk)?.kind == .pqSideBand(kind: .ratchetEphemeralKey))
#expect(
try localBase.openIncoming(blob: ratchetEk)?.kind
== .pqSideBand(kind: .ratchetEphemeralKey))

// Local responds: seals the injected secret to the EK, parking the CT reply.
let ratchetInbound1 = try localSession.ingest(ratchetEk)
#expect(ratchetInbound1.kind == .ratchet)
let ratchetReply = try #require(localSession.advance(after: ratchetInbound1))
#expect(ratchetReply.kind == .ratchet)
// CT frame — classify by opening the seal.
#expect(try remoteBase.openIncoming(blob: ratchetReply.payload)?.kind == .pqSideBand(kind: .ratchetCiphertext))
#expect(
try remoteBase.openIncoming(blob: ratchetReply.payload)?.kind
== .pqSideBand(kind: .ratchetCiphertext))
// The consuming take hands the frame out exactly once.
#expect(localSession.advance(after: ratchetInbound1) == nil)

Expand All @@ -304,10 +313,13 @@ struct LifecycleTests {
// -- Step 8: exchanges still flow on the ratcheted groups.
try localSession.exchange(with: remoteSession)
_ = try remoteSession.prepareToEncrypt(proposing: nil)
let postRatchetFrame = try remoteSession.encrypt(appMessage: Data("post-ratchet".utf8))
let postRatchetFrame = try remoteSession.encrypt(
appMessage: Data("post-ratchet".utf8))
let postRatchet = try #require(
try localSession.decrypt(postRatchetFrame.cipherText))
#expect(try postRatchet.applicationMessage.tryUnwrap.appMessageData == Data("post-ratchet".utf8))
#expect(
try postRatchet.applicationMessage.tryUnwrap.appMessageData
== Data("post-ratchet".utf8))
}

/// The poster's post address is its recv group's current exporter; the recv
Expand Down
26 changes: 17 additions & 9 deletions Tests/TwoMLSPQTests/PQInvitationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import CommProtocol
import Foundation
import Testing

import TwoMLSPQ
import TwoMLSPQBinding

Expand Down Expand Up @@ -47,7 +46,8 @@ struct PQInvitationReceiveTests {
let (acceptorSession, stapled) = try invitation.receive(
sendGroupWelcome: welcome,
remoteKeyPackage: initiatorKp,
bootstrapKpCommitment: try #require(initiatorSession.bootstrapKpCommitment()),
bootstrapKpCommitment: try #require(
initiatorSession.bootstrapKpCommitment()),
remoteClientId: initiator.clientId().bytes,
welcomeToken: WelcomeToken(PQDigest.over(welcome)),
stapledMessage: nil,
Expand All @@ -63,16 +63,21 @@ struct PQInvitationReceiveTests {
let back = try acceptorSession.encrypt(appMessage: "hello back".utf8Data)
let received = try #require(
try approveEstablishmentRaw(
initiator: initiatorSession, ciphertext: back.cipherText, dedicatedId: dedicatedId)
initiator: initiatorSession, ciphertext: back.cipherText,
dedicatedId: dedicatedId)
)
#expect(received.applicationMessage?.appMessageData == "hello back".utf8Data)

// And a routine round now that the initiator is fully established.
_ = try initiatorSession.prepareToEncrypt(proposing: nil)
let routine = try initiatorSession.encrypt(appMessage: "routine".utf8Data)
guard case .decrypted(let decrypted) =
try acceptorSession.processIncoming(ciphertext: routine.cipherText)
else { Issue.record("unexpected establishment pause"); throw TestErrors.unexpected }
guard
case .decrypted(let decrypted) =
try acceptorSession.processIncoming(ciphertext: routine.cipherText)
else {
Issue.record("unexpected establishment pause")
throw TestErrors.unexpected
}
#expect(
try decrypted.tryUnwrap.applicationMessage.tryUnwrap.appMessageData
== "routine".utf8Data
Expand Down Expand Up @@ -185,7 +190,8 @@ struct PQInvitationReceiveTests {
_ = try invitation.receive(
sendGroupWelcome: welcome,
remoteKeyPackage: initiatorKp,
bootstrapKpCommitment: Data(repeating: 0, count: 31), // one byte short of a SHA-256
// one byte short of a SHA-256
bootstrapKpCommitment: Data(repeating: 0, count: 31),
remoteClientId: initiator.clientId().bytes,
welcomeToken: token,
stapledMessage: nil,
Expand All @@ -207,7 +213,8 @@ struct PQInvitationReceiveTests {
let (acceptorSession, stapled) = try invitation.receive(
sendGroupWelcome: welcome,
remoteKeyPackage: initiatorKp,
bootstrapKpCommitment: try #require(initiatorSession.bootstrapKpCommitment()),
bootstrapKpCommitment: try #require(
initiatorSession.bootstrapKpCommitment()),
remoteClientId: initiator.clientId().bytes,
welcomeToken: token,
stapledMessage: nil,
Expand All @@ -220,7 +227,8 @@ struct PQInvitationReceiveTests {
let back = try acceptorSession.encrypt(appMessage: "established".utf8Data)
let received = try #require(
try approveEstablishmentRaw(
initiator: initiatorSession, ciphertext: back.cipherText, dedicatedId: dedicatedId)
initiator: initiatorSession, ciphertext: back.cipherText,
dedicatedId: dedicatedId)
)
#expect(received.applicationMessage?.appMessageData == "established".utf8Data)
}
Expand Down
23 changes: 16 additions & 7 deletions Tests/TwoMLSPQTests/TestSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ extension PQSession {
/// returning the bytes for the peer-side approval.
@discardableResult
func installMockEstablishmentEnvelope() throws -> Data {
#expect(pendingEstablishmentWelcome != nil, "a born-dedicated acceptor owes a welcome")
#expect(
pendingEstablishmentWelcome != nil,
"a born-dedicated acceptor owes a welcome")
try installEstablishmentEnvelope(mockEstablishmentEnvelope)
return mockEstablishmentEnvelope
}
Expand All @@ -54,13 +56,17 @@ extension PQSession {
) throws -> PQDecryptResult? {
_ = try acceptor.prepareToEncrypt(proposing: nil)
let frame = try acceptor.encrypt(appMessage: message)
guard case .pendingEstablishment(let pending) = try processIncoming(ciphertext: frame.cipherText)
guard
case .pendingEstablishment(let pending) = try processIncoming(
ciphertext: frame.cipherText)
else {
Issue.record("expected a born-dedicated establishment pause")
throw TestErrors.unexpected
}
#expect(pending.envelope == mockEstablishmentEnvelope)
#expect(pending.welcome.first == 0x01, "the surfaced welcome is a bare APQWelcome_A")
#expect(
pending.welcome.first == 0x01, "the surfaced welcome is a bare APQWelcome_A"
)
return try pending.resume(admittedCreator: dedicatedId)
}
}
Expand Down Expand Up @@ -109,7 +115,8 @@ extension PQSession {
/// pause) and return its payload. For frames past establishment, where a pause
/// cannot occur — a pause here is a test-setup bug, surfaced loudly.
func decrypt(_ ciphertext: Data) throws -> PQDecryptResult? {
guard case .decrypted(let result) = try processIncoming(ciphertext: ciphertext) else {
guard case .decrypted(let result) = try processIncoming(ciphertext: ciphertext)
else {
Issue.record("unexpected establishment pause on a post-establishment frame")
throw TestErrors.unexpected
}
Expand All @@ -126,9 +133,11 @@ extension PQSession {

// Post-establishment: the receiver decrypts. (A born-dedicated first frame
// pauses instead — that path is `acceptEstablishment`, not `send`.)
guard case .decrypted(let decrypted) = try remote.processIncoming(
ciphertext: encryptedOutgoing.cipherText
) else {
guard
case .decrypted(let decrypted) = try remote.processIncoming(
ciphertext: encryptedOutgoing.cipherText
)
else {
Issue.record("unexpected establishment pause in a steady-state send")
throw TestErrors.unexpected
}
Expand Down
Loading