From 4465952154af237019dc86967d25da5e88c4a61d Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Mon, 3 Aug 2026 16:42:37 -0700 Subject: [PATCH 1/3] Add deterministic-CBOR infrastructure (GER-1965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin swift-cbor at the same revision CoreAppLogic uses, and add a small DeterministicCbor seam establishing the conventions new wire types must follow: short string map keys (swift-cbor's Codable surface only ever produces/accepts string keys — it silently drops non-string keys on decode rather than erroring, so integer keys are wire-incompatible by construction), the archive-companion pattern, and riding a CBOR blob inside a positional container as a plain Data field. Also hoists the hex-fixture decode helper out of AgentHelloDualOfferTests into shared test support, for reuse by new wire-pinning tests. --- Package.resolved | 10 ++- Package.swift | 7 ++ .../CommProtocol/Cbor/DeterministicCbor.swift | 80 +++++++++++++++++++ .../AgentHelloDualOfferTests.swift | 18 ----- .../TestSupport/Data+Hex.swift | 31 +++++++ 5 files changed, 127 insertions(+), 19 deletions(-) create mode 100644 Sources/CommProtocol/Cbor/DeterministicCbor.swift create mode 100644 Tests/CommProtocolTests/TestSupport/Data+Hex.swift diff --git a/Package.resolved b/Package.resolved index cafd78c..23b06eb 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "936655932fb4b96db50e6696efe5f8cc49511b4b87827b2908f8a6521980023d", + "originHash" : "5e61db99593c694326104c2b6d58bffd3fcc011a04b0c5024de8e4e151e8cf81", "pins" : [ { "identity" : "atprototypes", @@ -37,6 +37,14 @@ "version" : "0.2.0" } }, + { + "identity" : "swift-cbor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/nnabeyang/swift-cbor.git", + "state" : { + "revision" : "8d9b9c25284c6a2f0a564f4c42c7dc3466d08472" + } + }, { "identity" : "swift-crypto", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 240d455..2e6310f 100644 --- a/Package.swift +++ b/Package.swift @@ -25,6 +25,12 @@ let package = Package( from: "0.2.2" ), .package(url: "https://github.com/swift-libp2p/swift-bases.git", from: "0.2.0"), + .package( + // pinned by revision: `Options.deterministicCbor` (RFC 8949 §4.2.1) + // is on main and not in any released tag. Same pin as CoreAppLogic. + url: "https://github.com/nnabeyang/swift-cbor.git", + revision: "8d9b9c25284c6a2f0a564f4c42c7dc3466d08472" + ), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -36,6 +42,7 @@ let package = Package( .product(name: "AtprotoTypesMocks", package: "AtprotoTypes"), .product(name: "Base64", package: "swift-bases"), "GermConvenience", + .product(name: "SwiftCbor", package: "swift-cbor"), ] ), .target( diff --git a/Sources/CommProtocol/Cbor/DeterministicCbor.swift b/Sources/CommProtocol/Cbor/DeterministicCbor.swift new file mode 100644 index 0000000..0ab29d4 --- /dev/null +++ b/Sources/CommProtocol/Cbor/DeterministicCbor.swift @@ -0,0 +1,80 @@ +// +// DeterministicCbor.swift +// CommProtocol +// + +import Foundation +import SwiftCbor + +/// The one seam through which any new CBOR wire type in this package encodes +/// or decodes. Not a general-purpose CBOR wrapper — a place to pin the +/// options and gotchas every such type must share, so no call site can drift +/// from them by picking its own defaults. +/// +/// **New wire types are deterministic-CBOR maps with short string keys, not +/// integers.** `swift-cbor`'s Codable surface only ever produces or accepts +/// text-string map keys — its `KeyedEncodingContainer` keys off +/// `CodingKey.stringValue` exclusively, and its decoder silently *drops* any +/// map key that isn't a CBOR text string rather than erroring on one. An +/// integer-keyed map is wire-incompatible with this library by construction. +/// Keys are 1-2 lowercase letters, matching CoreAppLogic's existing CBOR +/// types (`AudioEnvelope`, `AudioMessage`) — e.g. `case authKey = "k"`. Once a +/// key has shipped, mark it `//WIRE-FROZEN` and never change the raw value. +/// +/// **Archive-companion pattern.** A public wire type should not itself +/// conform to `Codable` — that lets any call site hand it to `JSONEncoder`, +/// or to `CborEncoder` without `.deterministicCbor`, and mint bytes nothing +/// here produced. Instead, keep a `private struct Archive: Codable` mirror +/// inside the same file as the only thing this seam ever sees, so there is +/// exactly one way to encode the type. See CoreAppLogic's `AudioEnvelope` for +/// the shape: +/// +/// ```swift +/// public struct Example: Sendable, Equatable { +/// public let value: Data +/// +/// private struct Archive: Codable { +/// let value: Data +/// enum CodingKeys: String, CodingKey { +/// case value = "v" //WIRE-FROZEN +/// } +/// } +/// +/// public var wireFormat: Data { +/// get throws { try DeterministicCbor.encode(Archive(value: value)) } +/// } +/// +/// public init(wireFormat: Data) throws { +/// let archive = try DeterministicCbor.decode(Archive.self, from: wireFormat) +/// self.init(value: archive.value) +/// } +/// } +/// ``` +/// +/// **Framing a CBOR blob inside a positional (`LinearEncodable`) container.** +/// No new framing type is needed: wrap the encoded bytes as a plain `Data` +/// field. `Data: LinearEncodable` +/// (`MessageFraming/LinearEncodedData.swift`) already length-prefixes +/// arbitrary bytes — a 1-byte prefix, falling back internally to +/// `DeclaredWidthData` past 254 bytes — and reports consumed bytes correctly, +/// which is everything `LinearEncodable.parse` needs from a nested value. A +/// type whose wire form is entirely CBOR can conform to `LinearEncodable` +/// itself by riding this: `parse`/`wireFormat` simply delegate to `Data`'s. +public enum DeterministicCbor { + /// Encodes under RFC 8949 §4.2.1 (`CborEncoder.Options.deterministicCbor`) + /// — the only encode path any type using this seam may use. + public static func encode(_ value: T) throws -> Data { + try CborEncoder(options: .deterministicCbor).encode(value) + } + + /// Decodes `type` from `data`. + /// + /// Re-copies into a fresh, zero-based `Data` before handing it to + /// `CborDecoder` — its scanner indexes zero-based and crashes on a + /// non-zero-based slice (the same gotcha CoreAppLogic's + /// `AudioEnvelope.init(sealedPlaintext:)` works around), which a caller + /// handing this a subrange of a larger buffer would otherwise hit. + public static func decode(_ type: T.Type, from data: Data) throws -> T { + try CborDecoder().decode(type, from: Data(data)) + } +} diff --git a/Tests/CommProtocolTests/IdentityExchange/AgentHelloDualOfferTests.swift b/Tests/CommProtocolTests/IdentityExchange/AgentHelloDualOfferTests.swift index 1f4b391..2f566c1 100644 --- a/Tests/CommProtocolTests/IdentityExchange/AgentHelloDualOfferTests.swift +++ b/Tests/CommProtocolTests/IdentityExchange/AgentHelloDualOfferTests.swift @@ -209,21 +209,3 @@ struct AgentHelloDualOfferTests { + "222222222222222222222222222222222222222222222222222222222222222222" + "22222222222241da1b5980000000" } - -extension Data { - fileprivate init?(hexString: String) { - guard hexString.count.isMultiple(of: 2) else { return nil } - var bytes = [UInt8]() - bytes.reserveCapacity(hexString.count / 2) - var index = hexString.startIndex - while index < hexString.endIndex { - let next = hexString.index(index, offsetBy: 2) - guard let byte = UInt8(hexString[index.. Date: Mon, 3 Aug 2026 16:43:10 -0700 Subject: [PATCH 2/3] Add MailboxGrant + derivation, ProtocolAddress bridge (GER-1966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MailboxGrant {authKey, serviceHost, expiration} — the authenticated mailbox address, replacing ProtocolAddress in new carriage. address and putTag(nonce:bodyDigest:) are HMAC-SHA256 derivations matching germ-service's deriveMailboxAddress/computePutTag byte-for-byte (KATs cross-validated against test/mailbox-hmac.spec.ts). TypedKeyMaterial's existing .hmacSha256 case and validated symmetric-key initializer already covered what the issue asked for; nothing to add there. Also adds the transitional ProtocolAddress <-> MailboxGrant bridge: a grant's authKey (32 raw bytes) and a legacy crypto.randomUUID() address (36 chars, 27 decoded bytes) never collide by length, so a grant can ride the existing identifier field on wire-shape-frozen surfaces without any arity change — see AgentUpdateV2's commit for where this applies. --- .../CommProtocol/Objects/MailboxGrant.swift | 178 +++++++++++++++++ .../CommProtocolTests/MailboxGrantTests.swift | 180 ++++++++++++++++++ 2 files changed, 358 insertions(+) create mode 100644 Sources/CommProtocol/Objects/MailboxGrant.swift create mode 100644 Tests/CommProtocolTests/MailboxGrantTests.swift diff --git a/Sources/CommProtocol/Objects/MailboxGrant.swift b/Sources/CommProtocol/Objects/MailboxGrant.swift new file mode 100644 index 0000000..a47bff0 --- /dev/null +++ b/Sources/CommProtocol/Objects/MailboxGrant.swift @@ -0,0 +1,178 @@ +// +// MailboxGrant.swift +// CommProtocol +// + +import Base64 +import CryptoKit +import Foundation + +/// An authenticated mailbox address, minted by a server and shared with a +/// peer so it can message the holder — the successor to `ProtocolAddress` in +/// new carriage (docs/attachment-mailbox-delivery.md, "Authenticated +/// addresses"). +/// +/// Deliberately has **no identifier field**: unlike a legacy address (a bare +/// bearer token), both the address and the put-authentication tag are HMAC +/// derivations of `authKey`, so a peer holding the grant derives them itself +/// rather than trusting a transmitted value. `address` and `putTag(nonce: +/// bodyDigest:)`, below, are that derivation — matching germ-service's +/// `deriveMailboxAddress`/`computePutTag` (`authentication/mailbox-hmac.ts`) +/// byte-for-byte, so KATs are shared between the two implementations. +public struct MailboxGrant: Sendable { + public let authKey: TypedKeyMaterial // .hmacSha256, 32B + public let serviceHost: String + public let expiration: RoundedDate + + public init( + authKey: TypedKeyMaterial, + serviceHost: String, + expiration: Date + ) throws(LinearEncodingError) { + try self.init( + authKey: authKey, + serviceHost: serviceHost, + expiration: RoundedDate(date: expiration) + ) + } + + init( + authKey: TypedKeyMaterial, + serviceHost: String, + expiration: RoundedDate + ) throws(LinearEncodingError) { + guard authKey.algorithm == .hmacSha256 else { + throw .mismatchedAlgorithms(expected: .hmacSha256, found: authKey.algorithm) + } + self.authKey = authKey + self.serviceHost = serviceHost + self.expiration = expiration + } +} + +// A grant is identified by its key and host, not its expiration — matching +// `ProtocolAddress`'s precedent (the type it succeeds). +extension MailboxGrant: Equatable { + public static func == (lhs: MailboxGrant, rhs: MailboxGrant) -> Bool { + lhs.authKey == rhs.authKey && lhs.serviceHost == rhs.serviceHost + } +} + +// MARK: - Derivation + +extension MailboxGrant { + private static let addressLabel = Data("germ-addr:v1".utf8) + private static let putLabel = Data("germ-put:v1".utf8) + + /// The address a sender messages to reach this grant's holder — derived, + /// never transmitted. `base64url(HMAC-SHA256(authKey, "germ-addr:v1" ‖ + /// serviceHost))`, unpadded. + public var address: String { + let key = SymmetricKey(data: authKey.keyData) + let message = Self.addressLabel + Data(serviceHost.utf8) + let mac = HMAC.authenticationCode(for: message, using: key) + return Data(mac).base64URLEncoded(padded: false) + } + + /// The tag a put's `Authorization` header carries, authenticating a body + /// whose digest is `bodyDigest` against this grant's `nonce`-bound + /// challenge. `HMAC-SHA256(authKey, "germ-put:v1" ‖ address ‖ nonce ‖ + /// bodyDigest)`. + /// + /// Byte representations are fixed, matching the server's contract + /// (`mailbox-hmac.ts`'s header comment): `address` is the UTF-8 bytes of + /// its base64url *string* form (43 bytes), `nonce` is the raw bytes + /// returned by the put-challenge (not re-encoded), and `bodyDigest` is + /// the raw 32-byte SHA-256 output over the exact request body bytes. + public func putTag(nonce: Data, bodyDigest: Data) -> Data { + let key = SymmetricKey(data: authKey.keyData) + let message = Self.putLabel + Data(address.utf8) + nonce + bodyDigest + let mac = HMAC.authenticationCode(for: message, using: key) + return Data(mac) + } +} + +// MARK: - Wire format + +extension MailboxGrant { + /// The peer-to-peer carriage form — a deterministic-CBOR map, nested + /// inside `AgentUpdateV2.grants` (see `DeterministicCbor`'s doc comment + /// for why the keys are short strings, not integers). + private struct Archive: Codable { + let authKey: Data // TypedKeyMaterial.wireFormat: 1 algorithm byte + 32 key bytes + let serviceHost: String + let expiration: UInt32 // RoundedDate.hoursSinceEpoch + + //WIRE-FROZEN: never change a raw value once shipped. + enum CodingKeys: String, CodingKey { + case authKey = "k" + case serviceHost = "h" + case expiration = "e" + } + } + + public var wireFormat: Data { + get throws { + try DeterministicCbor.encode( + Archive( + authKey: authKey.wireFormat, + serviceHost: serviceHost, + expiration: expiration.hoursSinceEpoch + ) + ) + } + } + + public init(wireFormat: Data) throws { + let archive = try DeterministicCbor.decode(Archive.self, from: wireFormat) + try self.init( + authKey: TypedKeyMaterial(wireFormat: archive.authKey), + serviceHost: archive.serviceHost, + expiration: RoundedDate(hoursSinceEpoch: archive.expiration) + ) + } +} + +// MARK: - ProtocolAddress bridge + +extension ProtocolAddress { + /// Non-nil when `identifier` decodes as a mailbox grant's `authKey` + /// (exactly 32 raw bytes) rather than a legacy `crypto.randomUUID()` + /// address (36 characters, which decode as base64url to 27 bytes since + /// `-` is in the alphabet — the lengths never collide). + /// + /// This is the transitional carriage for surfaces that cannot take a + /// wire-shape change (`PQAppWelcome`/`PQAnchorWelcome`/handoffs — see + /// docs/attachment-mailbox-delivery.md, "Wire types"): only an `authKey` + /// ever rides `identifier` in this format, never the derived `address` + /// — the address is also 32 raw bytes and indistinguishable by shape, so + /// the discrimination is sound only because both ends derive it from the + /// key. `init(mailboxGrant:)`, below, is the only sanctioned way to build + /// a grant-bearing `ProtocolAddress`; nothing else should hand-construct + /// one with a base64url identifier. + public var mailboxGrant: MailboxGrant? { + guard let keyData = Data(base64URLEncoded: identifier), + keyData.count == TypedKeyMaterial.Algorithms.hmacSha256.contentByteSize, + let authKey = try? TypedKeyMaterial( + algorithm: .hmacSha256, + symmetricKey: SymmetricKey(data: keyData) + ) + else { + return nil + } + return try? MailboxGrant( + authKey: authKey, + serviceHost: serviceHost, + expiration: expiration + ) + } + + /// Builds the transitional carriage of `mailboxGrant` — see `mailboxGrant`. + public init(mailboxGrant: MailboxGrant) { + self.init( + identifier: mailboxGrant.authKey.keyData.base64URLEncoded(padded: false), + serviceHost: mailboxGrant.serviceHost, + expiration: mailboxGrant.expiration + ) + } +} diff --git a/Tests/CommProtocolTests/MailboxGrantTests.swift b/Tests/CommProtocolTests/MailboxGrantTests.swift new file mode 100644 index 0000000..c2a6005 --- /dev/null +++ b/Tests/CommProtocolTests/MailboxGrantTests.swift @@ -0,0 +1,180 @@ +// +// MailboxGrantTests.swift +// CommProtocol +// + +import CryptoKit +import Foundation +import Testing + +@testable import CommProtocol + +private func rangeBytes(_ range: Range) -> Data { + Data(range) +} + +struct MailboxGrantTests { + + private static let zeroKey = try! TypedKeyMaterial( + algorithm: .hmacSha256, + symmetricKey: SymmetricKey(data: Data(count: 32)) + ) + + // MARK: - KATs, cross-validated against germ-service's test/mailbox-hmac.spec.ts + + @Test func testAddressKnownAnswer() throws { + let grant = try MailboxGrant(authKey: Self.zeroKey, serviceHost: "ger.mx", expiration: .now) + // Independently computed: hmac.new(bytes(32), b"germ-addr:v1" + b"ger.mx", + // sha256).hexdigest() == 193221da6fb96fb001ff056007a9e84792acd4b34ab8af79905e7d86d8976435 + // — the exact vector in germ-service's test/mailbox-hmac.spec.ts. This + // asserts the base64url string form, since that's what actually rides + // the wire (and what `putTag` below is keyed against). + #expect(grant.address == "GTIh2m-5b7AB_wVgB6noR5Ks1LNKuK95kF59htiXZDU") + } + + @Test func testPutTagKnownAnswer() throws { + let grant = try MailboxGrant(authKey: Self.zeroKey, serviceHost: "ger.mx", expiration: .now) + let nonce = rangeBytes(0..<32) + let bodyDigest = rangeBytes(32..<64) + let tag = grant.putTag(nonce: nonce, bodyDigest: bodyDigest) + // germ-service's own putTag KAT exercises the raw HMAC in isolation + // against a placeholder address string ("some-address-string"), which + // this API can't reproduce directly — a grant always signs under its + // own derived address. So this vector instead chains off the address + // KAT above (independently computed the same way: hmac.new(bytes(32), + // b"germ-put:v1" + address.encode() + nonce + bodyDigest, + // sha256).hexdigest()), exercising the real coupled API rather than + // the server's decoupled one. + #expect( + tag + == Data( + hexString: "67ec98aad3d2bf4551195c3a23db92c4a2f79cda35732c3eca9ac67eda19b1b6" + ) + ) + } + + @Test func testAddressDiffersByServiceHost() throws { + let a = try MailboxGrant(authKey: Self.zeroKey, serviceHost: "ger.mx", expiration: .now) + let b = try MailboxGrant(authKey: Self.zeroKey, serviceHost: "other.example", expiration: .now) + #expect(a.address != b.address) + } + + @Test func testPutTagDiffersByNonce() throws { + let grant = try MailboxGrant(authKey: Self.zeroKey, serviceHost: "ger.mx", expiration: .now) + let digest = rangeBytes(32..<64) + let a = grant.putTag(nonce: rangeBytes(0..<32), bodyDigest: digest) + let b = grant.putTag(nonce: rangeBytes(1..<33), bodyDigest: digest) + #expect(a != b) + } + + @Test func testRejectsAKeyThatIsNot32Bytes() { + #expect(throws: (any Error).self) { + try TypedKeyMaterial(algorithm: .hmacSha256, symmetricKey: SymmetricKey(data: Data(count: 16))) + } + } + + @Test func testRejectsAnAuthKeyOfTheWrongAlgorithm() { + let wrongAlgorithm = try! TypedKeyMaterial( + algorithm: .chaCha20Poly1305, + symmetricKey: SymmetricKey(size: .bits256) + ) + #expect(throws: (any Error).self) { + try MailboxGrant(authKey: wrongAlgorithm, serviceHost: "ger.mx", expiration: .now) + } + } + + // MARK: - Equatable (excludes expiration) + + @Test func testEqualityIgnoresExpiration() throws { + let a = try MailboxGrant(authKey: Self.zeroKey, serviceHost: "ger.mx", expiration: Date()) + let b = try MailboxGrant( + authKey: Self.zeroKey, + serviceHost: "ger.mx", + expiration: Date().addingTimeInterval(3600) + ) + #expect(a == b) + } + + @Test func testInequalityByAuthKey() throws { + let otherKey = try TypedKeyMaterial( + algorithm: .hmacSha256, + symmetricKey: SymmetricKey(data: Data(repeating: 1, count: 32)) + ) + let a = try MailboxGrant(authKey: Self.zeroKey, serviceHost: "ger.mx", expiration: .now) + let b = try MailboxGrant(authKey: otherKey, serviceHost: "ger.mx", expiration: .now) + #expect(a != b) + } + + // MARK: - CBOR round-trip + + @Test func testCborRoundTrip() throws { + let original = try MailboxGrant( + authKey: Self.zeroKey, + serviceHost: "ger.mx", + expiration: Date() + ) + let encoded = try original.wireFormat + let decoded = try MailboxGrant(wireFormat: encoded) + #expect(decoded == original) + #expect(decoded.expiration == original.expiration) // Equatable ignores this — check directly + } + + // MARK: - Wire-pinning + + @Test func testCborWireFormatGolden() throws { + let key = try TypedKeyMaterial( + algorithm: .hmacSha256, + symmetricKey: SymmetricKey(data: Data(count: 32)) + ) + let grant = try MailboxGrant( + authKey: key, + serviceHost: "ger.mx", + expiration: RoundedDate(hoursSinceEpoch: 471442) + ) + let encoded = try grant.wireFormat + // Locks the encoding against drift: a renamed CodingKeys raw value or + // a reordered field must fail HERE, in CI. Byte-verified by hand at + // introduction: a3 (map, 3 pairs) / 61 65 (key "e") 1a 00073192 + // (uint32 471442) / 61 68 (key "h") 66 <"ger.mx"> (text, 6 bytes) / + // 61 6b (key "k") 58 21 <05 + 32 zero bytes> (byte string, 33 bytes: + // the .hmacSha256 algorithm tag + a 32-byte all-zero key) — RFC 8949 + // bytewise key order ("e" < "h" < "k"). + #expect( + encoded + == Data( + hexString: + "a361651a000731926168666765722e6d78616b5821050000000000000000000000000000000000000000000000000000000000000000" + ) + ) + } + + // MARK: - ProtocolAddress bridge + + @Test func testProtocolAddressBridgeRoundTrip() throws { + let grant = try MailboxGrant(authKey: Self.zeroKey, serviceHost: "ger.mx", expiration: Date()) + let address = ProtocolAddress(mailboxGrant: grant) + #expect(address.serviceHost == grant.serviceHost) + #expect(address.mailboxGrant == grant) + #expect(address.mailboxGrant?.expiration == grant.expiration) + } + + @Test func testProtocolAddressBridgeDiscriminatesLegacyUUID() { + // Legacy addresses are `crypto.randomUUID()` — 36 characters, which + // decode as base64url to 27 bytes, never 32. Never a grant. + let legacy = ProtocolAddress( + identifier: UUID().uuidString, + serviceHost: "ger.mx", + expiration: Date() + ) + #expect(legacy.mailboxGrant == nil) + } + + @Test func testProtocolAddressBridgeRejectsGarbageIdentifier() { + let garbage = ProtocolAddress( + identifier: "not-base64url-shaped-at-all!!", + serviceHost: "ger.mx", + expiration: Date() + ) + #expect(garbage.mailboxGrant == nil) + } +} From 55ddccbeed5927635adf15a0b8d80f9e99a7f1db Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Mon, 3 Aug 2026 16:43:31 -0700 Subject: [PATCH 3/3] Add AgentUpdateV2 behind the mailboxGrantVersion gate (GER-1967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentUpdateV2 {version, isAppClip, grants} — grants only, no legacy addresses field — carried by a new CommProposal.agentUpdateV2 case (tag 6, appended so existing tags 1-5 keep their values). Follows the pqCardUpgrade precedent exactly: a new ProposalType tag drops the whole message on a peer that doesn't recognize it, so this is emitted only to a peer observed >= AgentUpdate.mailboxGrantVersion (2.4.0, next after pqCapableVersion). Readers ship before writers; the emission gate itself lives in the app. mailboxGrantVersion/supportsMailboxGrants mirror pqCapableVersion/ isPQCapable exactly. Below the gate, addresses keeps riding the classic .sameAgent triple, byte-for-byte unchanged (pinned). The PQ establishment surfaces (PQAppWelcome/PQAnchorWelcome/handoffs) don't need a wire change at all: they're already live, fixed-arity structs, but they already carry AgentUpdate.addresses (directly or via an embedded AgentUpdate), and the ProtocolAddress <-> MailboxGrant bridge means those addresses can already be grants. Once a peer clears the gate, the steady-state conversation upgrades to real agentUpdateV2 proposals; the bridge is only ever the transitional carriage. --- .../IdentityExchange/IdentityFollowup.swift | 24 ++ .../CommProtocol/IdentityKeys/AgentKeys.swift | 45 ++++ .../IdentityUpdate/AgentUpdateV2.swift | 119 ++++++++ .../IdentityUpdate/CommProposal.swift | 28 ++ Sources/CommProtocolMocks/Mocks.swift | 19 ++ .../IdentityUpdate/AgentUpdateV2Tests.swift | 253 ++++++++++++++++++ 6 files changed, 488 insertions(+) create mode 100644 Sources/CommProtocol/IdentityUpdate/AgentUpdateV2.swift create mode 100644 Tests/CommProtocolTests/IdentityUpdate/AgentUpdateV2Tests.swift diff --git a/Sources/CommProtocol/IdentityExchange/IdentityFollowup.swift b/Sources/CommProtocol/IdentityExchange/IdentityFollowup.swift index 1ed7944..a07a912 100644 --- a/Sources/CommProtocol/IdentityExchange/IdentityFollowup.swift +++ b/Sources/CommProtocol/IdentityExchange/IdentityFollowup.swift @@ -75,6 +75,30 @@ extension AgentUpdate { var domainSeparatesHandoff: Bool { version >= Self.pqDomainSeparationVersion } + + ///The agent version at (and above) which an agent is emitted `.agentUpdateV2` + ///`CommProposal`s carrying ``MailboxGrant``s instead of legacy + ///``ProtocolAddress``es — next in line after ``pqCapableVersion`` = 2.3.0 + ///(docs/attachment-mailbox-delivery.md, "Wire types"). + /// + ///Below this, an agent keeps receiving the classic triple — a legacy + ///``ProtocolAddress`` list — byte-for-byte unchanged; nothing about its + ///wire shape moves. `public` so the app imports the same constant as the + ///single source of truth for the emission gate, which lives there (see + ///``supportsMailboxGrants``). + public static let mailboxGrantVersion = SemanticVersion( + major: 2, + minor: 4, + patch: 0 + ) + + ///Whether this agent is emitted `.agentUpdateV2` proposals, per + ///``mailboxGrantVersion``. Tracking *which* peer has been observed at or + ///above this threshold — and so deciding when to actually emit one — is + ///an app-layer concern, the same split as ``isPQCapable``. + public var supportsMailboxGrants: Bool { + version >= Self.mailboxGrantVersion + } } extension AgentUpdate: LinearEncodedTriple { diff --git a/Sources/CommProtocol/IdentityKeys/AgentKeys.swift b/Sources/CommProtocol/IdentityKeys/AgentKeys.swift index 7a89af9..50d072e 100644 --- a/Sources/CommProtocol/IdentityKeys/AgentKeys.swift +++ b/Sources/CommProtocol/IdentityKeys/AgentKeys.swift @@ -167,6 +167,32 @@ public struct AgentPrivateKey: Sendable { ) } + ///Gated successor to `proposeLeafNode` — only ever call this for a peer + ///observed at or above `AgentUpdate.mailboxGrantVersion` (see + ///`CommProposal.agentUpdateV2`'s doc comment); the gate itself lives in + ///the app. + public func proposeAgentUpdateV2( + leafNodeUpdate: Data, + agentUpdate: AgentUpdateV2, + signedIdentityMutable: SignedObject?, + context: TypedDigest + ) throws -> CommProposal { + let signature = try sign( + input: agentUpdate.formatForSigning( + updateMessage: leafNodeUpdate, + context: context + ) + ) + + return .agentUpdateV2( + .init( + content: agentUpdate, + signature: signature + ), + signedIdentityMutable + ) + } + ///In-band classical->PQ card upgrade offer/welcome/decline. Signed by the ///established agent over the same `updateMessage + context` binding as ///``proposeLeafNode(leafNodeUpdate:agentUpdate:signedIdentityMutable:context:)``, @@ -416,6 +442,25 @@ public struct AgentPublicKey: Sendable { } return signedUpgrade.content } + + func validate( + signedAgentUpdateV2: SignedObject, + for updateMessage: Data, + context: TypedDigest + ) throws -> AgentUpdateV2 { + let signatureBody = try signedAgentUpdateV2.content.formatForSigning( + updateMessage: updateMessage, + context: context + ) + guard keyType == signedAgentUpdateV2.signature.signingAlgorithm, + publicKey.isValidSignature( + signedAgentUpdateV2.signature.signature, + for: signatureBody) + else { + throw ProtocolError.authenticationError + } + return signedAgentUpdateV2.content + } } extension AgentPublicKey { diff --git a/Sources/CommProtocol/IdentityUpdate/AgentUpdateV2.swift b/Sources/CommProtocol/IdentityUpdate/AgentUpdateV2.swift new file mode 100644 index 0000000..276a00f --- /dev/null +++ b/Sources/CommProtocol/IdentityUpdate/AgentUpdateV2.swift @@ -0,0 +1,119 @@ +// +// AgentUpdateV2.swift +// CommProtocol +// +// The gated successor to `AgentUpdate` — grants only, no legacy `addresses` +// field (docs/attachment-mailbox-delivery.md, "Wire types"). Carried by +// `CommProposal.agentUpdateV2`, emitted only to a peer observed at or above +// `AgentUpdate.mailboxGrantVersion`; a pre-gate peer keeps receiving the +// classic triple (`.sameAgent`) byte-for-byte, unchanged. +// +// BACKWARD COMPATIBILITY: this is a NEW `ProposalType` tag, same rule as +// `PQCardUpgrade` (see its header comment). A peer that doesn't recognize the +// tag drops the whole message on `LinearEnum` parse, so this case must ONLY +// ever be emitted to a peer already confirmed at `mailboxGrantVersion`. The +// capability gate lives in the app; this type is the wire carrier. +// + +import Foundation + +public struct AgentUpdateV2: Sendable, Equatable { + public let version: SemanticVersion + public let isAppClip: Bool + public let grants: [MailboxGrant] + + public init(version: SemanticVersion, isAppClip: Bool, grants: [MailboxGrant]) { + self.version = version + self.isAppClip = isAppClip + self.grants = grants + } + + ///Mirrors `AgentUpdate.formatForSigning(updateMessage:context:)` — the + ///signature binds the update to the MLS proposal (`updateMessage`) and + ///the session `context`. + func formatForSigning( + updateMessage: Data, + context: TypedDigest + ) throws -> Data { + try wireFormat + updateMessage + context.wireFormat + } +} + +// MARK: - Wire format + +extension AgentUpdateV2 { + private struct VersionArchive: Codable { + let major: UInt32 + let minor: UInt32 + let patch: UInt32 + let preReleaseSuffix: String? + + //WIRE-FROZEN: never change a raw value once shipped. + enum CodingKeys: String, CodingKey { + case major = "j" + case minor = "n" + case patch = "p" + case preReleaseSuffix = "r" + } + } + + private struct Archive: Codable { + let version: VersionArchive + let isAppClip: Bool + let grants: [Data] // each entry a MailboxGrant.wireFormat + + //WIRE-FROZEN: never change a raw value once shipped. + enum CodingKeys: String, CodingKey { + case version = "v" + case isAppClip = "c" + case grants = "g" + } + } + + private var cborEncoded: Data { + get throws { + try DeterministicCbor.encode( + Archive( + version: VersionArchive( + major: version.major, + minor: version.minor, + patch: version.patch, + preReleaseSuffix: version.preReleaseSuffix + ), + isAppClip: isAppClip, + grants: try grants.map { try $0.wireFormat } + ) + ) + } + } + + private init(cborEncoded: Data) throws { + let archive = try DeterministicCbor.decode(Archive.self, from: cborEncoded) + self.init( + version: SemanticVersion( + major: archive.version.major, + minor: archive.version.minor, + patch: archive.version.patch, + preReleaseSuffix: archive.version.preReleaseSuffix + ), + isAppClip: archive.isAppClip, + grants: try archive.grants.map { try MailboxGrant(wireFormat: $0) } + ) + } +} + +// A CBOR value is self-delimiting to its own decoder, but `swift-cbor`'s +// public API doesn't report how many bytes a decode consumed out of a larger +// buffer — so, per the framing rule in `DeterministicCbor`, this rides as an +// opaque `Data` field: `Data: LinearEncodable` supplies the length prefix and +// the consumed-byte accounting that `SignedObject`/`CommProposal` need. +extension AgentUpdateV2: LinearEncodable { + public static func parse(_ input: Data) throws -> (AgentUpdateV2, Int) { + let (cbor, consumed) = try Data.parse(input) + return (try AgentUpdateV2(cborEncoded: cbor), consumed) + } + + public var wireFormat: Data { + get throws { try cborEncoded.wireFormat } + } +} diff --git a/Sources/CommProtocol/IdentityUpdate/CommProposal.swift b/Sources/CommProtocol/IdentityUpdate/CommProposal.swift index fd84979..5d482e0 100644 --- a/Sources/CommProtocol/IdentityUpdate/CommProposal.swift +++ b/Sources/CommProtocol/IdentityUpdate/CommProposal.swift @@ -51,6 +51,10 @@ public enum CommProposal: LinearEncodable, Equatable, Sendable { //Emitted ONLY to a confirmed PQ-capable peer (unknown tag drops the message //on a legacy peer). case pqCardUpgrade(SignedObject) + //gated successor to .sameAgent — see AgentUpdateV2.swift. Emitted ONLY to + //a peer observed >= AgentUpdate.mailboxGrantVersion (unknown tag drops + //the message on a peer below the gate). + case agentUpdateV2(SignedObject, SignedObject?) enum ProposalType: UInt8, LinearEnum { case sameAgent = 1 @@ -58,6 +62,7 @@ public enum CommProposal: LinearEncodable, Equatable, Sendable { case newIdentity case anchorHandOff case pqCardUpgrade + case agentUpdateV2 } public enum ValidatedForCard: Sendable { @@ -66,6 +71,7 @@ public enum CommProposal: LinearEncodable, Equatable, Sendable { case newIdentity( SignedObject, IdentityMutableData, AgentHandoff.Validated) case pqCardUpgrade(PQCardUpgrade) + case agentUpdateV2(AgentUpdateV2, IdentityMutableData?) } public func validate( @@ -114,6 +120,15 @@ public enum CommProposal: LinearEncodable, Equatable, Sendable { context: context ) ) + case .agentUpdateV2(let signedAgentUpdateV2, let signedIdentityMutable): + .agentUpdateV2( + try knownAgent.validate( + signedAgentUpdateV2: signedAgentUpdateV2, + for: updateMessage, + context: context + ), + try knownIdentity.validate(maybeSignedObject: signedIdentityMutable) + ) } } @@ -140,6 +155,15 @@ public enum CommProposal: LinearEncodable, Equatable, Sendable { let (signedUpgrade, consumed) = try SignedObject.parse(remainder) return (.pqCardUpgrade(signedUpgrade), consumed + 1) + case .agentUpdateV2: + let (signedAgentUpdateV2, signedIdentityMutable, consumed) = + try LinearEncoder + .decode( + SignedObject.self, + (SignedObject?).self, + input: remainder + ) + return (.agentUpdateV2(signedAgentUpdateV2, signedIdentityMutable), consumed + 1) } } @@ -167,6 +191,10 @@ public enum CommProposal: LinearEncodable, Equatable, Sendable { case .pqCardUpgrade(let signedUpgrade): try [ProposalType.pqCardUpgrade.rawValue] + signedUpgrade.wireFormat + case .agentUpdateV2(let signedAgentUpdateV2, let signedIdentityMutable): + try [ProposalType.agentUpdateV2.rawValue] + + signedAgentUpdateV2.wireFormat + + signedIdentityMutable.wireFormat } } } diff --git a/Sources/CommProtocolMocks/Mocks.swift b/Sources/CommProtocolMocks/Mocks.swift index 35696fb..0de1783 100644 --- a/Sources/CommProtocolMocks/Mocks.swift +++ b/Sources/CommProtocolMocks/Mocks.swift @@ -29,6 +29,25 @@ extension ProtocolAddress { } } +extension MailboxGrant { + public static func mock() -> Self { + try! .init( + authKey: .init( + algorithm: .hmacSha256, + symmetricKey: SymmetricKey(size: .bits256) + ), + serviceHost: UUID().uuidString, + expiration: .distantFuture + ) + } +} + +extension AgentUpdateV2 { + public static func mock() -> Self { + .init(version: .mock(), isAppClip: true, grants: [.mock()]) + } +} + public struct Mocks { public static func mockMessage() -> Data { SymmetricKey(size: .bits256).rawRepresentation diff --git a/Tests/CommProtocolTests/IdentityUpdate/AgentUpdateV2Tests.swift b/Tests/CommProtocolTests/IdentityUpdate/AgentUpdateV2Tests.swift new file mode 100644 index 0000000..87bff23 --- /dev/null +++ b/Tests/CommProtocolTests/IdentityUpdate/AgentUpdateV2Tests.swift @@ -0,0 +1,253 @@ +// +// AgentUpdateV2Tests.swift +// CommProtocol +// + +import CommProtocolMocks +import CryptoKit +import Foundation +import Testing + +@testable import CommProtocol + +struct AgentUpdateV2Tests { + let knownIdentityKey: IdentityPrivateKey + let knownSignedIdentity: SignedObject + let knownAgent: AgentPrivateKey + + init() throws { + (knownIdentityKey, knownSignedIdentity) = try Mocks.mockIdentity() + knownAgent = .init() + } + + // MARK: - CBOR round-trip + + @Test func testCborRoundTrip() throws { + let original = AgentUpdateV2.mock() + let encoded = try original.wireFormat + let (decoded, consumed) = try AgentUpdateV2.parse(encoded) + #expect(consumed == encoded.count) + #expect(decoded == original) + } + + /// `SemanticVersion.mock()` includes a pre-release suffix only at random, + /// so the mock round-trip above exercises the version archive's "r" key + /// probabilistically — this pins both branches deterministically. + @Test func testCborRoundTripVersionSuffixBothWays() throws { + for suffix in [String?.none, "-beta.1"] { + let original = AgentUpdateV2( + version: SemanticVersion( + major: 2, minor: 4, patch: 0, preReleaseSuffix: suffix + ), + isAppClip: false, + grants: [.mock()] + ) + let (decoded, _) = try AgentUpdateV2.parse(try original.wireFormat) + #expect(decoded == original) + #expect(decoded.version.string == original.version.string) + } + } + + @Test func testGrantsOnlyNoLegacyAddressesField() { + // Structural check that the type itself has no way to carry a legacy + // address list — the whole point of the gated cutover + // (docs/attachment-mailbox-delivery.md, "Wire types"). If this ever + // fails to compile, `AgentUpdateV2` grew an `addresses` field. + let update = AgentUpdateV2.mock() + let mirror = Mirror(reflecting: update) + #expect(mirror.children.map { $0.label } == ["version", "isAppClip", "grants"]) + } + + // MARK: - Wire-pinning + + @Test func testCborWireFormatGolden() throws { + let key = try TypedKeyMaterial( + algorithm: .hmacSha256, + symmetricKey: SymmetricKey(data: Data(count: 32)) + ) + let grant = try MailboxGrant( + authKey: key, + serviceHost: "ger.mx", + expiration: RoundedDate(hoursSinceEpoch: 471442) + ) + let update = AgentUpdateV2( + version: SemanticVersion(major: 2, minor: 4, patch: 0), + isAppClip: false, + grants: [grant] + ) + let encoded = try update.wireFormat + // Locks the encoding against drift, same discipline as + // MailboxGrantTests.testCborWireFormatGolden. Byte-verified at + // introduction with an independent CBOR parse: a length-prefixed + // `Data` (0x4b = 75 bytes) wrapping a 3-key canonical map ("c" < + // "g" < "v" bytewise) — isAppClip=false, a one-element grants array + // whose entry is exactly the golden `MailboxGrant` bytes, and the + // version {major:2, minor:4, patch:0}. + #expect( + encoded + == Data( + hexString: + "4ba36163f46167815836a361651a000731926168666765722e6d78616b58210500000000000000000000000000000000000000000000000000000000000000006176a3616a02616e04617000" + ) + ) + } + + /// The `ProposalType` tags of every case that predates this one must not + /// move — that's what "legacy peers keep receiving the classic triple + /// byte-identically" actually depends on, since `.agentUpdateV2` is + /// appended, not inserted. + @Test func testExistingProposalTagsUnchanged() { + #expect(CommProposal.ProposalType.sameAgent.rawValue == 1) + #expect(CommProposal.ProposalType.sameIdentity.rawValue == 2) + #expect(CommProposal.ProposalType.newIdentity.rawValue == 3) + #expect(CommProposal.ProposalType.anchorHandOff.rawValue == 4) + #expect(CommProposal.ProposalType.pqCardUpgrade.rawValue == 5) + #expect(CommProposal.ProposalType.agentUpdateV2.rawValue == 6) + } + + /// A hex pin of `.sameAgent`'s deterministic portion (the `ProposalType` + /// tag byte + `AgentUpdate`'s own encoding) under fixed inputs — proving + /// the pre-existing case's bytes are unaffected by this file's changes. + /// Extends the golden-hex pattern in `AgentHelloDualOfferTests`. + /// + /// Stops short of pinning the trailing `TypedSignature`: CryptoKit's + /// Ed25519 does not reproduce identical signature bytes for the same key + /// and message across runs (verified empirically — two runs against this + /// exact fixture diverged only in that trailing region), unlike RFC + /// 8032's nominally-deterministic algorithm. The `validate` call below + /// is the correctness check for that region instead: it can only succeed + /// if the signature, whatever its bytes, verifies against exactly this + /// `AgentUpdate` and signing key. + @Test func testSameAgentProposalGoldenHexUnaffected() throws { + let fixedAgent = try AgentPrivateKey( + archive: .init(prefix: .curve25519Signing, checkedData: Data(count: 32)) + ) + let fixedAddress = ProtocolAddress( + identifier: "00000000-0000-0000-0000-000000000000", + serviceHost: "ger.mx", + expiration: RoundedDate(hoursSinceEpoch: 471442) + ) + let fixedAgentUpdate = AgentUpdate( + version: SemanticVersion(major: 1, minor: 0, patch: 0), + isAppClip: false, + addresses: [fixedAddress] + ) + let fixedMessage = Data([0x01, 0x02, 0x03]) + let fixedContext = TypedDigest(prefix: .sha256, over: Data([0x00])) + + let proposal = try fixedAgent.proposeLeafNode( + leafNodeUpdate: fixedMessage, + agentUpdate: fixedAgentUpdate, + signedIdentityMutable: nil, + context: fixedContext + ) + let encoded = try proposal.wireFormat + #expect(encoded.count == 122) // tag(1) + AgentUpdate(56) + TypedSignature(65) + #expect( + encoded.prefix(57) + == Data( + hexString: + "010100000000012430303030303030302d303030302d303030302d303030302d303030303030303030303030066765722e6d78ff0007319200" + ) + ) + + // And it still validates — this proposal is unaffected end to end, + // not merely byte-identical by coincidence. + let (parsed, consumed) = try CommProposal.parse(encoded) + #expect(consumed == encoded.count) + guard case .sameAgent(let signedUpdate, nil) = parsed else { + Issue.record("expected .sameAgent") + return + } + let verified = try fixedAgent.publicKey.validate( + signedAgentUpdate: signedUpdate, + for: fixedMessage, + context: fixedContext + ) + #expect(verified == fixedAgentUpdate) + } + + // MARK: - CommProposal.agentUpdateV2 round-trip + + @Test func testAgentUpdateV2Proposal() throws { + let mockMessage = Mocks.mockMessage() + let mockContext = try TypedDigest.mock() + let signedIdentityMutable = try knownIdentityKey.sign(mutableData: .mock()) + + let proposal = try knownAgent.proposeAgentUpdateV2( + leafNodeUpdate: mockMessage, + agentUpdate: .mock(), + signedIdentityMutable: signedIdentityMutable, + context: mockContext + ) + let wireProposal = try proposal.wireFormat + + let validated = try CommProposal.finalParse(wireProposal) + .validate( + knownIdentity: knownSignedIdentity.content.id, + knownAgent: knownAgent.publicKey, + context: mockContext, + updateMessage: mockMessage + ) + + guard case .agentUpdateV2(let agentUpdate, let mutableData) = validated else { + Issue.record("expected .agentUpdateV2") + return + } + #expect(!agentUpdate.grants.isEmpty) + #expect(mutableData != nil) + } + + @Test func testAgentUpdateV2ProposalRejectsWrongKey() throws { + let mockMessage = Mocks.mockMessage() + let mockContext = try TypedDigest.mock() + + let proposal = try knownAgent.proposeAgentUpdateV2( + leafNodeUpdate: mockMessage, + agentUpdate: .mock(), + signedIdentityMutable: nil, + context: mockContext + ) + let wireProposal = try proposal.wireFormat + let wrongKey = AgentPrivateKey() + + #expect(throws: ProtocolError.authenticationError) { + let _ = try CommProposal.finalParse(wireProposal) + .validate( + knownIdentity: knownSignedIdentity.content.id, + knownAgent: wrongKey.publicKey, + context: mockContext, + updateMessage: mockMessage + ) + } + } + + // MARK: - mailboxGrantVersion gate + + @Test func testSupportsMailboxGrantsBoundary() { + let below = AgentUpdate( + version: SemanticVersion(major: 2, minor: 3, patch: 9999), + isAppClip: false, + addresses: [] + ) + let at = AgentUpdate( + version: AgentUpdate.mailboxGrantVersion, + isAppClip: false, + addresses: [] + ) + let above = AgentUpdate( + version: SemanticVersion(major: 2, minor: 4, patch: 1), + isAppClip: false, + addresses: [] + ) + #expect(!below.supportsMailboxGrants) + #expect(at.supportsMailboxGrants) + #expect(above.supportsMailboxGrants) + } + + @Test func testMailboxGrantVersionOrdering() { + // Sits between the two existing PQ thresholds, per the design doc. + #expect(AgentUpdate.pqCapableVersion < AgentUpdate.mailboxGrantVersion) + #expect(AgentUpdate.mailboxGrantVersion < AgentUpdate.pqDomainSeparationVersion) + } +}