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
36 changes: 36 additions & 0 deletions .changeset/opaque-handoff-bodies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"@germ-network/autonomous-comm-protocol": minor
---

Add opaque-digest handoff bodies (v2), with corrected discriminators.

`createNewAgentHandoff` and `PublicAnchorAgent.verify(anchorHandoff:…)` gain
overloads taking `groupContext`/`mlsUpdateDigest` as `Data` instead of
`TypedDigest`. The new v2 signature bodies commit to those bytes as length-framed
values and never interpret them.

The motivation is ownership. A digest's algorithm is a facet of the MLS backend's
cipher suite, so requiring a `TypedDigest` meant the backend could not adopt a new
suite until this package shipped a matching `DigestTypes` case — a release
dependency in the wrong direction. Committing to opaque bytes removes it: the
caller's own self-describing encoding travels inside the value, so cross-era
signatures stay unambiguous without this package knowing the eras.

This is safe because a verifier never parses a digest out of a handoff — it
rebuilds the signature body from its own locally derived reference digest and
checks the signature against that. Agreement on the algorithm is enforced where
the session is established, not here.

**Nothing existing changes.** The typed overloads, the v1 bodies, and their
discriminators are untouched, so every already-issued handoff keeps verifying.
v1 and v2 are mutually unverifiable by construction (distinct discriminators),
which is pinned by tests in both directions.

**Discriminator note.** v1's `ActiveAgentBody` and `RetiredAgentBody` carry each
other's names. That is a labeling bug, not a security one — domain separation
needs the committed strings to be distinct, not correctly named, and they are —
but it is now frozen and commented as such, because renaming in place would
silently fail verification for every live relationship. The v2 bodies carry the
corrected names, `.v2`-suffixed: the plain corrected strings are unavailable
precisely because v1 has them live on the opposite structs. A test asserts all six
are pairwise distinct.
90 changes: 90 additions & 0 deletions Sources/CommProtocol/Anchors/Agent/AnchorAgent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,96 @@ extension PublicAnchorAgent {
)
}

/// Verify a handoff minted by the OPAQUE overload of `createNewAgentHandoff` (v2 bodies).
///
/// `context` and `mlsUpdateDigest` are the verifier's OWN locally derived reference values —
/// they are never read out of the handoff, which carries no digests at all. Supply the bytes
/// this side's MLS backend produced (TwoMLSPQ: the receiver's
/// `QueuedRemoteProposal.context`/`.digest`); the signature check does the comparison, so
/// wrong bytes simply fail to verify. This is why the bodies can commit to an algorithm this
/// package cannot name.
public func verify(
anchorHandoff: AnchorHandoff,
context: Data,
mlsUpdateDigest: Data
) throws -> AnchorHandoff.Verified {
// Mirror of the mint-side guard: an empty reference digest could only ever
// match a handoff minted with the same integration bug, and that pair would
// verify with the MLS binding silently absent.
guard !context.isEmpty, !mlsUpdateDigest.isEmpty else {
throw ProtocolError.unexpected("empty digest bytes in opaque handoff body")
}
let verifiedPackage = try verifyPackageV2(
handoff: anchorHandoff,
mlsUpdateDigest: mlsUpdateDigest
)

let content = verifiedPackage.first
let newAnchor = try verify(newAnchor: content.second)
let activeAnchor = newAnchor?.publicKey ?? anchor.publicKey

guard
activeAnchor
.verifier(
verifiedPackage.second,
try content.activeAnchorBodyV2(
groupContext: context,
knownAgent: agentKey
).wireFormat
)
else {
throw ProtocolError.authenticationError
}

let newAgentKey = try AgentPublicKey(
archive: content.first.first
)
guard
newAgentKey.verifier(
verifiedPackage.third,
try content
.activeAgentBodyV2(
groupContext: context,
mlsUpdateDigest: mlsUpdateDigest,
knownAgent: agentKey
).wireFormat
)
else {
throw ProtocolError.authenticationError
}

return .init(
newAnchor: newAnchor != nil,
agent: .init(
anchor: newAnchor ?? anchor,
agentKey: newAgentKey
),
newAgentUpdate: content.first.second
)
}

private func verifyPackageV2(
handoff: AnchorHandoff,
mlsUpdateDigest: Data
) throws -> AnchorHandoff.Package {
guard
agentKey.verifier(
handoff.first,
try AnchorHandoff
.RetiredAgentBodyV2(
encodedPackage: handoff.second,
mlsUpdateDigest: mlsUpdateDigest,
knownAgent: agentKey
)
.wireFormat
)
else {
throw ProtocolError.authenticationError
}

return try .finalParse(handoff.second)
}

private func verifyPackage(
handoff: AnchorHandoff,
mlsUpdateDigest: TypedDigest
Expand Down
107 changes: 107 additions & 0 deletions Sources/CommProtocol/Anchors/Exchange/AnchorHandoff.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,34 @@ extension AnchorHandoff {
fifth: knownAgent.id
)
}

// MARK: v2 — opaque digest bodies

func activeAnchorBodyV2(
groupContext: Data,
knownAgent: AgentPublicKey,
) throws -> ActiveAnchorBodyV2 {
.init(
first: ActiveAnchorBodyV2.discriminator,
second: self,
third: groupContext,
fourth: knownAgent.id
)
}

func activeAgentBodyV2(
groupContext: Data,
mlsUpdateDigest: Data,
knownAgent: AgentPublicKey,
) throws -> ActiveAgentBodyV2 {
.init(
first: ActiveAgentBodyV2.discriminator,
second: self,
third: groupContext,
fourth: mlsUpdateDigest,
fifth: knownAgent.id
)
}
}

public struct Package: LinearEncodedTriple {
Expand Down Expand Up @@ -127,6 +155,17 @@ extension AnchorHandoff {

//signature bodies
extension AnchorHandoff {
// The v1 bodies. Their digest fields are `TypedDigest`, so this package must be able to
// NAME the hash a peer used — which is why v2 exists (see below).
//
// KNOWN-SWAPPED DISCRIMINATORS, DELIBERATELY FROZEN. `ActiveAgentBody` carries the string
// "AnchorHandoff.RetiredAgentBody" and vice versa. This is a labeling bug, not a security
// one: domain separation needs the committed strings to be DISTINCT, not correctly named,
// and they are distinct — so no signature can be replayed across contexts even when one key
// signs both body types across successive rotations. DO NOT "fix" these in place: live
// relationships have signatures committed over them, and renaming would silently fail every
// verification. They retire with the v1 bodies. The v2 bodies below carry the corrected
// names.
struct ActiveAnchorBody: LinearEncodedQuad {
static let discriminator = "AnchorHandoff.ActiveAnchorBody"
let first: String
Expand Down Expand Up @@ -174,6 +213,74 @@ extension AnchorHandoff {
self.fourth = knownAgent.id
}
}

// MARK: - v2 bodies: opaque digests
//
// Identical in shape to v1, with the digest fields as length-framed `Data`. The point is
// ownership: a digest's algorithm is a facet of the MLS backend's cipher suite, so naming it
// here (`DigestTypes`) meant a backend could not adopt a new suite until this package
// released a matching case. These bodies commit to whatever bytes the caller supplies and
// never interpret them — the caller's own self-describing encoding travels INSIDE the value,
// so cross-era signatures stay unambiguous without this package knowing the eras.
//
// This is safe because a verifier never parses a digest off the wire: it rebuilds the body
// from a locally derived reference digest and checks the signature against that. Agreement on
// the algorithm is enforced where the session is established, not here.
//
// DISCRIMINATORS: corrected names, `.v2`-suffixed. The suffix is not decoration — the plain
// corrected strings are UNAVAILABLE, because v1 has them live on each other's structs (see
// the frozen-swap note above). Reusing one would put two different structures under one
// committed string across the union of live body types, which is exactly the distinctness
// that domain separation rests on. `ActiveAnchorBodyV2` takes the suffix too, though its name
// was never swapped: its ENCODING differs from v1's, and keeping discriminator↔encoding 1:1
// is what stops this bug class from recurring.
struct ActiveAnchorBodyV2: LinearEncodedQuad {
static let discriminator = "AnchorHandoff.ActiveAnchorBody.v2"
let first: String
let second: Content
let third: Data //group context, opaque
let fourth: TypedKeyMaterial //knownAgent
}

struct ActiveAgentBodyV2: LinearEncodedQuintuple {
static let discriminator = "AnchorHandoff.ActiveAgentBody.v2"
let first: String
let second: Content
let third: Data //group context, opaque
let fourth: Data //mls update digest, opaque
let fifth: TypedKeyMaterial //knownAgent
}

struct RetiredAgentBodyV2: LinearEncodedQuad {
static let discriminator = "AnchorHandoff.RetiredAgentBody.v2"
let first: String
let second: Data //Package.wireformat
let third: Data //mls update digest, opaque
let fourth: TypedKeyMaterial //knownAgent

init(
first: String,
second: Data,
third: Data,
fourth: TypedKeyMaterial
) {
self.first = first
self.second = second
self.third = third
self.fourth = fourth
}

init(
encodedPackage: Data,
mlsUpdateDigest: Data,
knownAgent: AgentPublicKey
) {
self.first = Self.discriminator
self.second = encodedPackage
self.third = mlsUpdateDigest
self.fourth = knownAgent.id
}
}
}

extension AnchorHandoff.Verified {
Expand Down
73 changes: 73 additions & 0 deletions Sources/CommProtocol/Anchors/PrivateActiveAnchor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,79 @@ extension PrivateActiveAnchor {
second: encodedPackage
)
}

/// Mint a handoff whose digests are OPAQUE bytes (v2 bodies).
///
/// Same flow and same wire structure as the typed overload — only the signature bodies
/// differ, so `AnchorHandoff` itself is unchanged and the digests never travel in it. Pass
/// the MLS backend's own values verbatim (TwoMLSPQ: `proposalContext` and `proposalHash`,
/// or a digest from `PQDigest.over(_:)`). They are committed, never parsed, so this package
/// no longer needs a case for the backend's hash — the backend can change suites without a
/// release here.
///
/// Verify with `PublicAnchorAgent.verify(anchorHandoff:context:mlsUpdateDigest:)`'s matching
/// opaque overload: the two are a pair, and a handoff minted here will NOT verify against
/// the typed one (different discriminators, by design).
public func createNewAgentHandoff(
agentUpdate: AgentUpdate,
newAgent: AgentPrivateKey,
from retiredAgent: PrivateAnchorAgent,
groupContext: Data,
mlsUpdateDigest: Data,
) throws -> AnchorHandoff {
// The typed overload guaranteed a well-formed digest structurally; opaque bytes
// move that to runtime. Empty is the one unambiguously degenerate value — a
// handoff committing to nothing for a slot — and if BOTH ends plumbed empty
// (a default-value integration bug), verification would succeed with the MLS
// binding silently absent. Refuse at mint so the bug is loud at its source.
guard !groupContext.isEmpty, !mlsUpdateDigest.isEmpty else {
throw ProtocolError.unexpected("empty digest bytes in opaque handoff body")
}
let handoffContent = AnchorHandoff.Content(
first: .init(
publicKey: newAgent.publicKey,
agentUpdate: agentUpdate
),
second: nil
)

let activeAnchorSignature = try privateKey.signer(
try handoffContent
.activeAnchorBodyV2(
groupContext: groupContext,
knownAgent: retiredAgent.publicKey
).wireFormat
)

let newAgentSignature = try newAgent.signer(
try handoffContent
.activeAgentBodyV2(
groupContext: groupContext,
mlsUpdateDigest: mlsUpdateDigest,
knownAgent: retiredAgent.publicKey
).wireFormat
)

let package = AnchorHandoff.Package(
first: handoffContent,
second: activeAnchorSignature, //active anchor
third: newAgentSignature //new agent
)

let encodedPackage = try package.wireFormat
let retiredAgentSignature = try retiredAgent.signer(
try AnchorHandoff.RetiredAgentBodyV2(
encodedPackage: encodedPackage,
mlsUpdateDigest: mlsUpdateDigest,
knownAgent: retiredAgent.publicKey
).wireFormat
)

return .init(
first: retiredAgentSignature,
second: encodedPackage
)
}
}

extension PrivateActiveAnchor {
Expand Down
Loading
Loading