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
10 changes: 9 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
Expand Down
80 changes: 80 additions & 0 deletions Sources/CommProtocol/Cbor/DeterministicCbor.swift
Original file line number Diff line number Diff line change
@@ -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<T: Encodable>(_ 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<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
try CborDecoder().decode(type, from: Data(data))
}
}
24 changes: 24 additions & 0 deletions Sources/CommProtocol/IdentityExchange/IdentityFollowup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
45 changes: 45 additions & 0 deletions Sources/CommProtocol/IdentityKeys/AgentKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<IdentityMutableData>?,
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:)``,
Expand Down Expand Up @@ -416,6 +442,25 @@ public struct AgentPublicKey: Sendable {
}
return signedUpgrade.content
}

func validate(
signedAgentUpdateV2: SignedObject<AgentUpdateV2>,
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 {
Expand Down
119 changes: 119 additions & 0 deletions Sources/CommProtocol/IdentityUpdate/AgentUpdateV2.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
Loading
Loading