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
5 changes: 5 additions & 0 deletions .changeset/sharp-otters-verify.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@germ-network/atprototypes": minor
---

`AtprotoTypesVerify` now verifies secp256k1 repo commit signatures, not just P-256 — a from-scratch, verify-only Swift port (field/scalar arithmetic, Jacobian point operations, SEC1 decompression, ECDSA), since most Bluesky accounts sign with this curve and swift-crypto has no k256 support. Checked against Wycheproof's secp256k1 test vectors and a P256K-backed differential oracle in `AtprotoTypesVerifyTests` (test-only dependency; nothing new ships in the product). `AtprotoTypesVerifyMocks`'s fixture signer is now a protocol (`RepoFixtureSigningKey`) instead of concrete P256, so a consumer's tests can build synthetic k256 repos too.
5 changes: 5 additions & 0 deletions .changeset/thirty-crabs-verify.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@germ-network/atprototypes": minor
---

Add `AtprotoTypesVerify` and `AtprotoTypesVerifyMocks`: CAR framing, DAG-CBOR, MST proof walking, CID recomputation, and P-256 repo commit-signature verification, plus fixture-building support for building synthetic signed repos in tests. Additive products — a consumer that never links the new products pays nothing for their existing (e.g. an App Clip target). The existing `AtprotoTypes` target gains one small addition of its own: an `Atproto.Repo` seam (`RecordPath`, `Proof`, `ProofVerifying`, `ProofUnavailable`) cheap enough for a space-constrained consumer to link even when it doesn't want the verifier itself.
7 changes: 6 additions & 1 deletion .github/workflows/ci-apple.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,9 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Test platform ${{ matrix.destination }}
run: set -o pipefail && xcodebuild -skipMacroValidation -scheme AtprotoTypes -destination "${{ matrix.destination }}" test | xcbeautify
# -skipPackagePluginValidation: swift-secp256k1 (the differential-test
# oracle for AtprotoTypesVerify's from-scratch k256, test-only and never
# shipped) carries a build-tool plugin that copies its shared sources.
# xcodebuild refuses unvalidated plugins non-interactively; `swift test`
# doesn't gate them, which is why Linux stayed green while Apple failed.
run: set -o pipefail && xcodebuild -skipMacroValidation -skipPackagePluginValidation -scheme AtprotoTypes -destination "${{ matrix.destination }}" test | xcbeautify
10 changes: 10 additions & 0 deletions .swiftpm/xcode/xcshareddata/xcschemes/AtprotoTypes.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AtprotoTypesVerifyTests"
BuildableName = "AtprotoTypesVerifyTests"
BlueprintName = "AtprotoTypesVerifyTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
Expand Down
11 changes: 10 additions & 1 deletion Package.resolved

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

44 changes: 44 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ let package = Package(
),
.library(name: "AtprotoTypesMocks", targets: ["AtprotoTypesMocks"]),
.library(name: "Mockable", targets: ["Mockable"]),
//CAR framing, DAG-CBOR, MST proof walking, CID recomputation and repo
//commit-signature verification — additive, so a consumer that never
//links it (e.g. an App Clip target) pays nothing for it existing.
.library(name: "AtprotoTypesVerify", targets: ["AtprotoTypesVerify"]),
.library(name: "AtprotoTypesVerifyMocks", targets: ["AtprotoTypesVerifyMocks"]),
],
dependencies: [
.package(url: "https://github.com/swift-libp2p/swift-bases.git", from: "0.2.0"),
Expand All @@ -27,6 +32,10 @@ let package = Package(
),
.package(url: "https://github.com/apple/swift-http-types.git", from: "1.5.1"),
.package(url: "https://github.com/apple/swift-log", from: "1.6.0"),
//Differential oracle for the from-scratch secp256k1 verify port — test
//dependency only, never in a shipped product (that's the whole reason
//AtprotoTypesVerify carries its own pure-Swift implementation).
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1.git", from: "0.23.1"),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
Expand All @@ -53,5 +62,40 @@ let package = Package(
name: "AtprotoTypesTests",
dependencies: ["AtprotoTypes", "AtprotoTypesMocks", "Mockable"]
),
.target(
name: "AtprotoTypesVerify",
dependencies: [
"AtprotoTypes",
.product(name: "Crypto", package: "swift-crypto"),
.product(name: "Base32", package: "swift-bases"),
.product(name: "BaseX", package: "swift-bases"),
]
),
//Fixture-building support for AtprotoTypesVerify — builds a real signed
//commit over an MST over record blocks, so a consumer's tests can forge
//one field and watch the proof fail for the reason it should, rather
//than pinning captured bytes. A real library target, not test-target-
//internal code, mirroring `AtprotoTypesMocks` alongside `AtprotoTypes`,
//so it is usable from a different package's tests (`@testable import`
//does not cross package boundaries).
.target(
name: "AtprotoTypesVerifyMocks",
dependencies: [
"AtprotoTypesVerify",
"AtprotoTypes",
.product(name: "Crypto", package: "swift-crypto"),
]
),
.testTarget(
name: "AtprotoTypesVerifyTests",
dependencies: [
"AtprotoTypesVerify",
"AtprotoTypesVerifyMocks",
"AtprotoTypes",
.product(name: "Crypto", package: "swift-crypto"),
.product(name: "P256K", package: "swift-secp256k1"),
],
resources: [.copy("Resources/wycheproof")]
),
]
)
116 changes: 116 additions & 0 deletions Sources/AtprotoTypes/Atproto/Repo/RepoProof.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//
// RepoProof.swift
// AtprotoTypes
//
// Created by Mark @ Germ on 8/17/26.
//

import Foundation

///The seam, and only the seam. Everything that can actually check a proof —
///CAR framing, DAG-CBOR, the MST walk, curve arithmetic — lives in
///`AtprotoTypesVerify`, an additive product a space-constrained consumer can
///simply not link. This file has to stay cheap enough that linking it costs
///such a consumer nothing.
extension Atproto {
public enum Repo {}
}

extension Atproto.Repo {
///Where a record sits in a repo. The MST keys a record by
///`collection/rkey`, so that string is the thing an inclusion proof is
///actually about.
public struct RecordPath: Sendable, Hashable {
public let collection: Atproto.NSID
public let rkey: String

public init(collection: Atproto.NSID, rkey: String) {
self.collection = collection
self.rkey = rkey
}

public var mstKey: String {
collection.rawValue + "/" + rkey
}
}

///A record that is *in* a DID's repo, as distinct from one a server handed
///us while claiming to speak for that DID. `verified(for:)`-style internal
///consistency checks establish the latter and cannot establish the former:
///an attacker mints a well-formed record binding their own key to a
///victim's DID and passes it. What they cannot do is get it into the
///victim's repo under a commit signed by the victim's signing key, which is
///exactly what this value witnesses.
public struct Proof: Sendable {
public let did: Atproto.DID
public let path: RecordPath
///The record's own CID, recomputed from `block` rather than trusted.
public let cid: Atproto.CID
///The DAG-CBOR bytes the CID commits to.
public let block: Data
///The repo revision the commit carried, for ordering two proofs.
public let rev: String

public init(
did: Atproto.DID,
path: RecordPath,
cid: Atproto.CID,
block: Data,
rev: String
) {
self.did = did
self.path = path
self.cid = cid
self.block = block
self.rev = rev
}
}

public protocol ProofVerifying: Sendable {
///Checks a CAR — a signed commit plus an inclusion proof: that the
///commit is signed by `document`'s atproto signing key, that the MST
///proves `path` from the signed commit's root, and that the record
///block hashes to the CID the MST names.
///
///Deliberately pure and synchronous, and it takes no view on where the
///bytes came from. That is the point: CAR is self-authenticating, so a
///relay can withhold or replay but never forge, and the same check holds
///for a monitor's `/records/{did}` and for the DID's own PDS alike.
///Keeping the fetch out also makes this testable without a network and
///makes it plain the verifier cannot go and ask anyone anything.
func verifyRecordProof(
car: Data,
did: Atproto.DID,
path: RecordPath,
document: Atproto.DIDDocument
) throws -> Proof
}

///What a space-constrained consumer injects instead of linking
///`AtprotoTypesVerify`. It refuses rather than passing the record through
///unverified: a caller asking for a proof must never receive a value that
///merely looks like one.
public struct ProofUnavailable: ProofVerifying {
public init() {}

public func verifyRecordProof(
car: Data,
did: Atproto.DID,
path: RecordPath,
document: Atproto.DIDDocument
) throws -> Proof {
throw Errors.verificationUnavailable
}
}

public enum Errors: Error, Equatable, LocalizedError {
case verificationUnavailable

public var errorDescription: String? {
switch self {
case .verificationUnavailable:
"Repo verification is not available in this build"
}
}
}
}
80 changes: 80 additions & 0 deletions Sources/AtprotoTypesVerify/ByteReader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//
// ByteReader.swift
// AtprotoTypesVerify
//
// Created by Mark @ Germ on 8/17/26.
//

import AtprotoTypes
import Foundation

///Cursor over untrusted bytes. Copies to `[UInt8]` up front so slice index
///arithmetic can't be got wrong — `Data`'s indices don't rebase on slicing, and
///every read here is attacker-influenced.
struct ByteReader {
private let bytes: [UInt8]
private(set) var offset: Int

init(_ data: Data) {
self.bytes = Array(data)
self.offset = 0
}

var isAtEnd: Bool { offset >= bytes.count }
var remaining: Int { bytes.count - offset }

mutating func readByte() throws -> UInt8 {
guard offset < bytes.count else {
throw Atproto.Repo.ProofError.truncated
}
defer { offset += 1 }
return bytes[offset]
}

mutating func read(_ count: Int) throws -> [UInt8] {
guard count >= 0, remaining >= count else {
throw Atproto.Repo.ProofError.truncated
}
defer { offset += count }
return Array(bytes[offset..<(offset + count)])
}

///Unsigned LEB128 as multiformats uses it. Rejects both overflow and the
///non-minimal encodings that would otherwise let the same number be written
///two ways — which matters because CAR block framing is length-prefixed and
///a second spelling of a length is a second parse of the same stream.
mutating func readUnsignedVarint() throws -> UInt64 {
var result: UInt64 = 0
var shift: UInt64 = 0

for index in 0..<10 {
let byte = try readByte()
let payload = UInt64(byte & 0x7F)

guard shift < 64, !(shift == 63 && payload > 1) else {
throw Atproto.Repo.ProofError.varintOverflow
}
result |= payload << shift

if byte & 0x80 == 0 {
//a trailing continuation-free zero byte adds nothing, so it is
//a second spelling of a shorter varint
guard index == 0 || byte != 0 else {
throw Atproto.Repo.ProofError.varintNotMinimal
}
return result
}
shift += 7
}
throw Atproto.Repo.ProofError.varintOverflow
}

///Reads a length that has to be usable as an `Int` index.
mutating func readLength() throws -> Int {
let value = try readUnsignedVarint()
guard value <= UInt64(Int.max), Int(value) <= remaining else {
throw Atproto.Repo.ProofError.truncated
}
return Int(value)
}
}
Loading
Loading