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
30 changes: 30 additions & 0 deletions .changeset/session-id-is-the-initiator-group-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@germ-network/two-mls-pq": minor
---

The session id is the initiator's group id; remove the client-id-pair hash

`active_session_id()` and its `SessionId` type returned a hash of the two client
ids — `SHA-256(min(a,b) ‖ max(a,b))`, no seed. That is a participant-**pair**
fingerprint, not a session id: anyone holding the two public client ids can
compute it, and it is identical across every session the pair ever opens. Both
are removed (finishing what contract 31 began by dropping the free
`derive_session_id`).

The real session id is the **initiator's randomly-generated group id** — fresh
per session, unpredictable, and already shared, because the initiator's send
group is the acceptor's receive group. Read it with `send_group_id()` on the
initiator and `receive_group_id()` on the acceptor (the classical half, present
from construction); both name the same value, and it survives archive restore
with the group state. The tests now assert this, including that two sessions
between the *same* pair get different ids — the property the old hash could never
satisfy.

Nothing downstream breaks: the vended Swift wrapper never forwarded the accessor,
AbstractTwoMLS never called it, and the app already keys on group ids
(`receiveGroupId` / `sendGroupId`). The stored field is dropped from the live
session; its slot stays vestigial in the archive (written empty, ignored on
decode) so a released 0.14 archive still decodes under the v3 migration.

Binding contract 31 → 32 — two FFI symbols removed, re-pair the vendored binding.
No wire change (archive layout stays v3) and no error-variant change.
35 changes: 22 additions & 13 deletions Sources/TwoMLSPQ/PQSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -225,12 +225,19 @@ import TwoMLSPQBinding
// v31 (contract 31): `deriveSessionId` is REMOVED from the crate's FFI surface — a session
// pins its id at its FOUNDING pair for life, so re-deriving from current client ids
// disagreed with it after a principal rotation (and always, for a born-dedicated
// acceptor). `activeSessionId()` is the session's id; a pre-session pair key is a digest
// the caller computes itself. Nothing to do here: the raw binding is an internal target,
// so this function was never reachable from this product. The bump covers the dropped FFI
// symbol, which requires the vendored binding to be re-paired with the binary. No wire,
// API, or error-variant change.
private let expectedBindingContract: UInt64 = 31
// acceptor). Nothing to do here: the raw binding is an internal target, so this function
// was never reachable from this product. The bump covers the dropped FFI symbol, which
// requires the vendored binding to be re-paired with the binary. No wire, API, or
// error-variant change.
// v32 (contract 32): `activeSessionId()` and its `SessionId` type are REMOVED, finishing v31.
// A seedless hash of the two client ids is a participant-pair fingerprint, not a session
// id — predictable from the public ids and identical across every session the pair opens.
// The session id is the INITIATOR's randomly-generated group id (fresh per session,
// already shared: the initiator's send group is the acceptor's receive group), read via
// the group-id accessors. Nothing to do here either — the removed function was never
// reachable from this product, and the whole app stack already keys on the group id. The
// bump covers the dropped FFI symbols. No wire, API, or error-variant change.
private let expectedBindingContract: UInt64 = 32

enum TwoMLSPQBindingContract {
static let verified: Void = {
Expand Down Expand Up @@ -752,13 +759,15 @@ public struct PQSession {
base.receiveGroupId()?.classical.bytes
}

/// This side's OWN send-group classical id — a stable, per-endpoint session
/// identifier present from creation. Distinct from `shouldListenOn()` (which
/// bundles the same value into a routing/rendezvous tuple): this is the identity
/// value on its own, for an adopter that keys local session state by it. It is
/// LOCAL — each endpoint's send group differs (my send group is the peer's
/// receive group), so it is never a shared-across-peers at-rest identifier, and
/// it is NOT `activeSessionId()` (the shared client-id-pair hash).
/// This side's OWN send-group classical id — present from creation. Distinct from
/// `shouldListenOn()` (which bundles the same value into a routing/rendezvous tuple):
/// this is the identity value on its own, for an adopter that keys local session state
/// by it. It is PER-ENDPOINT — each side's send group differs, and my send group is the
/// peer's receive group. The INITIATOR's send-group id doubles as the shared session id
/// (the acceptor names the same value as its receive group); the acceptor's own
/// `localSessionId` is a distinct per-endpoint value. There is no separate derived
/// session id — a hash of the two client ids would be a pair fingerprint, not a session
/// id.
public var localSessionId: GroupID? {
base.sendGroupId()?.classical.bytes
}
Expand Down
98 changes: 17 additions & 81 deletions Sources/TwoMLSPQBinding/two_mls_pq.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1791,8 +1791,6 @@ public func FfiConverterTypeTwoMlsPqPrincipal_lower(_ value: TwoMlsPqPrincipal)
*/
public protocol TwoMlsPqSessionProtocol: AnyObject, Sendable {

func activeSessionId() -> SessionId

/**
* The app-state binding this session was created with (`initiate`'s `app_binding`,
* or the binding the accepted welcome carried), or `None` for an unbound session.
Expand Down Expand Up @@ -1899,12 +1897,14 @@ public protocol TwoMlsPqSessionProtocol: AnyObject, Sendable {

/**
* This session's OWN send-group id — the classical half is present from
* creation, the PQ half empty until its deferred bootstrap (A.3). Unlike
* [`active_session_id`](Self::active_session_id) (a hash of the two client ids,
* shared across the pair) this is a per-endpoint value: each side's send group
* differs, so an adopter keying local state by it never shares an at-rest
* identifier with its peer. The mirror of [`receive_group_id`](Self::receive_group_id)
* (my send group is the peer's receive group).
* creation, the PQ half empty until its deferred bootstrap (A.3). A per-endpoint
* value: each side's send group differs. The mirror of
* [`receive_group_id`](Self::receive_group_id) — my send group is the peer's receive
* group — which is what makes the INITIATOR's send-group id a shared identifier both
* parties can name (the initiator by this accessor, the acceptor by `receive_group_id`).
* That randomly-seeded, per-session group id is the session identifier; there is no
* separate derived id (a hash of the two client ids would be a pair fingerprint, not a
* session id — the same pair's every session would share it).
*/
func sendGroupId() -> CombinerGroupId?

Expand Down Expand Up @@ -2522,14 +2522,6 @@ public static func restore(core: Archive?, checkpoint: Archive?)throws -> TwoMl



open func activeSessionId() -> SessionId {
return try! FfiConverterTypeSessionId_lift(try! rustCall() {
uniffi_two_mls_pq_fn_method_twomlspqsession_active_session_id(
self.uniffiCloneHandle(),$0
)
})
}

/**
* The app-state binding this session was created with (`initiate`'s `app_binding`,
* or the binding the accepted welcome carried), or `None` for an unbound session.
Expand Down Expand Up @@ -2708,12 +2700,14 @@ open func receiveGroupId() -> CombinerGroupId? {

/**
* This session's OWN send-group id — the classical half is present from
* creation, the PQ half empty until its deferred bootstrap (A.3). Unlike
* [`active_session_id`](Self::active_session_id) (a hash of the two client ids,
* shared across the pair) this is a per-endpoint value: each side's send group
* differs, so an adopter keying local state by it never shares an at-rest
* identifier with its peer. The mirror of [`receive_group_id`](Self::receive_group_id)
* (my send group is the peer's receive group).
* creation, the PQ half empty until its deferred bootstrap (A.3). A per-endpoint
* value: each side's send group differs. The mirror of
* [`receive_group_id`](Self::receive_group_id) — my send group is the peer's receive
* group — which is what makes the INITIATOR's send-group id a shared identifier both
* parties can name (the initiator by this accessor, the acceptor by `receive_group_id`).
* That randomly-seeded, per-session group id is the session identifier; there is no
* separate derived id (a hash of the two client ids would be a pair fingerprint, not a
* session id — the same pair's every session would share it).
*/
open func sendGroupId() -> CombinerGroupId? {
return try! FfiConverterOptionTypeCombinerGroupId.lift(try! rustCall() {
Expand Down Expand Up @@ -4782,61 +4776,6 @@ public func FfiConverterTypeRendezvousId_lower(_ value: RendezvousId) -> RustBuf
return FfiConverterTypeRendezvousId.lower(value)
}


/**
* Session identifier derived from both parties' client IDs at init time.
* Both sides can derive the same ID independently, preventing identity
* confusion when both parties initiate simultaneously.
*/
public struct SessionId: Equatable, Hashable {
public var bytes: Data

// Default memberwise initializers are never public by default, so we
// declare one manually.
public init(bytes: Data) {
self.bytes = bytes
}




}

#if compiler(>=6)
extension SessionId: Sendable {}
#endif

#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeSessionId: FfiConverterRustBuffer {
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionId {
return
try SessionId(
bytes: FfiConverterData.read(from: &buf)
)
}

public static func write(_ value: SessionId, into buf: inout [UInt8]) {
FfiConverterData.write(value.bytes, into: &buf)
}
}


#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeSessionId_lift(_ buf: RustBuffer) throws -> SessionId {
return try FfiConverterTypeSessionId.lift(buf)
}

#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeSessionId_lower(_ value: SessionId) -> RustBuffer {
return FfiConverterTypeSessionId.lower(value)
}

// Note that we don't yet support `indirect` for enums.
// See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion.
/**
Expand Down Expand Up @@ -6420,9 +6359,6 @@ private let initializationResult: InitializationResult = {
if (uniffi_two_mls_pq_checksum_method_twomlspqprincipal_generate_key_package() != 11085) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_two_mls_pq_checksum_method_twomlspqsession_active_session_id() != 37750) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_two_mls_pq_checksum_method_twomlspqsession_app_binding() != 59144) {
return InitializationResult.apiChecksumMismatch
}
Expand Down Expand Up @@ -6459,7 +6395,7 @@ private let initializationResult: InitializationResult = {
if (uniffi_two_mls_pq_checksum_method_twomlspqsession_receive_group_id() != 24855) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_two_mls_pq_checksum_method_twomlspqsession_send_group_id() != 56157) {
if (uniffi_two_mls_pq_checksum_method_twomlspqsession_send_group_id() != 9016) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_two_mls_pq_checksum_method_twomlspqsession_set_initial_app_payload() != 22701) {
Expand Down
38 changes: 21 additions & 17 deletions book/src/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ The receiving side of a published key package — no live client required.
initiator sees the dedicated principal from the very first frame, with no
founding→dedicated rotation, so nothing can displace the welcome staple. The receive-group join still
uses the invitation identity (the welcome was addressed to its key package), and
the session id still derives from the founding pair, so both sides agree on it.
the session id — the initiator's random group id — is unaffected by any of this,
so both sides still name the same one.
`expected_remote` is the identity the caller already expects the welcome from
(Germ validates it from the decrypted initial frame): a key package naming anyone
else is rejected as `RemoteIdentityMismatch` **before any invitation state is
Expand Down Expand Up @@ -137,20 +138,23 @@ The receiving side of a published key package — no live client required.
- `MlsCipherSuite::is_combiner_pq()` / `is_combiner_classical()` — routing signals (true for
the PQ `0xFDEA` and classical `0x0003` halves respectively).

`derive_session_id(a, b)` was **removed** in contract 31. A session pins its id at its
**founding** pair — the invitation identity the initiator addressed — and never moves it
again, while the client ids themselves do: a principal rotation replaces them, and a
born-dedicated acceptor never operated under its founding id at all. Re-deriving from the
ids a caller holds later therefore produced a digest the session did not agree with. There
is no single replacement, because the call answered two different questions:
**The session id is the initiator's group id.** `derive_session_id(a, b)` was removed in
contract 31 and its stored replacement `active_session_id()` in contract 32, because both
were a hash of the two client ids — `SHA-256(min(a,b) ‖ max(a,b))` — and that is a
participant-**pair** fingerprint, not a session id: it is computable by anyone holding the
two public `ClientId`s, and it is identical across every session the pair ever opens.

- *"What is this session's id?"* — [`active_session_id()`](#twomlspqsession), the stored
founding value: identical on both sides, available from construction, preserved across
archive restore.
- *"What is a stable key for this pair, before a session exists?"* — compute your own
digest. It was only `SHA-256(min(a,b) ‖ max(a,b))` over two public `ClientId`s, with
nothing secret and nothing protocol-specific in it — but do not call the result a session
id, since it stops matching the session's the moment either party rotates.
A real session id must be fresh and unpredictable per session. The one already in hand is
the **initiator's randomly-generated group id**: seeded at group creation, unique per
session, and shared — the initiator's send group *is* the acceptor's receive group. Read it
with [`send_group_id()`](#twomlspqsession) on the initiator and
[`receive_group_id()`](#twomlspqsession) on the acceptor (the classical half, present from
construction; both sides name the same value). It survives archive restore with the group
state.

If you genuinely need a stable key for a *pair* before any session exists, compute your own
`SHA-256(min(a,b) ‖ max(a,b))` over the two `ClientId`s — nothing secret, nothing
protocol-specific — but do not call it a session id.

## `TwoMlsPqSession`

Expand All @@ -175,9 +179,9 @@ rest by higher `state_seq`) and fails closed (`ArchiveInvalid`) on a PQ-epoch ma
mismatch.

State: `is_established`, `is_fully_established`, `has_receive_group`,
`active_session_id` (the founding-pair id this session was constructed with — since
contract 31 the only way to obtain it, the free `derive_session_id` having been removed),
`receive_group_id`, `my_principal_state`, `their_principal_state`,
`send_group_id` / `receive_group_id` (the initiator's `send_group_id` is the session id —
see above; each is a `CombinerGroupId` whose classical half is present from construction),
`my_principal_state`, `their_principal_state`,
`pending_outbound` (the standalone copy of the own welcome — not consumed by
`encrypt`; the welcome also rides every pre-commit frame as the staple), `epochs`,
`app_binding() -> Result<Option<Vec<u8>>>` (Swift `try appBinding() -> Data?`; the
Expand Down
Loading
Loading