From 3ea088b5517b1e46b3e9805bfcbaabc666e0f542 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 06:15:34 +0000 Subject: [PATCH 01/17] Model signed device redemption for approvals: APNs, Secure Enclave, App Attest, ECDSA P-256 The protocol between the roadmap server and the operator's phone for redeeming an approval with a biometric-gated device signature, modeled before any server route or Swift. iOS is the V1 realization; Android is modeled at every platform point (FCM, Keystore key attestation) and realized by nothing yet. - extdeps.crypto.signature: ECDSA P-256/SHA-256 interface (FIPS 186-5), one wire encoding per key and signature, sole_constructor evidence naming its message. - extdeps.apple.apns / secure_enclave / app_attest, extdeps.google.fcm, extdeps.android.key_attestation: cited upstream shapes. - gunbc.auth.approval_device_redemption: push is an opaque wake-up only; the app fetches the stored request, signs over a server challenge, the stored request text, the verb and the capability; server owns decided_at and derives the login; signature REQUIRED on the device route; RedemptionIdentityEvidence names the legacy arms by mechanism. - Server routes, verification primitives, ApprovalTarget, cutover and Android are declared frontiers; the existing /approve route and store are untouched while the Mt. Collins first boot runs on them. Co-Authored-By: Claude Opus 5 (1M context) --- dag/extdeps/android/key_attestation.dag | 32 ++ dag/extdeps/apple/apns.dag | 181 +++++++++++ dag/extdeps/apple/app_attest.dag | 77 +++++ dag/extdeps/apple/secure_enclave.dag | 67 +++++ dag/extdeps/crypto/signature.dag | 140 +++++++++ dag/extdeps/google/fcm.dag | 34 +++ dag/gunbc/auth/approval_device_redemption.dag | 283 ++++++++++++++++++ ...pproval_device_redemption_witness_test.dag | 153 ++++++++++ 8 files changed, 967 insertions(+) create mode 100644 dag/extdeps/android/key_attestation.dag create mode 100644 dag/extdeps/apple/apns.dag create mode 100644 dag/extdeps/apple/app_attest.dag create mode 100644 dag/extdeps/apple/secure_enclave.dag create mode 100644 dag/extdeps/crypto/signature.dag create mode 100644 dag/extdeps/google/fcm.dag create mode 100644 dag/gunbc/auth/approval_device_redemption.dag create mode 100644 dag/test/claim/approval_device_redemption_witness_test.dag diff --git a/dag/extdeps/android/key_attestation.dag b/dag/extdeps/android/key_attestation.dag new file mode 100644 index 00000000000..07780fd0a77 --- /dev/null +++ b/dag/extdeps/android/key_attestation.dag @@ -0,0 +1,32 @@ +module extdeps.android.key_attestation + +import std.types { NonEmptyStr, List } +import extdeps.external_authority { ExternalAuthority } +import extdeps.uri { Uri, Https } + +// Android Keystore hardware-backed key attestation. A key generated with setAttestationChallenge +// yields an X.509 chain whose leaf carries the KeyDescription extension (OID 1.3.6.1.4.1.11129.2.1.17): +// the attestation challenge, security level (Software / TrustedEnvironment / StrongBox), and the key's +// authorization list (purpose, userAuthenticationType, per-operation auth, attestationApplicationId +// naming the package and signing-certificate digests). The chain roots at a Google attestation root. +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "source.android.com/docs/security/features/keystore/attestation" + } +} + +data key_description_extension_oid: NonEmptyStr = "1.3.6.1.4.1.11129.2.1.17" + +type KeymasterSecurityLevel + = SecurityLevelSoftware + | SecurityLevelTrustedEnvironment + | SecurityLevelStrongBox + +// Unlike App Attest, Android attests THE DECISION KEY ITSELF, including whether its use requires +// per-operation biometric authorization -- so no second key is needed to bind app to key. +type AndroidAttestationChain { + certificates_der_b64: List +} + +data software_level_is_not_hardware_note: NonEmptyStr = "SecurityLevelSoftware means the key is not hardware-backed; a verifier requiring a device-bound key refuses it rather than admitting it at a weaker standing." diff --git a/dag/extdeps/apple/apns.dag b/dag/extdeps/apple/apns.dag new file mode 100644 index 00000000000..3183a5b80e0 --- /dev/null +++ b/dag/extdeps/apple/apns.dag @@ -0,0 +1,181 @@ +module extdeps.apple.apns + +import std.types { NonEmptyStr, String, Int, Bool } +import extdeps.external_authority { ExternalAuthority } +import extdeps.uri { Uri, Https } +import extdeps.crypto.signature { SignatureSuite, EcdsaP256Sha256 } +import extdeps.transports.rest { RestOutcome, RestOk, RestStatusRefused, RestTransportRefused, RestBodyUndecodable } + +// Apple Push Notification service, HTTP/2 provider API with token-based authentication. +// Cited pages: "Sending notification requests to APNs", "Establishing a token-based connection to +// APNs", "Generating a remote notification", "Handling notification responses from APNs". +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns" + } +} + +// ── Environments ───────────────────────────────────────────────────────────────────────────── +// Two hosts, and the choice is a fact of how the APP WAS SIGNED, not of the provider's preference: a +// device token minted by a development-signed build is only valid against the sandbox host, and one +// minted by a distribution build (App Store AND TestFlight) only against production. Sending to the +// wrong one answers 400 BadDeviceToken -- a delivery failure that looks like a bad token. +type ApnsEnvironment + = ApnsDevelopment + | ApnsProduction + +fn apns_host(e: ApnsEnvironment) -> NonEmptyStr { + match e { + ApnsDevelopment => "api.sandbox.push.apple.com" as NonEmptyStr + ApnsProduction => "api.push.apple.com" as NonEmptyStr + } +} + +data testflight_is_production_note: NonEmptyStr = "TestFlight builds are distribution-signed, so their device tokens are production tokens and must be sent to api.push.apple.com. Only an Xcode development build uses api.sandbox.push.apple.com." + +// ── Token-based provider authentication ────────────────────────────────────────────────────── +// The provider signs a JWT (RFC 7519) with the .p8 auth key: header { alg: ES256, kid: }, +// claims { iss: , iat: }. ES256 is ECDSA P-256 SHA-256, so the suite +// is extdeps.crypto.signature's, not a second name for it. +type ApnsKeyId = NonEmptyStr where brand("ApnsKeyId") +type AppleTeamId = NonEmptyStr where brand("AppleTeamId") + +data apns_provider_token_suite: SignatureSuite = EcdsaP256Sha256 + +// Apple's two timing facts, and they bound in opposite directions: a token older than one hour is +// refused (403 ExpiredProviderToken), and a provider that REFRESHES more often than once per twenty +// minutes is refused too (429 TooManyProviderTokenUpdates). So the token is a cached value with a +// window, not a per-request mint. +data apns_provider_token_max_age_seconds: Int = 3600 +data apns_provider_token_min_refresh_seconds: Int = 1200 + +type ApnsProviderIdentity { + team_id: AppleTeamId + key_id: ApnsKeyId +} + +// ── Request headers ────────────────────────────────────────────────────────────────────────── +// apns-topic is the app's bundle identifier for an alert push. +type ApnsTopic = NonEmptyStr where brand("ApnsTopic") + +// apns-push-type is required on watchOS and recommended everywhere; an alert must say alert. +type ApnsPushType + = PushTypeAlert + | PushTypeBackground + +fn apns_push_type_wire(t: ApnsPushType) -> NonEmptyStr { + match t { + PushTypeAlert => "alert" as NonEmptyStr + PushTypeBackground => "background" as NonEmptyStr + } +} + +// apns-priority: 10 delivers immediately, 5 is power-considerate, 1 prioritizes power. A background +// push MUST be 5; an alert may be 10. +type ApnsPriority + = PriorityImmediate + | PriorityPowerConsiderate + | PriorityPowerPreferred + +fn apns_priority_wire(p: ApnsPriority) -> Int { + match p { + PriorityImmediate => 10 + PriorityPowerConsiderate => 5 + PriorityPowerPreferred => 1 + } +} + +// apns-collapse-id: notifications sharing it are coalesced on the device into the newest. Apple caps +// its value at 64 bytes. +type ApnsCollapseId = NonEmptyStr where brand("ApnsCollapseId") +data apns_collapse_id_max_bytes: Int = 64 + +// ── Payload ────────────────────────────────────────────────────────────────────────────────── +// The aps dictionary's alert and mutable-content keys. mutable-content = 1 lets a Notification +// Service Extension rewrite the content before display; custom keys travel beside aps at the top +// level of the JSON body. +type ApnsInterruptionLevel + = InterruptionPassive + | InterruptionActive + | InterruptionTimeSensitive + +fn apns_interruption_level_wire(l: ApnsInterruptionLevel) -> NonEmptyStr { + match l { + InterruptionPassive => "passive" as NonEmptyStr + InterruptionActive => "active" as NonEmptyStr + InterruptionTimeSensitive => "time-sensitive" as NonEmptyStr + } +} + +type ApnsAlert { + title: NonEmptyStr + subtitle: String + body: NonEmptyStr +} + +type ApnsAps { + alert: ApnsAlert + mutable_content: Bool + interruption_level: ApnsInterruptionLevel + category: String +} + +// Apple's limit for a non-VoIP remote notification payload. +data apns_payload_max_bytes: Int = 4096 + +// A device token is an opaque per-app-install address APNs issues to the app; the provider stores it +// and never interprets it. +type ApnsDeviceToken = NonEmptyStr where brand("ApnsDeviceToken") + +type ApnsRequest { + environment: ApnsEnvironment + device_token: ApnsDeviceToken + topic: ApnsTopic + push_type: ApnsPushType + priority: ApnsPriority + collapse_id: ApnsCollapseId + aps: ApnsAps +} + +// ── Responses ──────────────────────────────────────────────────────────────────────────────── +// 200 carries the apns-id header and no body. Every refusal carries a JSON body { reason }, and 410 +// adds { timestamp } -- the last moment APNs knew the token was valid. The status table is Apple's. +// A 410 is not a transient failure: the token will never work again, and the consumer's remedy is to +// forget the enrolment's token, not to retry. +type ApnsRefusalClass + = ApnsBadRequest + | ApnsAuthenticationRefused + | ApnsBadPath + | ApnsBadMethod + | ApnsTokenUnregistered + | ApnsPayloadTooLarge + | ApnsTooManyRequests + | ApnsServerError + | ApnsServiceUnavailable + | ApnsStatusUnlisted { status: Int } + +fn apns_refusal_class(status: Int) -> ApnsRefusalClass { + if status == 400 { ApnsBadRequest } else { if status == 403 { ApnsAuthenticationRefused } else { if status == 404 { ApnsBadPath } else { if status == 405 { ApnsBadMethod } else { if status == 410 { ApnsTokenUnregistered } else { if status == 413 { ApnsPayloadTooLarge } else { if status == 429 { ApnsTooManyRequests } else { if status == 500 { ApnsServerError } else { if status == 503 { ApnsServiceUnavailable } else { ApnsStatusUnlisted { status: status } } } } } } } } } } +} + +// The same four-way outcome extdeps.ntfy.ntfy NtfyPublishOutcome settled on, for the same reasons: a +// transport refusal has no status and must not be given one, and an undecodable 200 is not a +// refusal. The broker-level shape both fold into is the consumer's (gunbc.auth.approval_push +// PushOutcome), not this module's. +type ApnsSendOutcome + = ApnsAccepted { apns_id: NonEmptyStr } + | ApnsRefused { status: Int, class: ApnsRefusalClass, reason: String } + | ApnsUnreached { cause: String } + | ApnsUndecodable { status: Int, cause: String } + +fn apns_send_outcome_of(rest: RestOutcome, apns_id: String) -> ApnsSendOutcome { + match rest { + RestOk => ApnsAccepted { apns_id: apns_id as NonEmptyStr } + RestStatusRefused { status, body } => ApnsRefused { status: status, class: apns_refusal_class(status: status), reason: body } + RestTransportRefused { cause } => ApnsUnreached { cause: cause as String } + RestBodyUndecodable { status, cause } => ApnsUndecodable { status: status, cause: cause as String } + } +} + +data apns_accepted_is_not_delivery_note: NonEmptyStr = "ApnsAccepted establishes that APNs accepted the request for delivery. APNs stores and forwards one notification per device while it is offline and may drop it; acceptance is not delivery, not display, and never consent." diff --git a/dag/extdeps/apple/app_attest.dag b/dag/extdeps/apple/app_attest.dag new file mode 100644 index 00000000000..52cc01a6765 --- /dev/null +++ b/dag/extdeps/apple/app_attest.dag @@ -0,0 +1,77 @@ +module extdeps.apple.app_attest + +import std.types { NonEmptyStr, String, Int } +import extdeps.external_authority { ExternalAuthority } +import extdeps.uri { Uri, Https } + +// DeviceCheck DCAppAttestService, and Apple's server-side validation procedure for its attestation +// objects. Cited pages: "Establishing your app's integrity", "Validating apps that connect to your +// server", "DCAppAttestService". +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server" + } +} + +// ── What an attestation establishes, and what it does not ──────────────────────────────────── +// App Attest generates its OWN P-256 key in the Secure Enclave (generateKey), separate from any +// CryptoKit key, and Apple certifies that key as belonging to a genuine instance of the app with a +// named team id and bundle id on genuine Apple hardware. Two things it does NOT give: +// +// * It cannot be gated by biometrics -- DCAppAttestService exposes no access-control policy -- so +// it proves the APP, not the operator. The decision-signing key is the CryptoKit enclave key +// under extdeps.apple.secure_enclave BiometryCurrentSet. +// * It certifies nothing about any other key. The link from the attested app to the decision key +// is made by the enrolment protocol: the clientDataHash the app attests over commits to the +// decision key and the one-time enrolment code (gunbc.auth.approval_push). +data attestation_proves_the_app_not_the_person_note: NonEmptyStr = "An App Attest attestation certifies a Secure Enclave key belonging to a genuine instance of one team id + bundle id on Apple hardware. It carries no biometric gate and says nothing about other keys; binding it to the biometric-gated decision key is the enrolment protocol's job, through the clientDataHash." + +type AttestKeyId = NonEmptyStr where brand("AttestKeyId") + +// Two environments by entitlement (com.apple.developer.devicecheck.appattest-environment), visible +// to the server as the authenticator data's AAGUID: "appattestdevelop" or "appattest" followed by +// seven zero bytes. TestFlight and App Store builds attest in production. +type AppAttestEnvironment + = AppAttestDevelopment + | AppAttestProduction + +// ── Validation, in Apple's order ───────────────────────────────────────────────────────────── +// The attestation object is CBOR { fmt: "apple-appattest", attStmt: { x5c, receipt }, authData }. +// Apple lists the checks; each failure is its own refusal because each is a different incident. +// +// 1. x5c chains to the Apple App Attestation Root CA +// 2. nonce = SHA256(authData || clientDataHash) +// 3. nonce equals the credential certificate extension OID 1.2.840.113635.100.8.2 +// 4. SHA256(credential public key) equals the key id +// 5. authData RP ID hash equals SHA256(.) +// 6. authData counter is 0 +// 7. AAGUID names the expected environment +// 8. authData credential id equals the key id +type AttestationRefusal + = AttestationFormatUnexpected { declared: String } + | AttestationChainUntrusted { detail: String } + | AttestationNonceMismatch + | AttestationKeyIdMismatch { declared: AttestKeyId } + | AttestationAppIdMismatch { expected_app_id: NonEmptyStr } + | AttestationCounterNonZero { counter: Int } + | AttestationEnvironmentUnexpected { expected: AppAttestEnvironment } + | AttestationCredentialIdMismatch { declared: AttestKeyId } + | AttestationUndecodable { cause: String } + +data apple_app_attest_root_ca_note: NonEmptyStr = "The trust anchor is Apple's App Attestation Root CA, published at www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem. It is pinned by the verifier's configuration, never taken from the attestation's own x5c." + +data app_attest_nonce_extension_oid: NonEmptyStr = "1.2.840.113635.100.8.2" + +// The app id the RP ID hash commits to is ".". +fn app_attest_app_id(team_id: NonEmptyStr, bundle_id: NonEmptyStr) -> NonEmptyStr { + join([team_id as String, bundle_id as String], ".") as NonEmptyStr +} + +// Success names the attested key and the clientDataHash it was attested over, so a verified +// attestation cannot be paired afterwards with a different enrolment's client data. +type AttestationVerification + = AttestationVerified { key_id: AttestKeyId, client_data_hash_hex: NonEmptyStr, environment: AppAttestEnvironment } + | AttestationRefused { cause: AttestationRefusal } + +data app_attest_unavailable_in_simulator_note: NonEmptyStr = "DCAppAttestService.isSupported is false in the Simulator and on devices without a Secure Enclave; the app must refuse enrolment there rather than enrolling without an attestation." diff --git a/dag/extdeps/apple/secure_enclave.dag b/dag/extdeps/apple/secure_enclave.dag new file mode 100644 index 00000000000..2d951ff88c3 --- /dev/null +++ b/dag/extdeps/apple/secure_enclave.dag @@ -0,0 +1,67 @@ +module extdeps.apple.secure_enclave + +import std.types { NonEmptyStr } +import std.logic { Bool } +import extdeps.external_authority { ExternalAuthority } +import extdeps.uri { Uri, Https } +import extdeps.crypto.signature { SignatureSuite, EcdsaP256Sha256, PublicKeyEncoding, Sec1Uncompressed, SignatureEncoding, P1363FixedWidth } + +// Apple CryptoKit SecureEnclave.P256.Signing.PrivateKey, and the Keychain access-control flags that +// gate its use. Cited pages: CryptoKit "SecureEnclave.P256.Signing.PrivateKey", Security +// "SecAccessControlCreateFlags", Apple Platform Security "Secure Enclave". +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "developer.apple.com/documentation/cryptokit/secureenclave/p256/signing/privatekey" + } +} + +// THE ENCLAVE SUPPORTS EXACTLY ONE SIGNING SUITE, and it is extdeps.crypto.signature's, named there. +// Apple documents the Secure Enclave as P-256 only; a model with a suite field a caller may choose +// would describe keys the hardware cannot hold. +data secure_enclave_signing_suite: SignatureSuite = EcdsaP256Sha256 + +// What leaves the device. The private key never does: CryptoKit's dataRepresentation for an enclave +// key is an opaque, device-bound wrapped blob, usable only by the enclave that made it. +data secure_enclave_public_key_encoding: PublicKeyEncoding = Sec1Uncompressed +data secure_enclave_signature_encoding: SignatureEncoding = P1363FixedWidth + +data private_key_never_leaves_note: NonEmptyStr = "A Secure Enclave private key is generated inside the enclave and cannot be exported; its dataRepresentation is a blob only that device's enclave can unwrap. Possession of a signature under the enrolled public key is therefore evidence of possession of that physical device -- not of the person holding it, which is what the access-control policy below adds." + +// ── Who may use the key ────────────────────────────────────────────────────────────────────── +// The access-control flags bound to the key at CREATION, not checked by the app at sign time. That +// is the difference between an enclave-enforced gate and an app-enforced one: with the policy on the +// key, the enclave itself refuses to sign without a satisfied biometric, so a compromised app process +// cannot sign silently. An LAContext prompt the app shows before calling an unguarded key is a UI, +// not a gate. +type EnclaveKeyAccessPolicy + = BiometryCurrentSet + | BiometryAny + | UserPresence + +// biometryCurrentSet invalidates the key when the enrolled fingers or face change. That is the +// property wanted here: someone who adds their face to the operator's unlocked phone does not inherit +// the ability to approve. The cost is that re-enrolling Face ID requires re-enrolling the device. +data approval_key_access_policy_note: NonEmptyStr = "BiometryCurrentSet binds the key to the biometric set enrolled at key creation: adding or removing a face or finger invalidates it. BiometryAny survives such a change; UserPresence also admits the device passcode. For a key whose signature names the operator, BiometryCurrentSet is the only arm under which a changed biometric set cannot silently inherit the operator's authority." + +fn enclave_policy_admits_passcode(p: EnclaveKeyAccessPolicy) -> Bool { + match p { + BiometryCurrentSet => false + BiometryAny => false + UserPresence => true + } +} + +fn enclave_policy_survives_biometric_change(p: EnclaveKeyAccessPolicy) -> Bool { + match p { + BiometryCurrentSet => false + BiometryAny => true + UserPresence => true + } +} + +// THE SIGNED INPUT IS HASHED BY CRYPTOKIT, NOT BY THE CALLER. PrivateKey.signature(for: Data) hashes +// the data with SHA-256 and signs the digest, which is the verify side's expectation under +// EcdsaP256Sha256. Signing a pre-computed digest through signature(for: digest) and verifying the +// raw message would double-hash; the model fixes the one spelling. +data enclave_signs_message_not_digest_note: NonEmptyStr = "The app passes the exact signing-input bytes to signature(for:), which SHA-256-hashes them; the server verifies ECDSA-P256-SHA256 over the same bytes. Neither side pre-hashes." diff --git a/dag/extdeps/crypto/signature.dag b/dag/extdeps/crypto/signature.dag new file mode 100644 index 00000000000..af43b965a49 --- /dev/null +++ b/dag/extdeps/crypto/signature.dag @@ -0,0 +1,140 @@ +module extdeps.crypto.signature + +import std.types { NonEmptyStr, String, Int } +import std.logic { Bool } +import extdeps.external_authority { ExternalAuthority } +import extdeps.uri { Uri, Https } + +// ECDSA per FIPS 186-5 (Digital Signature Standard), over the NIST P-256 curve (SP 800-186) with +// SHA-256 (FIPS 180-4, cited by extdeps.crypto.hash). The anchor is the standard, not Apple and not +// a crate: the Secure Enclave is one PRODUCER of this signature (extdeps.apple.secure_enclave) and a +// server-side verifier is another realization of the same shape. §3: the interface is modeled once. +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "csrc.nist.gov/pubs/fips/186-5/final" + } +} + +// THE ASYMMETRIC TWIN OF extdeps.crypto.mac, and the reason it exists is the note that module +// carries: with a MAC, whoever can verify can mint. A verifier that must NOT be able to issue -- a +// server that records which device decided, and must not be able to forge that device's decision -- +// needs a public-key construction. That is the only property this module adds, and the only one a +// consumer may rely on it for. +data verification_key_cannot_sign_note: NonEmptyStr = "An ECDSA public key verifies and cannot sign. A record that names a device's public key as the signer of a decision therefore attributes the decision to whoever holds the private key, and nothing that holds only the public key -- including the server storing the record -- can manufacture a matching signature. That is the property a MAC cannot give (extdeps.crypto.mac symmetric_verification_is_issuance_note)." + +// ONE FIXED SUITE, closed for the same algorithm-confusion reason as MacSuite: the verifier picks +// the suite from its enrolled key, never from the message it is checking. +type SignatureSuite = + | EcdsaP256Sha256 + +fn signature_suite_name(s: SignatureSuite) -> NonEmptyStr { + match s { + EcdsaP256Sha256 => "ECDSA-P256-SHA256" as NonEmptyStr + } +} + +// THE PUBLIC KEY SPELLING IS SEC 1 §2.3.3 UNCOMPRESSED POINT: 0x04 || X || Y, 65 octets for P-256. +// It is also ANSI X9.63's spelling, which is the name Apple's CryptoKit exposes it under +// (x963Representation). One spelling on the wire, so a key cannot be enrolled under one encoding +// and compared under another. +fn signature_suite_public_key_octets(s: SignatureSuite) -> Int { + match s { + EcdsaP256Sha256 => 65 + } +} + +// THE SIGNATURE SPELLING IS THE FIXED-WIDTH r || s, 64 octets for P-256 (IEEE P1363 form; CryptoKit +// rawRepresentation). DER (CryptoKit derRepresentation) is the other common spelling; it is not +// admitted, because a verifier that accepts two encodings of one signature has a malleability +// surface it did not need. +fn signature_suite_signature_octets(s: SignatureSuite) -> Int { + match s { + EcdsaP256Sha256 => 64 + } +} + +type PublicKeyEncoding = + | Sec1Uncompressed + +type SignatureEncoding = + | P1363FixedWidth + +// The key a verifier holds. Not Secret: a public key is published by design, and wrapping it would +// stop it reaching the audit record where it belongs. +type VerifyingKey { + suite: SignatureSuite + encoding: PublicKeyEncoding + point_b64url: NonEmptyStr +} + +type SignatureBytes { + suite: SignatureSuite + encoding: SignatureEncoding + b64url: NonEmptyStr +} + +// ── Verification ───────────────────────────────────────────────────────────────────────────── +// THE SUCCESS CARRIES THE MESSAGE AND THE KEY, for the reason extdeps.crypto.mac VerifiedMessage +// does: a success detached from its message authenticates every message. sole_constructor makes the +// only mint the realization seam below. +type SignedMessage sole_constructor { + key: VerifyingKey + message: NonEmptyStr +} + +type SignatureVerification + = SignatureVerified { verified: SignedMessage } + | SignatureInvalid { suite: SignatureSuite } + | SignatureMalformed { octets_declared: Int, octets_expected: Int } + | VerifyingKeyMalformed { octets_declared: Int, octets_expected: Int } + | SignatureSuiteMismatch { key_suite: SignatureSuite, signature_suite: SignatureSuite } + +fn signature_verification_succeeded(v: SignatureVerification) -> Bool { + match v { + SignatureVerified { verified: _ } => true + SignatureInvalid { suite: _ } => false + SignatureMalformed { octets_declared: _, octets_expected: _ } => false + VerifyingKeyMalformed { octets_declared: _, octets_expected: _ } => false + SignatureSuiteMismatch { key_suite: _, signature_suite: _ } => false + } +} + +// NO BOUND IMPLEMENTATION YET, and that is declared rather than papered: extdeps.crypto.mac binds +// RustCrypto's hmac through a host primitive; the ECDSA twin (RustCrypto p256 VerifyingKey::verify) +// has no host primitive in this tree. The consumer that needs it is the /approve route's device +// signature check (gunbc.auth.approval_push); until the primitive lands no route may mint +// SignatureVerified from anything, so no redemption can claim a device. +// +// ── The realization seam ───────────────────────────────────────────────────────────────────── +// Same boundary as mac_verification_from_implementation, stated the same way: evidence cannot exist +// without naming its message and key (structural), and whether the bound implementation actually ran +// ECDSA verification over those bytes is the realization's trust, not this module's (not +// structural). Length checks happen HERE, before the implementation is consulted, because they are +// facts of the suite and not of the curve arithmetic. +fn signature_verification_from_implementation( + key: VerifyingKey, + key_octets: Int, + signature: SignatureBytes, + signature_octets: Int, + message: NonEmptyStr, + implementation_verified: Bool, +) -> SignatureVerification { + if key.suite != signature.suite { + SignatureSuiteMismatch { key_suite: key.suite, signature_suite: signature.suite } + } else { + if key_octets != signature_suite_public_key_octets(s: key.suite) { + VerifyingKeyMalformed { octets_declared: key_octets, octets_expected: signature_suite_public_key_octets(s: key.suite) } + } else { + if signature_octets != signature_suite_signature_octets(s: signature.suite) { + SignatureMalformed { octets_declared: signature_octets, octets_expected: signature_suite_signature_octets(s: signature.suite) } + } else { + if implementation_verified { + SignatureVerified { verified: SignedMessage { key: key, message: message } } + } else { + SignatureInvalid { suite: signature.suite } + } + } + } + } +} diff --git a/dag/extdeps/google/fcm.dag b/dag/extdeps/google/fcm.dag new file mode 100644 index 00000000000..0eb950ba06d --- /dev/null +++ b/dag/extdeps/google/fcm.dag @@ -0,0 +1,34 @@ +module extdeps.google.fcm + +import std.types { NonEmptyStr } +import extdeps.external_authority { ExternalAuthority } +import extdeps.uri { Uri, Https } + +// Firebase Cloud Messaging HTTP v1 API: POST +// https://fcm.googleapis.com/v1/projects//messages:send, OAuth 2.0 bearer from a service +// account, body { message: { token, data, notification, android } }. +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send" + } +} + +type FcmProjectId = NonEmptyStr where brand("FcmProjectId") + +// Issued to one app install by the FCM SDK; opaque to the sender, and replaced by the SDK on +// reinstall or data clear (onNewToken). +type FcmRegistrationToken = NonEmptyStr where brand("FcmRegistrationToken") + +// FCM names its refusals in the response body's ErrorCode. UNREGISTERED is the registration-is-dead +// arm, the counterpart of APNs 410. +type FcmErrorCode + = FcmInvalidArgument + | FcmUnregistered + | FcmSenderIdMismatch + | FcmQuotaExceeded + | FcmUnavailable + | FcmInternal + | FcmThirdPartyAuthError + +data fcm_transport_encryption_note: NonEmptyStr = "FCM encrypts in transit but is not end-to-end encrypted: Google can read message content. Firebase's own guidance for sensitive payloads is an opaque data message followed by a fetch from the application server." diff --git a/dag/gunbc/auth/approval_device_redemption.dag b/dag/gunbc/auth/approval_device_redemption.dag new file mode 100644 index 00000000000..04cb5dcb56f --- /dev/null +++ b/dag/gunbc/auth/approval_device_redemption.dag @@ -0,0 +1,283 @@ +module gunbc.auth.approval_device_redemption + +import std.types { NonEmptyStr, String, Timestamp, Int } +import std.logic { Bool } +import std.dissolution { DissolutionCondition, unbound_dissolution } +import extdeps.crypto.signature { VerifyingKey, SignatureBytes } +import extdeps.apple.apns { + ApnsSendOutcome, ApnsAccepted, ApnsRefused, ApnsUnreached, ApnsUndecodable, + ApnsEnvironment, ApnsTopic, ApnsDeviceToken, ApnsRefusalClass, + ApnsBadRequest, ApnsAuthenticationRefused, ApnsBadPath, ApnsBadMethod, ApnsTokenUnregistered, + ApnsPayloadTooLarge, ApnsTooManyRequests, ApnsServerError, ApnsServiceUnavailable, ApnsStatusUnlisted, +} +import extdeps.apple.app_attest { AttestKeyId } +import extdeps.google.fcm { FcmProjectId, FcmRegistrationToken } +import extdeps.android.key_attestation { AndroidAttestationChain } +import gunbc.auth.approval_capability { ProposedDecision, proposed_decision_name, signing_field_separator } +import gunbc.secret_provision { SecretRef, fleet_secret_ref, secret_ref_pin_version } + +// ── What this module is ────────────────────────────────────────────────────────────────────── +// The protocol between the roadmap server and the operator's phone for redeeming an approval with +// a device-bound, biometric-gated signature. ONE protocol, platform realizations beside it: iOS is +// built (extdeps.apple.*); Android is modeled at every platform point and realized by nothing yet +// (android_realization_frontier). Push is one peripheral here -- it only wakes the app. +// +// WHY A DEVICE SIGNATURE. The loopback deployment cannot attribute Tailscale-User-Login to the proxy +// (gunbc.auth.approval_decision_store approval_identity_attribution_frontier), so a capability-only +// redemption records the capability's bearer. A signature under an enrolled key the enclave (or +// Keystore) releases only on the operator's biometric attributes the decision to that enrolment +// without trusting anything on the socket. On the device route the signature is REQUIRED: a route +// that still admitted the capability alone would leave anonymous approval standing beside it. + +type MobilePlatform + = MobileIos + | MobileAndroid + +// ── Identity evidence a redemption carries ─────────────────────────────────────────────────── +// Named by MECHANISM. The two Legacy arms are what the existing /approve route admits today; they +// exist exactly as long as that route and the ntfy loop do, and are deleted at cutover +// (approval_device_cutover_frontier). The device route admits only EnrolledDeviceProof. +type RedemptionIdentityEvidence + = EnrolledDeviceProof { + enrollment_id: NonEmptyStr + operator_login: NonEmptyStr + platform: MobilePlatform + decision_key: VerifyingKey + } + | LegacyTailnetProxyHeader { presented_login: NonEmptyStr } + | LegacyCapabilityBearer { residual: NonEmptyStr } + +fn identity_evidence_names_a_person(e: RedemptionIdentityEvidence) -> Bool { + match e { + EnrolledDeviceProof { enrollment_id: _, operator_login: _, platform: _, decision_key: _ } => true + LegacyTailnetProxyHeader { presented_login: _ } => true + LegacyCapabilityBearer { residual: _ } => false + } +} + +// ── Enrolment ──────────────────────────────────────────────────────────────────────────────── +// The operator's explicit act on one phone, admitted by a ONE-TIME CODE the dashboard shows. The +// operator login is DERIVED BY THE SERVER from the code's issuance, never supplied by the app: the +// code was issued to a login, so redeeming it enrols a key for that login and no other. +// +// THE RESIDUAL, stated rather than claimed away: the dashboard binds loopback behind tailscale +// serve, so no off-tailnet caller can reach the code-issuing route, but an on-host process under +// another POSIX user can. The code's authority is therefore "whoever could reach the dashboard on +// srv1 within the code's lifetime", the same boundary approval_identity_attribution_frontier names. +data enrolment_code_residual_note: NonEmptyStr = "The one-time enrolment code is reachable only through the dashboard, which binds loopback behind tailscale serve: off-tailnet callers are excluded, on-host POSIX users are not. A short lifetime and single use bound the exposure; peer attribution at the serve boundary (approval_identity_attribution_frontier) is what would close it." + +data approval_enrolment_protocol: NonEmptyStr = "gunbc.approval-enrolment.v1" as NonEmptyStr + +type EnrolmentChallenge { + challenge_id: NonEmptyStr + code: NonEmptyStr + issued_to_login: NonEmptyStr + issued_at: Timestamp + expires_at: Timestamp +} + +// Push registration is SEPARATE from enrolment: a token rotates (reinstall, restore, APNs 410) +// without changing who is enrolled or which key decides, and revoking an enrolment must end its +// authority whatever push tokens still exist. +type PushRegistration + = ApnsRegistration { environment: ApnsEnvironment, topic: ApnsTopic, token: ApnsDeviceToken } + | FcmRegistration { project: FcmProjectId, token: FcmRegistrationToken } + +// What establishes the decision key belongs to a genuine app instance on genuine hardware. iOS: an +// App Attest attestation whose clientDataHash is SHA-256 of enrolment_transcript (the App Attest key +// is not the decision key -- extdeps.apple.app_attest). Android: a Keystore attestation chain for +// the decision key itself, whose attestation challenge is the same transcript. +type PlatformEnrollmentEvidence + = IosAppAttestBoundDecisionKey { attest_key_id: AttestKeyId, attestation_b64: NonEmptyStr } + | AndroidAttestedDecisionKey { chain: AndroidAttestationChain } + +fn platform_of_evidence(e: PlatformEnrollmentEvidence) -> MobilePlatform { + match e { + IosAppAttestBoundDecisionKey { attest_key_id: _, attestation_b64: _ } => MobileIos + AndroidAttestedDecisionKey { chain: _ } => MobileAndroid + } +} + +type EnrollmentStanding + = EnrollmentActive + | EnrollmentRevoked { revoked_at: Timestamp, reason: NonEmptyStr } + +type DeviceEnrollment { + enrollment_id: NonEmptyStr + operator_login: NonEmptyStr + decision_key: VerifyingKey + evidence: PlatformEnrollmentEvidence + standing: EnrollmentStanding +} + +// The bytes the platform attestation commits to. ONE rendering, joined by the capability's unit +// separator so no field can forge a boundary. It binds the server's challenge (so a replayed +// attestation cannot enrol under a fresh code) to the decision key (so a leaked code cannot enrol a +// key the app did not attest alongside it). The login is not here: the server derives it from the +// challenge, and an app-supplied login would be a claim nobody checks. +fn enrolment_transcript(challenge: EnrolmentChallenge, decision_key: VerifyingKey, platform: MobilePlatform) -> NonEmptyStr { + join( + [ + approval_enrolment_protocol as String, + challenge.challenge_id as String, + challenge.code as String, + platform_wire(p: platform) as String, + decision_key.point_b64url as String, + ], + signing_field_separator, + ) as NonEmptyStr +} + +fn platform_wire(p: MobilePlatform) -> NonEmptyStr { + match p { + MobileIos => "ios" as NonEmptyStr + MobileAndroid => "android" as NonEmptyStr + } +} + +// ── The push ───────────────────────────────────────────────────────────────────────────────── +// THE PUSH WAKES; IT DOES NOT CARRY WHAT IS DECIDED. It holds one opaque, non-authorizing locator. +// The app fetches the pending request from the roadmap server over the tailnet, displays THAT, and +// signs over it. Two reasons, each sufficient: the approve/deny capabilities are bearer credentials +// that must not transit or rest at APNs or FCM; and anything rendered from the payload is a second +// copy of the request that can drift from the revision being authorized. The alert text is generic +// ("Approval requested") because a lock screen shows it. Push is an accelerator: the app also lists +// pending approvals on open, so a lost push costs latency, never a decision. +type ApprovalPushHint { + notification_id: NonEmptyStr +} + +// ── Redemption ─────────────────────────────────────────────────────────────────────────────── +// The server issues a fresh challenge with every fetched request; the device signs over it, the +// exact stored request it displayed, the chosen verb and the exact capability it redeems. The +// server owns decided_at -- a client clock can neither backdate a decision nor extend an expiry. +data approval_redemption_protocol: NonEmptyStr = "gunbc.approval-redemption.v1" as NonEmptyStr + +type RedemptionChallenge { + challenge_id: NonEmptyStr + nonce: NonEmptyStr + expires_at: Timestamp +} + +// stored_request_text is gunbc.auth.approval_decision_store stored_request_json of the record the +// app displayed, byte for byte; capability_text is approval_capability_signing_input of the +// capability selected. Both are the existing canonical renderings, so the device signs no third +// spelling of either -- and when the stored request gains its typed target +// (approval_target_frontier) the signature covers it with no change here. +type DeviceRedemptionSigningInput { + audience: NonEmptyStr + enrollment_id: NonEmptyStr + challenge: RedemptionChallenge + escalation_id: NonEmptyStr + request_revision: NonEmptyStr + stored_request_text: NonEmptyStr + decision: ProposedDecision + capability_text: NonEmptyStr + capability_tag: NonEmptyStr +} + +fn device_redemption_signing_input(i: DeviceRedemptionSigningInput) -> NonEmptyStr { + join( + [ + approval_redemption_protocol as String, + i.audience as String, + i.enrollment_id as String, + i.challenge.challenge_id as String, + i.challenge.nonce as String, + i.escalation_id as String, + i.request_revision as String, + i.stored_request_text as String, + proposed_decision_name(d: i.decision) as String, + i.capability_text as String, + i.capability_tag as String, + ], + signing_field_separator, + ) as NonEmptyStr +} + +// Per-redemption platform proof beside the decision signature. iOS re-establishes the app instance +// with an App Attest assertion over the same signing input (its counter refuses replay). Android's +// Keystore attestation already bound the decision key at enrolment, so the signature is the proof. +type PlatformRedemptionProof + = IosAppAttestAssertion { assertion_b64: NonEmptyStr } + | AndroidDecisionKeyOnly + +type SignedRedemption { + signing_input: DeviceRedemptionSigningInput + signature: SignatureBytes + platform_proof: PlatformRedemptionProof +} + +// ── Push provider outcome ──────────────────────────────────────────────────────────────────── +// Named for what a provider can establish: it ACCEPTED the message. Not delivered, not displayed, +// not read, never consent. retryable is Apple's reading of its own status, not a policy here. +type PushProvider + = ProviderApns + | ProviderFcm + +type PushProviderOutcome + = PushProviderAccepted { provider: PushProvider, receipt: NonEmptyStr } + | PushProviderRefused { provider: PushProvider, status: Int, detail: String, retryable: Bool } + | PushRegistrationInvalid { provider: PushProvider, detail: String } + | PushProviderUnreached { provider: PushProvider, cause: String } + | PushProviderUndecodable { provider: PushProvider, status: Int, cause: String } + +fn apns_refusal_retryable(c: ApnsRefusalClass) -> Bool { + match c { + ApnsBadRequest => false + ApnsAuthenticationRefused => false + ApnsBadPath => false + ApnsBadMethod => false + ApnsTokenUnregistered => false + ApnsPayloadTooLarge => false + ApnsTooManyRequests => true + ApnsServerError => true + ApnsServiceUnavailable => true + ApnsStatusUnlisted { status: _ } => false + } +} + +// APNs 410 is the one refusal that is a REGISTRATION fact: the token is dead and will never work, +// so the remedy is re-registration, not retry. +fn push_outcome_of_apns(o: ApnsSendOutcome) -> PushProviderOutcome { + match o { + ApnsAccepted { apns_id } => PushProviderAccepted { provider: ProviderApns, receipt: apns_id } + ApnsRefused { status, class, reason } => + match class { + ApnsTokenUnregistered => PushRegistrationInvalid { provider: ProviderApns, detail: reason } + _ => PushProviderRefused { provider: ProviderApns, status: status, detail: reason, retryable: apns_refusal_retryable(c: class) } + } + ApnsUnreached { cause } => PushProviderUnreached { provider: ProviderApns, cause: cause } + ApnsUndecodable { status, cause } => PushProviderUndecodable { provider: ProviderApns, status: status, cause: cause } + } +} + +// ── The provider key ───────────────────────────────────────────────────────────────────────── +// The .p8 APNs auth key lives in Secret Manager, pinned. It is NOT yet a row in +// gunbc.auth.fleet_secret_accessor_roster: that fold stops at the first refusal, so a row for a +// secret that does not exist yet would red every accessor converge. The row lands with the key. +data approval_apns_auth_key_secret_ref: SecretRef = secret_ref_pin_version( + ref: fleet_secret_ref(secret_id: "approval-apns-auth-key"), + version: "1", +) + +// ── Frontiers ──────────────────────────────────────────────────────────────────────────────── +data approval_device_routes_frontier: DissolutionCondition = unbound_dissolution( + description: "gunbc.roadmap_serve gains, BESIDE the existing /approve route and without changing it or the store record: an operator-only enrolment-challenge issuer on the dashboard, POST enrolment verifying PlatformEnrollmentEvidence over enrolment_transcript, a pending-approvals list and fetch that issue a RedemptionChallenge, and a device redemption route that REQUIRES a verified SignedRedemption under an active DeviceEnrollment before committing through the existing decision CAS with server-owned decided_at -- SUFFICIENT FOR one approval decided on the phone to be read back from approval_decision_store with decided_by naming the enrolled login", +) + +data ecdsa_verification_realization_frontier: DissolutionCondition = unbound_dissolution( + description: "a host primitive binding RustCrypto p256 ECDSA verification behind extdeps.crypto.signature signature_verification_from_implementation, and an App Attest attestation and assertion verifier (CBOR decode, X.509 chain to the pinned Apple App Attestation Root CA, counter) behind extdeps.apple.app_attest -- SUFFICIENT FOR the device routes to mint SignatureVerified and AttestationVerified from real bytes", +) + +data approval_target_frontier: DissolutionCondition = unbound_dissolution( + description: "gunbc.auth.approval_decision_store StoredApprovalRequest gains a typed ApprovalTarget (authority, subject, operator sentence derived from the subject) rendered by stored_request_json, landed after the Mt. Collins first boot releases the store -- and carries the access lifetime (gunbc.auth.access_request AccessLifetime) distinct from the link's expires_at -- SUFFICIENT FOR the app to show the host and what is granted for how long from the one stored record, never the link expiry in its place, and for the device signature to cover both", +) + +data approval_device_cutover_frontier: DissolutionCondition = unbound_dissolution( + description: "one real approval decided on the enrolled phone reads back from the store with decided_by naming the login, after which ONE change routes file_and_notify to APNs, deletes the ntfy publish, the bearer-only /approve route and the two Legacy arms of RedemptionIdentityEvidence together -- SUFFICIENT FOR no admitted redemption to lack an attributable principal", +) + +data android_realization_frontier: DissolutionCondition = unbound_dissolution( + description: "an Android client that generates the decision key in Keystore with per-operation biometric authorization, submits its attestation chain over enrolment_transcript, registers with FCM, and redeems with SignedRedemption, plus the server's Android key-attestation verifier and FCM publish -- SUFFICIENT FOR an approval decided on a physical Android device to read back with decided_by naming the enrolled login", +) diff --git a/dag/test/claim/approval_device_redemption_witness_test.dag b/dag/test/claim/approval_device_redemption_witness_test.dag new file mode 100644 index 00000000000..cec2637c409 --- /dev/null +++ b/dag/test/claim/approval_device_redemption_witness_test.dag @@ -0,0 +1,153 @@ +module test.claim.approval_device_redemption_witness_test + +import std.logic { Bool } +import std.types { String, NonEmptyStr, Timestamp } +import extdeps.transports.rest { RestOk, RestStatusRefused, RestTransportRefused } +import extdeps.apple.apns { + ApnsStatusUnlisted, + apns_send_outcome_of, apns_refusal_class, +} +import extdeps.crypto.signature { + VerifyingKey, SignatureBytes, EcdsaP256Sha256, Sec1Uncompressed, P1363FixedWidth, + SignatureVerified, SignatureInvalid, SignatureMalformed, VerifyingKeyMalformed, + signature_verification_from_implementation, +} +import gunbc.auth.approval_capability { ProposeApprove, ProposeDeny } +import gunbc.auth.approval_device_redemption { + DeviceRedemptionSigningInput, RedemptionChallenge, EnrolmentChallenge, MobileIos, MobileAndroid, + PushProviderAccepted, PushProviderRefused, PushRegistrationInvalid, PushProviderUnreached, PushProviderUndecodable, + push_outcome_of_apns, device_redemption_signing_input, enrolment_transcript, +} + +data key_a: VerifyingKey = VerifyingKey { suite: EcdsaP256Sha256, encoding: Sec1Uncompressed, point_b64url: "BKEYA" } +data key_b: VerifyingKey = VerifyingKey { suite: EcdsaP256Sha256, encoding: Sec1Uncompressed, point_b64url: "BKEYB" } +data sig: SignatureBytes = SignatureBytes { suite: EcdsaP256Sha256, encoding: P1363FixedWidth, b64url: "SIG" } + +fn redemption_input(verb_approve: Bool, enrollment: NonEmptyStr, nonce: NonEmptyStr, request_text: NonEmptyStr, tag: NonEmptyStr) -> NonEmptyStr { + device_redemption_signing_input(i: DeviceRedemptionSigningInput { + audience: "srv1.tailecbe08.ts.net", + enrollment_id: enrollment, + challenge: RedemptionChallenge { challenge_id: "c1", nonce: nonce, expires_at: "2026-09-18T12:05:00Z" as Timestamp }, + escalation_id: "esc-1", + request_revision: "r1", + stored_request_text: request_text, + decision: if verb_approve { ProposeApprove } else { ProposeDeny }, + capability_text: "cap", + capability_tag: tag, + }) +} + +data base_input: NonEmptyStr = redemption_input(verb_approve: true, enrollment: "e1", nonce: "n1", request_text: "req", tag: "T") + +data enrol_challenge: EnrolmentChallenge = EnrolmentChallenge { + challenge_id: "ec1", code: "482913", issued_to_login: "operator", + issued_at: "2026-09-18T12:00:00Z" as Timestamp, expires_at: "2026-09-18T12:10:00Z" as Timestamp, +} + +// 410 IS THE ONE STATUS THAT IS NOT A RETRY: the enrolment's token is dead. It must surface as its +// own arm, not as a generic refusal the notifier would retry forever. +test fn witness_apns_410_becomes_registration_invalid() -> Bool { + match push_outcome_of_apns(o: apns_send_outcome_of(rest: RestStatusRefused { status: 410, body: "{\"reason\":\"Unregistered\"}" }, apns_id: "")) { + PushRegistrationInvalid { provider: _, detail } => detail != "" + PushProviderAccepted { provider: _, receipt: _ } => false + PushProviderRefused { provider: _, status: _, detail: _, retryable: _ } => false + PushProviderUnreached { provider: _, cause: _ } => false + PushProviderUndecodable { provider: _, status: _, cause: _ } => false + } +} + +test fn witness_apns_429_stays_a_refusal_carrying_its_status() -> Bool { + match push_outcome_of_apns(o: apns_send_outcome_of(rest: RestStatusRefused { status: 429, body: "{\"reason\":\"TooManyRequests\"}" }, apns_id: "")) { + PushProviderRefused { provider: _, status, detail: _, retryable } => status == 429 && retryable + PushRegistrationInvalid { provider: _, detail: _ } => false + PushProviderAccepted { provider: _, receipt: _ } => false + PushProviderUnreached { provider: _, cause: _ } => false + PushProviderUndecodable { provider: _, status: _, cause: _ } => false + } +} + +test fn witness_apns_ok_carries_apns_id() -> Bool { + match push_outcome_of_apns(o: apns_send_outcome_of(rest: RestOk, apns_id: "id-7")) { + PushProviderAccepted { provider: _, receipt: message_id } => message_id as String == "id-7" + PushProviderRefused { provider: _, status: _, detail: _, retryable: _ } => false + PushRegistrationInvalid { provider: _, detail: _ } => false + PushProviderUnreached { provider: _, cause: _ } => false + PushProviderUndecodable { provider: _, status: _, cause: _ } => false + } +} + +test fn witness_apns_transport_refusal_has_no_status() -> Bool { + match push_outcome_of_apns(o: apns_send_outcome_of(rest: RestTransportRefused { cause: "connect refused" }, apns_id: "")) { + PushProviderUnreached { provider: _, cause } => cause == "connect refused" + PushProviderAccepted { provider: _, receipt: _ } => false + PushProviderRefused { provider: _, status: _, detail: _, retryable: _ } => false + PushRegistrationInvalid { provider: _, detail: _ } => false + PushProviderUndecodable { provider: _, status: _, cause: _ } => false + } +} + +test fn witness_apns_unlisted_status_is_not_misclassified() -> Bool { + match apns_refusal_class(status: 418) { + ApnsStatusUnlisted { status } => status == 418 + _ => false + } +} + +// The signing input discriminates on every field an attacker would try to swap. +test fn witness_signing_input_binds_the_verb() -> Bool { + base_input != redemption_input(verb_approve: false, enrollment: "e1", nonce: "n1", request_text: "req", tag: "T") +} + +test fn witness_signing_input_binds_the_enrollment() -> Bool { + base_input != redemption_input(verb_approve: true, enrollment: "e2", nonce: "n1", request_text: "req", tag: "T") +} + +test fn witness_signing_input_binds_the_server_challenge() -> Bool { + base_input != redemption_input(verb_approve: true, enrollment: "e1", nonce: "n2", request_text: "req", tag: "T") +} + +test fn witness_signing_input_binds_the_displayed_request() -> Bool { + base_input != redemption_input(verb_approve: true, enrollment: "e1", nonce: "n1", request_text: "req-revised", tag: "T") +} + +test fn witness_signing_input_binds_the_capability_tag() -> Bool { + base_input != redemption_input(verb_approve: true, enrollment: "e1", nonce: "n1", request_text: "req", tag: "T2") +} + +test fn witness_enrolment_transcript_binds_the_decision_key() -> Bool { + enrolment_transcript(challenge: enrol_challenge, decision_key: key_a, platform: MobileIos) != enrolment_transcript(challenge: enrol_challenge, decision_key: key_b, platform: MobileIos) +} + +test fn witness_enrolment_transcript_binds_the_platform() -> Bool { + enrolment_transcript(challenge: enrol_challenge, decision_key: key_a, platform: MobileIos) != enrolment_transcript(challenge: enrol_challenge, decision_key: key_a, platform: MobileAndroid) +} + +// The seam refuses on encoding facts BEFORE consulting the implementation's verdict: a true verdict +// over a malformed signature must not mint SignatureVerified. +test fn witness_malformed_signature_refuses_despite_a_true_verdict() -> Bool { + match signature_verification_from_implementation(key: key_a, key_octets: 65, signature: sig, signature_octets: 71, message: "m", implementation_verified: true) { + SignatureMalformed { octets_declared, octets_expected } => octets_declared == 71 && octets_expected == 64 + _ => false + } +} + +test fn witness_malformed_key_refuses_despite_a_true_verdict() -> Bool { + match signature_verification_from_implementation(key: key_a, key_octets: 33, signature: sig, signature_octets: 64, message: "m", implementation_verified: true) { + VerifyingKeyMalformed { octets_declared, octets_expected: _ } => octets_declared == 33 + _ => false + } +} + +test fn witness_well_formed_true_verdict_names_its_message() -> Bool { + match signature_verification_from_implementation(key: key_a, key_octets: 65, signature: sig, signature_octets: 64, message: "m", implementation_verified: true) { + SignatureVerified { verified } => verified.message as String == "m" + _ => false + } +} + +test fn witness_false_verdict_is_invalid() -> Bool { + match signature_verification_from_implementation(key: key_a, key_octets: 65, signature: sig, signature_octets: 64, message: "m", implementation_verified: false) { + SignatureInvalid { suite: _ } => true + _ => false + } +} From df5a58216545b38b29dbe592aa13be63d5680083 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 06:27:31 +0000 Subject: [PATCH 02/17] Device redemption admission fold, stateless challenge, assertion verdicts, route paths Co-Authored-By: Claude Opus 5 (1M context) --- dag/extdeps/apple/app_attest.dag | 19 +- dag/gunbc/auth/approval_device_redemption.dag | 267 +++++++++++++++++- ...pproval_device_redemption_witness_test.dag | 206 +++++++++++++- 3 files changed, 483 insertions(+), 9 deletions(-) diff --git a/dag/extdeps/apple/app_attest.dag b/dag/extdeps/apple/app_attest.dag index 52cc01a6765..9b852d38127 100644 --- a/dag/extdeps/apple/app_attest.dag +++ b/dag/extdeps/apple/app_attest.dag @@ -24,7 +24,7 @@ data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { // under extdeps.apple.secure_enclave BiometryCurrentSet. // * It certifies nothing about any other key. The link from the attested app to the decision key // is made by the enrolment protocol: the clientDataHash the app attests over commits to the -// decision key and the one-time enrolment code (gunbc.auth.approval_push). +// decision key and the one-time enrolment code (gunbc.auth.approval_device_redemption). data attestation_proves_the_app_not_the_person_note: NonEmptyStr = "An App Attest attestation certifies a Secure Enclave key belonging to a genuine instance of one team id + bundle id on Apple hardware. It carries no biometric gate and says nothing about other keys; binding it to the biometric-gated decision key is the enrolment protocol's job, through the clientDataHash." type AttestKeyId = NonEmptyStr where brand("AttestKeyId") @@ -75,3 +75,20 @@ type AttestationVerification | AttestationRefused { cause: AttestationRefusal } data app_attest_unavailable_in_simulator_note: NonEmptyStr = "DCAppAttestService.isSupported is false in the Simulator and on devices without a Secure Enclave; the app must refuse enrolment there rather than enrolling without an attestation." + +// ── Assertions ─────────────────────────────────────────────────────────────────────────────── +// Each later request carries an assertion: CBOR { signature, authenticatorData } produced by +// generateAssertion(keyId, clientDataHash). Apple's server checks, in order: the signature over +// SHA256(authenticatorData || clientDataHash) verifies under the public key stored at attestation; +// the RP ID hash equals SHA256(app id); the counter is STRICTLY greater than the last one stored -- +// the replay refusal. Success names the counter the server must now store, and the client data it +// was made over, so it cannot be paired with another request's bytes. +type AssertionRefusal + = AssertionSignatureInvalid + | AssertionAppIdMismatch { expected_app_id: NonEmptyStr } + | AssertionCounterNotIncreasing { stored: Int, presented: Int } + | AssertionUndecodable { cause: String } + +type AssertionVerification + = AssertionVerified { key_id: AttestKeyId, counter: Int, client_data: NonEmptyStr } + | AssertionRefused { cause: AssertionRefusal } diff --git a/dag/gunbc/auth/approval_device_redemption.dag b/dag/gunbc/auth/approval_device_redemption.dag index 04cb5dcb56f..b8ecbb95acb 100644 --- a/dag/gunbc/auth/approval_device_redemption.dag +++ b/dag/gunbc/auth/approval_device_redemption.dag @@ -3,7 +3,7 @@ module gunbc.auth.approval_device_redemption import std.types { NonEmptyStr, String, Timestamp, Int } import std.logic { Bool } import std.dissolution { DissolutionCondition, unbound_dissolution } -import extdeps.crypto.signature { VerifyingKey, SignatureBytes } +import extdeps.crypto.signature { VerifyingKey, SignatureBytes, SignatureVerification, SignatureVerified } import extdeps.apple.apns { ApnsSendOutcome, ApnsAccepted, ApnsRefused, ApnsUnreached, ApnsUndecodable, ApnsEnvironment, ApnsTopic, ApnsDeviceToken, ApnsRefusalClass, @@ -13,7 +13,20 @@ import extdeps.apple.apns { import extdeps.apple.app_attest { AttestKeyId } import extdeps.google.fcm { FcmProjectId, FcmRegistrationToken } import extdeps.android.key_attestation { AndroidAttestationChain } -import gunbc.auth.approval_capability { ProposedDecision, proposed_decision_name, signing_field_separator } +import gunbc.auth.approval_capability { + ProposedDecision, ProposeApprove, ProposeDeny, proposed_decision_name, signing_field_separator, + ApprovalCapability, ApprovalCapabilityClaims, CapabilityRefusal, CapabilityAuthentic, CapabilityRefused, + approval_capability_signing_input, verify_capability, capability_refusal_reason, utc_instant_before, +} +import gunbc.auth.approval_broker { BrokerDecision, BrokerApproved, BrokerDenied, ExecutionObligation } +import gunbc.auth.approval_decision_store { + StoredApprovalRequest, stored_request_json, observe_escalation, + EscalationNotFiled, EscalationPending, EscalationDecided, EscalationUnreadable, + ApprovalKeyringRead, ApprovalKeyLoaded, ApprovalKeyringRefused, ApprovalKeyringRefusal, + claims_for, commit_broker_decision, StoreWritten, StoreSlotOccupied, StoreRefused, + approval_capability_expectation, approval_operator_login, +} +import extdeps.crypto.mac { MacKey, MacVerified, mac_verify } import gunbc.secret_provision { SecretRef, fleet_secret_ref, secret_ref_pin_version } // ── What this module is ────────────────────────────────────────────────────────────────────── @@ -153,10 +166,28 @@ type ApprovalPushHint { // server owns decided_at -- a client clock can neither backdate a decision nor extend an expiry. data approval_redemption_protocol: NonEmptyStr = "gunbc.approval-redemption.v1" as NonEmptyStr +// STATELESS: nonce_hex is the server's MAC, under the capability key it already holds, over +// redemption_challenge_message. Nothing is stored to issue it and nothing to check it -- the server +// recomputes. Replay needs no challenge ledger: the decision slot's CAS admits one decision per +// escalation, and the App Attest counter refuses a replayed assertion. type RedemptionChallenge { - challenge_id: NonEmptyStr - nonce: NonEmptyStr expires_at: Timestamp + nonce_hex: NonEmptyStr +} + +data approval_redemption_challenge_protocol: NonEmptyStr = "gunbc.approval-redemption-challenge.v1" as NonEmptyStr + +fn redemption_challenge_message(escalation_id: NonEmptyStr, request_revision: NonEmptyStr, enrollment_id: NonEmptyStr, expires_at: Timestamp) -> NonEmptyStr { + join( + [ + approval_redemption_challenge_protocol as String, + escalation_id as String, + request_revision as String, + enrollment_id as String, + expires_at, + ], + signing_field_separator, + ) as NonEmptyStr } // stored_request_text is gunbc.auth.approval_decision_store stored_request_json of the record the @@ -182,8 +213,8 @@ fn device_redemption_signing_input(i: DeviceRedemptionSigningInput) -> NonEmptyS approval_redemption_protocol as String, i.audience as String, i.enrollment_id as String, - i.challenge.challenge_id as String, - i.challenge.nonce as String, + i.challenge.expires_at, + i.challenge.nonce_hex as String, i.escalation_id as String, i.request_revision as String, i.stored_request_text as String, @@ -252,6 +283,230 @@ fn push_outcome_of_apns(o: ApnsSendOutcome) -> PushProviderOutcome { } } +// ── The device routes ──────────────────────────────────────────────────────────────────────── +// ONE SPELLING OF EACH PATH, read by the server's route table and checked by the app's fixture, so +// neither side coins its own. All four sit under /approve/device beside the existing /approve +// route, which they do not touch. +data approval_device_enrol_path: NonEmptyStr = "/approve/device/enrol" as NonEmptyStr +data approval_device_pending_path: NonEmptyStr = "/approve/device/pending" as NonEmptyStr +data approval_device_request_path_prefix: NonEmptyStr = "/approve/device/requests/" as NonEmptyStr +data approval_device_redeem_path: NonEmptyStr = "/approve/device/redeem" as NonEmptyStr + +// ── Redemption over the store ──────────────────────────────────────────────────────────────── +// THE DEVICE ROUTE'S FOLD, BESIDE redeem_over_store AND REUSING ITS PARTS: the same slot read +// (observe_escalation), the same claims (claims_for), the same capability verification +// (verify_capability) and the same generation-2 commit (commit_broker_decision). Nothing in the +// store or broker changes; this route differs only in WHAT ADMITS -- a verified device signature +// under an active enrolment of the expected operator, never the capability alone -- and so in what +// decided_by can name. +// +// ORDER, each stage narrowing what the next may assume: the enrolment must be active and name the +// expected operator; the escalation must be filed and pending; the signed bytes must be exactly the +// server's own renderings of THAT request, THAT capability and a live challenge this server minted; +// the capability must verify; the device signature must verify over those bytes under the enrolled +// key; the platform proof must verify over the same bytes. Only then is the decision committed, with +// decided_at the server's clock. The verifier verdicts arrive as parameters because verification is +// the bound realization's (ecdsa_verification_realization_frontier); this fold's job is the joins. +type DeviceRedemptionOutcome + = DeviceRedeemed { decision: BrokerDecision } + | DeviceAlreadyDecided { decision: BrokerDecision } + | DeviceEnrollmentRevoked { enrollment_id: NonEmptyStr } + | DeviceEnrollmentForAnotherOperator { enrolled: NonEmptyStr, expected: NonEmptyStr } + | DeviceEscalationNotFiled { escalation_id: NonEmptyStr } + | DeviceStoreUnreadable { detail: NonEmptyStr } + | DeviceKeyringUnavailable { cause: ApprovalKeyringRefusal } + | DeviceSignedForAnotherRequest { field: NonEmptyStr } + | DeviceChallengeExpired { expires_at: Timestamp, observed_at: Timestamp } + | DeviceChallengeNotIssuedHere + | DeviceCapabilityRefused { cause: CapabilityRefusal } + | DeviceSignatureRefused { verification: SignatureVerification } + | DevicePlatformProofRefused { detail: NonEmptyStr } + | DeviceLostTheRace + | DeviceCommitRefused { detail: NonEmptyStr } + +fn device_redemption_reason(o: DeviceRedemptionOutcome) -> NonEmptyStr { + match o { + DeviceRedeemed { decision: d } => match d { + BrokerApproved { escalation_id: _, revision: _, decided_by: _, decided_at: _, obligation: _ } => "approved and recorded" as NonEmptyStr + BrokerDenied { escalation_id: _, revision: _, decided_by: _, decided_at: _, reason: _ } => "denied and recorded" as NonEmptyStr + } + DeviceAlreadyDecided { decision: _ } => "this escalation was already decided; nothing changed" as NonEmptyStr + DeviceEnrollmentRevoked { enrollment_id: _ } => "this device's enrolment has been revoked" as NonEmptyStr + DeviceEnrollmentForAnotherOperator { enrolled: _, expected: _ } => "this device is enrolled for a different operator than the one this request names" as NonEmptyStr + DeviceEscalationNotFiled { escalation_id: _ } => "no request is filed under this escalation" as NonEmptyStr + DeviceStoreUnreadable { detail: d } => d + DeviceKeyringUnavailable { cause: _ } => "the server's capability key is unavailable" as NonEmptyStr + DeviceSignedForAnotherRequest { field: f } => join(["the signed bytes do not match the stored request: ", f as String], "") as NonEmptyStr + DeviceChallengeExpired { expires_at: _, observed_at: _ } => "the challenge expired; reopen the request and decide again" as NonEmptyStr + DeviceChallengeNotIssuedHere => "the challenge was not issued by this server" as NonEmptyStr + DeviceCapabilityRefused { cause: c } => capability_refusal_reason(r: c) as NonEmptyStr + DeviceSignatureRefused { verification: _ } => "the device signature did not verify under the enrolled key" as NonEmptyStr + DevicePlatformProofRefused { detail: d } => d + DeviceLostTheRace => "another decision landed first; this one changed nothing" as NonEmptyStr + DeviceCommitRefused { detail: d } => d + } +} + +// The server's own rendering of what the device must have signed, from the STORED request and the +// server's key. A device that signed anything else signed for another request. +fn expected_signing_fields_agree( + signed: DeviceRedemptionSigningInput, + enrollment: DeviceEnrollment, + request: StoredApprovalRequest, + capability_text: NonEmptyStr, +) -> String { + if signed.audience != approval_device_audience { "audience" } + else if signed.enrollment_id != enrollment.enrollment_id { "enrollment_id" } + else if signed.escalation_id != request.escalation_id { "escalation_id" } + else if signed.request_revision != request.request_revision { "request_revision" } + else if (signed.stored_request_text as String) != stored_request_json(r: request) { "stored_request_text" } + else if signed.capability_text != capability_text { "capability_text" } + else { "" } +} + +data approval_device_audience: NonEmptyStr = "gunbc.roadmap_serve/device-redemption" as NonEmptyStr + +fn redeem_device_over_store( + root: NonEmptyStr, + keyring: ApprovalKeyringRead, + enrollment: DeviceEnrollment, + redemption: SignedRedemption, + signature: SignatureVerification, + platform: PlatformProofVerification, + observed_at: Timestamp, +) -> DeviceRedemptionOutcome { + let signed = redemption.signing_input + match enrollment.standing { + EnrollmentRevoked { revoked_at: _, reason: _ } => DeviceEnrollmentRevoked { enrollment_id: enrollment.enrollment_id } + EnrollmentActive => + if enrollment.operator_login != approval_operator_login { + DeviceEnrollmentForAnotherOperator { enrolled: enrollment.operator_login, expected: approval_operator_login } + } else { + match observe_escalation(root: root, escalation_id: signed.escalation_id) { + EscalationNotFiled => DeviceEscalationNotFiled { escalation_id: signed.escalation_id } + EscalationUnreadable { detail: d } => DeviceStoreUnreadable { detail: d } + EscalationDecided { decision: d } => DeviceAlreadyDecided { decision: d } + EscalationPending { request: req } => + match keyring { + ApprovalKeyringRefused { cause: c } => DeviceKeyringUnavailable { cause: c } + ApprovalKeyLoaded { key: key } => + redeem_pending_with_key(root: root, key: key, enrollment: enrollment, request: req, redemption: redemption, signature: signature, platform: platform, observed_at: observed_at) + } + } + } + } +} + +// EVERY GATE, AND NO EFFECT. Split from the commit so that the whole admission -- including the +// accepting path -- is decidable without a store, and so that the commit cannot be reached except +// through DeviceAdmitted. +type DeviceAdmission + = DeviceAdmitted { claims: ApprovalCapabilityClaims } + | DeviceNotAdmitted { outcome: DeviceRedemptionOutcome } + +fn device_redemption_admission( + key: MacKey, + enrollment: DeviceEnrollment, + request: StoredApprovalRequest, + redemption: SignedRedemption, + signature: SignatureVerification, + platform: PlatformProofVerification, + observed_at: Timestamp, +) -> DeviceAdmission { + let signed = redemption.signing_input + let claims = claims_for(request: request, decision: signed.decision) + let capability_text = approval_capability_signing_input(c: claims) + let mismatch = expected_signing_fields_agree(signed: signed, enrollment: enrollment, request: request, capability_text: capability_text) + if mismatch != "" { + DeviceNotAdmitted { outcome: DeviceSignedForAnotherRequest { field: mismatch as NonEmptyStr } } + } else if !utc_instant_before(a: observed_at, b: signed.challenge.expires_at) { + DeviceNotAdmitted { outcome: DeviceChallengeExpired { expires_at: signed.challenge.expires_at, observed_at: observed_at } } + } else { + let challenge_message = redemption_challenge_message(escalation_id: request.escalation_id, request_revision: request.request_revision, enrollment_id: enrollment.enrollment_id, expires_at: signed.challenge.expires_at) + match mac_verify(key: key, message: challenge_message, tag_hex: signed.challenge.nonce_hex) { + MacVerified { verified: _ } => + match verify_capability( + capability: ApprovalCapability { claims: claims, tag_b64url: signed.capability_tag }, + expectation: approval_capability_expectation, + mac_result: mac_verify(key: key, message: capability_text, tag_hex: signed.capability_tag), + observed_at: observed_at, + ) { + CapabilityRefused { cause: c } => DeviceNotAdmitted { outcome: DeviceCapabilityRefused { cause: c } } + CapabilityAuthentic { claims: cl } => + match device_signature_joins(signature: signature, enrollment: enrollment, signed_bytes: device_redemption_signing_input(i: signed)) { + DeviceSignatureDoesNotJoin => DeviceNotAdmitted { outcome: DeviceSignatureRefused { verification: signature } } + DeviceSignatureJoins => + match platform { + PlatformProofRefused { detail: d } => DeviceNotAdmitted { outcome: DevicePlatformProofRefused { detail: d } } + PlatformProofVerified => DeviceAdmitted { claims: cl } + } + } + } + _ => DeviceNotAdmitted { outcome: DeviceChallengeNotIssuedHere } + } + } +} + +fn redeem_pending_with_key( + root: NonEmptyStr, + key: MacKey, + enrollment: DeviceEnrollment, + request: StoredApprovalRequest, + redemption: SignedRedemption, + signature: SignatureVerification, + platform: PlatformProofVerification, + observed_at: Timestamp, +) -> DeviceRedemptionOutcome { + match device_redemption_admission(key: key, enrollment: enrollment, request: request, redemption: redemption, signature: signature, platform: platform, observed_at: observed_at) { + DeviceNotAdmitted { outcome: o } => o + DeviceAdmitted { claims: cl } => commit_device_decision(root: root, claims: cl, request: request, decided_by: enrollment.operator_login, observed_at: observed_at) + } +} + +// THE SIGNATURE JOINS only when the verified evidence names the ENROLLED key and EXACTLY the bytes +// this server rendered -- the same message-and-key join extdeps.crypto.signature SignedMessage exists +// for. A verified signature under some other key, or over some other message, admits nothing. +type DeviceSignatureJoin + = DeviceSignatureJoins + | DeviceSignatureDoesNotJoin + +fn device_signature_joins(signature: SignatureVerification, enrollment: DeviceEnrollment, signed_bytes: NonEmptyStr) -> DeviceSignatureJoin { + match signature { + SignatureVerified { verified: v } => + if v.message == signed_bytes && v.key.point_b64url == enrollment.decision_key.point_b64url && v.key.suite == enrollment.decision_key.suite { + DeviceSignatureJoins + } else { + DeviceSignatureDoesNotJoin + } + _ => DeviceSignatureDoesNotJoin + } +} + +// The platform proof's verdict, reduced to what this fold consumes. The realization builds it from +// extdeps.apple.app_attest AssertionVerified (checking its client_data equals the signed bytes and +// storing its counter) or, on Android, from the signature alone. +type PlatformProofVerification + = PlatformProofVerified + | PlatformProofRefused { detail: NonEmptyStr } + +fn commit_device_decision(root: NonEmptyStr, claims: ApprovalCapabilityClaims, request: StoredApprovalRequest, decided_by: NonEmptyStr, observed_at: Timestamp) -> DeviceRedemptionOutcome { + let decision = match claims.decision { + ProposeApprove => BrokerApproved { + escalation_id: claims.escalation_id, revision: claims.request_revision, decided_by: decided_by, decided_at: observed_at, + obligation: ExecutionObligation { authority: claims.authority, intent_revision: claims.request_revision, execute_by: request.expires_at }, + } + ProposeDeny => BrokerDenied { + escalation_id: claims.escalation_id, revision: claims.request_revision, decided_by: decided_by, decided_at: observed_at, + reason: "denied by the operator on the enrolled device", + } + } + match commit_broker_decision(root: root, decision: decision) { + StoreWritten => DeviceRedeemed { decision: decision } + StoreSlotOccupied => DeviceLostTheRace + StoreRefused { detail: d } => DeviceCommitRefused { detail: d } + } +} + // ── The provider key ───────────────────────────────────────────────────────────────────────── // The .p8 APNs auth key lives in Secret Manager, pinned. It is NOT yet a row in // gunbc.auth.fleet_secret_accessor_roster: that fold stops at the first refusal, so a row for a diff --git a/dag/test/claim/approval_device_redemption_witness_test.dag b/dag/test/claim/approval_device_redemption_witness_test.dag index cec2637c409..03a82991c6c 100644 --- a/dag/test/claim/approval_device_redemption_witness_test.dag +++ b/dag/test/claim/approval_device_redemption_witness_test.dag @@ -1,7 +1,9 @@ module test.claim.approval_device_redemption_witness_test import std.logic { Bool } -import std.types { String, NonEmptyStr, Timestamp } +import std.types { String, NonEmptyStr, Timestamp, Secret } +import std.scoped_authorization { AttemptIdentity } +import extdeps.crypto.mac { MacKey, HmacSha256, mac_sign, MacSigned, MacKeyMaterialNotHex } import extdeps.transports.rest { RestOk, RestStatusRefused, RestTransportRefused } import extdeps.apple.apns { ApnsStatusUnlisted, @@ -17,6 +19,15 @@ import gunbc.auth.approval_device_redemption { DeviceRedemptionSigningInput, RedemptionChallenge, EnrolmentChallenge, MobileIos, MobileAndroid, PushProviderAccepted, PushProviderRefused, PushRegistrationInvalid, PushProviderUnreached, PushProviderUndecodable, push_outcome_of_apns, device_redemption_signing_input, enrolment_transcript, + DeviceEnrollment, EnrollmentActive, EnrollmentRevoked, IosAppAttestBoundDecisionKey, IosAppAttestAssertion, SignedRedemption, + PlatformProofVerified, PlatformProofRefused, DeviceAdmitted, DeviceNotAdmitted, + DeviceSignedForAnotherRequest, DeviceChallengeExpired, DeviceChallengeNotIssuedHere, DeviceCapabilityRefused, + DeviceSignatureRefused, DevicePlatformProofRefused, DeviceEnrollmentRevoked, DeviceEnrollmentForAnotherOperator, + device_redemption_admission, redeem_device_over_store, redemption_challenge_message, approval_device_audience, +} +import gunbc.auth.approval_capability { approval_capability_signing_input } +import gunbc.auth.approval_decision_store { + StoredApprovalRequest, stored_request_json, claims_for, approval_mac_key_id, approval_operator_login, ApprovalKeyLoaded, } data key_a: VerifyingKey = VerifyingKey { suite: EcdsaP256Sha256, encoding: Sec1Uncompressed, point_b64url: "BKEYA" } @@ -27,7 +38,7 @@ fn redemption_input(verb_approve: Bool, enrollment: NonEmptyStr, nonce: NonEmpty device_redemption_signing_input(i: DeviceRedemptionSigningInput { audience: "srv1.tailecbe08.ts.net", enrollment_id: enrollment, - challenge: RedemptionChallenge { challenge_id: "c1", nonce: nonce, expires_at: "2026-09-18T12:05:00Z" as Timestamp }, + challenge: RedemptionChallenge { expires_at: "2026-09-18T12:05:00Z" as Timestamp, nonce_hex: nonce }, escalation_id: "esc-1", request_revision: "r1", stored_request_text: request_text, @@ -151,3 +162,194 @@ test fn witness_false_verdict_is_invalid() -> Bool { _ => false } } + +// ── Admission: every gate of the device route, with real MACs, no store ────────────────────── +// The key and request are the store witness's fixtures (approval_decision_store_witness_test); the +// challenge nonce and capability tag are minted here by mac_sign under that key, so each refusal +// below differs from the admitted control in exactly one input. +data fx_key_hex: String = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +fn fx_key() -> MacKey { + MacKey { id: approval_mac_key_id, suite: HmacSha256, material: fx_key_hex as Secret } +} + +fn fx_request() -> StoredApprovalRequest { + StoredApprovalRequest { + escalation_id: "esc-mtc1-boot-1", + request_revision: "sha256:abababababababababababababababababababababababababababababababab", + attempt: "mtc1-boot-attempt-1" as AttemptIdentity, + requester: "eager-owl-205", + purpose: "Boot Mt. Collins unit 1 once from the diskless image over BMC virtual media.", + destructive: true, + issued_at: "2026-09-16T20:00:00Z", + expires_at: "2026-09-17T20:00:00Z", + key_id: approval_mac_key_id, + } +} + +data fx_observed_at: Timestamp = "2026-09-17T10:00:00Z" +data fx_challenge_expiry: Timestamp = "2026-09-17T10:05:00Z" + +fn mac_hex(message: NonEmptyStr) -> NonEmptyStr { + match mac_sign(key: fx_key(), message: message) { + MacSigned { tag: t } => t.hex + MacKeyMaterialNotHex { key_id: _ } => "not-hex" + } +} + +fn fx_enrollment(standing_active: Bool, login: NonEmptyStr) -> DeviceEnrollment { + DeviceEnrollment { + enrollment_id: "enr-1", + operator_login: login, + decision_key: key_a, + evidence: IosAppAttestBoundDecisionKey { attest_key_id: "attest-1", attestation_b64: "ATT" }, + standing: if standing_active { EnrollmentActive } else { EnrollmentRevoked { revoked_at: "2026-09-17T09:00:00Z", reason: "lost phone" } }, + } +} + +fn fx_signing_input(request_text: NonEmptyStr, expiry: Timestamp, nonce: NonEmptyStr, tag: NonEmptyStr) -> DeviceRedemptionSigningInput { + DeviceRedemptionSigningInput { + audience: approval_device_audience, + enrollment_id: "enr-1", + challenge: RedemptionChallenge { expires_at: expiry, nonce_hex: nonce }, + escalation_id: fx_request().escalation_id, + request_revision: fx_request().request_revision, + stored_request_text: request_text, + decision: ProposeApprove, + capability_text: approval_capability_signing_input(c: claims_for(request: fx_request(), decision: ProposeApprove)), + capability_tag: tag, + } +} + +fn fx_nonce() -> NonEmptyStr { + mac_hex(message: redemption_challenge_message(escalation_id: fx_request().escalation_id, request_revision: fx_request().request_revision, enrollment_id: "enr-1", expires_at: fx_challenge_expiry)) +} + +fn fx_tag() -> NonEmptyStr { + mac_hex(message: approval_capability_signing_input(c: claims_for(request: fx_request(), decision: ProposeApprove))) +} + +fn fx_good_input() -> DeviceRedemptionSigningInput { + fx_signing_input(request_text: stored_request_json(r: fx_request()) as NonEmptyStr, expiry: fx_challenge_expiry, nonce: fx_nonce(), tag: fx_tag()) +} + +// A verdict as the bound verifier would report it: over `message`, under `key`. +fn verdict(key: VerifyingKey, message: NonEmptyStr) -> SignatureVerification { + signature_verification_from_implementation(key: key, key_octets: 65, signature: sig, signature_octets: 64, message: message, implementation_verified: true) +} + +fn admit(input: DeviceRedemptionSigningInput, signature: SignatureVerification, platform_ok: Bool) -> DeviceAdmission { + device_redemption_admission( + key: fx_key(), + enrollment: fx_enrollment(standing_active: true, login: approval_operator_login), + request: fx_request(), + redemption: SignedRedemption { signing_input: input, signature: sig, platform_proof: IosAppAttestAssertion { assertion_b64: "ASSERT" } }, + signature: signature, + platform: if platform_ok { PlatformProofVerified } else { PlatformProofRefused { detail: "assertion counter did not increase" } }, + observed_at: fx_observed_at, + ) +} + +// THE POSITIVE CONTROL: every gate passes and the claims admitted are the stored request's. +test fn witness_device_admission_admits_the_exact_signed_request() -> Bool { + match admit(input: fx_good_input(), signature: verdict(key: key_a, message: device_redemption_signing_input(i: fx_good_input())), platform_ok: true) { + DeviceAdmitted { claims: cl } => cl.escalation_id == fx_request().escalation_id && cl.request_revision == fx_request().request_revision + DeviceNotAdmitted { outcome: _ } => false + } +} + +test fn witness_device_admission_refuses_a_different_displayed_request() -> Bool { + let i = fx_signing_input(request_text: "{\"kind\":\"request\"}", expiry: fx_challenge_expiry, nonce: fx_nonce(), tag: fx_tag()) + match admit(input: i, signature: verdict(key: key_a, message: device_redemption_signing_input(i: i)), platform_ok: true) { + DeviceNotAdmitted { outcome: DeviceSignedForAnotherRequest { field: f } } => f as String == "stored_request_text" + _ => false + } +} + +test fn witness_device_admission_refuses_an_expired_challenge() -> Bool { + let i = fx_signing_input(request_text: stored_request_json(r: fx_request()) as NonEmptyStr, expiry: "2026-09-17T09:59:00Z", nonce: fx_nonce(), tag: fx_tag()) + match admit(input: i, signature: verdict(key: key_a, message: device_redemption_signing_input(i: i)), platform_ok: true) { + DeviceNotAdmitted { outcome: DeviceChallengeExpired { expires_at: _, observed_at: _ } } => true + _ => false + } +} + +// A nonce the server did not mint -- here, one MAC'd over a different expiry -- is refused even +// though everything else, including the device signature, is valid. +test fn witness_device_admission_refuses_a_challenge_not_issued_here() -> Bool { + let forged = mac_hex(message: redemption_challenge_message(escalation_id: fx_request().escalation_id, request_revision: fx_request().request_revision, enrollment_id: "enr-1", expires_at: "2026-09-17T11:00:00Z")) + let i = fx_signing_input(request_text: stored_request_json(r: fx_request()) as NonEmptyStr, expiry: fx_challenge_expiry, nonce: forged, tag: fx_tag()) + match admit(input: i, signature: verdict(key: key_a, message: device_redemption_signing_input(i: i)), platform_ok: true) { + DeviceNotAdmitted { outcome: DeviceChallengeNotIssuedHere } => true + _ => false + } +} + +test fn witness_device_admission_refuses_a_capability_for_the_other_verb() -> Bool { + let deny_tag = mac_hex(message: approval_capability_signing_input(c: claims_for(request: fx_request(), decision: ProposeDeny))) + let i = fx_signing_input(request_text: stored_request_json(r: fx_request()) as NonEmptyStr, expiry: fx_challenge_expiry, nonce: fx_nonce(), tag: deny_tag) + match admit(input: i, signature: verdict(key: key_a, message: device_redemption_signing_input(i: i)), platform_ok: true) { + DeviceNotAdmitted { outcome: DeviceCapabilityRefused { cause: _ } } => true + _ => false + } +} + +test fn witness_device_admission_refuses_a_signature_over_other_bytes() -> Bool { + match admit(input: fx_good_input(), signature: verdict(key: key_a, message: "something else"), platform_ok: true) { + DeviceNotAdmitted { outcome: DeviceSignatureRefused { verification: _ } } => true + _ => false + } +} + +test fn witness_device_admission_refuses_a_signature_under_another_key() -> Bool { + match admit(input: fx_good_input(), signature: verdict(key: key_b, message: device_redemption_signing_input(i: fx_good_input())), platform_ok: true) { + DeviceNotAdmitted { outcome: DeviceSignatureRefused { verification: _ } } => true + _ => false + } +} + +test fn witness_device_admission_refuses_an_invalid_signature_verdict() -> Bool { + let invalid = signature_verification_from_implementation(key: key_a, key_octets: 65, signature: sig, signature_octets: 64, message: device_redemption_signing_input(i: fx_good_input()), implementation_verified: false) + match admit(input: fx_good_input(), signature: invalid, platform_ok: true) { + DeviceNotAdmitted { outcome: DeviceSignatureRefused { verification: _ } } => true + _ => false + } +} + +test fn witness_device_admission_refuses_a_failed_platform_proof() -> Bool { + match admit(input: fx_good_input(), signature: verdict(key: key_a, message: device_redemption_signing_input(i: fx_good_input())), platform_ok: false) { + DeviceNotAdmitted { outcome: DevicePlatformProofRefused { detail: _ } } => true + _ => false + } +} + +// These two refuse before the store is read, so the nonexistent root is never touched. +test fn witness_device_redemption_refuses_a_revoked_enrollment_before_the_store() -> Bool { + match redeem_device_over_store( + root: "/nonexistent/approvals", + keyring: ApprovalKeyLoaded { key: fx_key() }, + enrollment: fx_enrollment(standing_active: false, login: approval_operator_login), + redemption: SignedRedemption { signing_input: fx_good_input(), signature: sig, platform_proof: IosAppAttestAssertion { assertion_b64: "ASSERT" } }, + signature: verdict(key: key_a, message: device_redemption_signing_input(i: fx_good_input())), + platform: PlatformProofVerified, + observed_at: fx_observed_at, + ) { + DeviceEnrollmentRevoked { enrollment_id: e } => e as String == "enr-1" + _ => false + } +} + +test fn witness_device_redemption_refuses_an_enrollment_for_another_operator() -> Bool { + match redeem_device_over_store( + root: "/nonexistent/approvals", + keyring: ApprovalKeyLoaded { key: fx_key() }, + enrollment: fx_enrollment(standing_active: true, login: "someone-else@example.com"), + redemption: SignedRedemption { signing_input: fx_good_input(), signature: sig, platform_proof: IosAppAttestAssertion { assertion_b64: "ASSERT" } }, + signature: verdict(key: key_a, message: device_redemption_signing_input(i: fx_good_input())), + platform: PlatformProofVerified, + observed_at: fx_observed_at, + ) { + DeviceEnrollmentForAnotherOperator { enrolled: _, expected: _ } => true + _ => false + } +} From d51fd6ade3c7933e393e6f82243355dc223758ae Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Fri, 18 Sep 2026 06:54:40 +0000 Subject: [PATCH 03/17] Signed device redemption, lane A: P-256 ECDSA verify and the ES256 APNs provider token Two v1 host primitives over RustCrypto p256 0.13 (digest 0.10, the family sha2 0.10 and hmac 0.12 already use), registered exactly as hmac_sha256_hex was (40fb5b94d3): 04_method.dag signature + infer_method mirror (--required-regen first_generation_equal=true), dispatch roster, interpreter arm, std.primitives contract + surface name + roster, v1_interpreter_primitive_surface row. - p256_ecdsa_verify_b64url(key_point_b64url, signature_b64url, message) -> Bool?: 65-octet SEC 1 uncompressed point, 64-octet r||s, unpadded base64url; the message is SHA-256 hashed by the verifier. Absent on any non-admitted encoding. - extdeps.crypto.signature p256_ecdsa_verify: decodes both inputs, hands the DECODED octet lengths to signature_verification_from_implementation. Three new arms keep absence from reading as a mismatch: VerifyingKeyUndecodable, SignatureUndecodable, SignatureEncodingRefused (right lengths, not a point on the curve / scalar in range). - es256_jwt_sign(p8_pem_secret, key_id, team_id, issued_at_epoch) -> String?: header {alg ES256, kid}, claims {iss, iat}, JOSE r||s, RFC 6979 deterministic. - extdeps.apple.apns apns_provider_token -> ApnsProviderTokenMint (Signed | AuthKeyUnreadable | IssuedAtBeforeEpoch). - Witnesses (test.claim.p256_ecdsa_witness_test): RFC 7515 A.3's PUBLISHED ES256 signature verifies; tampered message, tampered signature and wrong key are SignatureInvalid; the compressed spelling of the right key is VerifyingKeyMalformed{33}; an off-curve point is SignatureEncodingRefused; the provider token equals an exact expected JWT (independently verified with openssl) and verifies under the RFC public key; unreadable key and negative iat refuse. Rust unit tests beside the arms. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 187 ++++++++++++++++++ dag/extdeps/apple/apns.dag | 40 +++- dag/extdeps/crypto/signature.dag | 68 ++++++- .../v1/v1_interpreter_primitive_surface.dag | 18 ++ dag/std/primitives.dag | 26 +++ dag/test/claim/p256_ecdsa_witness_test.dag | 137 +++++++++++++ src/v1/04_method.dag | 2 + src/v1/stage0/Cargo.toml | 6 + src/v1/stage0/src/v1_compiler_infer_method.rs | 43 ++++ src/v1/stage0/src/v1_interpreter.rs | 176 +++++++++++++++++ .../src/v1_interpreter_dispatch_generated.rs | 6 + 11 files changed, 702 insertions(+), 7 deletions(-) create mode 100644 dag/test/claim/p256_ecdsa_witness_test.dag diff --git a/Cargo.lock b/Cargo.lock index 887507694ec..189e48ad832 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,12 +67,24 @@ dependencies = [ "object", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitmaps" version = "2.1.0" @@ -153,6 +165,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -171,6 +189,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -181,6 +211,17 @@ dependencies = [ "typenum", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -188,6 +229,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -203,12 +245,56 @@ dependencies = [ "syn", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -242,6 +328,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -255,6 +342,17 @@ dependencies = [ "wasi", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -483,12 +581,43 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -498,6 +627,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -531,6 +669,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] [[package]] name = "rand_xoshiro" @@ -541,6 +682,16 @@ dependencies = [ "rand_core", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -590,6 +741,20 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "serde" version = "1.0.228" @@ -659,6 +824,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -681,6 +856,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -855,12 +1040,14 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" name = "v1-compiler" version = "0.1.0" dependencies = [ + "base64", "clap", "hex", "hmac", "im", "lazy_static", "libc", + "p256", "serde", "serde_json", "sha2", diff --git a/dag/extdeps/apple/apns.dag b/dag/extdeps/apple/apns.dag index 3183a5b80e0..0b5ca7d1af0 100644 --- a/dag/extdeps/apple/apns.dag +++ b/dag/extdeps/apple/apns.dag @@ -1,6 +1,6 @@ module extdeps.apple.apns -import std.types { NonEmptyStr, String, Int, Bool } +import std.types { NonEmptyStr, String, Int, Bool, Secret } import extdeps.external_authority { ExternalAuthority } import extdeps.uri { Uri, Https } import extdeps.crypto.signature { SignatureSuite, EcdsaP256Sha256 } @@ -55,6 +55,44 @@ type ApnsProviderIdentity { key_id: ApnsKeyId } +// THE TOKEN, and what it names. The JWT is the bearer credential sent as `authorization: bearer +// `; it carries its identity and issue time beside it so a cache can decide the refresh window +// (the two bounds above) without decoding its own output. +type ApnsProviderToken { + identity: ApnsProviderIdentity + issued_at_epoch: Int + jwt: NonEmptyStr +} + +// Minting refuses rather than fabricating. The bound implementation can see two malformed inputs: a +// key that is not a P-256 PKCS #8 PEM (an APNs .p8 is exactly that), and an issue time before the +// epoch. The second is checked here, so the implementation's absence means the first. +type ApnsProviderTokenMint + = ApnsProviderTokenSigned { token: ApnsProviderToken } + | ApnsAuthKeyUnreadable { key_id: ApnsKeyId } + | ApnsIssuedAtBeforeEpoch { issued_at_epoch: Int } + +// `es256_jwt_sign` is a host primitive over RustCrypto's p256 SigningKey (RFC 6979 deterministic +// nonces, so one input has one token and no RNG is consulted): header {"alg":"ES256","kid"}, +// claims {"iss","iat"}, unpadded base64url segments, JOSE r || s signature (RFC 7518 §3.4). The key +// travels as a Secret and never appears in an argv (gunbc.credential_argv_exposure), which is why +// this is a primitive and not a shell transport. The suite match is total so a second +// SignatureSuite arm cannot reach the ES256 implementation without an authored arm. +fn apns_provider_token(identity: ApnsProviderIdentity, auth_key: Secret, issued_at_epoch: Int) -> ApnsProviderTokenMint { + if issued_at_epoch < 0 { + ApnsIssuedAtBeforeEpoch { issued_at_epoch: issued_at_epoch } + } else { + match apns_provider_token_suite { + EcdsaP256Sha256 => + match es256_jwt_sign(auth_key as String, identity.key_id as String, identity.team_id as String, issued_at_epoch) { + Absent => ApnsAuthKeyUnreadable { key_id: identity.key_id } + Present { value: jwt } => + ApnsProviderTokenSigned { token: ApnsProviderToken { identity: identity, issued_at_epoch: issued_at_epoch, jwt: jwt as NonEmptyStr } } + } + } + } +} + // ── Request headers ────────────────────────────────────────────────────────────────────────── // apns-topic is the app's bundle identifier for an alert push. type ApnsTopic = NonEmptyStr where brand("ApnsTopic") diff --git a/dag/extdeps/crypto/signature.dag b/dag/extdeps/crypto/signature.dag index af43b965a49..827efc1cbbd 100644 --- a/dag/extdeps/crypto/signature.dag +++ b/dag/extdeps/crypto/signature.dag @@ -1,6 +1,7 @@ module extdeps.crypto.signature import std.types { NonEmptyStr, String, Int } +import std.encoding { base64_decode, UrlSafe } import std.logic { Bool } import extdeps.external_authority { ExternalAuthority } import extdeps.uri { Uri, Https } @@ -89,6 +90,9 @@ type SignatureVerification | SignatureMalformed { octets_declared: Int, octets_expected: Int } | VerifyingKeyMalformed { octets_declared: Int, octets_expected: Int } | SignatureSuiteMismatch { key_suite: SignatureSuite, signature_suite: SignatureSuite } + | VerifyingKeyUndecodable { suite: SignatureSuite } + | SignatureUndecodable { suite: SignatureSuite } + | SignatureEncodingRefused { suite: SignatureSuite } fn signature_verification_succeeded(v: SignatureVerification) -> Bool { match v { @@ -97,15 +101,12 @@ fn signature_verification_succeeded(v: SignatureVerification) -> Bool { SignatureMalformed { octets_declared: _, octets_expected: _ } => false VerifyingKeyMalformed { octets_declared: _, octets_expected: _ } => false SignatureSuiteMismatch { key_suite: _, signature_suite: _ } => false + VerifyingKeyUndecodable { suite: _ } => false + SignatureUndecodable { suite: _ } => false + SignatureEncodingRefused { suite: _ } => false } } -// NO BOUND IMPLEMENTATION YET, and that is declared rather than papered: extdeps.crypto.mac binds -// RustCrypto's hmac through a host primitive; the ECDSA twin (RustCrypto p256 VerifyingKey::verify) -// has no host primitive in this tree. The consumer that needs it is the /approve route's device -// signature check (gunbc.auth.approval_push); until the primitive lands no route may mint -// SignatureVerified from anything, so no redemption can claim a device. -// // ── The realization seam ───────────────────────────────────────────────────────────────────── // Same boundary as mac_verification_from_implementation, stated the same way: evidence cannot exist // without naming its message and key (structural), and whether the bound implementation actually ran @@ -138,3 +139,58 @@ fn signature_verification_from_implementation( } } } + +// ── The bound implementation ───────────────────────────────────────────────────────────────── +// `p256_ecdsa_verify_b64url` is a host primitive backed by RustCrypto's p256 VerifyingKey::verify, +// which SHA-256-hashes the message itself: nothing here hands the implementation a digest. The octet +// lengths the seam checks are the DECODED lengths, so a 64-octet key or a 71-octet DER signature is +// named as such rather than collapsed into a mismatch. +// +// Absence from the primitive is not a mismatch either. When the seam's own checks pass (so the +// answer with a false bit would be SignatureInvalid) and the primitive still answers absent, the +// bytes had the right lengths and are not an admitted encoding -- a point off the curve, a scalar out +// of range, a prefix other than 0x04, a non-canonical base64url spelling -- and that is +// SignatureEncodingRefused, never SignatureInvalid: an invalid signature is a statement about a +// well-formed one. +fn p256_ecdsa_verify(key: VerifyingKey, signature: SignatureBytes, message: NonEmptyStr) -> SignatureVerification { + match base64_decode(s: key.point_b64url as String, variant: UrlSafe) { + Absent => VerifyingKeyUndecodable { suite: key.suite } + Present { value: key_octets } => + match base64_decode(s: signature.b64url as String, variant: UrlSafe) { + Absent => SignatureUndecodable { suite: signature.suite } + Present { value: signature_octets } => + match key.suite { + EcdsaP256Sha256 => + match p256_ecdsa_verify_b64url(key.point_b64url as String, signature.b64url as String, message as String) { + Present { value: verified } => + signature_verification_from_implementation( + key: key, + key_octets: count(key_octets), + signature: signature, + signature_octets: count(signature_octets), + message: message, + implementation_verified: verified, + ) + Absent => + encoding_refusal(v: signature_verification_from_implementation( + key: key, + key_octets: count(key_octets), + signature: signature, + signature_octets: count(signature_octets), + message: message, + implementation_verified: false, + )) + } + } + } + } +} + +// The seam's structural refusals stand; only the arm that would claim a well-formed invalid +// signature is re-read as an encoding refusal. +fn encoding_refusal(v: SignatureVerification) -> SignatureVerification { + match v { + SignatureInvalid { suite: s } => SignatureEncodingRefused { suite: s } + _ => v + } +} diff --git a/dag/gunbc/v1/v1_interpreter_primitive_surface.dag b/dag/gunbc/v1/v1_interpreter_primitive_surface.dag index 50e2a148f8a..e6b762fbdcc 100644 --- a/dag/gunbc/v1/v1_interpreter_primitive_surface.dag +++ b/dag/gunbc/v1/v1_interpreter_primitive_surface.dag @@ -506,6 +506,24 @@ fn v1_interpreter_authored_roster_arms() -> List List { [ "hmac_sha256_verify_hex", "hmac_sha256_hex", + "p256_ecdsa_verify_b64url", + "es256_jwt_sign", "count", "string_length", "code_point", @@ -698,6 +722,8 @@ fn primitive_contract_roster() -> List { string_length_contract, hmac_sha256_verify_hex_contract, hmac_sha256_hex_contract, + p256_ecdsa_verify_b64url_contract, + es256_jwt_sign_contract, substring_contract, string_contains_contract, starts_with_contract, diff --git a/dag/test/claim/p256_ecdsa_witness_test.dag b/dag/test/claim/p256_ecdsa_witness_test.dag new file mode 100644 index 00000000000..a62a69cba90 --- /dev/null +++ b/dag/test/claim/p256_ecdsa_witness_test.dag @@ -0,0 +1,137 @@ +module test.claim.p256_ecdsa_witness_test + +import std.logic { Bool } +import std.types { NonEmptyStr, Secret } +import extdeps.crypto.signature { + VerifyingKey, SignatureBytes, EcdsaP256Sha256, Sec1Uncompressed, P1363FixedWidth, + SignatureVerification, SignatureVerified, SignatureInvalid, VerifyingKeyMalformed, + SignatureEncodingRefused, VerifyingKeyUndecodable, p256_ecdsa_verify, +} +import extdeps.apple.apns { + ApnsProviderIdentity, ApnsKeyId, AppleTeamId, + ApnsProviderTokenMint, ApnsProviderTokenSigned, ApnsAuthKeyUnreadable, ApnsIssuedAtBeforeEpoch, + apns_provider_token, +} + +// ── RFC 7515 Appendix A.3 ──────────────────────────────────────────────────────────────────── +// A PUBLISHED ES256 signature, so the accepted vector is one this implementation did not produce. +// The point is the RFC's JWK x and y as the SEC 1 uncompressed point 0x04 || x || y. +data rfc7515_key: VerifyingKey = VerifyingKey { + suite: EcdsaP256Sha256, + encoding: Sec1Uncompressed, + point_b64url: "BH_Nzidw9sRdQYPL7m_bS3tYBzM1e-nvE7rPbjx70VRFx_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0", +} + +data rfc7515_input: NonEmptyStr = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ" + +data rfc7515_signature: SignatureBytes = SignatureBytes { + suite: EcdsaP256Sha256, + encoding: P1363FixedWidth, + b64url: "DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSApmWQxfKTUJqPP3-Kg6NU1Q", +} + +fn is_invalid(v: SignatureVerification) -> Bool { + match v { + SignatureInvalid { suite: _ } => true + _ => false + } +} + +test fn the_published_rfc7515_signature_verifies_and_names_its_message() -> Bool { + match p256_ecdsa_verify(key: rfc7515_key, signature: rfc7515_signature, message: rfc7515_input) { + SignatureVerified { verified } => verified.message == rfc7515_input && verified.key == rfc7515_key + _ => false + } +} + +// The same signature over a different message: the header gained one member. +test fn a_tampered_message_is_invalid() -> Bool { + is_invalid(v: p256_ecdsa_verify(key: rfc7515_key, signature: rfc7515_signature, message: "eyJhbGciOiJFUzI1NiIsIngiOjF9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ")) +} + +// One bit of r flipped (octet 10); still a well-formed 64-octet r || s. +test fn a_tampered_signature_is_invalid() -> Bool { + let tampered = SignatureBytes { suite: EcdsaP256Sha256, encoding: P1363FixedWidth, b64url: "DtEhU3ljbEg8L34VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSApmWQxfKTUJqPP3-Kg6NU1Q" } + is_invalid(v: p256_ecdsa_verify(key: rfc7515_key, signature: tampered, message: rfc7515_input)) +} + +// A different, valid P-256 point (generated by openssl, unrelated to the RFC key). +test fn the_wrong_key_is_invalid() -> Bool { + let other = VerifyingKey { suite: EcdsaP256Sha256, encoding: Sec1Uncompressed, point_b64url: "BI7Qg7X06dj3JFJRA0BSdk3VAs8VQoORRsaajXYJIPjDD-00C_bU7Oz-mrPSxTjkZP2GGtJ3azfiD0olo1WQCsM" } + is_invalid(v: p256_ecdsa_verify(key: other, signature: rfc7515_signature, message: rfc7515_input)) +} + +// The RFC key's SEC 1 COMPRESSED spelling: a real encoding of the same key, and not the admitted one. +test fn the_compressed_spelling_of_the_right_key_is_malformed_at_its_decoded_length() -> Bool { + let compressed = VerifyingKey { suite: EcdsaP256Sha256, encoding: Sec1Uncompressed, point_b64url: "A3_Nzidw9sRdQYPL7m_bS3tYBzM1e-nvE7rPbjx70VRF" } + match p256_ecdsa_verify(key: compressed, signature: rfc7515_signature, message: rfc7515_input) { + VerifyingKeyMalformed { octets_declared, octets_expected } => octets_declared == 33 && octets_expected == 65 + _ => false + } +} + +// 65 octets, 0x04 prefix, y's last bit flipped: the right shape and not a point on the curve. That +// is an encoding refusal, not an invalid signature. +test fn a_point_off_the_curve_is_an_encoding_refusal() -> Bool { + let off_curve = VerifyingKey { suite: EcdsaP256Sha256, encoding: Sec1Uncompressed, point_b64url: "BH_Nzidw9sRdQYPL7m_bS3tYBzM1e-nvE7rPbjx70VRFx_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5aw" } + match p256_ecdsa_verify(key: off_curve, signature: rfc7515_signature, message: rfc7515_input) { + SignatureEncodingRefused { suite: _ } => true + _ => false + } +} + +test fn a_key_that_is_not_base64url_is_undecodable() -> Bool { + let not_b64 = VerifyingKey { suite: EcdsaP256Sha256, encoding: Sec1Uncompressed, point_b64url: "BH+Nzidw" } + match p256_ecdsa_verify(key: not_b64, signature: rfc7515_signature, message: rfc7515_input) { + VerifyingKeyUndecodable { suite: _ } => true + _ => false + } +} + +// ── The APNs provider token ────────────────────────────────────────────────────────────────── +// The RFC 7515 A.3 private scalar d wrapped as PKCS #8, the shape of an APNs .p8. RFC 6979 makes the +// signature deterministic, so the whole token is a fixed expected value -- and that exact token was +// independently verified with `openssl dgst -sha256 -verify` against the RFC public key. +data rfc7515_p8: Secret = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgjpsQnnGQmL+YBIff\nH1136cspYG6+0iY7X1fCE9+E9LKhRANCAAR/zc4ncPbEXUGDy+5v20t7WAczNXvp\n7xO6z248e9FURcfxRM0bvZt+hyzf7bnuufSzaV1uqQskrYpGIyiFiOWt\n-----END PRIVATE KEY-----\n" as Secret + +data test_identity: ApnsProviderIdentity = ApnsProviderIdentity { + team_id: "DEF123GHIJ" as AppleTeamId, + key_id: "ABC123DEFG" as ApnsKeyId, +} + +// {"alg":"ES256","kid":"ABC123DEFG"} . {"iss":"DEF123GHIJ","iat":1700000000} +data expected_signing_input: NonEmptyStr = "eyJhbGciOiJFUzI1NiIsImtpZCI6IkFCQzEyM0RFRkcifQ.eyJpc3MiOiJERUYxMjNHSElKIiwiaWF0IjoxNzAwMDAwMDAwfQ" +data expected_signature: NonEmptyStr = "9eqgVsaVpJcjng-z9xvwbFH5RQ4bIRjaUdI0UT5WUNjDlcVbRVsgHTYZ2TWGP-HMFIbg6jaaLZG9RVlfBpOdlg" + +test fn the_provider_token_is_the_expected_es256_jwt() -> Bool { + match apns_provider_token(identity: test_identity, auth_key: rfc7515_p8, issued_at_epoch: 1700000000) { + ApnsProviderTokenSigned { token } => + token.jwt as String == concat(concat(expected_signing_input as String, "."), expected_signature as String) + && token.issued_at_epoch == 1700000000 + _ => false + } +} + +// The token's signature segment verifies under the RFC PUBLIC key over the token's signing input, +// through the same verifier the device routes use -- issuance and verification agree on JOSE r || s. +test fn the_provider_token_signature_verifies_under_the_public_key() -> Bool { + let sig = SignatureBytes { suite: EcdsaP256Sha256, encoding: P1363FixedWidth, b64url: expected_signature } + match p256_ecdsa_verify(key: rfc7515_key, signature: sig, message: expected_signing_input) { + SignatureVerified { verified: _ } => true + _ => false + } +} + +test fn a_key_that_is_not_pkcs8_pem_mints_no_token() -> Bool { + match apns_provider_token(identity: test_identity, auth_key: "not a key" as Secret, issued_at_epoch: 1700000000) { + ApnsAuthKeyUnreadable { key_id } => key_id == test_identity.key_id + _ => false + } +} + +test fn an_issue_time_before_the_epoch_mints_no_token() -> Bool { + match apns_provider_token(identity: test_identity, auth_key: rfc7515_p8, issued_at_epoch: -1) { + ApnsIssuedAtBeforeEpoch { issued_at_epoch } => issued_at_epoch == -1 + _ => false + } +} diff --git a/src/v1/04_method.dag b/src/v1/04_method.dag index 822610907ca..9fafc06875b 100644 --- a/src/v1/04_method.dag +++ b/src/v1/04_method.dag @@ -271,6 +271,8 @@ data builtin_function_registry: Map = { "count": derived_signature(names: ["xs"], method: "count", returns: int_type), "hmac_sha256_verify_hex": BuiltinSignature { params: [BuiltinParam { name: "key_hex", ty: NamedTemplate { name: "String" } }, BuiltinParam { name: "message", ty: NamedTemplate { name: "String" } }, BuiltinParam { name: "tag_hex", ty: NamedTemplate { name: "String" } }], returns: bool_type }, "hmac_sha256_hex": BuiltinSignature { params: [BuiltinParam { name: "key_hex", ty: NamedTemplate { name: "String" } }, BuiltinParam { name: "message", ty: NamedTemplate { name: "String" } }], returns: with_optional_cardinality(n: string_type) }, + "p256_ecdsa_verify_b64url": BuiltinSignature { params: [BuiltinParam { name: "key_point_b64url", ty: NamedTemplate { name: "String" } }, BuiltinParam { name: "signature_b64url", ty: NamedTemplate { name: "String" } }, BuiltinParam { name: "message", ty: NamedTemplate { name: "String" } }], returns: with_optional_cardinality(n: bool_type) }, + "es256_jwt_sign": BuiltinSignature { params: [BuiltinParam { name: "p8_pem_secret", ty: NamedTemplate { name: "String" } }, BuiltinParam { name: "key_id", ty: NamedTemplate { name: "String" } }, BuiltinParam { name: "team_id", ty: NamedTemplate { name: "String" } }, BuiltinParam { name: "issued_at_epoch", ty: NamedTemplate { name: "Int" } }], returns: with_optional_cardinality(n: string_type) }, "string_length": BuiltinSignature { params: [BuiltinParam { name: "s", ty: NamedTemplate { name: "String" } }], returns: int_type }, "code_point": BuiltinSignature { params: [BuiltinParam { name: "c", ty: NamedTemplate { name: "String" } }], returns: int_type }, "to_int": derived_signature(names: ["s"], method: "to_int", returns: int_type), diff --git a/src/v1/stage0/Cargo.toml b/src/v1/stage0/Cargo.toml index 0c1be947195..c11dc085483 100644 --- a/src/v1/stage0/Cargo.toml +++ b/src/v1/stage0/Cargo.toml @@ -50,6 +50,12 @@ serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" hmac = { workspace = true } hex = { workspace = true } +# ECDSA P-256 / SHA-256 (FIPS 186-5), the asymmetric twin of hmac above: RustCrypto's p256 on the +# same digest 0.10 family sha2 0.10 uses. Verification backs extdeps.crypto.signature +# p256_ecdsa_verify; signing (pkcs8 + pem, for the APNs .p8 key) backs extdeps.apple.apns +# apns_provider_token. base64 is the URL-safe unpadded codec both wire formats use. +p256 = { version = "0.13", default-features = false, features = ["ecdsa", "pkcs8", "pem", "std"] } +base64 = "0.22" sha2 = "0.10" toml = { workspace = true } ureq = { version = "2", features = ["json"] } diff --git a/src/v1/stage0/src/v1_compiler_infer_method.rs b/src/v1/stage0/src/v1_compiler_infer_method.rs index 9a965e2facb..35ec95276b6 100644 --- a/src/v1/stage0/src/v1_compiler_infer_method.rs +++ b/src/v1/stage0/src/v1_compiler_infer_method.rs @@ -286,6 +286,49 @@ pub fn builtin_function_registry() -> Rc>> ty: Rc::new(AlgebraTypeTemplate::NamedTemplate { name: "String".to_string(), }), + })]), + returns: crate::v1_std_core::with_optional_cardinality(string_type()), + })); + __m.insert("p256_ecdsa_verify_b64url".to_string(), Rc::new(BuiltinSignature { + params: Rc::new(vec![Rc::new(BuiltinParam { + name: "key_point_b64url".to_string(), + ty: Rc::new(AlgebraTypeTemplate::NamedTemplate { + name: "String".to_string(), + }), + }), Rc::new(BuiltinParam { + name: "signature_b64url".to_string(), + ty: Rc::new(AlgebraTypeTemplate::NamedTemplate { + name: "String".to_string(), + }), + }), Rc::new(BuiltinParam { + name: "message".to_string(), + ty: Rc::new(AlgebraTypeTemplate::NamedTemplate { + name: "String".to_string(), + }), + })]), + returns: crate::v1_std_core::with_optional_cardinality(bool_type()), + })); + __m.insert("es256_jwt_sign".to_string(), Rc::new(BuiltinSignature { + params: Rc::new(vec![Rc::new(BuiltinParam { + name: "p8_pem_secret".to_string(), + ty: Rc::new(AlgebraTypeTemplate::NamedTemplate { + name: "String".to_string(), + }), + }), Rc::new(BuiltinParam { + name: "key_id".to_string(), + ty: Rc::new(AlgebraTypeTemplate::NamedTemplate { + name: "String".to_string(), + }), + }), Rc::new(BuiltinParam { + name: "team_id".to_string(), + ty: Rc::new(AlgebraTypeTemplate::NamedTemplate { + name: "String".to_string(), + }), + }), Rc::new(BuiltinParam { + name: "issued_at_epoch".to_string(), + ty: Rc::new(AlgebraTypeTemplate::NamedTemplate { + name: "Int".to_string(), + }), })]), returns: crate::v1_std_core::with_optional_cardinality(string_type()), })); diff --git a/src/v1/stage0/src/v1_interpreter.rs b/src/v1/stage0/src/v1_interpreter.rs index fc428eba16c..eb383b8465c 100644 --- a/src/v1/stage0/src/v1_interpreter.rs +++ b/src/v1/stage0/src/v1_interpreter.rs @@ -19685,6 +19685,46 @@ macro_rules! v1_builtin_arms { })) }, + // ECDSA P-256 / SHA-256 VERIFICATION (FIPS 186-5), the asymmetric twin of + // hmac_sha256_verify_hex and bound behind extdeps.crypto.signature p256_ecdsa_verify. + // One spelling per input, the ones that module declares: the key is the 65-octet SEC 1 + // uncompressed point, the signature the 64-octet fixed-width r || s, both unpadded + // base64url. The MESSAGE is hashed here with SHA-256 -- a caller never hands in a + // digest, so a verifier cannot be pointed at a hash of the attacker's choosing. + // + // Three answers, not two: true (verified), false (well-formed and does not verify), + // and ABSENT when an input is not an admitted encoding -- a key off the curve, a + // scalar out of range, a wrong length, a non-canonical base64url. The .dag binding + // turns absence into its typed refusal; it is never reported as a mismatch. + arm "free_call.p256_ecdsa_verify_b64url" { "p256_ecdsa_verify_b64url" } => { + Ok(Some(match p256_ecdsa_verify_b64url( + expect_value_str($positional.first().copied(), "p256_ecdsa_verify_b64url key")?.as_str(), + expect_value_str($positional.get(1).copied(), "p256_ecdsa_verify_b64url signature")?.as_str(), + expect_value_str($positional.get(2).copied(), "p256_ecdsa_verify_b64url message")?.as_str(), + ) { + Some(verified) => Value::Bool(verified), + None => Value::Null, + })) + }, + + // THE APNs PROVIDER TOKEN (extdeps.apple.apns apns_provider_token): an RFC 7519 JWT + // signed ES256 (RFC 7518 §3.4) with the .p8 PKCS #8 auth key. Header {alg, kid}, + // claims {iss, iat}, each segment unpadded base64url, the signature JOSE r || s. + // RustCrypto's SigningKey signs with RFC 6979 deterministic nonces, so no RNG is + // consulted and one input has exactly one token. A key that is not a P-256 PKCS #8 + // PEM, or a negative issue time, answers ABSENT -- never a token. + arm "free_call.es256_jwt_sign" { "es256_jwt_sign" } => { + Ok(Some(match es256_jwt_sign( + expect_value_str($positional.first().copied(), "es256_jwt_sign key")?.as_str(), + expect_value_str($positional.get(1).copied(), "es256_jwt_sign key_id")?.as_str(), + expect_value_str($positional.get(2).copied(), "es256_jwt_sign team_id")?.as_str(), + expect_int($positional.get(3).copied(), "es256_jwt_sign issued_at")?, + ) { + Some(token) => str_value(token), + None => Value::Null, + })) + }, + arm "free_call.string_length" { "string_length" } => { let s = expect_value_str($positional.first().copied(), "string_length")?; Ok(Some(Value::Int(s.string_length()))) @@ -22377,6 +22417,142 @@ mod hmac_sha256_hex_tests { } } +/// The `p256_ecdsa_verify_b64url` builtin's computation: `Some(verified)` over well-formed +/// inputs, `None` when an input is not an admitted encoding (see the arm). +fn p256_ecdsa_verify_b64url( + key_b64url: &str, + signature_b64url: &str, + message: &str, +) -> Option { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine; + use p256::ecdsa::signature::Verifier; + use p256::ecdsa::{Signature, VerifyingKey}; + let point = URL_SAFE_NO_PAD.decode(key_b64url).ok()?; + // SEC 1 §2.3.3 uncompressed only: from_sec1_bytes would also admit the 33-octet compressed + // form, a second spelling of the same key. + if point.len() != 65 || point[0] != 0x04 { + return None; + } + let key = VerifyingKey::from_sec1_bytes(&point).ok()?; + let sig = URL_SAFE_NO_PAD.decode(signature_b64url).ok()?; + if sig.len() != 64 { + return None; + } + let sig = Signature::from_slice(&sig).ok()?; + Some(key.verify(message.as_bytes(), &sig).is_ok()) +} + +/// The `es256_jwt_sign` builtin's computation: the compact JWS of the APNs provider token, or +/// `None` when the key is not a P-256 PKCS #8 PEM or the issue time is negative. +fn es256_jwt_sign(p8_pem: &str, key_id: &str, team_id: &str, issued_at: i64) -> Option { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine; + use p256::ecdsa::signature::Signer; + use p256::ecdsa::{Signature, SigningKey}; + use p256::pkcs8::DecodePrivateKey; + if issued_at < 0 { + return None; + } + let key = SigningKey::from_pkcs8_pem(p8_pem).ok()?; + // serde_json escapes the two caller strings; member order is fixed by the literal. + let header = format!( + "{{\"alg\":\"ES256\",\"kid\":{}}}", + serde_json::to_string(key_id).ok()? + ); + let claims = format!( + "{{\"iss\":{},\"iat\":{}}}", + serde_json::to_string(team_id).ok()?, + issued_at + ); + let signing_input = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(header.as_bytes()), + URL_SAFE_NO_PAD.encode(claims.as_bytes()) + ); + let signature: Signature = key.sign(signing_input.as_bytes()); + Some(format!( + "{}.{}", + signing_input, + URL_SAFE_NO_PAD.encode(signature.to_bytes()) + )) +} + +#[cfg(test)] +mod p256_ecdsa_tests { + use super::{es256_jwt_sign, p256_ecdsa_verify_b64url}; + + // RFC 7515 Appendix A.3: a published ES256 JWS, verified against a key and signature this + // implementation did not produce. + const RFC7515_A3_POINT: &str = + "BH_Nzidw9sRdQYPL7m_bS3tYBzM1e-nvE7rPbjx70VRFx_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"; + const RFC7515_A3_INPUT: &str = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ"; + const RFC7515_A3_SIG: &str = + "DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSApmWQxfKTUJqPP3-Kg6NU1Q"; + // The same RFC's private scalar d, wrapped as PKCS #8 the way an APNs .p8 is. + const RFC7515_A3_P8: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgjpsQnnGQmL+YBIff\nH1136cspYG6+0iY7X1fCE9+E9LKhRANCAAR/zc4ncPbEXUGDy+5v20t7WAczNXvp\n7xO6z248e9FURcfxRM0bvZt+hyzf7bnuufSzaV1uqQskrYpGIyiFiOWt\n-----END PRIVATE KEY-----\n"; + + #[test] + fn the_published_rfc7515_a3_signature_verifies() { + assert_eq!( + p256_ecdsa_verify_b64url(RFC7515_A3_POINT, RFC7515_A3_SIG, RFC7515_A3_INPUT), + Some(true) + ); + } + + #[test] + fn a_different_message_does_not_verify() { + let tampered = + RFC7515_A3_INPUT.replacen("eyJhbGciOiJFUzI1NiJ9", "eyJhbGciOiJFUzI1NiIsIngiOjF9", 1); + assert_eq!( + p256_ecdsa_verify_b64url(RFC7515_A3_POINT, RFC7515_A3_SIG, &tampered), + Some(false) + ); + } + + #[test] + fn a_non_admitted_encoding_is_absent_not_false() { + // 64 octets of key: the 0x04 prefix dropped. + assert_eq!( + p256_ecdsa_verify_b64url(&RFC7515_A3_POINT[2..], RFC7515_A3_SIG, RFC7515_A3_INPUT), + None + ); + // Padded base64url is a second spelling. + assert_eq!( + p256_ecdsa_verify_b64url( + RFC7515_A3_POINT, + &format!("{}==", RFC7515_A3_SIG), + RFC7515_A3_INPUT + ), + None + ); + } + + #[test] + fn an_es256_provider_token_verifies_under_its_public_key() { + let token = + es256_jwt_sign(RFC7515_A3_P8, "ABC123DEFG", "DEF123GHIJ", 1_700_000_000).unwrap(); + let (input, sig) = token.rsplit_once('.').unwrap(); + assert_eq!(input, "eyJhbGciOiJFUzI1NiIsImtpZCI6IkFCQzEyM0RFRkcifQ.eyJpc3MiOiJERUYxMjNHSElKIiwiaWF0IjoxNzAwMDAwMDAwfQ"); + assert_eq!( + p256_ecdsa_verify_b64url(RFC7515_A3_POINT, sig, input), + Some(true) + ); + } + + #[test] + fn a_key_that_is_not_pkcs8_pem_yields_no_token() { + assert_eq!( + es256_jwt_sign("not a key", "ABC123DEFG", "DEF123GHIJ", 1_700_000_000), + None + ); + assert_eq!( + es256_jwt_sign(RFC7515_A3_P8, "ABC123DEFG", "DEF123GHIJ", -1), + None + ); + } +} + /// Like `expect_str`, but returns the `Value::Str` carrier itself instead of an owned /// copy: the caller gets the `&str` view AND the carried ASCII fact, and pays no O(n) /// `.to_string()` for a read-only use (STRING-INDEX-0). diff --git a/src/v1/stage0/src/v1_interpreter_dispatch_generated.rs b/src/v1/stage0/src/v1_interpreter_dispatch_generated.rs index 4a5fc2a6b6a..93885b5303d 100644 --- a/src/v1/stage0/src/v1_interpreter_dispatch_generated.rs +++ b/src/v1/stage0/src/v1_interpreter_dispatch_generated.rs @@ -23,6 +23,8 @@ pub enum EvalBuiltinArm { FreeCallReverse, FreeCallHmacSha256VerifyHex, FreeCallHmacSha256Hex, + FreeCallP256EcdsaVerifyB64url, + FreeCallEs256JwtSign, FreeCallStringLength, FreeCallSubstring, FreeCallCharAt, @@ -166,6 +168,8 @@ pub fn lookup_eval_builtin_inner(spelling: &str) -> Option { "reverse" => Some(EvalBuiltinArm::FreeCallReverse), "hmac_sha256_verify_hex" => Some(EvalBuiltinArm::FreeCallHmacSha256VerifyHex), "hmac_sha256_hex" => Some(EvalBuiltinArm::FreeCallHmacSha256Hex), + "p256_ecdsa_verify_b64url" => Some(EvalBuiltinArm::FreeCallP256EcdsaVerifyB64url), + "es256_jwt_sign" => Some(EvalBuiltinArm::FreeCallEs256JwtSign), "string_length" => Some(EvalBuiltinArm::FreeCallStringLength), "substring" => Some(EvalBuiltinArm::FreeCallSubstring), "char_at" => Some(EvalBuiltinArm::FreeCallCharAt), @@ -313,6 +317,8 @@ macro_rules! eval_builtin_inner_arm { ("free_call.reverse") => { $crate::v1_interpreter_dispatch_generated::EvalBuiltinArm::FreeCallReverse }; ("free_call.hmac_sha256_verify_hex") => { $crate::v1_interpreter_dispatch_generated::EvalBuiltinArm::FreeCallHmacSha256VerifyHex }; ("free_call.hmac_sha256_hex") => { $crate::v1_interpreter_dispatch_generated::EvalBuiltinArm::FreeCallHmacSha256Hex }; + ("free_call.p256_ecdsa_verify_b64url") => { $crate::v1_interpreter_dispatch_generated::EvalBuiltinArm::FreeCallP256EcdsaVerifyB64url }; + ("free_call.es256_jwt_sign") => { $crate::v1_interpreter_dispatch_generated::EvalBuiltinArm::FreeCallEs256JwtSign }; ("free_call.string_length") => { $crate::v1_interpreter_dispatch_generated::EvalBuiltinArm::FreeCallStringLength }; ("free_call.substring") => { $crate::v1_interpreter_dispatch_generated::EvalBuiltinArm::FreeCallSubstring }; ("free_call.char_at") => { $crate::v1_interpreter_dispatch_generated::EvalBuiltinArm::FreeCallCharAt }; From 3dc42b2d3e1a770545963ba848a5e21164fbf033 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 07:32:01 +0000 Subject: [PATCH 04/17] Address #11585 reviews: sole-constructed admission and enrolment, injective framing, App Attest current checks - AdmittedDeviceRedemption and VerifiedDeviceEnrollment are sole_constructor; the decision commit consumes only the admitted value, and the login comes from the code's issuance. - Enrolment code, enrolment and revocation are generations of ONE CAS slot, so consuming the code is creating the enrolment; expired, reused, other-bytes and Android refuse. - Every signed/MAC'd message is length-framed (code-point counts): injective for any field content, where the unit-separator join was not (capability_text itself contains it). - capability_tag_hex names the tag's real encoding. - App Attest: AppIdPrefix (not team id), validation category and bundle version refusals, seam-minted VerifiedAttestation/VerifiedAssertion carrying key, receipt and client data; redemption joins the assertion to the enrolled attest key and the exact signed bytes. - APNs: top-level custom-data carrier; a 200 without apns-id is undecodable. - Reads that return capabilities are assertion-authenticated; residual stated. - std.measure Second/ByteSize for APNs ceilings and signature sizes (review 67564). - Unconsumed declarations removed or given named consumers; Android attestation corrected; iOS realization frontier added; stale approval_push references fixed. - gunbc.auth.approval_device_redemption_fixtures renders the cross-language vectors. Co-Authored-By: Claude Opus 5 (1M context) --- dag/extdeps/android/key_attestation.dag | 8 +- dag/extdeps/apple/apns.dag | 29 +- dag/extdeps/apple/app_attest.dag | 85 ++- dag/extdeps/apple/secure_enclave.dag | 17 - dag/extdeps/crypto/signature.dag | 55 +- dag/gunbc/auth/approval_device_redemption.dag | 600 +++++++++++------- .../approval_device_redemption_fixtures.dag | 134 ++++ ...pproval_device_redemption_witness_test.dag | 336 +++++----- 8 files changed, 793 insertions(+), 471 deletions(-) create mode 100644 dag/gunbc/auth/approval_device_redemption_fixtures.dag diff --git a/dag/extdeps/android/key_attestation.dag b/dag/extdeps/android/key_attestation.dag index 07780fd0a77..363b9ad9253 100644 --- a/dag/extdeps/android/key_attestation.dag +++ b/dag/extdeps/android/key_attestation.dag @@ -5,10 +5,14 @@ import extdeps.external_authority { ExternalAuthority } import extdeps.uri { Uri, Https } // Android Keystore hardware-backed key attestation. A key generated with setAttestationChallenge -// yields an X.509 chain whose leaf carries the KeyDescription extension (OID 1.3.6.1.4.1.11129.2.1.17): +// yields an X.509 chain carrying the KeyDescription extension (OID 1.3.6.1.4.1.11129.2.1.17). The +// verifier must NOT assume the extension sits in the leaf: it reads the first occurrence in the +// chain that chains to a trusted root, checks every certificate against Google's revocation list, +// and trusts the CURRENT set of Google attestation roots (more than one; the page lists them). The +// extension carries: // the attestation challenge, security level (Software / TrustedEnvironment / StrongBox), and the key's // authorization list (purpose, userAuthenticationType, per-operation auth, attestationApplicationId -// naming the package and signing-certificate digests). The chain roots at a Google attestation root. +// naming the package and signing-certificate digests). data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { uri: Uri { scheme: Https diff --git a/dag/extdeps/apple/apns.dag b/dag/extdeps/apple/apns.dag index 3183a5b80e0..6216750718d 100644 --- a/dag/extdeps/apple/apns.dag +++ b/dag/extdeps/apple/apns.dag @@ -1,8 +1,9 @@ module extdeps.apple.apns -import std.types { NonEmptyStr, String, Int, Bool } +import std.types { NonEmptyStr, String, Int, Bool, List } import extdeps.external_authority { ExternalAuthority } import extdeps.uri { Uri, Https } +import std.measure { Second, second, ByteSize, byte_size } import extdeps.crypto.signature { SignatureSuite, EcdsaP256Sha256 } import extdeps.transports.rest { RestOutcome, RestOk, RestStatusRefused, RestTransportRefused, RestBodyUndecodable } @@ -47,8 +48,8 @@ data apns_provider_token_suite: SignatureSuite = EcdsaP256Sha256 // refused (403 ExpiredProviderToken), and a provider that REFRESHES more often than once per twenty // minutes is refused too (429 TooManyProviderTokenUpdates). So the token is a cached value with a // window, not a per-request mint. -data apns_provider_token_max_age_seconds: Int = 3600 -data apns_provider_token_min_refresh_seconds: Int = 1200 +data apns_provider_token_max_age: Second = second(count: 3600) +data apns_provider_token_min_refresh: Second = second(count: 1200) type ApnsProviderIdentity { team_id: AppleTeamId @@ -89,7 +90,7 @@ fn apns_priority_wire(p: ApnsPriority) -> Int { // apns-collapse-id: notifications sharing it are coalesced on the device into the newest. Apple caps // its value at 64 bytes. type ApnsCollapseId = NonEmptyStr where brand("ApnsCollapseId") -data apns_collapse_id_max_bytes: Int = 64 +data apns_collapse_id_max_size: ByteSize = byte_size(count: 64) // ── Payload ────────────────────────────────────────────────────────────────────────────────── // The aps dictionary's alert and mutable-content keys. mutable-content = 1 lets a Notification @@ -122,12 +123,20 @@ type ApnsAps { } // Apple's limit for a non-VoIP remote notification payload. -data apns_payload_max_bytes: Int = 4096 +data apns_payload_max_size: ByteSize = byte_size(count: 4096) // A device token is an opaque per-app-install address APNs issues to the app; the provider stores it // and never interprets it. type ApnsDeviceToken = NonEmptyStr where brand("ApnsDeviceToken") +// APP-SPECIFIC DATA IS A PEER OF aps, never inside it: Apple reserves the aps dictionary and puts +// custom keys at the top level of the JSON body. String values only here, which is all a consumer +// of this module needs; a richer value shape is added when a consumer needs one. +type ApnsCustomKey { + key: NonEmptyStr + value: NonEmptyStr +} + type ApnsRequest { environment: ApnsEnvironment device_token: ApnsDeviceToken @@ -136,6 +145,7 @@ type ApnsRequest { priority: ApnsPriority collapse_id: ApnsCollapseId aps: ApnsAps + custom: List } // ── Responses ──────────────────────────────────────────────────────────────────────────────── @@ -161,17 +171,20 @@ fn apns_refusal_class(status: Int) -> ApnsRefusalClass { // The same four-way outcome extdeps.ntfy.ntfy NtfyPublishOutcome settled on, for the same reasons: a // transport refusal has no status and must not be given one, and an undecodable 200 is not a -// refusal. The broker-level shape both fold into is the consumer's (gunbc.auth.approval_push -// PushOutcome), not this module's. +// refusal. The broker-level shape both fold into is the consumer's (gunbc.auth.approval_device_redemption +// PushProviderOutcome), not this module's. type ApnsSendOutcome = ApnsAccepted { apns_id: NonEmptyStr } | ApnsRefused { status: Int, class: ApnsRefusalClass, reason: String } | ApnsUnreached { cause: String } | ApnsUndecodable { status: Int, cause: String } +// APNs ANSWERS EVERY ACCEPTED REQUEST WITH AN apns-id HEADER, supplied or generated. A 200 without +// one is not an acceptance this module can name, so it is undecodable -- never an ApnsAccepted +// carrying an empty id. fn apns_send_outcome_of(rest: RestOutcome, apns_id: String) -> ApnsSendOutcome { match rest { - RestOk => ApnsAccepted { apns_id: apns_id as NonEmptyStr } + RestOk => if apns_id == "" { ApnsUndecodable { status: 200, cause: "accepted response carried no apns-id header" } } else { ApnsAccepted { apns_id: apns_id as NonEmptyStr } } RestStatusRefused { status, body } => ApnsRefused { status: status, class: apns_refusal_class(status: status), reason: body } RestTransportRefused { cause } => ApnsUnreached { cause: cause as String } RestBodyUndecodable { status, cause } => ApnsUndecodable { status: status, cause: cause as String } diff --git a/dag/extdeps/apple/app_attest.dag b/dag/extdeps/apple/app_attest.dag index 9b852d38127..fd9bd47b6ca 100644 --- a/dag/extdeps/apple/app_attest.dag +++ b/dag/extdeps/apple/app_attest.dag @@ -38,16 +38,21 @@ type AppAttestEnvironment // ── Validation, in Apple's order ───────────────────────────────────────────────────────────── // The attestation object is CBOR { fmt: "apple-appattest", attStmt: { x5c, receipt }, authData }. -// Apple lists the checks; each failure is its own refusal because each is a different incident. +// Apple's current procedure; each failure is its own refusal because each is a different incident. // // 1. x5c chains to the Apple App Attestation Root CA // 2. nonce = SHA256(authData || clientDataHash) // 3. nonce equals the credential certificate extension OID 1.2.840.113635.100.8.2 // 4. SHA256(credential public key) equals the key id -// 5. authData RP ID hash equals SHA256(.) +// 5. authData RP ID hash equals SHA256(App ID) -- App ID prefix, ".", bundle id // 6. authData counter is 0 // 7. AAGUID names the expected environment -// 8. authData credential id equals the key id +// 8. authData credentialId equals the key id +// 9. apple_validation_category_01 (UInt32, extensions CBOR) names an admitted launch category +// 10. apple_bundle_version_01 (String, extensions CBOR) is the distributed app version +// +// The server then STORES the credential public key and the receipt: the key verifies every later +// assertion, and the receipt is the handle for Apple's fraud-metric call. type AttestationRefusal = AttestationFormatUnexpected { declared: String } | AttestationChainUntrusted { detail: String } @@ -57,38 +62,88 @@ type AttestationRefusal | AttestationCounterNonZero { counter: Int } | AttestationEnvironmentUnexpected { expected: AppAttestEnvironment } | AttestationCredentialIdMismatch { declared: AttestKeyId } + | AttestationValidationCategoryRefused { category: Int } + | AttestationBundleVersionAbsent | AttestationUndecodable { cause: String } data apple_app_attest_root_ca_note: NonEmptyStr = "The trust anchor is Apple's App Attestation Root CA, published at www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem. It is pinned by the verifier's configuration, never taken from the attestation's own x5c." data app_attest_nonce_extension_oid: NonEmptyStr = "1.2.840.113635.100.8.2" -// The app id the RP ID hash commits to is ".". -fn app_attest_app_id(team_id: NonEmptyStr, bundle_id: NonEmptyStr) -> NonEmptyStr { - join([team_id as String, bundle_id as String], ".") as NonEmptyStr +// THE APP ID IS ".". Apple: the prefix is USUALLY the 10-character team +// identifier, and is read from the app's Identifier entry in the developer account -- so it is its +// own fact, not a second name for the team id, and the verifier is configured with it directly. +type AppIdPrefix = NonEmptyStr where brand("AppIdPrefix") + +fn app_attest_app_id(prefix: AppIdPrefix, bundle_id: NonEmptyStr) -> NonEmptyStr { + join([prefix as String, bundle_id as String], ".") as NonEmptyStr +} + +// ── Verified facts, minted only at the seam ────────────────────────────────────────────────── +// Same boundary as extdeps.crypto.mac VerifiedMessage: nothing outside this module constructs a +// verified attestation or assertion, and each names what it was verified OVER, so a success cannot +// be paired afterwards with another enrolment's or another request's bytes. +type VerifiedAttestation sole_constructor { + key_id: AttestKeyId + public_key_point_b64url: NonEmptyStr + receipt_b64: NonEmptyStr + environment: AppAttestEnvironment + client_data: NonEmptyStr } -// Success names the attested key and the clientDataHash it was attested over, so a verified -// attestation cannot be paired afterwards with a different enrolment's client data. type AttestationVerification - = AttestationVerified { key_id: AttestKeyId, client_data_hash_hex: NonEmptyStr, environment: AppAttestEnvironment } + = AttestationVerified { verified: VerifiedAttestation } | AttestationRefused { cause: AttestationRefusal } +fn attestation_verification_from_implementation( + key_id: AttestKeyId, + public_key_point_b64url: NonEmptyStr, + receipt_b64: NonEmptyStr, + environment: AppAttestEnvironment, + client_data: NonEmptyStr, + refusal: AttestationRefusal?, +) -> AttestationVerification { + match refusal { + Present { value: r } => AttestationRefused { cause: r } + Absent => AttestationVerified { + verified: VerifiedAttestation { key_id: key_id, public_key_point_b64url: public_key_point_b64url, receipt_b64: receipt_b64, environment: environment, client_data: client_data } + } + } +} + data app_attest_unavailable_in_simulator_note: NonEmptyStr = "DCAppAttestService.isSupported is false in the Simulator and on devices without a Secure Enclave; the app must refuse enrolment there rather than enrolling without an attestation." // ── Assertions ─────────────────────────────────────────────────────────────────────────────── // Each later request carries an assertion: CBOR { signature, authenticatorData } produced by -// generateAssertion(keyId, clientDataHash). Apple's server checks, in order: the signature over -// SHA256(authenticatorData || clientDataHash) verifies under the public key stored at attestation; -// the RP ID hash equals SHA256(app id); the counter is STRICTLY greater than the last one stored -- -// the replay refusal. Success names the counter the server must now store, and the client data it -// was made over, so it cannot be paired with another request's bytes. +// generateAssertion(keyId, clientDataHash). The checks: the signature over +// SHA256(authenticatorData || clientDataHash) verifies under the STORED public key; the RP ID hash +// equals SHA256(App ID); the counter is strictly greater than the stored one; the client data +// carries the server's challenge. The consumer decides whether it stores the counter -- see +// gunbc.auth.approval_device_redemption for this deployment's reasoning. type AssertionRefusal = AssertionSignatureInvalid | AssertionAppIdMismatch { expected_app_id: NonEmptyStr } | AssertionCounterNotIncreasing { stored: Int, presented: Int } | AssertionUndecodable { cause: String } +type VerifiedAssertion sole_constructor { + key_id: AttestKeyId + counter: Int + client_data: NonEmptyStr +} + type AssertionVerification - = AssertionVerified { key_id: AttestKeyId, counter: Int, client_data: NonEmptyStr } + = AssertionVerified { verified: VerifiedAssertion } | AssertionRefused { cause: AssertionRefusal } + +fn assertion_verification_from_implementation( + key_id: AttestKeyId, + counter: Int, + client_data: NonEmptyStr, + refusal: AssertionRefusal?, +) -> AssertionVerification { + match refusal { + Present { value: r } => AssertionRefused { cause: r } + Absent => AssertionVerified { verified: VerifiedAssertion { key_id: key_id, counter: counter, client_data: client_data } } + } +} diff --git a/dag/extdeps/apple/secure_enclave.dag b/dag/extdeps/apple/secure_enclave.dag index 2d951ff88c3..91027749ec4 100644 --- a/dag/extdeps/apple/secure_enclave.dag +++ b/dag/extdeps/apple/secure_enclave.dag @@ -1,7 +1,6 @@ module extdeps.apple.secure_enclave import std.types { NonEmptyStr } -import std.logic { Bool } import extdeps.external_authority { ExternalAuthority } import extdeps.uri { Uri, Https } import extdeps.crypto.signature { SignatureSuite, EcdsaP256Sha256, PublicKeyEncoding, Sec1Uncompressed, SignatureEncoding, P1363FixedWidth } @@ -44,22 +43,6 @@ type EnclaveKeyAccessPolicy // the ability to approve. The cost is that re-enrolling Face ID requires re-enrolling the device. data approval_key_access_policy_note: NonEmptyStr = "BiometryCurrentSet binds the key to the biometric set enrolled at key creation: adding or removing a face or finger invalidates it. BiometryAny survives such a change; UserPresence also admits the device passcode. For a key whose signature names the operator, BiometryCurrentSet is the only arm under which a changed biometric set cannot silently inherit the operator's authority." -fn enclave_policy_admits_passcode(p: EnclaveKeyAccessPolicy) -> Bool { - match p { - BiometryCurrentSet => false - BiometryAny => false - UserPresence => true - } -} - -fn enclave_policy_survives_biometric_change(p: EnclaveKeyAccessPolicy) -> Bool { - match p { - BiometryCurrentSet => false - BiometryAny => true - UserPresence => true - } -} - // THE SIGNED INPUT IS HASHED BY CRYPTOKIT, NOT BY THE CALLER. PrivateKey.signature(for: Data) hashes // the data with SHA-256 and signs the digest, which is the verify side's expectation under // EcdsaP256Sha256. Signing a pre-computed digest through signature(for: digest) and verifying the diff --git a/dag/extdeps/crypto/signature.dag b/dag/extdeps/crypto/signature.dag index af43b965a49..0608fe72faa 100644 --- a/dag/extdeps/crypto/signature.dag +++ b/dag/extdeps/crypto/signature.dag @@ -2,6 +2,7 @@ module extdeps.crypto.signature import std.types { NonEmptyStr, String, Int } import std.logic { Bool } +import std.measure { ByteSize, byte_size, byte_size_count } import extdeps.external_authority { ExternalAuthority } import extdeps.uri { Uri, Https } @@ -28,19 +29,13 @@ data verification_key_cannot_sign_note: NonEmptyStr = "An ECDSA public key verif type SignatureSuite = | EcdsaP256Sha256 -fn signature_suite_name(s: SignatureSuite) -> NonEmptyStr { - match s { - EcdsaP256Sha256 => "ECDSA-P256-SHA256" as NonEmptyStr - } -} - // THE PUBLIC KEY SPELLING IS SEC 1 §2.3.3 UNCOMPRESSED POINT: 0x04 || X || Y, 65 octets for P-256. // It is also ANSI X9.63's spelling, which is the name Apple's CryptoKit exposes it under // (x963Representation). One spelling on the wire, so a key cannot be enrolled under one encoding // and compared under another. -fn signature_suite_public_key_octets(s: SignatureSuite) -> Int { +fn signature_suite_public_key_size(s: SignatureSuite) -> ByteSize { match s { - EcdsaP256Sha256 => 65 + EcdsaP256Sha256 => byte_size(count: 65) } } @@ -48,9 +43,9 @@ fn signature_suite_public_key_octets(s: SignatureSuite) -> Int { // rawRepresentation). DER (CryptoKit derRepresentation) is the other common spelling; it is not // admitted, because a verifier that accepts two encodings of one signature has a malleability // surface it did not need. -fn signature_suite_signature_octets(s: SignatureSuite) -> Int { +fn signature_suite_signature_size(s: SignatureSuite) -> ByteSize { match s { - EcdsaP256Sha256 => 64 + EcdsaP256Sha256 => byte_size(count: 64) } } @@ -86,24 +81,14 @@ type SignedMessage sole_constructor { type SignatureVerification = SignatureVerified { verified: SignedMessage } | SignatureInvalid { suite: SignatureSuite } - | SignatureMalformed { octets_declared: Int, octets_expected: Int } - | VerifyingKeyMalformed { octets_declared: Int, octets_expected: Int } + | SignatureMalformed { declared: ByteSize, expected: ByteSize } + | VerifyingKeyMalformed { declared: ByteSize, expected: ByteSize } | SignatureSuiteMismatch { key_suite: SignatureSuite, signature_suite: SignatureSuite } -fn signature_verification_succeeded(v: SignatureVerification) -> Bool { - match v { - SignatureVerified { verified: _ } => true - SignatureInvalid { suite: _ } => false - SignatureMalformed { octets_declared: _, octets_expected: _ } => false - VerifyingKeyMalformed { octets_declared: _, octets_expected: _ } => false - SignatureSuiteMismatch { key_suite: _, signature_suite: _ } => false - } -} - // NO BOUND IMPLEMENTATION YET, and that is declared rather than papered: extdeps.crypto.mac binds // RustCrypto's hmac through a host primitive; the ECDSA twin (RustCrypto p256 VerifyingKey::verify) // has no host primitive in this tree. The consumer that needs it is the /approve route's device -// signature check (gunbc.auth.approval_push); until the primitive lands no route may mint +// signature check (gunbc.auth.approval_device_redemption); until the primitive lands no route may mint // SignatureVerified from anything, so no redemption can claim a device. // // ── The realization seam ───────────────────────────────────────────────────────────────────── @@ -114,27 +99,21 @@ fn signature_verification_succeeded(v: SignatureVerification) -> Bool { // facts of the suite and not of the curve arithmetic. fn signature_verification_from_implementation( key: VerifyingKey, - key_octets: Int, + key_size: ByteSize, signature: SignatureBytes, - signature_octets: Int, + signature_size: ByteSize, message: NonEmptyStr, implementation_verified: Bool, ) -> SignatureVerification { if key.suite != signature.suite { SignatureSuiteMismatch { key_suite: key.suite, signature_suite: signature.suite } + } else if byte_size_count(b: key_size) != byte_size_count(b: signature_suite_public_key_size(s: key.suite)) { + VerifyingKeyMalformed { declared: key_size, expected: signature_suite_public_key_size(s: key.suite) } + } else if byte_size_count(b: signature_size) != byte_size_count(b: signature_suite_signature_size(s: signature.suite)) { + SignatureMalformed { declared: signature_size, expected: signature_suite_signature_size(s: signature.suite) } + } else if implementation_verified { + SignatureVerified { verified: SignedMessage { key: key, message: message } } } else { - if key_octets != signature_suite_public_key_octets(s: key.suite) { - VerifyingKeyMalformed { octets_declared: key_octets, octets_expected: signature_suite_public_key_octets(s: key.suite) } - } else { - if signature_octets != signature_suite_signature_octets(s: signature.suite) { - SignatureMalformed { octets_declared: signature_octets, octets_expected: signature_suite_signature_octets(s: signature.suite) } - } else { - if implementation_verified { - SignatureVerified { verified: SignedMessage { key: key, message: message } } - } else { - SignatureInvalid { suite: signature.suite } - } - } - } + SignatureInvalid { suite: signature.suite } } } diff --git a/dag/gunbc/auth/approval_device_redemption.dag b/dag/gunbc/auth/approval_device_redemption.dag index b8ecbb95acb..6280c16dae3 100644 --- a/dag/gunbc/auth/approval_device_redemption.dag +++ b/dag/gunbc/auth/approval_device_redemption.dag @@ -1,7 +1,8 @@ module gunbc.auth.approval_device_redemption -import std.types { NonEmptyStr, String, Timestamp, Int } +import std.types { NonEmptyStr, String, Timestamp, Int, List } import std.logic { Bool } +import std.measure { Second, second } import std.dissolution { DissolutionCondition, unbound_dissolution } import extdeps.crypto.signature { VerifyingKey, SignatureBytes, SignatureVerification, SignatureVerified } import extdeps.apple.apns { @@ -9,12 +10,18 @@ import extdeps.apple.apns { ApnsEnvironment, ApnsTopic, ApnsDeviceToken, ApnsRefusalClass, ApnsBadRequest, ApnsAuthenticationRefused, ApnsBadPath, ApnsBadMethod, ApnsTokenUnregistered, ApnsPayloadTooLarge, ApnsTooManyRequests, ApnsServerError, ApnsServiceUnavailable, ApnsStatusUnlisted, + ApnsCustomKey, } -import extdeps.apple.app_attest { AttestKeyId } +import extdeps.apple.app_attest { + AttestKeyId, AttestationVerification, AttestationVerified, AttestationRefused, AttestationRefusal, + AssertionVerification, AssertionVerified, AssertionRefused, AssertionRefusal, +} +import extdeps.apple.secure_enclave { EnclaveKeyAccessPolicy, BiometryCurrentSet } import extdeps.google.fcm { FcmProjectId, FcmRegistrationToken } import extdeps.android.key_attestation { AndroidAttestationChain } +import extdeps.crypto.mac { MacKey, MacVerified, mac_verify } import gunbc.auth.approval_capability { - ProposedDecision, ProposeApprove, ProposeDeny, proposed_decision_name, signing_field_separator, + ProposedDecision, ProposeApprove, ProposeDeny, proposed_decision_name, ApprovalCapability, ApprovalCapabilityClaims, CapabilityRefusal, CapabilityAuthentic, CapabilityRefused, approval_capability_signing_input, verify_capability, capability_refusal_reason, utc_instant_before, } @@ -26,69 +33,103 @@ import gunbc.auth.approval_decision_store { claims_for, commit_broker_decision, StoreWritten, StoreSlotOccupied, StoreRefused, approval_capability_expectation, approval_operator_login, } -import extdeps.crypto.mac { MacKey, MacVerified, mac_verify } import gunbc.secret_provision { SecretRef, fleet_secret_ref, secret_ref_pin_version } // ── What this module is ────────────────────────────────────────────────────────────────────── // The protocol between the roadmap server and the operator's phone for redeeming an approval with -// a device-bound, biometric-gated signature. ONE protocol, platform realizations beside it: iOS is -// built (extdeps.apple.*); Android is modeled at every platform point and realized by nothing yet -// (android_realization_frontier). Push is one peripheral here -- it only wakes the app. +// a device-bound, biometric-gated signature: one protocol, platform realizations beside it. This +// module is the MODEL; extdeps.apple.* and extdeps.google/android.* model the upstream interfaces. +// No realization exists yet: the iOS client, the server routes, the verifiers and the APNs publisher +// are each a frontier below (ios_realization_frontier, approval_device_routes_frontier, +// ecdsa_verification_realization_frontier), and Android is modeled at every platform point and +// realized by nothing (android_realization_frontier). // // WHY A DEVICE SIGNATURE. The loopback deployment cannot attribute Tailscale-User-Login to the proxy // (gunbc.auth.approval_decision_store approval_identity_attribution_frontier), so a capability-only // redemption records the capability's bearer. A signature under an enrolled key the enclave (or // Keystore) releases only on the operator's biometric attributes the decision to that enrolment -// without trusting anything on the socket. On the device route the signature is REQUIRED: a route -// that still admitted the capability alone would leave anonymous approval standing beside it. +// without trusting anything on the socket. On the device route the signature is REQUIRED. +// +// VERIFIED FACTS ARE SOLE-CONSTRUCTED. Every value that stands for "this was checked" -- a verified +// enrolment, an admitted redemption -- is minted by exactly one fold here, and every effect that +// depends on it (enrolment commit, decision commit) takes that value and nothing looser. A caller +// cannot reach the decision commit with raw claims and a login it chose. type MobilePlatform = MobileIos | MobileAndroid -// ── Identity evidence a redemption carries ─────────────────────────────────────────────────── +fn platform_wire(p: MobilePlatform) -> NonEmptyStr { + match p { + MobileIos => "ios" as NonEmptyStr + MobileAndroid => "android" as NonEmptyStr + } +} + +// ── One injective byte rendering ───────────────────────────────────────────────────────────── +// EVERY SIGNED OR MAC'D MESSAGE IN THIS PROTOCOL IS LENGTH-FRAMED: each field is rendered +// ":," where n is the field's length in Unicode code points (string_length here, +// String.unicodeScalars.count in Swift). A plain separator join is not injective when a field may +// contain the separator -- and one field here, capability_text, is itself a unit-separator join -- +// so two different field lists could render to the same bytes and one signature would stand for +// both. Framing makes the rendering injective for ANY field content, with no alphabet restriction to +// enforce on ids, request text or capability text. +fn framed_field(f: String) -> String { + join([to_string(string_length(s: f)), ":", f, ","], "") +} + +fn framed(fields: List) -> NonEmptyStr { + join(map(fields, f => framed_field(f: f)), "") as NonEmptyStr +} + +// ── Identity evidence ──────────────────────────────────────────────────────────────────────── // Named by MECHANISM. The two Legacy arms are what the existing /approve route admits today; they // exist exactly as long as that route and the ntfy loop do, and are deleted at cutover -// (approval_device_cutover_frontier). The device route admits only EnrolledDeviceProof. +// (approval_device_cutover_frontier). The device arm carries the sole-constructed enrolment, so +// "names a person" cannot be claimed by picking a constructor. type RedemptionIdentityEvidence - = EnrolledDeviceProof { - enrollment_id: NonEmptyStr - operator_login: NonEmptyStr - platform: MobilePlatform - decision_key: VerifyingKey - } + = EnrolledDeviceProof { enrollment: VerifiedDeviceEnrollment } | LegacyTailnetProxyHeader { presented_login: NonEmptyStr } | LegacyCapabilityBearer { residual: NonEmptyStr } -fn identity_evidence_names_a_person(e: RedemptionIdentityEvidence) -> Bool { - match e { - EnrolledDeviceProof { enrollment_id: _, operator_login: _, platform: _, decision_key: _ } => true - LegacyTailnetProxyHeader { presented_login: _ } => true - LegacyCapabilityBearer { residual: _ } => false - } -} - // ── Enrolment ──────────────────────────────────────────────────────────────────────────────── -// The operator's explicit act on one phone, admitted by a ONE-TIME CODE the dashboard shows. The -// operator login is DERIVED BY THE SERVER from the code's issuance, never supplied by the app: the -// code was issued to a login, so redeeming it enrols a key for that login and no other. +// The operator's explicit act on one phone, admitted by a ONE-TIME CODE the dashboard issues. The +// login is DERIVED BY THE SERVER from the code's issuance record, never supplied by the app. // -// THE RESIDUAL, stated rather than claimed away: the dashboard binds loopback behind tailscale -// serve, so no off-tailnet caller can reach the code-issuing route, but an on-host process under -// another POSIX user can. The code's authority is therefore "whoever could reach the dashboard on -// srv1 within the code's lifetime", the same boundary approval_identity_attribution_frontier names. -data enrolment_code_residual_note: NonEmptyStr = "The one-time enrolment code is reachable only through the dashboard, which binds loopback behind tailscale serve: off-tailnet callers are excluded, on-host POSIX users are not. A short lifetime and single use bound the exposure; peer attribution at the serve boundary (approval_identity_attribution_frontier) is what would close it." +// THE CODE IS NOT OPERATOR-ONLY, AND THIS MODULE DOES NOT CLAIM IT IS. The dashboard binds loopback +// behind tailscale serve: off-tailnet callers cannot reach the issuing route -- but an on-host +// process under another POSIX user can reach loopback. So the code's authority is "whoever could +// reach the dashboard on srv1 within the code's lifetime". Single use and a short lifetime bound it; +// peer attribution at the serve boundary (approval_identity_attribution_frontier) is what would make +// it operator-only. +data enrolment_code_residual_note: NonEmptyStr = "The one-time enrolment code is issued on the dashboard, which binds loopback behind tailscale serve: off-tailnet callers are excluded, on-host POSIX users are not. It is single-use and short-lived; it is not operator-only until peer attribution at the serve boundary lands (approval_identity_attribution_frontier)." data approval_enrolment_protocol: NonEmptyStr = "gunbc.approval-enrolment.v1" as NonEmptyStr +// THE CODE IS THE IDENTIFIER: the operator types one thing, so a separate id would be a second key +// for one fact. It also names the enrolment it produces (enrollment_id_for_code), so issuance, +// consumption and the enrolment are ONE durable slot and consuming the code IS creating the +// enrolment -- one atomic transition, no second slot to disagree with. type EnrolmentChallenge { - challenge_id: NonEmptyStr code: NonEmptyStr issued_to_login: NonEmptyStr issued_at: Timestamp expires_at: Timestamp } +fn enrollment_id_for_code(code: NonEmptyStr) -> NonEmptyStr { + join(["enr-", code as String], "") as NonEmptyStr +} + +// The standing of one code's slot: issued and waiting, consumed into an enrolment, or that +// enrolment revoked -- generations 1, 2 and 3 of one CAS slot -- or not readable as any of them. +type EnrolmentSlotStanding + = EnrolmentCodeUnspent { challenge: EnrolmentChallenge } + | EnrolmentCodeConsumed { enrollment: VerifiedDeviceEnrollment } + | EnrolmentRevoked { enrollment: VerifiedDeviceEnrollment, revoked_at: Timestamp, reason: NonEmptyStr } + | EnrolmentCodeUnknown + | EnrolmentSlotUnreadable { detail: NonEmptyStr } + // Push registration is SEPARATE from enrolment: a token rotates (reinstall, restore, APNs 410) // without changing who is enrolled or which key decides, and revoking an enrolment must end its // authority whatever push tokens still exist. @@ -96,80 +137,136 @@ type PushRegistration = ApnsRegistration { environment: ApnsEnvironment, topic: ApnsTopic, token: ApnsDeviceToken } | FcmRegistration { project: FcmProjectId, token: FcmRegistrationToken } -// What establishes the decision key belongs to a genuine app instance on genuine hardware. iOS: an -// App Attest attestation whose clientDataHash is SHA-256 of enrolment_transcript (the App Attest key -// is not the decision key -- extdeps.apple.app_attest). Android: a Keystore attestation chain for -// the decision key itself, whose attestation challenge is the same transcript. -type PlatformEnrollmentEvidence - = IosAppAttestBoundDecisionKey { attest_key_id: AttestKeyId, attestation_b64: NonEmptyStr } - | AndroidAttestedDecisionKey { chain: AndroidAttestationChain } - -fn platform_of_evidence(e: PlatformEnrollmentEvidence) -> MobilePlatform { - match e { - IosAppAttestBoundDecisionKey { attest_key_id: _, attestation_b64: _ } => MobileIos - AndroidAttestedDecisionKey { chain: _ } => MobileAndroid - } -} +// What the app PRESENTS at enrolment: raw, unverified. +type PresentedEnrollmentEvidence + = IosAppAttestAttestation { attest_key_id: AttestKeyId, attestation_b64: NonEmptyStr } + | AndroidKeyAttestationChain { chain: AndroidAttestationChain } -type EnrollmentStanding - = EnrollmentActive - | EnrollmentRevoked { revoked_at: Timestamp, reason: NonEmptyStr } +// What the server KEEPS once verified: the facts later checks need, never the raw object. iOS keeps +// the App Attest key's id and public key (every later assertion verifies under it) and the receipt +// (Apple's fraud-metric handle). Android's attestation certifies the decision key itself. +type VerifiedPlatformEvidence + = IosAttestedAppInstance { attest_key_id: AttestKeyId, attest_public_key_b64url: NonEmptyStr, receipt_b64: NonEmptyStr } + | AndroidAttestedDecisionKey -type DeviceEnrollment { +// THE DECISION KEY'S ACCESS POLICY, one fact the iOS realization creates the key under: a changed +// biometric set invalidates it, so a face added to the operator's phone inherits no authority. +data approval_decision_key_access_policy: EnclaveKeyAccessPolicy = BiometryCurrentSet + +type VerifiedDeviceEnrollment sole_constructor { enrollment_id: NonEmptyStr operator_login: NonEmptyStr + platform: MobilePlatform decision_key: VerifyingKey - evidence: PlatformEnrollmentEvidence - standing: EnrollmentStanding + evidence: VerifiedPlatformEvidence + enrolled_at: Timestamp } -// The bytes the platform attestation commits to. ONE rendering, joined by the capability's unit -// separator so no field can forge a boundary. It binds the server's challenge (so a replayed -// attestation cannot enrol under a fresh code) to the decision key (so a leaked code cannot enrol a -// key the app did not attest alongside it). The login is not here: the server derives it from the -// challenge, and an app-supplied login would be a claim nobody checks. -fn enrolment_transcript(challenge: EnrolmentChallenge, decision_key: VerifyingKey, platform: MobilePlatform) -> NonEmptyStr { - join( - [ - approval_enrolment_protocol as String, - challenge.challenge_id as String, - challenge.code as String, - platform_wire(p: platform) as String, - decision_key.point_b64url as String, - ], - signing_field_separator, - ) as NonEmptyStr +// The bytes the platform attestation commits to (App Attest: SHA-256 of these is the +// clientDataHash; Android: the attestation challenge). It binds the server-issued code (so a +// replayed attestation cannot enrol under a fresh code) to the decision key (so a leaked code cannot +// enrol a key the app did not attest alongside it). The login is not here: the server derives it. +fn enrolment_transcript(code: NonEmptyStr, decision_key: VerifyingKey, platform: MobilePlatform) -> NonEmptyStr { + framed(fields: [ + approval_enrolment_protocol as String, + code as String, + platform_wire(p: platform) as String, + decision_key.point_b64url as String, + ]) } -fn platform_wire(p: MobilePlatform) -> NonEmptyStr { - match p { - MobileIos => "ios" as NonEmptyStr - MobileAndroid => "android" as NonEmptyStr +type EnrolmentRefusal + = EnrolmentCodeNotIssued + | EnrolmentCodeAlreadyUsed + | EnrolmentCodeExpired { expires_at: Timestamp, observed_at: Timestamp } + | EnrolmentStoreUnreadable { detail: NonEmptyStr } + | EnrolmentAttestationRefused { cause: AttestationRefusal } + | EnrolmentAttestationForOtherBytes + | EnrolmentAndroidUnrealized + +type EnrolmentAdmission + = EnrolmentAdmitted { enrollment: VerifiedDeviceEnrollment } + | EnrolmentRefused { cause: EnrolmentRefusal } + +// THE ONE FOLD THAT MINTS AN ENROLMENT. The code must be unspent and live; the platform evidence +// must be verified, over exactly this code's transcript; the login comes from the code's issuance. +// Android has no verifier yet, so an Android enrolment is REFUSED rather than admitted unverified. +fn enrolment_admission( + slot: EnrolmentSlotStanding, + platform: MobilePlatform, + decision_key: VerifyingKey, + attestation: AttestationVerification, + observed_at: Timestamp, +) -> EnrolmentAdmission { + match slot { + EnrolmentCodeUnknown => EnrolmentRefused { cause: EnrolmentCodeNotIssued } + EnrolmentSlotUnreadable { detail: d } => EnrolmentRefused { cause: EnrolmentStoreUnreadable { detail: d } } + EnrolmentCodeConsumed { enrollment: _ } => EnrolmentRefused { cause: EnrolmentCodeAlreadyUsed } + EnrolmentRevoked { enrollment: _, revoked_at: _, reason: _ } => EnrolmentRefused { cause: EnrolmentCodeAlreadyUsed } + EnrolmentCodeUnspent { challenge: c } => + if !utc_instant_before(a: observed_at, b: c.expires_at) { + EnrolmentRefused { cause: EnrolmentCodeExpired { expires_at: c.expires_at, observed_at: observed_at } } + } else { + match platform { + MobileAndroid => EnrolmentRefused { cause: EnrolmentAndroidUnrealized } + MobileIos => + match attestation { + AttestationRefused { cause: r } => EnrolmentRefused { cause: EnrolmentAttestationRefused { cause: r } } + AttestationVerified { verified: v } => + if v.client_data != enrolment_transcript(code: c.code, decision_key: decision_key, platform: MobileIos) { + EnrolmentRefused { cause: EnrolmentAttestationForOtherBytes } + } else { + EnrolmentAdmitted { + enrollment: VerifiedDeviceEnrollment { + enrollment_id: enrollment_id_for_code(code: c.code), + operator_login: c.issued_to_login, + platform: MobileIos, + decision_key: decision_key, + evidence: IosAttestedAppInstance { attest_key_id: v.key_id, attest_public_key_b64url: v.public_key_point_b64url, receipt_b64: v.receipt_b64 }, + enrolled_at: observed_at, + } + } + } + } + } + } } } // ── The push ───────────────────────────────────────────────────────────────────────────────── -// THE PUSH WAKES; IT DOES NOT CARRY WHAT IS DECIDED. It holds one opaque, non-authorizing locator. -// The app fetches the pending request from the roadmap server over the tailnet, displays THAT, and -// signs over it. Two reasons, each sufficient: the approve/deny capabilities are bearer credentials -// that must not transit or rest at APNs or FCM; and anything rendered from the payload is a second -// copy of the request that can drift from the revision being authorized. The alert text is generic -// ("Approval requested") because a lock screen shows it. Push is an accelerator: the app also lists -// pending approvals on open, so a lost push costs latency, never a decision. +// THE PUSH WAKES; IT DOES NOT CARRY WHAT IS DECIDED. One opaque, non-authorizing locator, carried as +// a top-level APNs custom key beside aps. The capabilities are bearer credentials that must not +// transit or rest at APNs or FCM, and anything rendered from the payload would be a second copy of +// the request. The alert is generic. The app also lists pending approvals on open, so a lost push +// costs latency, never a decision. type ApprovalPushHint { notification_id: NonEmptyStr } -// ── Redemption ─────────────────────────────────────────────────────────────────────────────── -// The server issues a fresh challenge with every fetched request; the device signs over it, the -// exact stored request it displayed, the chosen verb and the exact capability it redeems. The -// server owns decided_at -- a client clock can neither backdate a decision nor extend an expiry. -data approval_redemption_protocol: NonEmptyStr = "gunbc.approval-redemption.v1" as NonEmptyStr +data approval_push_alert_title: NonEmptyStr = "Approval requested" as NonEmptyStr + +fn approval_push_custom_keys(h: ApprovalPushHint) -> List { + [ApnsCustomKey { key: "notification_id", value: h.notification_id }] +} + +// ── Authenticated reads ────────────────────────────────────────────────────────────────────── +// THE FETCH RETURNS BEARER CAPABILITIES, so it is authenticated, not merely hidden behind an opaque +// push. The app proves it is the enrolled app instance with an App Attest assertion (no Face ID -- +// reading is not deciding) over a framed read request naming the path, the enrolment and the app's +// claimed time; the server admits it only inside a short skew window and only for an ACTIVE iOS +// enrolment. Residual, stated: the counter is not stored (see PlatformRedemptionProof), so a +// captured read request replays inside its window -- over the tailnet's TLS -- and would disclose a +// capability that, until cutover, the legacy bearer route still honours. Cutover closes it. +data approval_device_read_protocol: NonEmptyStr = "gunbc.approval-device-read.v1" as NonEmptyStr +data approval_device_read_skew: Second = second(count: 60) + +fn device_read_client_data(path: NonEmptyStr, enrollment_id: NonEmptyStr, requested_at: Timestamp) -> NonEmptyStr { + framed(fields: [approval_device_read_protocol as String, path as String, enrollment_id as String, requested_at]) +} -// STATELESS: nonce_hex is the server's MAC, under the capability key it already holds, over -// redemption_challenge_message. Nothing is stored to issue it and nothing to check it -- the server -// recomputes. Replay needs no challenge ledger: the decision slot's CAS admits one decision per -// escalation, and the App Attest counter refuses a replayed assertion. +// ── Redemption ─────────────────────────────────────────────────────────────────────────────── +// STATELESS CHALLENGE: nonce_hex is the server's MAC, under the capability key it already holds, +// over redemption_challenge_message. Nothing is stored to issue it or to check it. type RedemptionChallenge { expires_at: Timestamp nonce_hex: NonEmptyStr @@ -178,23 +275,23 @@ type RedemptionChallenge { data approval_redemption_challenge_protocol: NonEmptyStr = "gunbc.approval-redemption-challenge.v1" as NonEmptyStr fn redemption_challenge_message(escalation_id: NonEmptyStr, request_revision: NonEmptyStr, enrollment_id: NonEmptyStr, expires_at: Timestamp) -> NonEmptyStr { - join( - [ - approval_redemption_challenge_protocol as String, - escalation_id as String, - request_revision as String, - enrollment_id as String, - expires_at, - ], - signing_field_separator, - ) as NonEmptyStr + framed(fields: [ + approval_redemption_challenge_protocol as String, + escalation_id as String, + request_revision as String, + enrollment_id as String, + expires_at, + ]) } +data approval_redemption_protocol: NonEmptyStr = "gunbc.approval-redemption.v1" as NonEmptyStr +data approval_device_audience: NonEmptyStr = "gunbc.roadmap_serve/device-redemption" as NonEmptyStr + // stored_request_text is gunbc.auth.approval_decision_store stored_request_json of the record the // app displayed, byte for byte; capability_text is approval_capability_signing_input of the -// capability selected. Both are the existing canonical renderings, so the device signs no third -// spelling of either -- and when the stored request gains its typed target -// (approval_target_frontier) the signature covers it with no change here. +// capability for the chosen verb; capability_tag_hex is that capability's MAC tag in the lowercase +// hex the existing /approve segment and mac_verify both use. The server re-renders all three from +// the STORED request and refuses any disagreement, so the device signs no third spelling. type DeviceRedemptionSigningInput { audience: NonEmptyStr enrollment_id: NonEmptyStr @@ -204,31 +301,32 @@ type DeviceRedemptionSigningInput { stored_request_text: NonEmptyStr decision: ProposedDecision capability_text: NonEmptyStr - capability_tag: NonEmptyStr + capability_tag_hex: NonEmptyStr } fn device_redemption_signing_input(i: DeviceRedemptionSigningInput) -> NonEmptyStr { - join( - [ - approval_redemption_protocol as String, - i.audience as String, - i.enrollment_id as String, - i.challenge.expires_at, - i.challenge.nonce_hex as String, - i.escalation_id as String, - i.request_revision as String, - i.stored_request_text as String, - proposed_decision_name(d: i.decision) as String, - i.capability_text as String, - i.capability_tag as String, - ], - signing_field_separator, - ) as NonEmptyStr + framed(fields: [ + approval_redemption_protocol as String, + i.audience as String, + i.enrollment_id as String, + i.challenge.expires_at, + i.challenge.nonce_hex as String, + i.escalation_id as String, + i.request_revision as String, + i.stored_request_text as String, + proposed_decision_name(d: i.decision) as String, + i.capability_text as String, + i.capability_tag_hex as String, + ]) } -// Per-redemption platform proof beside the decision signature. iOS re-establishes the app instance -// with an App Attest assertion over the same signing input (its counter refuses replay). Android's -// Keystore attestation already bound the decision key at enrolment, so the signature is the proof. +// Per-redemption platform proof. iOS: an App Attest assertion whose client data is EXACTLY the +// signing input. THE ASSERTION COUNTER IS NOT STORED, a stated departure from Apple's recommended +// flow: Apple uses it to refuse replay, and a redemption replay is already refused twice -- the +// challenge is server-minted, expiring and bound to one escalation revision, and the decision slot +// admits one decision per escalation. Storing it would add one CAS generation per approval against +// the store's generation probe bound, for no refusal the route lacks. (Reads are the exception, and +// their residual is stated at device_read_client_data.) type PlatformRedemptionProof = IosAppAttestAssertion { assertion_b64: NonEmptyStr } | AndroidDecisionKeyOnly @@ -241,7 +339,7 @@ type SignedRedemption { // ── Push provider outcome ──────────────────────────────────────────────────────────────────── // Named for what a provider can establish: it ACCEPTED the message. Not delivered, not displayed, -// not read, never consent. retryable is Apple's reading of its own status, not a policy here. +// never consent. retryable is Apple's reading of its own status, not a policy here. type PushProvider = ProviderApns | ProviderFcm @@ -284,32 +382,23 @@ fn push_outcome_of_apns(o: ApnsSendOutcome) -> PushProviderOutcome { } // ── The device routes ──────────────────────────────────────────────────────────────────────── -// ONE SPELLING OF EACH PATH, read by the server's route table and checked by the app's fixture, so -// neither side coins its own. All four sit under /approve/device beside the existing /approve -// route, which they do not touch. +// ONE SPELLING OF EACH PATH, read by the server's route table and by the app, so neither coins its +// own. All sit under /approve/device beside the existing /approve route, which they do not touch. data approval_device_enrol_path: NonEmptyStr = "/approve/device/enrol" as NonEmptyStr data approval_device_pending_path: NonEmptyStr = "/approve/device/pending" as NonEmptyStr data approval_device_request_path_prefix: NonEmptyStr = "/approve/device/requests/" as NonEmptyStr data approval_device_redeem_path: NonEmptyStr = "/approve/device/redeem" as NonEmptyStr -// ── Redemption over the store ──────────────────────────────────────────────────────────────── +// ── Redemption admission ───────────────────────────────────────────────────────────────────── // THE DEVICE ROUTE'S FOLD, BESIDE redeem_over_store AND REUSING ITS PARTS: the same slot read -// (observe_escalation), the same claims (claims_for), the same capability verification -// (verify_capability) and the same generation-2 commit (commit_broker_decision). Nothing in the -// store or broker changes; this route differs only in WHAT ADMITS -- a verified device signature -// under an active enrolment of the expected operator, never the capability alone -- and so in what -// decided_by can name. -// -// ORDER, each stage narrowing what the next may assume: the enrolment must be active and name the -// expected operator; the escalation must be filed and pending; the signed bytes must be exactly the -// server's own renderings of THAT request, THAT capability and a live challenge this server minted; -// the capability must verify; the device signature must verify over those bytes under the enrolled -// key; the platform proof must verify over the same bytes. Only then is the decision committed, with -// decided_at the server's clock. The verifier verdicts arrive as parameters because verification is -// the bound realization's (ecdsa_verification_realization_frontier); this fold's job is the joins. +// (observe_escalation), claims (claims_for), capability verification (verify_capability) and +// generation-2 commit (commit_broker_decision). Nothing in the store or broker changes; this route +// differs only in WHAT ADMITS -- a verified device signature and platform proof under a verified, +// active enrolment of the expected operator -- and so in what decided_by names. type DeviceRedemptionOutcome = DeviceRedeemed { decision: BrokerDecision } | DeviceAlreadyDecided { decision: BrokerDecision } + | DeviceEnrollmentUnknown | DeviceEnrollmentRevoked { enrollment_id: NonEmptyStr } | DeviceEnrollmentForAnotherOperator { enrolled: NonEmptyStr, expected: NonEmptyStr } | DeviceEscalationNotFiled { escalation_id: NonEmptyStr } @@ -320,7 +409,10 @@ type DeviceRedemptionOutcome | DeviceChallengeNotIssuedHere | DeviceCapabilityRefused { cause: CapabilityRefusal } | DeviceSignatureRefused { verification: SignatureVerification } - | DevicePlatformProofRefused { detail: NonEmptyStr } + | DevicePlatformProofRefused { cause: AssertionRefusal } + | DevicePlatformProofForOtherBytes + | DevicePlatformProofWrongKey + | DevicePlatformUnrealized | DeviceLostTheRace | DeviceCommitRefused { detail: NonEmptyStr } @@ -331,6 +423,7 @@ fn device_redemption_reason(o: DeviceRedemptionOutcome) -> NonEmptyStr { BrokerDenied { escalation_id: _, revision: _, decided_by: _, decided_at: _, reason: _ } => "denied and recorded" as NonEmptyStr } DeviceAlreadyDecided { decision: _ } => "this escalation was already decided; nothing changed" as NonEmptyStr + DeviceEnrollmentUnknown => "this device is not enrolled" as NonEmptyStr DeviceEnrollmentRevoked { enrollment_id: _ } => "this device's enrolment has been revoked" as NonEmptyStr DeviceEnrollmentForAnotherOperator { enrolled: _, expected: _ } => "this device is enrolled for a different operator than the one this request names" as NonEmptyStr DeviceEscalationNotFiled { escalation_id: _ } => "no request is filed under this escalation" as NonEmptyStr @@ -341,17 +434,34 @@ fn device_redemption_reason(o: DeviceRedemptionOutcome) -> NonEmptyStr { DeviceChallengeNotIssuedHere => "the challenge was not issued by this server" as NonEmptyStr DeviceCapabilityRefused { cause: c } => capability_refusal_reason(r: c) as NonEmptyStr DeviceSignatureRefused { verification: _ } => "the device signature did not verify under the enrolled key" as NonEmptyStr - DevicePlatformProofRefused { detail: d } => d + DevicePlatformProofRefused { cause: _ } => "the app-instance assertion did not verify" as NonEmptyStr + DevicePlatformProofForOtherBytes => "the app-instance assertion was made over different bytes" as NonEmptyStr + DevicePlatformProofWrongKey => "the app-instance assertion is not from the enrolled app instance" as NonEmptyStr + DevicePlatformUnrealized => "this platform has no verifier yet" as NonEmptyStr DeviceLostTheRace => "another decision landed first; this one changed nothing" as NonEmptyStr DeviceCommitRefused { detail: d } => d } } -// The server's own rendering of what the device must have signed, from the STORED request and the -// server's key. A device that signed anything else signed for another request. -fn expected_signing_fields_agree( +// THE ADMITTED REDEMPTION: minted only by device_redemption_admission, consumed only by +// commit_admitted_redemption. It carries the claims the capability authenticated and the login the +// verified enrolment names -- nothing a caller of the commit could supply. +type AdmittedDeviceRedemption sole_constructor { + claims: ApprovalCapabilityClaims + enrollment_id: NonEmptyStr + decided_by: NonEmptyStr + execute_by: Timestamp + observed_at: Timestamp +} + +type DeviceAdmission + = DeviceAdmitted { admitted: AdmittedDeviceRedemption } + | DeviceNotAdmitted { outcome: DeviceRedemptionOutcome } + +// The server's own rendering of what the device must have signed, from the STORED request. +fn signing_field_disagreement( signed: DeviceRedemptionSigningInput, - enrollment: DeviceEnrollment, + enrollment: VerifiedDeviceEnrollment, request: StoredApprovalRequest, capability_text: NonEmptyStr, ) -> String { @@ -364,61 +474,53 @@ fn expected_signing_fields_agree( else { "" } } -data approval_device_audience: NonEmptyStr = "gunbc.roadmap_serve/device-redemption" as NonEmptyStr +// THE SIGNATURE JOINS only when the verified evidence names the ENROLLED key and EXACTLY the bytes +// this server rendered. A verified signature under another key, or over another message, admits +// nothing. +fn device_signature_joins(signature: SignatureVerification, enrollment: VerifiedDeviceEnrollment, signed_bytes: NonEmptyStr) -> Bool { + match signature { + SignatureVerified { verified: v } => + v.message == signed_bytes && v.key.point_b64url == enrollment.decision_key.point_b64url && v.key.suite == enrollment.decision_key.suite + _ => false + } +} -fn redeem_device_over_store( - root: NonEmptyStr, - keyring: ApprovalKeyringRead, - enrollment: DeviceEnrollment, - redemption: SignedRedemption, - signature: SignatureVerification, - platform: PlatformProofVerification, - observed_at: Timestamp, -) -> DeviceRedemptionOutcome { - let signed = redemption.signing_input - match enrollment.standing { - EnrollmentRevoked { revoked_at: _, reason: _ } => DeviceEnrollmentRevoked { enrollment_id: enrollment.enrollment_id } - EnrollmentActive => - if enrollment.operator_login != approval_operator_login { - DeviceEnrollmentForAnotherOperator { enrolled: enrollment.operator_login, expected: approval_operator_login } - } else { - match observe_escalation(root: root, escalation_id: signed.escalation_id) { - EscalationNotFiled => DeviceEscalationNotFiled { escalation_id: signed.escalation_id } - EscalationUnreadable { detail: d } => DeviceStoreUnreadable { detail: d } - EscalationDecided { decision: d } => DeviceAlreadyDecided { decision: d } - EscalationPending { request: req } => - match keyring { - ApprovalKeyringRefused { cause: c } => DeviceKeyringUnavailable { cause: c } - ApprovalKeyLoaded { key: key } => - redeem_pending_with_key(root: root, key: key, enrollment: enrollment, request: req, redemption: redemption, signature: signature, platform: platform, observed_at: observed_at) - } - } +// THE PLATFORM PROOF JOINS to the enrolment's own verified evidence: an iOS assertion must be from +// the enrolled App Attest key and over exactly the signed bytes. Android has no verifier, so it +// refuses rather than passing on the signature alone. +fn platform_proof_disagreement(enrollment: VerifiedDeviceEnrollment, assertion: AssertionVerification, signed_bytes: NonEmptyStr) -> DeviceRedemptionOutcome? { + match enrollment.evidence { + AndroidAttestedDecisionKey => Present { value: DevicePlatformUnrealized } + IosAttestedAppInstance { attest_key_id: k, attest_public_key_b64url: _, receipt_b64: _ } => + match assertion { + AssertionRefused { cause: c } => Present { value: DevicePlatformProofRefused { cause: c } } + AssertionVerified { verified: v } => + if v.key_id != k { Present { value: DevicePlatformProofWrongKey } } + else if v.client_data != signed_bytes { Present { value: DevicePlatformProofForOtherBytes } } + else { Absent } } } } -// EVERY GATE, AND NO EFFECT. Split from the commit so that the whole admission -- including the -// accepting path -- is decidable without a store, and so that the commit cannot be reached except -// through DeviceAdmitted. -type DeviceAdmission - = DeviceAdmitted { claims: ApprovalCapabilityClaims } - | DeviceNotAdmitted { outcome: DeviceRedemptionOutcome } - +// EVERY GATE, AND NO EFFECT: decidable without a store, including the accepting path. fn device_redemption_admission( key: MacKey, - enrollment: DeviceEnrollment, + enrollment: VerifiedDeviceEnrollment, request: StoredApprovalRequest, redemption: SignedRedemption, signature: SignatureVerification, - platform: PlatformProofVerification, + assertion: AssertionVerification, observed_at: Timestamp, ) -> DeviceAdmission { let signed = redemption.signing_input + let signed_bytes = device_redemption_signing_input(i: signed) let claims = claims_for(request: request, decision: signed.decision) let capability_text = approval_capability_signing_input(c: claims) - let mismatch = expected_signing_fields_agree(signed: signed, enrollment: enrollment, request: request, capability_text: capability_text) - if mismatch != "" { - DeviceNotAdmitted { outcome: DeviceSignedForAnotherRequest { field: mismatch as NonEmptyStr } } + let disagreement = signing_field_disagreement(signed: signed, enrollment: enrollment, request: request, capability_text: capability_text) + if enrollment.operator_login != approval_operator_login { + DeviceNotAdmitted { outcome: DeviceEnrollmentForAnotherOperator { enrolled: enrollment.operator_login, expected: approval_operator_login } } + } else if disagreement != "" { + DeviceNotAdmitted { outcome: DeviceSignedForAnotherRequest { field: disagreement as NonEmptyStr } } } else if !utc_instant_before(a: observed_at, b: signed.challenge.expires_at) { DeviceNotAdmitted { outcome: DeviceChallengeExpired { expires_at: signed.challenge.expires_at, observed_at: observed_at } } } else { @@ -426,20 +528,25 @@ fn device_redemption_admission( match mac_verify(key: key, message: challenge_message, tag_hex: signed.challenge.nonce_hex) { MacVerified { verified: _ } => match verify_capability( - capability: ApprovalCapability { claims: claims, tag_b64url: signed.capability_tag }, + capability: ApprovalCapability { claims: claims, tag_b64url: signed.capability_tag_hex }, expectation: approval_capability_expectation, - mac_result: mac_verify(key: key, message: capability_text, tag_hex: signed.capability_tag), + mac_result: mac_verify(key: key, message: capability_text, tag_hex: signed.capability_tag_hex), observed_at: observed_at, ) { CapabilityRefused { cause: c } => DeviceNotAdmitted { outcome: DeviceCapabilityRefused { cause: c } } CapabilityAuthentic { claims: cl } => - match device_signature_joins(signature: signature, enrollment: enrollment, signed_bytes: device_redemption_signing_input(i: signed)) { - DeviceSignatureDoesNotJoin => DeviceNotAdmitted { outcome: DeviceSignatureRefused { verification: signature } } - DeviceSignatureJoins => - match platform { - PlatformProofRefused { detail: d } => DeviceNotAdmitted { outcome: DevicePlatformProofRefused { detail: d } } - PlatformProofVerified => DeviceAdmitted { claims: cl } + if !device_signature_joins(signature: signature, enrollment: enrollment, signed_bytes: signed_bytes) { + DeviceNotAdmitted { outcome: DeviceSignatureRefused { verification: signature } } + } else { + match platform_proof_disagreement(enrollment: enrollment, assertion: assertion, signed_bytes: signed_bytes) { + Present { value: o } => DeviceNotAdmitted { outcome: o } + Absent => DeviceAdmitted { + admitted: AdmittedDeviceRedemption { + claims: cl, enrollment_id: enrollment.enrollment_id, decided_by: enrollment.operator_login, + execute_by: request.expires_at, observed_at: observed_at, + } } + } } } _ => DeviceNotAdmitted { outcome: DeviceChallengeNotIssuedHere } @@ -447,56 +554,16 @@ fn device_redemption_admission( } } -fn redeem_pending_with_key( - root: NonEmptyStr, - key: MacKey, - enrollment: DeviceEnrollment, - request: StoredApprovalRequest, - redemption: SignedRedemption, - signature: SignatureVerification, - platform: PlatformProofVerification, - observed_at: Timestamp, -) -> DeviceRedemptionOutcome { - match device_redemption_admission(key: key, enrollment: enrollment, request: request, redemption: redemption, signature: signature, platform: platform, observed_at: observed_at) { - DeviceNotAdmitted { outcome: o } => o - DeviceAdmitted { claims: cl } => commit_device_decision(root: root, claims: cl, request: request, decided_by: enrollment.operator_login, observed_at: observed_at) - } -} - -// THE SIGNATURE JOINS only when the verified evidence names the ENROLLED key and EXACTLY the bytes -// this server rendered -- the same message-and-key join extdeps.crypto.signature SignedMessage exists -// for. A verified signature under some other key, or over some other message, admits nothing. -type DeviceSignatureJoin - = DeviceSignatureJoins - | DeviceSignatureDoesNotJoin - -fn device_signature_joins(signature: SignatureVerification, enrollment: DeviceEnrollment, signed_bytes: NonEmptyStr) -> DeviceSignatureJoin { - match signature { - SignatureVerified { verified: v } => - if v.message == signed_bytes && v.key.point_b64url == enrollment.decision_key.point_b64url && v.key.suite == enrollment.decision_key.suite { - DeviceSignatureJoins - } else { - DeviceSignatureDoesNotJoin - } - _ => DeviceSignatureDoesNotJoin - } -} - -// The platform proof's verdict, reduced to what this fold consumes. The realization builds it from -// extdeps.apple.app_attest AssertionVerified (checking its client_data equals the signed bytes and -// storing its counter) or, on Android, from the signature alone. -type PlatformProofVerification - = PlatformProofVerified - | PlatformProofRefused { detail: NonEmptyStr } - -fn commit_device_decision(root: NonEmptyStr, claims: ApprovalCapabilityClaims, request: StoredApprovalRequest, decided_by: NonEmptyStr, observed_at: Timestamp) -> DeviceRedemptionOutcome { - let decision = match claims.decision { +// THE ONLY DECISION COMMIT ON THIS ROUTE, and it takes the admitted value -- not claims, not a login. +fn commit_admitted_redemption(root: NonEmptyStr, admitted: AdmittedDeviceRedemption) -> DeviceRedemptionOutcome { + let cl = admitted.claims + let decision = match cl.decision { ProposeApprove => BrokerApproved { - escalation_id: claims.escalation_id, revision: claims.request_revision, decided_by: decided_by, decided_at: observed_at, - obligation: ExecutionObligation { authority: claims.authority, intent_revision: claims.request_revision, execute_by: request.expires_at }, + escalation_id: cl.escalation_id, revision: cl.request_revision, decided_by: admitted.decided_by, decided_at: admitted.observed_at, + obligation: ExecutionObligation { authority: cl.authority, intent_revision: cl.request_revision, execute_by: admitted.execute_by }, } ProposeDeny => BrokerDenied { - escalation_id: claims.escalation_id, revision: claims.request_revision, decided_by: decided_by, decided_at: observed_at, + escalation_id: cl.escalation_id, revision: cl.request_revision, decided_by: admitted.decided_by, decided_at: admitted.observed_at, reason: "denied by the operator on the enrolled device", } } @@ -507,6 +574,41 @@ fn commit_device_decision(root: NonEmptyStr, claims: ApprovalCapabilityClaims, r } } +// THE ROUTE'S FOLD: the enrolment standing is read from the store by the realization and passed in +// as its slot standing, so revocation is the store's fact, not a field a caller sets. +fn redeem_device_over_store( + root: NonEmptyStr, + keyring: ApprovalKeyringRead, + enrolment: EnrolmentSlotStanding, + redemption: SignedRedemption, + signature: SignatureVerification, + assertion: AssertionVerification, + observed_at: Timestamp, +) -> DeviceRedemptionOutcome { + let signed = redemption.signing_input + match enrolment { + EnrolmentRevoked { enrollment: e, revoked_at: _, reason: _ } => DeviceEnrollmentRevoked { enrollment_id: e.enrollment_id } + EnrolmentCodeUnspent { challenge: _ } => DeviceEnrollmentUnknown + EnrolmentCodeUnknown => DeviceEnrollmentUnknown + EnrolmentSlotUnreadable { detail: d } => DeviceStoreUnreadable { detail: d } + EnrolmentCodeConsumed { enrollment: enrollment } => + match observe_escalation(root: root, escalation_id: signed.escalation_id) { + EscalationNotFiled => DeviceEscalationNotFiled { escalation_id: signed.escalation_id } + EscalationUnreadable { detail: d } => DeviceStoreUnreadable { detail: d } + EscalationDecided { decision: d } => DeviceAlreadyDecided { decision: d } + EscalationPending { request: req } => + match keyring { + ApprovalKeyringRefused { cause: c } => DeviceKeyringUnavailable { cause: c } + ApprovalKeyLoaded { key: key } => + match device_redemption_admission(key: key, enrollment: enrollment, request: req, redemption: redemption, signature: signature, assertion: assertion, observed_at: observed_at) { + DeviceNotAdmitted { outcome: o } => o + DeviceAdmitted { admitted: a } => commit_admitted_redemption(root: root, admitted: a) + } + } + } + } +} + // ── The provider key ───────────────────────────────────────────────────────────────────────── // The .p8 APNs auth key lives in Secret Manager, pinned. It is NOT yet a row in // gunbc.auth.fleet_secret_accessor_roster: that fold stops at the first refusal, so a row for a @@ -518,21 +620,25 @@ data approval_apns_auth_key_secret_ref: SecretRef = secret_ref_pin_version( // ── Frontiers ──────────────────────────────────────────────────────────────────────────────── data approval_device_routes_frontier: DissolutionCondition = unbound_dissolution( - description: "gunbc.roadmap_serve gains, BESIDE the existing /approve route and without changing it or the store record: an operator-only enrolment-challenge issuer on the dashboard, POST enrolment verifying PlatformEnrollmentEvidence over enrolment_transcript, a pending-approvals list and fetch that issue a RedemptionChallenge, and a device redemption route that REQUIRES a verified SignedRedemption under an active DeviceEnrollment before committing through the existing decision CAS with server-owned decided_at -- SUFFICIENT FOR one approval decided on the phone to be read back from approval_decision_store with decided_by naming the enrolled login", + description: "gunbc.roadmap_serve gains, BESIDE the existing /approve route and without changing it or the store record: a dashboard enrolment-code issuer; POST approval_device_enrol_path consuming the code's slot through enrolment_admission as ONE CAS transition (generation 1 issued -> generation 2 enrolment); assertion-authenticated reads at approval_device_pending_path and approval_device_request_path_prefix that return the stored request, a RedemptionChallenge and both verbs' capabilities; and POST approval_device_redeem_path through redeem_device_over_store; and file_and_notify's APNs publish rendering extdeps.apple.apns apns_push_type_wire, apns_priority_wire and apns_interruption_level_wire into headers and payload with approval_push_custom_keys, and the verifier configured with app_attest_app_id -- SUFFICIENT FOR one approval decided on the phone to be read back from approval_decision_store with decided_by naming the enrolled login", ) data ecdsa_verification_realization_frontier: DissolutionCondition = unbound_dissolution( - description: "a host primitive binding RustCrypto p256 ECDSA verification behind extdeps.crypto.signature signature_verification_from_implementation, and an App Attest attestation and assertion verifier (CBOR decode, X.509 chain to the pinned Apple App Attestation Root CA, counter) behind extdeps.apple.app_attest -- SUFFICIENT FOR the device routes to mint SignatureVerified and AttestationVerified from real bytes", + description: "host primitives binding RustCrypto p256 ECDSA verification behind extdeps.crypto.signature signature_verification_from_implementation, and an App Attest attestation and assertion verifier (CBOR decode, X.509 chain to the pinned Apple App Attestation Root CA, App ID prefix RP ID, validation category and bundle version) behind extdeps.apple.app_attest attestation_verification_from_implementation and assertion_verification_from_implementation -- SUFFICIENT FOR the device routes to mint SignatureVerified, AttestationVerified and AssertionVerified from real bytes", +) + +data ios_realization_frontier: DissolutionCondition = unbound_dissolution( + description: "the SwiftUI client at apps/approve-ios generating its decision key in the Secure Enclave under BiometryCurrentSet, attesting with App Attest over enrolment_transcript, registering for APNs, fetching with assertion-authenticated reads, and redeeming with SignedRedemption, its byte builders checked against gunbc.auth.approval_device_redemption_fixtures vectors, built and uploaded to TestFlight -- SUFFICIENT FOR one physical-phone approval to read back with decided_by naming the enrolled login", ) data approval_target_frontier: DissolutionCondition = unbound_dissolution( - description: "gunbc.auth.approval_decision_store StoredApprovalRequest gains a typed ApprovalTarget (authority, subject, operator sentence derived from the subject) rendered by stored_request_json, landed after the Mt. Collins first boot releases the store -- and carries the access lifetime (gunbc.auth.access_request AccessLifetime) distinct from the link's expires_at -- SUFFICIENT FOR the app to show the host and what is granted for how long from the one stored record, never the link expiry in its place, and for the device signature to cover both", + description: "gunbc.auth.approval_decision_store StoredApprovalRequest gains a typed ApprovalTarget (authority, subject, operator sentence derived from the subject) rendered by stored_request_json, and carries the access lifetime (gunbc.auth.access_request AccessLifetime) distinct from the link's expires_at, landed after the Mt. Collins first boot releases the store -- SUFFICIENT FOR the app to show the host and what is granted for how long from the one stored record, never the link expiry in its place, and for the device signature to cover both", ) data approval_device_cutover_frontier: DissolutionCondition = unbound_dissolution( - description: "one real approval decided on the enrolled phone reads back from the store with decided_by naming the login, after which ONE change routes file_and_notify to APNs, deletes the ntfy publish, the bearer-only /approve route and the two Legacy arms of RedemptionIdentityEvidence together -- SUFFICIENT FOR no admitted redemption to lack an attributable principal", + description: "one real approval decided on the enrolled phone reads back from the store with decided_by naming the login, after which ONE change routes file_and_notify to APNs, deletes the ntfy publish, the bearer-only /approve route and the two Legacy arms of RedemptionIdentityEvidence together -- SUFFICIENT FOR no admitted redemption to lack an attributable principal and for a disclosed capability to admit nothing without the enrolled device", ) data android_realization_frontier: DissolutionCondition = unbound_dissolution( - description: "an Android client that generates the decision key in Keystore with per-operation biometric authorization, submits its attestation chain over enrolment_transcript, registers with FCM, and redeems with SignedRedemption, plus the server's Android key-attestation verifier and FCM publish -- SUFFICIENT FOR an approval decided on a physical Android device to read back with decided_by naming the enrolled login", + description: "an Android client that generates the decision key in Keystore with per-operation biometric authorization, submits its attestation chain over enrolment_transcript, registers with FCM, and redeems with SignedRedemption, plus the server's Android key-attestation verifier (first trusted KeyDescription occurrence, revocation, current Google roots) and FCM publish, replacing enrolment_admission's EnrolmentAndroidUnrealized and the redemption's DevicePlatformUnrealized arms -- SUFFICIENT FOR an approval decided on a physical Android device to read back with decided_by naming the enrolled login", ) diff --git a/dag/gunbc/auth/approval_device_redemption_fixtures.dag b/dag/gunbc/auth/approval_device_redemption_fixtures.dag new file mode 100644 index 00000000000..34a57866dde --- /dev/null +++ b/dag/gunbc/auth/approval_device_redemption_fixtures.dag @@ -0,0 +1,134 @@ +module gunbc.auth.approval_device_redemption_fixtures + +import std.types { NonEmptyStr, String, Timestamp, List } +import std.process { ProcessExit, ExitSuccess, exit_failure } +import extdeps.filesystem.filesystem_io { Filesystem, filesystem_read_outcome, FilesystemReadSucceeded, FilesystemReadRefused } +import extdeps.languages.json.emit { JsonValue, serialize_json, json_object, json_kv, json_string, json_array } +import extdeps.crypto.signature { VerifyingKey, EcdsaP256Sha256, Sec1Uncompressed } +import gunbc.auth.approval_capability { ProposedDecision, ProposeApprove, ProposeDeny, proposed_decision_name, signing_field_separator } +import gunbc.auth.approval_device_redemption { + DeviceRedemptionSigningInput, RedemptionChallenge, MobilePlatform, MobileIos, MobileAndroid, + device_redemption_signing_input, enrolment_transcript, platform_wire, approval_device_audience, +} + +// ── The cross-language byte contract ───────────────────────────────────────────────────────── +// The iOS app builds the signing input and the enrolment transcript in Swift; the server builds +// them here. The two must agree BYTE FOR BYTE or every signature fails. The contract is not +// re-typed in Swift tests: this module renders each vector's EXPECTED bytes through the very +// functions the server verifies against, and the app's XCTest reads the emitted file. A change to +// field order or separator on this side changes the file; `agree` refuses a stale commit, and the +// app's test refuses the new bytes until its builder follows. +data approval_device_vectors_path: String = "dag/test/fixture/approval_device_redemption/vectors.json" + +type RedemptionVector { + name: NonEmptyStr + input: DeviceRedemptionSigningInput +} + +type EnrolmentVector { + name: NonEmptyStr + code: NonEmptyStr + platform: MobilePlatform + point_b64url: NonEmptyStr +} + +// The stored-request texts are real store renderings in shape, and deliberately carry the +// characters a hand-built Swift string gets wrong: quotes, a backslash, non-ASCII, and a newline. +fn redemption_vector(name: NonEmptyStr, decision: ProposedDecision, stored_request_text: NonEmptyStr) -> RedemptionVector { + RedemptionVector { + name: name, + input: DeviceRedemptionSigningInput { + audience: approval_device_audience, + enrollment_id: "enr-7f3a", + challenge: RedemptionChallenge { + expires_at: "2026-09-18T12:05:00Z" as Timestamp, + nonce_hex: "9c1d2e3f4a5b6c7d8e9fa0b1c2d3e4f5061728394a5b6c7d8e9fa0b1c2d3e4f5", + }, + escalation_id: "esc-mtc1-boot-1", + request_revision: "sha256:abababababababababababababababababababababababababababababababab", + stored_request_text: stored_request_text, + decision: decision, + capability_text: join(["gunbc.approval-capability.v1", "gunbc.roadmap_serve", "gunbc.auth.approval_broker"], signing_field_separator) as NonEmptyStr, + capability_tag_hex: "d1f1f1034370302f1405bd0bcd71e907c079ead5bf912da095cbe110d7876663", + }, + } +} + +data redemption_vectors: List = [ + redemption_vector(name: "approve-plain", decision: ProposeApprove, stored_request_text: "{\"kind\":\"request\",\"escalation_id\":\"esc-mtc1-boot-1\",\"destructive\":true}"), + redemption_vector(name: "deny-plain", decision: ProposeDeny, stored_request_text: "{\"kind\":\"request\",\"escalation_id\":\"esc-mtc1-boot-1\",\"destructive\":true}"), + redemption_vector(name: "approve-escapes", decision: ProposeApprove, stored_request_text: "{\"purpose\":\"Boot Mt. Collins \\\\ unit 1 — été\nline two\"}"), +] + +data enrolment_vectors: List = [ + EnrolmentVector { name: "ios", code: "482913", platform: MobileIos, point_b64url: "BHt2Zm9vYmFyYmF6cXV4" }, + EnrolmentVector { name: "android", code: "482913", platform: MobileAndroid, point_b64url: "BHt2Zm9vYmFyYmF6cXV4" }, +] + +fn enrolment_expected(v: EnrolmentVector) -> NonEmptyStr { + enrolment_transcript( + code: v.code, + decision_key: VerifyingKey { suite: EcdsaP256Sha256, encoding: Sec1Uncompressed, point_b64url: v.point_b64url }, + platform: v.platform, + ) +} + +fn redemption_vector_json(v: RedemptionVector) -> JsonValue { + let i = v.input + json_object(members: [ + json_kv(key: "name", value: json_string(s: v.name as String)), + json_kv(key: "input", value: json_object(members: [ + json_kv(key: "audience", value: json_string(s: i.audience as String)), + json_kv(key: "enrollment_id", value: json_string(s: i.enrollment_id as String)), + json_kv(key: "challenge_expires_at", value: json_string(s: i.challenge.expires_at)), + json_kv(key: "nonce_hex", value: json_string(s: i.challenge.nonce_hex as String)), + json_kv(key: "escalation_id", value: json_string(s: i.escalation_id as String)), + json_kv(key: "request_revision", value: json_string(s: i.request_revision as String)), + json_kv(key: "stored_request_text", value: json_string(s: i.stored_request_text as String)), + json_kv(key: "decision", value: json_string(s: proposed_decision_name(d: i.decision) as String)), + json_kv(key: "capability_text", value: json_string(s: i.capability_text as String)), + json_kv(key: "capability_tag_hex", value: json_string(s: i.capability_tag_hex as String)), + ])), + json_kv(key: "expected", value: json_string(s: device_redemption_signing_input(i: i) as String)), + ]) +} + +fn enrolment_vector_json(v: EnrolmentVector) -> JsonValue { + json_object(members: [ + json_kv(key: "name", value: json_string(s: v.name as String)), + json_kv(key: "input", value: json_object(members: [ + json_kv(key: "code", value: json_string(s: v.code as String)), + json_kv(key: "platform", value: json_string(s: platform_wire(p: v.platform) as String)), + json_kv(key: "point_b64url", value: json_string(s: v.point_b64url as String)), + ])), + json_kv(key: "expected", value: json_string(s: enrolment_expected(v: v) as String)), + ]) +} + +fn approval_device_vectors_json() -> String { + concat( + serialize_json(v: json_object(members: [ + json_kv(key: "framing", value: json_string(s: "each field as :, concatenated")), + json_kv(key: "redemption", value: json_array(elements: map(redemption_vectors, v => redemption_vector_json(v: v)))), + json_kv(key: "enrolment", value: json_array(elements: map(enrolment_vectors, v => enrolment_vector_json(v: v)))), + ])), + "\n", + ) +} + +// Writes the projection. Run from the repository root. +fn regen() -> ProcessExit { + let w = Filesystem.Write(path: approval_device_vectors_path, content: approval_device_vectors_json()) + if w.success { ExitSuccess } else { exit_failure(reason: concat("write failed: ", w.error)) } +} + +// Refuses a committed file that is not exactly the projection. +fn agree() -> ProcessExit { + let r = Filesystem.Read(path: approval_device_vectors_path) + match filesystem_read_outcome(content: r.content, success: r.success, error: r.error) { + FilesystemReadRefused { error } => exit_failure(reason: concat("vectors.json unreadable: ", error)) + FilesystemReadSucceeded { content } => + if content == approval_device_vectors_json() { ExitSuccess } + else { exit_failure(reason: "vectors.json is stale; run gunbc.auth.approval_device_redemption_fixtures regen") } + } +} diff --git a/dag/test/claim/approval_device_redemption_witness_test.dag b/dag/test/claim/approval_device_redemption_witness_test.dag index 03a82991c6c..3ae93d46e0e 100644 --- a/dag/test/claim/approval_device_redemption_witness_test.dag +++ b/dag/test/claim/approval_device_redemption_witness_test.dag @@ -2,30 +2,34 @@ module test.claim.approval_device_redemption_witness_test import std.logic { Bool } import std.types { String, NonEmptyStr, Timestamp, Secret } +import std.measure { byte_size } import std.scoped_authorization { AttemptIdentity } import extdeps.crypto.mac { MacKey, HmacSha256, mac_sign, MacSigned, MacKeyMaterialNotHex } import extdeps.transports.rest { RestOk, RestStatusRefused, RestTransportRefused } -import extdeps.apple.apns { - ApnsStatusUnlisted, - apns_send_outcome_of, apns_refusal_class, +import extdeps.apple.apns { ApnsStatusUnlisted, apns_send_outcome_of, apns_refusal_class } +import extdeps.apple.app_attest { + AppAttestProduction, AttestationVerification, AssertionVerification, AssertionSignatureInvalid, AttestationNonceMismatch, + attestation_verification_from_implementation, assertion_verification_from_implementation, } import extdeps.crypto.signature { - VerifyingKey, SignatureBytes, EcdsaP256Sha256, Sec1Uncompressed, P1363FixedWidth, + VerifyingKey, SignatureBytes, SignatureVerification, EcdsaP256Sha256, Sec1Uncompressed, P1363FixedWidth, SignatureVerified, SignatureInvalid, SignatureMalformed, VerifyingKeyMalformed, signature_verification_from_implementation, } -import gunbc.auth.approval_capability { ProposeApprove, ProposeDeny } +import gunbc.auth.approval_capability { ProposeApprove, ProposeDeny, approval_capability_signing_input } import gunbc.auth.approval_device_redemption { DeviceRedemptionSigningInput, RedemptionChallenge, EnrolmentChallenge, MobileIos, MobileAndroid, PushProviderAccepted, PushProviderRefused, PushRegistrationInvalid, PushProviderUnreached, PushProviderUndecodable, - push_outcome_of_apns, device_redemption_signing_input, enrolment_transcript, - DeviceEnrollment, EnrollmentActive, EnrollmentRevoked, IosAppAttestBoundDecisionKey, IosAppAttestAssertion, SignedRedemption, - PlatformProofVerified, PlatformProofRefused, DeviceAdmitted, DeviceNotAdmitted, + push_outcome_of_apns, device_redemption_signing_input, enrolment_transcript, framed, + VerifiedDeviceEnrollment, EnrolmentSlotStanding, EnrolmentCodeUnspent, EnrolmentCodeConsumed, EnrolmentRevoked, EnrolmentCodeUnknown, + EnrolmentAdmitted, EnrolmentRefused, EnrolmentCodeAlreadyUsed, EnrolmentCodeExpired, EnrolmentAttestationForOtherBytes, + EnrolmentAttestationRefused, EnrolmentAndroidUnrealized, enrolment_admission, + IosAppAttestAssertion, SignedRedemption, DeviceAdmission, DeviceAdmitted, DeviceNotAdmitted, DeviceSignedForAnotherRequest, DeviceChallengeExpired, DeviceChallengeNotIssuedHere, DeviceCapabilityRefused, - DeviceSignatureRefused, DevicePlatformProofRefused, DeviceEnrollmentRevoked, DeviceEnrollmentForAnotherOperator, + DeviceSignatureRefused, DevicePlatformProofRefused, DevicePlatformProofForOtherBytes, DevicePlatformProofWrongKey, + DeviceEnrollmentRevoked, DeviceEnrollmentForAnotherOperator, DeviceEnrollmentUnknown, device_redemption_admission, redeem_device_over_store, redemption_challenge_message, approval_device_audience, } -import gunbc.auth.approval_capability { approval_capability_signing_input } import gunbc.auth.approval_decision_store { StoredApprovalRequest, stored_request_json, claims_for, approval_mac_key_id, approval_operator_login, ApprovalKeyLoaded, } @@ -34,66 +38,42 @@ data key_a: VerifyingKey = VerifyingKey { suite: EcdsaP256Sha256, encoding: Sec1 data key_b: VerifyingKey = VerifyingKey { suite: EcdsaP256Sha256, encoding: Sec1Uncompressed, point_b64url: "BKEYB" } data sig: SignatureBytes = SignatureBytes { suite: EcdsaP256Sha256, encoding: P1363FixedWidth, b64url: "SIG" } -fn redemption_input(verb_approve: Bool, enrollment: NonEmptyStr, nonce: NonEmptyStr, request_text: NonEmptyStr, tag: NonEmptyStr) -> NonEmptyStr { - device_redemption_signing_input(i: DeviceRedemptionSigningInput { - audience: "srv1.tailecbe08.ts.net", - enrollment_id: enrollment, - challenge: RedemptionChallenge { expires_at: "2026-09-18T12:05:00Z" as Timestamp, nonce_hex: nonce }, - escalation_id: "esc-1", - request_revision: "r1", - stored_request_text: request_text, - decision: if verb_approve { ProposeApprove } else { ProposeDeny }, - capability_text: "cap", - capability_tag: tag, - }) -} - -data base_input: NonEmptyStr = redemption_input(verb_approve: true, enrollment: "e1", nonce: "n1", request_text: "req", tag: "T") - -data enrol_challenge: EnrolmentChallenge = EnrolmentChallenge { - challenge_id: "ec1", code: "482913", issued_to_login: "operator", - issued_at: "2026-09-18T12:00:00Z" as Timestamp, expires_at: "2026-09-18T12:10:00Z" as Timestamp, -} +// ── Push outcomes ──────────────────────────────────────────────────────────────────────────── -// 410 IS THE ONE STATUS THAT IS NOT A RETRY: the enrolment's token is dead. It must surface as its -// own arm, not as a generic refusal the notifier would retry forever. +// 410 IS THE ONE STATUS THAT IS NOT A RETRY: the registration is dead. test fn witness_apns_410_becomes_registration_invalid() -> Bool { match push_outcome_of_apns(o: apns_send_outcome_of(rest: RestStatusRefused { status: 410, body: "{\"reason\":\"Unregistered\"}" }, apns_id: "")) { PushRegistrationInvalid { provider: _, detail } => detail != "" - PushProviderAccepted { provider: _, receipt: _ } => false - PushProviderRefused { provider: _, status: _, detail: _, retryable: _ } => false - PushProviderUnreached { provider: _, cause: _ } => false - PushProviderUndecodable { provider: _, status: _, cause: _ } => false + _ => false } } -test fn witness_apns_429_stays_a_refusal_carrying_its_status() -> Bool { +test fn witness_apns_429_stays_a_retryable_refusal_carrying_its_status() -> Bool { match push_outcome_of_apns(o: apns_send_outcome_of(rest: RestStatusRefused { status: 429, body: "{\"reason\":\"TooManyRequests\"}" }, apns_id: "")) { PushProviderRefused { provider: _, status, detail: _, retryable } => status == 429 && retryable - PushRegistrationInvalid { provider: _, detail: _ } => false - PushProviderAccepted { provider: _, receipt: _ } => false - PushProviderUnreached { provider: _, cause: _ } => false - PushProviderUndecodable { provider: _, status: _, cause: _ } => false + _ => false } } test fn witness_apns_ok_carries_apns_id() -> Bool { match push_outcome_of_apns(o: apns_send_outcome_of(rest: RestOk, apns_id: "id-7")) { - PushProviderAccepted { provider: _, receipt: message_id } => message_id as String == "id-7" - PushProviderRefused { provider: _, status: _, detail: _, retryable: _ } => false - PushRegistrationInvalid { provider: _, detail: _ } => false - PushProviderUnreached { provider: _, cause: _ } => false - PushProviderUndecodable { provider: _, status: _, cause: _ } => false + PushProviderAccepted { provider: _, receipt } => receipt as String == "id-7" + _ => false + } +} + +// A 200 WITHOUT apns-id IS NOT AN ACCEPTANCE: it must not become PushProviderAccepted on an empty id. +test fn witness_apns_ok_without_apns_id_is_undecodable() -> Bool { + match push_outcome_of_apns(o: apns_send_outcome_of(rest: RestOk, apns_id: "")) { + PushProviderUndecodable { provider: _, status, cause: _ } => status == 200 + _ => false } } test fn witness_apns_transport_refusal_has_no_status() -> Bool { match push_outcome_of_apns(o: apns_send_outcome_of(rest: RestTransportRefused { cause: "connect refused" }, apns_id: "")) { PushProviderUnreached { provider: _, cause } => cause == "connect refused" - PushProviderAccepted { provider: _, receipt: _ } => false - PushProviderRefused { provider: _, status: _, detail: _, retryable: _ } => false - PushRegistrationInvalid { provider: _, detail: _ } => false - PushProviderUndecodable { provider: _, status: _, cause: _ } => false + _ => false } } @@ -104,69 +84,115 @@ test fn witness_apns_unlisted_status_is_not_misclassified() -> Bool { } } -// The signing input discriminates on every field an attacker would try to swap. -test fn witness_signing_input_binds_the_verb() -> Bool { - base_input != redemption_input(verb_approve: false, enrollment: "e1", nonce: "n1", request_text: "req", tag: "T") +// ── Framing ────────────────────────────────────────────────────────────────────────────────── +// THE NON-INJECTIVITY A SEPARATOR JOIN HAD: ["a,b", "c"] and ["a", "b,c"] (and any pair that moves +// a separator across a boundary) must render differently. With framing they do, whatever the +// fields contain. +test fn witness_framing_is_injective_across_a_moved_boundary() -> Bool { + framed(fields: ["a:1,b", "c"]) != framed(fields: ["a", "1,b:c"]) + && framed(fields: ["ab", ""]) != framed(fields: ["a", "b"]) +} + +test fn witness_framing_counts_code_points() -> Bool { + framed(fields: ["été"]) as String == "3:été," +} + +// ── Signature seam ─────────────────────────────────────────────────────────────────────────── +test fn witness_malformed_signature_refuses_despite_a_true_verdict() -> Bool { + match signature_verification_from_implementation(key: key_a, key_size: byte_size(count: 65), signature: sig, signature_size: byte_size(count: 71), message: "m", implementation_verified: true) { + SignatureMalformed { declared: _, expected: _ } => true + _ => false + } } -test fn witness_signing_input_binds_the_enrollment() -> Bool { - base_input != redemption_input(verb_approve: true, enrollment: "e2", nonce: "n1", request_text: "req", tag: "T") +test fn witness_malformed_key_refuses_despite_a_true_verdict() -> Bool { + match signature_verification_from_implementation(key: key_a, key_size: byte_size(count: 33), signature: sig, signature_size: byte_size(count: 64), message: "m", implementation_verified: true) { + VerifyingKeyMalformed { declared: _, expected: _ } => true + _ => false + } } -test fn witness_signing_input_binds_the_server_challenge() -> Bool { - base_input != redemption_input(verb_approve: true, enrollment: "e1", nonce: "n2", request_text: "req", tag: "T") +test fn witness_false_verdict_is_invalid() -> Bool { + match signature_verification_from_implementation(key: key_a, key_size: byte_size(count: 65), signature: sig, signature_size: byte_size(count: 64), message: "m", implementation_verified: false) { + SignatureInvalid { suite: _ } => true + _ => false + } } -test fn witness_signing_input_binds_the_displayed_request() -> Bool { - base_input != redemption_input(verb_approve: true, enrollment: "e1", nonce: "n1", request_text: "req-revised", tag: "T") +// ── Enrolment ──────────────────────────────────────────────────────────────────────────────── +data fx_code: NonEmptyStr = "482913" +data fx_observed_at: Timestamp = "2026-09-17T10:00:00Z" + +fn fx_code_challenge(expires_at: Timestamp) -> EnrolmentChallenge { + EnrolmentChallenge { code: fx_code, issued_to_login: approval_operator_login, issued_at: "2026-09-17T09:55:00Z", expires_at: expires_at } } -test fn witness_signing_input_binds_the_capability_tag() -> Bool { - base_input != redemption_input(verb_approve: true, enrollment: "e1", nonce: "n1", request_text: "req", tag: "T2") +data fx_live_code: EnrolmentSlotStanding = EnrolmentCodeUnspent { challenge: fx_code_challenge(expires_at: "2026-09-17T10:05:00Z") } + +fn fx_attestation(client_data: NonEmptyStr) -> AttestationVerification { + attestation_verification_from_implementation(key_id: "attest-1", public_key_point_b64url: "BATTEST", receipt_b64: "RECEIPT", environment: AppAttestProduction, client_data: client_data, refusal: Absent) } -test fn witness_enrolment_transcript_binds_the_decision_key() -> Bool { - enrolment_transcript(challenge: enrol_challenge, decision_key: key_a, platform: MobileIos) != enrolment_transcript(challenge: enrol_challenge, decision_key: key_b, platform: MobileIos) +fn fx_admitted_enrollment() -> VerifiedDeviceEnrollment? { + match enrolment_admission(slot: fx_live_code, platform: MobileIos, decision_key: key_a, attestation: fx_attestation(client_data: enrolment_transcript(code: fx_code, decision_key: key_a, platform: MobileIos)), observed_at: fx_observed_at) { + EnrolmentAdmitted { enrollment: e } => Present { value: e } + EnrolmentRefused { cause: _ } => Absent + } } -test fn witness_enrolment_transcript_binds_the_platform() -> Bool { - enrolment_transcript(challenge: enrol_challenge, decision_key: key_a, platform: MobileIos) != enrolment_transcript(challenge: enrol_challenge, decision_key: key_a, platform: MobileAndroid) +// THE POSITIVE CONTROL: the login comes from the code's issuance, never from the app. +test fn witness_enrolment_admits_a_live_code_with_an_attestation_over_its_transcript() -> Bool { + match fx_admitted_enrollment() { + Present { value: e } => e.operator_login == approval_operator_login && e.enrollment_id as String == "enr-482913" + Absent => false + } } -// The seam refuses on encoding facts BEFORE consulting the implementation's verdict: a true verdict -// over a malformed signature must not mint SignatureVerified. -test fn witness_malformed_signature_refuses_despite_a_true_verdict() -> Bool { - match signature_verification_from_implementation(key: key_a, key_octets: 65, signature: sig, signature_octets: 71, message: "m", implementation_verified: true) { - SignatureMalformed { octets_declared, octets_expected } => octets_declared == 71 && octets_expected == 64 +test fn witness_enrolment_refuses_a_consumed_code() -> Bool { + match fx_admitted_enrollment() { + Absent => false + Present { value: e } => + match enrolment_admission(slot: EnrolmentCodeConsumed { enrollment: e }, platform: MobileIos, decision_key: key_a, attestation: fx_attestation(client_data: enrolment_transcript(code: fx_code, decision_key: key_a, platform: MobileIos)), observed_at: fx_observed_at) { + EnrolmentRefused { cause: EnrolmentCodeAlreadyUsed } => true + _ => false + } + } +} + +test fn witness_enrolment_refuses_an_expired_code() -> Bool { + match enrolment_admission(slot: EnrolmentCodeUnspent { challenge: fx_code_challenge(expires_at: "2026-09-17T09:59:59Z") }, platform: MobileIos, decision_key: key_a, attestation: fx_attestation(client_data: enrolment_transcript(code: fx_code, decision_key: key_a, platform: MobileIos)), observed_at: fx_observed_at) { + EnrolmentRefused { cause: EnrolmentCodeExpired { expires_at: _, observed_at: _ } } => true _ => false } } -test fn witness_malformed_key_refuses_despite_a_true_verdict() -> Bool { - match signature_verification_from_implementation(key: key_a, key_octets: 33, signature: sig, signature_octets: 64, message: "m", implementation_verified: true) { - VerifyingKeyMalformed { octets_declared, octets_expected: _ } => octets_declared == 33 +// An attestation over ANOTHER key's transcript cannot enrol this key. +test fn witness_enrolment_refuses_an_attestation_over_another_key() -> Bool { + match enrolment_admission(slot: fx_live_code, platform: MobileIos, decision_key: key_a, attestation: fx_attestation(client_data: enrolment_transcript(code: fx_code, decision_key: key_b, platform: MobileIos)), observed_at: fx_observed_at) { + EnrolmentRefused { cause: EnrolmentAttestationForOtherBytes } => true _ => false } } -test fn witness_well_formed_true_verdict_names_its_message() -> Bool { - match signature_verification_from_implementation(key: key_a, key_octets: 65, signature: sig, signature_octets: 64, message: "m", implementation_verified: true) { - SignatureVerified { verified } => verified.message as String == "m" +test fn witness_enrolment_refuses_a_refused_attestation() -> Bool { + let refused = attestation_verification_from_implementation(key_id: "attest-1", public_key_point_b64url: "BATTEST", receipt_b64: "RECEIPT", environment: AppAttestProduction, client_data: "x", refusal: Present { value: AttestationNonceMismatch }) + match enrolment_admission(slot: fx_live_code, platform: MobileIos, decision_key: key_a, attestation: refused, observed_at: fx_observed_at) { + EnrolmentRefused { cause: EnrolmentAttestationRefused { cause: _ } } => true _ => false } } -test fn witness_false_verdict_is_invalid() -> Bool { - match signature_verification_from_implementation(key: key_a, key_octets: 65, signature: sig, signature_octets: 64, message: "m", implementation_verified: false) { - SignatureInvalid { suite: _ } => true +test fn witness_enrolment_refuses_android_until_its_verifier_exists() -> Bool { + match enrolment_admission(slot: fx_live_code, platform: MobileAndroid, decision_key: key_a, attestation: fx_attestation(client_data: enrolment_transcript(code: fx_code, decision_key: key_a, platform: MobileAndroid)), observed_at: fx_observed_at) { + EnrolmentRefused { cause: EnrolmentAndroidUnrealized } => true _ => false } } -// ── Admission: every gate of the device route, with real MACs, no store ────────────────────── +// ── Redemption admission: every gate, real MACs, no store ──────────────────────────────────── // The key and request are the store witness's fixtures (approval_decision_store_witness_test); the // challenge nonce and capability tag are minted here by mac_sign under that key, so each refusal -// below differs from the admitted control in exactly one input. +// differs from the admitted control in exactly one input. data fx_key_hex: String = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" fn fx_key() -> MacKey { @@ -187,7 +213,6 @@ fn fx_request() -> StoredApprovalRequest { } } -data fx_observed_at: Timestamp = "2026-09-17T10:00:00Z" data fx_challenge_expiry: Timestamp = "2026-09-17T10:05:00Z" fn mac_hex(message: NonEmptyStr) -> NonEmptyStr { @@ -197,32 +222,22 @@ fn mac_hex(message: NonEmptyStr) -> NonEmptyStr { } } -fn fx_enrollment(standing_active: Bool, login: NonEmptyStr) -> DeviceEnrollment { - DeviceEnrollment { - enrollment_id: "enr-1", - operator_login: login, - decision_key: key_a, - evidence: IosAppAttestBoundDecisionKey { attest_key_id: "attest-1", attestation_b64: "ATT" }, - standing: if standing_active { EnrollmentActive } else { EnrollmentRevoked { revoked_at: "2026-09-17T09:00:00Z", reason: "lost phone" } }, - } -} - fn fx_signing_input(request_text: NonEmptyStr, expiry: Timestamp, nonce: NonEmptyStr, tag: NonEmptyStr) -> DeviceRedemptionSigningInput { DeviceRedemptionSigningInput { audience: approval_device_audience, - enrollment_id: "enr-1", + enrollment_id: "enr-482913", challenge: RedemptionChallenge { expires_at: expiry, nonce_hex: nonce }, escalation_id: fx_request().escalation_id, request_revision: fx_request().request_revision, stored_request_text: request_text, decision: ProposeApprove, capability_text: approval_capability_signing_input(c: claims_for(request: fx_request(), decision: ProposeApprove)), - capability_tag: tag, + capability_tag_hex: tag, } } fn fx_nonce() -> NonEmptyStr { - mac_hex(message: redemption_challenge_message(escalation_id: fx_request().escalation_id, request_revision: fx_request().request_revision, enrollment_id: "enr-1", expires_at: fx_challenge_expiry)) + mac_hex(message: redemption_challenge_message(escalation_id: fx_request().escalation_id, request_revision: fx_request().request_revision, enrollment_id: "enr-482913", expires_at: fx_challenge_expiry)) } fn fx_tag() -> NonEmptyStr { @@ -233,34 +248,45 @@ fn fx_good_input() -> DeviceRedemptionSigningInput { fx_signing_input(request_text: stored_request_json(r: fx_request()) as NonEmptyStr, expiry: fx_challenge_expiry, nonce: fx_nonce(), tag: fx_tag()) } -// A verdict as the bound verifier would report it: over `message`, under `key`. fn verdict(key: VerifyingKey, message: NonEmptyStr) -> SignatureVerification { - signature_verification_from_implementation(key: key, key_octets: 65, signature: sig, signature_octets: 64, message: message, implementation_verified: true) + signature_verification_from_implementation(key: key, key_size: byte_size(count: 65), signature: sig, signature_size: byte_size(count: 64), message: message, implementation_verified: true) } -fn admit(input: DeviceRedemptionSigningInput, signature: SignatureVerification, platform_ok: Bool) -> DeviceAdmission { - device_redemption_admission( - key: fx_key(), - enrollment: fx_enrollment(standing_active: true, login: approval_operator_login), - request: fx_request(), - redemption: SignedRedemption { signing_input: input, signature: sig, platform_proof: IosAppAttestAssertion { assertion_b64: "ASSERT" } }, - signature: signature, - platform: if platform_ok { PlatformProofVerified } else { PlatformProofRefused { detail: "assertion counter did not increase" } }, - observed_at: fx_observed_at, - ) +fn assertion_over(key_id: NonEmptyStr, client_data: NonEmptyStr) -> AssertionVerification { + assertion_verification_from_implementation(key_id: key_id, counter: 1, client_data: client_data, refusal: Absent) +} + +fn redemption(input: DeviceRedemptionSigningInput) -> SignedRedemption { + SignedRedemption { signing_input: input, signature: sig, platform_proof: IosAppAttestAssertion { assertion_b64: "ASSERT" } } +} + +fn admit(input: DeviceRedemptionSigningInput, signature: SignatureVerification, assertion: AssertionVerification) -> DeviceAdmission { + match fx_admitted_enrollment() { + Absent => DeviceNotAdmitted { outcome: DeviceEnrollmentUnknown } + Present { value: e } => + device_redemption_admission(key: fx_key(), enrollment: e, request: fx_request(), redemption: redemption(input: input), signature: signature, assertion: assertion, observed_at: fx_observed_at) + } +} + +fn good_signature() -> SignatureVerification { + verdict(key: key_a, message: device_redemption_signing_input(i: fx_good_input())) } -// THE POSITIVE CONTROL: every gate passes and the claims admitted are the stored request's. +fn good_assertion() -> AssertionVerification { + assertion_over(key_id: "attest-1", client_data: device_redemption_signing_input(i: fx_good_input())) +} + +// THE POSITIVE CONTROL: every gate passes; the admitted value names the ENROLMENT'S login. test fn witness_device_admission_admits_the_exact_signed_request() -> Bool { - match admit(input: fx_good_input(), signature: verdict(key: key_a, message: device_redemption_signing_input(i: fx_good_input())), platform_ok: true) { - DeviceAdmitted { claims: cl } => cl.escalation_id == fx_request().escalation_id && cl.request_revision == fx_request().request_revision + match admit(input: fx_good_input(), signature: good_signature(), assertion: good_assertion()) { + DeviceAdmitted { admitted: a } => a.decided_by == approval_operator_login && a.claims.escalation_id == fx_request().escalation_id DeviceNotAdmitted { outcome: _ } => false } } test fn witness_device_admission_refuses_a_different_displayed_request() -> Bool { let i = fx_signing_input(request_text: "{\"kind\":\"request\"}", expiry: fx_challenge_expiry, nonce: fx_nonce(), tag: fx_tag()) - match admit(input: i, signature: verdict(key: key_a, message: device_redemption_signing_input(i: i)), platform_ok: true) { + match admit(input: i, signature: verdict(key: key_a, message: device_redemption_signing_input(i: i)), assertion: assertion_over(key_id: "attest-1", client_data: device_redemption_signing_input(i: i))) { DeviceNotAdmitted { outcome: DeviceSignedForAnotherRequest { field: f } } => f as String == "stored_request_text" _ => false } @@ -268,18 +294,16 @@ test fn witness_device_admission_refuses_a_different_displayed_request() -> Bool test fn witness_device_admission_refuses_an_expired_challenge() -> Bool { let i = fx_signing_input(request_text: stored_request_json(r: fx_request()) as NonEmptyStr, expiry: "2026-09-17T09:59:00Z", nonce: fx_nonce(), tag: fx_tag()) - match admit(input: i, signature: verdict(key: key_a, message: device_redemption_signing_input(i: i)), platform_ok: true) { + match admit(input: i, signature: verdict(key: key_a, message: device_redemption_signing_input(i: i)), assertion: assertion_over(key_id: "attest-1", client_data: device_redemption_signing_input(i: i))) { DeviceNotAdmitted { outcome: DeviceChallengeExpired { expires_at: _, observed_at: _ } } => true _ => false } } -// A nonce the server did not mint -- here, one MAC'd over a different expiry -- is refused even -// though everything else, including the device signature, is valid. test fn witness_device_admission_refuses_a_challenge_not_issued_here() -> Bool { - let forged = mac_hex(message: redemption_challenge_message(escalation_id: fx_request().escalation_id, request_revision: fx_request().request_revision, enrollment_id: "enr-1", expires_at: "2026-09-17T11:00:00Z")) + let forged = mac_hex(message: redemption_challenge_message(escalation_id: fx_request().escalation_id, request_revision: fx_request().request_revision, enrollment_id: "enr-482913", expires_at: "2026-09-17T11:00:00Z")) let i = fx_signing_input(request_text: stored_request_json(r: fx_request()) as NonEmptyStr, expiry: fx_challenge_expiry, nonce: forged, tag: fx_tag()) - match admit(input: i, signature: verdict(key: key_a, message: device_redemption_signing_input(i: i)), platform_ok: true) { + match admit(input: i, signature: verdict(key: key_a, message: device_redemption_signing_input(i: i)), assertion: assertion_over(key_id: "attest-1", client_data: device_redemption_signing_input(i: i))) { DeviceNotAdmitted { outcome: DeviceChallengeNotIssuedHere } => true _ => false } @@ -288,68 +312,92 @@ test fn witness_device_admission_refuses_a_challenge_not_issued_here() -> Bool { test fn witness_device_admission_refuses_a_capability_for_the_other_verb() -> Bool { let deny_tag = mac_hex(message: approval_capability_signing_input(c: claims_for(request: fx_request(), decision: ProposeDeny))) let i = fx_signing_input(request_text: stored_request_json(r: fx_request()) as NonEmptyStr, expiry: fx_challenge_expiry, nonce: fx_nonce(), tag: deny_tag) - match admit(input: i, signature: verdict(key: key_a, message: device_redemption_signing_input(i: i)), platform_ok: true) { + match admit(input: i, signature: verdict(key: key_a, message: device_redemption_signing_input(i: i)), assertion: assertion_over(key_id: "attest-1", client_data: device_redemption_signing_input(i: i))) { DeviceNotAdmitted { outcome: DeviceCapabilityRefused { cause: _ } } => true _ => false } } test fn witness_device_admission_refuses_a_signature_over_other_bytes() -> Bool { - match admit(input: fx_good_input(), signature: verdict(key: key_a, message: "something else"), platform_ok: true) { + match admit(input: fx_good_input(), signature: verdict(key: key_a, message: "something else"), assertion: good_assertion()) { DeviceNotAdmitted { outcome: DeviceSignatureRefused { verification: _ } } => true _ => false } } test fn witness_device_admission_refuses_a_signature_under_another_key() -> Bool { - match admit(input: fx_good_input(), signature: verdict(key: key_b, message: device_redemption_signing_input(i: fx_good_input())), platform_ok: true) { + match admit(input: fx_good_input(), signature: verdict(key: key_b, message: device_redemption_signing_input(i: fx_good_input())), assertion: good_assertion()) { DeviceNotAdmitted { outcome: DeviceSignatureRefused { verification: _ } } => true _ => false } } -test fn witness_device_admission_refuses_an_invalid_signature_verdict() -> Bool { - let invalid = signature_verification_from_implementation(key: key_a, key_octets: 65, signature: sig, signature_octets: 64, message: device_redemption_signing_input(i: fx_good_input()), implementation_verified: false) - match admit(input: fx_good_input(), signature: invalid, platform_ok: true) { - DeviceNotAdmitted { outcome: DeviceSignatureRefused { verification: _ } } => true +test fn witness_device_admission_refuses_a_refused_assertion() -> Bool { + let refused = assertion_verification_from_implementation(key_id: "attest-1", counter: 1, client_data: device_redemption_signing_input(i: fx_good_input()), refusal: Present { value: AssertionSignatureInvalid }) + match admit(input: fx_good_input(), signature: good_signature(), assertion: refused) { + DeviceNotAdmitted { outcome: DevicePlatformProofRefused { cause: _ } } => true _ => false } } -test fn witness_device_admission_refuses_a_failed_platform_proof() -> Bool { - match admit(input: fx_good_input(), signature: verdict(key: key_a, message: device_redemption_signing_input(i: fx_good_input())), platform_ok: false) { - DeviceNotAdmitted { outcome: DevicePlatformProofRefused { detail: _ } } => true +test fn witness_device_admission_refuses_an_assertion_from_another_app_key() -> Bool { + match admit(input: fx_good_input(), signature: good_signature(), assertion: assertion_over(key_id: "attest-other", client_data: device_redemption_signing_input(i: fx_good_input()))) { + DeviceNotAdmitted { outcome: DevicePlatformProofWrongKey } => true _ => false } } -// These two refuse before the store is read, so the nonexistent root is never touched. -test fn witness_device_redemption_refuses_a_revoked_enrollment_before_the_store() -> Bool { - match redeem_device_over_store( - root: "/nonexistent/approvals", - keyring: ApprovalKeyLoaded { key: fx_key() }, - enrollment: fx_enrollment(standing_active: false, login: approval_operator_login), - redemption: SignedRedemption { signing_input: fx_good_input(), signature: sig, platform_proof: IosAppAttestAssertion { assertion_b64: "ASSERT" } }, - signature: verdict(key: key_a, message: device_redemption_signing_input(i: fx_good_input())), - platform: PlatformProofVerified, - observed_at: fx_observed_at, - ) { - DeviceEnrollmentRevoked { enrollment_id: e } => e as String == "enr-1" +test fn witness_device_admission_refuses_an_assertion_over_other_bytes() -> Bool { + match admit(input: fx_good_input(), signature: good_signature(), assertion: assertion_over(key_id: "attest-1", client_data: "other")) { + DeviceNotAdmitted { outcome: DevicePlatformProofForOtherBytes } => true _ => false } } -test fn witness_device_redemption_refuses_an_enrollment_for_another_operator() -> Bool { +// These refuse on the enrolment slot, before any store read, so the nonexistent root is untouched. +test fn witness_device_redemption_refuses_a_revoked_enrollment_before_the_store() -> Bool { + match fx_admitted_enrollment() { + Absent => false + Present { value: e } => + match redeem_device_over_store( + root: "/nonexistent/approvals", + keyring: ApprovalKeyLoaded { key: fx_key() }, + enrolment: EnrolmentRevoked { enrollment: e, revoked_at: "2026-09-17T09:59:00Z", reason: "lost phone" }, + redemption: redemption(input: fx_good_input()), + signature: good_signature(), + assertion: good_assertion(), + observed_at: fx_observed_at, + ) { + DeviceEnrollmentRevoked { enrollment_id: id } => id as String == "enr-482913" + _ => false + } + } +} + +test fn witness_device_redemption_refuses_an_unknown_enrollment_before_the_store() -> Bool { match redeem_device_over_store( root: "/nonexistent/approvals", keyring: ApprovalKeyLoaded { key: fx_key() }, - enrollment: fx_enrollment(standing_active: true, login: "someone-else@example.com"), - redemption: SignedRedemption { signing_input: fx_good_input(), signature: sig, platform_proof: IosAppAttestAssertion { assertion_b64: "ASSERT" } }, - signature: verdict(key: key_a, message: device_redemption_signing_input(i: fx_good_input())), - platform: PlatformProofVerified, + enrolment: EnrolmentCodeUnknown, + redemption: redemption(input: fx_good_input()), + signature: good_signature(), + assertion: good_assertion(), observed_at: fx_observed_at, ) { - DeviceEnrollmentForAnotherOperator { enrolled: _, expected: _ } => true + DeviceEnrollmentUnknown => true _ => false } } + +// An enrolment made from a code issued to another login is refused even with every proof valid. +test fn witness_device_admission_refuses_an_enrollment_for_another_operator() -> Bool { + let other_code = EnrolmentCodeUnspent { challenge: EnrolmentChallenge { code: fx_code, issued_to_login: "someone-else@example.com", issued_at: "2026-09-17T09:55:00Z", expires_at: "2026-09-17T10:05:00Z" } } + match enrolment_admission(slot: other_code, platform: MobileIos, decision_key: key_a, attestation: fx_attestation(client_data: enrolment_transcript(code: fx_code, decision_key: key_a, platform: MobileIos)), observed_at: fx_observed_at) { + EnrolmentRefused { cause: _ } => false + EnrolmentAdmitted { enrollment: e } => + match device_redemption_admission(key: fx_key(), enrollment: e, request: fx_request(), redemption: redemption(input: fx_good_input()), signature: good_signature(), assertion: good_assertion(), observed_at: fx_observed_at) { + DeviceNotAdmitted { outcome: DeviceEnrollmentForAnotherOperator { enrolled: _, expected: _ } } => true + _ => false + } + } +} From bdd8e152faa3e09c427752777cd961e2486b8c6e Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 08:00:01 +0000 Subject: [PATCH 05/17] Optional value is none, not Absent (resolve failure found by lane A); split wire contract into approval_device_wire Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/auth/approval_device_redemption.dag | 147 +---------------- .../approval_device_redemption_fixtures.dag | 2 +- dag/gunbc/auth/approval_device_wire.dag | 152 ++++++++++++++++++ ...pproval_device_redemption_witness_test.dag | 60 ++----- .../approval_device_wire_witness_test.dag | 104 ++++++++++++ 5 files changed, 274 insertions(+), 191 deletions(-) create mode 100644 dag/gunbc/auth/approval_device_wire.dag create mode 100644 dag/test/claim/approval_device_wire_witness_test.dag diff --git a/dag/gunbc/auth/approval_device_redemption.dag b/dag/gunbc/auth/approval_device_redemption.dag index 6280c16dae3..29443c3e652 100644 --- a/dag/gunbc/auth/approval_device_redemption.dag +++ b/dag/gunbc/auth/approval_device_redemption.dag @@ -1,8 +1,7 @@ module gunbc.auth.approval_device_redemption -import std.types { NonEmptyStr, String, Timestamp, Int, List } +import std.types { NonEmptyStr, String, Timestamp, Int } import std.logic { Bool } -import std.measure { Second, second } import std.dissolution { DissolutionCondition, unbound_dissolution } import extdeps.crypto.signature { VerifyingKey, SignatureBytes, SignatureVerification, SignatureVerified } import extdeps.apple.apns { @@ -10,7 +9,6 @@ import extdeps.apple.apns { ApnsEnvironment, ApnsTopic, ApnsDeviceToken, ApnsRefusalClass, ApnsBadRequest, ApnsAuthenticationRefused, ApnsBadPath, ApnsBadMethod, ApnsTokenUnregistered, ApnsPayloadTooLarge, ApnsTooManyRequests, ApnsServerError, ApnsServiceUnavailable, ApnsStatusUnlisted, - ApnsCustomKey, } import extdeps.apple.app_attest { AttestKeyId, AttestationVerification, AttestationVerified, AttestationRefused, AttestationRefusal, @@ -33,6 +31,10 @@ import gunbc.auth.approval_decision_store { claims_for, commit_broker_decision, StoreWritten, StoreSlotOccupied, StoreRefused, approval_capability_expectation, approval_operator_login, } +import gunbc.auth.approval_device_wire { + MobilePlatform, MobileIos, MobileAndroid, enrolment_transcript, DeviceRedemptionSigningInput, + device_redemption_signing_input, redemption_challenge_message, approval_device_audience, +} import gunbc.secret_provision { SecretRef, fleet_secret_ref, secret_ref_pin_version } // ── What this module is ────────────────────────────────────────────────────────────────────── @@ -55,33 +57,6 @@ import gunbc.secret_provision { SecretRef, fleet_secret_ref, secret_ref_pin_vers // depends on it (enrolment commit, decision commit) takes that value and nothing looser. A caller // cannot reach the decision commit with raw claims and a login it chose. -type MobilePlatform - = MobileIos - | MobileAndroid - -fn platform_wire(p: MobilePlatform) -> NonEmptyStr { - match p { - MobileIos => "ios" as NonEmptyStr - MobileAndroid => "android" as NonEmptyStr - } -} - -// ── One injective byte rendering ───────────────────────────────────────────────────────────── -// EVERY SIGNED OR MAC'D MESSAGE IN THIS PROTOCOL IS LENGTH-FRAMED: each field is rendered -// ":," where n is the field's length in Unicode code points (string_length here, -// String.unicodeScalars.count in Swift). A plain separator join is not injective when a field may -// contain the separator -- and one field here, capability_text, is itself a unit-separator join -- -// so two different field lists could render to the same bytes and one signature would stand for -// both. Framing makes the rendering injective for ANY field content, with no alphabet restriction to -// enforce on ids, request text or capability text. -fn framed_field(f: String) -> String { - join([to_string(string_length(s: f)), ":", f, ","], "") -} - -fn framed(fields: List) -> NonEmptyStr { - join(map(fields, f => framed_field(f: f)), "") as NonEmptyStr -} - // ── Identity evidence ──────────────────────────────────────────────────────────────────────── // Named by MECHANISM. The two Legacy arms are what the existing /approve route admits today; they // exist exactly as long as that route and the ntfy loop do, and are deleted at cutover @@ -104,8 +79,6 @@ type RedemptionIdentityEvidence // it operator-only. data enrolment_code_residual_note: NonEmptyStr = "The one-time enrolment code is issued on the dashboard, which binds loopback behind tailscale serve: off-tailnet callers are excluded, on-host POSIX users are not. It is single-use and short-lived; it is not operator-only until peer attribution at the serve boundary lands (approval_identity_attribution_frontier)." -data approval_enrolment_protocol: NonEmptyStr = "gunbc.approval-enrolment.v1" as NonEmptyStr - // THE CODE IS THE IDENTIFIER: the operator types one thing, so a separate id would be a second key // for one fact. It also names the enrolment it produces (enrollment_id_for_code), so issuance, // consumption and the enrolment are ONE durable slot and consuming the code IS creating the @@ -162,19 +135,6 @@ type VerifiedDeviceEnrollment sole_constructor { enrolled_at: Timestamp } -// The bytes the platform attestation commits to (App Attest: SHA-256 of these is the -// clientDataHash; Android: the attestation challenge). It binds the server-issued code (so a -// replayed attestation cannot enrol under a fresh code) to the decision key (so a leaked code cannot -// enrol a key the app did not attest alongside it). The login is not here: the server derives it. -fn enrolment_transcript(code: NonEmptyStr, decision_key: VerifyingKey, platform: MobilePlatform) -> NonEmptyStr { - framed(fields: [ - approval_enrolment_protocol as String, - code as String, - platform_wire(p: platform) as String, - decision_key.point_b64url as String, - ]) -} - type EnrolmentRefusal = EnrolmentCodeNotIssued | EnrolmentCodeAlreadyUsed @@ -233,93 +193,6 @@ fn enrolment_admission( } } -// ── The push ───────────────────────────────────────────────────────────────────────────────── -// THE PUSH WAKES; IT DOES NOT CARRY WHAT IS DECIDED. One opaque, non-authorizing locator, carried as -// a top-level APNs custom key beside aps. The capabilities are bearer credentials that must not -// transit or rest at APNs or FCM, and anything rendered from the payload would be a second copy of -// the request. The alert is generic. The app also lists pending approvals on open, so a lost push -// costs latency, never a decision. -type ApprovalPushHint { - notification_id: NonEmptyStr -} - -data approval_push_alert_title: NonEmptyStr = "Approval requested" as NonEmptyStr - -fn approval_push_custom_keys(h: ApprovalPushHint) -> List { - [ApnsCustomKey { key: "notification_id", value: h.notification_id }] -} - -// ── Authenticated reads ────────────────────────────────────────────────────────────────────── -// THE FETCH RETURNS BEARER CAPABILITIES, so it is authenticated, not merely hidden behind an opaque -// push. The app proves it is the enrolled app instance with an App Attest assertion (no Face ID -- -// reading is not deciding) over a framed read request naming the path, the enrolment and the app's -// claimed time; the server admits it only inside a short skew window and only for an ACTIVE iOS -// enrolment. Residual, stated: the counter is not stored (see PlatformRedemptionProof), so a -// captured read request replays inside its window -- over the tailnet's TLS -- and would disclose a -// capability that, until cutover, the legacy bearer route still honours. Cutover closes it. -data approval_device_read_protocol: NonEmptyStr = "gunbc.approval-device-read.v1" as NonEmptyStr -data approval_device_read_skew: Second = second(count: 60) - -fn device_read_client_data(path: NonEmptyStr, enrollment_id: NonEmptyStr, requested_at: Timestamp) -> NonEmptyStr { - framed(fields: [approval_device_read_protocol as String, path as String, enrollment_id as String, requested_at]) -} - -// ── Redemption ─────────────────────────────────────────────────────────────────────────────── -// STATELESS CHALLENGE: nonce_hex is the server's MAC, under the capability key it already holds, -// over redemption_challenge_message. Nothing is stored to issue it or to check it. -type RedemptionChallenge { - expires_at: Timestamp - nonce_hex: NonEmptyStr -} - -data approval_redemption_challenge_protocol: NonEmptyStr = "gunbc.approval-redemption-challenge.v1" as NonEmptyStr - -fn redemption_challenge_message(escalation_id: NonEmptyStr, request_revision: NonEmptyStr, enrollment_id: NonEmptyStr, expires_at: Timestamp) -> NonEmptyStr { - framed(fields: [ - approval_redemption_challenge_protocol as String, - escalation_id as String, - request_revision as String, - enrollment_id as String, - expires_at, - ]) -} - -data approval_redemption_protocol: NonEmptyStr = "gunbc.approval-redemption.v1" as NonEmptyStr -data approval_device_audience: NonEmptyStr = "gunbc.roadmap_serve/device-redemption" as NonEmptyStr - -// stored_request_text is gunbc.auth.approval_decision_store stored_request_json of the record the -// app displayed, byte for byte; capability_text is approval_capability_signing_input of the -// capability for the chosen verb; capability_tag_hex is that capability's MAC tag in the lowercase -// hex the existing /approve segment and mac_verify both use. The server re-renders all three from -// the STORED request and refuses any disagreement, so the device signs no third spelling. -type DeviceRedemptionSigningInput { - audience: NonEmptyStr - enrollment_id: NonEmptyStr - challenge: RedemptionChallenge - escalation_id: NonEmptyStr - request_revision: NonEmptyStr - stored_request_text: NonEmptyStr - decision: ProposedDecision - capability_text: NonEmptyStr - capability_tag_hex: NonEmptyStr -} - -fn device_redemption_signing_input(i: DeviceRedemptionSigningInput) -> NonEmptyStr { - framed(fields: [ - approval_redemption_protocol as String, - i.audience as String, - i.enrollment_id as String, - i.challenge.expires_at, - i.challenge.nonce_hex as String, - i.escalation_id as String, - i.request_revision as String, - i.stored_request_text as String, - proposed_decision_name(d: i.decision) as String, - i.capability_text as String, - i.capability_tag_hex as String, - ]) -} - // Per-redemption platform proof. iOS: an App Attest assertion whose client data is EXACTLY the // signing input. THE ASSERTION COUNTER IS NOT STORED, a stated departure from Apple's recommended // flow: Apple uses it to refuse replay, and a redemption replay is already refused twice -- the @@ -381,14 +254,6 @@ fn push_outcome_of_apns(o: ApnsSendOutcome) -> PushProviderOutcome { } } -// ── The device routes ──────────────────────────────────────────────────────────────────────── -// ONE SPELLING OF EACH PATH, read by the server's route table and by the app, so neither coins its -// own. All sit under /approve/device beside the existing /approve route, which they do not touch. -data approval_device_enrol_path: NonEmptyStr = "/approve/device/enrol" as NonEmptyStr -data approval_device_pending_path: NonEmptyStr = "/approve/device/pending" as NonEmptyStr -data approval_device_request_path_prefix: NonEmptyStr = "/approve/device/requests/" as NonEmptyStr -data approval_device_redeem_path: NonEmptyStr = "/approve/device/redeem" as NonEmptyStr - // ── Redemption admission ───────────────────────────────────────────────────────────────────── // THE DEVICE ROUTE'S FOLD, BESIDE redeem_over_store AND REUSING ITS PARTS: the same slot read // (observe_escalation), claims (claims_for), capability verification (verify_capability) and @@ -497,7 +362,7 @@ fn platform_proof_disagreement(enrollment: VerifiedDeviceEnrollment, assertion: AssertionVerified { verified: v } => if v.key_id != k { Present { value: DevicePlatformProofWrongKey } } else if v.client_data != signed_bytes { Present { value: DevicePlatformProofForOtherBytes } } - else { Absent } + else { none } } } } diff --git a/dag/gunbc/auth/approval_device_redemption_fixtures.dag b/dag/gunbc/auth/approval_device_redemption_fixtures.dag index 34a57866dde..954528c4392 100644 --- a/dag/gunbc/auth/approval_device_redemption_fixtures.dag +++ b/dag/gunbc/auth/approval_device_redemption_fixtures.dag @@ -6,7 +6,7 @@ import extdeps.filesystem.filesystem_io { Filesystem, filesystem_read_outcome, F import extdeps.languages.json.emit { JsonValue, serialize_json, json_object, json_kv, json_string, json_array } import extdeps.crypto.signature { VerifyingKey, EcdsaP256Sha256, Sec1Uncompressed } import gunbc.auth.approval_capability { ProposedDecision, ProposeApprove, ProposeDeny, proposed_decision_name, signing_field_separator } -import gunbc.auth.approval_device_redemption { +import gunbc.auth.approval_device_wire { DeviceRedemptionSigningInput, RedemptionChallenge, MobilePlatform, MobileIos, MobileAndroid, device_redemption_signing_input, enrolment_transcript, platform_wire, approval_device_audience, } diff --git a/dag/gunbc/auth/approval_device_wire.dag b/dag/gunbc/auth/approval_device_wire.dag new file mode 100644 index 00000000000..5ef9749487f --- /dev/null +++ b/dag/gunbc/auth/approval_device_wire.dag @@ -0,0 +1,152 @@ +module gunbc.auth.approval_device_wire + +import std.types { NonEmptyStr, String, Timestamp, Int, List } +import std.measure { Second, second } +import extdeps.crypto.signature { VerifyingKey } +import extdeps.apple.apns { ApnsCustomKey } +import gunbc.auth.approval_capability { ProposedDecision, proposed_decision_name } + +// ── What this module is ────────────────────────────────────────────────────────────────────── +// THE BYTE CONTRACT BETWEEN THE SERVER AND THE APP, and nothing else: the platform names, the +// injective field framing, every signed or MAC'd transcript, the push hint and the route paths. It is +// its own module because the app and the fixture generator need these facts and must not pay for the +// store: gunbc.auth.approval_device_redemption (the server's admission and commit, which reads the +// approval store) imports this, never the reverse. + +type MobilePlatform + = MobileIos + | MobileAndroid + +fn platform_wire(p: MobilePlatform) -> NonEmptyStr { + match p { + MobileIos => "ios" as NonEmptyStr + MobileAndroid => "android" as NonEmptyStr + } +} + +// ── One injective byte rendering ───────────────────────────────────────────────────────────── +// EVERY SIGNED OR MAC'D MESSAGE IN THIS PROTOCOL IS LENGTH-FRAMED: each field is rendered +// ":," where n is the field's length in Unicode code points (string_length here, +// String.unicodeScalars.count in Swift). A plain separator join is not injective when a field may +// contain the separator -- and one field here, capability_text, is itself a unit-separator join -- +// so two different field lists could render to the same bytes and one signature would stand for +// both. Framing makes the rendering injective for ANY field content, with no alphabet restriction to +// enforce on ids, request text or capability text. +fn framed_field(f: String) -> String { + join([to_string(string_length(s: f)), ":", f, ","], "") +} + +fn framed(fields: List) -> NonEmptyStr { + join(map(fields, f => framed_field(f: f)), "") as NonEmptyStr +} + +data approval_enrolment_protocol: NonEmptyStr = "gunbc.approval-enrolment.v1" as NonEmptyStr + +// The bytes the platform attestation commits to (App Attest: SHA-256 of these is the +// clientDataHash; Android: the attestation challenge). It binds the server-issued code (so a +// replayed attestation cannot enrol under a fresh code) to the decision key (so a leaked code cannot +// enrol a key the app did not attest alongside it). The login is not here: the server derives it. +fn enrolment_transcript(code: NonEmptyStr, decision_key: VerifyingKey, platform: MobilePlatform) -> NonEmptyStr { + framed(fields: [ + approval_enrolment_protocol as String, + code as String, + platform_wire(p: platform) as String, + decision_key.point_b64url as String, + ]) +} + +// ── The push ───────────────────────────────────────────────────────────────────────────────── +// THE PUSH WAKES; IT DOES NOT CARRY WHAT IS DECIDED. One opaque, non-authorizing locator, carried as +// a top-level APNs custom key beside aps. The capabilities are bearer credentials that must not +// transit or rest at APNs or FCM, and anything rendered from the payload would be a second copy of +// the request. The alert is generic. The app also lists pending approvals on open, so a lost push +// costs latency, never a decision. +type ApprovalPushHint { + notification_id: NonEmptyStr +} + +data approval_push_alert_title: NonEmptyStr = "Approval requested" as NonEmptyStr + +fn approval_push_custom_keys(h: ApprovalPushHint) -> List { + [ApnsCustomKey { key: "notification_id", value: h.notification_id }] +} + +// ── Authenticated reads ────────────────────────────────────────────────────────────────────── +// THE FETCH RETURNS BEARER CAPABILITIES, so it is authenticated, not merely hidden behind an opaque +// push. The app proves it is the enrolled app instance with an App Attest assertion (no Face ID -- +// reading is not deciding) over a framed read request naming the path, the enrolment and the app's +// claimed time; the server admits it only inside a short skew window and only for an ACTIVE iOS +// enrolment. Residual, stated: the counter is not stored (see PlatformRedemptionProof), so a +// captured read request replays inside its window -- over the tailnet's TLS -- and would disclose a +// capability that, until cutover, the legacy bearer route still honours. Cutover closes it. +data approval_device_read_protocol: NonEmptyStr = "gunbc.approval-device-read.v1" as NonEmptyStr +data approval_device_read_skew: Second = second(count: 60) + +fn device_read_client_data(path: NonEmptyStr, enrollment_id: NonEmptyStr, requested_at: Timestamp) -> NonEmptyStr { + framed(fields: [approval_device_read_protocol as String, path as String, enrollment_id as String, requested_at]) +} + +// ── Redemption ─────────────────────────────────────────────────────────────────────────────── +// STATELESS CHALLENGE: nonce_hex is the server's MAC, under the capability key it already holds, +// over redemption_challenge_message. Nothing is stored to issue it or to check it. +type RedemptionChallenge { + expires_at: Timestamp + nonce_hex: NonEmptyStr +} + +data approval_redemption_challenge_protocol: NonEmptyStr = "gunbc.approval-redemption-challenge.v1" as NonEmptyStr + +fn redemption_challenge_message(escalation_id: NonEmptyStr, request_revision: NonEmptyStr, enrollment_id: NonEmptyStr, expires_at: Timestamp) -> NonEmptyStr { + framed(fields: [ + approval_redemption_challenge_protocol as String, + escalation_id as String, + request_revision as String, + enrollment_id as String, + expires_at, + ]) +} + +data approval_redemption_protocol: NonEmptyStr = "gunbc.approval-redemption.v1" as NonEmptyStr +data approval_device_audience: NonEmptyStr = "gunbc.roadmap_serve/device-redemption" as NonEmptyStr + +// stored_request_text is gunbc.auth.approval_decision_store stored_request_json of the record the +// app displayed, byte for byte; capability_text is approval_capability_signing_input of the +// capability for the chosen verb; capability_tag_hex is that capability's MAC tag in the lowercase +// hex the existing /approve segment and mac_verify both use. The server re-renders all three from +// the STORED request and refuses any disagreement, so the device signs no third spelling. +type DeviceRedemptionSigningInput { + audience: NonEmptyStr + enrollment_id: NonEmptyStr + challenge: RedemptionChallenge + escalation_id: NonEmptyStr + request_revision: NonEmptyStr + stored_request_text: NonEmptyStr + decision: ProposedDecision + capability_text: NonEmptyStr + capability_tag_hex: NonEmptyStr +} + +fn device_redemption_signing_input(i: DeviceRedemptionSigningInput) -> NonEmptyStr { + framed(fields: [ + approval_redemption_protocol as String, + i.audience as String, + i.enrollment_id as String, + i.challenge.expires_at, + i.challenge.nonce_hex as String, + i.escalation_id as String, + i.request_revision as String, + i.stored_request_text as String, + proposed_decision_name(d: i.decision) as String, + i.capability_text as String, + i.capability_tag_hex as String, + ]) +} + +// ── The device routes ──────────────────────────────────────────────────────────────────────── +// ONE SPELLING OF EACH PATH, read by the server's route table and by the app, so neither coins its +// own. All sit under /approve/device beside the existing /approve route, which they do not touch. +data approval_device_enrol_path: NonEmptyStr = "/approve/device/enrol" as NonEmptyStr +data approval_device_pending_path: NonEmptyStr = "/approve/device/pending" as NonEmptyStr +data approval_device_request_path_prefix: NonEmptyStr = "/approve/device/requests/" as NonEmptyStr +data approval_device_redeem_path: NonEmptyStr = "/approve/device/redeem" as NonEmptyStr + diff --git a/dag/test/claim/approval_device_redemption_witness_test.dag b/dag/test/claim/approval_device_redemption_witness_test.dag index 3ae93d46e0e..7b1b6702316 100644 --- a/dag/test/claim/approval_device_redemption_witness_test.dag +++ b/dag/test/claim/approval_device_redemption_witness_test.dag @@ -6,21 +6,20 @@ import std.measure { byte_size } import std.scoped_authorization { AttemptIdentity } import extdeps.crypto.mac { MacKey, HmacSha256, mac_sign, MacSigned, MacKeyMaterialNotHex } import extdeps.transports.rest { RestOk, RestStatusRefused, RestTransportRefused } -import extdeps.apple.apns { ApnsStatusUnlisted, apns_send_outcome_of, apns_refusal_class } +import extdeps.apple.apns { apns_send_outcome_of } import extdeps.apple.app_attest { AppAttestProduction, AttestationVerification, AssertionVerification, AssertionSignatureInvalid, AttestationNonceMismatch, attestation_verification_from_implementation, assertion_verification_from_implementation, } import extdeps.crypto.signature { VerifyingKey, SignatureBytes, SignatureVerification, EcdsaP256Sha256, Sec1Uncompressed, P1363FixedWidth, - SignatureVerified, SignatureInvalid, SignatureMalformed, VerifyingKeyMalformed, signature_verification_from_implementation, } import gunbc.auth.approval_capability { ProposeApprove, ProposeDeny, approval_capability_signing_input } import gunbc.auth.approval_device_redemption { - DeviceRedemptionSigningInput, RedemptionChallenge, EnrolmentChallenge, MobileIos, MobileAndroid, + EnrolmentChallenge, PushProviderAccepted, PushProviderRefused, PushRegistrationInvalid, PushProviderUnreached, PushProviderUndecodable, - push_outcome_of_apns, device_redemption_signing_input, enrolment_transcript, framed, + push_outcome_of_apns, VerifiedDeviceEnrollment, EnrolmentSlotStanding, EnrolmentCodeUnspent, EnrolmentCodeConsumed, EnrolmentRevoked, EnrolmentCodeUnknown, EnrolmentAdmitted, EnrolmentRefused, EnrolmentCodeAlreadyUsed, EnrolmentCodeExpired, EnrolmentAttestationForOtherBytes, EnrolmentAttestationRefused, EnrolmentAndroidUnrealized, enrolment_admission, @@ -28,7 +27,11 @@ import gunbc.auth.approval_device_redemption { DeviceSignedForAnotherRequest, DeviceChallengeExpired, DeviceChallengeNotIssuedHere, DeviceCapabilityRefused, DeviceSignatureRefused, DevicePlatformProofRefused, DevicePlatformProofForOtherBytes, DevicePlatformProofWrongKey, DeviceEnrollmentRevoked, DeviceEnrollmentForAnotherOperator, DeviceEnrollmentUnknown, - device_redemption_admission, redeem_device_over_store, redemption_challenge_message, approval_device_audience, + device_redemption_admission, redeem_device_over_store, +} +import gunbc.auth.approval_device_wire { + DeviceRedemptionSigningInput, RedemptionChallenge, MobileIos, MobileAndroid, + device_redemption_signing_input, enrolment_transcript, redemption_challenge_message, approval_device_audience, } import gunbc.auth.approval_decision_store { StoredApprovalRequest, stored_request_json, claims_for, approval_mac_key_id, approval_operator_login, ApprovalKeyLoaded, @@ -77,47 +80,6 @@ test fn witness_apns_transport_refusal_has_no_status() -> Bool { } } -test fn witness_apns_unlisted_status_is_not_misclassified() -> Bool { - match apns_refusal_class(status: 418) { - ApnsStatusUnlisted { status } => status == 418 - _ => false - } -} - -// ── Framing ────────────────────────────────────────────────────────────────────────────────── -// THE NON-INJECTIVITY A SEPARATOR JOIN HAD: ["a,b", "c"] and ["a", "b,c"] (and any pair that moves -// a separator across a boundary) must render differently. With framing they do, whatever the -// fields contain. -test fn witness_framing_is_injective_across_a_moved_boundary() -> Bool { - framed(fields: ["a:1,b", "c"]) != framed(fields: ["a", "1,b:c"]) - && framed(fields: ["ab", ""]) != framed(fields: ["a", "b"]) -} - -test fn witness_framing_counts_code_points() -> Bool { - framed(fields: ["été"]) as String == "3:été," -} - -// ── Signature seam ─────────────────────────────────────────────────────────────────────────── -test fn witness_malformed_signature_refuses_despite_a_true_verdict() -> Bool { - match signature_verification_from_implementation(key: key_a, key_size: byte_size(count: 65), signature: sig, signature_size: byte_size(count: 71), message: "m", implementation_verified: true) { - SignatureMalformed { declared: _, expected: _ } => true - _ => false - } -} - -test fn witness_malformed_key_refuses_despite_a_true_verdict() -> Bool { - match signature_verification_from_implementation(key: key_a, key_size: byte_size(count: 33), signature: sig, signature_size: byte_size(count: 64), message: "m", implementation_verified: true) { - VerifyingKeyMalformed { declared: _, expected: _ } => true - _ => false - } -} - -test fn witness_false_verdict_is_invalid() -> Bool { - match signature_verification_from_implementation(key: key_a, key_size: byte_size(count: 65), signature: sig, signature_size: byte_size(count: 64), message: "m", implementation_verified: false) { - SignatureInvalid { suite: _ } => true - _ => false - } -} // ── Enrolment ──────────────────────────────────────────────────────────────────────────────── data fx_code: NonEmptyStr = "482913" @@ -130,13 +92,13 @@ fn fx_code_challenge(expires_at: Timestamp) -> EnrolmentChallenge { data fx_live_code: EnrolmentSlotStanding = EnrolmentCodeUnspent { challenge: fx_code_challenge(expires_at: "2026-09-17T10:05:00Z") } fn fx_attestation(client_data: NonEmptyStr) -> AttestationVerification { - attestation_verification_from_implementation(key_id: "attest-1", public_key_point_b64url: "BATTEST", receipt_b64: "RECEIPT", environment: AppAttestProduction, client_data: client_data, refusal: Absent) + attestation_verification_from_implementation(key_id: "attest-1", public_key_point_b64url: "BATTEST", receipt_b64: "RECEIPT", environment: AppAttestProduction, client_data: client_data, refusal: none) } fn fx_admitted_enrollment() -> VerifiedDeviceEnrollment? { match enrolment_admission(slot: fx_live_code, platform: MobileIos, decision_key: key_a, attestation: fx_attestation(client_data: enrolment_transcript(code: fx_code, decision_key: key_a, platform: MobileIos)), observed_at: fx_observed_at) { EnrolmentAdmitted { enrollment: e } => Present { value: e } - EnrolmentRefused { cause: _ } => Absent + EnrolmentRefused { cause: _ } => none } } @@ -253,7 +215,7 @@ fn verdict(key: VerifyingKey, message: NonEmptyStr) -> SignatureVerification { } fn assertion_over(key_id: NonEmptyStr, client_data: NonEmptyStr) -> AssertionVerification { - assertion_verification_from_implementation(key_id: key_id, counter: 1, client_data: client_data, refusal: Absent) + assertion_verification_from_implementation(key_id: key_id, counter: 1, client_data: client_data, refusal: none) } fn redemption(input: DeviceRedemptionSigningInput) -> SignedRedemption { diff --git a/dag/test/claim/approval_device_wire_witness_test.dag b/dag/test/claim/approval_device_wire_witness_test.dag new file mode 100644 index 00000000000..8a3ddcd9bd7 --- /dev/null +++ b/dag/test/claim/approval_device_wire_witness_test.dag @@ -0,0 +1,104 @@ +module test.claim.approval_device_wire_witness_test + +import std.logic { Bool } +import std.types { String, NonEmptyStr } +import std.measure { byte_size } +import extdeps.apple.apns { ApnsStatusUnlisted, apns_refusal_class } +import extdeps.crypto.signature { + VerifyingKey, SignatureBytes, EcdsaP256Sha256, Sec1Uncompressed, P1363FixedWidth, + SignatureInvalid, SignatureMalformed, VerifyingKeyMalformed, signature_verification_from_implementation, +} +import gunbc.auth.approval_capability { ProposeApprove, ProposeDeny } +import gunbc.auth.approval_device_wire { + framed, device_redemption_signing_input, enrolment_transcript, DeviceRedemptionSigningInput, RedemptionChallenge, + approval_device_audience, MobileIos, MobileAndroid, +} + +data key_a: VerifyingKey = VerifyingKey { suite: EcdsaP256Sha256, encoding: Sec1Uncompressed, point_b64url: "BKEYA" } +data key_b: VerifyingKey = VerifyingKey { suite: EcdsaP256Sha256, encoding: Sec1Uncompressed, point_b64url: "BKEYB" } +data sig: SignatureBytes = SignatureBytes { suite: EcdsaP256Sha256, encoding: P1363FixedWidth, b64url: "SIG" } + +test fn witness_apns_unlisted_status_is_not_misclassified() -> Bool { + match apns_refusal_class(status: 418) { + ApnsStatusUnlisted { status } => status == 418 + _ => false + } +} + +// ── Framing ────────────────────────────────────────────────────────────────────────────────── +// THE NON-INJECTIVITY A SEPARATOR JOIN HAD: ["a,b", "c"] and ["a", "b,c"] (and any pair that moves +// a separator across a boundary) must render differently. With framing they do, whatever the +// fields contain. +test fn witness_framing_is_injective_across_a_moved_boundary() -> Bool { + framed(fields: ["a:1,b", "c"]) != framed(fields: ["a", "1,b:c"]) + && framed(fields: ["ab", ""]) != framed(fields: ["a", "b"]) +} + +test fn witness_framing_counts_code_points() -> Bool { + framed(fields: ["été"]) as String == "3:été," +} + +// ── Signature seam ─────────────────────────────────────────────────────────────────────────── +test fn witness_malformed_signature_refuses_despite_a_true_verdict() -> Bool { + match signature_verification_from_implementation(key: key_a, key_size: byte_size(count: 65), signature: sig, signature_size: byte_size(count: 71), message: "m", implementation_verified: true) { + SignatureMalformed { declared: _, expected: _ } => true + _ => false + } +} + +test fn witness_malformed_key_refuses_despite_a_true_verdict() -> Bool { + match signature_verification_from_implementation(key: key_a, key_size: byte_size(count: 33), signature: sig, signature_size: byte_size(count: 64), message: "m", implementation_verified: true) { + VerifyingKeyMalformed { declared: _, expected: _ } => true + _ => false + } +} + +test fn witness_false_verdict_is_invalid() -> Bool { + match signature_verification_from_implementation(key: key_a, key_size: byte_size(count: 65), signature: sig, signature_size: byte_size(count: 64), message: "m", implementation_verified: false) { + SignatureInvalid { suite: _ } => true + _ => false + } +} + + +// ── Signing input: every field an attacker would swap changes the bytes ────────────────────── +fn redemption_input(approve: Bool, enrollment: NonEmptyStr, nonce: NonEmptyStr, request_text: NonEmptyStr, tag: NonEmptyStr) -> NonEmptyStr { + device_redemption_signing_input(i: DeviceRedemptionSigningInput { + audience: approval_device_audience, + enrollment_id: enrollment, + challenge: RedemptionChallenge { expires_at: "2026-09-18T12:05:00Z", nonce_hex: nonce }, + escalation_id: "esc-1", + request_revision: "r1", + stored_request_text: request_text, + decision: if approve { ProposeApprove } else { ProposeDeny }, + capability_text: "cap", + capability_tag_hex: tag, + }) +} + +data base_input: NonEmptyStr = redemption_input(approve: true, enrollment: "e1", nonce: "n1", request_text: "req", tag: "T") + +test fn witness_signing_input_binds_the_verb() -> Bool { + base_input != redemption_input(approve: false, enrollment: "e1", nonce: "n1", request_text: "req", tag: "T") +} + +test fn witness_signing_input_binds_the_enrollment() -> Bool { + base_input != redemption_input(approve: true, enrollment: "e2", nonce: "n1", request_text: "req", tag: "T") +} + +test fn witness_signing_input_binds_the_server_challenge() -> Bool { + base_input != redemption_input(approve: true, enrollment: "e1", nonce: "n2", request_text: "req", tag: "T") +} + +test fn witness_signing_input_binds_the_displayed_request() -> Bool { + base_input != redemption_input(approve: true, enrollment: "e1", nonce: "n1", request_text: "req-revised", tag: "T") +} + +test fn witness_signing_input_binds_the_capability_tag() -> Bool { + base_input != redemption_input(approve: true, enrollment: "e1", nonce: "n1", request_text: "req", tag: "T2") +} + +test fn witness_enrolment_transcript_binds_key_and_platform() -> Bool { + enrolment_transcript(code: "482913", decision_key: key_a, platform: MobileIos) != enrolment_transcript(code: "482913", decision_key: key_b, platform: MobileIos) + && enrolment_transcript(code: "482913", decision_key: key_a, platform: MobileIos) != enrolment_transcript(code: "482913", decision_key: key_a, platform: MobileAndroid) +} From c4921903c794dee025f14a69b7eb7f92b6bd83cc Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 08:28:51 +0000 Subject: [PATCH 06/17] Emit the cross-language wire vectors (gunbc.auth.approval_device_redemption_fixtures regen) Co-Authored-By: Claude Opus 5 (1M context) --- dag/test/fixture/approval_device_redemption/vectors.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 dag/test/fixture/approval_device_redemption/vectors.json diff --git a/dag/test/fixture/approval_device_redemption/vectors.json b/dag/test/fixture/approval_device_redemption/vectors.json new file mode 100644 index 00000000000..bb286f0f32c --- /dev/null +++ b/dag/test/fixture/approval_device_redemption/vectors.json @@ -0,0 +1 @@ +{"framing": "each field as :, concatenated", "redemption": [{"name": "approve-plain", "input": {"audience": "gunbc.roadmap_serve/device-redemption", "enrollment_id": "enr-7f3a", "challenge_expires_at": "2026-09-18T12:05:00Z", "nonce_hex": "9c1d2e3f4a5b6c7d8e9fa0b1c2d3e4f5061728394a5b6c7d8e9fa0b1c2d3e4f5", "escalation_id": "esc-mtc1-boot-1", "request_revision": "sha256:abababababababababababababababababababababababababababababababab", "stored_request_text": "{\"kind\":\"request\",\"escalation_id\":\"esc-mtc1-boot-1\",\"destructive\":true}", "decision": "approve", "capability_text": "gunbc.approval-capability.v1\u001Fgunbc.roadmap_serve\u001Fgunbc.auth.approval_broker", "capability_tag_hex": "d1f1f1034370302f1405bd0bcd71e907c079ead5bf912da095cbe110d7876663"}, "expected": "28:gunbc.approval-redemption.v1,37:gunbc.roadmap_serve/device-redemption,8:enr-7f3a,20:2026-09-18T12:05:00Z,64:9c1d2e3f4a5b6c7d8e9fa0b1c2d3e4f5061728394a5b6c7d8e9fa0b1c2d3e4f5,15:esc-mtc1-boot-1,71:sha256:abababababababababababababababababababababababababababababababab,71:{\"kind\":\"request\",\"escalation_id\":\"esc-mtc1-boot-1\",\"destructive\":true},7:approve,75:gunbc.approval-capability.v1\u001Fgunbc.roadmap_serve\u001Fgunbc.auth.approval_broker,64:d1f1f1034370302f1405bd0bcd71e907c079ead5bf912da095cbe110d7876663,"}, {"name": "deny-plain", "input": {"audience": "gunbc.roadmap_serve/device-redemption", "enrollment_id": "enr-7f3a", "challenge_expires_at": "2026-09-18T12:05:00Z", "nonce_hex": "9c1d2e3f4a5b6c7d8e9fa0b1c2d3e4f5061728394a5b6c7d8e9fa0b1c2d3e4f5", "escalation_id": "esc-mtc1-boot-1", "request_revision": "sha256:abababababababababababababababababababababababababababababababab", "stored_request_text": "{\"kind\":\"request\",\"escalation_id\":\"esc-mtc1-boot-1\",\"destructive\":true}", "decision": "deny", "capability_text": "gunbc.approval-capability.v1\u001Fgunbc.roadmap_serve\u001Fgunbc.auth.approval_broker", "capability_tag_hex": "d1f1f1034370302f1405bd0bcd71e907c079ead5bf912da095cbe110d7876663"}, "expected": "28:gunbc.approval-redemption.v1,37:gunbc.roadmap_serve/device-redemption,8:enr-7f3a,20:2026-09-18T12:05:00Z,64:9c1d2e3f4a5b6c7d8e9fa0b1c2d3e4f5061728394a5b6c7d8e9fa0b1c2d3e4f5,15:esc-mtc1-boot-1,71:sha256:abababababababababababababababababababababababababababababababab,71:{\"kind\":\"request\",\"escalation_id\":\"esc-mtc1-boot-1\",\"destructive\":true},4:deny,75:gunbc.approval-capability.v1\u001Fgunbc.roadmap_serve\u001Fgunbc.auth.approval_broker,64:d1f1f1034370302f1405bd0bcd71e907c079ead5bf912da095cbe110d7876663,"}, {"name": "approve-escapes", "input": {"audience": "gunbc.roadmap_serve/device-redemption", "enrollment_id": "enr-7f3a", "challenge_expires_at": "2026-09-18T12:05:00Z", "nonce_hex": "9c1d2e3f4a5b6c7d8e9fa0b1c2d3e4f5061728394a5b6c7d8e9fa0b1c2d3e4f5", "escalation_id": "esc-mtc1-boot-1", "request_revision": "sha256:abababababababababababababababababababababababababababababababab", "stored_request_text": "{\"purpose\":\"Boot Mt. Collins \\\\ unit 1 — été\nline two\"}", "decision": "approve", "capability_text": "gunbc.approval-capability.v1\u001Fgunbc.roadmap_serve\u001Fgunbc.auth.approval_broker", "capability_tag_hex": "d1f1f1034370302f1405bd0bcd71e907c079ead5bf912da095cbe110d7876663"}, "expected": "28:gunbc.approval-redemption.v1,37:gunbc.roadmap_serve/device-redemption,8:enr-7f3a,20:2026-09-18T12:05:00Z,64:9c1d2e3f4a5b6c7d8e9fa0b1c2d3e4f5061728394a5b6c7d8e9fa0b1c2d3e4f5,15:esc-mtc1-boot-1,71:sha256:abababababababababababababababababababababababababababababababab,55:{\"purpose\":\"Boot Mt. Collins \\\\ unit 1 — été\nline two\"},7:approve,75:gunbc.approval-capability.v1\u001Fgunbc.roadmap_serve\u001Fgunbc.auth.approval_broker,64:d1f1f1034370302f1405bd0bcd71e907c079ead5bf912da095cbe110d7876663,"}], "enrolment": [{"name": "ios", "input": {"code": "482913", "platform": "ios", "point_b64url": "BHt2Zm9vYmFyYmF6cXV4"}, "expected": "27:gunbc.approval-enrolment.v1,6:482913,3:ios,20:BHt2Zm9vYmFyYmF6cXV4,"}, {"name": "android", "input": {"code": "482913", "platform": "android", "point_b64url": "BHt2Zm9vYmFyYmF6cXV4"}, "expected": "27:gunbc.approval-enrolment.v1,6:482913,7:android,20:BHt2Zm9vYmFyYmF6cXV4,"}]} From 9aa8fb7625daab0b741527fce67e163a36c9b22e Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Fri, 18 Sep 2026 08:44:54 +0000 Subject: [PATCH 07/17] Rename the frontier to its remaining subject: app_attest_verification_realization_frontier (review 67617) Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/auth/approval_device_redemption.dag | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dag/gunbc/auth/approval_device_redemption.dag b/dag/gunbc/auth/approval_device_redemption.dag index f98376bb078..dd20b75fd76 100644 --- a/dag/gunbc/auth/approval_device_redemption.dag +++ b/dag/gunbc/auth/approval_device_redemption.dag @@ -41,9 +41,9 @@ import gunbc.secret_provision { SecretRef, fleet_secret_ref, secret_ref_pin_vers // The protocol between the roadmap server and the operator's phone for redeeming an approval with // a device-bound, biometric-gated signature: one protocol, platform realizations beside it. This // module is the MODEL; extdeps.apple.* and extdeps.google/android.* model the upstream interfaces. -// No realization exists yet: the iOS client, the server routes, the verifiers and the APNs publisher -// are each a frontier below (ios_realization_frontier, approval_device_routes_frontier, -// ecdsa_verification_realization_frontier), and Android is modeled at every platform point and +// The ECDSA verifier is realized (extdeps.crypto.signature p256_ecdsa_verify). The iOS client, the +// server routes, the App Attest verifiers and the APNs publisher are each a frontier below (ios_realization_frontier, approval_device_routes_frontier, +// app_attest_verification_realization_frontier), and Android is modeled at every platform point and // realized by nothing (android_realization_frontier). // // WHY A DEVICE SIGNATURE. The loopback deployment cannot attribute Tailscale-User-Login to the proxy @@ -488,7 +488,7 @@ data approval_device_routes_frontier: DissolutionCondition = unbound_dissolution description: "gunbc.roadmap_serve gains, BESIDE the existing /approve route and without changing it or the store record: a dashboard enrolment-code issuer; POST approval_device_enrol_path consuming the code's slot through enrolment_admission as ONE CAS transition (generation 1 issued -> generation 2 enrolment); assertion-authenticated reads at approval_device_pending_path and approval_device_request_path_prefix that return the stored request, a RedemptionChallenge and both verbs' capabilities; and POST approval_device_redeem_path through redeem_device_over_store; and file_and_notify's APNs publish, authenticated by a cached extdeps.apple.apns apns_provider_token minted from approval_apns_auth_key_secret_ref, rendering extdeps.apple.apns apns_push_type_wire, apns_priority_wire and apns_interruption_level_wire into headers and payload with approval_push_custom_keys, and the verifier configured with app_attest_app_id -- SUFFICIENT FOR one approval decided on the phone to be read back from approval_decision_store with decided_by naming the enrolled login", ) -data ecdsa_verification_realization_frontier: DissolutionCondition = unbound_dissolution( +data app_attest_verification_realization_frontier: DissolutionCondition = unbound_dissolution( description: "an App Attest attestation and assertion verifier (CBOR decode, X.509 chain to the pinned Apple App Attestation Root CA, App ID prefix RP ID, validation category and bundle version) behind extdeps.apple.app_attest attestation_verification_from_implementation and assertion_verification_from_implementation -- SUFFICIENT FOR the device routes to mint AttestationVerified and AssertionVerified from real bytes (SignatureVerified is minted from real bytes by extdeps.crypto.signature p256_ecdsa_verify)", ) From bdf846ff6c7c63c32c830ec54e785f5ed0ebb088 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Fri, 18 Sep 2026 09:23:03 +0000 Subject: [PATCH 08/17] apns_provider_token takes EpochSecs; the negative-iat refusal is unwritable, not validated (review 67625) Co-Authored-By: Claude Opus 5 (1M context) --- dag/extdeps/apple/apns.dag | 31 +++++++++------------- dag/test/claim/p256_ecdsa_witness_test.dag | 9 +------ 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/dag/extdeps/apple/apns.dag b/dag/extdeps/apple/apns.dag index be4b088b3c1..5f274bd90a6 100644 --- a/dag/extdeps/apple/apns.dag +++ b/dag/extdeps/apple/apns.dag @@ -1,6 +1,6 @@ module extdeps.apple.apns -import std.types { NonEmptyStr, String, Int, Bool, List, Secret } +import std.types { NonEmptyStr, String, Int, Bool, List, Secret, EpochSecs } import extdeps.external_authority { ExternalAuthority } import extdeps.uri { Uri, Https } import std.measure { Second, second, ByteSize, byte_size } @@ -61,17 +61,16 @@ type ApnsProviderIdentity { // (the two bounds above) without decoding its own output. type ApnsProviderToken { identity: ApnsProviderIdentity - issued_at_epoch: Int + issued_at_epoch: EpochSecs jwt: NonEmptyStr } -// Minting refuses rather than fabricating. The bound implementation can see two malformed inputs: a -// key that is not a P-256 PKCS #8 PEM (an APNs .p8 is exactly that), and an issue time before the -// epoch. The second is checked here, so the implementation's absence means the first. +// Minting refuses rather than fabricating. The one malformed input the bound implementation can see +// is a key that is not a P-256 PKCS #8 PEM (an APNs .p8 is exactly that). An issue time before the +// epoch is not a refusal here: EpochSecs makes it unwritable. type ApnsProviderTokenMint = ApnsProviderTokenSigned { token: ApnsProviderToken } | ApnsAuthKeyUnreadable { key_id: ApnsKeyId } - | ApnsIssuedAtBeforeEpoch { issued_at_epoch: Int } // `es256_jwt_sign` is a host primitive over RustCrypto's p256 SigningKey (RFC 6979 deterministic // nonces, so one input has one token and no RNG is consulted): header {"alg":"ES256","kid"}, @@ -79,18 +78,14 @@ type ApnsProviderTokenMint // travels as a Secret and never appears in an argv (gunbc.credential_argv_exposure), which is why // this is a primitive and not a shell transport. The suite match is total so a second // SignatureSuite arm cannot reach the ES256 implementation without an authored arm. -fn apns_provider_token(identity: ApnsProviderIdentity, auth_key: Secret, issued_at_epoch: Int) -> ApnsProviderTokenMint { - if issued_at_epoch < 0 { - ApnsIssuedAtBeforeEpoch { issued_at_epoch: issued_at_epoch } - } else { - match apns_provider_token_suite { - EcdsaP256Sha256 => - match es256_jwt_sign(auth_key as String, identity.key_id as String, identity.team_id as String, issued_at_epoch) { - Absent => ApnsAuthKeyUnreadable { key_id: identity.key_id } - Present { value: jwt } => - ApnsProviderTokenSigned { token: ApnsProviderToken { identity: identity, issued_at_epoch: issued_at_epoch, jwt: jwt as NonEmptyStr } } - } - } +fn apns_provider_token(identity: ApnsProviderIdentity, auth_key: Secret, issued_at_epoch: EpochSecs) -> ApnsProviderTokenMint { + match apns_provider_token_suite { + EcdsaP256Sha256 => + match es256_jwt_sign(auth_key as String, identity.key_id as String, identity.team_id as String, issued_at_epoch as Int) { + Absent => ApnsAuthKeyUnreadable { key_id: identity.key_id } + Present { value: jwt } => + ApnsProviderTokenSigned { token: ApnsProviderToken { identity: identity, issued_at_epoch: issued_at_epoch, jwt: jwt as NonEmptyStr } } + } } } diff --git a/dag/test/claim/p256_ecdsa_witness_test.dag b/dag/test/claim/p256_ecdsa_witness_test.dag index 36443e2479b..9a8c6d6e466 100644 --- a/dag/test/claim/p256_ecdsa_witness_test.dag +++ b/dag/test/claim/p256_ecdsa_witness_test.dag @@ -10,7 +10,7 @@ import extdeps.crypto.signature { } import extdeps.apple.apns { ApnsProviderIdentity, ApnsKeyId, AppleTeamId, - ApnsProviderTokenMint, ApnsProviderTokenSigned, ApnsAuthKeyUnreadable, ApnsIssuedAtBeforeEpoch, + ApnsProviderTokenMint, ApnsProviderTokenSigned, ApnsAuthKeyUnreadable, apns_provider_token, } @@ -129,10 +129,3 @@ test fn a_key_that_is_not_pkcs8_pem_mints_no_token() -> Bool { _ => false } } - -test fn an_issue_time_before_the_epoch_mints_no_token() -> Bool { - match apns_provider_token(identity: test_identity, auth_key: rfc7515_p8, issued_at_epoch: -1) { - ApnsIssuedAtBeforeEpoch { issued_at_epoch } => issued_at_epoch == -1 - _ => false - } -} From f998bfd608e0d00bc1a7d9fc3b8da0521381f3a7 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 10:30:18 +0000 Subject: [PATCH 09/17] Address reviews 67602/67620/67625 and side-chat round 2 - Admission takes the signing input plus seam-minted verdicts; SignedRedemption, EnrolmentRequest, PresentedEnrollmentEvidence and PushRegistration are wire bodies in approval_device_wire, with their deferred consumer stated once; RedemptionIdentityEvidence deleted. - Capability tag carried in its canonical base64url spelling and converted through capability_tag_hex (refusing a non-canonical spelling); witnesses start from issue_capability and refuse a hex tag placed in the base64url field. - App Attest assertion result renamed AssertionAuthentic and scoped to the checks it makes; counter and challenge left to the consumer, whose replay authority is stated; stored public key joined. - APNs: only Apple's documented priorities (10, 5); push type scoped to alert. - Read transcript vectors and a discriminating witness; enrolment store (code/enrolment/revocation as one CAS slot) with record round-trip witnesses. Co-Authored-By: Claude Opus 5 (1M context) --- dag/extdeps/apple/apns.dag | 16 +- dag/extdeps/apple/app_attest.dag | 28 +- dag/gunbc/auth/approval_device_redemption.dag | 287 ++++++++++++++---- .../approval_device_redemption_fixtures.dag | 31 +- dag/gunbc/auth/approval_device_wire.dag | 63 +++- ...pproval_device_redemption_witness_test.dag | 96 +++++- .../approval_device_wire_witness_test.dag | 13 +- .../approval_device_redemption/vectors.json | 2 +- 8 files changed, 428 insertions(+), 108 deletions(-) diff --git a/dag/extdeps/apple/apns.dag b/dag/extdeps/apple/apns.dag index 6216750718d..212089172dc 100644 --- a/dag/extdeps/apple/apns.dag +++ b/dag/extdeps/apple/apns.dag @@ -60,30 +60,28 @@ type ApnsProviderIdentity { // apns-topic is the app's bundle identifier for an alert push. type ApnsTopic = NonEmptyStr where brand("ApnsTopic") -// apns-push-type is required on watchOS and recommended everywhere; an alert must say alert. -type ApnsPushType - = PushTypeAlert - | PushTypeBackground +// apns-push-type is required on watchOS and recommended everywhere. SCOPED TO THE ALERT SHAPE: a +// background push is a different payload (content-available: 1, no alert, priority 5), and no +// consumer here sends one, so it is not modeled rather than modeled wrong. +type ApnsPushType = + | PushTypeAlert fn apns_push_type_wire(t: ApnsPushType) -> NonEmptyStr { match t { PushTypeAlert => "alert" as NonEmptyStr - PushTypeBackground => "background" as NonEmptyStr } } -// apns-priority: 10 delivers immediately, 5 is power-considerate, 1 prioritizes power. A background -// push MUST be 5; an alert may be 10. +// apns-priority: Apple documents exactly two values -- 10 delivers immediately (the default when +// omitted) and 5 delivers with the device's power considerations. type ApnsPriority = PriorityImmediate | PriorityPowerConsiderate - | PriorityPowerPreferred fn apns_priority_wire(p: ApnsPriority) -> Int { match p { PriorityImmediate => 10 PriorityPowerConsiderate => 5 - PriorityPowerPreferred => 1 } } diff --git a/dag/extdeps/apple/app_attest.dag b/dag/extdeps/apple/app_attest.dag index fd9bd47b6ca..02f1308a7ca 100644 --- a/dag/extdeps/apple/app_attest.dag +++ b/dag/extdeps/apple/app_attest.dag @@ -115,35 +115,45 @@ data app_attest_unavailable_in_simulator_note: NonEmptyStr = "DCAppAttestService // ── Assertions ─────────────────────────────────────────────────────────────────────────────── // Each later request carries an assertion: CBOR { signature, authenticatorData } produced by -// generateAssertion(keyId, clientDataHash). The checks: the signature over -// SHA256(authenticatorData || clientDataHash) verifies under the STORED public key; the RP ID hash -// equals SHA256(App ID); the counter is strictly greater than the stored one; the client data -// carries the server's challenge. The consumer decides whether it stores the counter -- see -// gunbc.auth.approval_device_redemption for this deployment's reasoning. +// generateAssertion(keyId, clientDataHash). Apple's procedure: (1) clientDataHash = SHA256(clientData); +// (2) nonce = SHA256(authenticatorData || clientDataHash); (3) the signature verifies for nonce under +// the STORED public key; (4) the RP ID hash equals SHA256(App ID); (5) the counter exceeds the +// previous assertion's; (6) the embedded challenge matches the one issued; (7) validationCategory +// and (8) bundleVersion, from the extensions CBOR. Only after all eight does Apple call it trusted. +// +// THIS MODULE'S SUCCESS IS NAMED FOR LESS, deliberately: AssertionAuthentic covers steps 1-4, 7 and +// 8 -- the parts a verifier can decide from the assertion, the client data and the stored key. +// Step 5 needs the previous counter and step 6 needs the issued challenge; both are the CONSUMER'S +// state, so the counter is carried out for the consumer to compare (or to state why it does not) and +// the client data is carried out for the consumer to join to its challenge. A value named for full +// trust would claim checks this producer cannot make. type AssertionRefusal = AssertionSignatureInvalid | AssertionAppIdMismatch { expected_app_id: NonEmptyStr } - | AssertionCounterNotIncreasing { stored: Int, presented: Int } + | AssertionValidationCategoryRefused { category: Int } + | AssertionBundleVersionAbsent | AssertionUndecodable { cause: String } -type VerifiedAssertion sole_constructor { +type AuthenticAssertion sole_constructor { key_id: AttestKeyId + public_key_point_b64url: NonEmptyStr counter: Int client_data: NonEmptyStr } type AssertionVerification - = AssertionVerified { verified: VerifiedAssertion } + = AssertionAuthentic { authentic: AuthenticAssertion } | AssertionRefused { cause: AssertionRefusal } fn assertion_verification_from_implementation( key_id: AttestKeyId, + public_key_point_b64url: NonEmptyStr, counter: Int, client_data: NonEmptyStr, refusal: AssertionRefusal?, ) -> AssertionVerification { match refusal { Present { value: r } => AssertionRefused { cause: r } - Absent => AssertionVerified { verified: VerifiedAssertion { key_id: key_id, counter: counter, client_data: client_data } } + Absent => AssertionAuthentic { authentic: AuthenticAssertion { key_id: key_id, public_key_point_b64url: public_key_point_b64url, counter: counter, client_data: client_data } } } } diff --git a/dag/gunbc/auth/approval_device_redemption.dag b/dag/gunbc/auth/approval_device_redemption.dag index 29443c3e652..d623a71ca77 100644 --- a/dag/gunbc/auth/approval_device_redemption.dag +++ b/dag/gunbc/auth/approval_device_redemption.dag @@ -1,27 +1,25 @@ module gunbc.auth.approval_device_redemption -import std.types { NonEmptyStr, String, Timestamp, Int } +import std.types { NonEmptyStr, String, Timestamp, Int, List } import std.logic { Bool } import std.dissolution { DissolutionCondition, unbound_dissolution } -import extdeps.crypto.signature { VerifyingKey, SignatureBytes, SignatureVerification, SignatureVerified } +import extdeps.crypto.signature { VerifyingKey, SignatureVerification, SignatureVerified } import extdeps.apple.apns { ApnsSendOutcome, ApnsAccepted, ApnsRefused, ApnsUnreached, ApnsUndecodable, - ApnsEnvironment, ApnsTopic, ApnsDeviceToken, ApnsRefusalClass, + ApnsRefusalClass, ApnsBadRequest, ApnsAuthenticationRefused, ApnsBadPath, ApnsBadMethod, ApnsTokenUnregistered, ApnsPayloadTooLarge, ApnsTooManyRequests, ApnsServerError, ApnsServiceUnavailable, ApnsStatusUnlisted, } import extdeps.apple.app_attest { AttestKeyId, AttestationVerification, AttestationVerified, AttestationRefused, AttestationRefusal, - AssertionVerification, AssertionVerified, AssertionRefused, AssertionRefusal, + AssertionVerification, AssertionAuthentic, AssertionRefused, AssertionRefusal, } import extdeps.apple.secure_enclave { EnclaveKeyAccessPolicy, BiometryCurrentSet } -import extdeps.google.fcm { FcmProjectId, FcmRegistrationToken } -import extdeps.android.key_attestation { AndroidAttestationChain } import extdeps.crypto.mac { MacKey, MacVerified, mac_verify } import gunbc.auth.approval_capability { ProposedDecision, ProposeApprove, ProposeDeny, proposed_decision_name, ApprovalCapability, ApprovalCapabilityClaims, CapabilityRefusal, CapabilityAuthentic, CapabilityRefused, - approval_capability_signing_input, verify_capability, capability_refusal_reason, utc_instant_before, + approval_capability_signing_input, verify_capability, capability_refusal_reason, utc_instant_before, capability_tag_hex, } import gunbc.auth.approval_broker { BrokerDecision, BrokerApproved, BrokerDenied, ExecutionObligation } import gunbc.auth.approval_decision_store { @@ -29,12 +27,24 @@ import gunbc.auth.approval_decision_store { EscalationNotFiled, EscalationPending, EscalationDecided, EscalationUnreadable, ApprovalKeyringRead, ApprovalKeyLoaded, ApprovalKeyringRefused, ApprovalKeyringRefusal, claims_for, commit_broker_decision, StoreWritten, StoreSlotOccupied, StoreRefused, - approval_capability_expectation, approval_operator_login, + approval_capability_expectation, approval_operator_login, json_field_string, } import gunbc.auth.approval_device_wire { MobilePlatform, MobileIos, MobileAndroid, enrolment_transcript, DeviceRedemptionSigningInput, device_redemption_signing_input, redemption_challenge_message, approval_device_audience, } +import gunbc.durable_cas_file_store { + OwnerOnlyCreateOnly, admit_cas_attempt_for_derived_payload, DerivedPayloadAdmitted, DerivedPayloadKeyNotSlotAddressable, + file_compare_and_set, observe_cas_slot_state, +} +import std.durable_compare_and_set { + cas_unreadable_slot_detail, CasCommitted, CasPreconditionFailed, CasStoreRefused, + CasObservedReadable, CasObservedUnreadable, CasReadableAbsent, CasReadablePresent, + ExpectSlotAbsent, ExpectSlotGeneration, cas_generation_first, cas_generation_next, cas_generation_count, +} +import extdeps.languages.json.emit { JsonValue, JsonKeyValue, serialize_json, json_object, json_kv, json_string } +import extdeps.languages.json.parse { JsonDocumentParsed, JsonDocumentUnreadable, parse_json_document } +import extdeps.crypto.signature { EcdsaP256Sha256, Sec1Uncompressed } import gunbc.secret_provision { SecretRef, fleet_secret_ref, secret_ref_pin_version } // ── What this module is ────────────────────────────────────────────────────────────────────── @@ -57,16 +67,6 @@ import gunbc.secret_provision { SecretRef, fleet_secret_ref, secret_ref_pin_vers // depends on it (enrolment commit, decision commit) takes that value and nothing looser. A caller // cannot reach the decision commit with raw claims and a login it chose. -// ── Identity evidence ──────────────────────────────────────────────────────────────────────── -// Named by MECHANISM. The two Legacy arms are what the existing /approve route admits today; they -// exist exactly as long as that route and the ntfy loop do, and are deleted at cutover -// (approval_device_cutover_frontier). The device arm carries the sole-constructed enrolment, so -// "names a person" cannot be claimed by picking a constructor. -type RedemptionIdentityEvidence - = EnrolledDeviceProof { enrollment: VerifiedDeviceEnrollment } - | LegacyTailnetProxyHeader { presented_login: NonEmptyStr } - | LegacyCapabilityBearer { residual: NonEmptyStr } - // ── Enrolment ──────────────────────────────────────────────────────────────────────────────── // The operator's explicit act on one phone, admitted by a ONE-TIME CODE the dashboard issues. The // login is DERIVED BY THE SERVER from the code's issuance record, never supplied by the app. @@ -103,18 +103,6 @@ type EnrolmentSlotStanding | EnrolmentCodeUnknown | EnrolmentSlotUnreadable { detail: NonEmptyStr } -// Push registration is SEPARATE from enrolment: a token rotates (reinstall, restore, APNs 410) -// without changing who is enrolled or which key decides, and revoking an enrolment must end its -// authority whatever push tokens still exist. -type PushRegistration - = ApnsRegistration { environment: ApnsEnvironment, topic: ApnsTopic, token: ApnsDeviceToken } - | FcmRegistration { project: FcmProjectId, token: FcmRegistrationToken } - -// What the app PRESENTS at enrolment: raw, unverified. -type PresentedEnrollmentEvidence - = IosAppAttestAttestation { attest_key_id: AttestKeyId, attestation_b64: NonEmptyStr } - | AndroidKeyAttestationChain { chain: AndroidAttestationChain } - // What the server KEEPS once verified: the facts later checks need, never the raw object. iOS keeps // the App Attest key's id and public key (every later assertion verifies under it) and the receipt // (Apple's fraud-metric handle). Android's attestation certifies the decision key itself. @@ -193,23 +181,6 @@ fn enrolment_admission( } } -// Per-redemption platform proof. iOS: an App Attest assertion whose client data is EXACTLY the -// signing input. THE ASSERTION COUNTER IS NOT STORED, a stated departure from Apple's recommended -// flow: Apple uses it to refuse replay, and a redemption replay is already refused twice -- the -// challenge is server-minted, expiring and bound to one escalation revision, and the decision slot -// admits one decision per escalation. Storing it would add one CAS generation per approval against -// the store's generation probe bound, for no refusal the route lacks. (Reads are the exception, and -// their residual is stated at device_read_client_data.) -type PlatformRedemptionProof - = IosAppAttestAssertion { assertion_b64: NonEmptyStr } - | AndroidDecisionKeyOnly - -type SignedRedemption { - signing_input: DeviceRedemptionSigningInput - signature: SignatureBytes - platform_proof: PlatformRedemptionProof -} - // ── Push provider outcome ──────────────────────────────────────────────────────────────────── // Named for what a provider can establish: it ACCEPTED the message. Not delivered, not displayed, // never consent. retryable is Apple's reading of its own status, not a policy here. @@ -272,6 +243,7 @@ type DeviceRedemptionOutcome | DeviceSignedForAnotherRequest { field: NonEmptyStr } | DeviceChallengeExpired { expires_at: Timestamp, observed_at: Timestamp } | DeviceChallengeNotIssuedHere + | DeviceCapabilityTagMalformed | DeviceCapabilityRefused { cause: CapabilityRefusal } | DeviceSignatureRefused { verification: SignatureVerification } | DevicePlatformProofRefused { cause: AssertionRefusal } @@ -297,6 +269,7 @@ fn device_redemption_reason(o: DeviceRedemptionOutcome) -> NonEmptyStr { DeviceSignedForAnotherRequest { field: f } => join(["the signed bytes do not match the stored request: ", f as String], "") as NonEmptyStr DeviceChallengeExpired { expires_at: _, observed_at: _ } => "the challenge expired; reopen the request and decide again" as NonEmptyStr DeviceChallengeNotIssuedHere => "the challenge was not issued by this server" as NonEmptyStr + DeviceCapabilityTagMalformed => "the capability tag is not its canonical base64url spelling" as NonEmptyStr DeviceCapabilityRefused { cause: c } => capability_refusal_reason(r: c) as NonEmptyStr DeviceSignatureRefused { verification: _ } => "the device signature did not verify under the enrolled key" as NonEmptyStr DevicePlatformProofRefused { cause: _ } => "the app-instance assertion did not verify" as NonEmptyStr @@ -350,17 +323,22 @@ fn device_signature_joins(signature: SignatureVerification, enrollment: Verified } } -// THE PLATFORM PROOF JOINS to the enrolment's own verified evidence: an iOS assertion must be from -// the enrolled App Attest key and over exactly the signed bytes. Android has no verifier, so it +// THE PLATFORM PROOF JOINS to the enrolment's own verified evidence: an iOS assertion must be +// authentic under the enrolled App Attest key AND its stored public key, and over exactly the signed +// bytes. THIS IS THE PRODUCT'S REPLAY AUTHORITY, not Apple's counter: an authentic assertion is +// admitted without comparing its counter to a stored one (Apple's step 5, which +// extdeps.apple.app_attest AssertionAuthentic deliberately does not claim), because the signed bytes +// carry a server-minted, expiring challenge bound to one escalation revision and the decision slot +// admits one decision per escalation. Android has no verifier, so it // refuses rather than passing on the signature alone. fn platform_proof_disagreement(enrollment: VerifiedDeviceEnrollment, assertion: AssertionVerification, signed_bytes: NonEmptyStr) -> DeviceRedemptionOutcome? { match enrollment.evidence { AndroidAttestedDecisionKey => Present { value: DevicePlatformUnrealized } - IosAttestedAppInstance { attest_key_id: k, attest_public_key_b64url: _, receipt_b64: _ } => + IosAttestedAppInstance { attest_key_id: k, attest_public_key_b64url: pk, receipt_b64: _ } => match assertion { AssertionRefused { cause: c } => Present { value: DevicePlatformProofRefused { cause: c } } - AssertionVerified { verified: v } => - if v.key_id != k { Present { value: DevicePlatformProofWrongKey } } + AssertionAuthentic { authentic: v } => + if v.key_id != k || v.public_key_point_b64url != pk { Present { value: DevicePlatformProofWrongKey } } else if v.client_data != signed_bytes { Present { value: DevicePlatformProofForOtherBytes } } else { none } } @@ -372,12 +350,11 @@ fn device_redemption_admission( key: MacKey, enrollment: VerifiedDeviceEnrollment, request: StoredApprovalRequest, - redemption: SignedRedemption, + signed: DeviceRedemptionSigningInput, signature: SignatureVerification, assertion: AssertionVerification, observed_at: Timestamp, ) -> DeviceAdmission { - let signed = redemption.signing_input let signed_bytes = device_redemption_signing_input(i: signed) let claims = claims_for(request: request, decision: signed.decision) let capability_text = approval_capability_signing_input(c: claims) @@ -392,10 +369,13 @@ fn device_redemption_admission( let challenge_message = redemption_challenge_message(escalation_id: request.escalation_id, request_revision: request.request_revision, enrollment_id: enrollment.enrollment_id, expires_at: signed.challenge.expires_at) match mac_verify(key: key, message: challenge_message, tag_hex: signed.challenge.nonce_hex) { MacVerified { verified: _ } => + match capability_tag_hex(capability: ApprovalCapability { claims: claims, tag_b64url: signed.capability_tag_b64url }) { + Absent => DeviceNotAdmitted { outcome: DeviceCapabilityTagMalformed } + Present { value: tag_hex } => match verify_capability( - capability: ApprovalCapability { claims: claims, tag_b64url: signed.capability_tag_hex }, + capability: ApprovalCapability { claims: claims, tag_b64url: signed.capability_tag_b64url }, expectation: approval_capability_expectation, - mac_result: mac_verify(key: key, message: capability_text, tag_hex: signed.capability_tag_hex), + mac_result: mac_verify(key: key, message: capability_text, tag_hex: tag_hex), observed_at: observed_at, ) { CapabilityRefused { cause: c } => DeviceNotAdmitted { outcome: DeviceCapabilityRefused { cause: c } } @@ -414,6 +394,7 @@ fn device_redemption_admission( } } } + } _ => DeviceNotAdmitted { outcome: DeviceChallengeNotIssuedHere } } } @@ -445,12 +426,11 @@ fn redeem_device_over_store( root: NonEmptyStr, keyring: ApprovalKeyringRead, enrolment: EnrolmentSlotStanding, - redemption: SignedRedemption, + signed: DeviceRedemptionSigningInput, signature: SignatureVerification, assertion: AssertionVerification, observed_at: Timestamp, ) -> DeviceRedemptionOutcome { - let signed = redemption.signing_input match enrolment { EnrolmentRevoked { enrollment: e, revoked_at: _, reason: _ } => DeviceEnrollmentRevoked { enrollment_id: e.enrollment_id } EnrolmentCodeUnspent { challenge: _ } => DeviceEnrollmentUnknown @@ -465,7 +445,7 @@ fn redeem_device_over_store( match keyring { ApprovalKeyringRefused { cause: c } => DeviceKeyringUnavailable { cause: c } ApprovalKeyLoaded { key: key } => - match device_redemption_admission(key: key, enrollment: enrollment, request: req, redemption: redemption, signature: signature, assertion: assertion, observed_at: observed_at) { + match device_redemption_admission(key: key, enrollment: enrollment, request: req, signed: signed, signature: signature, assertion: assertion, observed_at: observed_at) { DeviceNotAdmitted { outcome: o } => o DeviceAdmitted { admitted: a } => commit_admitted_redemption(root: root, admitted: a) } @@ -474,6 +454,187 @@ fn redeem_device_over_store( } } +// ── The enrolment store ────────────────────────────────────────────────────────────────────── +// ONE CAS SLOT PER CODE, keyed by the code: generation 1 is the issued code, generation 2 the +// enrolment it became, generation 3 that enrolment's revocation. Each write expects the generation +// before it, so a code is consumed at most once, an enrolment exists only by consuming its code, and +// a revocation lands only on a live enrolment. Records are owner-only, as the approval store's are. +// +// READING A PERSISTED ENROLMENT RE-MINTS VerifiedDeviceEnrollment, which is why the parse lives in +// this module: the only bytes it trusts are this store's own, written only by commit_enrolment from +// an EnrolmentAdmitted value, under a root no other principal can write. +data approval_device_store_root: NonEmptyStr = "/var/lib/gunbc/approval-devices" + +type DeviceStoreWrite + = DeviceStoreWritten + | DeviceStoreSlotOccupied + | DeviceStoreRefused { detail: NonEmptyStr } + +fn device_store_write(root: NonEmptyStr, key: NonEmptyStr, expected_generation: Int, payload: NonEmptyStr) -> DeviceStoreWrite { + let expected = if expected_generation == 0 { ExpectSlotAbsent } + else if expected_generation == 1 { ExpectSlotGeneration { generation: cas_generation_first() } } + else { ExpectSlotGeneration { generation: cas_generation_next(g: cas_generation_first()) } } + match admit_cas_attempt_for_derived_payload(key: key, expected: expected, proposed: payload) { + DerivedPayloadKeyNotSlotAddressable { key: _ } => DeviceStoreRefused { detail: "enrolment code is not slot-addressable" } + DerivedPayloadAdmitted { verified: v } => + match file_compare_and_set(root: root, publication: OwnerOnlyCreateOnly, verified: v) { + CasCommitted { committed: _ } => DeviceStoreWritten + CasPreconditionFailed { expected: _, observed: _ } => DeviceStoreSlotOccupied + CasStoreRefused { cause: _ } => DeviceStoreRefused { detail: "device store refused the publication" } + } + } +} + +fn enrolment_code_json(c: EnrolmentChallenge) -> String { + serialize_json(v: json_object(members: [ + json_kv(key: "kind", value: json_string(s: "code")), + json_kv(key: "code", value: json_string(s: c.code as String)), + json_kv(key: "issued_to_login", value: json_string(s: c.issued_to_login as String)), + json_kv(key: "issued_at", value: json_string(s: c.issued_at)), + json_kv(key: "expires_at", value: json_string(s: c.expires_at)), + ])) +} + +fn enrolment_members(kind: String, e: VerifiedDeviceEnrollment) -> List { + let evidence = match e.evidence { + IosAttestedAppInstance { attest_key_id: k, attest_public_key_b64url: pk, receipt_b64: r } => [ + json_kv(key: "attest_key_id", value: json_string(s: k as String)), + json_kv(key: "attest_public_key_b64url", value: json_string(s: pk as String)), + json_kv(key: "receipt_b64", value: json_string(s: r as String)), + ] + AndroidAttestedDecisionKey => [] + } + concat_lists([ + json_kv(key: "kind", value: json_string(s: kind)), + json_kv(key: "enrollment_id", value: json_string(s: e.enrollment_id as String)), + json_kv(key: "operator_login", value: json_string(s: e.operator_login as String)), + json_kv(key: "platform", value: json_string(s: platform_wire(p: e.platform) as String)), + json_kv(key: "decision_key_point_b64url", value: json_string(s: e.decision_key.point_b64url as String)), + json_kv(key: "enrolled_at", value: json_string(s: e.enrolled_at)), + ], evidence) +} + +fn enrolment_json(e: VerifiedDeviceEnrollment) -> String { + serialize_json(v: json_object(members: enrolment_members(kind: "enrolled", e: e))) +} + +fn revocation_json(e: VerifiedDeviceEnrollment, revoked_at: Timestamp, reason: NonEmptyStr) -> String { + serialize_json(v: json_object(members: concat_lists(enrolment_members(kind: "revoked", e: e), [ + json_kv(key: "revoked_at", value: json_string(s: revoked_at)), + json_kv(key: "reason", value: json_string(s: reason as String)), + ]))) +} + +// A required string field, or "" when absent -- the caller checks every required key first, so a +// missing field is an unreadable record, never a defaulted one. +fn field_or_empty(v: JsonValue, key: String) -> String { + match json_field_string(v: v, key: key) { Present { value: s } => s Absent => "" } +} + +fn first_missing(v: JsonValue, keys: List) -> String { + fold(keys, init: "", f: (acc, k) => if acc != "" { acc } else if field_or_empty(v: v, key: k) == "" { k } else { "" }) +} + +data enrolment_required_keys: List = ["enrollment_id", "operator_login", "platform", "decision_key_point_b64url", "enrolled_at", "attest_key_id", "attest_public_key_b64url", "receipt_b64"] + +fn parse_enrolment_fields(v: JsonValue) -> VerifiedDeviceEnrollment? { + if first_missing(v: v, keys: enrolment_required_keys) != "" || field_or_empty(v: v, key: "platform") != "ios" { + none + } else { + Present { value: VerifiedDeviceEnrollment { + enrollment_id: field_or_empty(v: v, key: "enrollment_id") as NonEmptyStr, + operator_login: field_or_empty(v: v, key: "operator_login") as NonEmptyStr, + platform: MobileIos, + decision_key: VerifyingKey { suite: EcdsaP256Sha256, encoding: Sec1Uncompressed, point_b64url: field_or_empty(v: v, key: "decision_key_point_b64url") as NonEmptyStr }, + evidence: IosAttestedAppInstance { + attest_key_id: field_or_empty(v: v, key: "attest_key_id") as AttestKeyId, + attest_public_key_b64url: field_or_empty(v: v, key: "attest_public_key_b64url") as NonEmptyStr, + receipt_b64: field_or_empty(v: v, key: "receipt_b64") as NonEmptyStr, + }, + enrolled_at: field_or_empty(v: v, key: "enrolled_at"), + } } + } +} + +// The record kind must agree with the generation it sits at; a disagreement is unreadable, never +// reinterpreted. +fn parse_enrolment_record(text: String, generation: Int) -> EnrolmentSlotStanding { + match parse_json_document(s: text) { + JsonDocumentUnreadable { gap: _ } => EnrolmentSlotUnreadable { detail: "enrolment record is not a JSON document" } + JsonDocumentParsed { value: v } => { + let kind = field_or_empty(v: v, key: "kind") + if kind == "code" && generation == 1 { + if first_missing(v: v, keys: ["code", "issued_to_login", "issued_at", "expires_at"]) != "" { + EnrolmentSlotUnreadable { detail: "issued code record lacks a field" } + } else { + EnrolmentCodeUnspent { challenge: EnrolmentChallenge { + code: field_or_empty(v: v, key: "code") as NonEmptyStr, + issued_to_login: field_or_empty(v: v, key: "issued_to_login") as NonEmptyStr, + issued_at: field_or_empty(v: v, key: "issued_at"), + expires_at: field_or_empty(v: v, key: "expires_at"), + } } + } + } else if kind == "enrolled" && generation == 2 { + match parse_enrolment_fields(v: v) { + Present { value: e } => EnrolmentCodeConsumed { enrollment: e } + Absent => EnrolmentSlotUnreadable { detail: "enrolment record lacks a field or names an unrealized platform" } + } + } else if kind == "revoked" && generation == 3 { + match parse_enrolment_fields(v: v) { + Absent => EnrolmentSlotUnreadable { detail: "revocation record lacks an enrolment field" } + Present { value: e } => + if first_missing(v: v, keys: ["revoked_at", "reason"]) != "" { EnrolmentSlotUnreadable { detail: "revocation record lacks revoked_at or reason" } } + else { EnrolmentRevoked { enrollment: e, revoked_at: field_or_empty(v: v, key: "revoked_at"), reason: field_or_empty(v: v, key: "reason") as NonEmptyStr } } + } + } else { + EnrolmentSlotUnreadable { detail: "enrolment record kind disagrees with its generation" } + } + } + } +} + +fn observe_enrolment_slot(root: NonEmptyStr, code: NonEmptyStr) -> EnrolmentSlotStanding { + match observe_cas_slot_state(root: root, key: code) { + CasObservedUnreadable { cause: c } => EnrolmentSlotUnreadable { detail: concat("enrolment slot unreadable: ", cas_unreadable_slot_detail(cause: c)) as NonEmptyStr } + CasObservedReadable { readable: r } => match r { + CasReadableAbsent => EnrolmentCodeUnknown + CasReadablePresent { version: ver } => parse_enrolment_record(text: ver.value as String, generation: cas_generation_count(g: ver.generation)) + } + } +} + +// The enrolment id is "enr-" + code, so the slot a redemption names is recovered from it. +fn code_of_enrollment_id(enrollment_id: NonEmptyStr) -> String { + if starts_with(s: enrollment_id as String, prefix: "enr-") { substring(s: enrollment_id as String, start: 4, end: string_length(s: enrollment_id as String)) } else { "" } +} + +fn observe_enrollment(root: NonEmptyStr, enrollment_id: NonEmptyStr) -> EnrolmentSlotStanding { + let code = code_of_enrollment_id(enrollment_id: enrollment_id) + if code == "" { EnrolmentCodeUnknown } else { observe_enrolment_slot(root: root, code: code as NonEmptyStr) } +} + +fn issue_enrolment_code(root: NonEmptyStr, challenge: EnrolmentChallenge) -> DeviceStoreWrite { + device_store_write(root: root, key: challenge.code, expected_generation: 0, payload: enrolment_code_json(c: challenge) as NonEmptyStr) +} + +// THE ONLY ENROLMENT WRITE, and it takes the admitted value: consuming the code and creating the +// enrolment are this one CAS, expecting the issued generation. +fn commit_enrolment(root: NonEmptyStr, admitted: EnrolmentAdmission) -> DeviceStoreWrite { + match admitted { + EnrolmentRefused { cause: _ } => DeviceStoreRefused { detail: "nothing admitted to commit" } + EnrolmentAdmitted { enrollment: e } => + device_store_write(root: root, key: code_of_enrollment_id(enrollment_id: e.enrollment_id) as NonEmptyStr, expected_generation: 1, payload: enrolment_json(e: e) as NonEmptyStr) + } +} + +fn revoke_enrolment(root: NonEmptyStr, standing: EnrolmentSlotStanding, revoked_at: Timestamp, reason: NonEmptyStr) -> DeviceStoreWrite { + match standing { + EnrolmentCodeConsumed { enrollment: e } => + device_store_write(root: root, key: code_of_enrollment_id(enrollment_id: e.enrollment_id) as NonEmptyStr, expected_generation: 2, payload: revocation_json(e: e, revoked_at: revoked_at, reason: reason) as NonEmptyStr) + _ => DeviceStoreRefused { detail: "only a live enrolment can be revoked" } + } +} + // ── The provider key ───────────────────────────────────────────────────────────────────────── // The .p8 APNs auth key lives in Secret Manager, pinned. It is NOT yet a row in // gunbc.auth.fleet_secret_accessor_roster: that fold stops at the first refusal, so a row for a @@ -485,15 +646,15 @@ data approval_apns_auth_key_secret_ref: SecretRef = secret_ref_pin_version( // ── Frontiers ──────────────────────────────────────────────────────────────────────────────── data approval_device_routes_frontier: DissolutionCondition = unbound_dissolution( - description: "gunbc.roadmap_serve gains, BESIDE the existing /approve route and without changing it or the store record: a dashboard enrolment-code issuer; POST approval_device_enrol_path consuming the code's slot through enrolment_admission as ONE CAS transition (generation 1 issued -> generation 2 enrolment); assertion-authenticated reads at approval_device_pending_path and approval_device_request_path_prefix that return the stored request, a RedemptionChallenge and both verbs' capabilities; and POST approval_device_redeem_path through redeem_device_over_store; and file_and_notify's APNs publish rendering extdeps.apple.apns apns_push_type_wire, apns_priority_wire and apns_interruption_level_wire into headers and payload with approval_push_custom_keys, and the verifier configured with app_attest_app_id -- SUFFICIENT FOR one approval decided on the phone to be read back from approval_decision_store with decided_by naming the enrolled login", + description: "gunbc.roadmap_serve gains, BESIDE the existing /approve route and without changing it or the store record: a dashboard enrolment-code issuer (issue_enrolment_code); POST approval_device_enrol_path decoding gunbc.auth.approval_device_wire EnrolmentRequest and storing its PushRegistration, consuming the code's slot through enrolment_admission as ONE CAS transition (generation 1 issued -> generation 2 enrolment); reads at approval_device_pending_path and approval_device_request_path_prefix authenticated by an App Attest assertion over device_read_client_data within approval_device_read_skew that return the stored request, a RedemptionChallenge and both verbs' capabilities; and POST approval_device_redeem_path decoding SignedRedemption, verifying its signature and platform_proof at the seams, and folding through redeem_device_over_store; and file_and_notify's APNs publish rendering extdeps.apple.apns apns_push_type_wire, apns_priority_wire and apns_interruption_level_wire into headers and payload with approval_push_alert_title and approval_push_custom_keys, and the verifier configured with app_attest_app_id -- SUFFICIENT FOR one approval decided on the phone to be read back from approval_decision_store with decided_by naming the enrolled login", ) data ecdsa_verification_realization_frontier: DissolutionCondition = unbound_dissolution( - description: "host primitives binding RustCrypto p256 ECDSA verification behind extdeps.crypto.signature signature_verification_from_implementation, and an App Attest attestation and assertion verifier (CBOR decode, X.509 chain to the pinned Apple App Attestation Root CA, App ID prefix RP ID, validation category and bundle version) behind extdeps.apple.app_attest attestation_verification_from_implementation and assertion_verification_from_implementation -- SUFFICIENT FOR the device routes to mint SignatureVerified, AttestationVerified and AssertionVerified from real bytes", + description: "host primitives binding RustCrypto p256 ECDSA verification behind extdeps.crypto.signature signature_verification_from_implementation, and an App Attest attestation and assertion verifier (CBOR decode, X.509 chain to the pinned Apple App Attestation Root CA, App ID prefix RP ID, validation category and bundle version) behind extdeps.apple.app_attest attestation_verification_from_implementation and assertion_verification_from_implementation -- SUFFICIENT FOR the device routes to mint SignatureVerified, AttestationVerified and AssertionAuthentic from real bytes", ) data ios_realization_frontier: DissolutionCondition = unbound_dissolution( - description: "the SwiftUI client at apps/approve-ios generating its decision key in the Secure Enclave under BiometryCurrentSet, attesting with App Attest over enrolment_transcript, registering for APNs, fetching with assertion-authenticated reads, and redeeming with SignedRedemption, its byte builders checked against gunbc.auth.approval_device_redemption_fixtures vectors, built and uploaded to TestFlight -- SUFFICIENT FOR one physical-phone approval to read back with decided_by naming the enrolled login", + description: "the SwiftUI client at apps/approve-ios generating its decision key in the Secure Enclave under approval_decision_key_access_policy, attesting with App Attest over enrolment_transcript, registering for APNs, fetching with assertion-authenticated reads, and redeeming with SignedRedemption, its byte builders checked against gunbc.auth.approval_device_redemption_fixtures vectors, built and uploaded to TestFlight -- SUFFICIENT FOR one physical-phone approval to read back with decided_by naming the enrolled login", ) data approval_target_frontier: DissolutionCondition = unbound_dissolution( @@ -501,7 +662,7 @@ data approval_target_frontier: DissolutionCondition = unbound_dissolution( ) data approval_device_cutover_frontier: DissolutionCondition = unbound_dissolution( - description: "one real approval decided on the enrolled phone reads back from the store with decided_by naming the login, after which ONE change routes file_and_notify to APNs, deletes the ntfy publish, the bearer-only /approve route and the two Legacy arms of RedemptionIdentityEvidence together -- SUFFICIENT FOR no admitted redemption to lack an attributable principal and for a disclosed capability to admit nothing without the enrolled device", + description: "one real approval decided on the enrolled phone reads back from the store with decided_by naming the login, after which ONE change routes file_and_notify to APNs, deletes the ntfy publish, the bearer-only /approve route and gunbc.auth.approval_broker IdentityAttribution's header arms together -- SUFFICIENT FOR no admitted redemption to lack an attributable principal and for a disclosed capability to admit nothing without the enrolled device", ) data android_realization_frontier: DissolutionCondition = unbound_dissolution( diff --git a/dag/gunbc/auth/approval_device_redemption_fixtures.dag b/dag/gunbc/auth/approval_device_redemption_fixtures.dag index 954528c4392..9bc4fb08e3f 100644 --- a/dag/gunbc/auth/approval_device_redemption_fixtures.dag +++ b/dag/gunbc/auth/approval_device_redemption_fixtures.dag @@ -9,6 +9,7 @@ import gunbc.auth.approval_capability { ProposedDecision, ProposeApprove, Propos import gunbc.auth.approval_device_wire { DeviceRedemptionSigningInput, RedemptionChallenge, MobilePlatform, MobileIos, MobileAndroid, device_redemption_signing_input, enrolment_transcript, platform_wire, approval_device_audience, + device_read_client_data, approval_device_pending_path, approval_device_request_path_prefix, } // ── The cross-language byte contract ───────────────────────────────────────────────────────── @@ -49,7 +50,7 @@ fn redemption_vector(name: NonEmptyStr, decision: ProposedDecision, stored_reque stored_request_text: stored_request_text, decision: decision, capability_text: join(["gunbc.approval-capability.v1", "gunbc.roadmap_serve", "gunbc.auth.approval_broker"], signing_field_separator) as NonEmptyStr, - capability_tag_hex: "d1f1f1034370302f1405bd0bcd71e907c079ead5bf912da095cbe110d7876663", + capability_tag_b64url: "0fHxA0NwMC8UBb0LzXHpB8B56tW_kS2glcvhENeHZmM=", }, } } @@ -87,7 +88,7 @@ fn redemption_vector_json(v: RedemptionVector) -> JsonValue { json_kv(key: "stored_request_text", value: json_string(s: i.stored_request_text as String)), json_kv(key: "decision", value: json_string(s: proposed_decision_name(d: i.decision) as String)), json_kv(key: "capability_text", value: json_string(s: i.capability_text as String)), - json_kv(key: "capability_tag_hex", value: json_string(s: i.capability_tag_hex as String)), + json_kv(key: "capability_tag_b64url", value: json_string(s: i.capability_tag_b64url as String)), ])), json_kv(key: "expected", value: json_string(s: device_redemption_signing_input(i: i) as String)), ]) @@ -105,12 +106,38 @@ fn enrolment_vector_json(v: EnrolmentVector) -> JsonValue { ]) } +// The read transcript an App Attest assertion covers on the two authenticated GETs. +type ReadVector { + name: NonEmptyStr + path: NonEmptyStr + enrollment_id: NonEmptyStr + requested_at: Timestamp +} + +data read_vectors: List = [ + ReadVector { name: "pending", path: approval_device_pending_path, enrollment_id: "enr-482913", requested_at: "2026-09-18T12:00:30Z" as Timestamp }, + ReadVector { name: "request", path: join([approval_device_request_path_prefix as String, "esc-mtc1-boot-1"], "") as NonEmptyStr, enrollment_id: "enr-482913", requested_at: "2026-09-18T12:00:30Z" as Timestamp }, +] + +fn read_vector_json(v: ReadVector) -> JsonValue { + json_object(members: [ + json_kv(key: "name", value: json_string(s: v.name as String)), + json_kv(key: "input", value: json_object(members: [ + json_kv(key: "path", value: json_string(s: v.path as String)), + json_kv(key: "enrollment_id", value: json_string(s: v.enrollment_id as String)), + json_kv(key: "requested_at", value: json_string(s: v.requested_at)), + ])), + json_kv(key: "expected", value: json_string(s: device_read_client_data(path: v.path, enrollment_id: v.enrollment_id, requested_at: v.requested_at) as String)), + ]) +} + fn approval_device_vectors_json() -> String { concat( serialize_json(v: json_object(members: [ json_kv(key: "framing", value: json_string(s: "each field as :, concatenated")), json_kv(key: "redemption", value: json_array(elements: map(redemption_vectors, v => redemption_vector_json(v: v)))), json_kv(key: "enrolment", value: json_array(elements: map(enrolment_vectors, v => enrolment_vector_json(v: v)))), + json_kv(key: "read", value: json_array(elements: map(read_vectors, v => read_vector_json(v: v)))), ])), "\n", ) diff --git a/dag/gunbc/auth/approval_device_wire.dag b/dag/gunbc/auth/approval_device_wire.dag index 5ef9749487f..7ab786add38 100644 --- a/dag/gunbc/auth/approval_device_wire.dag +++ b/dag/gunbc/auth/approval_device_wire.dag @@ -2,8 +2,11 @@ module gunbc.auth.approval_device_wire import std.types { NonEmptyStr, String, Timestamp, Int, List } import std.measure { Second, second } -import extdeps.crypto.signature { VerifyingKey } -import extdeps.apple.apns { ApnsCustomKey } +import extdeps.crypto.signature { VerifyingKey, SignatureBytes } +import extdeps.apple.apns { ApnsCustomKey, ApnsEnvironment, ApnsTopic, ApnsDeviceToken } +import extdeps.apple.app_attest { AttestKeyId } +import extdeps.google.fcm { FcmProjectId, FcmRegistrationToken } +import extdeps.android.key_attestation { AndroidAttestationChain } import gunbc.auth.approval_capability { ProposedDecision, proposed_decision_name } // ── What this module is ────────────────────────────────────────────────────────────────────── @@ -111,8 +114,9 @@ data approval_device_audience: NonEmptyStr = "gunbc.roadmap_serve/device-redempt // stored_request_text is gunbc.auth.approval_decision_store stored_request_json of the record the // app displayed, byte for byte; capability_text is approval_capability_signing_input of the -// capability for the chosen verb; capability_tag_hex is that capability's MAC tag in the lowercase -// hex the existing /approve segment and mac_verify both use. The server re-renders all three from +// capability for the chosen verb; capability_tag_b64url is that capability's tag in its canonical +// wire spelling (gunbc.auth.approval_capability ApprovalCapability tag_b64url), which the server +// converts through capability_tag_hex before mac_verify -- the one declared inverse. The server re-renders all three from // the STORED request and refuses any disagreement, so the device signs no third spelling. type DeviceRedemptionSigningInput { audience: NonEmptyStr @@ -123,7 +127,7 @@ type DeviceRedemptionSigningInput { stored_request_text: NonEmptyStr decision: ProposedDecision capability_text: NonEmptyStr - capability_tag_hex: NonEmptyStr + capability_tag_b64url: NonEmptyStr } fn device_redemption_signing_input(i: DeviceRedemptionSigningInput) -> NonEmptyStr { @@ -138,7 +142,7 @@ fn device_redemption_signing_input(i: DeviceRedemptionSigningInput) -> NonEmptyS i.stored_request_text as String, proposed_decision_name(d: i.decision) as String, i.capability_text as String, - i.capability_tag_hex as String, + i.capability_tag_b64url as String, ]) } @@ -150,3 +154,50 @@ data approval_device_pending_path: NonEmptyStr = "/approve/device/pending" as No data approval_device_request_path_prefix: NonEmptyStr = "/approve/device/requests/" as NonEmptyStr data approval_device_redeem_path: NonEmptyStr = "/approve/device/redeem" as NonEmptyStr +// ── Request bodies ─────────────────────────────────────────────────────────────────────────── +// DECLARED FRONTIER, stated once for every type in this section: they are the JSON bodies of the +// device routes, decoded by the route handlers that gunbc.auth.approval_device_redemption +// approval_device_routes_frontier names, and constructed by the app (ios_realization_frontier). No +// fold in this change reads them; the trigger is those handlers landing. +// Push registration is SEPARATE from enrolment: a token rotates (reinstall, restore, APNs 410) +// without changing who is enrolled or which key decides, and revoking an enrolment must end its +// authority whatever push tokens still exist. +type PushRegistration + = ApnsRegistration { environment: ApnsEnvironment, topic: ApnsTopic, token: ApnsDeviceToken } + | FcmRegistration { project: FcmProjectId, token: FcmRegistrationToken } + +// What the app PRESENTS at enrolment: raw, unverified. +type PresentedEnrollmentEvidence + = IosAppAttestAttestation { attest_key_id: AttestKeyId, attestation_b64: NonEmptyStr } + | AndroidKeyAttestationChain { chain: AndroidAttestationChain } + +// THE ENROLMENT POST BODY. The server reads the code's slot, verifies the evidence over +// enrolment_transcript, and derives the login from the code; nothing here names a person. +type EnrolmentRequest { + code: NonEmptyStr + platform: MobilePlatform + decision_key: VerifyingKey + evidence: PresentedEnrollmentEvidence + push: PushRegistration +} + +// Per-redemption platform proof. iOS: an App Attest assertion whose client data is EXACTLY the +// signing input. THE ASSERTION COUNTER IS NOT STORED, a stated departure from Apple's recommended +// flow: Apple uses it to refuse replay, and a redemption replay is already refused twice -- the +// challenge is server-minted, expiring and bound to one escalation revision, and the decision slot +// admits one decision per escalation. Storing it would add one CAS generation per approval against +// the store's generation probe bound, for no refusal the route lacks. (Reads are the exception, and +// their residual is stated at device_read_client_data.) +type PlatformRedemptionProof + = IosAppAttestAssertion { assertion_b64: NonEmptyStr } + | AndroidDecisionKeyOnly + +// THE REDEEM POST BODY: the wire carrier only. The server turns signature and platform_proof into +// seam-minted verdicts and hands admission those verdicts with signing_input; admission never reads +// these raw fields, so there is one representation of each proof on each side of the seam. +type SignedRedemption { + signing_input: DeviceRedemptionSigningInput + signature: SignatureBytes + platform_proof: PlatformRedemptionProof +} + diff --git a/dag/test/claim/approval_device_redemption_witness_test.dag b/dag/test/claim/approval_device_redemption_witness_test.dag index 7b1b6702316..e51bfeb1298 100644 --- a/dag/test/claim/approval_device_redemption_witness_test.dag +++ b/dag/test/claim/approval_device_redemption_witness_test.dag @@ -15,7 +15,7 @@ import extdeps.crypto.signature { VerifyingKey, SignatureBytes, SignatureVerification, EcdsaP256Sha256, Sec1Uncompressed, P1363FixedWidth, signature_verification_from_implementation, } -import gunbc.auth.approval_capability { ProposeApprove, ProposeDeny, approval_capability_signing_input } +import gunbc.auth.approval_capability { ProposedDecision, ProposeApprove, ProposeDeny, approval_capability_signing_input, issue_capability, CapabilityIssued, CapabilityIssuanceRefused } import gunbc.auth.approval_device_redemption { EnrolmentChallenge, PushProviderAccepted, PushProviderRefused, PushRegistrationInvalid, PushProviderUnreached, PushProviderUndecodable, @@ -23,11 +23,12 @@ import gunbc.auth.approval_device_redemption { VerifiedDeviceEnrollment, EnrolmentSlotStanding, EnrolmentCodeUnspent, EnrolmentCodeConsumed, EnrolmentRevoked, EnrolmentCodeUnknown, EnrolmentAdmitted, EnrolmentRefused, EnrolmentCodeAlreadyUsed, EnrolmentCodeExpired, EnrolmentAttestationForOtherBytes, EnrolmentAttestationRefused, EnrolmentAndroidUnrealized, enrolment_admission, - IosAppAttestAssertion, SignedRedemption, DeviceAdmission, DeviceAdmitted, DeviceNotAdmitted, - DeviceSignedForAnotherRequest, DeviceChallengeExpired, DeviceChallengeNotIssuedHere, DeviceCapabilityRefused, + DeviceAdmission, DeviceAdmitted, DeviceNotAdmitted, + DeviceSignedForAnotherRequest, DeviceChallengeExpired, DeviceChallengeNotIssuedHere, DeviceCapabilityRefused, DeviceCapabilityTagMalformed, DeviceSignatureRefused, DevicePlatformProofRefused, DevicePlatformProofForOtherBytes, DevicePlatformProofWrongKey, DeviceEnrollmentRevoked, DeviceEnrollmentForAnotherOperator, DeviceEnrollmentUnknown, device_redemption_admission, redeem_device_over_store, + parse_enrolment_record, enrolment_json, enrolment_code_json, code_of_enrollment_id, } import gunbc.auth.approval_device_wire { DeviceRedemptionSigningInput, RedemptionChallenge, MobileIos, MobileAndroid, @@ -194,7 +195,7 @@ fn fx_signing_input(request_text: NonEmptyStr, expiry: Timestamp, nonce: NonEmpt stored_request_text: request_text, decision: ProposeApprove, capability_text: approval_capability_signing_input(c: claims_for(request: fx_request(), decision: ProposeApprove)), - capability_tag_hex: tag, + capability_tag_b64url: tag, } } @@ -202,8 +203,17 @@ fn fx_nonce() -> NonEmptyStr { mac_hex(message: redemption_challenge_message(escalation_id: fx_request().escalation_id, request_revision: fx_request().request_revision, enrollment_id: "enr-482913", expires_at: fx_challenge_expiry)) } +// THE TAG COMES FROM REAL ISSUANCE, in its canonical base64url wire spelling -- the same route the +// server's links take -- so admission's capability_tag_hex conversion is exercised, not bypassed. +fn issued_tag(decision: ProposedDecision) -> NonEmptyStr { + match issue_capability(claims: claims_for(request: fx_request(), decision: decision), key: fx_key()) { + CapabilityIssued { capability: c } => c.tag_b64url + CapabilityIssuanceRefused { cause: _ } => "issuance-refused" + } +} + fn fx_tag() -> NonEmptyStr { - mac_hex(message: approval_capability_signing_input(c: claims_for(request: fx_request(), decision: ProposeApprove))) + issued_tag(decision: ProposeApprove) } fn fx_good_input() -> DeviceRedemptionSigningInput { @@ -215,18 +225,14 @@ fn verdict(key: VerifyingKey, message: NonEmptyStr) -> SignatureVerification { } fn assertion_over(key_id: NonEmptyStr, client_data: NonEmptyStr) -> AssertionVerification { - assertion_verification_from_implementation(key_id: key_id, counter: 1, client_data: client_data, refusal: none) -} - -fn redemption(input: DeviceRedemptionSigningInput) -> SignedRedemption { - SignedRedemption { signing_input: input, signature: sig, platform_proof: IosAppAttestAssertion { assertion_b64: "ASSERT" } } + assertion_verification_from_implementation(key_id: key_id, public_key_point_b64url: "BATTEST", counter: 1, client_data: client_data, refusal: none) } fn admit(input: DeviceRedemptionSigningInput, signature: SignatureVerification, assertion: AssertionVerification) -> DeviceAdmission { match fx_admitted_enrollment() { Absent => DeviceNotAdmitted { outcome: DeviceEnrollmentUnknown } Present { value: e } => - device_redemption_admission(key: fx_key(), enrollment: e, request: fx_request(), redemption: redemption(input: input), signature: signature, assertion: assertion, observed_at: fx_observed_at) + device_redemption_admission(key: fx_key(), enrollment: e, request: fx_request(), signed: input, signature: signature, assertion: assertion, observed_at: fx_observed_at) } } @@ -272,7 +278,7 @@ test fn witness_device_admission_refuses_a_challenge_not_issued_here() -> Bool { } test fn witness_device_admission_refuses_a_capability_for_the_other_verb() -> Bool { - let deny_tag = mac_hex(message: approval_capability_signing_input(c: claims_for(request: fx_request(), decision: ProposeDeny))) + let deny_tag = issued_tag(decision: ProposeDeny) let i = fx_signing_input(request_text: stored_request_json(r: fx_request()) as NonEmptyStr, expiry: fx_challenge_expiry, nonce: fx_nonce(), tag: deny_tag) match admit(input: i, signature: verdict(key: key_a, message: device_redemption_signing_input(i: i)), assertion: assertion_over(key_id: "attest-1", client_data: device_redemption_signing_input(i: i))) { DeviceNotAdmitted { outcome: DeviceCapabilityRefused { cause: _ } } => true @@ -295,7 +301,7 @@ test fn witness_device_admission_refuses_a_signature_under_another_key() -> Bool } test fn witness_device_admission_refuses_a_refused_assertion() -> Bool { - let refused = assertion_verification_from_implementation(key_id: "attest-1", counter: 1, client_data: device_redemption_signing_input(i: fx_good_input()), refusal: Present { value: AssertionSignatureInvalid }) + let refused = assertion_verification_from_implementation(key_id: "attest-1", public_key_point_b64url: "BATTEST", counter: 1, client_data: device_redemption_signing_input(i: fx_good_input()), refusal: Present { value: AssertionSignatureInvalid }) match admit(input: fx_good_input(), signature: good_signature(), assertion: refused) { DeviceNotAdmitted { outcome: DevicePlatformProofRefused { cause: _ } } => true _ => false @@ -325,7 +331,7 @@ test fn witness_device_redemption_refuses_a_revoked_enrollment_before_the_store( root: "/nonexistent/approvals", keyring: ApprovalKeyLoaded { key: fx_key() }, enrolment: EnrolmentRevoked { enrollment: e, revoked_at: "2026-09-17T09:59:00Z", reason: "lost phone" }, - redemption: redemption(input: fx_good_input()), + signed: fx_good_input(), signature: good_signature(), assertion: good_assertion(), observed_at: fx_observed_at, @@ -341,7 +347,7 @@ test fn witness_device_redemption_refuses_an_unknown_enrollment_before_the_store root: "/nonexistent/approvals", keyring: ApprovalKeyLoaded { key: fx_key() }, enrolment: EnrolmentCodeUnknown, - redemption: redemption(input: fx_good_input()), + signed: fx_good_input(), signature: good_signature(), assertion: good_assertion(), observed_at: fx_observed_at, @@ -357,9 +363,67 @@ test fn witness_device_admission_refuses_an_enrollment_for_another_operator() -> match enrolment_admission(slot: other_code, platform: MobileIos, decision_key: key_a, attestation: fx_attestation(client_data: enrolment_transcript(code: fx_code, decision_key: key_a, platform: MobileIos)), observed_at: fx_observed_at) { EnrolmentRefused { cause: _ } => false EnrolmentAdmitted { enrollment: e } => - match device_redemption_admission(key: fx_key(), enrollment: e, request: fx_request(), redemption: redemption(input: fx_good_input()), signature: good_signature(), assertion: good_assertion(), observed_at: fx_observed_at) { + match device_redemption_admission(key: fx_key(), enrollment: e, request: fx_request(), signed: fx_good_input(), signature: good_signature(), assertion: good_assertion(), observed_at: fx_observed_at) { DeviceNotAdmitted { outcome: DeviceEnrollmentForAnotherOperator { enrolled: _, expected: _ } } => true _ => false } } } + +// ── The enrolment store's records ──────────────────────────────────────────────────────────── +// A persisted enrolment reads back as the SAME verified enrolment, and a record at the wrong +// generation for its kind is unreadable -- never reinterpreted as a live enrolment. +test fn witness_enrolment_record_round_trips_at_generation_two() -> Bool { + match fx_admitted_enrollment() { + Absent => false + Present { value: e } => + match parse_enrolment_record(text: enrolment_json(e: e), generation: 2) { + EnrolmentCodeConsumed { enrollment: r } => + r.enrollment_id == e.enrollment_id && r.operator_login == e.operator_login && r.decision_key.point_b64url == e.decision_key.point_b64url + _ => false + } + } +} + +test fn witness_enrolment_record_at_the_wrong_generation_is_unreadable() -> Bool { + match fx_admitted_enrollment() { + Absent => false + Present { value: e } => + match parse_enrolment_record(text: enrolment_json(e: e), generation: 1) { + EnrolmentSlotUnreadable { detail: _ } => true + _ => false + } + } +} + +test fn witness_issued_code_record_round_trips_at_generation_one() -> Bool { + match parse_enrolment_record(text: enrolment_code_json(c: fx_code_challenge(expires_at: "2026-09-17T10:05:00Z")), generation: 1) { + EnrolmentCodeUnspent { challenge: c } => c.code == fx_code && c.issued_to_login == approval_operator_login + _ => false + } +} + +test fn witness_enrollment_id_names_its_code_slot() -> Bool { + code_of_enrollment_id(enrollment_id: "enr-482913") == "482913" && code_of_enrollment_id(enrollment_id: "other") == "" +} + +// THE ENCODING THE REVIEW CAUGHT: the raw lowercase-hex tag placed where the canonical base64url +// spelling belongs is refused, never verified as if it were the tag. +test fn witness_device_admission_refuses_a_hex_tag_in_the_base64url_field() -> Bool { + let hex = mac_hex(message: approval_capability_signing_input(c: claims_for(request: fx_request(), decision: ProposeApprove))) + let i = fx_signing_input(request_text: stored_request_json(r: fx_request()) as NonEmptyStr, expiry: fx_challenge_expiry, nonce: fx_nonce(), tag: hex) + match admit(input: i, signature: verdict(key: key_a, message: device_redemption_signing_input(i: i)), assertion: assertion_over(key_id: "attest-1", client_data: device_redemption_signing_input(i: i))) { + DeviceNotAdmitted { outcome: DeviceCapabilityTagMalformed } => true + DeviceNotAdmitted { outcome: DeviceCapabilityRefused { cause: _ } } => true + _ => false + } +} + +// An assertion under the enrolled key id but a DIFFERENT public key is not the enrolled app instance. +test fn witness_device_admission_refuses_an_assertion_under_another_public_key() -> Bool { + let other = assertion_verification_from_implementation(key_id: "attest-1", public_key_point_b64url: "BOTHER", counter: 1, client_data: device_redemption_signing_input(i: fx_good_input()), refusal: none) + match admit(input: fx_good_input(), signature: good_signature(), assertion: other) { + DeviceNotAdmitted { outcome: DevicePlatformProofWrongKey } => true + _ => false + } +} diff --git a/dag/test/claim/approval_device_wire_witness_test.dag b/dag/test/claim/approval_device_wire_witness_test.dag index 8a3ddcd9bd7..2e59a860f6b 100644 --- a/dag/test/claim/approval_device_wire_witness_test.dag +++ b/dag/test/claim/approval_device_wire_witness_test.dag @@ -11,7 +11,7 @@ import extdeps.crypto.signature { import gunbc.auth.approval_capability { ProposeApprove, ProposeDeny } import gunbc.auth.approval_device_wire { framed, device_redemption_signing_input, enrolment_transcript, DeviceRedemptionSigningInput, RedemptionChallenge, - approval_device_audience, MobileIos, MobileAndroid, + approval_device_audience, MobileIos, MobileAndroid, device_read_client_data, } data key_a: VerifyingKey = VerifyingKey { suite: EcdsaP256Sha256, encoding: Sec1Uncompressed, point_b64url: "BKEYA" } @@ -72,7 +72,7 @@ fn redemption_input(approve: Bool, enrollment: NonEmptyStr, nonce: NonEmptyStr, stored_request_text: request_text, decision: if approve { ProposeApprove } else { ProposeDeny }, capability_text: "cap", - capability_tag_hex: tag, + capability_tag_b64url: tag, }) } @@ -102,3 +102,12 @@ test fn witness_enrolment_transcript_binds_key_and_platform() -> Bool { enrolment_transcript(code: "482913", decision_key: key_a, platform: MobileIos) != enrolment_transcript(code: "482913", decision_key: key_b, platform: MobileIos) && enrolment_transcript(code: "482913", decision_key: key_a, platform: MobileIos) != enrolment_transcript(code: "482913", decision_key: key_a, platform: MobileAndroid) } + +// The read transcript binds the path and the enrolment: an assertion for one GET or one device +// cannot authenticate another. +test fn witness_read_transcript_binds_path_enrollment_and_time() -> Bool { + let base = device_read_client_data(path: "/approve/device/pending", enrollment_id: "enr-1", requested_at: "2026-09-18T12:00:30Z") + base != device_read_client_data(path: "/approve/device/requests/esc-1", enrollment_id: "enr-1", requested_at: "2026-09-18T12:00:30Z") + && base != device_read_client_data(path: "/approve/device/pending", enrollment_id: "enr-2", requested_at: "2026-09-18T12:00:30Z") + && base != device_read_client_data(path: "/approve/device/pending", enrollment_id: "enr-1", requested_at: "2026-09-18T12:00:31Z") +} diff --git a/dag/test/fixture/approval_device_redemption/vectors.json b/dag/test/fixture/approval_device_redemption/vectors.json index bb286f0f32c..a28edcb033d 100644 --- a/dag/test/fixture/approval_device_redemption/vectors.json +++ b/dag/test/fixture/approval_device_redemption/vectors.json @@ -1 +1 @@ -{"framing": "each field as :, concatenated", "redemption": [{"name": "approve-plain", "input": {"audience": "gunbc.roadmap_serve/device-redemption", "enrollment_id": "enr-7f3a", "challenge_expires_at": "2026-09-18T12:05:00Z", "nonce_hex": "9c1d2e3f4a5b6c7d8e9fa0b1c2d3e4f5061728394a5b6c7d8e9fa0b1c2d3e4f5", "escalation_id": "esc-mtc1-boot-1", "request_revision": "sha256:abababababababababababababababababababababababababababababababab", "stored_request_text": "{\"kind\":\"request\",\"escalation_id\":\"esc-mtc1-boot-1\",\"destructive\":true}", "decision": "approve", "capability_text": "gunbc.approval-capability.v1\u001Fgunbc.roadmap_serve\u001Fgunbc.auth.approval_broker", "capability_tag_hex": "d1f1f1034370302f1405bd0bcd71e907c079ead5bf912da095cbe110d7876663"}, "expected": "28:gunbc.approval-redemption.v1,37:gunbc.roadmap_serve/device-redemption,8:enr-7f3a,20:2026-09-18T12:05:00Z,64:9c1d2e3f4a5b6c7d8e9fa0b1c2d3e4f5061728394a5b6c7d8e9fa0b1c2d3e4f5,15:esc-mtc1-boot-1,71:sha256:abababababababababababababababababababababababababababababababab,71:{\"kind\":\"request\",\"escalation_id\":\"esc-mtc1-boot-1\",\"destructive\":true},7:approve,75:gunbc.approval-capability.v1\u001Fgunbc.roadmap_serve\u001Fgunbc.auth.approval_broker,64:d1f1f1034370302f1405bd0bcd71e907c079ead5bf912da095cbe110d7876663,"}, {"name": "deny-plain", "input": {"audience": "gunbc.roadmap_serve/device-redemption", "enrollment_id": "enr-7f3a", "challenge_expires_at": "2026-09-18T12:05:00Z", "nonce_hex": "9c1d2e3f4a5b6c7d8e9fa0b1c2d3e4f5061728394a5b6c7d8e9fa0b1c2d3e4f5", "escalation_id": "esc-mtc1-boot-1", "request_revision": "sha256:abababababababababababababababababababababababababababababababab", "stored_request_text": "{\"kind\":\"request\",\"escalation_id\":\"esc-mtc1-boot-1\",\"destructive\":true}", "decision": "deny", "capability_text": "gunbc.approval-capability.v1\u001Fgunbc.roadmap_serve\u001Fgunbc.auth.approval_broker", "capability_tag_hex": "d1f1f1034370302f1405bd0bcd71e907c079ead5bf912da095cbe110d7876663"}, "expected": "28:gunbc.approval-redemption.v1,37:gunbc.roadmap_serve/device-redemption,8:enr-7f3a,20:2026-09-18T12:05:00Z,64:9c1d2e3f4a5b6c7d8e9fa0b1c2d3e4f5061728394a5b6c7d8e9fa0b1c2d3e4f5,15:esc-mtc1-boot-1,71:sha256:abababababababababababababababababababababababababababababababab,71:{\"kind\":\"request\",\"escalation_id\":\"esc-mtc1-boot-1\",\"destructive\":true},4:deny,75:gunbc.approval-capability.v1\u001Fgunbc.roadmap_serve\u001Fgunbc.auth.approval_broker,64:d1f1f1034370302f1405bd0bcd71e907c079ead5bf912da095cbe110d7876663,"}, {"name": "approve-escapes", "input": {"audience": "gunbc.roadmap_serve/device-redemption", "enrollment_id": "enr-7f3a", "challenge_expires_at": "2026-09-18T12:05:00Z", "nonce_hex": "9c1d2e3f4a5b6c7d8e9fa0b1c2d3e4f5061728394a5b6c7d8e9fa0b1c2d3e4f5", "escalation_id": "esc-mtc1-boot-1", "request_revision": "sha256:abababababababababababababababababababababababababababababababab", "stored_request_text": "{\"purpose\":\"Boot Mt. Collins \\\\ unit 1 — été\nline two\"}", "decision": "approve", "capability_text": "gunbc.approval-capability.v1\u001Fgunbc.roadmap_serve\u001Fgunbc.auth.approval_broker", "capability_tag_hex": "d1f1f1034370302f1405bd0bcd71e907c079ead5bf912da095cbe110d7876663"}, "expected": "28:gunbc.approval-redemption.v1,37:gunbc.roadmap_serve/device-redemption,8:enr-7f3a,20:2026-09-18T12:05:00Z,64:9c1d2e3f4a5b6c7d8e9fa0b1c2d3e4f5061728394a5b6c7d8e9fa0b1c2d3e4f5,15:esc-mtc1-boot-1,71:sha256:abababababababababababababababababababababababababababababababab,55:{\"purpose\":\"Boot Mt. Collins \\\\ unit 1 — été\nline two\"},7:approve,75:gunbc.approval-capability.v1\u001Fgunbc.roadmap_serve\u001Fgunbc.auth.approval_broker,64:d1f1f1034370302f1405bd0bcd71e907c079ead5bf912da095cbe110d7876663,"}], "enrolment": [{"name": "ios", "input": {"code": "482913", "platform": "ios", "point_b64url": "BHt2Zm9vYmFyYmF6cXV4"}, "expected": "27:gunbc.approval-enrolment.v1,6:482913,3:ios,20:BHt2Zm9vYmFyYmF6cXV4,"}, {"name": "android", "input": {"code": "482913", "platform": "android", "point_b64url": "BHt2Zm9vYmFyYmF6cXV4"}, "expected": "27:gunbc.approval-enrolment.v1,6:482913,7:android,20:BHt2Zm9vYmFyYmF6cXV4,"}]} +{"framing": "each field as :, concatenated", "redemption": [{"name": "approve-plain", "input": {"audience": "gunbc.roadmap_serve/device-redemption", "enrollment_id": "enr-7f3a", "challenge_expires_at": "2026-09-18T12:05:00Z", "nonce_hex": "9c1d2e3f4a5b6c7d8e9fa0b1c2d3e4f5061728394a5b6c7d8e9fa0b1c2d3e4f5", "escalation_id": "esc-mtc1-boot-1", "request_revision": "sha256:abababababababababababababababababababababababababababababababab", "stored_request_text": "{\"kind\":\"request\",\"escalation_id\":\"esc-mtc1-boot-1\",\"destructive\":true}", "decision": "approve", "capability_text": "gunbc.approval-capability.v1\u001Fgunbc.roadmap_serve\u001Fgunbc.auth.approval_broker", "capability_tag_b64url": "0fHxA0NwMC8UBb0LzXHpB8B56tW_kS2glcvhENeHZmM="}, "expected": "28:gunbc.approval-redemption.v1,37:gunbc.roadmap_serve/device-redemption,8:enr-7f3a,20:2026-09-18T12:05:00Z,64:9c1d2e3f4a5b6c7d8e9fa0b1c2d3e4f5061728394a5b6c7d8e9fa0b1c2d3e4f5,15:esc-mtc1-boot-1,71:sha256:abababababababababababababababababababababababababababababababab,71:{\"kind\":\"request\",\"escalation_id\":\"esc-mtc1-boot-1\",\"destructive\":true},7:approve,75:gunbc.approval-capability.v1\u001Fgunbc.roadmap_serve\u001Fgunbc.auth.approval_broker,44:0fHxA0NwMC8UBb0LzXHpB8B56tW_kS2glcvhENeHZmM=,"}, {"name": "deny-plain", "input": {"audience": "gunbc.roadmap_serve/device-redemption", "enrollment_id": "enr-7f3a", "challenge_expires_at": "2026-09-18T12:05:00Z", "nonce_hex": "9c1d2e3f4a5b6c7d8e9fa0b1c2d3e4f5061728394a5b6c7d8e9fa0b1c2d3e4f5", "escalation_id": "esc-mtc1-boot-1", "request_revision": "sha256:abababababababababababababababababababababababababababababababab", "stored_request_text": "{\"kind\":\"request\",\"escalation_id\":\"esc-mtc1-boot-1\",\"destructive\":true}", "decision": "deny", "capability_text": "gunbc.approval-capability.v1\u001Fgunbc.roadmap_serve\u001Fgunbc.auth.approval_broker", "capability_tag_b64url": "0fHxA0NwMC8UBb0LzXHpB8B56tW_kS2glcvhENeHZmM="}, "expected": "28:gunbc.approval-redemption.v1,37:gunbc.roadmap_serve/device-redemption,8:enr-7f3a,20:2026-09-18T12:05:00Z,64:9c1d2e3f4a5b6c7d8e9fa0b1c2d3e4f5061728394a5b6c7d8e9fa0b1c2d3e4f5,15:esc-mtc1-boot-1,71:sha256:abababababababababababababababababababababababababababababababab,71:{\"kind\":\"request\",\"escalation_id\":\"esc-mtc1-boot-1\",\"destructive\":true},4:deny,75:gunbc.approval-capability.v1\u001Fgunbc.roadmap_serve\u001Fgunbc.auth.approval_broker,44:0fHxA0NwMC8UBb0LzXHpB8B56tW_kS2glcvhENeHZmM=,"}, {"name": "approve-escapes", "input": {"audience": "gunbc.roadmap_serve/device-redemption", "enrollment_id": "enr-7f3a", "challenge_expires_at": "2026-09-18T12:05:00Z", "nonce_hex": "9c1d2e3f4a5b6c7d8e9fa0b1c2d3e4f5061728394a5b6c7d8e9fa0b1c2d3e4f5", "escalation_id": "esc-mtc1-boot-1", "request_revision": "sha256:abababababababababababababababababababababababababababababababab", "stored_request_text": "{\"purpose\":\"Boot Mt. Collins \\\\ unit 1 — été\nline two\"}", "decision": "approve", "capability_text": "gunbc.approval-capability.v1\u001Fgunbc.roadmap_serve\u001Fgunbc.auth.approval_broker", "capability_tag_b64url": "0fHxA0NwMC8UBb0LzXHpB8B56tW_kS2glcvhENeHZmM="}, "expected": "28:gunbc.approval-redemption.v1,37:gunbc.roadmap_serve/device-redemption,8:enr-7f3a,20:2026-09-18T12:05:00Z,64:9c1d2e3f4a5b6c7d8e9fa0b1c2d3e4f5061728394a5b6c7d8e9fa0b1c2d3e4f5,15:esc-mtc1-boot-1,71:sha256:abababababababababababababababababababababababababababababababab,55:{\"purpose\":\"Boot Mt. Collins \\\\ unit 1 — été\nline two\"},7:approve,75:gunbc.approval-capability.v1\u001Fgunbc.roadmap_serve\u001Fgunbc.auth.approval_broker,44:0fHxA0NwMC8UBb0LzXHpB8B56tW_kS2glcvhENeHZmM=,"}], "enrolment": [{"name": "ios", "input": {"code": "482913", "platform": "ios", "point_b64url": "BHt2Zm9vYmFyYmF6cXV4"}, "expected": "27:gunbc.approval-enrolment.v1,6:482913,3:ios,20:BHt2Zm9vYmFyYmF6cXV4,"}, {"name": "android", "input": {"code": "482913", "platform": "android", "point_b64url": "BHt2Zm9vYmFyYmF6cXV4"}, "expected": "27:gunbc.approval-enrolment.v1,6:482913,7:android,20:BHt2Zm9vYmFyYmF6cXV4,"}], "read": [{"name": "pending", "input": {"path": "/approve/device/pending", "enrollment_id": "enr-482913", "requested_at": "2026-09-18T12:00:30Z"}, "expected": "29:gunbc.approval-device-read.v1,23:/approve/device/pending,10:enr-482913,20:2026-09-18T12:00:30Z,"}, {"name": "request", "input": {"path": "/approve/device/requests/esc-mtc1-boot-1", "enrollment_id": "enr-482913", "requested_at": "2026-09-18T12:00:30Z"}, "expected": "29:gunbc.approval-device-read.v1,40:/approve/device/requests/esc-mtc1-boot-1,10:enr-482913,20:2026-09-18T12:00:30Z,"}]} From df7fa53f14cc7d0fcceafdd102960f00ba03f2ad Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 10:44:05 +0000 Subject: [PATCH 10/17] List join via concat, not the ambiguous bare concat_lists (found by lane A) Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/auth/approval_device_redemption.dag | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dag/gunbc/auth/approval_device_redemption.dag b/dag/gunbc/auth/approval_device_redemption.dag index d623a71ca77..d07df7ea60e 100644 --- a/dag/gunbc/auth/approval_device_redemption.dag +++ b/dag/gunbc/auth/approval_device_redemption.dag @@ -504,7 +504,7 @@ fn enrolment_members(kind: String, e: VerifiedDeviceEnrollment) -> List [] } - concat_lists([ + concat([ json_kv(key: "kind", value: json_string(s: kind)), json_kv(key: "enrollment_id", value: json_string(s: e.enrollment_id as String)), json_kv(key: "operator_login", value: json_string(s: e.operator_login as String)), @@ -519,7 +519,7 @@ fn enrolment_json(e: VerifiedDeviceEnrollment) -> String { } fn revocation_json(e: VerifiedDeviceEnrollment, revoked_at: Timestamp, reason: NonEmptyStr) -> String { - serialize_json(v: json_object(members: concat_lists(enrolment_members(kind: "revoked", e: e), [ + serialize_json(v: json_object(members: concat(enrolment_members(kind: "revoked", e: e), [ json_kv(key: "revoked_at", value: json_string(s: revoked_at)), json_kv(key: "reason", value: json_string(s: reason as String)), ]))) From 7123b087303efc39aa441d7a4dd7ce80b6a6dec2 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 10:55:05 +0000 Subject: [PATCH 11/17] =?UTF-8?q?=C2=A74c:=20drop=20the=20prose-only=20*?= =?UTF-8?q?=5Fnote=20data=20rows=20(review=2067593);=20the=20//=20blocks?= =?UTF-8?q?=20already=20carry=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- dag/extdeps/android/key_attestation.dag | 2 -- dag/extdeps/apple/apns.dag | 3 --- dag/extdeps/apple/app_attest.dag | 3 --- dag/extdeps/apple/secure_enclave.dag | 22 ++++++++----------- dag/extdeps/crypto/signature.dag | 1 - dag/extdeps/google/fcm.dag | 2 -- dag/gunbc/auth/approval_device_redemption.dag | 1 - 7 files changed, 9 insertions(+), 25 deletions(-) diff --git a/dag/extdeps/android/key_attestation.dag b/dag/extdeps/android/key_attestation.dag index 363b9ad9253..912a8c09997 100644 --- a/dag/extdeps/android/key_attestation.dag +++ b/dag/extdeps/android/key_attestation.dag @@ -32,5 +32,3 @@ type KeymasterSecurityLevel type AndroidAttestationChain { certificates_der_b64: List } - -data software_level_is_not_hardware_note: NonEmptyStr = "SecurityLevelSoftware means the key is not hardware-backed; a verifier requiring a device-bound key refuses it rather than admitting it at a weaker standing." diff --git a/dag/extdeps/apple/apns.dag b/dag/extdeps/apple/apns.dag index 212089172dc..e6c18e2e07d 100644 --- a/dag/extdeps/apple/apns.dag +++ b/dag/extdeps/apple/apns.dag @@ -33,7 +33,6 @@ fn apns_host(e: ApnsEnvironment) -> NonEmptyStr { } } -data testflight_is_production_note: NonEmptyStr = "TestFlight builds are distribution-signed, so their device tokens are production tokens and must be sent to api.push.apple.com. Only an Xcode development build uses api.sandbox.push.apple.com." // ── Token-based provider authentication ────────────────────────────────────────────────────── // The provider signs a JWT (RFC 7519) with the .p8 auth key: header { alg: ES256, kid: }, @@ -188,5 +187,3 @@ fn apns_send_outcome_of(rest: RestOutcome, apns_id: String) -> ApnsSendOutcome { RestBodyUndecodable { status, cause } => ApnsUndecodable { status: status, cause: cause as String } } } - -data apns_accepted_is_not_delivery_note: NonEmptyStr = "ApnsAccepted establishes that APNs accepted the request for delivery. APNs stores and forwards one notification per device while it is offline and may drop it; acceptance is not delivery, not display, and never consent." diff --git a/dag/extdeps/apple/app_attest.dag b/dag/extdeps/apple/app_attest.dag index 02f1308a7ca..47823cd66cf 100644 --- a/dag/extdeps/apple/app_attest.dag +++ b/dag/extdeps/apple/app_attest.dag @@ -25,7 +25,6 @@ data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { // * It certifies nothing about any other key. The link from the attested app to the decision key // is made by the enrolment protocol: the clientDataHash the app attests over commits to the // decision key and the one-time enrolment code (gunbc.auth.approval_device_redemption). -data attestation_proves_the_app_not_the_person_note: NonEmptyStr = "An App Attest attestation certifies a Secure Enclave key belonging to a genuine instance of one team id + bundle id on Apple hardware. It carries no biometric gate and says nothing about other keys; binding it to the biometric-gated decision key is the enrolment protocol's job, through the clientDataHash." type AttestKeyId = NonEmptyStr where brand("AttestKeyId") @@ -66,7 +65,6 @@ type AttestationRefusal | AttestationBundleVersionAbsent | AttestationUndecodable { cause: String } -data apple_app_attest_root_ca_note: NonEmptyStr = "The trust anchor is Apple's App Attestation Root CA, published at www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem. It is pinned by the verifier's configuration, never taken from the attestation's own x5c." data app_attest_nonce_extension_oid: NonEmptyStr = "1.2.840.113635.100.8.2" @@ -111,7 +109,6 @@ fn attestation_verification_from_implementation( } } -data app_attest_unavailable_in_simulator_note: NonEmptyStr = "DCAppAttestService.isSupported is false in the Simulator and on devices without a Secure Enclave; the app must refuse enrolment there rather than enrolling without an attestation." // ── Assertions ─────────────────────────────────────────────────────────────────────────────── // Each later request carries an assertion: CBOR { signature, authenticatorData } produced by diff --git a/dag/extdeps/apple/secure_enclave.dag b/dag/extdeps/apple/secure_enclave.dag index 91027749ec4..75567c23ced 100644 --- a/dag/extdeps/apple/secure_enclave.dag +++ b/dag/extdeps/apple/secure_enclave.dag @@ -25,26 +25,22 @@ data secure_enclave_signing_suite: SignatureSuite = EcdsaP256Sha256 data secure_enclave_public_key_encoding: PublicKeyEncoding = Sec1Uncompressed data secure_enclave_signature_encoding: SignatureEncoding = P1363FixedWidth -data private_key_never_leaves_note: NonEmptyStr = "A Secure Enclave private key is generated inside the enclave and cannot be exported; its dataRepresentation is a blob only that device's enclave can unwrap. Possession of a signature under the enrolled public key is therefore evidence of possession of that physical device -- not of the person holding it, which is what the access-control policy below adds." - +// THE SIGNED INPUT IS HASHED BY CRYPTOKIT, NOT BY THE CALLER. PrivateKey.signature(for: Data) hashes +// the data with SHA-256 and signs the digest, which is the verify side's expectation under +// EcdsaP256Sha256. Signing a pre-computed digest through signature(for: digest) and verifying the +// raw message would double-hash; the model fixes the one spelling. +// // ── Who may use the key ────────────────────────────────────────────────────────────────────── // The access-control flags bound to the key at CREATION, not checked by the app at sign time. That // is the difference between an enclave-enforced gate and an app-enforced one: with the policy on the // key, the enclave itself refuses to sign without a satisfied biometric, so a compromised app process // cannot sign silently. An LAContext prompt the app shows before calling an unguarded key is a UI, // not a gate. +// +// biometryCurrentSet invalidates the key when the enrolled fingers or face change. That is the +// property wanted here: someone who adds their face to the operator's unlocked phone does not inherit +// the ability to approve. The cost is that re-enrolling Face ID requires re-enrolling the device. type EnclaveKeyAccessPolicy = BiometryCurrentSet | BiometryAny | UserPresence - -// biometryCurrentSet invalidates the key when the enrolled fingers or face change. That is the -// property wanted here: someone who adds their face to the operator's unlocked phone does not inherit -// the ability to approve. The cost is that re-enrolling Face ID requires re-enrolling the device. -data approval_key_access_policy_note: NonEmptyStr = "BiometryCurrentSet binds the key to the biometric set enrolled at key creation: adding or removing a face or finger invalidates it. BiometryAny survives such a change; UserPresence also admits the device passcode. For a key whose signature names the operator, BiometryCurrentSet is the only arm under which a changed biometric set cannot silently inherit the operator's authority." - -// THE SIGNED INPUT IS HASHED BY CRYPTOKIT, NOT BY THE CALLER. PrivateKey.signature(for: Data) hashes -// the data with SHA-256 and signs the digest, which is the verify side's expectation under -// EcdsaP256Sha256. Signing a pre-computed digest through signature(for: digest) and verifying the -// raw message would double-hash; the model fixes the one spelling. -data enclave_signs_message_not_digest_note: NonEmptyStr = "The app passes the exact signing-input bytes to signature(for:), which SHA-256-hashes them; the server verifies ECDSA-P256-SHA256 over the same bytes. Neither side pre-hashes." diff --git a/dag/extdeps/crypto/signature.dag b/dag/extdeps/crypto/signature.dag index 0608fe72faa..9e9a4e0b595 100644 --- a/dag/extdeps/crypto/signature.dag +++ b/dag/extdeps/crypto/signature.dag @@ -22,7 +22,6 @@ data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { // server that records which device decided, and must not be able to forge that device's decision -- // needs a public-key construction. That is the only property this module adds, and the only one a // consumer may rely on it for. -data verification_key_cannot_sign_note: NonEmptyStr = "An ECDSA public key verifies and cannot sign. A record that names a device's public key as the signer of a decision therefore attributes the decision to whoever holds the private key, and nothing that holds only the public key -- including the server storing the record -- can manufacture a matching signature. That is the property a MAC cannot give (extdeps.crypto.mac symmetric_verification_is_issuance_note)." // ONE FIXED SUITE, closed for the same algorithm-confusion reason as MacSuite: the verifier picks // the suite from its enrolled key, never from the message it is checking. diff --git a/dag/extdeps/google/fcm.dag b/dag/extdeps/google/fcm.dag index 0eb950ba06d..8673ba622a7 100644 --- a/dag/extdeps/google/fcm.dag +++ b/dag/extdeps/google/fcm.dag @@ -30,5 +30,3 @@ type FcmErrorCode | FcmUnavailable | FcmInternal | FcmThirdPartyAuthError - -data fcm_transport_encryption_note: NonEmptyStr = "FCM encrypts in transit but is not end-to-end encrypted: Google can read message content. Firebase's own guidance for sensitive payloads is an opaque data message followed by a fetch from the application server." diff --git a/dag/gunbc/auth/approval_device_redemption.dag b/dag/gunbc/auth/approval_device_redemption.dag index d07df7ea60e..e5a38f4b778 100644 --- a/dag/gunbc/auth/approval_device_redemption.dag +++ b/dag/gunbc/auth/approval_device_redemption.dag @@ -77,7 +77,6 @@ import gunbc.secret_provision { SecretRef, fleet_secret_ref, secret_ref_pin_vers // reach the dashboard on srv1 within the code's lifetime". Single use and a short lifetime bound it; // peer attribution at the serve boundary (approval_identity_attribution_frontier) is what would make // it operator-only. -data enrolment_code_residual_note: NonEmptyStr = "The one-time enrolment code is issued on the dashboard, which binds loopback behind tailscale serve: off-tailnet callers are excluded, on-host POSIX users are not. It is single-use and short-lived; it is not operator-only until peer attribution at the serve boundary lands (approval_identity_attribution_frontier)." // THE CODE IS THE IDENTIFIER: the operator types one thing, so a separate id would be a second key // for one fact. It also names the enrolment it produces (enrollment_id_for_code), so issuance, From cbf5a0657434c54bc512b5c1a90ef1f7374a1c13 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 11:05:39 +0000 Subject: [PATCH 12/17] review 67668: register the wire vectors as a generated artifact; CAS expectation typed - vectors.json is gunbc.generated_artifact ApprovalDeviceVectorsArtifact (new RepoConsumer CrossLanguageClientTest), so the required generated-artifact phase and the refusing merge driver gate it; the module's own regen/agree are deleted as a second route. - device_store_write takes std.durable_compare_and_set CasExpectation; the Int decode and its absorbing else are gone. Co-Authored-By: Claude Opus 5 (1M context) --- .gitattributes | 1 + dag/gunbc/auth/approval_device_redemption.dag | 13 ++++----- .../approval_device_redemption_fixtures.dag | 27 +++---------------- dag/gunbc/generated_artifact.dag | 11 +++++++- dag/gunbc/generated_artifact_emit.dag | 7 ++++- .../instruments/docs_projection_gate.dag | 4 ++- dag/gunbc/ledger_row_coherence.dag | 3 ++- 7 files changed, 31 insertions(+), 35 deletions(-) diff --git a/.gitattributes b/.gitattributes index effcd467520..603173838e4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -23,6 +23,7 @@ ROADMAP.md merge=generated-artifact dag/gunbc/stage0/stage0_crate_layout_generated.dag merge=generated-artifact dag/gunbc/stage0/stage0_crate_partition_generated.dag merge=generated-artifact dag/gunbc/stage0/stage0_executable_assembly_generated.dag merge=generated-artifact +dag/test/fixture/approval_device_redemption/vectors.json merge=generated-artifact docs/design-rung-drops.md merge=generated-artifact docs/onboarding.md merge=generated-artifact docs/plans/blackjack-onboarding.md merge=generated-artifact diff --git a/dag/gunbc/auth/approval_device_redemption.dag b/dag/gunbc/auth/approval_device_redemption.dag index e5a38f4b778..27d3182c1f3 100644 --- a/dag/gunbc/auth/approval_device_redemption.dag +++ b/dag/gunbc/auth/approval_device_redemption.dag @@ -40,7 +40,7 @@ import gunbc.durable_cas_file_store { import std.durable_compare_and_set { cas_unreadable_slot_detail, CasCommitted, CasPreconditionFailed, CasStoreRefused, CasObservedReadable, CasObservedUnreadable, CasReadableAbsent, CasReadablePresent, - ExpectSlotAbsent, ExpectSlotGeneration, cas_generation_first, cas_generation_next, cas_generation_count, + CasExpectation, ExpectSlotAbsent, ExpectSlotGeneration, cas_generation_first, cas_generation_next, cas_generation_count, } import extdeps.languages.json.emit { JsonValue, JsonKeyValue, serialize_json, json_object, json_kv, json_string } import extdeps.languages.json.parse { JsonDocumentParsed, JsonDocumentUnreadable, parse_json_document } @@ -469,10 +469,7 @@ type DeviceStoreWrite | DeviceStoreSlotOccupied | DeviceStoreRefused { detail: NonEmptyStr } -fn device_store_write(root: NonEmptyStr, key: NonEmptyStr, expected_generation: Int, payload: NonEmptyStr) -> DeviceStoreWrite { - let expected = if expected_generation == 0 { ExpectSlotAbsent } - else if expected_generation == 1 { ExpectSlotGeneration { generation: cas_generation_first() } } - else { ExpectSlotGeneration { generation: cas_generation_next(g: cas_generation_first()) } } +fn device_store_write(root: NonEmptyStr, key: NonEmptyStr, expected: CasExpectation, payload: NonEmptyStr) -> DeviceStoreWrite { match admit_cas_attempt_for_derived_payload(key: key, expected: expected, proposed: payload) { DerivedPayloadKeyNotSlotAddressable { key: _ } => DeviceStoreRefused { detail: "enrolment code is not slot-addressable" } DerivedPayloadAdmitted { verified: v } => @@ -613,7 +610,7 @@ fn observe_enrollment(root: NonEmptyStr, enrollment_id: NonEmptyStr) -> Enrolmen } fn issue_enrolment_code(root: NonEmptyStr, challenge: EnrolmentChallenge) -> DeviceStoreWrite { - device_store_write(root: root, key: challenge.code, expected_generation: 0, payload: enrolment_code_json(c: challenge) as NonEmptyStr) + device_store_write(root: root, key: challenge.code, expected: ExpectSlotAbsent, payload: enrolment_code_json(c: challenge) as NonEmptyStr) } // THE ONLY ENROLMENT WRITE, and it takes the admitted value: consuming the code and creating the @@ -622,14 +619,14 @@ fn commit_enrolment(root: NonEmptyStr, admitted: EnrolmentAdmission) -> DeviceSt match admitted { EnrolmentRefused { cause: _ } => DeviceStoreRefused { detail: "nothing admitted to commit" } EnrolmentAdmitted { enrollment: e } => - device_store_write(root: root, key: code_of_enrollment_id(enrollment_id: e.enrollment_id) as NonEmptyStr, expected_generation: 1, payload: enrolment_json(e: e) as NonEmptyStr) + device_store_write(root: root, key: code_of_enrollment_id(enrollment_id: e.enrollment_id) as NonEmptyStr, expected: ExpectSlotGeneration { generation: cas_generation_first() }, payload: enrolment_json(e: e) as NonEmptyStr) } } fn revoke_enrolment(root: NonEmptyStr, standing: EnrolmentSlotStanding, revoked_at: Timestamp, reason: NonEmptyStr) -> DeviceStoreWrite { match standing { EnrolmentCodeConsumed { enrollment: e } => - device_store_write(root: root, key: code_of_enrollment_id(enrollment_id: e.enrollment_id) as NonEmptyStr, expected_generation: 2, payload: revocation_json(e: e, revoked_at: revoked_at, reason: reason) as NonEmptyStr) + device_store_write(root: root, key: code_of_enrollment_id(enrollment_id: e.enrollment_id) as NonEmptyStr, expected: ExpectSlotGeneration { generation: cas_generation_next(g: cas_generation_first()) }, payload: revocation_json(e: e, revoked_at: revoked_at, reason: reason) as NonEmptyStr) _ => DeviceStoreRefused { detail: "only a live enrolment can be revoked" } } } diff --git a/dag/gunbc/auth/approval_device_redemption_fixtures.dag b/dag/gunbc/auth/approval_device_redemption_fixtures.dag index 9bc4fb08e3f..44f13700032 100644 --- a/dag/gunbc/auth/approval_device_redemption_fixtures.dag +++ b/dag/gunbc/auth/approval_device_redemption_fixtures.dag @@ -1,8 +1,6 @@ module gunbc.auth.approval_device_redemption_fixtures import std.types { NonEmptyStr, String, Timestamp, List } -import std.process { ProcessExit, ExitSuccess, exit_failure } -import extdeps.filesystem.filesystem_io { Filesystem, filesystem_read_outcome, FilesystemReadSucceeded, FilesystemReadRefused } import extdeps.languages.json.emit { JsonValue, serialize_json, json_object, json_kv, json_string, json_array } import extdeps.crypto.signature { VerifyingKey, EcdsaP256Sha256, Sec1Uncompressed } import gunbc.auth.approval_capability { ProposedDecision, ProposeApprove, ProposeDeny, proposed_decision_name, signing_field_separator } @@ -16,10 +14,10 @@ import gunbc.auth.approval_device_wire { // The iOS app builds the signing input and the enrolment transcript in Swift; the server builds // them here. The two must agree BYTE FOR BYTE or every signature fails. The contract is not // re-typed in Swift tests: this module renders each vector's EXPECTED bytes through the very -// functions the server verifies against, and the app's XCTest reads the emitted file. A change to -// field order or separator on this side changes the file; `agree` refuses a stale commit, and the -// app's test refuses the new bytes until its builder follows. -data approval_device_vectors_path: String = "dag/test/fixture/approval_device_redemption/vectors.json" +// functions the server verifies against, and the app's XCTest reads the emitted file. The file is +// gunbc.generated_artifact ApprovalDeviceVectorsArtifact: the required generated-artifact phase +// refuses a commit that differs from approval_device_vectors_json, the refusing merge driver guards +// it, and the app's test refuses the new bytes until its builder follows. type RedemptionVector { name: NonEmptyStr @@ -142,20 +140,3 @@ fn approval_device_vectors_json() -> String { "\n", ) } - -// Writes the projection. Run from the repository root. -fn regen() -> ProcessExit { - let w = Filesystem.Write(path: approval_device_vectors_path, content: approval_device_vectors_json()) - if w.success { ExitSuccess } else { exit_failure(reason: concat("write failed: ", w.error)) } -} - -// Refuses a committed file that is not exactly the projection. -fn agree() -> ProcessExit { - let r = Filesystem.Read(path: approval_device_vectors_path) - match filesystem_read_outcome(content: r.content, success: r.success, error: r.error) { - FilesystemReadRefused { error } => exit_failure(reason: concat("vectors.json unreadable: ", error)) - FilesystemReadSucceeded { content } => - if content == approval_device_vectors_json() { ExitSuccess } - else { exit_failure(reason: "vectors.json is stale; run gunbc.auth.approval_device_redemption_fixtures regen") } - } -} diff --git a/dag/gunbc/generated_artifact.dag b/dag/gunbc/generated_artifact.dag index 3e7cc03f07e..a33bf616c9b 100644 --- a/dag/gunbc/generated_artifact.dag +++ b/dag/gunbc/generated_artifact.dag @@ -44,11 +44,12 @@ type GeneratedArtifact | PlanArtifact { plan: Plan } | CouponCadQueryProgramArtifact | WholeCorpusCompileMeasuredRootDemandsArtifact + | ApprovalDeviceVectorsArtifact data generated_artifact_registry: List = concat( concat( concat( - [WitnessFloorYamlArtifact, FleetConvergeYamlArtifact, FleetDesiredAdmissionYamlArtifact, GitignoreArtifact, GitattributesArtifact, RoadmapArtifact, DesignArtifact, DesignFailureModesArtifact, DesignRungDropsArtifact, ContributorOnboardingArtifact, GithooksPrePushArtifact, GithooksPreCommitArtifact, GeneratedArtifactMergeDriverArtifact, RunnerDeployArtifact, Stage0CrateLayoutGeneratedDagArtifact, Stage0CrateLayoutGeneratedRsArtifact, Stage0CratePartitionGeneratedDagArtifact, Stage0ExecutableAssemblyGeneratedDagArtifact, SeedRetentionFrontierGeneratedRsArtifact, V1InterpreterDispatchGeneratedRsArtifact, EvaluationBudgetConsequenceGeneratedRsArtifact, CouponCadQueryProgramArtifact, Fci1BoundedExecutionContextArtifact, FileTransportRealizationGeneratedRsArtifact, WholeCorpusCompileMeasuredRootDemandsArtifact], + [WitnessFloorYamlArtifact, FleetConvergeYamlArtifact, FleetDesiredAdmissionYamlArtifact, GitignoreArtifact, GitattributesArtifact, RoadmapArtifact, DesignArtifact, DesignFailureModesArtifact, DesignRungDropsArtifact, ContributorOnboardingArtifact, GithooksPrePushArtifact, GithooksPreCommitArtifact, GeneratedArtifactMergeDriverArtifact, RunnerDeployArtifact, Stage0CrateLayoutGeneratedDagArtifact, Stage0CrateLayoutGeneratedRsArtifact, Stage0CratePartitionGeneratedDagArtifact, Stage0ExecutableAssemblyGeneratedDagArtifact, SeedRetentionFrontierGeneratedRsArtifact, V1InterpreterDispatchGeneratedRsArtifact, EvaluationBudgetConsequenceGeneratedRsArtifact, CouponCadQueryProgramArtifact, Fci1BoundedExecutionContextArtifact, FileTransportRealizationGeneratedRsArtifact, WholeCorpusCompileMeasuredRootDemandsArtifact, ApprovalDeviceVectorsArtifact], map(fleet_autoinstall_specs, s => AutoinstallUserDataArtifact { spec: s }) ), map(fleet_intent_known_hosts, h => RunnerHostSudoersArtifact { host: h.identity }) @@ -58,12 +59,16 @@ data generated_artifact_registry: List = concat( // CompilerRunInput: the compiler reads the file as a declared input at arm time, before any source // resolves (gunbc.whole_corpus_compile_demand_projection). +// CrossLanguageClientTest: a client written in another language reads the file as its test oracle +// for bytes this tree defines (gunbc.auth.approval_device_redemption_fixtures, read by the iOS +// app's XCTest), so a stale commit fails the drift gate here rather than silently passing there. type RepoConsumer = GitProtocol | GithubActionsWorkflow | ProjectDocumentation | HostReconciler | CompilerRunInput + | CrossLanguageClientTest type CommitPolicy = CommitRequired { consumer: RepoConsumer } @@ -194,6 +199,7 @@ fn artifact_location(a: GeneratedArtifact) -> ArtifactLocation { CouponCadQueryProgramArtifact => ArtifactLocation { directory: "manufacturing", name: "hole_diameter_ladder_r1.py" } WholeCorpusCompileMeasuredRootDemandsArtifact => ArtifactLocation { directory: whole_corpus_compile_demand_projection_directory, name: whole_corpus_compile_demand_projection_name } + ApprovalDeviceVectorsArtifact => ArtifactLocation { directory: "dag/test/fixture/approval_device_redemption", name: "vectors.json" } Stage0CrateLayoutGeneratedDagArtifact => ArtifactLocation { directory: "dag/gunbc/stage0", name: "stage0_crate_layout_generated.dag" } Stage0CrateLayoutGeneratedRsArtifact => ArtifactLocation { directory: "src/v1/stage0/src", name: "bootstrap_stage0_crate_layout_generated.rs" } Stage0CratePartitionGeneratedDagArtifact => ArtifactLocation { directory: "dag/gunbc/stage0", name: "stage0_crate_partition_generated.dag" } @@ -268,6 +274,7 @@ fn artifact_commit_policy(a: GeneratedArtifact) -> CommitPolicy { RunnerDeployArtifact => NotCommitted CouponCadQueryProgramArtifact => NotCommitted WholeCorpusCompileMeasuredRootDemandsArtifact => CommitRequired { consumer: CompilerRunInput } + ApprovalDeviceVectorsArtifact => CommitRequired { consumer: CrossLanguageClientTest } Stage0CrateLayoutGeneratedDagArtifact => CommitRequired { consumer: GitProtocol } Stage0CrateLayoutGeneratedRsArtifact => CommitRequired { consumer: GitProtocol } Stage0CratePartitionGeneratedDagArtifact => CommitRequired { consumer: GitProtocol } @@ -323,6 +330,7 @@ fn artifact_eq(a: GeneratedArtifact, b: GeneratedArtifact) -> Bool { RunnerDeployArtifact => match b { RunnerDeployArtifact => true _ => false } CouponCadQueryProgramArtifact => match b { CouponCadQueryProgramArtifact => true _ => false } WholeCorpusCompileMeasuredRootDemandsArtifact => match b { WholeCorpusCompileMeasuredRootDemandsArtifact => true _ => false } + ApprovalDeviceVectorsArtifact => match b { ApprovalDeviceVectorsArtifact => true _ => false } Stage0CrateLayoutGeneratedDagArtifact => match b { Stage0CrateLayoutGeneratedDagArtifact => true _ => false } Stage0CrateLayoutGeneratedRsArtifact => match b { Stage0CrateLayoutGeneratedRsArtifact => true _ => false } Stage0CratePartitionGeneratedDagArtifact => match b { Stage0CratePartitionGeneratedDagArtifact => true _ => false } @@ -391,6 +399,7 @@ fn artifact_producer(a: GeneratedArtifact) -> DeclarationRef { RunnerDeployArtifact => decl_ref(module_path: "gunbc.runner_deploy_emit", decl_name: "expected_runner_deploy_manifest") CouponCadQueryProgramArtifact => decl_ref(module_path: "product.printed_chassis.cadquery_realization", decl_name: "expected_coupon_cadquery_program") WholeCorpusCompileMeasuredRootDemandsArtifact => decl_ref(module_path: "gunbc.whole_corpus_compile_demand_projection", decl_name: "expected_whole_corpus_compile_measured_root_demands_json") + ApprovalDeviceVectorsArtifact => decl_ref(module_path: "gunbc.auth.approval_device_redemption_fixtures", decl_name: "approval_device_vectors_json") Stage0CrateLayoutGeneratedDagArtifact => decl_ref(module_path: "gunbc.stage0_crate_layout_emit", decl_name: "expected_stage0_crate_layout_generated_dag") Stage0CrateLayoutGeneratedRsArtifact => decl_ref(module_path: "gunbc.stage0_crate_layout_emit", decl_name: "expected_stage0_crate_layout_generated_rs") Stage0CratePartitionGeneratedDagArtifact => decl_ref(module_path: "gunbc.stage0_crate_partition_emit", decl_name: "expected_stage0_crate_partition_generated_dag") diff --git a/dag/gunbc/generated_artifact_emit.dag b/dag/gunbc/generated_artifact_emit.dag index 27dc7c4d4e6..5ea4f4efbb3 100644 --- a/dag/gunbc/generated_artifact_emit.dag +++ b/dag/gunbc/generated_artifact_emit.dag @@ -1,6 +1,7 @@ module gunbc.generated_artifact_emit +import gunbc.auth.approval_device_redemption_fixtures { approval_device_vectors_json } import product.printed_chassis.cadquery_realization { CouponProgramGeneration, CouponProgramGenerated, CouponProgramRefused, expected_coupon_cadquery_program } import gunbc.generated_artifact { GeneratedArtifact, @@ -15,10 +16,11 @@ import gunbc.generated_artifact { FileTransportRealizationGeneratedRsArtifact, Fci1BoundedExecutionContextArtifact, WholeCorpusCompileMeasuredRootDemandsArtifact, + ApprovalDeviceVectorsArtifact, artifact_path, artifact_commit_policy, CommitRequired, NotCommitted, - GitProtocol, GithubActionsWorkflow, ProjectDocumentation, HostReconciler, CompilerRunInput, + GitProtocol, GithubActionsWorkflow, ProjectDocumentation, HostReconciler, CompilerRunInput, CrossLanguageClientTest, committed_generated_artifacts, heal_regenerated_after_provisional_merge_paths } @@ -114,6 +116,7 @@ fn artifact_generate_unadorned(a: GeneratedArtifact) -> ArtifactGenerationOutcom )) RunnerDeployArtifact => artifact_generated(content: expected_runner_deploy_manifest()) WholeCorpusCompileMeasuredRootDemandsArtifact => artifact_generated(content: expected_whole_corpus_compile_measured_root_demands_json()) + ApprovalDeviceVectorsArtifact => artifact_generated(content: approval_device_vectors_json()) CouponCadQueryProgramArtifact => match expected_coupon_cadquery_program() { CouponProgramGenerated { content } => artifact_generated(content: content) CouponProgramRefused { reason } => ArtifactGenerationRefused { reason: reason } @@ -191,6 +194,7 @@ fn artifact_generate(a: GeneratedArtifact) -> ArtifactGenerationOutcome { CommitRequired { consumer: ProjectDocumentation } => generated CommitRequired { consumer: HostReconciler } => generated CommitRequired { consumer: CompilerRunInput } => generated + CommitRequired { consumer: CrossLanguageClientTest } => generated NotCommitted => generated } } @@ -224,6 +228,7 @@ fn artifact_extra_valid(a: GeneratedArtifact, generated: ArtifactGenerationOutco RunnerDeployArtifact => true CouponCadQueryProgramArtifact => true WholeCorpusCompileMeasuredRootDemandsArtifact => true + ApprovalDeviceVectorsArtifact => true Stage0CrateLayoutGeneratedDagArtifact => true Stage0CrateLayoutGeneratedRsArtifact => true Stage0CratePartitionGeneratedDagArtifact => true diff --git a/dag/gunbc/instruments/docs_projection_gate.dag b/dag/gunbc/instruments/docs_projection_gate.dag index f0206c2f348..7f83f73816e 100644 --- a/dag/gunbc/instruments/docs_projection_gate.dag +++ b/dag/gunbc/instruments/docs_projection_gate.dag @@ -36,7 +36,8 @@ import gunbc.generated_artifact { AutoinstallUserDataArtifact, RunnerHostSudoersArtifact, PlanArtifact, - CouponCadQueryProgramArtifact + CouponCadQueryProgramArtifact, + ApprovalDeviceVectorsArtifact } import v2.std.optional { Optional, Absent, Present } import tools.docs_projection_agreement { docs_projection_bytes_agree } @@ -96,6 +97,7 @@ fn docs_projection_producer(a: GeneratedArtifact) -> Optional { Fci1BoundedExecutionContextArtifact => Absent CouponCadQueryProgramArtifact => Absent WholeCorpusCompileMeasuredRootDemandsArtifact => Absent + ApprovalDeviceVectorsArtifact => Absent AutoinstallUserDataArtifact { spec: _ } => Absent RunnerHostSudoersArtifact { host: _ } => Absent PlanArtifact { plan: _ } => Absent diff --git a/dag/gunbc/ledger_row_coherence.dag b/dag/gunbc/ledger_row_coherence.dag index 60a35c3e538..705722020a1 100644 --- a/dag/gunbc/ledger_row_coherence.dag +++ b/dag/gunbc/ledger_row_coherence.dag @@ -11,7 +11,7 @@ import gunbc.generated_artifact { Stage0CratePartitionGeneratedDagArtifact, Stage0ExecutableAssemblyGeneratedDagArtifact, SeedRetentionFrontierGeneratedRsArtifact, V1InterpreterDispatchGeneratedRsArtifact, EvaluationBudgetConsequenceGeneratedRsArtifact, - FileTransportRealizationGeneratedRsArtifact, CouponCadQueryProgramArtifact, WholeCorpusCompileMeasuredRootDemandsArtifact, + FileTransportRealizationGeneratedRsArtifact, CouponCadQueryProgramArtifact, WholeCorpusCompileMeasuredRootDemandsArtifact, ApprovalDeviceVectorsArtifact, AutoinstallUserDataArtifact, RunnerHostSudoersArtifact, PlanArtifact } import gunbc.recurring_failure_mode { RecurringFailureMode, authored } @@ -134,6 +134,7 @@ fn ledger_rows(a: GeneratedArtifact) -> List { Fci1BoundedExecutionContextArtifact => [] CouponCadQueryProgramArtifact => [] WholeCorpusCompileMeasuredRootDemandsArtifact => [] + ApprovalDeviceVectorsArtifact => [] AutoinstallUserDataArtifact { spec: _ } => [] RunnerHostSudoersArtifact { host: _ } => [] PlanArtifact { plan: _ } => [] From 48685ebc93a5cdfaf9868702555e351ad577ed91 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 11:33:21 +0000 Subject: [PATCH 13/17] Import platform_wire and EnrolmentSlotUnreadable explicitly (review 67674) Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/auth/approval_device_redemption.dag | 2 +- dag/test/claim/approval_device_redemption_witness_test.dag | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dag/gunbc/auth/approval_device_redemption.dag b/dag/gunbc/auth/approval_device_redemption.dag index 27d3182c1f3..57c66bb7d66 100644 --- a/dag/gunbc/auth/approval_device_redemption.dag +++ b/dag/gunbc/auth/approval_device_redemption.dag @@ -30,7 +30,7 @@ import gunbc.auth.approval_decision_store { approval_capability_expectation, approval_operator_login, json_field_string, } import gunbc.auth.approval_device_wire { - MobilePlatform, MobileIos, MobileAndroid, enrolment_transcript, DeviceRedemptionSigningInput, + MobilePlatform, MobileIos, MobileAndroid, platform_wire, enrolment_transcript, DeviceRedemptionSigningInput, device_redemption_signing_input, redemption_challenge_message, approval_device_audience, } import gunbc.durable_cas_file_store { diff --git a/dag/test/claim/approval_device_redemption_witness_test.dag b/dag/test/claim/approval_device_redemption_witness_test.dag index e51bfeb1298..cf5a44f7a2f 100644 --- a/dag/test/claim/approval_device_redemption_witness_test.dag +++ b/dag/test/claim/approval_device_redemption_witness_test.dag @@ -20,7 +20,7 @@ import gunbc.auth.approval_device_redemption { EnrolmentChallenge, PushProviderAccepted, PushProviderRefused, PushRegistrationInvalid, PushProviderUnreached, PushProviderUndecodable, push_outcome_of_apns, - VerifiedDeviceEnrollment, EnrolmentSlotStanding, EnrolmentCodeUnspent, EnrolmentCodeConsumed, EnrolmentRevoked, EnrolmentCodeUnknown, + VerifiedDeviceEnrollment, EnrolmentSlotStanding, EnrolmentCodeUnspent, EnrolmentCodeConsumed, EnrolmentRevoked, EnrolmentCodeUnknown, EnrolmentSlotUnreadable, EnrolmentAdmitted, EnrolmentRefused, EnrolmentCodeAlreadyUsed, EnrolmentCodeExpired, EnrolmentAttestationForOtherBytes, EnrolmentAttestationRefused, EnrolmentAndroidUnrealized, enrolment_admission, DeviceAdmission, DeviceAdmitted, DeviceNotAdmitted, From 38705b4cc44bf4f4ca1a9cca1eae58c4a8806f77 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 11:46:39 +0000 Subject: [PATCH 14/17] Enrolment codes are issued only by the operator over SSH as the store owner (side-chat condition) The issuer takes no login: issue_enrolment_code writes approval_operator_login. No HTTP issuer, since the loopback dashboard is reachable by every on-host POSIX user; the enrolment POST only consumes an already-issued code. Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/auth/approval_device_redemption.dag | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/dag/gunbc/auth/approval_device_redemption.dag b/dag/gunbc/auth/approval_device_redemption.dag index 57c66bb7d66..3f9ab4ee6ed 100644 --- a/dag/gunbc/auth/approval_device_redemption.dag +++ b/dag/gunbc/auth/approval_device_redemption.dag @@ -68,15 +68,17 @@ import gunbc.secret_provision { SecretRef, fleet_secret_ref, secret_ref_pin_vers // cannot reach the decision commit with raw claims and a login it chose. // ── Enrolment ──────────────────────────────────────────────────────────────────────────────── -// The operator's explicit act on one phone, admitted by a ONE-TIME CODE the dashboard issues. The -// login is DERIVED BY THE SERVER from the code's issuance record, never supplied by the app. +// The operator's explicit act on one phone, admitted by a ONE-TIME CODE. The login is DERIVED BY +// THE SERVER, never supplied by the app: issue_enrolment_code writes approval_operator_login into the +// code's record, and enrolment_admission reads it from there. // -// THE CODE IS NOT OPERATOR-ONLY, AND THIS MODULE DOES NOT CLAIM IT IS. The dashboard binds loopback -// behind tailscale serve: off-tailnet callers cannot reach the issuing route -- but an on-host -// process under another POSIX user can reach loopback. So the code's authority is "whoever could -// reach the dashboard on srv1 within the code's lifetime". Single use and a short lifetime bound it; -// peer attribution at the serve boundary (approval_identity_attribution_frontier) is what would make -// it operator-only. +// ONLY THE OPERATOR CAN ISSUE A CODE (operator ruling: "as long as only I can access it"). Codes are +// NOT issued over HTTP: the dashboard binds loopback, which every on-host POSIX user can reach, so an +// HTTP issuer would let a runner or another session enrol its own key and be attributed to the +// operator. Instead the operator runs the issuing verb over the fleet SSH administrator edge as the +// owner of approval_device_store_root, whose owner-only records no other principal can write, and +// the code is printed in the operator's terminal. The operator's SSH key is the attribution; the +// enrolment POST only CONSUMES a code that already exists. // THE CODE IS THE IDENTIFIER: the operator types one thing, so a separate id would be a second key // for one fact. It also names the enrolment it produces (enrollment_id_for_code), so issuance, @@ -609,8 +611,11 @@ fn observe_enrollment(root: NonEmptyStr, enrollment_id: NonEmptyStr) -> Enrolmen if code == "" { EnrolmentCodeUnknown } else { observe_enrolment_slot(root: root, code: code as NonEmptyStr) } } -fn issue_enrolment_code(root: NonEmptyStr, challenge: EnrolmentChallenge) -> DeviceStoreWrite { - device_store_write(root: root, key: challenge.code, expected: ExpectSlotAbsent, payload: enrolment_code_json(c: challenge) as NonEmptyStr) +// THE ISSUER TAKES NO LOGIN: the code is issued to approval_operator_login and to nobody a caller +// could name, so the verb that runs this cannot enrol a device for anyone else. +fn issue_enrolment_code(root: NonEmptyStr, code: NonEmptyStr, issued_at: Timestamp, expires_at: Timestamp) -> DeviceStoreWrite { + let challenge = EnrolmentChallenge { code: code, issued_to_login: approval_operator_login, issued_at: issued_at, expires_at: expires_at } + device_store_write(root: root, key: code, expected: ExpectSlotAbsent, payload: enrolment_code_json(c: challenge) as NonEmptyStr) } // THE ONLY ENROLMENT WRITE, and it takes the admitted value: consuming the code and creating the @@ -642,7 +647,7 @@ data approval_apns_auth_key_secret_ref: SecretRef = secret_ref_pin_version( // ── Frontiers ──────────────────────────────────────────────────────────────────────────────── data approval_device_routes_frontier: DissolutionCondition = unbound_dissolution( - description: "gunbc.roadmap_serve gains, BESIDE the existing /approve route and without changing it or the store record: a dashboard enrolment-code issuer (issue_enrolment_code); POST approval_device_enrol_path decoding gunbc.auth.approval_device_wire EnrolmentRequest and storing its PushRegistration, consuming the code's slot through enrolment_admission as ONE CAS transition (generation 1 issued -> generation 2 enrolment); reads at approval_device_pending_path and approval_device_request_path_prefix authenticated by an App Attest assertion over device_read_client_data within approval_device_read_skew that return the stored request, a RedemptionChallenge and both verbs' capabilities; and POST approval_device_redeem_path decoding SignedRedemption, verifying its signature and platform_proof at the seams, and folding through redeem_device_over_store; and file_and_notify's APNs publish rendering extdeps.apple.apns apns_push_type_wire, apns_priority_wire and apns_interruption_level_wire into headers and payload with approval_push_alert_title and approval_push_custom_keys, and the verifier configured with app_attest_app_id -- SUFFICIENT FOR one approval decided on the phone to be read back from approval_decision_store with decided_by naming the enrolled login", + description: "gunbc.roadmap_serve gains, BESIDE the existing /approve route and without changing it or the store record: an operator verb run over the fleet SSH administrator edge as the owner of approval_device_store_root that calls issue_enrolment_code and prints the code (never an HTTP issuer); POST approval_device_enrol_path decoding gunbc.auth.approval_device_wire EnrolmentRequest and storing its PushRegistration, consuming the code's slot through enrolment_admission as ONE CAS transition (generation 1 issued -> generation 2 enrolment); reads at approval_device_pending_path and approval_device_request_path_prefix authenticated by an App Attest assertion over device_read_client_data within approval_device_read_skew that return the stored request, a RedemptionChallenge and both verbs' capabilities; and POST approval_device_redeem_path decoding SignedRedemption, verifying its signature and platform_proof at the seams, and folding through redeem_device_over_store; and file_and_notify's APNs publish rendering extdeps.apple.apns apns_push_type_wire, apns_priority_wire and apns_interruption_level_wire into headers and payload with approval_push_alert_title and approval_push_custom_keys, and the verifier configured with app_attest_app_id -- SUFFICIENT FOR one approval decided on the phone to be read back from approval_decision_store with decided_by naming the enrolled login", ) data ecdsa_verification_realization_frontier: DissolutionCondition = unbound_dissolution( From 77cef725afda0915f4ec47b3c7db6cf166fe96cf Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 13:24:16 +0000 Subject: [PATCH 15/17] Regenerate witnesses.yml: heal stages the new registered vectors artifact (from CI heal bundle) Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/witnesses.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/witnesses.yml b/.github/workflows/witnesses.yml index b6224973a0d..a63b1964c5f 100644 --- a/.github/workflows/witnesses.yml +++ b/.github/workflows/witnesses.yml @@ -548,6 +548,7 @@ jobs: if [ -e "tools/fabric_ci_fci1_bounded_execution_context.env" ]; then git add "tools/fabric_ci_fci1_bounded_execution_context.env"; else echo "chore-heal: registered generated artifact absent on this tree, not staging: tools/fabric_ci_fci1_bounded_execution_context.env"; fi if [ -e "src/v1/stage0/src/gunbc_file_transport_generated.rs" ]; then git add "src/v1/stage0/src/gunbc_file_transport_generated.rs"; else echo "chore-heal: registered generated artifact absent on this tree, not staging: src/v1/stage0/src/gunbc_file_transport_generated.rs"; fi if [ -e "tools/whole_corpus_compile_measured_root_demands.json" ]; then git add "tools/whole_corpus_compile_measured_root_demands.json"; else echo "chore-heal: registered generated artifact absent on this tree, not staging: tools/whole_corpus_compile_measured_root_demands.json"; fi + if [ -e "dag/test/fixture/approval_device_redemption/vectors.json" ]; then git add "dag/test/fixture/approval_device_redemption/vectors.json"; else echo "chore-heal: registered generated artifact absent on this tree, not staging: dag/test/fixture/approval_device_redemption/vectors.json"; fi if [ -e "provisioning/srv1/gunbc-ghrunner.sudoers" ]; then git add "provisioning/srv1/gunbc-ghrunner.sudoers"; else echo "chore-heal: registered generated artifact absent on this tree, not staging: provisioning/srv1/gunbc-ghrunner.sudoers"; fi if [ -e "provisioning/srv2/gunbc-ghrunner.sudoers" ]; then git add "provisioning/srv2/gunbc-ghrunner.sudoers"; else echo "chore-heal: registered generated artifact absent on this tree, not staging: provisioning/srv2/gunbc-ghrunner.sudoers"; fi if [ -e "provisioning/srv3/gunbc-ghrunner.sudoers" ]; then git add "provisioning/srv3/gunbc-ghrunner.sudoers"; else echo "chore-heal: registered generated artifact absent on this tree, not staging: provisioning/srv3/gunbc-ghrunner.sudoers"; fi From cc517973ec1c0ac81e8683cf333d2d81c947f264 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Fri, 18 Sep 2026 13:46:54 +0000 Subject: [PATCH 16/17] wip-regrain --- dag/extdeps/apple/apns.dag | 33 +++---- dag/extdeps/auth/jws.dag | 42 +++++++++ dag/extdeps/crypto/signature.dag | 27 +++++- .../v1/v1_interpreter_primitive_surface.dag | 4 +- dag/std/primitives.dag | 16 ++-- dag/test/claim/p256_ecdsa_witness_test.dag | 30 ++++++- src/v1/04_method.dag | 2 +- src/v1/stage0/src/v1_compiler_infer_method.rs | 14 +-- src/v1/stage0/src/v1_interpreter.rs | 86 +++++++------------ .../src/v1_interpreter_dispatch_generated.rs | 6 +- 10 files changed, 159 insertions(+), 101 deletions(-) create mode 100644 dag/extdeps/auth/jws.dag diff --git a/dag/extdeps/apple/apns.dag b/dag/extdeps/apple/apns.dag index d3a096c137f..9c5abad6bdf 100644 --- a/dag/extdeps/apple/apns.dag +++ b/dag/extdeps/apple/apns.dag @@ -4,7 +4,10 @@ import std.types { NonEmptyStr, String, Int, Bool, List, Secret, EpochSecs } import extdeps.external_authority { ExternalAuthority } import extdeps.uri { Uri, Https } import std.measure { Second, second, ByteSize, byte_size } -import extdeps.crypto.signature { SignatureSuite, EcdsaP256Sha256 } +import extdeps.crypto.signature { SignatureSuite, EcdsaP256Sha256, SignatureSigned, SigningKeyUnreadable, p256_ecdsa_sign } +import extdeps.auth.jwt { JwtCompactSerialization } +import extdeps.auth.jws { jws_signing_input, jws_compact } +import extdeps.languages.json.emit { json_object, json_kv, json_string, json_int } import extdeps.transports.rest { RestOutcome, RestOk, RestStatusRefused, RestTransportRefused, RestBodyUndecodable } // Apple Push Notification service, HTTP/2 provider API with token-based authentication. @@ -61,7 +64,7 @@ type ApnsProviderIdentity { type ApnsProviderToken { identity: ApnsProviderIdentity issued_at_epoch: EpochSecs - jwt: NonEmptyStr + jwt: JwtCompactSerialization } // Minting refuses rather than fabricating. The one malformed input the bound implementation can see @@ -71,20 +74,20 @@ type ApnsProviderTokenMint = ApnsProviderTokenSigned { token: ApnsProviderToken } | ApnsAuthKeyUnreadable { key_id: ApnsKeyId } -// `es256_jwt_sign` is a host primitive over RustCrypto's p256 SigningKey (RFC 6979 deterministic -// nonces, so one input has one token and no RNG is consulted): header {"alg":"ES256","kid"}, -// claims {"iss","iat"}, unpadded base64url segments, JOSE r || s signature (RFC 7518 §3.4). The key -// travels as a Secret and never appears in an argv (gunbc.credential_argv_exposure), which is why -// this is a primitive and not a shell transport. The suite match is total so a second -// SignatureSuite arm cannot reach the ES256 implementation without an authored arm. +// THE TOKEN IS A JWS (extdeps.auth.jws) OVER A JWT (extdeps.auth.jwt): protected header +// {"alg": "ES256", "kid": }, payload {"iss": , "iat": }, both serialized by +// extdeps.languages.json.emit, and the signature over the JWS signing input is ES256 -- ECDSA P-256 +// SHA-256 as extdeps.crypto.signature p256_ecdsa_sign, whose r || s base64url is exactly RFC 7518 +// §3.4's JWS Signature. The only host kernel is that signature; every wire shape is modeled. fn apns_provider_token(identity: ApnsProviderIdentity, auth_key: Secret, issued_at_epoch: EpochSecs) -> ApnsProviderTokenMint { - match apns_provider_token_suite { - EcdsaP256Sha256 => - match es256_jwt_sign(auth_key as String, identity.key_id as String, identity.team_id as String, issued_at_epoch as Int) { - Absent => ApnsAuthKeyUnreadable { key_id: identity.key_id } - Present { value: jwt } => - ApnsProviderTokenSigned { token: ApnsProviderToken { identity: identity, issued_at_epoch: issued_at_epoch, jwt: jwt as NonEmptyStr } } - } + let signing_input = jws_signing_input( + protected_header: json_object(members: [json_kv(key: "alg", value: json_string(s: "ES256")), json_kv(key: "kid", value: json_string(s: identity.key_id as String))]), + payload: json_object(members: [json_kv(key: "iss", value: json_string(s: identity.team_id as String)), json_kv(key: "iat", value: json_int(n: issued_at_epoch as Int))]), + ) + match p256_ecdsa_sign(suite: apns_provider_token_suite, key_pem: auth_key, message: signing_input) { + SigningKeyUnreadable { suite: _ } => ApnsAuthKeyUnreadable { key_id: identity.key_id } + SignatureSigned { signature } => + ApnsProviderTokenSigned { token: ApnsProviderToken { identity: identity, issued_at_epoch: issued_at_epoch, jwt: jws_compact(signing_input: signing_input, signature_b64url: signature.b64url) } } } } diff --git a/dag/extdeps/auth/jws.dag b/dag/extdeps/auth/jws.dag new file mode 100644 index 00000000000..c671d14ced8 --- /dev/null +++ b/dag/extdeps/auth/jws.dag @@ -0,0 +1,42 @@ +module extdeps.auth.jws + +import std.types { String, NonEmptyStr } +import std.bytes { bytes_octets, utf8_encode_bytes } +import std.encoding { base64_encode, UrlSafe } +import extdeps.external_authority { ExternalAuthority } +import extdeps.uri { Uri, Https } +import extdeps.languages.json.emit { JsonValue, serialize_json } +import extdeps.auth.jwt { JwtCompactSerialization } + +// JSON Web Signature, RFC 7515: the compact serialization a JWT (extdeps.auth.jwt, RFC 7519) is +// carried in. Its own module because it is its own specification: JWT names the claims, JWS names +// how a signed object is spelled on the wire. +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "datatracker.ietf.org/doc/html/rfc7515" + } +} + +// RFC 7515 §2 "Base64url Encoding": RFC 4648 §5 base64url with every trailing '=' omitted. std.encoding +// owns the alphabet and the grouping; this is only the omission JWS specifies on top of it. +fn jws_strip_padding(s: String) -> String { + if s.ends_with("=") { jws_strip_padding(s: s.substring(start: 0, end: string_length(s) - 1)) } else { s } +} + +fn jws_base64url(octets_of: String) -> String { + jws_strip_padding(s: base64_encode(octets: bytes_octets(b: utf8_encode_bytes(s: octets_of)), variant: UrlSafe)) +} + +// §5.1 steps 2-6: BASE64URL(UTF8(protected header)) || '.' || BASE64URL(payload). The header and the +// payload arrive as JSON values and are serialized by extdeps.languages.json.emit, so no member order +// or escaping is re-decided here. +fn jws_signing_input(protected_header: JsonValue, payload: JsonValue) -> NonEmptyStr { + concat(concat(jws_base64url(octets_of: serialize_json(v: protected_header)), "."), jws_base64url(octets_of: serialize_json(v: payload))) as NonEmptyStr +} + +// §7.1: the signing input, '.', and BASE64URL(JWS Signature). The signature arrives already in its +// base64url spelling -- extdeps.crypto.signature SignatureBytes.b64url is exactly that. +fn jws_compact(signing_input: NonEmptyStr, signature_b64url: NonEmptyStr) -> JwtCompactSerialization { + concat(concat(signing_input as String, "."), signature_b64url as String) as JwtCompactSerialization +} diff --git a/dag/extdeps/crypto/signature.dag b/dag/extdeps/crypto/signature.dag index 2167aa037f1..4c882ac032c 100644 --- a/dag/extdeps/crypto/signature.dag +++ b/dag/extdeps/crypto/signature.dag @@ -1,6 +1,6 @@ module extdeps.crypto.signature -import std.types { NonEmptyStr, String, Int } +import std.types { NonEmptyStr, String, Int, Secret } import std.encoding { base64_decode, UrlSafe } import std.logic { Bool } import std.measure { ByteSize, byte_size, byte_size_count } @@ -169,3 +169,28 @@ fn encoding_refusal(v: SignatureVerification) -> SignatureVerification { _ => v } } + +// ── Signing ────────────────────────────────────────────────────────────────────────────────── +// THE KEY HOLDER'S OPERATION, and the only one this module adds for it. The private key travels as a +// PKCS #8 PEM Secret (an APNs .p8 is exactly that) and never enters an argv +// (gunbc.credential_argv_exposure), which is why this is a host primitive and not a shell transport. +// The signature comes back in the one spelling verification admits -- 64-octet r || s, unpadded +// base64url -- as SignatureBytes, so whatever carries it (a JWS, a record) needs no re-encoding. +type SignatureSigning + = SignatureSigned { signature: SignatureBytes } + | SigningKeyUnreadable { suite: SignatureSuite } + +// `p256_ecdsa_sign_b64url` is RustCrypto's p256 SigningKey over SHA-256 with RFC 6979 deterministic +// nonces: no RNG, one message has one signature. Absent means the key is not a P-256 PKCS #8 PEM. +// The suite match is total so a second SignatureSuite arm cannot reach this implementation without +// an authored arm. +fn p256_ecdsa_sign(suite: SignatureSuite, key_pem: Secret, message: NonEmptyStr) -> SignatureSigning { + match suite { + EcdsaP256Sha256 => + match p256_ecdsa_sign_b64url(key_pem as String, message as String) { + Absent => SigningKeyUnreadable { suite: suite } + Present { value: b64url } => + SignatureSigned { signature: SignatureBytes { suite: suite, encoding: P1363FixedWidth, b64url: b64url as NonEmptyStr } } + } + } +} diff --git a/dag/gunbc/v1/v1_interpreter_primitive_surface.dag b/dag/gunbc/v1/v1_interpreter_primitive_surface.dag index e6b762fbdcc..97fa6ca1576 100644 --- a/dag/gunbc/v1/v1_interpreter_primitive_surface.dag +++ b/dag/gunbc/v1/v1_interpreter_primitive_surface.dag @@ -516,9 +516,9 @@ fn v1_interpreter_authored_roster_arms() -> List List { "hmac_sha256_verify_hex", "hmac_sha256_hex", "p256_ecdsa_verify_b64url", - "es256_jwt_sign", + "p256_ecdsa_sign_b64url", "count", "string_length", "code_point", @@ -723,7 +723,7 @@ fn primitive_contract_roster() -> List { hmac_sha256_verify_hex_contract, hmac_sha256_hex_contract, p256_ecdsa_verify_b64url_contract, - es256_jwt_sign_contract, + p256_ecdsa_sign_b64url_contract, substring_contract, string_contains_contract, starts_with_contract, diff --git a/dag/test/claim/p256_ecdsa_witness_test.dag b/dag/test/claim/p256_ecdsa_witness_test.dag index 9a8c6d6e466..3059bce4424 100644 --- a/dag/test/claim/p256_ecdsa_witness_test.dag +++ b/dag/test/claim/p256_ecdsa_witness_test.dag @@ -7,6 +7,7 @@ import extdeps.crypto.signature { VerifyingKey, SignatureBytes, EcdsaP256Sha256, Sec1Uncompressed, P1363FixedWidth, SignatureVerification, SignatureVerified, SignatureInvalid, VerifyingKeyMalformed, SignatureEncodingRefused, VerifyingKeyUndecodable, p256_ecdsa_verify, + SignatureSigned, SigningKeyUnreadable, p256_ecdsa_sign, } import extdeps.apple.apns { ApnsProviderIdentity, ApnsKeyId, AppleTeamId, @@ -100,9 +101,11 @@ data test_identity: ApnsProviderIdentity = ApnsProviderIdentity { key_id: "ABC123DEFG" as ApnsKeyId, } -// {"alg":"ES256","kid":"ABC123DEFG"} . {"iss":"DEF123GHIJ","iat":1700000000} -data expected_signing_input: NonEmptyStr = "eyJhbGciOiJFUzI1NiIsImtpZCI6IkFCQzEyM0RFRkcifQ.eyJpc3MiOiJERUYxMjNHSElKIiwiaWF0IjoxNzAwMDAwMDAwfQ" -data expected_signature: NonEmptyStr = "9eqgVsaVpJcjng-z9xvwbFH5RQ4bIRjaUdI0UT5WUNjDlcVbRVsgHTYZ2TWGP-HMFIbg6jaaLZG9RVlfBpOdlg" +// {"alg": "ES256", "kid": "ABC123DEFG"} . {"iss": "DEF123GHIJ", "iat": 1700000000}, the spelling +// extdeps.languages.json.emit serializes; the segments were computed independently (Python base64) +// and the signature verified with openssl against the RFC public key. +data expected_signing_input: NonEmptyStr = "eyJhbGciOiAiRVMyNTYiLCAia2lkIjogIkFCQzEyM0RFRkcifQ.eyJpc3MiOiAiREVGMTIzR0hJSiIsICJpYXQiOiAxNzAwMDAwMDAwfQ" +data expected_signature: NonEmptyStr = "OTyh71RihyTg_dlPNjtlmiynS2x_YcIkJJNTJB0Ds-KhH-mkLZcdCnbaDiWWSWMSf-yJ0JrnC9OZxO-2ju2bYg" test fn the_provider_token_is_the_expected_es256_jwt() -> Bool { match apns_provider_token(identity: test_identity, auth_key: rfc7515_p8, issued_at_epoch: 1700000000) { @@ -129,3 +132,24 @@ test fn a_key_that_is_not_pkcs8_pem_mints_no_token() -> Bool { _ => false } } + +// ── The signing seam directly ──────────────────────────────────────────────────────────────── +// The RFC's private key signing the RFC's own signing input: RFC 6979 makes the result deterministic +// (not the published value, which used a random nonce), and it must verify under the RFC public key. +test fn p256_ecdsa_sign_produces_a_signature_the_rfc_public_key_verifies() -> Bool { + match p256_ecdsa_sign(suite: EcdsaP256Sha256, key_pem: rfc7515_p8, message: rfc7515_input) { + SignatureSigned { signature } => + match p256_ecdsa_verify(key: rfc7515_key, signature: signature, message: rfc7515_input) { + SignatureVerified { verified: _ } => true + _ => false + } + SigningKeyUnreadable { suite: _ } => false + } +} + +test fn p256_ecdsa_sign_refuses_a_key_that_is_not_pkcs8_pem() -> Bool { + match p256_ecdsa_sign(suite: EcdsaP256Sha256, key_pem: "not a key" as Secret, message: rfc7515_input) { + SigningKeyUnreadable { suite: _ } => true + SignatureSigned { signature: _ } => false + } +} diff --git a/src/v1/04_method.dag b/src/v1/04_method.dag index 9fafc06875b..28576697f32 100644 --- a/src/v1/04_method.dag +++ b/src/v1/04_method.dag @@ -272,7 +272,7 @@ data builtin_function_registry: Map = { "hmac_sha256_verify_hex": BuiltinSignature { params: [BuiltinParam { name: "key_hex", ty: NamedTemplate { name: "String" } }, BuiltinParam { name: "message", ty: NamedTemplate { name: "String" } }, BuiltinParam { name: "tag_hex", ty: NamedTemplate { name: "String" } }], returns: bool_type }, "hmac_sha256_hex": BuiltinSignature { params: [BuiltinParam { name: "key_hex", ty: NamedTemplate { name: "String" } }, BuiltinParam { name: "message", ty: NamedTemplate { name: "String" } }], returns: with_optional_cardinality(n: string_type) }, "p256_ecdsa_verify_b64url": BuiltinSignature { params: [BuiltinParam { name: "key_point_b64url", ty: NamedTemplate { name: "String" } }, BuiltinParam { name: "signature_b64url", ty: NamedTemplate { name: "String" } }, BuiltinParam { name: "message", ty: NamedTemplate { name: "String" } }], returns: with_optional_cardinality(n: bool_type) }, - "es256_jwt_sign": BuiltinSignature { params: [BuiltinParam { name: "p8_pem_secret", ty: NamedTemplate { name: "String" } }, BuiltinParam { name: "key_id", ty: NamedTemplate { name: "String" } }, BuiltinParam { name: "team_id", ty: NamedTemplate { name: "String" } }, BuiltinParam { name: "issued_at_epoch", ty: NamedTemplate { name: "Int" } }], returns: with_optional_cardinality(n: string_type) }, + "p256_ecdsa_sign_b64url": BuiltinSignature { params: [BuiltinParam { name: "p8_pem_secret", ty: NamedTemplate { name: "String" } }, BuiltinParam { name: "message", ty: NamedTemplate { name: "String" } }], returns: with_optional_cardinality(n: string_type) }, "string_length": BuiltinSignature { params: [BuiltinParam { name: "s", ty: NamedTemplate { name: "String" } }], returns: int_type }, "code_point": BuiltinSignature { params: [BuiltinParam { name: "c", ty: NamedTemplate { name: "String" } }], returns: int_type }, "to_int": derived_signature(names: ["s"], method: "to_int", returns: int_type), diff --git a/src/v1/stage0/src/v1_compiler_infer_method.rs b/src/v1/stage0/src/v1_compiler_infer_method.rs index 35ec95276b6..db4746dd10c 100644 --- a/src/v1/stage0/src/v1_compiler_infer_method.rs +++ b/src/v1/stage0/src/v1_compiler_infer_method.rs @@ -308,27 +308,17 @@ pub fn builtin_function_registry() -> Rc>> })]), returns: crate::v1_std_core::with_optional_cardinality(bool_type()), })); - __m.insert("es256_jwt_sign".to_string(), Rc::new(BuiltinSignature { + __m.insert("p256_ecdsa_sign_b64url".to_string(), Rc::new(BuiltinSignature { params: Rc::new(vec![Rc::new(BuiltinParam { name: "p8_pem_secret".to_string(), ty: Rc::new(AlgebraTypeTemplate::NamedTemplate { name: "String".to_string(), }), }), Rc::new(BuiltinParam { - name: "key_id".to_string(), - ty: Rc::new(AlgebraTypeTemplate::NamedTemplate { - name: "String".to_string(), - }), - }), Rc::new(BuiltinParam { - name: "team_id".to_string(), + name: "message".to_string(), ty: Rc::new(AlgebraTypeTemplate::NamedTemplate { name: "String".to_string(), }), - }), Rc::new(BuiltinParam { - name: "issued_at_epoch".to_string(), - ty: Rc::new(AlgebraTypeTemplate::NamedTemplate { - name: "Int".to_string(), - }), })]), returns: crate::v1_std_core::with_optional_cardinality(string_type()), })); diff --git a/src/v1/stage0/src/v1_interpreter.rs b/src/v1/stage0/src/v1_interpreter.rs index eb383b8465c..151b9fc6bd7 100644 --- a/src/v1/stage0/src/v1_interpreter.rs +++ b/src/v1/stage0/src/v1_interpreter.rs @@ -19707,20 +19707,19 @@ macro_rules! v1_builtin_arms { })) }, - // THE APNs PROVIDER TOKEN (extdeps.apple.apns apns_provider_token): an RFC 7519 JWT - // signed ES256 (RFC 7518 §3.4) with the .p8 PKCS #8 auth key. Header {alg, kid}, - // claims {iss, iat}, each segment unpadded base64url, the signature JOSE r || s. - // RustCrypto's SigningKey signs with RFC 6979 deterministic nonces, so no RNG is - // consulted and one input has exactly one token. A key that is not a P-256 PKCS #8 - // PEM, or a negative issue time, answers ABSENT -- never a token. - arm "free_call.es256_jwt_sign" { "es256_jwt_sign" } => { - Ok(Some(match es256_jwt_sign( - expect_value_str($positional.first().copied(), "es256_jwt_sign key")?.as_str(), - expect_value_str($positional.get(1).copied(), "es256_jwt_sign key_id")?.as_str(), - expect_value_str($positional.get(2).copied(), "es256_jwt_sign team_id")?.as_str(), - expect_int($positional.get(3).copied(), "es256_jwt_sign issued_at")?, + // ECDSA P-256 / SHA-256 SIGNING, the key holder's twin of p256_ecdsa_verify_b64url and + // bound behind extdeps.crypto.signature p256_ecdsa_sign. The key is a PKCS #8 PEM (the + // shape of an APNs .p8); the message is SHA-256 hashed here; the answer is the 64-octet + // r || s as unpadded base64url. Only the signature is a host kernel: every wire shape + // around it (a JWS header, claims, segments, compact form) is assembled in the .dag. + // RFC 6979 deterministic nonces, so no RNG is consulted and one input has one + // signature. A key that is not a P-256 PKCS #8 PEM answers ABSENT -- never a signature. + arm "free_call.p256_ecdsa_sign_b64url" { "p256_ecdsa_sign_b64url" } => { + Ok(Some(match p256_ecdsa_sign_b64url( + expect_value_str($positional.first().copied(), "p256_ecdsa_sign_b64url key")?.as_str(), + expect_value_str($positional.get(1).copied(), "p256_ecdsa_sign_b64url message")?.as_str(), ) { - Some(token) => str_value(token), + Some(signature) => str_value(signature), None => Value::Null, })) }, @@ -22443,44 +22442,22 @@ fn p256_ecdsa_verify_b64url( Some(key.verify(message.as_bytes(), &sig).is_ok()) } -/// The `es256_jwt_sign` builtin's computation: the compact JWS of the APNs provider token, or -/// `None` when the key is not a P-256 PKCS #8 PEM or the issue time is negative. -fn es256_jwt_sign(p8_pem: &str, key_id: &str, team_id: &str, issued_at: i64) -> Option { +/// The `p256_ecdsa_sign_b64url` builtin's computation: the fixed-width r || s of `message` under +/// the PKCS #8 PEM key, unpadded base64url, or `None` when the key is not a P-256 PKCS #8 PEM. +fn p256_ecdsa_sign_b64url(p8_pem: &str, message: &str) -> Option { use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; use p256::ecdsa::signature::Signer; use p256::ecdsa::{Signature, SigningKey}; use p256::pkcs8::DecodePrivateKey; - if issued_at < 0 { - return None; - } let key = SigningKey::from_pkcs8_pem(p8_pem).ok()?; - // serde_json escapes the two caller strings; member order is fixed by the literal. - let header = format!( - "{{\"alg\":\"ES256\",\"kid\":{}}}", - serde_json::to_string(key_id).ok()? - ); - let claims = format!( - "{{\"iss\":{},\"iat\":{}}}", - serde_json::to_string(team_id).ok()?, - issued_at - ); - let signing_input = format!( - "{}.{}", - URL_SAFE_NO_PAD.encode(header.as_bytes()), - URL_SAFE_NO_PAD.encode(claims.as_bytes()) - ); - let signature: Signature = key.sign(signing_input.as_bytes()); - Some(format!( - "{}.{}", - signing_input, - URL_SAFE_NO_PAD.encode(signature.to_bytes()) - )) + let signature: Signature = key.sign(message.as_bytes()); + Some(URL_SAFE_NO_PAD.encode(signature.to_bytes())) } #[cfg(test)] mod p256_ecdsa_tests { - use super::{es256_jwt_sign, p256_ecdsa_verify_b64url}; + use super::{p256_ecdsa_sign_b64url, p256_ecdsa_verify_b64url}; // RFC 7515 Appendix A.3: a published ES256 JWS, verified against a key and signature this // implementation did not produce. @@ -22528,28 +22505,25 @@ mod p256_ecdsa_tests { ); } + // Signing the RFC's own signing input with the RFC's private key: RFC 6979 makes the value + // deterministic, and it must verify under the RFC public key (the published signature does + // not match byte-for-byte because the RFC used a random nonce). #[test] - fn an_es256_provider_token_verifies_under_its_public_key() { - let token = - es256_jwt_sign(RFC7515_A3_P8, "ABC123DEFG", "DEF123GHIJ", 1_700_000_000).unwrap(); - let (input, sig) = token.rsplit_once('.').unwrap(); - assert_eq!(input, "eyJhbGciOiJFUzI1NiIsImtpZCI6IkFCQzEyM0RFRkcifQ.eyJpc3MiOiJERUYxMjNHSElKIiwiaWF0IjoxNzAwMDAwMDAwfQ"); + fn a_signature_verifies_under_its_public_key() { + let sig = p256_ecdsa_sign_b64url(RFC7515_A3_P8, RFC7515_A3_INPUT).unwrap(); assert_eq!( - p256_ecdsa_verify_b64url(RFC7515_A3_POINT, sig, input), + p256_ecdsa_verify_b64url(RFC7515_A3_POINT, &sig, RFC7515_A3_INPUT), Some(true) ); + assert_eq!( + p256_ecdsa_verify_b64url(RFC7515_A3_POINT, &sig, "a different message"), + Some(false) + ); } #[test] - fn a_key_that_is_not_pkcs8_pem_yields_no_token() { - assert_eq!( - es256_jwt_sign("not a key", "ABC123DEFG", "DEF123GHIJ", 1_700_000_000), - None - ); - assert_eq!( - es256_jwt_sign(RFC7515_A3_P8, "ABC123DEFG", "DEF123GHIJ", -1), - None - ); + fn a_key_that_is_not_pkcs8_pem_yields_no_signature() { + assert_eq!(p256_ecdsa_sign_b64url("not a key", RFC7515_A3_INPUT), None); } } diff --git a/src/v1/stage0/src/v1_interpreter_dispatch_generated.rs b/src/v1/stage0/src/v1_interpreter_dispatch_generated.rs index 93885b5303d..b439b650d98 100644 --- a/src/v1/stage0/src/v1_interpreter_dispatch_generated.rs +++ b/src/v1/stage0/src/v1_interpreter_dispatch_generated.rs @@ -24,7 +24,7 @@ pub enum EvalBuiltinArm { FreeCallHmacSha256VerifyHex, FreeCallHmacSha256Hex, FreeCallP256EcdsaVerifyB64url, - FreeCallEs256JwtSign, + FreeCallP256EcdsaSignB64url, FreeCallStringLength, FreeCallSubstring, FreeCallCharAt, @@ -169,7 +169,7 @@ pub fn lookup_eval_builtin_inner(spelling: &str) -> Option { "hmac_sha256_verify_hex" => Some(EvalBuiltinArm::FreeCallHmacSha256VerifyHex), "hmac_sha256_hex" => Some(EvalBuiltinArm::FreeCallHmacSha256Hex), "p256_ecdsa_verify_b64url" => Some(EvalBuiltinArm::FreeCallP256EcdsaVerifyB64url), - "es256_jwt_sign" => Some(EvalBuiltinArm::FreeCallEs256JwtSign), + "p256_ecdsa_sign_b64url" => Some(EvalBuiltinArm::FreeCallP256EcdsaSignB64url), "string_length" => Some(EvalBuiltinArm::FreeCallStringLength), "substring" => Some(EvalBuiltinArm::FreeCallSubstring), "char_at" => Some(EvalBuiltinArm::FreeCallCharAt), @@ -318,7 +318,7 @@ macro_rules! eval_builtin_inner_arm { ("free_call.hmac_sha256_verify_hex") => { $crate::v1_interpreter_dispatch_generated::EvalBuiltinArm::FreeCallHmacSha256VerifyHex }; ("free_call.hmac_sha256_hex") => { $crate::v1_interpreter_dispatch_generated::EvalBuiltinArm::FreeCallHmacSha256Hex }; ("free_call.p256_ecdsa_verify_b64url") => { $crate::v1_interpreter_dispatch_generated::EvalBuiltinArm::FreeCallP256EcdsaVerifyB64url }; - ("free_call.es256_jwt_sign") => { $crate::v1_interpreter_dispatch_generated::EvalBuiltinArm::FreeCallEs256JwtSign }; + ("free_call.p256_ecdsa_sign_b64url") => { $crate::v1_interpreter_dispatch_generated::EvalBuiltinArm::FreeCallP256EcdsaSignB64url }; ("free_call.string_length") => { $crate::v1_interpreter_dispatch_generated::EvalBuiltinArm::FreeCallStringLength }; ("free_call.substring") => { $crate::v1_interpreter_dispatch_generated::EvalBuiltinArm::FreeCallSubstring }; ("free_call.char_at") => { $crate::v1_interpreter_dispatch_generated::EvalBuiltinArm::FreeCallCharAt }; From 7da0a5e4b1189ddff4b2af590c4ebc9b4f846551 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Fri, 18 Sep 2026 14:04:41 +0000 Subject: [PATCH 17/17] Sign at the raw-signature grain: p256_ecdsa_sign_b64url replaces es256_jwt_sign; the JWS is assembled in .dag (review 67702) The host kernel is only the P-256 signature. The APNs provider token's header, claims, base64url segments and compact form are now assembled from extdeps.languages.json.emit, a new extdeps.auth.jws (RFC 7515) and extdeps.crypto.signature p256_ecdsa_sign, and the token is typed as extdeps.auth.jwt JwtCompactSerialization. The unreachable issued_at < 0 check goes with the old primitive. Co-Authored-By: Claude Opus 5 (1M context)