Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
166 commits
Select commit Hold shift + click to select a range
4a45ee7
App Attest step 1: CBOR (RFC 8949), COSE_Key EC2 (RFC 9052) and WebAu…
Sep 21, 2026
a5530e9
App Attest step 2: DER (X.690), X.509 (RFC 5280), PEM (RFC 7468) read…
Sep 21, 2026
f957900
wip: nist_p256 recut (temporary)
Sep 21, 2026
46e926c
rfc_9052: cose_label_matches discriminates through cose_integer_of ra…
Sep 21, 2026
b7670d5
Merge branch 'session/nimble-eagle-216' into session/nimble-eagle-216…
Sep 21, 2026
22be8ad
App Attest step 3 (crypto authoring): one NIST prime-curve fold with …
Sep 21, 2026
7c56db7
wip: step 3b verifier folds (temporary)
Sep 21, 2026
ff9a1e3
Review 69598: the COSE coordinate width consumes extdeps.crypto.signa…
Sep 21, 2026
bbe2ca6
Merge branch 'session/nimble-eagle-216' into session/nimble-eagle-216…
Sep 21, 2026
7642a1f
Review 69597: PEM admits exactly one block and refuses text outside i…
Sep 21, 2026
21d9045
Merge branch 'session/nimble-eagle-216-step2' into session/nimble-eag…
Sep 21, 2026
63f630a
App Attest step 3b: the attestation and assertion verifier folds in .…
Sep 21, 2026
20a5cd1
Review 69614: the family fold's limb width is the plain ceiling (256 …
Sep 21, 2026
789b20d
Review 69616: a BIT STRING with no contents octet refuses before any …
Sep 21, 2026
8c67ca6
Merge branch 'session/nimble-eagle-216-step2' into session/nimble-eag…
Sep 21, 2026
273a32d
Merge branch 'session/nimble-eagle-216-step3' into session/nimble-eag…
Sep 21, 2026
4b271ee
Review 69626: read_tbs returns an X509TbsFields carrier with no signa…
Sep 21, 2026
7985277
Merge branch 'session/nimble-eagle-216-step2' into session/nimble-eag…
Sep 21, 2026
ad59b70
Review 69625: the four orphaned BigNat parameter rows deleted (the fa…
Sep 21, 2026
1290072
Merge branch 'session/nimble-eagle-216-step3' into session/nimble-eag…
Sep 21, 2026
d9efd5c
Review 69631: the attestation object, both certificates, the root and…
Sep 21, 2026
18dd28b
Review 69648: X509Certificate nests the X509TbsFields record instead …
Sep 21, 2026
2f44b79
Merge branch 'session/nimble-eagle-216-step2' into session/nimble-eag…
Sep 21, 2026
6304327
Merge branch 'session/nimble-eagle-216-step3' into session/nimble-eag…
Sep 21, 2026
98101a1
P-384 witness reads the nested tbs record (after review 69648 on #11975)
Sep 21, 2026
e817735
Merge branch 'session/nimble-eagle-216-step3' into session/nimble-eag…
Sep 21, 2026
ea70f31
Review 69652: refusal paths build the refused arm directly (no stand-…
Sep 21, 2026
ba3dda3
App Attest step 4: the six device routes on the approval broker, ever…
Sep 21, 2026
b82444f
Review 69660: Time is carried as its CHOICE (UtcTime | GeneralizedTim…
Sep 21, 2026
67810d4
Merge branch 'session/nimble-eagle-216-step2' into session/nimble-eag…
Sep 21, 2026
1e4baeb
Merge branch 'session/nimble-eagle-216-step3' into session/nimble-eag…
Sep 21, 2026
b60a030
Merge step 3 (Time as its CHOICE); the validity fold reads the arm
Sep 21, 2026
9acf41b
Review 69664: one point-admission predicate in the family fold (lengt…
Sep 21, 2026
0f280a8
Merge branch 'session/nimble-eagle-216-step3' into session/nimble-eag…
Sep 21, 2026
311bd02
Review 69668: the two chain links are sequenced through a match so a …
Sep 21, 2026
cfd2cdc
Merge branch 'session/nimble-eagle-216-step3b' into session/nimble-ea…
Sep 21, 2026
d93129c
std.decimal owns the decimal-digit predicate; rfc_5280's time convers…
Sep 21, 2026
73519cd
Merge branch 'session/nimble-eagle-216-step3b' into session/nimble-ea…
Sep 21, 2026
9945e92
Review 69672: redemption_status is exhaustive over the closed outcome…
Sep 21, 2026
199f273
Review 69674: the authenticator-data widths are ByteSize rows with de…
Sep 21, 2026
0ee6594
Merge branch 'session/nimble-eagle-216' into session/nimble-eagle-216…
Sep 21, 2026
c383de3
Review 69676: the four tag rows no reader consumed are deleted; Valid…
Sep 21, 2026
17a7d1d
Merge branch 'session/nimble-eagle-216-step3' into session/nimble-eag…
Sep 21, 2026
a7d31df
Merge branch 'session/nimble-eagle-216-step2' into session/nimble-eag…
Sep 21, 2026
c9b3bc4
Merge branch 'session/nimble-eagle-216-step3b' into session/nimble-ea…
Sep 21, 2026
7e1a486
Merge steps 1-3 review rounds; app_attest reads the prefix width as B…
Sep 21, 2026
4d7cca4
Merge branch 'session/nimble-eagle-216-step3b' into session/nimble-ea…
Sep 21, 2026
2fdea11
Review 69692: the WebAuthn flag bits are a closed sum matched exhaust…
Sep 21, 2026
f4bc498
Merge branch 'session/nimble-eagle-216' into session/nimble-eagle-216…
Sep 21, 2026
a8fe832
Review 69699: DerChildCountUnexpected { at, expected, found } for a f…
Sep 21, 2026
dfea752
Merge branch 'session/nimble-eagle-216-step2' into session/nimble-eag…
Sep 21, 2026
1b39ee4
Merge branch 'session/nimble-eagle-216-step3' into session/nimble-eag…
Sep 21, 2026
9f5f04f
Review 69702: the four decimal-digit copies cut over to std.decimal (…
Sep 21, 2026
d226ab3
Merge branch 'session/nimble-eagle-216-step3b' into session/nimble-ea…
Sep 21, 2026
9f25eb4
Review 69704: the entropy width is a ByteSize; cas_slot_keys routes t…
Sep 21, 2026
87539e5
Review 69721: a coordinate that is not a byte string refuses as CoseK…
Sep 21, 2026
dd18c3a
Merge branch 'session/nimble-eagle-216' into session/nimble-eagle-216…
Sep 21, 2026
5c478fc
Review 69723: the X509Time annotation names the later change that int…
Sep 21, 2026
8f9d693
Merge branch 'session/nimble-eagle-216-step2' into session/nimble-eag…
Sep 21, 2026
80fb05a
Merge branch 'session/nimble-eagle-216-step3' into session/nimble-eag…
Sep 21, 2026
6b37dee
Merge branch 'session/nimble-eagle-216-step3b' into session/nimble-ea…
Sep 21, 2026
6526cb2
Review 69725: x509_validity_at answers RFC 5280's inclusive interval …
Sep 21, 2026
47dd04e
Merge branch 'session/nimble-eagle-216-step3b' into session/nimble-ea…
Sep 21, 2026
a2d1aec
Review 69750: the issuer's named curve selects the PrimeCurve row and…
Sep 21, 2026
8e88a95
Merge branch 'session/nimble-eagle-216-step3b' into session/nimble-ea…
Sep 21, 2026
fbaab6c
Review 69752: the writer-gate claim executes all three mutating route…
Sep 21, 2026
2c89993
Review 69773: an undecodable stored key or signature encoding maps to…
Sep 21, 2026
964d9bb
Merge branch 'session/nimble-eagle-216-step3b' into session/nimble-ea…
Sep 21, 2026
8c6f8c1
Merge remote-tracking branch 'origin/main' into session/nimble-eagle-…
Sep 21, 2026
8f04790
Merge branch 'session/nimble-eagle-216-step2' into session/nimble-eag…
Sep 21, 2026
f42a63a
Merge branch 'session/nimble-eagle-216-step3' into session/nimble-eag…
Sep 21, 2026
dae870f
Merge branch 'session/nimble-eagle-216-step3b' into session/nimble-ea…
Sep 21, 2026
9789664
Review 69794: delete app_attest_decode_certificate, a wrapper with no…
Sep 21, 2026
8ff4ba2
Merge branch 'session/nimble-eagle-216-step3b' into session/nimble-ea…
Sep 21, 2026
d594770
Review 69793: the CAS store's read verb refuses a key that is not slo…
Sep 21, 2026
4f860d0
Review 69803: the assertion expectation's credential key is read from…
Sep 22, 2026
3c1dde0
Merge branch 'session/nimble-eagle-216-step3b' into session/nimble-ea…
Sep 22, 2026
be01048
Merge remote-tracking branch 'origin/main' into session/nimble-eagle-…
Sep 22, 2026
654ed2b
Merge branch 'session/nimble-eagle-216-step3' into session/nimble-eag…
Sep 22, 2026
563a286
Merge branch 'session/nimble-eagle-216-step3b' into session/nimble-ea…
Sep 22, 2026
cdcd22c
Review 69827 + operator's Team ID: the push registration lives under …
Sep 22, 2026
b8c65c5
Merge remote-tracking branch 'origin/main' into session/nimble-eagle-…
Sep 22, 2026
6bcd8b3
Merge-queue run 35678328407: declared 4b(3) eval-step drop app_attest…
Sep 22, 2026
1e56715
Merge branch 'session/nimble-eagle-216-step3' into session/nimble-eag…
Sep 22, 2026
b2d7999
Merge branch 'session/nimble-eagle-216-step3b' into session/nimble-ea…
Sep 22, 2026
65d2750
Review 69909 + the merge-queue cost class: declared 4b(3) eval-step d…
Sep 22, 2026
448d65b
The enrolment margin is a second wall, and the cliff band is not decl…
Sep 22, 2026
4c96736
Review 69953: the two P-256 assertion verifications actually leave th…
Sep 22, 2026
8796a3c
Merge session/nimble-eagle-216-step3 into session/nimble-eagle-216-st…
Sep 22, 2026
75893fb
The published SHA-384 vectors are one claim over one interface, so th…
Sep 22, 2026
bdd8c96
Merge remote-tracking branch 'origin/session/nimble-eagle-216-step3' …
Sep 22, 2026
ad01c58
Each extension reader declares what it can conclude, so the unreachab…
Sep 22, 2026
94af320
Each published SHA-384 vector is its own claim, paired with the mutat…
Sep 22, 2026
8a00239
Merge remote-tracking branch 'origin/session/nimble-eagle-216-step3' …
Sep 22, 2026
ffc91bd
The drop declaration says nine and names the vectors it actually cove…
Sep 22, 2026
e6d4b2e
Each verifier drop row states its own billed work, because the sixth …
Sep 22, 2026
d854ef4
Merge step3 into step3b; the generated projection is regenerated, not…
Sep 22, 2026
9f7c13a
The published-vector claims encode the digest they already computed
Sep 22, 2026
15a9235
Merge remote-tracking branch 'origin/session/nimble-eagle-216-step3' …
Sep 22, 2026
0b0a2df
sha384_hex is deleted: my own dedup removed its last consumer
Sep 22, 2026
888075e
Merge remote-tracking branch 'origin/session/nimble-eagle-216-step3' …
Sep 22, 2026
7c146f4
p256_is_infinity is deleted: the family consolidation left a bare ali…
Sep 22, 2026
51e2f07
Merge remote-tracking branch 'origin/session/nimble-eagle-216-step3' …
Sep 22, 2026
abe65d2
Name the instrument for the SHA-384 rows; split the P-256 base-point …
Sep 22, 2026
e77a026
decimal_digit_char is the ordering test, and its single-character pre…
Sep 22, 2026
c3177bc
The frontier names the order-n fact, and the interrupted-witness inci…
Sep 22, 2026
2a7c38c
decimal_digit_char keeps its totality; the frontier conflict is union…
Sep 22, 2026
cef5b55
Merge remote-tracking branch 'origin/session/nimble-eagle-216-step3b'…
Sep 22, 2026
1f24f24
octet_split carries its index; and the drop's "no nested scan" senten…
Sep 22, 2026
473faeb
Merge origin/main into step3: union the sha2 imports (Word64 family p…
Sep 22, 2026
2371bec
Device routes: native-realization gate before any crypto, and the App…
Sep 22, 2026
4607fb4
Regenerate the rung-drops projection from the merged authorities
Sep 22, 2026
f65b59a
#11989: App Attest's intermediate must be authorized to act as a CA (…
gunbai-bot[bot] Sep 22, 2026
3c5134a
SHA-384 witness seals its digest through uint8_octets_of_ints before …
Sep 23, 2026
69b0fa7
Merge remote-tracking branch 'origin/main' into step3
Sep 23, 2026
a5dc656
Merge remote-tracking branch 'origin/main' into step3
Sep 23, 2026
1056d0c
Merge branch 'step3' into step3b
Sep 23, 2026
eba58ea
Merge branch 'step3b' into step4
Sep 23, 2026
256d190
chore: regenerate drifted generated artifacts (ci auto-heal)
gunbai-bot[bot] Sep 23, 2026
5b087f5
Merge remote-tracking branch 'origin/session/nimble-eagle-216-step3' …
Sep 23, 2026
2e38f24
Regenerate the rung-drops projection so step3b's App Attest verifier …
Sep 23, 2026
fe35853
Merge branch 's3b' into s4
Sep 23, 2026
cb5ac23
Merge remote-tracking branch 'origin/main' into s3bm
Sep 23, 2026
afb4db7
Merge branch 's3bm' into s4m
Sep 23, 2026
e673d52
App Attest verifier witnesses discriminate at one interface; two fold…
Sep 23, 2026
0923a73
The attestation and assertion step folds stop at the first refusal
Sep 23, 2026
66a2754
x509_ca_authority witness reads the sample's certificates through the…
Sep 23, 2026
400ea99
each_expectation_reds_its_own_step becomes one claim per step, over s…
Sep 23, 2026
dbd0f9f
Typed cost-debt admissions for the two App Attest real-path inhabitan…
Sep 23, 2026
4d089af
App Attest structural steps are named checks over a declared order; e…
Sep 23, 2026
b55acde
Known-red admission: file_hold_plan harness census is red on this sta…
Sep 23, 2026
daf2bda
Admit the nonce and key-id step residues; the verifier drop names the…
Sep 23, 2026
801c232
Merge branch 's3bwork' into s4work
Sep 23, 2026
cf5991e
mint_enrolment_code seals its entropy through base64_octets before ba…
Sep 23, 2026
27668fd
Seal the three success mints; steps are reachable only through the de…
Sep 23, 2026
8ea2413
Step claims go through ordered_step_refusal (fix a doubled paren from…
Sep 23, 2026
030c67b
Merge branch 's3bwork' into s4work
Sep 23, 2026
3f360a8
Key-id step and assertion real path leave the floor for the native ro…
Sep 23, 2026
35bcd8a
Merge branch 's3bwork' into s4work
Sep 23, 2026
9fced05
The writer gate is the first question the three mutating device route…
Sep 23, 2026
9c49365
Assertion counter: remove the 4096-generation availability cliff (CAS…
gunbai-bot[bot] Sep 23, 2026
ca8a6ac
Evidence fixtures and the three remaining step wrappers are caller-se…
Sep 23, 2026
0c82751
Enrolment dead band: an exact-two, self-staling observed-only authori…
Sep 23, 2026
4c40c22
Seed-growth admission for the dead-band mirror, and a claim pinning t…
Sep 23, 2026
41f0ed7
Scope the enrolment mirror lens to the mirror's own impl
Sep 23, 2026
18bc5d6
Merge remote-tracking branch 'origin/main' into s3bwork
Sep 23, 2026
2ee28a0
Move the dead-band seed test above the roster test's doc block and gi…
Sep 23, 2026
8eddfba
Merge remote-tracking branch 'origin/session/nimble-eagle-216-step4' …
Sep 23, 2026
e469b28
Merge remote-tracking branch 'origin/session/nimble-eagle-216-step3b'…
Sep 23, 2026
9e7cd40
approval_device_wire states the counter's true standing (comments only)
Sep 23, 2026
a77c53a
Move the dead-band arm's comment above enrolment_declared_measured_st…
Sep 23, 2026
7f9b795
Merge branch 's3bwork' into s4work
Sep 23, 2026
18d7d46
host_standup_assimilation_deduction witness: read ObservationVerdict'…
Sep 23, 2026
dff52b3
Merge branch 's3bwork' into s4work
Sep 23, 2026
5fe58ed
attempt_lifecycle: carry CasGenerationSpaceExhausted (added by #12080…
Sep 24, 2026
8d090d7
signature: an undecodable carrier refuses as VerifyingKeyUndecodable …
Sep 24, 2026
8585392
Merge remote-tracking branch 'origin/main' into step3b-fix
Sep 24, 2026
416af0c
Merge commit '85853926f99f31e983ce4ff65a414f44b7ad7d86' into step4-fix
Sep 24, 2026
c6273ab
chore: regenerate drifted generated artifacts (ci auto-heal)
gunbai-bot[bot] Sep 24, 2026
3c73aea
Merge remote-tracking branch 'origin/session/nimble-eagle-216-step3b'…
Sep 24, 2026
3c1706a
Merge origin/main into App Attest step 4 (#12000)
Sep 24, 2026
7b217bc
Regenerate docs/design-rung-drops.md after merging origin/main
Sep 24, 2026
50c93dc
Device-route witnesses: supply the redemption body, move the two effe…
Sep 24, 2026
36d8645
Read skew window: pure UTC seconds arithmetic, delete device_instant_…
Sep 24, 2026
d08d7a1
Merge origin/main into App Attest step 4 (#12000)
Sep 24, 2026
70687a3
Merge origin/main into App Attest step 4 (#12000)
Sep 24, 2026
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: 3 additions & 2 deletions apps/approve-ios/Approve/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,12 @@ final class AppState: ObservableObject {
let attempt = PushAttempt(enrollmentId: e.enrollment_id, attestKeyId: e.attest_key_id, token: desired)
do {
let client = try requireClient()
let body = WireEncode.pushUpdate(registration(client.config, desired))
let push = registration(client.config, desired)
let body = WireEncode.pushUpdate(push)
let requestedAt = Self.now()
let auth = try await assertion(e, requestedAt: requestedAt,
clientData: devicePushUpdateClientData(enrollmentId: e.enrollment_id, requestedAt: requestedAt, pushBodyJson: body))
try await client.updatePush(bodyJson: body, auth)
try await client.updatePush(push, auth)
// Record success only if the token is STILL desired and the enrolment the update
// was made for is STILL the current one; otherwise loop and re-derive.
guard apnsToken == desired, case .enrolled(var now) = state, now.enrollment_id == e.enrollment_id,
Expand Down
48 changes: 27 additions & 21 deletions apps/approve-ios/Approve/Wire.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,16 @@ enum WireEncode {
}
/// push_update_json
static func pushUpdate(_ p: ApnsRegistration) -> String { push(p).serialized }
/// read_authentication_json_value
static func readAuthentication(_ a: ReadAuth) -> WireJson {
.object([("enrollment_id", .string(a.enrollmentId)), ("requested_at", .string(a.requestedAt)), ("assertion_b64", .string(a.assertionB64))])
}
/// read_authentication_json: the body of the three authenticated reads
static func readRequest(_ a: ReadAuth) -> String { readAuthentication(a).serialized }
/// push_update_request_json: { auth, push }
static func pushUpdateRequest(_ a: ReadAuth, _ p: ApnsRegistration) -> String {
WireJson.object([("auth", readAuthentication(a)), ("push", push(p))]).serialized
}
}

enum WireDecode {
Expand Down Expand Up @@ -393,14 +403,12 @@ enum OutcomeName {
]
}

// ── Headers, paths ───────────────────────────────────────────────────────────────────────────
enum ReadHeader {
static let enrollment = "X-Approval-Enrollment"
static let requestedAt = "X-Approval-Requested-At"
static let assertion = "X-Approval-Assertion"
}

/// The credentials an authenticated GET or PUT carries; produced by the caller so this file signs nothing.
// ── Paths ────────────────────────────────────────────────────────────────────────────────────
/// EVERY DEVICE OPERATION IS A POST WHOSE BODY CARRIES ITS AUTHENTICATION (gunbc.auth.approval_device_wire
/// ReadAuthentication): the server hands a route no request header but the tailnet login, so the
/// enrolment id, the claimed time and the assertion travel in the JSON body. The assertion's client
/// data (device_read_client_data) is unchanged by where the carrier rides.
/// The credentials an authenticated read or push update carries; produced by the caller so this file signs nothing.
struct ReadAuth {
var enrollmentId: String
var requestedAt: String
Expand Down Expand Up @@ -476,14 +484,9 @@ struct Client {
let config: ServerConfig
let session = URLSession(configuration: .ephemeral)

private func send(_ method: String, _ path: String, body: String? = nil, read: ReadAuth? = nil) async throws -> Data {
private func send(_ method: String, _ path: String, body: String? = nil) async throws -> Data {
var req = URLRequest(url: try config.url(path))
req.httpMethod = method
if let read {
req.setValue(read.enrollmentId, forHTTPHeaderField: ReadHeader.enrollment)
req.setValue(read.requestedAt, forHTTPHeaderField: ReadHeader.requestedAt)
req.setValue(read.assertionB64, forHTTPHeaderField: ReadHeader.assertion)
}
if let body {
req.httpBody = Data(body.utf8)
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
Expand All @@ -501,21 +504,24 @@ struct Client {
func enrol(_ r: EnrolmentRequest) async throws -> EnrolmentGrant {
try WireDecode.enrolmentGrant(await send("POST", Route.enrol, body: WireEncode.enrolmentRequest(r)))
}
/// POST /approve/device/pending, body = read_authentication_json
func pending(_ read: ReadAuth) async throws -> [PendingApproval] {
try WireDecode.pendingList(await send("GET", Route.pending, read: read))
try WireDecode.pendingList(await send("POST", Route.pending, body: WireEncode.readRequest(read)))
}
/// POST /approve/device/requests/<id>, body = read_authentication_json
func fetch(_ path: String, _ read: ReadAuth) async throws -> FetchedRequest {
try WireDecode.fetchedRequest(await send("GET", path, read: read))
try WireDecode.fetchedRequest(await send("POST", path, body: WireEncode.readRequest(read)))
}
func redeem(_ r: SignedRedemption) async throws -> RedemptionOutcome {
try WireDecode.redemptionResponse(await send("POST", Route.redeem, body: WireEncode.signedRedemption(r)))
}
/// GET /approve/device/enrollments/<enrollment_id>, read-assertion authenticated.
/// POST /approve/device/enrollments/<enrollment_id>, body = read_authentication_json.
func readback(_ path: String, _ read: ReadAuth) async throws -> EnrolmentReadback {
try WireDecode.enrolmentReadback(await send("GET", path, read: read))
try WireDecode.enrolmentReadback(await send("POST", path, body: WireEncode.readRequest(read)))
}
/// PUT /approve/device/push; the body is the exact JSON the assertion's client data framed.
func updatePush(bodyJson: String, _ read: ReadAuth) async throws {
_ = try await send("PUT", Route.push, body: bodyJson, read: read)
/// POST /approve/device/push, body = push_update_request_json { auth, push }; the assertion's
/// client data frames push_update_json(push) alone, exactly as the server re-renders it.
func updatePush(_ push: ApnsRegistration, _ read: ReadAuth) async throws {
_ = try await send("POST", Route.push, body: WireEncode.pushUpdateRequest(read, push))
}
}
20 changes: 15 additions & 5 deletions apps/approve-ios/ApproveTests/ProtocolVectorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ struct Vectors: Decodable {
var envelope: [Envelope]
/// path_segment: input -> encoded, emitted by the .dag path_segment fold.
var path_segment: [PathSegment]
/// surface: the header names and route paths, by name.
/// surface: the one method, and the route paths, by name.
var surface: [Surface]
/// stored_request: the store's own rendering, so the detail screen's reader is joined to it.
/// Optional in the Codable so that a fixture predating the section (it is emitted by
Expand Down Expand Up @@ -178,6 +178,18 @@ final class ProtocolVectorTests: XCTestCase {
XCTAssertEqual(WireEncode.pushUpdate(try WireDecode.pushUpdate(Data(body.utf8))), body)
}

/// The read-authentication body and the push-update envelope { auth, push } are ENCODED only by
/// the app (the server decodes them), so they are checked against the fixture bytes directly
/// from the fixture's own field values.
func testReadAuthenticationEncodesToTheFixtureBytes() throws {
let body = try envelope("read_authentication")
let auth = ReadAuth(enrollmentId: "enr-482913", requestedAt: "2026-09-18T12:00:30Z", assertionB64: "omlzaWduYXR1cmU")
XCTAssertEqual(WireEncode.readRequest(auth), body)
let pushBody = try envelope("push_update")
let push = try WireDecode.pushUpdate(Data(pushBody.utf8))
XCTAssertEqual(WireEncode.pushUpdateRequest(auth, push), try envelope("push_update_request"))
}

/// Every response body decodes strictly, with its declared members present and non-empty.
func testResponsesDecode() throws {
XCTAssertFalse(try WireDecode.enrolmentGrant(Data(try envelope("enrolment_grant").utf8)).enrollment_id.isEmpty)
Expand Down Expand Up @@ -250,14 +262,12 @@ final class ProtocolVectorTests: XCTestCase {
XCTAssertEqual(got, Data(try envelope("push_update_client_data").utf8))
}

/// Every header name and route the app spells is the fixture's; a missing surface row FAILS, so
/// The method and every route the app spells is the fixture's; a missing surface row FAILS, so
/// a route the wire adds and the app does not spell is caught, not silently absent.
func testSurfaceMatchesTheFixture() throws {
let rows = Dictionary(uniqueKeysWithValues: try load().surface.map { ($0.name, $0.value) })
func surface(_ name: String) throws -> String { try XCTUnwrap(rows[name], "surface row \(name) missing") }
XCTAssertEqual(ReadHeader.enrollment, try surface("header_enrollment"))
XCTAssertEqual(ReadHeader.requestedAt, try surface("header_requested_at"))
XCTAssertEqual(ReadHeader.assertion, try surface("header_assertion"))
XCTAssertEqual("POST", try surface("method_every_device_operation"))
XCTAssertEqual(Route.enrol, try surface("route_enrol"))
XCTAssertEqual(Route.pending, try surface("route_pending"))
XCTAssertEqual(Route.requestPrefix, try surface("route_request_prefix"))
Expand Down
2 changes: 2 additions & 0 deletions apps/approve-ios/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ targets:
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: ai.gunb.approve
MARKETING_VERSION: "1"
CURRENT_PROJECT_VERSION: 1
INFOPLIST_KEY_UILaunchScreen_Generation: YES
INFOPLIST_KEY_NSFaceIDUsageDescription: "Approving a request signs it with a key that only unlocks with Face ID."
INFOPLIST_KEY_CFBundleDisplayName: Approve
Expand Down
9 changes: 9 additions & 0 deletions dag/extdeps/filesystem/filesystem_io.dag
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,15 @@ type FilesystemDirectoryListing sole_constructor {
entries: String
}

// THE ENTRY NAMES OF AN ADMITTED LISTING, decoded once beside the encoding they come from: `List`
// joins names with newlines, so this is the split -- blank lines dropped, every other line one
// name as the host spelled it. A consumer that split `entries` itself would be the second
// decoder of one wire format (review 69704 of gunbc#12000 found one; extdeps.realization
// artifact_store_fs carries an older one under filesystem_absence_establishment_adoption_standing).
fn filesystem_listing_entry_names(listing: FilesystemDirectoryListing) -> List<String> {
filter(listing.entries.split(delimiter: "\n"), n => n != "")
}

type FilesystemEstablishedAbsence sole_constructor {
directory: FilePath
name: String
Expand Down
53 changes: 53 additions & 0 deletions dag/gunbc/auth/approval_app_attest_config.dag
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
module gunbc.auth.approval_app_attest_config

import std.types { NonEmptyStr, Int, List }
import extdeps.apple.app_attest { AppIdPrefix, AppAttestEnvironment, AppAttestDevelopment, app_attest_app_id }

// THE FACTS THE APP ATTEST VERIFIER IS CONFIGURED WITH, and who owns each. The bundle identifier
// is the app's (apps/approve-ios/project.yml PRODUCT_BUNDLE_IDENTIFIER). The App ID prefix is
// read from the Identifier entry of the operator's Apple Developer account -- its own fact, not a
// second name for the team id (extdeps.apple.app_attest AppIdPrefix). The environment is decided
// by how the build reaches the phone: TestFlight and App Store builds attest in production. The
// admitted launch categories are Apple's validation categories for those distributions (2 =
// TestFlight, 4 = App Store; 3 = a development signing identity is admitted so the operator's
// own Xcode build can enrol during bring-up), and the bundle version is the one the operator
// distributed.
//
// THE OPERATOR SUPPLIED THE PREFIX (chat, 2026-09-22): the Team ID 72HGYAMBVQ shown under
// Membership details, which Apple uses as the App ID prefix for an account's own identifiers. The
// bring-up build reaches the phone from the operator's Xcode over a cable, so it attests in the
// development environment with a development signing identity (category 3); TestFlight (2) and
// App Store (4) stay admitted for the day the distribution changes, and the bundle version is the
// MARKETING_VERSION apps/approve-ios/project.yml sets. A wrong prefix does not fabricate: every
// attestation refuses AttestationAppIdMismatch by name. The row below is the one place that changes.
type AppAttestVerifierConfig
= AppAttestVerifierConfigured {
app_id_prefix: AppIdPrefix
bundle_id: NonEmptyStr
environment: AppAttestEnvironment
admitted_validation_categories: List<Int>
expected_bundle_version: NonEmptyStr
}
| AppAttestVerifierAwaitingOperator { missing: NonEmptyStr }

data approval_app_bundle_id: NonEmptyStr = "ai.gunb.approve"

data approval_app_attest_verifier: AppAttestVerifierConfig = AppAttestVerifierConfigured {
app_id_prefix: "72HGYAMBVQ" as AppIdPrefix,
bundle_id: approval_app_bundle_id,
environment: AppAttestDevelopment,
admitted_validation_categories: [2, 3, 4],
expected_bundle_version: approval_app_bundle_version,
}

// CFBundleShortVersionString of the operator's build: the same literal apps/approve-ios/project.yml
// sets as MARKETING_VERSION, which is hand-authored Swift/XcodeGen seed and cannot read this row.
data approval_app_bundle_version: NonEmptyStr = "1"

fn approval_app_attest_app_id(c: AppAttestVerifierConfig) -> NonEmptyStr? {
match c {
AppAttestVerifierAwaitingOperator { missing: _ } => none
AppAttestVerifierConfigured { app_id_prefix: p, bundle_id: b, environment: _, admitted_validation_categories: _, expected_bundle_version: _ } =>
Present { value: app_attest_app_id(prefix: p, bundle_id: b) }
}
}
Loading