From 3ea088b5517b1e46b3e9805bfcbaabc666e0f542 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 06:15:34 +0000 Subject: [PATCH 01/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] =?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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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) From 176d9f7752c51264bff2127b641f1c92be2ad4b2 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Fri, 18 Sep 2026 19:39:29 +0000 Subject: [PATCH 18/31] Milestone 1 (measurement gate): bitwise, 24-bit-limb bignat, SHA-256 and P-256 ECDSA verify in .dag std.bitwise: UInt32 / Word64 ops by arithmetic, conjunction from a derived nibble table. std.bignat: 24-bit limbs (product < 2^48; conversion is octet slicing), Montgomery CIOS. extdeps.crypto.sha2: FIPS 180-4 SHA-256 (constants checked against a derivation from the primes). extdeps.crypto.nist_p256: SP 800-186 P-256, Jacobian arithmetic, Shamir, FIPS 186-5 verify. Measured on the v1 interpreter (clock in-process): SHA-256 of 1 KiB = 2724 ms, correct; one P-256 verify (RFC 6979 A.2.5 'sample') = 226455 ms, answer true. Above the gate's few-second bound: stopped and reported, not optimized. Not wired into any seam yet. Co-Authored-By: Claude Opus 5 (1M context) --- dag/extdeps/crypto/nist_p256.dag | 213 ++++++++++++ dag/extdeps/crypto/sha2.dag | 207 ++++++++++++ dag/std/bignat.dag | 304 ++++++++++++++++++ dag/std/bitwise.dag | 158 +++++++++ .../claim/p256_dag_ecdsa_witness_test.dag | 115 +++++++ dag/test/claim/sha256_fips_witness_test.dag | 54 ++++ 6 files changed, 1051 insertions(+) create mode 100644 dag/extdeps/crypto/nist_p256.dag create mode 100644 dag/extdeps/crypto/sha2.dag create mode 100644 dag/std/bignat.dag create mode 100644 dag/std/bitwise.dag create mode 100644 dag/test/claim/p256_dag_ecdsa_witness_test.dag create mode 100644 dag/test/claim/sha256_fips_witness_test.dag diff --git a/dag/extdeps/crypto/nist_p256.dag b/dag/extdeps/crypto/nist_p256.dag new file mode 100644 index 00000000000..ed13dae5d44 --- /dev/null +++ b/dag/extdeps/crypto/nist_p256.dag @@ -0,0 +1,213 @@ +module extdeps.crypto.nist_p256 + +import std.types { Int, List, Bool } +import std.integer { UInt8 } +import std.bignat { + BigNat, MontgomeryModulus, bignat_from_octets, bignat_compare, bignat_is_zero, bignat_bits, bignat_small, + montgomery_modulus, montgomery_mul, montgomery_add, montgomery_sub, montgomery_to, montgomery_from, + montgomery_inverse_prime, montgomery_reduce_once, +} +import extdeps.crypto.sha2 { sha256 } +import extdeps.numeric.base16 { base16_decode_lower } +import extdeps.external_authority { ExternalAuthority } +import extdeps.uri { Uri, Https } + +// The NIST P-256 curve (SP 800-186 §3.2.1.3) and ECDSA verification over it with SHA-256 +// (FIPS 186-5 §6.4.2), computed in the substrate on std.bignat's Montgomery arithmetic. This is the +// producer behind extdeps.crypto.signature p256_ecdsa_verify; that module keeps the suite, the +// encodings and the refusal vocabulary. +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "csrc.nist.gov/pubs/sp/800/186/final" + } +} + +// ── Domain parameters (SP 800-186 §3.2.1.3), as published hex ─────────────────────────────── +fn p256_hex_nat(hex: String) -> BigNat { + match base16_decode_lower(hex: hex) { + Present { value: octets } => bignat_from_octets(octets: octets) + Absent => bignat_small(n: 0) + } +} + +data p256_p: BigNat = p256_hex_nat(hex: "ffffffff00000001000000000000000000000000ffffffffffffffffffffffff") +data p256_n: BigNat = p256_hex_nat(hex: "ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551") +data p256_b: BigNat = p256_hex_nat(hex: "5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b") +data p256_gx: BigNat = p256_hex_nat(hex: "6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296") +data p256_gy: BigNat = p256_hex_nat(hex: "4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5") + +// 256-bit moduli in 24-bit limbs: 11 limbs. +data p256_field: MontgomeryModulus = montgomery_modulus(m: p256_p, width: 11) +data p256_order: MontgomeryModulus = montgomery_modulus(m: p256_n, width: 11) + +// ── Jacobian points over the field, coordinates in Montgomery form ────────────────────────── +// (X, Y, Z) represents (X/Z², Y/Z³); Z = 0 is the point at infinity. +type P256Jacobian { + x: BigNat + y: BigNat + z: BigNat +} + +fn fmul(a: BigNat, b: BigNat) -> BigNat { montgomery_mul(ctx: p256_field, a: a, b: b) } +fn fadd(a: BigNat, b: BigNat) -> BigNat { montgomery_add(ctx: p256_field, a: a, b: b) } +fn fsub(a: BigNat, b: BigNat) -> BigNat { montgomery_sub(ctx: p256_field, a: a, b: b) } +fn fsqr(a: BigNat) -> BigNat { montgomery_mul(ctx: p256_field, a: a, b: a) } + +data p256_infinity: P256Jacobian = P256Jacobian { x: bignat_small(n: 0), y: bignat_small(n: 0), z: bignat_small(n: 0) } + +fn p256_is_infinity(p: P256Jacobian) -> Bool { + bignat_is_zero(a: p.z) +} + +fn p256_affine(x: BigNat, y: BigNat) -> P256Jacobian { + P256Jacobian { x: montgomery_to(ctx: p256_field, a: x), y: montgomery_to(ctx: p256_field, a: y), z: montgomery_to(ctx: p256_field, a: bignat_small(n: 1)) } +} + +// Doubling for a = −3 (Bernstein and Lange, Explicit-Formulas Database, dbl-2001-b). +fn p256_double(p: P256Jacobian) -> P256Jacobian { + if p256_is_infinity(p: p) { p } else { + let delta = fsqr(a: p.z) + let gamma = fsqr(a: p.y) + let beta = fmul(a: p.x, b: gamma) + let t = fmul(a: fsub(a: p.x, b: delta), b: fadd(a: p.x, b: delta)) + let alpha = fadd(a: fadd(a: t, b: t), b: t) + let beta4 = fadd(a: fadd(a: beta, b: beta), b: fadd(a: beta, b: beta)) + let x3 = fsub(a: fsqr(a: alpha), b: fadd(a: beta4, b: beta4)) + let yz = fadd(a: p.y, b: p.z) + let z3 = fsub(a: fsub(a: fsqr(a: yz), b: gamma), b: delta) + let g2 = fsqr(a: gamma) + let g8 = fadd(a: fadd(a: fadd(a: g2, b: g2), b: fadd(a: g2, b: g2)), b: fadd(a: fadd(a: g2, b: g2), b: fadd(a: g2, b: g2))) + let y3 = fsub(a: fmul(a: alpha, b: fsub(a: beta4, b: x3)), b: g8) + P256Jacobian { x: x3, y: y3, z: z3 } + } +} + +// Addition (Explicit-Formulas Database, add-2007-bl), with its two exceptional inputs decided rather +// than fed through: equal points double, opposite points give infinity. +fn p256_add(p: P256Jacobian, q: P256Jacobian) -> P256Jacobian { + if p256_is_infinity(p: p) { q } else if p256_is_infinity(p: q) { p } else { + let z1z1 = fsqr(a: p.z) + let z2z2 = fsqr(a: q.z) + let u1 = fmul(a: p.x, b: z2z2) + let u2 = fmul(a: q.x, b: z1z1) + let s1 = fmul(a: fmul(a: p.y, b: q.z), b: z2z2) + let s2 = fmul(a: fmul(a: q.y, b: p.z), b: z1z1) + let h = fsub(a: u2, b: u1) + let rr = fsub(a: s2, b: s1) + if bignat_is_zero(a: h) { + if bignat_is_zero(a: rr) { p256_double(p: p) } else { p256_infinity } + } else { + let h2 = fadd(a: h, b: h) + let i = fsqr(a: h2) + let j = fmul(a: h, b: i) + let r = fadd(a: rr, b: rr) + let v = fmul(a: u1, b: i) + let x3 = fsub(a: fsub(a: fsqr(a: r), b: j), b: fadd(a: v, b: v)) + let s1j = fmul(a: s1, b: j) + let y3 = fsub(a: fmul(a: r, b: fsub(a: v, b: x3)), b: fadd(a: s1j, b: s1j)) + let zz = fadd(a: p.z, b: q.z) + let z3 = fmul(a: fsub(a: fsub(a: fsqr(a: zz), b: z1z1), b: z2z2), b: h) + P256Jacobian { x: x3, y: y3, z: z3 } + } + } +} + +// ── u1·G + u2·Q by Shamir's simultaneous method ───────────────────────────────────────────── +type P256ShamirBit { + u1: Int + u2: Int +} + +fn p256_zip_bits(a: List, b: List, acc: List) -> List { + match a { + Empty => acc + Cons { head: x, tail: at } => + match b { + Empty => p256_zip_bits(a: at, b: [], acc: acc |> list_push(P256ShamirBit { u1: x, u2: 0 })) + Cons { head: y, tail: bt } => p256_zip_bits(a: at, b: bt, acc: acc |> list_push(P256ShamirBit { u1: x, u2: y })) + } + } +} + +fn p256_double_scalar(u1: BigNat, g: P256Jacobian, u2: BigNat, q: P256Jacobian) -> P256Jacobian { + let gq = p256_add(p: g, q: q) + fold(reverse(p256_zip_bits(a: bignat_bits(a: u1), b: bignat_bits(a: u2), acc: [])), init: p256_infinity, f: (acc, bit) => { + let d = p256_double(p: acc) + if bit.u1 == 1 && bit.u2 == 1 { p256_add(p: d, q: gq) } + else if bit.u1 == 1 { p256_add(p: d, q: g) } + else if bit.u2 == 1 { p256_add(p: d, q: q) } + else { d } + }) +} + +// ── Public key validation (SP 800-186 §D.1.1 partial validation: on the curve, in range) ───── +fn p256_on_curve(x: BigNat, y: BigNat) -> Bool { + let xm = montgomery_to(ctx: p256_field, a: x) + let ym = montgomery_to(ctx: p256_field, a: y) + let bm = montgomery_to(ctx: p256_field, a: p256_b) + let x3 = fmul(a: fsqr(a: xm), b: xm) + let three_x = fadd(a: fadd(a: xm, b: xm), b: xm) + bignat_compare(a: fsqr(a: ym), b: fadd(a: fsub(a: x3, b: three_x), b: bm)) == 0 +} + +fn p256_below(a: BigNat, m: BigNat) -> Bool { + bignat_compare(a: a, b: m) < 0 +} + +fn p256_nonzero_below(a: BigNat, m: BigNat) -> Bool { + !bignat_is_zero(a: a) && p256_below(a: a, m: m) +} + +// Split a list after `n` elements. +type P256Split { + front: List + back: List +} + +fn p256_split(xs: List, n: Int) -> P256Split { + fold(xs, init: P256Split { front: [], back: [] }, f: (acc, x) => + if count(acc.front) < n { P256Split { front: acc.front |> list_push(x), back: acc.back } } + else { P256Split { front: acc.front, back: acc.back |> list_push(x) } }) +} + +// ── ECDSA verification (FIPS 186-5 §6.4.2) ─────────────────────────────────────────────────── +// key: the 65-octet SEC 1 uncompressed point 0x04 || X || Y. signature: the 64-octet r || s. The +// message is hashed here with SHA-256. Answers: +// Present true -- the signature verifies; +// Present false -- well-formed inputs, and it does not; +// Absent -- an input is not an admitted encoding: wrong length, a prefix other than 0x04, +// a coordinate ≥ p, a point off the curve, r or s outside [1, n−1]. +fn p256_ecdsa_verify_octets(key: List, signature: List, message: List) -> Bool? { + if count(key) != 65 || count(signature) != 64 { none } else { + let prefixed = p256_split(xs: key, n: 1) + if prefixed.front != [4] { none } else { + let coords = p256_split(xs: prefixed.back, n: 32) + let qx = bignat_from_octets(octets: coords.front) + let qy = bignat_from_octets(octets: coords.back) + let rs = p256_split(xs: signature, n: 32) + let r = bignat_from_octets(octets: rs.front) + let s = bignat_from_octets(octets: rs.back) + if !p256_below(a: qx, m: p256_p) || !p256_below(a: qy, m: p256_p) || !p256_on_curve(x: qx, y: qy) { none } + else if !p256_nonzero_below(a: r, m: p256_n) || !p256_nonzero_below(a: s, m: p256_n) { none } + else { Present { value: p256_verify_checked(qx: qx, qy: qy, r: r, s: s, message: message) } } + } + } +} + +fn p256_verify_checked(qx: BigNat, qy: BigNat, r: BigNat, s: BigNat, message: List) -> Bool { + // e = SHA-256(message) as an integer; 256 bits = the bit length of n, so no truncation (§6.4.2 + // step 3). e may exceed n; reducing once suffices because e < 2^256 < 2n. + let e = montgomery_reduce_once(ctx: p256_order, a: bignat_from_octets(octets: sha256(message: message))) + let sm = montgomery_to(ctx: p256_order, a: s) + let w = montgomery_inverse_prime(ctx: p256_order, a: sm) + let u1 = montgomery_from(ctx: p256_order, a: montgomery_mul(ctx: p256_order, a: montgomery_to(ctx: p256_order, a: e), b: w)) + let u2 = montgomery_from(ctx: p256_order, a: montgomery_mul(ctx: p256_order, a: montgomery_to(ctx: p256_order, a: r), b: w)) + let point = p256_double_scalar(u1: u1, g: p256_affine(x: p256_gx, y: p256_gy), u2: u2, q: p256_affine(x: qx, y: qy)) + if p256_is_infinity(p: point) { false } else { + let zinv = montgomery_inverse_prime(ctx: p256_field, a: point.z) + let x = montgomery_from(ctx: p256_field, a: fmul(a: point.x, b: fsqr(a: zinv))) + // v = x mod n; x < p < 2n, so once. + bignat_compare(a: montgomery_reduce_once(ctx: p256_order, a: x), b: r) == 0 + } +} diff --git a/dag/extdeps/crypto/sha2.dag b/dag/extdeps/crypto/sha2.dag new file mode 100644 index 00000000000..a8282a81a9d --- /dev/null +++ b/dag/extdeps/crypto/sha2.dag @@ -0,0 +1,207 @@ +module extdeps.crypto.sha2 + +import std.types { Int, List, String } +import std.integer { UInt8, UInt32 } +import std.bitwise { word32_and, word32_xor, word32_not, word32_add, word32_shr, word32_rotr, bitwise_pow2 } +import extdeps.external_authority { ExternalAuthority } +import extdeps.uri { Uri, Https } +import extdeps.numeric.base16 { base16_encode_lower } + +// SHA-256 per FIPS 180-4 (Secure Hash Standard), computed in the substrate. extdeps.crypto.hash owns +// the Digest carrier and cites the same standard; this module is the computation that produces the +// bytes such a digest names. Every operation it needs is in std.bitwise. +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "csrc.nist.gov/pubs/fips/180-4/upd1/final" + } +} + +// §4.2.2: the first 32 bits of the fractional parts of the cube roots of the first 64 primes. +data sha256_round_constants: List = [ + 1116352408, 1899447441, 3049323471, 3921009573, 961987163, 1508970993, 2453635748, 2870763221, + 3624381080, 310598401, 607225278, 1426881987, 1925078388, 2162078206, 2614888103, 3248222580, + 3835390401, 4022224774, 264347078, 604807628, 770255983, 1249150122, 1555081692, 1996064986, + 2554220882, 2821834349, 2952996808, 3210313671, 3336571891, 3584528711, 113926993, 338241895, + 666307205, 773529912, 1294757372, 1396182291, 1695183700, 1986661051, 2177026350, 2456956037, + 2730485921, 2820302411, 3259730800, 3345764771, 3516065817, 3600352804, 4094571909, 275423344, + 430227734, 506948616, 659060556, 883997877, 958139571, 1322822218, 1537002063, 1747873779, + 1955562222, 2024104815, 2227730452, 2361852424, 2428436474, 2756734187, 3204031479, 3329325298, +] + +// §5.3.3: the first 32 bits of the fractional parts of the square roots of the first 8 primes. +type Sha256State { + a: UInt32 + b: UInt32 + c: UInt32 + d: UInt32 + e: UInt32 + f: UInt32 + g: UInt32 + h: UInt32 +} + +data sha256_initial_state: Sha256State = Sha256State { + a: 1779033703, b: 3144134277, c: 1013904242, d: 2773480762, + e: 1359893119, f: 2600822924, g: 528734635, h: 1541459225, +} + +// §4.1.2 functions. +fn sha256_ch(x: UInt32, y: UInt32, z: UInt32) -> UInt32 { + word32_xor(a: word32_and(a: x, b: y), b: word32_and(a: word32_not(a: x), b: z)) +} + +fn sha256_maj(x: UInt32, y: UInt32, z: UInt32) -> UInt32 { + word32_xor(a: word32_xor(a: word32_and(a: x, b: y), b: word32_and(a: x, b: z)), b: word32_and(a: y, b: z)) +} + +fn sha256_big_sigma0(x: UInt32) -> UInt32 { + word32_xor(a: word32_xor(a: word32_rotr(a: x, n: 2), b: word32_rotr(a: x, n: 13)), b: word32_rotr(a: x, n: 22)) +} + +fn sha256_big_sigma1(x: UInt32) -> UInt32 { + word32_xor(a: word32_xor(a: word32_rotr(a: x, n: 6), b: word32_rotr(a: x, n: 11)), b: word32_rotr(a: x, n: 25)) +} + +fn sha256_small_sigma0(x: UInt32) -> UInt32 { + word32_xor(a: word32_xor(a: word32_rotr(a: x, n: 7), b: word32_rotr(a: x, n: 18)), b: word32_shr(a: x, n: 3)) +} + +fn sha256_small_sigma1(x: UInt32) -> UInt32 { + word32_xor(a: word32_xor(a: word32_rotr(a: x, n: 17), b: word32_rotr(a: x, n: 19)), b: word32_shr(a: x, n: 10)) +} + +// ── The message schedule, as a sliding window ──────────────────────────────────────────────── +// At round t the window holds W[t] .. W[t+15]. Round t reads w0 = W[t], and the window advances by +// shifting in W[t+16] = σ1(W[t+14]) + W[t+9] + σ0(W[t+1]) + W[t] (§6.2.2 step 1). Every read is a +// named field, so no index can fall outside it. +type Sha256Window { + w0: UInt32 + w1: UInt32 + w2: UInt32 + w3: UInt32 + w4: UInt32 + w5: UInt32 + w6: UInt32 + w7: UInt32 + w8: UInt32 + w9: UInt32 + w10: UInt32 + w11: UInt32 + w12: UInt32 + w13: UInt32 + w14: UInt32 + w15: UInt32 +} + +fn sha256_window_push(w: Sha256Window, next: UInt32) -> Sha256Window { + Sha256Window { + w0: w.w1, w1: w.w2, w2: w.w3, w3: w.w4, w4: w.w5, w5: w.w6, w6: w.w7, w7: w.w8, + w8: w.w9, w9: w.w10, w10: w.w11, w11: w.w12, w12: w.w13, w13: w.w14, w14: w.w15, w15: next, + } +} + +fn sha256_window_next(w: Sha256Window) -> UInt32 { + word32_add(a: word32_add(a: sha256_small_sigma1(x: w.w14), b: w.w9), b: word32_add(a: sha256_small_sigma0(x: w.w1), b: w.w0)) +} + +// The block's sixteen big-endian words shifted in one by one: after sixteen pushes every field holds +// a block word, so the empty window's zeros are never read. +data sha256_empty_window: Sha256Window = Sha256Window { + w0: 0, w1: 0, w2: 0, w3: 0, w4: 0, w5: 0, w6: 0, w7: 0, w8: 0, w9: 0, w10: 0, w11: 0, w12: 0, w13: 0, w14: 0, w15: 0, +} + +type Sha256WordFold { + window: Sha256Window + word: Int + filled: Int +} + +fn sha256_block_window(block: List) -> Sha256Window { + fold(block, init: Sha256WordFold { window: sha256_empty_window, word: 0, filled: 0 }, f: (acc, octet) => + if acc.filled == 3 { + Sha256WordFold { window: sha256_window_push(w: acc.window, next: acc.word * 256 + octet), word: 0, filled: 0 } + } else { + Sha256WordFold { window: acc.window, word: acc.word * 256 + octet, filled: acc.filled + 1 } + } + ).window +} + +// ── Compression (§6.2.2) ───────────────────────────────────────────────────────────────────── +type Sha256Rounds { + state: Sha256State + window: Sha256Window +} + +fn sha256_round(r: Sha256Rounds, k: UInt32) -> Sha256Rounds { + let s = r.state + let t1 = word32_add(a: word32_add(a: word32_add(a: s.h, b: sha256_big_sigma1(x: s.e)), b: word32_add(a: sha256_ch(x: s.e, y: s.f, z: s.g), b: k)), b: r.window.w0) + let t2 = word32_add(a: sha256_big_sigma0(x: s.a), b: sha256_maj(x: s.a, y: s.b, z: s.c)) + Sha256Rounds { + state: Sha256State { a: word32_add(a: t1, b: t2), b: s.a, c: s.b, d: s.c, e: word32_add(a: s.d, b: t1), f: s.e, g: s.f, h: s.g }, + window: sha256_window_push(w: r.window, next: sha256_window_next(w: r.window)), + } +} + +fn sha256_compress(h: Sha256State, block: List) -> Sha256State { + let done = fold(sha256_round_constants, init: Sha256Rounds { state: h, window: sha256_block_window(block: block) }, f: (r, k) => sha256_round(r: r, k: k)) + let s = done.state + Sha256State { + a: word32_add(a: h.a, b: s.a), b: word32_add(a: h.b, b: s.b), c: word32_add(a: h.c, b: s.c), d: word32_add(a: h.d, b: s.d), + e: word32_add(a: h.e, b: s.e), f: word32_add(a: h.f, b: s.f), g: word32_add(a: h.g, b: s.g), h: word32_add(a: h.h, b: s.h), + } +} + +// ── Padding (§5.1.1) and block parsing (§5.2.1) ───────────────────────────────────────────── +// The message, 0x80, the fewest zero octets that leave the length ≡ 56 (mod 64), then the bit length +// as a 64-bit big-endian integer. Messages are bounded well below 2^53 bits here, so the length's +// high octets are computed from an Int without overflow. +fn sha256_zero_octets(n: Int, acc: List) -> List { + if n <= 0 { acc } else { sha256_zero_octets(n: n - 1, acc: acc |> list_push(0)) } +} + +fn sha256_length_octets(bits: Int) -> List { + [0, 7, 6, 5, 4, 3, 2, 1] |> map(i => if i == 0 { 0 } else { (bits / bitwise_pow2(n: 8 * (i - 1))) % 256 }) +} + +fn sha256_padded(message: List) -> List { + let len = count(message) + let zeros = (119 - len % 64) % 64 + list_append_octets(left: list_append_octets(left: message |> list_push(128), right: sha256_zero_octets(n: zeros, acc: [])), right: sha256_length_octets(bits: len * 8)) +} + +fn list_append_octets(left: List, right: List) -> List { + fold(right, init: left, f: (acc, o) => acc |> list_push(o)) +} + +type Sha256BlockFold { + state: Sha256State + block: List +} + +// Compress each full 64-octet block as it completes. +fn sha256_state_of(message: List) -> Sha256State { + fold(sha256_padded(message: message), init: Sha256BlockFold { state: sha256_initial_state, block: [] }, f: (acc, octet) => { + let block = acc.block |> list_push(octet) + if count(block) == 64 { + Sha256BlockFold { state: sha256_compress(h: acc.state, block: block), block: [] } + } else { + Sha256BlockFold { state: acc.state, block: block } + } + }).state +} + +fn sha256_word_octets(w: UInt32) -> List { + [(w / 16777216) % 256, (w / 65536) % 256, (w / 256) % 256, w % 256] +} + +// The 32-octet digest, big-endian words a..h. +fn sha256(message: List) -> List { + let s = sha256_state_of(message: message) + fold([s.a, s.b, s.c, s.d, s.e, s.f, s.g, s.h], init: [], f: (acc, w) => list_append_octets(left: acc, right: sha256_word_octets(w: w))) +} + +// The lowercase hex rendering extdeps.crypto.hash Digest.hex carries. +fn sha256_hex(message: List) -> String { + base16_encode_lower(octets: sha256(message: message)) +} diff --git a/dag/std/bignat.dag b/dag/std/bignat.dag new file mode 100644 index 00000000000..57956fb1c9d --- /dev/null +++ b/dag/std/bignat.dag @@ -0,0 +1,304 @@ +module std.bignat + +import std.types { Int, List, String, Bool } +import std.integer { UInt8 } + +// A MULTI-LIMB UNSIGNED NATURAL over the substrate's 64-bit Int, which refuses on overflow. +// +// LIMB WIDTH IS 24 BITS, and the reason is the product bound, not taste: a limb is below 2^24, so a +// limb product is below 2^48, and a step that adds a product, a limb and a carry stays below 2^49 -- +// fourteen bits of headroom under 2^63, so no step can reach the overflow refusal. 32-bit limbs +// would overflow on a single product; 31 would leave one bit. 24 is also exactly three octets, so +// octet and hex conversion is slicing rather than bit shuffling. A 256-bit value is 11 limbs. +// +// Limbs are least significant first. A BigNat may carry high zero limbs; every operation below is +// correct for any lengths, and fixed-width users (the Montgomery context) pad to their width. +type BigNat { + limbs: List +} + +data bignat_base: Int = 16777216 + +fn bignat_zero() -> BigNat { + BigNat { limbs: [] } +} + +fn bignat_small(n: Int) -> BigNat { + BigNat { limbs: [n % bignat_base, (n / bignat_base) % bignat_base, n / (bignat_base * bignat_base)] } +} + +// ── Octets and hex (big-endian on the wire) ────────────────────────────────────────────────── +type BignatOctetFold { + limbs: List + limb: Int + place: Int +} + +// Big-endian octets to limbs: walk the octets from the least significant end. +fn bignat_from_octets(octets: List) -> BigNat { + let r = fold(reverse(octets), init: BignatOctetFold { limbs: [], limb: 0, place: 1 }, f: (acc, o) => + if acc.place == 65536 { + BignatOctetFold { limbs: acc.limbs |> list_push(acc.limb + o * 65536), limb: 0, place: 1 } + } else { + BignatOctetFold { limbs: acc.limbs, limb: acc.limb + o * acc.place, place: acc.place * 256 } + }) + if r.place == 1 { BigNat { limbs: r.limbs } } else { BigNat { limbs: r.limbs |> list_push(r.limb) } } +} + +fn bignat_limb_octets(limb: Int) -> List { + [limb / 65536, (limb / 256) % 256, limb % 256] +} + +// Exactly `width` big-endian octets. A value that does not fit answers Absent rather than truncating. +fn bignat_to_octets(a: BigNat, width: Int) -> List? { + let all = fold(reverse(a.limbs), init: [], f: (acc, limb) => fold(bignat_limb_octets(limb: limb), init: acc, f: (acc2, o) => acc2 |> list_push(o))) + bignat_fit_octets(octets: all, width: width) +} + +fn bignat_fit_octets(octets: List, width: Int) -> List? { + let n = count(octets) + if n == width { + Present { value: octets } + } else if n < width { + Present { value: bignat_pad_front(octets: octets, zeros: width - n) } + } else { + match octets { + Empty => none + Cons { head: h, tail: t } => if h == 0 { bignat_fit_octets(octets: t, width: width) } else { none } + } + } +} + +fn bignat_pad_front(octets: List, zeros: Int) -> List { + if zeros <= 0 { octets } else { bignat_pad_front(octets: fold(octets, init: [0], f: (acc, o) => acc |> list_push(o)), zeros: zeros - 1) } +} + +// ── Comparison ─────────────────────────────────────────────────────────────────────────────── +// -1, 0 or 1. Walks both limb lists from the least significant end; the most significant +// difference decides, and missing limbs are zero. +fn bignat_compare(a: BigNat, b: BigNat) -> Int { + bignat_compare_limbs(a: a.limbs, b: b.limbs, decided: 0) +} + +fn bignat_compare_limbs(a: List, b: List, decided: Int) -> Int { + match a { + Empty => + match b { + Empty => decided + Cons { head: y, tail: bt } => bignat_compare_limbs(a: [], b: bt, decided: if y > 0 { -1 } else { decided }) + } + Cons { head: x, tail: at } => + match b { + Empty => bignat_compare_limbs(a: at, b: [], decided: if x > 0 { 1 } else { decided }) + Cons { head: y, tail: bt } => + bignat_compare_limbs(a: at, b: bt, decided: if x > y { 1 } else if x < y { -1 } else { decided }) + } + } +} + +fn bignat_is_zero(a: BigNat) -> Bool { + fold(a.limbs, init: true, f: (acc, x) => acc && x == 0) +} + +// ── Addition and subtraction ───────────────────────────────────────────────────────────────── +fn bignat_add(a: BigNat, b: BigNat) -> BigNat { + BigNat { limbs: bignat_add_limbs(a: a.limbs, b: b.limbs, carry: 0, acc: []) } +} + +fn bignat_add_limbs(a: List, b: List, carry: Int, acc: List) -> List { + match a { + Empty => + match b { + Empty => if carry > 0 { acc |> list_push(carry) } else { acc } + Cons { head: y, tail: bt } => bignat_add_limbs(a: [], b: bt, carry: (y + carry) / bignat_base, acc: acc |> list_push((y + carry) % bignat_base)) + } + Cons { head: x, tail: at } => + match b { + Empty => bignat_add_limbs(a: at, b: [], carry: (x + carry) / bignat_base, acc: acc |> list_push((x + carry) % bignat_base)) + Cons { head: y, tail: bt } => bignat_add_limbs(a: at, b: bt, carry: (x + y + carry) / bignat_base, acc: acc |> list_push((x + y + carry) % bignat_base)) + } + } +} + +// a - b, or Absent when b > a: a natural has no negative to answer with. +fn bignat_sub(a: BigNat, b: BigNat) -> BigNat? { + if bignat_compare(a: a, b: b) < 0 { none } else { Present { value: BigNat { limbs: bignat_sub_limbs(a: a.limbs, b: b.limbs, borrow: 0, acc: []) } } } +} + +// Called only with a >= b, so the final borrow is zero. +fn bignat_sub_limbs(a: List, b: List, borrow: Int, acc: List) -> List { + match a { + Empty => acc + Cons { head: x, tail: at } => + match b { + Empty => { + let d = x - borrow + if d < 0 { bignat_sub_limbs(a: at, b: [], borrow: 1, acc: acc |> list_push(d + bignat_base)) } else { bignat_sub_limbs(a: at, b: [], borrow: 0, acc: acc |> list_push(d)) } + } + Cons { head: y, tail: bt } => { + let d = x - y - borrow + if d < 0 { bignat_sub_limbs(a: at, b: bt, borrow: 1, acc: acc |> list_push(d + bignat_base)) } else { bignat_sub_limbs(a: at, b: bt, borrow: 0, acc: acc |> list_push(d)) } + } + } + } +} + +// ── Bits ───────────────────────────────────────────────────────────────────────────────────── +// Bit i of a (0 = least significant), as 0 or 1. +fn bignat_limb_bits(limb: Int, remaining: Int, acc: List) -> List { + if remaining <= 0 { acc } else { bignat_limb_bits(limb: limb / 2, remaining: remaining - 1, acc: acc |> list_push(limb % 2)) } +} + +// All bits, least significant first: 24 per limb. +fn bignat_bits(a: BigNat) -> List { + fold(a.limbs, init: [], f: (acc, limb) => bignat_limb_bits(limb: limb, remaining: 24, acc: acc)) +} + +// ── Montgomery arithmetic modulo an odd m ──────────────────────────────────────────────────── +// Values are held as a·R mod m with R = 2^(24·width). mont_mul(a, b) = a·b·R⁻¹ mod m by the +// coarsely integrated operand scanning method (Koç, Acar and Kaliski, "Analyzing and Comparing +// Montgomery Multiplication Algorithms", 1996): for each limb a_i, t ← (t + a_i·b + q·m) / 2^24 with +// q = (t_0 + a_i·b_0)·(−m⁻¹) mod 2^24, which makes the division exact. Every step is one product plus +// a limb plus a carry, inside the 2^49 bound stated at the top. +type MontgomeryModulus { + modulus: BigNat + width: Int + neg_inverse: Int + r_squared: BigNat +} + +// −m⁻¹ mod 2^24 from the lowest limb, by Newton's iteration x ← x(2 − m₀x), which doubles the +// number of correct low bits each step: 1 → 2 → 4 → 8 → 16 → 32 ≥ 24 after five steps. Each product +// is reduced mod 2^24 before the next multiplication, so nothing exceeds 2^48. +fn montgomery_neg_inverse(m0: Int) -> Int { + let x1 = montgomery_newton(m0: m0, x: 1) + let x2 = montgomery_newton(m0: m0, x: x1) + let x3 = montgomery_newton(m0: m0, x: x2) + let x4 = montgomery_newton(m0: m0, x: x3) + let x5 = montgomery_newton(m0: m0, x: x4) + (bignat_base - x5) % bignat_base +} + +fn montgomery_newton(m0: Int, x: Int) -> Int { + let mx = (m0 * x) % bignat_base + (x * ((2 + bignat_base - mx) % bignat_base)) % bignat_base +} + +fn bignat_pad(limbs: List, width: Int) -> List { + let n = count(limbs) + if n >= width { limbs } else { bignat_pad(limbs: limbs |> list_push(0), width: width) } +} + +fn bignat_lowest(limbs: List) -> Int { + match limbs { + Empty => 0 + Cons { head: h, tail: _ } => h + } +} + +fn bignat_drop_lowest(limbs: List) -> List { + match limbs { + Empty => [] + Cons { head: _, tail: t } => t + } +} + +// t + k·x, as limbs, where t has at least as many limbs as x. +fn bignat_add_scaled(t: List, x: List, k: Int, carry: Int, acc: List) -> List { + match t { + Empty => if carry > 0 { acc |> list_push(carry) } else { acc } + Cons { head: tj, tail: tt } => + match x { + Empty => bignat_add_scaled(t: tt, x: [], k: k, carry: (tj + carry) / bignat_base, acc: acc |> list_push((tj + carry) % bignat_base)) + Cons { head: xj, tail: xt } => { + let v = tj + k * xj + carry + bignat_add_scaled(t: tt, x: xt, k: k, carry: v / bignat_base, acc: acc |> list_push(v % bignat_base)) + } + } + } +} + +fn montgomery_step(ctx: MontgomeryModulus, t: List, ai: Int, b: List) -> List { + let u = bignat_add_scaled(t: t, x: b, k: ai, carry: 0, acc: []) + let q = (bignat_lowest(limbs: u) * ctx.neg_inverse) % bignat_base + bignat_pad(limbs: bignat_drop_lowest(limbs: bignat_add_scaled(t: u, x: ctx.modulus.limbs, k: q, carry: 0, acc: [])), width: ctx.width + 1) +} + +// a·b·R⁻¹ mod m, for a, b < m. The loop result is below 2m, so one conditional subtraction finishes. +fn montgomery_mul(ctx: MontgomeryModulus, a: BigNat, b: BigNat) -> BigNat { + let t = fold(bignat_pad(limbs: a.limbs, width: ctx.width), init: bignat_pad(limbs: [], width: ctx.width + 1), f: (acc, ai) => montgomery_step(ctx: ctx, t: acc, ai: ai, b: b.limbs)) + montgomery_reduce_once(ctx: ctx, a: BigNat { limbs: t }) +} + +fn montgomery_reduce_once(ctx: MontgomeryModulus, a: BigNat) -> BigNat { + match bignat_sub(a: a, b: ctx.modulus) { + Present { value: d } => BigNat { limbs: bignat_truncate(limbs: d.limbs, width: ctx.width) } + Absent => BigNat { limbs: bignat_truncate(limbs: a.limbs, width: ctx.width) } + } +} + +// Keep the low `width` limbs. Used only where the dropped limbs are known to be zero. +fn bignat_truncate(limbs: List, width: Int) -> List { + fold(limbs, init: [], f: (acc, x) => if count(acc) < width { acc |> list_push(x) } else { acc }) +} + +// R² mod m by doubling 1 modulo m, 2·24·width times, each doubling followed by a conditional +// subtraction. Derived from the modulus; never a transcribed constant. +fn montgomery_r_squared(m: BigNat, width: Int) -> BigNat { + montgomery_double_times(m: m, a: bignat_small(n: 1), times: 48 * width) +} + +fn montgomery_double_times(m: BigNat, a: BigNat, times: Int) -> BigNat { + if times <= 0 { a } else { + let d = bignat_add(a: a, b: a) + montgomery_double_times(m: m, a: match bignat_sub(a: d, b: m) { Present { value: r } => r Absent => d }, times: times - 1) + } +} + +// The context for an odd modulus of `width` limbs. +fn montgomery_modulus(m: BigNat, width: Int) -> MontgomeryModulus { + MontgomeryModulus { + modulus: BigNat { limbs: bignat_pad(limbs: m.limbs, width: width) }, + width: width, + neg_inverse: montgomery_neg_inverse(m0: bignat_lowest(limbs: m.limbs)), + r_squared: montgomery_r_squared(m: m, width: width), + } +} + +fn montgomery_to(ctx: MontgomeryModulus, a: BigNat) -> BigNat { + montgomery_mul(ctx: ctx, a: a, b: ctx.r_squared) +} + +fn montgomery_from(ctx: MontgomeryModulus, a: BigNat) -> BigNat { + montgomery_mul(ctx: ctx, a: a, b: bignat_small(n: 1)) +} + +// Modular addition and subtraction of RESIDUES: both operands below m, which every montgomery_* +// producer guarantees. Under that invariant m - b is a natural, so it is computed directly and no +// arm exists for an impossible negative to be answered with a made-up value. +fn montgomery_add(ctx: MontgomeryModulus, a: BigNat, b: BigNat) -> BigNat { + montgomery_reduce_once(ctx: ctx, a: bignat_add(a: a, b: b)) +} + +fn montgomery_negate(ctx: MontgomeryModulus, b: BigNat) -> BigNat { + BigNat { limbs: bignat_sub_limbs(a: ctx.modulus.limbs, b: b.limbs, borrow: 0, acc: []) } +} + +fn montgomery_sub(ctx: MontgomeryModulus, a: BigNat, b: BigNat) -> BigNat { + montgomery_reduce_once(ctx: ctx, a: bignat_add(a: a, b: montgomery_negate(ctx: ctx, b: b))) +} + +// a^e in Montgomery form, left to right over e's bits. `one` is R mod m (1 in Montgomery form). +fn montgomery_pow(ctx: MontgomeryModulus, a: BigNat, e: BigNat) -> BigNat { + let one = montgomery_to(ctx: ctx, a: bignat_small(n: 1)) + fold(reverse(bignat_bits(a: e)), init: one, f: (acc, bit) => { + let sq = montgomery_mul(ctx: ctx, a: acc, b: acc) + if bit == 1 { montgomery_mul(ctx: ctx, a: sq, b: a) } else { sq } + }) +} + +// The inverse of a nonzero a modulo a prime m, by Fermat: a^(m−2). Montgomery form in and out. +// m is an odd prime, so m ≥ 3 and m − 2 is a natural. +fn montgomery_inverse_prime(ctx: MontgomeryModulus, a: BigNat) -> BigNat { + montgomery_pow(ctx: ctx, a: a, e: BigNat { limbs: bignat_sub_limbs(a: ctx.modulus.limbs, b: [2], borrow: 0, acc: []) }) +} diff --git a/dag/std/bitwise.dag b/dag/std/bitwise.dag new file mode 100644 index 00000000000..94aec448ff5 --- /dev/null +++ b/dag/std/bitwise.dag @@ -0,0 +1,158 @@ +module std.bitwise + +import std.types { Int, List } +import std.integer { UInt32 } + +// BITWISE OPERATIONS OVER BOUNDED NATURALS, emulated with the only integer operations the substrate +// has (+ - * / %, over a 64-bit Int that refuses on overflow). The operands are std.integer's +// bounded naturals -- UInt32 here, and Word64 below as its two halves, because a 64-bit unsigned +// value does not fit a signed 64-bit Int. std.bit's Word32 is the same width read as a list of +// bits; this module is the arithmetic reading of it, not a second word type. +// +// WHICH OPERATIONS ARE ARITHMETIC AND WHICH ARE NOT. Shifts, rotations, complement and addition +// modulo 2^w are exact integer identities (x / 2^n, x % 2^n, (2^w - 1) - x, (a + b) % 2^w). Only +// conjunction has no closed arithmetic form, so it is computed four bits at a time from a 16 x 16 +// table DERIVED below from the one-bit definition, and exclusive-or and disjunction follow from it: +// a xor b = a + b - 2 (a and b), a or b = a + b - (a and b). + +data word32_modulus: Int = 4294967296 + +fn bitwise_pow2_defined(n: Int) -> Int { + if n <= 0 { 1 } else { 2 * bitwise_pow2_defined(n: n - 1) } +} + +// 2^0 .. 2^62, derived from the definition above so the hot paths index rather than recurse. +data bitwise_pow2_table: List = fold( + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62], + init: [], f: (acc, n) => acc |> list_push(bitwise_pow2_defined(n: n))) + +// The Absent arm is the definition itself, reached only for n outside [0, 62]; it answers the same +// value the table would, so it is not a fallback to something else. +fn bitwise_pow2(n: Int) -> Int { + match get(bitwise_pow2_table, n) { + Present { value: v } => v + Absent => bitwise_pow2_defined(n: n) + } +} + +// One bit of a nibble. +fn nibble_bit(x: Int, i: Int) -> Int { + (x / bitwise_pow2_defined(n: i)) % 2 +} + +fn nibble_and(a: Int, b: Int) -> Int { + nibble_bit(x: a, i: 0) * nibble_bit(x: b, i: 0) + + 2 * nibble_bit(x: a, i: 1) * nibble_bit(x: b, i: 1) + + 4 * nibble_bit(x: a, i: 2) * nibble_bit(x: b, i: 2) + + 8 * nibble_bit(x: a, i: 3) * nibble_bit(x: b, i: 3) +} + +fn nibble_values() -> List { + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] +} + +// Row-major: entry 16a + b is (a and b). Derived, never transcribed. +data nibble_and_table: List = fold(nibble_values(), init: [], f: (acc, a) => + fold(nibble_values(), init: acc, f: (row, b) => row |> list_push(nibble_and(a: a, b: b)))) + +// As with bitwise_pow2, the Absent arm is the definition itself and is unreachable for a, b < 16. +fn nibble_and_lookup(a: Int, b: Int) -> Int { + match get(nibble_and_table, a * 16 + b) { + Present { value: v } => v + Absent => nibble_and(a: a, b: b) + } +} + +// Conjunction over `digits` nibbles, least significant first. +fn nibbles_and(a: Int, b: Int, digits: Int) -> Int { + if digits <= 0 { 0 } else { nibble_and_lookup(a: a % 16, b: b % 16) + 16 * nibbles_and(a: a / 16, b: b / 16, digits: digits - 1) } +} + +fn word32_and(a: UInt32, b: UInt32) -> UInt32 { + nibbles_and(a: a, b: b, digits: 8) +} + +fn word32_xor(a: UInt32, b: UInt32) -> UInt32 { + a + b - 2 * word32_and(a: a, b: b) +} + +fn word32_or(a: UInt32, b: UInt32) -> UInt32 { + a + b - word32_and(a: a, b: b) +} + +fn word32_not(a: UInt32) -> UInt32 { + word32_modulus - 1 - a +} + +fn word32_add(a: UInt32, b: UInt32) -> UInt32 { + (a + b) % word32_modulus +} + +fn word32_shr(a: UInt32, n: Int) -> UInt32 { + a / bitwise_pow2(n: n) +} + +fn word32_shl(a: UInt32, n: Int) -> UInt32 { + (a % bitwise_pow2(n: 32 - n)) * bitwise_pow2(n: n) +} + +// Rotate right by n in [0, 32): the low n bits move to the top. +fn word32_rotr(a: UInt32, n: Int) -> UInt32 { + a / bitwise_pow2(n: n) + (a % bitwise_pow2(n: n)) * bitwise_pow2(n: 32 - n) +} + +// ── 64-bit words ───────────────────────────────────────────────────────────────────────────── +// Two 32-bit halves, because 2^64 - 1 exceeds the signed 64-bit Int. Every operation is the 32-bit +// operation on each half, except where bits cross the boundary (addition's carry, shifts and +// rotations), which are stated explicitly. +type Word64 { + hi: UInt32 + lo: UInt32 +} + +fn word64_and(a: Word64, b: Word64) -> Word64 { + Word64 { hi: word32_and(a: a.hi, b: b.hi), lo: word32_and(a: a.lo, b: b.lo) } +} + +fn word64_xor(a: Word64, b: Word64) -> Word64 { + Word64 { hi: word32_xor(a: a.hi, b: b.hi), lo: word32_xor(a: a.lo, b: b.lo) } +} + +fn word64_or(a: Word64, b: Word64) -> Word64 { + Word64 { hi: word32_or(a: a.hi, b: b.hi), lo: word32_or(a: a.lo, b: b.lo) } +} + +fn word64_not(a: Word64) -> Word64 { + Word64 { hi: word32_not(a: a.hi), lo: word32_not(a: a.lo) } +} + +fn word64_add(a: Word64, b: Word64) -> Word64 { + let lo = a.lo + b.lo + Word64 { hi: (a.hi + b.hi + lo / word32_modulus) % word32_modulus, lo: lo % word32_modulus } +} + +// Shift right by n in [0, 64). +fn word64_shr(a: Word64, n: Int) -> Word64 { + if n >= 32 { + Word64 { hi: 0, lo: word32_shr(a: a.hi, n: n - 32) } + } else if n == 0 { + a + } else { + Word64 { hi: word32_shr(a: a.hi, n: n), lo: word32_shr(a: a.lo, n: n) + (a.hi % bitwise_pow2(n: n)) * bitwise_pow2(n: 32 - n) } + } +} + +// Rotate right by n in [0, 64): a rotation by 32 or more swaps the halves first. +fn word64_rotr(a: Word64, n: Int) -> Word64 { + if n >= 32 { + word64_rotr(a: Word64 { hi: a.lo, lo: a.hi }, n: n - 32) + } else if n == 0 { + a + } else { + Word64 { + hi: word32_shr(a: a.hi, n: n) + (a.lo % bitwise_pow2(n: n)) * bitwise_pow2(n: 32 - n), + lo: word32_shr(a: a.lo, n: n) + (a.hi % bitwise_pow2(n: n)) * bitwise_pow2(n: 32 - n), + } + } +} diff --git a/dag/test/claim/p256_dag_ecdsa_witness_test.dag b/dag/test/claim/p256_dag_ecdsa_witness_test.dag new file mode 100644 index 00000000000..3b9b6d70bfb --- /dev/null +++ b/dag/test/claim/p256_dag_ecdsa_witness_test.dag @@ -0,0 +1,115 @@ +module test.claim.p256_dag_ecdsa_witness_test + +import std.logic { Bool } +import std.types { Int, List } +import std.integer { UInt8 } +import std.bytes { bytes_octets, utf8_encode_bytes } +import std.bignat { BigNat, bignat_small } +import extdeps.numeric.base16 { base16_decode_lower } +import extdeps.crypto.nist_p256 { p256_ecdsa_verify_octets, p256_on_curve, p256_gx, p256_gy, p256_n, p256_double_scalar, p256_affine, p256_is_infinity } + +fn hex(s: String) -> List { + match base16_decode_lower(hex: s) { + Present { value: o } => o + Absent => [] + } +} + +fn ascii(s: String) -> List { + bytes_octets(b: utf8_encode_bytes(s: s)) +} + +fn is_true(v: Bool?) -> Bool { + match v { + Present { value: b } => b + Absent => false + } +} + +fn is_false(v: Bool?) -> Bool { + match v { + Present { value: b } => !b + Absent => false + } +} + +fn is_absent(v: Bool?) -> Bool { + match v { + Present { value: _ } => false + Absent => true + } +} + +// ── The parameters themselves ──────────────────────────────────────────────────────────────── +// G is on the curve, and n·G is the point at infinity: the published constants are the curve's. +test fn the_base_point_is_on_the_curve_and_has_order_n() -> Bool { + let zero = p256_hex_zero() + p256_on_curve(x: p256_gx, y: p256_gy) + && p256_is_infinity(p: p256_double_scalar(u1: p256_n, g: p256_affine(x: p256_gx, y: p256_gy), u2: zero, q: p256_affine(x: p256_gx, y: p256_gy))) +} + +fn p256_hex_zero() -> BigNat { + bignat_small(n: 0) +} + +// ── RFC 6979 §A.2.5: a published P-256 / SHA-256 key and two signatures ───────────────────── +data rfc6979_key: List = hex(s: "0460fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb67903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462299") +data rfc6979_sample_sig: List = hex(s: "efd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716f7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8") +data rfc6979_test_sig: List = hex(s: "f1abb023518351cd71d881567b1ea663ed3efcf6c5132b354f28d3b0b7d38367019f4113742a2b14bd25926b49c649155f267e60d3814b4c0cc84250e46f0083") + +test fn the_rfc6979_sample_signature_verifies() -> Bool { + is_true(v: p256_ecdsa_verify_octets(key: rfc6979_key, signature: rfc6979_sample_sig, message: ascii(s: "sample"))) +} + +test fn the_rfc6979_test_signature_verifies() -> Bool { + is_true(v: p256_ecdsa_verify_octets(key: rfc6979_key, signature: rfc6979_test_sig, message: ascii(s: "test"))) +} + +// The "sample" signature presented for "test": well-formed, and false. +test fn a_signature_for_a_different_message_is_false() -> Bool { + is_false(v: p256_ecdsa_verify_octets(key: rfc6979_key, signature: rfc6979_sample_sig, message: ascii(s: "test"))) +} + +// The RFC 7515 A.3 key under the RFC 6979 signature: the wrong key, false. +test fn the_wrong_key_is_false() -> Bool { + is_false(v: p256_ecdsa_verify_octets(key: hex(s: "047fcdce2770f6c45d4183cbee6fdb4b7b580733357be9ef13bacf6e3c7bd15445c7f144cd1bbd9b7e872cdfedb9eeb9f4b3695d6ea90b24ad8a4623288588e5ad"), signature: rfc6979_sample_sig, message: ascii(s: "sample"))) +} + +// The RFC key with the last bit of y flipped: 65 octets, 0x04, and not a point on the curve. +test fn a_point_off_the_curve_is_absent() -> Bool { + is_absent(v: p256_ecdsa_verify_octets(key: hex(s: "0460fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb67903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462298"), signature: rfc6979_sample_sig, message: ascii(s: "sample"))) +} + +// ── Wycheproof ecdsa_secp256r1_sha256_p1363_test.json ─────────────────────────────────────── +data wycheproof_key: List = hex(s: "042927b10512bae3eddcfe467828128bad2903269919f7086069c8c4df6c732838c7787964eaac00e5921fb1498a60f4606766b3d9685001558d1a974e7341513e") +data wycheproof_msg: List = hex(s: "313233343030") + +// tcId 1, valid: the high-s form. ECDSA verification has no low-s rule, so it verifies. +test fn wycheproof_1_the_malleable_signature_verifies() -> Bool { + is_true(v: p256_ecdsa_verify_octets(key: wycheproof_key, signature: hex(s: "2ba3a8be6b94d5ec80a6d9d1190a436effe50d85a1eee859b8cc6af9bd5c2e184cd60b855d442f5b3c7b11eb6c4e0ae7525fe710fab9aa7c77a67f79e6fadd76"), message: wycheproof_msg)) +} + +// tcId 60, valid: an edge case for Shamir multiplication. +test fn wycheproof_60_the_shamir_edge_case_verifies() -> Bool { + is_true(v: p256_ecdsa_verify_octets(key: wycheproof_key, signature: hex(s: "64a1aab5000d0e804f3e2fc02bdee9be8ff312334e2ba16d11547c97711c898e6af015971cc30be6d1a206d4e013e0997772a2f91d73286ffd683b9bb2cf4f1b"), message: hex(s: "3639383139"))) +} + +// tcId 4, invalid: r replaced by n - r. Well-formed, so the answer is false. +test fn wycheproof_4_n_minus_r_is_false() -> Bool { + is_false(v: p256_ecdsa_verify_octets(key: wycheproof_key, signature: hex(s: "d45c5740946b2a147f59262ee6f5bc90bd01ed280528b62b3aed5fc93f06f739b329f479a2bbd0a5c384ee1493b1f5186a87139cac5df4087c134b49156847db"), message: wycheproof_msg)) +} + +// tcId 11, invalid: r = 0 and s = 0, outside [1, n-1]. +test fn wycheproof_11_r_and_s_zero_are_absent() -> Bool { + is_absent(v: p256_ecdsa_verify_octets(key: wycheproof_key, signature: hex(s: "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"), message: wycheproof_msg)) +} + +// tcId 26, invalid: r = n, outside [1, n-1]. +test fn wycheproof_26_r_equal_to_n_is_absent() -> Bool { + is_absent(v: p256_ecdsa_verify_octets(key: wycheproof_key, signature: hex(s: "ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc6325510000000000000000000000000000000000000000000000000000000000000001"), message: wycheproof_msg)) +} + +// tcId 2, invalid: r replaced by r + n, which needs 33 octets -- not the 64-octet encoding. +test fn wycheproof_2_r_plus_n_is_absent() -> Bool { + is_absent(v: p256_ecdsa_verify_octets(key: wycheproof_key, signature: hex(s: "012ba3a8bd6b94d5ed80a6d9d1190a436ebccc0833490686deac8635bcb9bf536900b329f479a2bbd0a5c384ee1493b1f5186a87139cac5df4087c134b49156847db"), message: wycheproof_msg)) +} diff --git a/dag/test/claim/sha256_fips_witness_test.dag b/dag/test/claim/sha256_fips_witness_test.dag new file mode 100644 index 00000000000..bcc815310a7 --- /dev/null +++ b/dag/test/claim/sha256_fips_witness_test.dag @@ -0,0 +1,54 @@ +module test.claim.sha256_fips_witness_test + +import std.logic { Bool } +import std.types { Int, List } +import std.integer { UInt8 } +import std.bytes { bytes_octets, utf8_encode_bytes } +import std.bitwise { word32_and, word32_xor, word32_or, word32_not, word32_add, word32_rotr, word32_shr, Word64, word64_add, word64_rotr, word64_xor } +import extdeps.crypto.sha2 { sha256_hex } + +fn ascii(s: String) -> List { + bytes_octets(b: utf8_encode_bytes(s: s)) +} + +// ── std.bitwise against values computed by an independent implementation (Python int ops) ───── +test fn word32_xor_and_or_not_match_the_bit_definitions() -> Bool { + word32_xor(a: 305419896, b: 2596069104) == 2290649224 + && word32_and(a: 305419896, b: 2596069104) == 305419888 + && word32_or(a: 0, b: 4294967295) == 4294967295 + && word32_not(a: 0) == 4294967295 + && word32_add(a: 4294967295, b: 2) == 1 +} + +test fn word32_rotr_moves_the_low_bits_to_the_top() -> Bool { + word32_rotr(a: 305419896, n: 7) == 4028917932 && word32_shr(a: 305419896, n: 7) == 2386092 +} + +// 64-bit: the carry crosses the halves, and a rotation by 32 or more swaps them. +test fn word64_add_carries_across_the_halves_and_rotr_swaps_them() -> Bool { + let all_ones = Word64 { hi: 4294967295, lo: 4294967295 } + let one = Word64 { hi: 0, lo: 1 } + let sum = word64_add(a: all_ones, b: one) + let r = word64_rotr(a: Word64 { hi: 1, lo: 0 }, n: 33) + let x = word64_xor(a: all_ones, b: one) + sum.hi == 0 && sum.lo == 0 && r.hi == 2147483648 && r.lo == 0 && x.hi == 4294967295 && x.lo == 4294967294 +} + +// ── SHA-256: FIPS 180-2 Appendix B, the published examples ──────────────────────────────────── +test fn sha256_of_the_empty_message_is_the_published_value() -> Bool { + sha256_hex(message: []) == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" +} + +test fn sha256_of_abc_is_the_published_one_block_value() -> Bool { + sha256_hex(message: ascii(s: "abc")) == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" +} + +// 448 bits: the padding no longer fits the first block, so this is the two-block path. +test fn sha256_of_the_448_bit_message_is_the_published_two_block_value() -> Bool { + sha256_hex(message: ascii(s: "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq")) == "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1" +} + +// One changed octet changes the digest: the discriminating red for the vectors above. +test fn sha256_of_a_one_octet_change_is_a_different_digest() -> Bool { + sha256_hex(message: ascii(s: "abd")) != "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" +} From 370b10587a55fb934740822006fecb776ea12afc Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 19 Sep 2026 11:03:37 +0000 Subject: [PATCH 19/31] nist_p256: move three in-body // annotations to module-item grain (the emitter refuses body annotations, DESIGN 4c) Co-Authored-By: Claude Opus 5 (1M context) --- dag/extdeps/crypto/nist_p256.dag | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dag/extdeps/crypto/nist_p256.dag b/dag/extdeps/crypto/nist_p256.dag index ed13dae5d44..bc92c29fee8 100644 --- a/dag/extdeps/crypto/nist_p256.dag +++ b/dag/extdeps/crypto/nist_p256.dag @@ -195,9 +195,10 @@ fn p256_ecdsa_verify_octets(key: List, signature: List, message: L } } +// e = SHA-256(message) as an integer; 256 bits is the bit length of n, so there is no truncation +// (§6.4.2 step 3). e may exceed n; one reduction suffices because e < 2^256 < 2n. Likewise the final +// v = x mod n needs one reduction, because x < p < 2n. fn p256_verify_checked(qx: BigNat, qy: BigNat, r: BigNat, s: BigNat, message: List) -> Bool { - // e = SHA-256(message) as an integer; 256 bits = the bit length of n, so no truncation (§6.4.2 - // step 3). e may exceed n; reducing once suffices because e < 2^256 < 2n. let e = montgomery_reduce_once(ctx: p256_order, a: bignat_from_octets(octets: sha256(message: message))) let sm = montgomery_to(ctx: p256_order, a: s) let w = montgomery_inverse_prime(ctx: p256_order, a: sm) @@ -207,7 +208,6 @@ fn p256_verify_checked(qx: BigNat, qy: BigNat, r: BigNat, s: BigNat, message: Li if p256_is_infinity(p: point) { false } else { let zinv = montgomery_inverse_prime(ctx: p256_field, a: point.z) let x = montgomery_from(ctx: p256_field, a: fmul(a: point.x, b: fsqr(a: zinv))) - // v = x mod n; x < p < 2n, so once. bignat_compare(a: montgomery_reduce_once(ctx: p256_order, a: x), b: r) == 0 } } From cabb3a9bc2ed704f2ca0f0b86938bd789c8a283b Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 19 Sep 2026 11:08:26 +0000 Subject: [PATCH 20/31] Recurring failure mode: a realization refusal surfaces one phase late, at rustc (18-site receipt from the A1 gate) Co-Authored-By: Claude Opus 5 (1M context) --- ...fusal_surfaces_one_phase_late_at_rustc.dag | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 dag/gunbc/recurring_failure_mode/realization_refusal_surfaces_one_phase_late_at_rustc.dag diff --git a/dag/gunbc/recurring_failure_mode/realization_refusal_surfaces_one_phase_late_at_rustc.dag b/dag/gunbc/recurring_failure_mode/realization_refusal_surfaces_one_phase_late_at_rustc.dag new file mode 100644 index 00000000000..3e73de48d24 --- /dev/null +++ b/dag/gunbc/recurring_failure_mode/realization_refusal_surfaces_one_phase_late_at_rustc.dag @@ -0,0 +1,23 @@ +module gunbc.recurring_failure_mode.realization_refusal_surfaces_one_phase_late_at_rustc + +import std.types { NonEmptyStr } +import gunbc.recurring_failure_mode { RecurringFailureMode } +import std.decl_ref { DeclarationRef, WholeDeclaration } + +data realization_refusal_surfaces_one_phase_late_at_rustc: RecurringFailureMode = RecurringFailureMode { + identity: "realization_refusal_surfaces_one_phase_late_at_rustc" as NonEmptyStr, + + receipts: [ + "realization refusal surfaces one phase late, at rustc (the compile step reports its verdict -- `0 blocking error(s)` and exit 0 -- while the emitter has written a realization refusal into the emitted crate as compile_error!, so the refusal the emitter decided is first seen by rustc, one phase later, under a build failure nobody joins back to the compile step that reported clean). INVALID STATE: an emitted artifact that carries a refusal while the step that produced it reports admission. HARM: every consumer that trusts the compile verdict -- a lane that emits and hands the crate on, a reader of the compile log -- proceeds on a clean verdict for an artifact that cannot build; the refusal is not lost, but it is relocated to a phase whose failure reads as a toolchain problem.", + + "RECEIPT, 2026-09-19 (lane A of the approval app, milestone A1 of the .dag crypto): `gunbc compile --entry --target rust` over the closures of std.bitwise, std.bignat, extdeps.crypto.sha2 and extdeps.crypto.nist_p256 printed `0 blocking error(s), 2011 advisory diagnostic(s) (policy: gunbc.compile_clean_diagnostic_policy)` and exited 0 for both entries. `cargo build` of the emitted crates then failed on the runner with 18 compile_error! sites, all `operator realization: host operator `` on structural operand std.integer. -- the declaration has no host realization and declares no operation for this operator`: 16 in the SHA-256 closure (7 `%`, 5 `+`, 2 `-`, 1 `*`, 1 `/`, all on std.integer.UInt32, in std.bitwise and extdeps.crypto.sha2) and those 16 plus 2 `*` on std.integer.UInt8 in std.bignat bignat_from_octets for the P-256 closure. The emitter decided every one of them at emission (std.operator_realization OperatorRealizationRefused, rendered by v1.compiler.emit_rust emit_typed_bin_op through emit_rust_compile_error_expr); none reached the verdict the compile step printed.", + + "DISTINGUISHING FACTS: the refusal is correct and typed, and the defect is only WHERE it is reported. The emitter's own decision (OperatorRealizationRefused) is a located refusal; what fails is the join from that decision to the step verdict, which counts the policy's blocking diagnostics and does not count refusals the emitter chose to render as compile_error! text. Not the same class as a refusal typed as success: the artifact does not claim to be valid -- it refuses at the next phase -- but the step that owned the decision reported the opposite. RUNG FOUND AT: mitigatable (the line does stop, at rustc). CEILING: structurally guaranteed -- every OperatorRealizationRefused the emitter renders is a fact the compile step already holds, so the step verdict can count it as blocking by construction. NEXT-RUNG TRIGGER: the compile step's blocking count includes every realization refusal the emitter rendered into the artifact, SUFFICIENT FOR a closure whose emission carries any compile_error! to be refused by `gunbc compile` itself with the site and message, before any toolchain runs.", + ], + + evidence: [ + DeclarationRef { module_path: "std.operator_realization", decl_name: "operator_realization_refusal_message", field: WholeDeclaration }, + DeclarationRef { module_path: "v1.compiler.emit_rust", decl_name: "emit_typed_bin_op", field: WholeDeclaration }, + DeclarationRef { module_path: "gunbc.compile_clean_diagnostic_policy", decl_name: "compile_clean_unlisted_import_use_enforcement", field: WholeDeclaration }, + ], +} From adf65e0384f6d02e39dfa211e36b572fbd550a4e Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 19 Sep 2026 11:14:06 +0000 Subject: [PATCH 21/31] Recurring failure mode: the v1 interpreter evaluates bounded-natural arithmetic as unbounded Int (std.bitwise xor reaching 2^33 typed UInt32) Co-Authored-By: Claude Opus 5 (1M context) --- ..._arithmetic_evaluated_as_unbounded_int.dag | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 dag/gunbc/recurring_failure_mode/bounded_natural_arithmetic_evaluated_as_unbounded_int.dag diff --git a/dag/gunbc/recurring_failure_mode/bounded_natural_arithmetic_evaluated_as_unbounded_int.dag b/dag/gunbc/recurring_failure_mode/bounded_natural_arithmetic_evaluated_as_unbounded_int.dag new file mode 100644 index 00000000000..60a04cce434 --- /dev/null +++ b/dag/gunbc/recurring_failure_mode/bounded_natural_arithmetic_evaluated_as_unbounded_int.dag @@ -0,0 +1,24 @@ +module gunbc.recurring_failure_mode.bounded_natural_arithmetic_evaluated_as_unbounded_int + +import std.types { NonEmptyStr } +import gunbc.recurring_failure_mode { RecurringFailureMode } +import std.decl_ref { DeclarationRef, WholeDeclaration } + +data bounded_natural_arithmetic_evaluated_as_unbounded_int: RecurringFailureMode = RecurringFailureMode { + identity: "bounded_natural_arithmetic_evaluated_as_unbounded_int" as NonEmptyStr, + + receipts: [ + "bounded natural arithmetic evaluated as unbounded Int (the v1 interpreter evaluates infix + - * / % on std.integer's bounded naturals -- Compose>, e.g. UInt8 and UInt32 -- as ordinary 64-bit Int arithmetic, with no check that operands or results lie in [0, 2^N)). INVALID STATE: a value typed UInt32 that is not a 32-bit unsigned natural. HARM: the interpreter answers a program the declared types do not admit, silently, and the emitted realization of the same program disagrees -- std.operator_realization refuses infix arithmetic on a structural operand (the corpus's rule is that structural arithmetic is a call to a declared operation with a typed outcome), so a closure that passes every interpreted witness does not build natively, and nothing in the interpreted run said the arithmetic was outside its type.", + + "RECEIPT, 2026-09-19 (lane A of the approval app, the .dag crypto): std.bitwise word32_xor is written `a + b - 2 * word32_and(a: a, b: b)` over a, b: UInt32, so `a + b` reaches 2^33 - 2 while typed UInt32; word32_or, word32_add ((a + b) % 2^32) and the rotations likewise pass through values above 2^32 - 1 typed UInt32. Under the interpreter every FIPS 180-2 SHA-256 vector passed (test.claim.sha256_fips_witness_test, 7/7) and the RFC 6979 A.2.5 P-256 verify answered true, while emitting the same closure produced 18 compile_error! operator refusals on UInt32/UInt8 (gunbc.recurring_failure_mode realization_refusal_surfaces_one_phase_late_at_rustc). The interpreter was the permissive side; the emitter was right.", + + "DISTINGUISHING FACTS: not an overflow of the 64-bit Int (that refuses, IntegerOverflow); the refusal missing is at the DECLARED width, which the interpreter never consults -- MachineWidth is a bodiless type and std.machine_constraints Compose is a Phantom, so no evaluation path can see N (measured: a generic fn returning its width parameter N as a value refuses NoSuchVariable). RUNG FOUND AT: below the ladder -- silent wrongness at the declared type. CEILING: structurally impossible -- bounded naturals are a sole-constructed carrier whose range check is the only mint, with arithmetic as declared operations returning typed outcomes (std.integer IntegerOverflowSemantics), so an out-of-range value has no constructor. NEXT-RUNG TRIGGER: the width N of MachineWidth readable as a value in .dag, SUFFICIENT FOR std.integer to declare one family of bounded-natural operations derived over N rather than a hand-restated width per type; until then the remedy can be spelled but not derived.", + ], + + evidence: [ + DeclarationRef { module_path: "std.machine_constraints", decl_name: "MachineWidth", field: WholeDeclaration }, + DeclarationRef { module_path: "std.integer", decl_name: "UInt32", field: WholeDeclaration }, + DeclarationRef { module_path: "std.integer", decl_name: "IntegerOverflowSemantics", field: WholeDeclaration }, + DeclarationRef { module_path: "std.operator_realization", decl_name: "operator_realization_refusal_message", field: WholeDeclaration }, + ], +} From 8b7792a5343760e07d509328ab1516dde72e4753 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 20 Sep 2026 01:06:57 +0000 Subject: [PATCH 22/31] std.bounded_nat: the declared operation family for bounded naturals, over a supplied width Items 1-3 of the approved A2 shape, authored now because the corrected ruling leaves nothing in them waiting on the compiler lane: the type-level index keeps taking a Nat literal or PointerWidth, and BitWidth is std.measure's existing declaration reached at the value layer. - Three-arm width admission: WidthAdmitted, WidthNotStatic (MachineWidth has no constant to reify; IntPlatform and UIntPlatform are its live inhabitants) and WidthExceedsHostInt. The two refusals stay separate because they are different deficits. WidthExceedsHostInt carries its next-rung trigger: arbitrary precision through std.bignat, SUFFICIENT FOR UInt64 and UInt128 to become admitted widths. - A sole-constructed carrier whose range admission is its only mint, so an out-of-width value has no constructor. - Checked add/sub/mul/div/rem with typed outcomes, plus add_mod/mul_mod as the declared wrapping arm. WidthMismatch refuses two widths as operands rather than widening one. Multiplication carries its own admission, since a product needs twice the width. WidthNotStatic has no producer here and says so: only the binding layer, which needs reification, can mint it. Not authored: the UInt32/UInt8 bindings, the 18 conversions, any default width. Co-Authored-By: Claude Opus 5 (1M context) --- dag/std/bounded_nat.dag | 217 ++++++++++++++++++++ dag/test/claim/bounded_nat_witness_test.dag | 117 +++++++++++ 2 files changed, 334 insertions(+) create mode 100644 dag/std/bounded_nat.dag create mode 100644 dag/test/claim/bounded_nat_witness_test.dag diff --git a/dag/std/bounded_nat.dag b/dag/std/bounded_nat.dag new file mode 100644 index 00000000000..1a57b5ff03a --- /dev/null +++ b/dag/std/bounded_nat.dag @@ -0,0 +1,217 @@ +module std.bounded_nat + +import std.types { Int, Bool, List } +import std.measure { BitWidth, bit_width, bit_width_count } +import std.bitwise { bitwise_pow2 } + +// ARITHMETIC ON BOUNDED NATURALS, AS DECLARED OPERATIONS RATHER THAN INFIX. +// +// std.operator_realization refuses infix + - * / % on a structural operand and says why: structural +// arithmetic is a call to a DECLARED OPERATION whose typed outcome an infix form would hide. The +// bounded naturals of std.integer (Compose>) are structural, so this module is +// the operation family that refusal points at. It is ONE family over a width, never one row per +// operator per type. +// +// WHY THIS IS NOT A SECOND AUTHORITY FOR THE TYPES. std.integer declares WHICH bounded naturals +// exist; this module declares WHAT ARITHMETIC ON ONE MEANS. The binding between them -- UInt32's +// width read from its own declaration -- is the one part that cannot be written yet: it needs the N +// of MachineWidth reified at compile time (operator ruling, 2026-09-20). Until that lands the +// width arrives as a value and NOTHING here names a width. +// +// THE DEFECT THIS EXISTS TO REMOVE is gunbc.recurring_failure_mode +// bounded_natural_arithmetic_evaluated_as_unbounded_int: the v1 interpreter evaluates infix +// arithmetic on these types as unbounded Int, so a value outside [0, 2^N) is silently admitted and +// the interpreted and emitted realizations of one program disagree. + +// ── Width admission, decided before any arithmetic ─────────────────────────────────────────── +// THREE ARMS, BECAUSE THE TWO REFUSALS ARE DIFFERENT FACTS. A width with no source constant +// (MachineWidth, whose inhabitants std.integer IntPlatform and UIntPlatform are real +// declarations) is not the same deficit as a constant that does not fit the host integer. Collapsing +// them into one refusal would put two deficits under one name, so neither could rank for repair. +// +// THE HOST BOUND IS 62 BITS: a value must be representable in the substrate's 64-bit signed Int, and +// the sum of two N-bit values needs N + 1 bits, so 2^62 - 1 is the largest admitted magnitude. +// +// WidthNotStatic HAS NO PRODUCER IN THIS MODULE, and that is a declared frontier rather than dead +// code: only reification can report that a MachineWidth's index is PointerWidth rather than a +// literal, so the arm is minted by the binding layer that reads a declaration's width, which cannot +// be written until reification lands. bounded_nat_admit below therefore never returns it, and no +// witness here can exercise it. Stated so a reader does not mistake an unreachable arm for a +// reachable one, and so the binding layer's obligation is visible before it is written. +type BoundedNatAdmission + = WidthAdmitted { bits: BitWidth } + | WidthNotStatic + | WidthExceedsHostInt { bits: BitWidth } + +// The host bound, stated once. +fn bounded_nat_host_bit_ceiling() -> Int { + 62 +} + +// WIDTH-EXCEEDS-HOST IS A CEILING WITH A TRIGGER, NOT A TERMINAL DESIGN (DESIGN 4b(2): no untracked +// stall). It is a fact about the HOST integer this family computes in, not about bounded naturals: +// UInt64 and UInt128 are perfectly ordinary bounded naturals whose magnitudes exceed one i64. +// NEXT-RUNG TRIGGER, a capability: arbitrary-precision arithmetic for this family through +// std.bignat's multi-limb naturals, SUFFICIENT FOR UInt64 and UInt128 to be ADMITTED widths whose +// operations carry the same typed outcomes, at which point this arm's population is empty rather +// than refused. std.bignat already carries the limbs and the Montgomery arithmetic that would serve. +data bounded_nat_width_ceiling_trigger: String = "arbitrary-precision bounded arithmetic through std.bignat, SUFFICIENT FOR UInt64 and UInt128 to become admitted widths rather than refused ones; the 62-bit bound is a property of the host Int this family computes in, not of bounded naturals" + +// A reified width admitted, or refused with the reason. `bits` is what reification returns for a +// MachineWidth whose index is a literal; a MachineWidth has no constant to reify +// and reaches this as WidthNotStatic, which is a caller's arm to answer rather than a default. +fn bounded_nat_admit(bits: BitWidth) -> BoundedNatAdmission { + if bit_width_count(b: bits) > bounded_nat_host_bit_ceiling() { + WidthExceedsHostInt { bits: bits } + } else { + WidthAdmitted { bits: bits } + } +} + +// The exclusive upper bound of a width: 2^N. +fn bounded_nat_modulus(bits: BitWidth) -> Int { + bitwise_pow2(n: bit_width_count(b: bits)) +} + +// ── The carrier: sole-constructed, its admission the only mint ─────────────────────────────── +// An out-of-width value has NO CONSTRUCTOR. That is the ceiling the failure-mode row names, and it +// is why this is a carrier rather than a bare Int with a check beside it: a check can be bypassed by +// constructing the value another way, and a constructor cannot. +type BoundedNat sole_constructor { + bits: BitWidth + value: Int +} + +type BoundedNatMint + = Minted { n: BoundedNat } + | OutOfWidth { bits: BitWidth, value: Int } + | WidthRefused { cause: BoundedNatAdmission } + +fn bounded_nat(bits: BitWidth, value: Int) -> BoundedNatMint { + match bounded_nat_admit(bits: bits) { + WidthNotStatic => WidthRefused { cause: WidthNotStatic } + WidthExceedsHostInt { bits: b } => WidthRefused { cause: WidthExceedsHostInt { bits: b } } + WidthAdmitted { bits: b } => + if value < 0 || value >= bounded_nat_modulus(bits: b) { + OutOfWidth { bits: b, value: value } + } else { + Minted { n: BoundedNat { bits: b, value: value } } + } + } +} + +// ── The operations ─────────────────────────────────────────────────────────────────────────── +// EVERY ARM IS A FACT THE CALLER MUST ANSWER, and there is no silent wrap anywhere: wrapping exists +// only under its own name below. This is std.integer IntegerOverflowSemantics read at the operation +// grain -- OverflowRaises for the checked operations, OverflowReturns{ReturnWrapping} for the _mod +// pair -- so no third overflow vocabulary is minted here. +// +// WIDTH MISMATCH IS ITS OWN ARM because two bounded naturals of different widths are not the +// operands of one operation. Widening the narrower one silently is exactly the fail-open this family +// exists to remove, so the disagreement is reported rather than resolved. +// +// PRODUCT WIDTH IS A SEPARATE ADMISSION FROM SUM WIDTH. A sum of two N-bit values needs N + 1 bits, +// but a product needs 2N, so multiplication is admitted only while 2N stays inside the host bound. +// Above that the operation refuses with its own arm rather than letting the host integer overflow -- +// which in the substrate is a raised refusal, not a value, and would stop the line somewhere this +// family cannot describe. +type BoundedNatArith + = Within { n: BoundedNat } + | Overflows { bits: BitWidth } + | NegativeResult { bits: BitWidth } + | DividesByZero + | WidthMismatch { left: BitWidth, right: BitWidth } + | ProductExceedsHostInt { bits: BitWidth } + +fn bounded_nat_widths_agree(a: BoundedNat, b: BoundedNat) -> Bool { + bit_width_count(b: a.bits) == bit_width_count(b: b.bits) +} + +fn bounded_nat_in_width(bits: BitWidth, value: Int) -> BoundedNatArith { + if value < 0 { + NegativeResult { bits: bits } + } else if value >= bounded_nat_modulus(bits: bits) { + Overflows { bits: bits } + } else { + Within { n: BoundedNat { bits: bits, value: value } } + } +} + +fn bounded_nat_add(a: BoundedNat, b: BoundedNat) -> BoundedNatArith { + if !bounded_nat_widths_agree(a: a, b: b) { + WidthMismatch { left: a.bits, right: b.bits } + } else { + bounded_nat_in_width(bits: a.bits, value: a.value + b.value) + } +} + +fn bounded_nat_sub(a: BoundedNat, b: BoundedNat) -> BoundedNatArith { + if !bounded_nat_widths_agree(a: a, b: b) { + WidthMismatch { left: a.bits, right: b.bits } + } else { + bounded_nat_in_width(bits: a.bits, value: a.value - b.value) + } +} + +// Admitted only while twice the width fits the host integer; see the note above. +fn bounded_nat_product_admitted(bits: BitWidth) -> Bool { + 2 * bit_width_count(b: bits) <= bounded_nat_host_bit_ceiling() +} + +fn bounded_nat_mul(a: BoundedNat, b: BoundedNat) -> BoundedNatArith { + if !bounded_nat_widths_agree(a: a, b: b) { + WidthMismatch { left: a.bits, right: b.bits } + } else if !bounded_nat_product_admitted(bits: a.bits) { + ProductExceedsHostInt { bits: a.bits } + } else { + bounded_nat_in_width(bits: a.bits, value: a.value * b.value) + } +} + +// Division and remainder are total only away from a zero divisor, which is the typed arm an infix +// `/` would have hidden -- the reason std.operator_realization refuses the infix form at all. +fn bounded_nat_div(a: BoundedNat, b: BoundedNat) -> BoundedNatArith { + if !bounded_nat_widths_agree(a: a, b: b) { + WidthMismatch { left: a.bits, right: b.bits } + } else if b.value == 0 { + DividesByZero + } else { + bounded_nat_in_width(bits: a.bits, value: a.value / b.value) + } +} + +fn bounded_nat_rem(a: BoundedNat, b: BoundedNat) -> BoundedNatArith { + if !bounded_nat_widths_agree(a: a, b: b) { + WidthMismatch { left: a.bits, right: b.bits } + } else if b.value == 0 { + DividesByZero + } else { + bounded_nat_in_width(bits: a.bits, value: a.value % b.value) + } +} + +// ── Wrapping, under its own name ───────────────────────────────────────────────────────────── +// THE DECLARED OverflowReturns{ReturnWrapping} ARM. Reduction modulo 2^N is what a hash's word +// arithmetic means -- SHA-256's additions are defined mod 2^32 -- so it is an operation with a name, +// not a silent outcome of the checked one. A caller that wants the checked arm calls the checked +// arm; nothing here decides that for it. +// +// These still refuse on a width disagreement and on a product the host cannot hold, because those +// are not overflow of the DECLARED width: they are the operation being inapplicable. +fn bounded_nat_add_mod(a: BoundedNat, b: BoundedNat) -> BoundedNatArith { + if !bounded_nat_widths_agree(a: a, b: b) { + WidthMismatch { left: a.bits, right: b.bits } + } else { + Within { n: BoundedNat { bits: a.bits, value: (a.value + b.value) % bounded_nat_modulus(bits: a.bits) } } + } +} + +fn bounded_nat_mul_mod(a: BoundedNat, b: BoundedNat) -> BoundedNatArith { + if !bounded_nat_widths_agree(a: a, b: b) { + WidthMismatch { left: a.bits, right: b.bits } + } else if !bounded_nat_product_admitted(bits: a.bits) { + ProductExceedsHostInt { bits: a.bits } + } else { + Within { n: BoundedNat { bits: a.bits, value: (a.value * b.value) % bounded_nat_modulus(bits: a.bits) } } + } +} diff --git a/dag/test/claim/bounded_nat_witness_test.dag b/dag/test/claim/bounded_nat_witness_test.dag new file mode 100644 index 00000000000..6a447e3523e --- /dev/null +++ b/dag/test/claim/bounded_nat_witness_test.dag @@ -0,0 +1,117 @@ +module test.claim.bounded_nat_witness_test + +import std.logic { Bool } +import std.types { Int } +import std.measure { BitWidth, bit_width } +import std.bounded_nat { + BoundedNat, BoundedNatMint, Minted, OutOfWidth, WidthRefused, + BoundedNatAdmission, WidthAdmitted, WidthExceedsHostInt, + BoundedNatArith, Within, Overflows, NegativeResult, DividesByZero, WidthMismatch, ProductExceedsHostInt, + bounded_nat, bounded_nat_admit, bounded_nat_add, bounded_nat_sub, bounded_nat_mul, + bounded_nat_div, bounded_nat_rem, bounded_nat_add_mod, bounded_nat_mul_mod, +} + +// The widths are SUPPLIED here, as every caller must supply one until reification can read a +// declaration's own width (DESIGN 3's witness rule: the inputs belong at the boundary as values). +fn w32() -> BitWidth { bit_width(count: 32) } +fn w8() -> BitWidth { bit_width(count: 8) } + +// A mint or nothing. The witness never constructs a BoundedNat itself: the carrier is +// sole-constructed, and fabricating one on a failed mint would be the fail-open this family removes. +fn m(bits: BitWidth, value: Int) -> BoundedNat? { + match bounded_nat(bits: bits, value: value) { + Minted { n } => Present { value: n } + OutOfWidth { bits: _, value: _ } => none + WidthRefused { cause: _ } => none + } +} + +// Applies an operation to two mints, or answers Absent if either did not mint. Absent is never a +// pass in the tests below: each asserts the arm it expects. +fn on2(a: BoundedNat?, b: BoundedNat?, f: fn(BoundedNat, BoundedNat) -> BoundedNatArith) -> BoundedNatArith? { + match a { + Absent => none + Present { value: x } => + match b { + Absent => none + Present { value: y } => Present { value: f(x, y) } + } + } +} + +fn arm_is_overflow(r: BoundedNatArith?) -> Bool { + match r { Present { value: Overflows { bits: _ } } => true _ => false } +} + +fn arm_value(r: BoundedNatArith?) -> Int? { + match r { + Present { value: Within { n } } => Present { value: n.value } + _ => none + } +} + +// ── The carrier admits only values inside its width ───────────────────────────────────────── +test fn a_value_inside_the_width_mints_and_one_outside_it_does_not() -> Bool { + let inside = match bounded_nat(bits: w8(), value: 255) { Minted { n } => n.value == 255 _ => false } + let above = match bounded_nat(bits: w8(), value: 256) { OutOfWidth { bits: _, value: v } => v == 256 _ => false } + let below = match bounded_nat(bits: w8(), value: -1) { OutOfWidth { bits: _, value: v } => v == -1 _ => false } + inside && above && below +} + +// A width the host integer cannot hold refuses at admission, and says which of the two refusals it is. +test fn a_width_beyond_the_host_bound_is_refused_as_such() -> Bool { + let admitted = match bounded_nat_admit(bits: w32()) { WidthAdmitted { bits: _ } => true _ => false } + let refused = match bounded_nat_admit(bits: bit_width(count: 64)) { WidthExceedsHostInt { bits: _ } => true _ => false } + let mint_refused = match bounded_nat(bits: bit_width(count: 64), value: 1) { WidthRefused { cause: WidthExceedsHostInt { bits: _ } } => true _ => false } + admitted && refused && mint_refused +} + +// ── Checked arithmetic refuses at the edge rather than wrapping ────────────────────────────── +test fn addition_at_the_top_of_the_width_overflows_rather_than_wrapping() -> Bool { + let ok = arm_value(r: on2(a: m(bits: w32(), value: 4294967294), b: m(bits: w32(), value: 1), f: bounded_nat_add)) == Present { value: 4294967295 } + let over = arm_is_overflow(r: on2(a: m(bits: w32(), value: 4294967295), b: m(bits: w32(), value: 1), f: bounded_nat_add)) + ok && over +} + +test fn subtraction_below_zero_is_a_negative_result_not_a_wrap() -> Bool { + match on2(a: m(bits: w8(), value: 3), b: m(bits: w8(), value: 4), f: bounded_nat_sub) { + Present { value: NegativeResult { bits: _ } } => true + _ => false + } +} + +test fn multiplication_overflowing_the_width_refuses_and_division_by_zero_refuses() -> Bool { + let over = arm_is_overflow(r: on2(a: m(bits: w8(), value: 16), b: m(bits: w8(), value: 16), f: bounded_nat_mul)) + let ok = arm_value(r: on2(a: m(bits: w8(), value: 15), b: m(bits: w8(), value: 17), f: bounded_nat_mul)) == Present { value: 255 } + let div0 = match on2(a: m(bits: w8(), value: 5), b: m(bits: w8(), value: 0), f: bounded_nat_div) { Present { value: DividesByZero } => true _ => false } + let rem0 = match on2(a: m(bits: w8(), value: 5), b: m(bits: w8(), value: 0), f: bounded_nat_rem) { Present { value: DividesByZero } => true _ => false } + let div = arm_value(r: on2(a: m(bits: w8(), value: 7), b: m(bits: w8(), value: 2), f: bounded_nat_div)) == Present { value: 3 } + over && ok && div0 && rem0 && div +} + +// ── Two widths are not the operands of one operation ───────────────────────────────────────── +test fn operands_of_different_widths_refuse_rather_than_widening() -> Bool { + match on2(a: m(bits: w8(), value: 1), b: m(bits: w32(), value: 1), f: bounded_nat_add) { + Present { value: WidthMismatch { left: _, right: _ } } => true + _ => false + } +} + +// ── Wrapping exists only under its own name ───────────────────────────────────────────────── +// The same pair that OVERFLOWS under the checked operation WRAPS here: the discriminating pair for +// the claim that no silent wrap exists. +test fn the_mod_operations_wrap_where_the_checked_ones_refuse() -> Bool { + let wrapped = arm_value(r: on2(a: m(bits: w32(), value: 4294967295), b: m(bits: w32(), value: 1), f: bounded_nat_add_mod)) == Present { value: 0 } + let checked = arm_is_overflow(r: on2(a: m(bits: w32(), value: 4294967295), b: m(bits: w32(), value: 1), f: bounded_nat_add)) + let mul_wrapped = arm_value(r: on2(a: m(bits: w8(), value: 16), b: m(bits: w8(), value: 16), f: bounded_nat_mul_mod)) == Present { value: 0 } + wrapped && checked && mul_wrapped +} + +// A product needs twice the width, so a width the host can hold may still have no admissible product. +test fn a_product_too_wide_for_the_host_refuses_on_its_own_arm() -> Bool { + let w40 = bit_width(count: 40) + match on2(a: m(bits: w40, value: 3), b: m(bits: w40, value: 3), f: bounded_nat_mul) { + Present { value: ProductExceedsHostInt { bits: _ } } => true + _ => false + } +} From 95004fb74290c71d876d8cc2a6e8c660fd144e63 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 20 Sep 2026 08:09:55 +0000 Subject: [PATCH 23/31] std.bounded_nat: the wrapping product stops inheriting the checked product's ceiling A reviewer asked whether the host-width rule refuses operands that are valid and computable. For the WRAPPING product it did, and that was an over-prohibition (DESIGN 4d): reduction modulo 2^N depends only on the low N bits, while the shared `bounded_nat_product_admitted` demanded 2N <= 62 because the CHECKED product needs the whole product in order to detect overflow. UInt32 multiplication therefore refused on both arms, though only one of them needed to. Splitting one operand at h = N / 2 gives (a * b) mod 2^N = ((a1 * b) mod 2^(N - h)) * 2^h + a0 * b (mod 2^N) whose intermediates are h + N and (N - h) + N bits. So `bounded_nat_mul_mod` now computes UInt32 wrapping products, while `bounded_nat_mul` on the same operands still refuses -- correctly, since its 64-bit product does not exist on this host. That distinction is what the shared ceiling was hiding. The new ceiling is stated rather than rounded up: the split admits N <= 41, not every width the carrier admits, and 42..62 still refuse as a property of the naive host realization. Its next-rung trigger names the capability -- bignat routing or a recursive split -- sufficient for every admitted width to have a computable wrapping product. Witnesses, run and read (10/10, exit 0), each with the inverted control run to show its red is reachable: both new vectors have 64-bit full products, so neither answer is obtainable by forming the product on this host, and the ceiling is asserted at its 41/42 edge so a later widening is reported rather than absorbed. Co-Authored-By: Claude Opus 5 (1M context) --- dag/std/bounded_nat.dag | 48 ++++++++++++++++++--- dag/test/claim/bounded_nat_witness_test.dag | 31 +++++++++++++ 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/dag/std/bounded_nat.dag b/dag/std/bounded_nat.dag index 1a57b5ff03a..e217c749419 100644 --- a/dag/std/bounded_nat.dag +++ b/dag/std/bounded_nat.dag @@ -110,11 +110,30 @@ fn bounded_nat(bits: BitWidth, value: Int) -> BoundedNatMint { // operands of one operation. Widening the narrower one silently is exactly the fail-open this family // exists to remove, so the disagreement is reported rather than resolved. // -// PRODUCT WIDTH IS A SEPARATE ADMISSION FROM SUM WIDTH. A sum of two N-bit values needs N + 1 bits, -// but a product needs 2N, so multiplication is admitted only while 2N stays inside the host bound. -// Above that the operation refuses with its own arm rather than letting the host integer overflow -- -// which in the substrate is a raised refusal, not a value, and would stop the line somewhere this +// PRODUCT WIDTH IS A SEPARATE ADMISSION FROM SUM WIDTH, AND THE TWO MULTIPLICATIONS DO NOT SHARE ONE. +// A sum of two N-bit values needs N + 1 bits. The CHECKED product needs 2N, because detecting that a +// product left the width requires the WHOLE product to exist -- so bounded_nat_mul is admitted only +// while 2N stays inside the host bound, and above that it refuses rather than letting the host +// integer overflow, which in the substrate is a raised refusal and would stop the line somewhere this // family cannot describe. +// +// THE WRAPPING PRODUCT NEEDS NO SUCH THING, AND GIVING IT THE CHECKED CEILING WAS AN OVER-PROHIBITION +// (DESIGN 4d: a mechanism that forbids more than the evidence supports costs the thing you never did). +// Reduction modulo 2^N depends only on the LOW N bits, which a host int can carry for widths the full +// product would overflow. Splitting one operand at h = N / 2 gives the identity +// (a * b) mod 2^N = ((a1 * b) mod 2^(N - h)) * 2^h + a0 * b (mod 2^N), a = a1 * 2^h + a0 +// whose largest intermediates are a0 * b and a1 * b, of h + N and (N - h) + N bits. So UInt32 +// wrapping multiplication is computable here and no longer refuses, while UInt32 CHECKED +// multiplication still refuses -- correctly, because its full 64-bit product does not exist on this +// host. That is the distinction the shared ceiling was hiding. +// +// THE WRAPPING CEILING IS REAL BUT HIGHER, AND IS STATED RATHER THAN ROUNDED. The split admits a +// width only while max(h + N, (N - h) + N) stays inside the host bound, which is N <= 41 -- not every +// width the carrier admits. Widths 42..62 still refuse, and that residual is a property of the NAIVE +// HOST REALIZATION rather than of bounded naturals. NEXT-RUNG TRIGGER for it, a capability: routing +// the wrapping product through std.bignat's multi-limb arithmetic (or a recursive split), SUFFICIENT +// FOR every width this carrier admits to have a computable wrapping product, at which point +// bounded_nat_wrapping_product_admitted is constantly true and dissolves. type BoundedNatArith = Within { n: BoundedNat } | Overflows { bits: BitWidth } @@ -206,12 +225,29 @@ fn bounded_nat_add_mod(a: BoundedNat, b: BoundedNat) -> BoundedNatArith { } } +// Admitted while the SPLIT's intermediates fit the host, which is a weaker demand than the checked +// product's 2N; see the note above the carrier for the identity and the residual 42..62 ceiling. +fn bounded_nat_wrapping_product_admitted(bits: BitWidth) -> Bool { + bit_width_count(b: bits) + (bit_width_count(b: bits) - bit_width_count(b: bits) / 2) + <= bounded_nat_host_bit_ceiling() +} + +// The low N bits of a product, without ever forming the whole product. +fn bounded_nat_wrapping_product(bits: BitWidth, a: Int, b: Int) -> Int { + let n = bit_width_count(b: bits) + let h = n / 2 + let low_modulus = bitwise_pow2(n: h) + let high_modulus = bitwise_pow2(n: n - h) + let high = ((a / low_modulus) * b) % high_modulus + (high * low_modulus + (a % low_modulus) * b) % bounded_nat_modulus(bits: bits) +} + fn bounded_nat_mul_mod(a: BoundedNat, b: BoundedNat) -> BoundedNatArith { if !bounded_nat_widths_agree(a: a, b: b) { WidthMismatch { left: a.bits, right: b.bits } - } else if !bounded_nat_product_admitted(bits: a.bits) { + } else if !bounded_nat_wrapping_product_admitted(bits: a.bits) { ProductExceedsHostInt { bits: a.bits } } else { - Within { n: BoundedNat { bits: a.bits, value: (a.value * b.value) % bounded_nat_modulus(bits: a.bits) } } + Within { n: BoundedNat { bits: a.bits, value: bounded_nat_wrapping_product(bits: a.bits, a: a.value, b: b.value) } } } } diff --git a/dag/test/claim/bounded_nat_witness_test.dag b/dag/test/claim/bounded_nat_witness_test.dag index 6a447e3523e..ab8bfec1259 100644 --- a/dag/test/claim/bounded_nat_witness_test.dag +++ b/dag/test/claim/bounded_nat_witness_test.dag @@ -115,3 +115,34 @@ test fn a_product_too_wide_for_the_host_refuses_on_its_own_arm() -> Bool { _ => false } } + +// THE REVIEWER'S DISCRIMINATING CASE (review of gunbc#11645): does the host-width rule refuse an +// operand that is valid and computable? It did, for the WRAPPING product, and this is the red that +// would have caught it. Both vectors have a 64-bit full product, so neither answer is reachable by +// forming the product on this host -- the split is doing the work, not luck. The paired half is that +// the CHECKED product on the same operands still refuses, which is correct rather than a gap: the +// whole product is what detects overflow, and it does not exist here. +test fn the_wrapping_product_is_computed_where_the_checked_one_cannot_be() -> Bool { + let all_ones = arm_value(r: on2(a: m(bits: w32(), value: 4294967295), b: m(bits: w32(), value: 4294967295), f: bounded_nat_mul_mod)) == Present { value: 1 } + let mixed = arm_value(r: on2(a: m(bits: w32(), value: 4294967295), b: m(bits: w32(), value: 3735928559), f: bounded_nat_mul_mod)) == Present { value: 559038737 } + let checked_still_refuses = match on2(a: m(bits: w32(), value: 4294967295), b: m(bits: w32(), value: 4294967295), f: bounded_nat_mul) { + Present { value: ProductExceedsHostInt { bits: _ } } => true + _ => false + } + all_ones && mixed && checked_still_refuses +} + +// THE RESIDUAL CEILING IS REAL AND IS ASSERTED AT ITS EDGE, so that widening it later is a change +// this witness reports rather than absorbs. 41 is the last width the split admits; 42 is the first +// it refuses, and the refusal names its own arm. +test fn the_wrapping_product_ceiling_sits_between_41_and_42() -> Bool { + let admitted = match on2(a: m(bits: bit_width(count: 41), value: 3), b: m(bits: bit_width(count: 41), value: 5), f: bounded_nat_mul_mod) { + Present { value: Within { n: _ } } => true + _ => false + } + let refused = match on2(a: m(bits: bit_width(count: 42), value: 3), b: m(bits: bit_width(count: 42), value: 5), f: bounded_nat_mul_mod) { + Present { value: ProductExceedsHostInt { bits: _ } } => true + _ => false + } + admitted && refused +} From ff30cbb7a568d6cc2311d97f618d6edf054e9b9c Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 20 Sep 2026 08:22:29 +0000 Subject: [PATCH 24/31] std.bounded_nat: decide checked overflow by comparison, not by the type's width REQUEST_CHANGES blocker 2, and a correction to a claim I made and built on. The checked product was admitted by `2N <= 62`, keyed on the TYPE's width rather than the OPERANDS' values, so 40-bit 3 * 3 was refused although its product is 9. The refused population was every small product of a wide type. The justification I gave -- that the checked arm "needs the full product in order to detect overflow" -- was false. Overflow is decidable before multiplying: b != 0 && a > (2^N - 1) / b <=> a * b > 2^N - 1 which forms no product, and when it admits, the product is bounded by 2^N - 1 and so sits inside the carrier's already-admitted width. Verified exhaustively over N = 3, 5, 8, 12: zero mismatches against the direct comparison. So `ProductExceedsHostInt` leaves the checked arm entirely. Re-derived rather than assumed (blocker item 4): the variant still has exactly ONE producer -- the wrapping arm at widths 42..62, where the split's intermediates genuinely do not fit -- so it stays vocabulary something produces. `bounded_nat_product_admitted` had no remaining reference and is deleted. The witness that asserted the old refusal ENCODED THE DEFECT rather than catching it, and is replaced by three points of the corrected behaviour: a tiny product of a wide type, a genuinely overflowing one, and the exact top of the width. A second witness of mine asserted that checked UInt32 still refused; the suite caught it red on this change, and it now asserts Overflows, which is the honest answer -- the two arms differ in the QUESTION they answer, not in their host headroom. 10/10 witnesses pass, with both rewritten witnesses re-run inverted to show their red is reachable. Co-Authored-By: Claude Opus 5 (1M context) --- dag/std/bounded_nat.dag | 42 +++++++++++++-------- dag/test/claim/bounded_nat_witness_test.dag | 32 +++++++++------- 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/dag/std/bounded_nat.dag b/dag/std/bounded_nat.dag index e217c749419..9597622429b 100644 --- a/dag/std/bounded_nat.dag +++ b/dag/std/bounded_nat.dag @@ -110,22 +110,23 @@ fn bounded_nat(bits: BitWidth, value: Int) -> BoundedNatMint { // operands of one operation. Widening the narrower one silently is exactly the fail-open this family // exists to remove, so the disagreement is reported rather than resolved. // -// PRODUCT WIDTH IS A SEPARATE ADMISSION FROM SUM WIDTH, AND THE TWO MULTIPLICATIONS DO NOT SHARE ONE. -// A sum of two N-bit values needs N + 1 bits. The CHECKED product needs 2N, because detecting that a -// product left the width requires the WHOLE product to exist -- so bounded_nat_mul is admitted only -// while 2N stays inside the host bound, and above that it refuses rather than letting the host -// integer overflow, which in the substrate is a raised refusal and would stop the line somewhere this -// family cannot describe. +// THE TWO MULTIPLICATIONS ANSWER DIFFERENT QUESTIONS AND NEITHER IS ADMITTED BY WIDTH. A sum of two +// N-bit values needs N + 1 bits. A product would need 2N -- but NEITHER ARM EVER FORMS ONE IT CANNOT +// HOLD, so neither needs 2N of host headroom, and a width-keyed refusal on either was rejecting +// operands whose actual product was small. The checked arm decides overflow by comparison BEFORE +// multiplying; the wrapping arm computes the low N bits by splitting. Each is described where it is +// defined below. // -// THE WRAPPING PRODUCT NEEDS NO SUCH THING, AND GIVING IT THE CHECKED CEILING WAS AN OVER-PROHIBITION +// THE WRAPPING PRODUCT NEEDS NO FULL PRODUCT EITHER, AND GIVING IT THE CHECKED CEILING WAS AN OVER-PROHIBITION // (DESIGN 4d: a mechanism that forbids more than the evidence supports costs the thing you never did). // Reduction modulo 2^N depends only on the LOW N bits, which a host int can carry for widths the full // product would overflow. Splitting one operand at h = N / 2 gives the identity // (a * b) mod 2^N = ((a1 * b) mod 2^(N - h)) * 2^h + a0 * b (mod 2^N), a = a1 * 2^h + a0 // whose largest intermediates are a0 * b and a1 * b, of h + N and (N - h) + N bits. So UInt32 -// wrapping multiplication is computable here and no longer refuses, while UInt32 CHECKED -// multiplication still refuses -- correctly, because its full 64-bit product does not exist on this -// host. That is the distinction the shared ceiling was hiding. +// wrapping multiplication is computable here for EVERY pair of operands, including those whose full +// product does not exist on this host -- which is what the checked arm's comparison cannot do, since +// it answers whether a product fits rather than what its low bits are. The two arms are separate +// because they answer different questions, not because one has more headroom than the other. // // THE WRAPPING CEILING IS REAL BUT HIGHER, AND IS STATED RATHER THAN ROUNDED. The split admits a // width only while max(h + N, (N - h) + N) stays inside the host bound, which is N <= 41 -- not every @@ -172,16 +173,27 @@ fn bounded_nat_sub(a: BoundedNat, b: BoundedNat) -> BoundedNatArith { } } -// Admitted only while twice the width fits the host integer; see the note above. -fn bounded_nat_product_admitted(bits: BitWidth) -> Bool { - 2 * bit_width_count(b: bits) <= bounded_nat_host_bit_ceiling() +// OVERFLOW IS DECIDED BEFORE THE PRODUCT IS FORMED, WHICH IS WHY THE CHECKED ARM NEEDS NO HEADROOM +// AT ALL. `a * b > 2^N - 1` is equivalent to `a > (2^N - 1) / b` under integer division, and that +// comparison forms no product. So the question is answered without the intermediate whose size used +// to be the reason for refusing, and when the comparison ADMITS, the product is bounded by 2^N - 1 -- +// already inside the carrier's own admitted width, hence inside the host bound. +// +// THIS REPLACED A WIDTH-KEYED REFUSAL THAT REJECTED VALID PRODUCTS (review of gunbc#11645). The old +// rule asked `2N <= 62` and so refused 40-bit 3 * 3, whose product is 9: it keyed on the TYPE's width +// rather than the OPERANDS' values, which is the over-prohibition arm of DESIGN 4d in its purest +// form -- the refused population was every small product of a wide type. The earlier claim that the +// checked arm "needs the full product in order to detect overflow" was simply false, and the witness +// that asserted the old refusal encoded the defect rather than catching it. +fn bounded_nat_product_overflows(bits: BitWidth, a: Int, b: Int) -> Bool { + b != 0 && a > (bounded_nat_modulus(bits: bits) - 1) / b } fn bounded_nat_mul(a: BoundedNat, b: BoundedNat) -> BoundedNatArith { if !bounded_nat_widths_agree(a: a, b: b) { WidthMismatch { left: a.bits, right: b.bits } - } else if !bounded_nat_product_admitted(bits: a.bits) { - ProductExceedsHostInt { bits: a.bits } + } else if bounded_nat_product_overflows(bits: a.bits, a: a.value, b: b.value) { + Overflows { bits: a.bits } } else { bounded_nat_in_width(bits: a.bits, value: a.value * b.value) } diff --git a/dag/test/claim/bounded_nat_witness_test.dag b/dag/test/claim/bounded_nat_witness_test.dag index ab8bfec1259..42fa691cbfc 100644 --- a/dag/test/claim/bounded_nat_witness_test.dag +++ b/dag/test/claim/bounded_nat_witness_test.dag @@ -108,28 +108,34 @@ test fn the_mod_operations_wrap_where_the_checked_ones_refuse() -> Bool { } // A product needs twice the width, so a width the host can hold may still have no admissible product. -test fn a_product_too_wide_for_the_host_refuses_on_its_own_arm() -> Bool { +test fn a_small_product_of_a_wide_type_is_not_refused_for_its_width() -> Bool { let w40 = bit_width(count: 40) - match on2(a: m(bits: w40, value: 3), b: m(bits: w40, value: 3), f: bounded_nat_mul) { - Present { value: ProductExceedsHostInt { bits: _ } } => true - _ => false - } + let tiny = arm_value(r: on2(a: m(bits: w40, value: 3), b: m(bits: w40, value: 3), f: bounded_nat_mul)) == Present { value: 9 } + let genuinely_over = arm_is_overflow(r: on2(a: m(bits: w40, value: 1048576), b: m(bits: w40, value: 1048576), f: bounded_nat_mul)) + let at_the_edge = arm_value(r: on2(a: m(bits: w40, value: 1099511627775), b: m(bits: w40, value: 1), f: bounded_nat_mul)) == Present { value: 1099511627775 } + tiny && genuinely_over && at_the_edge } -// THE REVIEWER'S DISCRIMINATING CASE (review of gunbc#11645): does the host-width rule refuse an + +// THE REVIEWER'S SECOND DISCRIMINATING CASE (review of gunbc#11645): 40-bit 3 * 3 is 9, and the +// checked multiplication used to REFUSE it because the old admission keyed on the TYPE's width +// (2N <= 62) rather than the OPERANDS' values. This witness previously asserted that refusal, so it +// encoded the defect instead of catching it; it now asserts the corrected behaviour at three points +// -- a tiny product of a wide type, a genuinely overflowing one, and the exact top of the width. +// +// THE REVIEWER'S FIRST DISCRIMINATING CASE (review of gunbc#11645): does the host-width rule refuse an // operand that is valid and computable? It did, for the WRAPPING product, and this is the red that // would have caught it. Both vectors have a 64-bit full product, so neither answer is reachable by // forming the product on this host -- the split is doing the work, not luck. The paired half is that -// the CHECKED product on the same operands still refuses, which is correct rather than a gap: the -// whole product is what detects overflow, and it does not exist here. +// the CHECKED product on the same operands reports Overflows -- not ProductExceedsHostInt. The two +// arms give different ANSWERS about the same operands because they are asked different questions: +// the wrapping arm returns the low 32 bits, the checked arm reports that the product left the width. +// Neither refuses for want of host headroom. test fn the_wrapping_product_is_computed_where_the_checked_one_cannot_be() -> Bool { let all_ones = arm_value(r: on2(a: m(bits: w32(), value: 4294967295), b: m(bits: w32(), value: 4294967295), f: bounded_nat_mul_mod)) == Present { value: 1 } let mixed = arm_value(r: on2(a: m(bits: w32(), value: 4294967295), b: m(bits: w32(), value: 3735928559), f: bounded_nat_mul_mod)) == Present { value: 559038737 } - let checked_still_refuses = match on2(a: m(bits: w32(), value: 4294967295), b: m(bits: w32(), value: 4294967295), f: bounded_nat_mul) { - Present { value: ProductExceedsHostInt { bits: _ } } => true - _ => false - } - all_ones && mixed && checked_still_refuses + let checked_reports_overflow = arm_is_overflow(r: on2(a: m(bits: w32(), value: 4294967295), b: m(bits: w32(), value: 4294967295), f: bounded_nat_mul)) + all_ones && mixed && checked_reports_overflow } // THE RESIDUAL CEILING IS REAL AND IS ASSERTED AT ITS EDGE, so that widening it later is a change From 151427ef29077fa1de06865201420a09662dd8e0 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 20 Sep 2026 08:29:58 +0000 Subject: [PATCH 25/31] std.bounded_nat: the wrapping ceiling is 42, and the arm no longer claims a falsehood Review of #11645, four findings, all correct. The arithmetic was re-derived here rather than accepted. THE OFF-BY-ONE, AND ITS ROOT. The split's admission compared its intermediate bit demand against `bounded_nat_host_bit_ceiling()` == 62 -- but that constant is the widest CARRIER this family admits (62 because a SUM needs N + 1 bits), not the host integer's positive capacity, which is 63. Two unrelated limits behind one constant: the same defect this branch removed one level up when it split the checked and wrapping admissions apart. Width 42's largest intermediate is 9223367638806167553 against a host maximum of 9223372036854775807 -- representable -- so 42 was being refused for no reason. 43 is the first width that genuinely cannot fit. `bounded_nat_host_positive_bit_capacity()` now states that limit separately. THE WITNESS HAD CANONIZED THE BUG. It asserted a REFUSAL at width 42 for 3 * 5, whose product is 15, so it certified the off-by-one and exercised only the admission predicate rather than the intermediate bound that predicate is about. It now asserts the admitted edge with near-maximal operands -- (2^42 - 1)^2 mod 2^42 = 1, an answer unobtainable by forming the 84-bit product -- and the refusal at 43. CHECKED MULTIPLICATION CONSUMES THE EXISTING AUTHORITY rather than re-deriving it. std.checked_arithmetic already owns "PRE-CHECK, NEVER POST-CHECK" and the division-based bound test in checked_int_multiply. The note now cites that as the pattern being consumed and states what is genuinely local: only the BOUND, since this family tests the carrier's 2^N - 1 rather than the host's range. THE ARM NAME STATED A FALSEHOOD. `ProductExceedsHostInt` is false for width-43 3 * 5 -- nothing exceeds anything; a realization refuses the width before looking at the operands. Renamed `WrappingProductUnavailableAtWidth`, with a trigger naming BOTH sufficient routes, recursive splitting or std.bignat. EVIDENCE. 10/10 pass. The boundary control is the discriminating one: restoring the constant to 62 turns the width-42 witness RED and names it, so the witness catches the exact off-by-one rather than asserting the predicate against itself. Co-Authored-By: Claude Opus 5 (1M context) --- dag/std/bounded_nat.dag | 61 ++++++++++++++++----- dag/test/claim/bounded_nat_witness_test.dag | 32 ++++++----- 2 files changed, 65 insertions(+), 28 deletions(-) diff --git a/dag/std/bounded_nat.dag b/dag/std/bounded_nat.dag index 9597622429b..fa5fb934f91 100644 --- a/dag/std/bounded_nat.dag +++ b/dag/std/bounded_nat.dag @@ -43,11 +43,26 @@ type BoundedNatAdmission | WidthNotStatic | WidthExceedsHostInt { bits: BitWidth } -// The host bound, stated once. +// The CARRIER bound, stated once: the widest bounded natural this family admits. fn bounded_nat_host_bit_ceiling() -> Int { 62 } +// THE HOST'S POSITIVE CAPACITY, WHICH IS A DIFFERENT LIMIT AND MUST NOT SHARE A CONSTANT WITH THE +// ONE ABOVE. The carrier ceiling is 62 because a SUM of two N-bit values needs N + 1 bits; the host +// integer itself represents positives up to 2^63 - 1, so an intermediate is representable while its +// bit demand is at most 63. Those two facts are unrelated, and collapsing them cost exactly one +// width: the wrapping split was refusing width 42, whose largest intermediate is 9223367638808264705 +// against a host maximum of 9223372036854775807 -- representable, with room to spare. +// +// THIS IS THE SAME DEFECT THIS FILE REMOVED ONE LEVEL UP, RECURRING (review of gunbc#11645). The +// checked and wrapping products were forced through one admission predicate; splitting them left two +// unrelated LIMITS still sharing one CONSTANT. A predicate that serves two facts is the shape to +// distrust, not the particular pair. +fn bounded_nat_host_positive_bit_capacity() -> Int { + 63 +} + // WIDTH-EXCEEDS-HOST IS A CEILING WITH A TRIGGER, NOT A TERMINAL DESIGN (DESIGN 4b(2): no untracked // stall). It is a fact about the HOST integer this family computes in, not about bounded naturals: // UInt64 and UInt128 are perfectly ordinary bounded naturals whose magnitudes exceed one i64. @@ -129,19 +144,27 @@ fn bounded_nat(bits: BitWidth, value: Int) -> BoundedNatMint { // because they answer different questions, not because one has more headroom than the other. // // THE WRAPPING CEILING IS REAL BUT HIGHER, AND IS STATED RATHER THAN ROUNDED. The split admits a -// width only while max(h + N, (N - h) + N) stays inside the host bound, which is N <= 41 -- not every -// width the carrier admits. Widths 42..62 still refuse, and that residual is a property of the NAIVE -// HOST REALIZATION rather than of bounded naturals. NEXT-RUNG TRIGGER for it, a capability: routing -// the wrapping product through std.bignat's multi-limb arithmetic (or a recursive split), SUFFICIENT -// FOR every width this carrier admits to have a computable wrapping product, at which point -// bounded_nat_wrapping_product_admitted is constantly true and dissolves. +// width only while max(h + N, (N - h) + N) stays inside the HOST'S POSITIVE CAPACITY -- 63, not the +// carrier ceiling of 62 -- which is N <= 42. Width 43 is the first whose worst intermediate genuinely +// does not fit, so 43..62 refuse. +// +// THAT RESIDUAL IS A PROPERTY OF THIS ONE-LEVEL SPLIT, NOT OF BOUNDED NATURALS, AND THE ARM IS NAMED +// FOR THAT. A refusal at width 43 says nothing about the operands: 3 * 5 is 15 there, and calling +// that "product exceeds host int" would state a falsehood about values in order to describe a +// realization's limit, which is anemic modeling in the diagnostic layer. +// +// NEXT-RUNG TRIGGER, a capability, and it names BOTH routes because either suffices: RECURSIVE +// SPLITTING (applying the same identity to the sub-products until every intermediate fits) or +// routing the wrapping product through std.bignat's multi-limb arithmetic -- SUFFICIENT FOR every +// width this carrier admits to have a computable wrapping product, at which point +// bounded_nat_wrapping_product_admitted is constantly true and dissolves along with its arm. type BoundedNatArith = Within { n: BoundedNat } | Overflows { bits: BitWidth } | NegativeResult { bits: BitWidth } | DividesByZero | WidthMismatch { left: BitWidth, right: BitWidth } - | ProductExceedsHostInt { bits: BitWidth } + | WrappingProductUnavailableAtWidth { bits: BitWidth } fn bounded_nat_widths_agree(a: BoundedNat, b: BoundedNat) -> Bool { bit_width_count(b: a.bits) == bit_width_count(b: b.bits) @@ -173,11 +196,19 @@ fn bounded_nat_sub(a: BoundedNat, b: BoundedNat) -> BoundedNatArith { } } -// OVERFLOW IS DECIDED BEFORE THE PRODUCT IS FORMED, WHICH IS WHY THE CHECKED ARM NEEDS NO HEADROOM -// AT ALL. `a * b > 2^N - 1` is equivalent to `a > (2^N - 1) / b` under integer division, and that -// comparison forms no product. So the question is answered without the intermediate whose size used -// to be the reason for refusing, and when the comparison ADMITS, the product is bounded by 2^N - 1 -- -// already inside the carrier's own admitted width, hence inside the host bound. +// THE PRE-CHECK DISCIPLINE AND THE DIVISION-BASED BOUND TEST ARE NOT DERIVED HERE. std.checked_arithmetic +// already owns both -- "PRE-CHECK, NEVER POST-CHECK: each function decides whether the operation WOULD +// cross the bound before performing it", realized in checked_int_multiply as a division-based bound +// test per sign quadrant. This is that pattern CONSUMED, not restated: re-deriving it in a second +// module would be the DESIGN 3 fork, and the earlier width-wide rule here was exactly that fork +// wearing a stricter rule (review of gunbc#11645). +// +// WHAT IS GENUINELY LOCAL is only the BOUND. std.checked_arithmetic tests against the HOST's +// representable range; this family tests against the CARRIER's width, 2^N - 1, which is a different +// and smaller bound. The shape of the test is borrowed; the limit it is applied to is this module's +// own fact. `a * b > 2^N - 1` iff `a > (2^N - 1) / b` under integer division, which forms no product, +// and when it admits, the product is bounded by 2^N - 1 -- inside the carrier's admitted width and so +// inside the host's. // // THIS REPLACED A WIDTH-KEYED REFUSAL THAT REJECTED VALID PRODUCTS (review of gunbc#11645). The old // rule asked `2N <= 62` and so refused 40-bit 3 * 3, whose product is 9: it keyed on the TYPE's width @@ -241,7 +272,7 @@ fn bounded_nat_add_mod(a: BoundedNat, b: BoundedNat) -> BoundedNatArith { // product's 2N; see the note above the carrier for the identity and the residual 42..62 ceiling. fn bounded_nat_wrapping_product_admitted(bits: BitWidth) -> Bool { bit_width_count(b: bits) + (bit_width_count(b: bits) - bit_width_count(b: bits) / 2) - <= bounded_nat_host_bit_ceiling() + <= bounded_nat_host_positive_bit_capacity() } // The low N bits of a product, without ever forming the whole product. @@ -258,7 +289,7 @@ fn bounded_nat_mul_mod(a: BoundedNat, b: BoundedNat) -> BoundedNatArith { if !bounded_nat_widths_agree(a: a, b: b) { WidthMismatch { left: a.bits, right: b.bits } } else if !bounded_nat_wrapping_product_admitted(bits: a.bits) { - ProductExceedsHostInt { bits: a.bits } + WrappingProductUnavailableAtWidth { bits: a.bits } } else { Within { n: BoundedNat { bits: a.bits, value: bounded_nat_wrapping_product(bits: a.bits, a: a.value, b: b.value) } } } diff --git a/dag/test/claim/bounded_nat_witness_test.dag b/dag/test/claim/bounded_nat_witness_test.dag index 42fa691cbfc..f7edc836bf0 100644 --- a/dag/test/claim/bounded_nat_witness_test.dag +++ b/dag/test/claim/bounded_nat_witness_test.dag @@ -6,7 +6,7 @@ import std.measure { BitWidth, bit_width } import std.bounded_nat { BoundedNat, BoundedNatMint, Minted, OutOfWidth, WidthRefused, BoundedNatAdmission, WidthAdmitted, WidthExceedsHostInt, - BoundedNatArith, Within, Overflows, NegativeResult, DividesByZero, WidthMismatch, ProductExceedsHostInt, + BoundedNatArith, Within, Overflows, NegativeResult, DividesByZero, WidthMismatch, WrappingProductUnavailableAtWidth, bounded_nat, bounded_nat_admit, bounded_nat_add, bounded_nat_sub, bounded_nat_mul, bounded_nat_div, bounded_nat_rem, bounded_nat_add_mod, bounded_nat_mul_mod, } @@ -127,7 +127,7 @@ test fn a_small_product_of_a_wide_type_is_not_refused_for_its_width() -> Bool { // operand that is valid and computable? It did, for the WRAPPING product, and this is the red that // would have caught it. Both vectors have a 64-bit full product, so neither answer is reachable by // forming the product on this host -- the split is doing the work, not luck. The paired half is that -// the CHECKED product on the same operands reports Overflows -- not ProductExceedsHostInt. The two +// the CHECKED product on the same operands reports Overflows -- not WrappingProductUnavailableAtWidth. The two // arms give different ANSWERS about the same operands because they are asked different questions: // the wrapping arm returns the low 32 bits, the checked arm reports that the product left the width. // Neither refuses for want of host headroom. @@ -138,17 +138,23 @@ test fn the_wrapping_product_is_computed_where_the_checked_one_cannot_be() -> Bo all_ones && mixed && checked_reports_overflow } -// THE RESIDUAL CEILING IS REAL AND IS ASSERTED AT ITS EDGE, so that widening it later is a change -// this witness reports rather than absorbs. 41 is the last width the split admits; 42 is the first -// it refuses, and the refusal names its own arm. -test fn the_wrapping_product_ceiling_sits_between_41_and_42() -> Bool { - let admitted = match on2(a: m(bits: bit_width(count: 41), value: 3), b: m(bits: bit_width(count: 41), value: 5), f: bounded_nat_mul_mod) { - Present { value: Within { n: _ } } => true - _ => false - } - let refused = match on2(a: m(bits: bit_width(count: 42), value: 3), b: m(bits: bit_width(count: 42), value: 5), f: bounded_nat_mul_mod) { - Present { value: ProductExceedsHostInt { bits: _ } } => true +// THE RESIDUAL CEILING IS ASSERTED WITH OPERANDS THAT ACTUALLY REACH IT. An earlier version of this +// witness asserted a REFUSAL at width 42 for 3 * 5, whose product is 15 -- so it certified an +// off-by-one instead of catching it, and it exercised only the admission predicate rather than the +// intermediate bound that predicate is about (review of gunbc#11645). +// +// The admitted edge is therefore near-MAXIMAL width-42 operands: (2^42 - 1)^2 mod 2^42 = 1, whose +// largest split intermediate is 9223367638806167553 against a host maximum of 9223372036854775807. +// That answer is unobtainable by forming the product -- the true product has 84 bits -- so this +// discriminates the split at the exact width where the bound nearly binds. 43 is the first width +// whose worst intermediate genuinely does not fit, and it refuses. +test fn the_wrapping_product_ceiling_sits_between_42_and_43() -> Bool { + let w42 = bit_width(count: 42) + let at_the_edge = arm_value(r: on2(a: m(bits: w42, value: 4398046511103), b: m(bits: w42, value: 4398046511103), f: bounded_nat_mul_mod)) == Present { value: 1 } + let refused = match on2(a: m(bits: bit_width(count: 43), value: 8796093022207), b: m(bits: bit_width(count: 43), value: 8796093022207), f: bounded_nat_mul_mod) { + Present { value: WrappingProductUnavailableAtWidth { bits: _ } } => true _ => false } - admitted && refused + at_the_edge && refused } + From 7b0ae1d497172d5c3aaf738cca52442700eadaad Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 20 Sep 2026 08:38:17 +0000 Subject: [PATCH 26/31] std.bignat: sole-construct the carriers, and execute the arithmetic that assumed their invariant REQUEST_CHANGES blocker 1, closed PARTIALLY and labelled as partial. THE HOLE. Every overflow argument in this module rests on each limb lying in [0, 2^24) -- that is what keeps a limb product below 2^48 and the CIOS accumulator inside the host integer. But BigNat was an ordinary record, so any caller could write BigNat { limbs: [-5, 99999999] } and the arithmetic would compute confidently on it. MontgomeryModulus was likewise assemblable with an even or zero modulus, a negative width, or an inconsistent inverse. A proof resting on an invariant the constructor does not enforce is validation standing where construction was available (DESIGN 5), and montgomery_add's own note asserted "both operands below m, which every montgomery_* producer guarantees" -- a guarantee nothing enforced. WHAT IS CLOSED: fabrication. sole_constructor means no expression outside std.bignat mints either carrier. This was cheap and safe because the real population is ELEVEN constructions, all inside the module, zero outside -- my earlier count of 32 across two files was a grep over "-> BigNat {", which counted function signatures rather than constructions. WHAT IS NOT CLOSED, stated on the carrier rather than implied: sole_constructor restricts WHO may construct, not WHAT may be constructed. montgomery_modulus still computes its fields from unchecked arguments. Its next-rung trigger names the capability -- a refusing admission establishing positive width, odd nonzero modulus, modulus fitting the width, and consistent derived fields -- and says why it is deferred: making it refusing turns p256_field and p256_order into refusable data rows, cascading through every consumer of the field arithmetic, and the P-256 suite has never executed, so that restructuring cannot yet be read for breakage. THE PAIRED WITNESS, because a wall whose interior is never exercised is specification-without-execution. Five small-modulus claims run the real producers and read their answers: Montgomery round trip, multiplication that must reduce past the modulus (20 * 30 = 600 = 6 * 97 + 18), additive wrap, and zero. 5/5 pass -- the first executed evidence std.bignat has ever had. CONTROL, perturbing the code rather than the assertion: setting bignat_base to 2^24 - 1 turns THREE of the five red by name. The other two do not depend on the base under that perturbation, so they are weaker claims and are not credited with discrimination they do not have. Co-Authored-By: Claude Opus 5 (1M context) --- dag/std/bignat.dag | 32 ++++++++- .../claim/bignat_montgomery_witness_test.dag | 69 +++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 dag/test/claim/bignat_montgomery_witness_test.dag diff --git a/dag/std/bignat.dag b/dag/std/bignat.dag index 57956fb1c9d..24bf196ae02 100644 --- a/dag/std/bignat.dag +++ b/dag/std/bignat.dag @@ -13,7 +13,21 @@ import std.integer { UInt8 } // // Limbs are least significant first. A BigNat may carry high zero limbs; every operation below is // correct for any lengths, and fixed-width users (the Montgomery context) pad to their width. -type BigNat { +// SOLE-CONSTRUCTED, BECAUSE EVERY OVERFLOW ARGUMENT IN THIS MODULE RESTS ON THE LIMB INVARIANT +// (review of gunbc#11645). The limb width of 24 bits is what keeps a limb product below 2^48 and the +// CIOS accumulator inside the host integer -- but that reasoning holds only if every limb really is +// in [0, 2^24). As an ordinary record, any caller could write BigNat { limbs: [-5, 99999999] } and +// the arithmetic below would compute confidently on it. A proof resting on an invariant the +// constructor does not enforce is validation standing where construction was available (DESIGN 5), +// and its tell is exactly the one DESIGN names: the guarantee can be satisfied by reading the +// declaration while the realization lies. +// +// WHAT THIS CLOSES AND WHAT IT DOES NOT. sole_constructor restricts WHO may construct: outside +// std.bignat there is no expression that mints one, so the FABRICATION route is gone by +// construction. It does not restrict WHAT this module may construct -- the eleven constructions +// below are trusted to preserve the invariant because each derives its limbs from octets, from a +// carry-propagating fold, or from another BigNat, never from unchecked caller input. +type BigNat sole_constructor { limbs: List } @@ -160,7 +174,21 @@ fn bignat_bits(a: BigNat) -> List { // Montgomery Multiplication Algorithms", 1996): for each limb a_i, t ← (t + a_i·b + q·m) / 2^24 with // q = (t_0 + a_i·b_0)·(−m⁻¹) mod 2^24, which makes the division exact. Every step is one product plus // a limb plus a carry, inside the 2^49 bound stated at the top. -type MontgomeryModulus { +// SOLE-CONSTRUCTED FOR THE SAME REASON, AND WITH A LARGER REMAINING GAP THAT IS STATED RATHER THAN +// IMPLIED. The Montgomery routines additionally assume a positive width, an odd nonzero modulus, a +// modulus fitting the declared width, a neg_inverse and r_squared consistent with that modulus, and +// operands that are reduced residues. sole_constructor stops an outside caller from ASSEMBLING an +// inconsistent one, which is the fabrication hole; it does NOT check the fields montgomery_modulus +// itself computes from its arguments. +// +// SO THIS IS A PARTIAL CLOSE AND MUST NOT BE READ AS A FULL ONE. NEXT-RUNG TRIGGER, a capability: a +// refusing admission on montgomery_modulus that establishes each invariant above and returns a typed +// refusal naming the one that failed, SUFFICIENT FOR an inconsistent modulus to have no constructor +// rather than merely no outside constructor. It is deferred to its own change because making this +// admission refusing turns p256_field and p256_order into refusable data rows, cascading through +// every consumer of the field arithmetic -- and the P-256 witness suite has never executed, so that +// restructuring cannot yet be read for breakage. +type MontgomeryModulus sole_constructor { modulus: BigNat width: Int neg_inverse: Int diff --git a/dag/test/claim/bignat_montgomery_witness_test.dag b/dag/test/claim/bignat_montgomery_witness_test.dag new file mode 100644 index 00000000000..1dd4ec6204c --- /dev/null +++ b/dag/test/claim/bignat_montgomery_witness_test.dag @@ -0,0 +1,69 @@ +module test.claim.bignat_montgomery_witness_test + +import std.logic { Bool } +import std.types { Int } +import std.bignat { + BigNat, MontgomeryModulus, + bignat_small, bignat_compare, bignat_is_zero, + montgomery_modulus, montgomery_to, montgomery_from, montgomery_mul, montgomery_add, +} + +// THE PAIRED HALF OF THE sole_constructor WALL ON BigNat AND MontgomeryModulus (review of +// gunbc#11645). The wall establishes that no caller OUTSIDE std.bignat can mint a carrier with +// negative or oversized limbs. It establishes nothing about whether the module's OWN eleven +// constructions preserve the limb invariant they all reason from, and a wall whose protected +// interior is never exercised is specification-without-execution (DESIGN 5). +// +// SO THESE CLAIMS RUN THE REAL PRODUCERS AND READ THEIR ANSWERS. They are deliberately small-modulus +// rather than P-256 sized: the subject is whether Montgomery arithmetic over this carrier returns +// the right residues, which is a property of the limb handling and not of the modulus's size, and a +// P-256 vector costs minutes per verify while these cost seconds. The P-256 suite remains the +// separate, heavier obligation over the curve itself. +// +// WHY A ROUND TRIP IS A DISCRIMINATING CLAIM AND NOT A TAUTOLOGY: montgomery_to multiplies by +// r_squared and montgomery_from multiplies by one, and BOTH run the CIOS reduction. A limb that left +// [0, 2^24) would propagate a wrong carry through that reduction, so the value would not come back. + +fn small_modulus() -> MontgomeryModulus { + montgomery_modulus(m: bignat_small(n: 97), width: 3) +} + +fn residues_equal(a: BigNat, b: BigNat) -> Bool { + bignat_compare(a: a, b: b) == 0 +} + +// A value carried into Montgomery form and back is the value it started as. +test fn montgomery_form_round_trips_a_residue() -> Bool { + let ctx = small_modulus() + let five = bignat_small(n: 5) + let back = montgomery_from(ctx: ctx, a: montgomery_to(ctx: ctx, a: five)) + residues_equal(a: back, b: five) +} + +// 5 * 7 = 35, and 35 is already reduced modulo 97, so the product survives the round trip unchanged. +test fn montgomery_multiplication_answers_the_residue() -> Bool { + let ctx = small_modulus() + let product = montgomery_mul(ctx: ctx, a: montgomery_to(ctx: ctx, a: bignat_small(n: 5)), b: montgomery_to(ctx: ctx, a: bignat_small(n: 7))) + residues_equal(a: montgomery_from(ctx: ctx, a: product), b: bignat_small(n: 35)) +} + +// 20 * 30 = 600 = 6 * 97 + 18, so the reduction must actually reduce rather than return the product. +// This is the case a wrong carry reaches: 600 does not fit in the modulus and 18 is not a prefix of +// it, so an unreduced or mis-carried answer cannot coincide with the right one. +test fn montgomery_multiplication_reduces_past_the_modulus() -> Bool { + let ctx = small_modulus() + let product = montgomery_mul(ctx: ctx, a: montgomery_to(ctx: ctx, a: bignat_small(n: 20)), b: montgomery_to(ctx: ctx, a: bignat_small(n: 30))) + residues_equal(a: montgomery_from(ctx: ctx, a: product), b: bignat_small(n: 18)) +} + +// Addition of residues wraps at the modulus: 90 + 20 = 110 = 97 + 13. +test fn montgomery_addition_wraps_at_the_modulus() -> Bool { + let ctx = small_modulus() + residues_equal(a: montgomery_add(ctx: ctx, a: bignat_small(n: 90), b: bignat_small(n: 20)), b: bignat_small(n: 13)) +} + +// Zero is carried and recognised rather than confused with an empty limb list. +test fn zero_survives_montgomery_form_and_is_recognised() -> Bool { + let ctx = small_modulus() + bignat_is_zero(a: montgomery_from(ctx: ctx, a: montgomery_to(ctx: ctx, a: bignat_small(n: 0)))) +} From be4f623e3e7e5e7a3aef6e21e3ba284870d71227 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 20 Sep 2026 09:13:47 +0000 Subject: [PATCH 27/31] Remove two fabricated defaults, and refuse the unchecked mint (review 69034) BOTH FINDINGS CONFIRMED FROM THE CODE. One correction to the second is below. FABRICATED DEFAULT ON THE CURVE PARAMETERS. `p256_hex_nat` answered `Absent => bignat_small(n: 0)`, and it produced p256_p, p256_n, p256_b, p256_gx and p256_gy -- so a mistyped literal would have substituted ZERO FOR THE FIELD PRIME and every verification below would have computed confidently on a degenerate curve. DESIGN 5 makes that a hard reject. GUARDING THE ARM WAS NOT THE FIX; REMOVING THE DECODE WAS. A refusing p256_hex_nat would make every consumer answer an Optional for a condition that cannot occur at runtime, since the input is a source literal. The parameters are now their big-endian octet encodings read by the total bignat_from_octets, so there is no arm to fabricate on. The same shape in the P-256 witness (`Absent => []`, where a mistyped vector became the empty message and the `is_absent` claims would still have GREENED, defeating their own red) is fixed the same way: all 14 vectors are octet literals and the decode helper is gone. THE UNCHECKED PUBLIC MINT. bignat_small took a raw caller Int, and my own note in the previous commit claimed the internal constructions derive limbs "never from unchecked caller input" -- false when written, of the very function that made it false. An annotation is never evidence (DESIGN 4c); that one asserted the invariant it stood next to a breach of. It now says so. CORRECTION TO THE FINDING, established by trying to write its test: the review names n >= 2^72 as overflowing the top limb. True in arithmetic, UNREACHABLE here -- Int is 64-bit, so the largest expressible n is 2^63 - 1, whose top limb is 32767, inside 2^24. My first fix guarded it anyway, and that guard was worse than dead: evaluating `bignat_base * bignat_base * bignat_base` is 2^72, which RAISES IntegerOverflow, so the check against an impossible input would itself have been the failure. The unreachable arm is described on the carrier instead of authored (DESIGN 4b: ask whether the RED is authorable before writing the check). The NEGATIVE case is real, reachable, and is what bignat_small now refuses -- it previously minted a negative limb. The two literal values this module needs are total constructors with identical limbs (bignat_zero_limbs, bignat_one), so substituting them at the p256 call sites changes no value. EVIDENCE: 6/6 bignat witnesses pass, including a new one asserting the negative refusal AND admission of the largest Int -- the positive control for the note that no Int escapes three limbs. Co-Authored-By: Claude Opus 5 (1M context) --- dag/extdeps/crypto/nist_p256.dag | 46 +++++---- dag/std/bignat.dag | 48 ++++++++-- .../claim/bignat_montgomery_witness_test.dag | 93 +++++++++++++++---- .../claim/p256_dag_ecdsa_witness_test.dag | 41 ++++---- 4 files changed, 161 insertions(+), 67 deletions(-) diff --git a/dag/extdeps/crypto/nist_p256.dag b/dag/extdeps/crypto/nist_p256.dag index bc92c29fee8..84a51306377 100644 --- a/dag/extdeps/crypto/nist_p256.dag +++ b/dag/extdeps/crypto/nist_p256.dag @@ -3,12 +3,11 @@ module extdeps.crypto.nist_p256 import std.types { Int, List, Bool } import std.integer { UInt8 } import std.bignat { - BigNat, MontgomeryModulus, bignat_from_octets, bignat_compare, bignat_is_zero, bignat_bits, bignat_small, + BigNat, MontgomeryModulus, bignat_from_octets, bignat_compare, bignat_is_zero, bignat_bits, bignat_zero_limbs, bignat_one, montgomery_modulus, montgomery_mul, montgomery_add, montgomery_sub, montgomery_to, montgomery_from, montgomery_inverse_prime, montgomery_reduce_once, } import extdeps.crypto.sha2 { sha256 } -import extdeps.numeric.base16 { base16_decode_lower } import extdeps.external_authority { ExternalAuthority } import extdeps.uri { Uri, Https } @@ -23,19 +22,32 @@ data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { } } -// ── Domain parameters (SP 800-186 §3.2.1.3), as published hex ─────────────────────────────── -fn p256_hex_nat(hex: String) -> BigNat { - match base16_decode_lower(hex: hex) { - Present { value: octets } => bignat_from_octets(octets: octets) - Absent => bignat_small(n: 0) - } -} - -data p256_p: BigNat = p256_hex_nat(hex: "ffffffff00000001000000000000000000000000ffffffffffffffffffffffff") -data p256_n: BigNat = p256_hex_nat(hex: "ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551") -data p256_b: BigNat = p256_hex_nat(hex: "5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b") -data p256_gx: BigNat = p256_hex_nat(hex: "6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296") -data p256_gy: BigNat = p256_hex_nat(hex: "4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5") +// ── Domain parameters (SP 800-186 §3.2.1.3), as published octets ──────────────────────────── +// THE DOMAIN PARAMETERS ARE OCTETS, NOT A DECODE, BECAUSE A DECODE HAS A FAILURE ARM AND THESE MAY +// NOT HAVE ONE (review 69034 of gunbc#11645). They were previously written as hex strings run +// through base16_decode_lower, whose Absent arm answered bignat_small(n: 0) -- so a mistyped literal +// would have substituted ZERO FOR THE FIELD PRIME and every verification below would have computed +// confidently on a degenerate curve. That is the fabricated default DESIGN 5 names a hard reject, +// and it sat on the five values the whole curve is defined by. +// +// GUARDING THE ARM WAS NOT THE FIX. A refusing p256_hex_nat would make every consumer of the field +// arithmetic answer an Optional for a condition that cannot occur at runtime at all, since the input +// is a source literal. Removing the DECODE removes the arm: each parameter is now its big-endian +// octet encoding, read directly by bignat_from_octets, which is total. There is no longer a state in +// which a parameter is wrong-but-accepted, rather than a check that such a state is reported. +// +// Values: SP 800-186 section 3.2.1.3 (P-256), big-endian, 32 octets each, as SEC1 renders them. +data p256_p_octets: List = [255, 255, 255, 255, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255] +data p256_n_octets: List = [255, 255, 255, 255, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 188, 230, 250, 173, 167, 23, 158, 132, 243, 185, 202, 194, 252, 99, 37, 81] +data p256_b_octets: List = [90, 198, 53, 216, 170, 58, 147, 231, 179, 235, 189, 85, 118, 152, 134, 188, 101, 29, 6, 176, 204, 83, 176, 246, 59, 206, 60, 62, 39, 210, 96, 75] +data p256_gx_octets: List = [107, 23, 209, 242, 225, 44, 66, 71, 248, 188, 230, 229, 99, 164, 64, 242, 119, 3, 125, 129, 45, 235, 51, 160, 244, 161, 57, 69, 216, 152, 194, 150] +data p256_gy_octets: List = [79, 227, 66, 226, 254, 26, 127, 155, 142, 231, 235, 74, 124, 15, 158, 22, 43, 206, 51, 87, 107, 49, 94, 206, 203, 182, 64, 104, 55, 191, 81, 245] + +data p256_p: BigNat = bignat_from_octets(octets: p256_p_octets) +data p256_n: BigNat = bignat_from_octets(octets: p256_n_octets) +data p256_b: BigNat = bignat_from_octets(octets: p256_b_octets) +data p256_gx: BigNat = bignat_from_octets(octets: p256_gx_octets) +data p256_gy: BigNat = bignat_from_octets(octets: p256_gy_octets) // 256-bit moduli in 24-bit limbs: 11 limbs. data p256_field: MontgomeryModulus = montgomery_modulus(m: p256_p, width: 11) @@ -54,14 +66,14 @@ fn fadd(a: BigNat, b: BigNat) -> BigNat { montgomery_add(ctx: p256_field, a: a, fn fsub(a: BigNat, b: BigNat) -> BigNat { montgomery_sub(ctx: p256_field, a: a, b: b) } fn fsqr(a: BigNat) -> BigNat { montgomery_mul(ctx: p256_field, a: a, b: a) } -data p256_infinity: P256Jacobian = P256Jacobian { x: bignat_small(n: 0), y: bignat_small(n: 0), z: bignat_small(n: 0) } +data p256_infinity: P256Jacobian = P256Jacobian { x: bignat_zero_limbs(), y: bignat_zero_limbs(), z: bignat_zero_limbs() } fn p256_is_infinity(p: P256Jacobian) -> Bool { bignat_is_zero(a: p.z) } fn p256_affine(x: BigNat, y: BigNat) -> P256Jacobian { - P256Jacobian { x: montgomery_to(ctx: p256_field, a: x), y: montgomery_to(ctx: p256_field, a: y), z: montgomery_to(ctx: p256_field, a: bignat_small(n: 1)) } + P256Jacobian { x: montgomery_to(ctx: p256_field, a: x), y: montgomery_to(ctx: p256_field, a: y), z: montgomery_to(ctx: p256_field, a: bignat_one()) } } // Doubling for a = −3 (Bernstein and Lange, Explicit-Formulas Database, dbl-2001-b). diff --git a/dag/std/bignat.dag b/dag/std/bignat.dag index 24bf196ae02..33dbb67078c 100644 --- a/dag/std/bignat.dag +++ b/dag/std/bignat.dag @@ -24,9 +24,14 @@ import std.integer { UInt8 } // // WHAT THIS CLOSES AND WHAT IT DOES NOT. sole_constructor restricts WHO may construct: outside // std.bignat there is no expression that mints one, so the FABRICATION route is gone by -// construction. It does not restrict WHAT this module may construct -- the eleven constructions -// below are trusted to preserve the invariant because each derives its limbs from octets, from a -// carry-propagating fold, or from another BigNat, never from unchecked caller input. +// construction. It does not restrict WHAT this module may construct. +// +// AN EARLIER VERSION OF THIS NOTE CLAIMED THE INTERNAL CONSTRUCTIONS NEVER DERIVE FROM UNCHECKED +// CALLER INPUT. THAT WAS FALSE WHEN WRITTEN (review 69034 of gunbc#11645): bignat_small took a raw +// caller Int and wrote n / base^2 as its top limb with no reduction, so any n >= 2^72 produced a +// limb above 2^24 and any negative n produced a negative limb -- through a PUBLIC function, which +// is the very route the wall above was claimed to close. An annotation is never evidence (DESIGN +// 4c); this one asserted the invariant it was standing next to a breach of. type BigNat sole_constructor { limbs: List } @@ -37,8 +42,35 @@ fn bignat_zero() -> BigNat { BigNat { limbs: [] } } -fn bignat_small(n: Int) -> BigNat { - BigNat { limbs: [n % bignat_base, (n / bignat_base) % bignat_base, n / (bignat_base * bignat_base)] } +// THE ONLY ENTRY THAT TOOK AN UNCHECKED CALLER Int, NOW REFUSING -- ON THE ONE CONDITION THAT CAN +// ACTUALLY OCCUR. A NEGATIVE n has no unsigned representation, and before this it produced a +// NEGATIVE LIMB through a public function, breaking the invariant every overflow argument in this +// module rests on (review 69034 of gunbc#11645). +// +// THE OVER-LARGE CASE THE REVIEW ALSO NAMED IS UNREACHABLE HERE, AND GUARDING IT WOULD HAVE BEEN +// WORSE THAN USELESS. Three limbs carry values below base^3 = 2^72, so the review is right that a +// larger n would overflow the top limb -- but Int is 64-bit, so the largest n expressible is +// 2^63 - 1, whose top limb is 32767, comfortably inside 2^24. No Int reaches the boundary. Writing +// `n >= bignat_base * bignat_base * bignat_base` would not merely be dead: evaluating 2^72 RAISES +// IntegerOverflow, so the guard against an impossible input would itself be the failure. The +// unreachable arm is therefore described here rather than authored (DESIGN 4b: ask whether the +// check's RED is authorable before writing the check). +fn bignat_small(n: Int) -> BigNat? { + if n < 0 { + none + } else { + Present { value: BigNat { limbs: [n % bignat_base, (n / bignat_base) % bignat_base, n / (bignat_base * bignat_base)] } } + } +} + +// Total, because their limbs are written here rather than derived from an argument. Byte-identical +// to what bignat_small produced for 0 and 1, so substituting them changes no value anywhere. +fn bignat_zero_limbs() -> BigNat { + BigNat { limbs: [0, 0, 0] } +} + +fn bignat_one() -> BigNat { + BigNat { limbs: [1, 0, 0] } } // ── Octets and hex (big-endian on the wire) ────────────────────────────────────────────────── @@ -273,7 +305,7 @@ fn bignat_truncate(limbs: List, width: Int) -> List { // R² mod m by doubling 1 modulo m, 2·24·width times, each doubling followed by a conditional // subtraction. Derived from the modulus; never a transcribed constant. fn montgomery_r_squared(m: BigNat, width: Int) -> BigNat { - montgomery_double_times(m: m, a: bignat_small(n: 1), times: 48 * width) + montgomery_double_times(m: m, a: bignat_one(), times: 48 * width) } fn montgomery_double_times(m: BigNat, a: BigNat, times: Int) -> BigNat { @@ -298,7 +330,7 @@ fn montgomery_to(ctx: MontgomeryModulus, a: BigNat) -> BigNat { } fn montgomery_from(ctx: MontgomeryModulus, a: BigNat) -> BigNat { - montgomery_mul(ctx: ctx, a: a, b: bignat_small(n: 1)) + montgomery_mul(ctx: ctx, a: a, b: bignat_one()) } // Modular addition and subtraction of RESIDUES: both operands below m, which every montgomery_* @@ -318,7 +350,7 @@ fn montgomery_sub(ctx: MontgomeryModulus, a: BigNat, b: BigNat) -> BigNat { // a^e in Montgomery form, left to right over e's bits. `one` is R mod m (1 in Montgomery form). fn montgomery_pow(ctx: MontgomeryModulus, a: BigNat, e: BigNat) -> BigNat { - let one = montgomery_to(ctx: ctx, a: bignat_small(n: 1)) + let one = montgomery_to(ctx: ctx, a: bignat_one()) fold(reverse(bignat_bits(a: e)), init: one, f: (acc, bit) => { let sq = montgomery_mul(ctx: ctx, a: acc, b: acc) if bit == 1 { montgomery_mul(ctx: ctx, a: sq, b: a) } else { sq } diff --git a/dag/test/claim/bignat_montgomery_witness_test.dag b/dag/test/claim/bignat_montgomery_witness_test.dag index 1dd4ec6204c..cf2b7c03989 100644 --- a/dag/test/claim/bignat_montgomery_witness_test.dag +++ b/dag/test/claim/bignat_montgomery_witness_test.dag @@ -10,9 +10,9 @@ import std.bignat { // THE PAIRED HALF OF THE sole_constructor WALL ON BigNat AND MontgomeryModulus (review of // gunbc#11645). The wall establishes that no caller OUTSIDE std.bignat can mint a carrier with -// negative or oversized limbs. It establishes nothing about whether the module's OWN eleven -// constructions preserve the limb invariant they all reason from, and a wall whose protected -// interior is never exercised is specification-without-execution (DESIGN 5). +// negative or oversized limbs. It establishes nothing about whether the module's OWN constructions +// preserve the limb invariant they all reason from, and a wall whose protected interior is never +// exercised is specification-without-execution (DESIGN 5). // // SO THESE CLAIMS RUN THE REAL PRODUCERS AND READ THEIR ANSWERS. They are deliberately small-modulus // rather than P-256 sized: the subject is whether Montgomery arithmetic over this carrier returns @@ -23,9 +23,37 @@ import std.bignat { // WHY A ROUND TRIP IS A DISCRIMINATING CLAIM AND NOT A TAUTOLOGY: montgomery_to multiplies by // r_squared and montgomery_from multiplies by one, and BOTH run the CIOS reduction. A limb that left // [0, 2^24) would propagate a wrong carry through that reduction, so the value would not come back. +// +// EVERY INPUT IS THREADED THROUGH ITS REFUSAL RATHER THAN UNWRAPPED. bignat_small now answers +// Optional, and a claim that cannot BUILD its input must FAIL rather than pass -- substituting a +// default here would be the same fabricated-default defect the review found in the curve parameters, +// reproduced in the evidence layer, where it would additionally defeat the claim's own red. + +fn with_modulus(f: fn(MontgomeryModulus) -> Bool) -> Bool { + match bignat_small(n: 97) { + Absent => false + Present { value: m } => f(montgomery_modulus(m: m, width: 3)) + } +} + +fn with2(a: Int, b: Int, f: fn(BigNat, BigNat) -> Bool) -> Bool { + match bignat_small(n: a) { + Absent => false + Present { value: x } => + match bignat_small(n: b) { + Absent => false + Present { value: y } => f(x, y) + } + } +} -fn small_modulus() -> MontgomeryModulus { - montgomery_modulus(m: bignat_small(n: 97), width: 3) +fn with3(a: Int, b: Int, c: Int, f: fn(BigNat, BigNat, BigNat) -> Bool) -> Bool { + with2(a: a, b: b, f: fn(x, y) { + match bignat_small(n: c) { + Absent => false + Present { value: z } => f(x, y, z) + } + }) } fn residues_equal(a: BigNat, b: BigNat) -> Bool { @@ -34,36 +62,63 @@ fn residues_equal(a: BigNat, b: BigNat) -> Bool { // A value carried into Montgomery form and back is the value it started as. test fn montgomery_form_round_trips_a_residue() -> Bool { - let ctx = small_modulus() - let five = bignat_small(n: 5) - let back = montgomery_from(ctx: ctx, a: montgomery_to(ctx: ctx, a: five)) - residues_equal(a: back, b: five) + with_modulus(f: fn(ctx) { + with2(a: 5, b: 5, f: fn(five, same) { + residues_equal(a: montgomery_from(ctx: ctx, a: montgomery_to(ctx: ctx, a: five)), b: same) + }) + }) } // 5 * 7 = 35, and 35 is already reduced modulo 97, so the product survives the round trip unchanged. test fn montgomery_multiplication_answers_the_residue() -> Bool { - let ctx = small_modulus() - let product = montgomery_mul(ctx: ctx, a: montgomery_to(ctx: ctx, a: bignat_small(n: 5)), b: montgomery_to(ctx: ctx, a: bignat_small(n: 7))) - residues_equal(a: montgomery_from(ctx: ctx, a: product), b: bignat_small(n: 35)) + with_modulus(f: fn(ctx) { + with3(a: 5, b: 7, c: 35, f: fn(five, seven, expected) { + let product = montgomery_mul(ctx: ctx, a: montgomery_to(ctx: ctx, a: five), b: montgomery_to(ctx: ctx, a: seven)) + residues_equal(a: montgomery_from(ctx: ctx, a: product), b: expected) + }) + }) } // 20 * 30 = 600 = 6 * 97 + 18, so the reduction must actually reduce rather than return the product. // This is the case a wrong carry reaches: 600 does not fit in the modulus and 18 is not a prefix of // it, so an unreduced or mis-carried answer cannot coincide with the right one. test fn montgomery_multiplication_reduces_past_the_modulus() -> Bool { - let ctx = small_modulus() - let product = montgomery_mul(ctx: ctx, a: montgomery_to(ctx: ctx, a: bignat_small(n: 20)), b: montgomery_to(ctx: ctx, a: bignat_small(n: 30))) - residues_equal(a: montgomery_from(ctx: ctx, a: product), b: bignat_small(n: 18)) + with_modulus(f: fn(ctx) { + with3(a: 20, b: 30, c: 18, f: fn(twenty, thirty, expected) { + let product = montgomery_mul(ctx: ctx, a: montgomery_to(ctx: ctx, a: twenty), b: montgomery_to(ctx: ctx, a: thirty)) + residues_equal(a: montgomery_from(ctx: ctx, a: product), b: expected) + }) + }) } // Addition of residues wraps at the modulus: 90 + 20 = 110 = 97 + 13. test fn montgomery_addition_wraps_at_the_modulus() -> Bool { - let ctx = small_modulus() - residues_equal(a: montgomery_add(ctx: ctx, a: bignat_small(n: 90), b: bignat_small(n: 20)), b: bignat_small(n: 13)) + with_modulus(f: fn(ctx) { + with3(a: 90, b: 20, c: 13, f: fn(ninety, twenty, expected) { + residues_equal(a: montgomery_add(ctx: ctx, a: ninety, b: twenty), b: expected) + }) + }) } // Zero is carried and recognised rather than confused with an empty limb list. test fn zero_survives_montgomery_form_and_is_recognised() -> Bool { - let ctx = small_modulus() - bignat_is_zero(a: montgomery_from(ctx: ctx, a: montgomery_to(ctx: ctx, a: bignat_small(n: 0)))) + with_modulus(f: fn(ctx) { + with2(a: 0, b: 0, f: fn(zero, _unused) { + bignat_is_zero(a: montgomery_from(ctx: ctx, a: montgomery_to(ctx: ctx, a: zero))) + }) + }) +} + +// THE REFUSAL IS REACHABLE AND IS ASSERTED, so bignat_small's new arm is not vocabulary nothing +// produces: a negative has no unsigned representation, and before this change it minted a BigNat +// with a NEGATIVE limb through a public function. +// +// THE UPPER EDGE IS ASSERTED AS AN ADMISSION RATHER THAN A REFUSAL, because no Int can exceed three +// limbs: the largest expressible n is 2^63 - 1, whose top limb is 32767 and so is inside 2^24. That +// is the positive control for the note on bignat_small -- if the top limb ever did escape, this is +// the input that would show it. +test fn bignat_small_refuses_a_negative_and_admits_the_largest_int() -> Bool { + let negative = match bignat_small(n: 0 - 1) { Absent => true Present { value: _ } => false } + let largest = match bignat_small(n: 9223372036854775807) { Absent => false Present { value: _ } => true } + negative && largest } diff --git a/dag/test/claim/p256_dag_ecdsa_witness_test.dag b/dag/test/claim/p256_dag_ecdsa_witness_test.dag index 3b9b6d70bfb..678775f02ee 100644 --- a/dag/test/claim/p256_dag_ecdsa_witness_test.dag +++ b/dag/test/claim/p256_dag_ecdsa_witness_test.dag @@ -4,17 +4,9 @@ import std.logic { Bool } import std.types { Int, List } import std.integer { UInt8 } import std.bytes { bytes_octets, utf8_encode_bytes } -import std.bignat { BigNat, bignat_small } -import extdeps.numeric.base16 { base16_decode_lower } +import std.bignat { BigNat, bignat_zero_limbs } import extdeps.crypto.nist_p256 { p256_ecdsa_verify_octets, p256_on_curve, p256_gx, p256_gy, p256_n, p256_double_scalar, p256_affine, p256_is_infinity } -fn hex(s: String) -> List { - match base16_decode_lower(hex: s) { - Present { value: o } => o - Absent => [] - } -} - fn ascii(s: String) -> List { bytes_octets(b: utf8_encode_bytes(s: s)) } @@ -48,14 +40,17 @@ test fn the_base_point_is_on_the_curve_and_has_order_n() -> Bool { && p256_is_infinity(p: p256_double_scalar(u1: p256_n, g: p256_affine(x: p256_gx, y: p256_gy), u2: zero, q: p256_affine(x: p256_gx, y: p256_gy))) } +// The scalar zero, from the total constructor rather than the refusing mint: bignat_small now +// answers Optional, and a witness that unwrapped it with a default would reintroduce here exactly +// the fabricated default this file was corrected for (review 69034 of gunbc#11645). fn p256_hex_zero() -> BigNat { - bignat_small(n: 0) + bignat_zero_limbs() } // ── RFC 6979 §A.2.5: a published P-256 / SHA-256 key and two signatures ───────────────────── -data rfc6979_key: List = hex(s: "0460fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb67903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462299") -data rfc6979_sample_sig: List = hex(s: "efd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716f7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8") -data rfc6979_test_sig: List = hex(s: "f1abb023518351cd71d881567b1ea663ed3efcf6c5132b354f28d3b0b7d38367019f4113742a2b14bd25926b49c649155f267e60d3814b4c0cc84250e46f0083") +data rfc6979_key: List = [4, 96, 254, 212, 186, 37, 90, 157, 49, 201, 97, 235, 116, 198, 53, 109, 104, 192, 73, 184, 146, 59, 97, 250, 108, 230, 105, 98, 46, 96, 242, 159, 182, 121, 3, 254, 16, 8, 184, 188, 153, 164, 26, 233, 233, 86, 40, 188, 100, 242, 241, 178, 12, 45, 126, 159, 81, 119, 163, 194, 148, 212, 70, 34, 153] +data rfc6979_sample_sig: List = [239, 212, 139, 42, 172, 182, 168, 253, 17, 64, 221, 156, 212, 94, 129, 214, 157, 44, 135, 123, 86, 170, 249, 145, 195, 77, 14, 168, 78, 175, 55, 22, 247, 203, 28, 148, 45, 101, 124, 65, 212, 54, 199, 161, 182, 226, 159, 101, 243, 233, 0, 219, 185, 175, 244, 6, 77, 196, 171, 47, 132, 58, 205, 168] +data rfc6979_test_sig: List = [241, 171, 176, 35, 81, 131, 81, 205, 113, 216, 129, 86, 123, 30, 166, 99, 237, 62, 252, 246, 197, 19, 43, 53, 79, 40, 211, 176, 183, 211, 131, 103, 1, 159, 65, 19, 116, 42, 43, 20, 189, 37, 146, 107, 73, 198, 73, 21, 95, 38, 126, 96, 211, 129, 75, 76, 12, 200, 66, 80, 228, 111, 0, 131] test fn the_rfc6979_sample_signature_verifies() -> Bool { is_true(v: p256_ecdsa_verify_octets(key: rfc6979_key, signature: rfc6979_sample_sig, message: ascii(s: "sample"))) @@ -72,44 +67,44 @@ test fn a_signature_for_a_different_message_is_false() -> Bool { // The RFC 7515 A.3 key under the RFC 6979 signature: the wrong key, false. test fn the_wrong_key_is_false() -> Bool { - is_false(v: p256_ecdsa_verify_octets(key: hex(s: "047fcdce2770f6c45d4183cbee6fdb4b7b580733357be9ef13bacf6e3c7bd15445c7f144cd1bbd9b7e872cdfedb9eeb9f4b3695d6ea90b24ad8a4623288588e5ad"), signature: rfc6979_sample_sig, message: ascii(s: "sample"))) + is_false(v: p256_ecdsa_verify_octets(key: [4, 127, 205, 206, 39, 112, 246, 196, 93, 65, 131, 203, 238, 111, 219, 75, 123, 88, 7, 51, 53, 123, 233, 239, 19, 186, 207, 110, 60, 123, 209, 84, 69, 199, 241, 68, 205, 27, 189, 155, 126, 135, 44, 223, 237, 185, 238, 185, 244, 179, 105, 93, 110, 169, 11, 36, 173, 138, 70, 35, 40, 133, 136, 229, 173], signature: rfc6979_sample_sig, message: ascii(s: "sample"))) } // The RFC key with the last bit of y flipped: 65 octets, 0x04, and not a point on the curve. test fn a_point_off_the_curve_is_absent() -> Bool { - is_absent(v: p256_ecdsa_verify_octets(key: hex(s: "0460fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb67903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462298"), signature: rfc6979_sample_sig, message: ascii(s: "sample"))) + is_absent(v: p256_ecdsa_verify_octets(key: [4, 96, 254, 212, 186, 37, 90, 157, 49, 201, 97, 235, 116, 198, 53, 109, 104, 192, 73, 184, 146, 59, 97, 250, 108, 230, 105, 98, 46, 96, 242, 159, 182, 121, 3, 254, 16, 8, 184, 188, 153, 164, 26, 233, 233, 86, 40, 188, 100, 242, 241, 178, 12, 45, 126, 159, 81, 119, 163, 194, 148, 212, 70, 34, 152], signature: rfc6979_sample_sig, message: ascii(s: "sample"))) } // ── Wycheproof ecdsa_secp256r1_sha256_p1363_test.json ─────────────────────────────────────── -data wycheproof_key: List = hex(s: "042927b10512bae3eddcfe467828128bad2903269919f7086069c8c4df6c732838c7787964eaac00e5921fb1498a60f4606766b3d9685001558d1a974e7341513e") -data wycheproof_msg: List = hex(s: "313233343030") +data wycheproof_key: List = [4, 41, 39, 177, 5, 18, 186, 227, 237, 220, 254, 70, 120, 40, 18, 139, 173, 41, 3, 38, 153, 25, 247, 8, 96, 105, 200, 196, 223, 108, 115, 40, 56, 199, 120, 121, 100, 234, 172, 0, 229, 146, 31, 177, 73, 138, 96, 244, 96, 103, 102, 179, 217, 104, 80, 1, 85, 141, 26, 151, 78, 115, 65, 81, 62] +data wycheproof_msg: List = [49, 50, 51, 52, 48, 48] // tcId 1, valid: the high-s form. ECDSA verification has no low-s rule, so it verifies. test fn wycheproof_1_the_malleable_signature_verifies() -> Bool { - is_true(v: p256_ecdsa_verify_octets(key: wycheproof_key, signature: hex(s: "2ba3a8be6b94d5ec80a6d9d1190a436effe50d85a1eee859b8cc6af9bd5c2e184cd60b855d442f5b3c7b11eb6c4e0ae7525fe710fab9aa7c77a67f79e6fadd76"), message: wycheproof_msg)) + is_true(v: p256_ecdsa_verify_octets(key: wycheproof_key, signature: [43, 163, 168, 190, 107, 148, 213, 236, 128, 166, 217, 209, 25, 10, 67, 110, 255, 229, 13, 133, 161, 238, 232, 89, 184, 204, 106, 249, 189, 92, 46, 24, 76, 214, 11, 133, 93, 68, 47, 91, 60, 123, 17, 235, 108, 78, 10, 231, 82, 95, 231, 16, 250, 185, 170, 124, 119, 166, 127, 121, 230, 250, 221, 118], message: wycheproof_msg)) } // tcId 60, valid: an edge case for Shamir multiplication. test fn wycheproof_60_the_shamir_edge_case_verifies() -> Bool { - is_true(v: p256_ecdsa_verify_octets(key: wycheproof_key, signature: hex(s: "64a1aab5000d0e804f3e2fc02bdee9be8ff312334e2ba16d11547c97711c898e6af015971cc30be6d1a206d4e013e0997772a2f91d73286ffd683b9bb2cf4f1b"), message: hex(s: "3639383139"))) + is_true(v: p256_ecdsa_verify_octets(key: wycheproof_key, signature: [100, 161, 170, 181, 0, 13, 14, 128, 79, 62, 47, 192, 43, 222, 233, 190, 143, 243, 18, 51, 78, 43, 161, 109, 17, 84, 124, 151, 113, 28, 137, 142, 106, 240, 21, 151, 28, 195, 11, 230, 209, 162, 6, 212, 224, 19, 224, 153, 119, 114, 162, 249, 29, 115, 40, 111, 253, 104, 59, 155, 178, 207, 79, 27], message: [54, 57, 56, 49, 57])) } // tcId 4, invalid: r replaced by n - r. Well-formed, so the answer is false. test fn wycheproof_4_n_minus_r_is_false() -> Bool { - is_false(v: p256_ecdsa_verify_octets(key: wycheproof_key, signature: hex(s: "d45c5740946b2a147f59262ee6f5bc90bd01ed280528b62b3aed5fc93f06f739b329f479a2bbd0a5c384ee1493b1f5186a87139cac5df4087c134b49156847db"), message: wycheproof_msg)) + is_false(v: p256_ecdsa_verify_octets(key: wycheproof_key, signature: [212, 92, 87, 64, 148, 107, 42, 20, 127, 89, 38, 46, 230, 245, 188, 144, 189, 1, 237, 40, 5, 40, 182, 43, 58, 237, 95, 201, 63, 6, 247, 57, 179, 41, 244, 121, 162, 187, 208, 165, 195, 132, 238, 20, 147, 177, 245, 24, 106, 135, 19, 156, 172, 93, 244, 8, 124, 19, 75, 73, 21, 104, 71, 219], message: wycheproof_msg)) } // tcId 11, invalid: r = 0 and s = 0, outside [1, n-1]. test fn wycheproof_11_r_and_s_zero_are_absent() -> Bool { - is_absent(v: p256_ecdsa_verify_octets(key: wycheproof_key, signature: hex(s: "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"), message: wycheproof_msg)) + is_absent(v: p256_ecdsa_verify_octets(key: wycheproof_key, signature: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], message: wycheproof_msg)) } // tcId 26, invalid: r = n, outside [1, n-1]. test fn wycheproof_26_r_equal_to_n_is_absent() -> Bool { - is_absent(v: p256_ecdsa_verify_octets(key: wycheproof_key, signature: hex(s: "ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc6325510000000000000000000000000000000000000000000000000000000000000001"), message: wycheproof_msg)) + is_absent(v: p256_ecdsa_verify_octets(key: wycheproof_key, signature: [255, 255, 255, 255, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 188, 230, 250, 173, 167, 23, 158, 132, 243, 185, 202, 194, 252, 99, 37, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], message: wycheproof_msg)) } // tcId 2, invalid: r replaced by r + n, which needs 33 octets -- not the 64-octet encoding. test fn wycheproof_2_r_plus_n_is_absent() -> Bool { - is_absent(v: p256_ecdsa_verify_octets(key: wycheproof_key, signature: hex(s: "012ba3a8bd6b94d5ed80a6d9d1190a436ebccc0833490686deac8635bcb9bf536900b329f479a2bbd0a5c384ee1493b1f5186a87139cac5df4087c134b49156847db"), message: wycheproof_msg)) + is_absent(v: p256_ecdsa_verify_octets(key: wycheproof_key, signature: [1, 43, 163, 168, 189, 107, 148, 213, 237, 128, 166, 217, 209, 25, 10, 67, 110, 188, 204, 8, 51, 73, 6, 134, 222, 172, 134, 53, 188, 185, 191, 83, 105, 0, 179, 41, 244, 121, 162, 187, 208, 165, 195, 132, 238, 20, 147, 177, 245, 24, 106, 135, 19, 156, 172, 93, 244, 8, 124, 19, 75, 73, 21, 104, 71, 219], message: wycheproof_msg)) } From 77fcb2b702b0507a13bec4b1e54dfc7400285224 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 20 Sep 2026 10:01:08 +0000 Subject: [PATCH 28/31] std.bitwise: no intermediate leaves the declared width, and the dead trigger row goes REVIEW 69054, FINDING 1, CONFIRMED FROM THE CODE AND FIXED AT THE SITE. word32_xor was `a + b - 2 * word32_and(...)` and word32_or was `a + b - word32_and(...)`, whose answers are right but whose SUM reaches 2^33 - 2 while typed UInt32; word32_add formed `a + b` before reducing. That is the invalid state gunbc.recurring_failure_mode bounded_natural_arithmetic_evaluated_as_unbounded_int names -- "a value typed UInt32 that is not a 32-bit unsigned natural" -- and the row on main cites word32_xor at this line. bitwise.dag is NEW in this PR, so it was landing fresh population of a class DESIGN 4b places BELOW the ladder, with no declared drop. The reviewer is right that a reader of std.bitwise alone could not have learned that. FIXED BY REMOVING THE OVERSIZED INTERMEDIATE, NOT BY DECLARING IT. Exclusive or is now nibble-wise through a derived table, exactly as word32_and already worked: each lookup is over values below 16 and the accumulator is bounded by the digits consumed. Disjunction is xor plus and, which are disjoint bit sets, so their sum IS the disjunction and is bounded by it. Addition decides the carry BEFORE forming the sum. Verified exhaustively at the edges and over 60,000 random pairs against the reference operations: zero mismatches, largest xor intermediate 4294960992, inside 2^32. SHA-256 still passes 7/7 and the bounded_nat suite 10/10. WHAT THIS DOES NOT DO, STATED SO IT IS NOT READ AS MORE: it closes the SILENT WRONGNESS half only. std.operator_realization refuses infix arithmetic on a structural operand regardless of MAGNITUDE, so these sites still emit as operator refusals -- the separate class accepted_source_emits_uncompilable_target. Closing that one needs the operations to be DECLARED rather than infix, i.e. routing through std.bounded_nat, which is blocked today by an import cycle rather than by reification: std.bounded_nat imports std.bitwise for bitwise_pow2, so bitwise cannot import it back (DESIGN 3, acyclicity). Relocating bitwise_pow2 breaks that cycle and is the real next step. FINDING 3: bounded_nat_width_ceiling_trigger was a String prose row with no reader -- DESIGN 4c dead data, the same shape as the rows removed in 7123b087303. Deleted; the // block above already carries the trigger and now attaches to bounded_nat_admit, which is the function that produces the arm it describes. Co-Authored-By: Claude Opus 5 (1M context) --- dag/std/bitwise.dag | 51 ++++++++++++++++++++++++++++++++++++++--- dag/std/bounded_nat.dag | 2 -- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/dag/std/bitwise.dag b/dag/std/bitwise.dag index 94aec448ff5..1c62ad877bd 100644 --- a/dag/std/bitwise.dag +++ b/dag/std/bitwise.dag @@ -48,6 +48,16 @@ fn nibble_and(a: Int, b: Int) -> Int { + 8 * nibble_bit(x: a, i: 3) * nibble_bit(x: b, i: 3) } +// Per-bit exclusive or: a + b - 2ab is 0,1,1,0 over the four bit pairs, and every term here is a +// single BIT, so nothing in this function approaches a word boundary. +fn nibble_xor(a: Int, b: Int) -> Int { + let x0 = nibble_bit(x: a, i: 0) + nibble_bit(x: b, i: 0) - 2 * nibble_bit(x: a, i: 0) * nibble_bit(x: b, i: 0) + let x1 = nibble_bit(x: a, i: 1) + nibble_bit(x: b, i: 1) - 2 * nibble_bit(x: a, i: 1) * nibble_bit(x: b, i: 1) + let x2 = nibble_bit(x: a, i: 2) + nibble_bit(x: b, i: 2) - 2 * nibble_bit(x: a, i: 2) * nibble_bit(x: b, i: 2) + let x3 = nibble_bit(x: a, i: 3) + nibble_bit(x: b, i: 3) - 2 * nibble_bit(x: a, i: 3) * nibble_bit(x: b, i: 3) + x0 + 2 * x1 + 4 * x2 + 8 * x3 +} + fn nibble_values() -> List { [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] } @@ -56,6 +66,10 @@ fn nibble_values() -> List { data nibble_and_table: List = fold(nibble_values(), init: [], f: (acc, a) => fold(nibble_values(), init: acc, f: (row, b) => row |> list_push(nibble_and(a: a, b: b)))) +// Row-major: entry 16a + b is (a xor b). Derived, never transcribed. +data nibble_xor_table: List = fold(nibble_values(), init: [], f: (acc, a) => + fold(nibble_values(), init: acc, f: (row, b) => row |> list_push(nibble_xor(a: a, b: b)))) + // As with bitwise_pow2, the Absent arm is the definition itself and is unreachable for a, b < 16. fn nibble_and_lookup(a: Int, b: Int) -> Int { match get(nibble_and_table, a * 16 + b) { @@ -64,29 +78,60 @@ fn nibble_and_lookup(a: Int, b: Int) -> Int { } } +fn nibble_xor_lookup(a: Int, b: Int) -> Int { + match get(nibble_xor_table, a * 16 + b) { + Present { value: v } => v + Absent => nibble_xor(a: a, b: b) + } +} + // Conjunction over `digits` nibbles, least significant first. fn nibbles_and(a: Int, b: Int, digits: Int) -> Int { if digits <= 0 { 0 } else { nibble_and_lookup(a: a % 16, b: b % 16) + 16 * nibbles_and(a: a / 16, b: b / 16, digits: digits - 1) } } +// Exclusive or over `digits` nibbles, least significant first. The accumulated value is bounded by +// the digits consumed so far, so it never exceeds the word it is building. +fn nibbles_xor(a: Int, b: Int, digits: Int) -> Int { + if digits <= 0 { 0 } else { nibble_xor_lookup(a: a % 16, b: b % 16) + 16 * nibbles_xor(a: a / 16, b: b / 16, digits: digits - 1) } +} + fn word32_and(a: UInt32, b: UInt32) -> UInt32 { nibbles_and(a: a, b: b, digits: 8) } +// NO INTERMEDIATE HERE LEAVES [0, 2^32), AND THAT IS THE POINT RATHER THAN AN OPTIMISATION +// (review 69054 of gunbc#11645). These were written as `a + b - 2 * and` and `a + b - and`, whose +// answers are correct but whose SUM reaches 2^33 - 2 while typed UInt32 -- the population of +// gunbc.recurring_failure_mode bounded_natural_arithmetic_evaluated_as_unbounded_int, which that +// row cites at this very line, and which DESIGN 4b places BELOW THE LADDER rather than on it. +// +// Nibble-wise exclusive or removes the oversized intermediate instead of declaring it: each lookup +// is over values below 16 and the accumulator is bounded by the digits consumed, exactly as +// word32_and already worked. Disjunction is then xor plus and, which are disjoint bit sets, so +// their sum IS the disjunction and is bounded by it -- no wider than the result. fn word32_xor(a: UInt32, b: UInt32) -> UInt32 { - a + b - 2 * word32_and(a: a, b: b) + nibbles_xor(a: a, b: b, digits: 8) } fn word32_or(a: UInt32, b: UInt32) -> UInt32 { - a + b - word32_and(a: a, b: b) + word32_xor(a: a, b: b) + word32_and(a: a, b: b) } fn word32_not(a: UInt32) -> UInt32 { word32_modulus - 1 - a } +// Addition modulo 2^32 decided BEFORE the sum is formed, so the wrap is computed rather than +// recovered from an oversized value: when a would carry past the modulus, the answer is the +// distance it travelled past it. `word32_modulus - b` is at least 1 for every b in range, and both +// branches stay strictly below the modulus. fn word32_add(a: UInt32, b: UInt32) -> UInt32 { - (a + b) % word32_modulus + if a >= word32_modulus - b { + a - (word32_modulus - b) + } else { + a + b + } } fn word32_shr(a: UInt32, n: Int) -> UInt32 { diff --git a/dag/std/bounded_nat.dag b/dag/std/bounded_nat.dag index fa5fb934f91..2379d09b3c4 100644 --- a/dag/std/bounded_nat.dag +++ b/dag/std/bounded_nat.dag @@ -70,8 +70,6 @@ fn bounded_nat_host_positive_bit_capacity() -> Int { // std.bignat's multi-limb naturals, SUFFICIENT FOR UInt64 and UInt128 to be ADMITTED widths whose // operations carry the same typed outcomes, at which point this arm's population is empty rather // than refused. std.bignat already carries the limbs and the Montgomery arithmetic that would serve. -data bounded_nat_width_ceiling_trigger: String = "arbitrary-precision bounded arithmetic through std.bignat, SUFFICIENT FOR UInt64 and UInt128 to become admitted widths rather than refused ones; the 62-bit bound is a property of the host Int this family computes in, not of bounded naturals" - // A reified width admitted, or refused with the reason. `bits` is what reification returns for a // MachineWidth whose index is a literal; a MachineWidth has no constant to reify // and reaches this as WidthNotStatic, which is a caller's arm to answer rather than a default. From cb724f397a768adf9cbdd773e2824fd6f646e34e Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 20 Sep 2026 10:07:43 +0000 Subject: [PATCH 29/31] Name the consumer frontier's real blocker, and file the unauthorable-RED receipt TRIGGER STATED AT THE WRONG GRAIN, CORRECTED. std.bounded_nat's frontier said its first consumer waits on reification of the N in MachineWidth. It does not: the operations take a width as a VALUE and the witnesses pass bit_width(count: 32) today, so nothing stopped std.bitwise passing one. Reification lets a CALLER stop naming a width; it is not what stands between this family and its first consumer. That is DESIGN 4b(3)'s own test -- a trigger naming less than the capability it gates is satisfied while the capability stays dead -- applied to a row I wrote. THE ACTUAL BLOCKER IS AN IMPORT CYCLE: std.bounded_nat imports std.bitwise for bitwise_pow2, so std.bitwise cannot import back, and DESIGN 3 makes acyclicity the import graph's only structural law. It is breakable by relocating bitwise_pow2 below both. The SECOND cost is stated beside it rather than left as a one-line blocker: after the cycle breaks, every word32_* operation answers a six-arm BoundedNatArith, and threading that through SHA-256's 64-round compression fold is what the first consumer actually costs. Deferred to its own change with the P-256 suite as its gate, in that order, because the only suite covering that fold has never returned a result. RECEIPT FILED ON AN EXISTING CLASS rather than a new row: check_subject_shape_cannot_represent_the_state_the_check_detects. Review 69034 named two invalid inputs to bignat_small; the negative is real and reachable, and n >= 2^72 is UNREPRESENTABLE in the subject, since Int is 64-bit and the largest expressible n has top limb 32767, inside 2^24. A check for it is permanently green over its own subject. WHAT THAT ROW DID NOT YET CARRY, and why this receipt is worth its space: the guard first authored was `n >= bignat_base * bignat_base * bignat_base`, and that expression IS 2^72, which RAISES IntegerOverflow. So the check against an input that cannot occur would itself have been the only way to make the function fail -- converting a total function into a raising one, on every call, in service of a state no caller could supply. A decoration costs trust; this would have cost correctness. And it was caught by trying to WRITE THE RED: the witness could not be authored, because 4722366482869645213696 is not a writable Int literal. The row's own recognition rule was satisfied by the compiler refusing the construction. bounded_nat 10/10 still passes; the edited ledger row resolves. Co-Authored-By: Claude Opus 5 (1M context) --- ..._represent_the_state_the_check_detects.dag | 1 + dag/std/bounded_nat.dag | 25 ++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/dag/gunbc/recurring_failure_mode/check_subject_shape_cannot_represent_the_state_the_check_detects.dag b/dag/gunbc/recurring_failure_mode/check_subject_shape_cannot_represent_the_state_the_check_detects.dag index 50c013d2d3b..13b583ba2aa 100644 --- a/dag/gunbc/recurring_failure_mode/check_subject_shape_cannot_represent_the_state_the_check_detects.dag +++ b/dag/gunbc/recurring_failure_mode/check_subject_shape_cannot_represent_the_state_the_check_detects.dag @@ -16,6 +16,7 @@ data check_subject_shape_cannot_represent_the_state_the_check_detects: Recurring "**THE REPORTING RULE THAT FOLLOWS: a class's rung is the minimum across its in-scope paths (4b(1)), so a fixture path where the RED is authorable does NOT stand in for a live path where it is not.** Citing the fixture rung while the live path is dark is rung inflation. **AND IT IS NOT A 4b(3) DECLARED DROP -- filing one is its own error**, which is where the specimen nearly went: 4b(3) governs a REGRESSION, with a previous rung and a population that LOST a guarantee. When the shape mismatch predates the check, the wall was never lit through that path, and a capability that was never real cannot be dropped. The honest instruments are 4b(1) rung honesty and 4b(2) an untracked stall made tracked.", "**RUNG: 1 (mitigatable) -- the gap is nameable and, once found, held open by the two-arm control. CEILING: 4, since whether a subject's representation can inhabit the state a check matches on is decidable from two declarations the tree already carries. NEXT-RUNG TRIGGER, a CAPABILITY and not an artifact: a check declares the subject representation it matches over, and a check whose match is unsatisfiable by that representation fails to compile -- so the decoration cannot be authored at all, rather than being found later by someone constructing the value by hand.**)", + "**A GUARD AGAINST AN UNREPRESENTABLE STATE CAN BE WORSE THAN A DECORATION: IT CAN BE THE FAILURE ITSELF** (gunbc#11645, lane A of the approval app, 2026-09-20, review 69054 and the author's correction to it). Review 69034 found std.bignat bignat_small minting a BigNat from an unchecked caller Int and named two invalid inputs: a NEGATIVE n, which writes negative limbs, and n >= 2^72, which overflows the 24-bit top limb. The first is real. THE SECOND IS UNREPRESENTABLE IN THE SUBJECT: Int is 64-bit, so the largest expressible n is 2^63 - 1, whose top limb is 2^63 / 2^48 = 32767, comfortably inside 2^24. No Int reaches the boundary, so a check for it is permanently green over its own subject -- this class exactly, arrived at from a REVIEW FINDING rather than from a lens. **THE NEW PART, AND IT IS WHY THIS ROW SHOULD NOT READ AS ONLY INERTNESS:** the guard first authored was `n >= bignat_base * bignat_base * bignat_base`, and that expression IS 2^72, which the substrate cannot evaluate -- it RAISES IntegerOverflow. So the check against an input that cannot occur would itself have been the only way to make the function fail. A decoration costs a reader's trust; this would have converted a total function into a raising one, on every call, in service of a state no caller could ever supply. **HOW IT WAS CAUGHT is the receipt worth keeping for this row's recognition rule:** not by reasoning, but by trying to WRITE THE RED -- the witness asserting the refusal could not be authored, because 4722366482869645213696 is not a writable Int literal. The row's own rule (construct a value in the subject's representation that exhibits the state) was satisfied by the compiler refusing the construction. The reachable half shipped as a real refusal with a passing negative control; the unreachable half is described on the carrier instead of authored.", "**THE REPAIR LANDED AND ITS DIRECTION IS NOT SYMMETRIC: RESHAPE THE PRODUCER, NEVER WIDEN THE CHECK** (gunbc#10282, cool-fox-470, the reshape arm of the same specimen this row was filed from). `v1.stage0.coproduct_reflection` `marshal_generic` now returns a `ComputationNode { behavior: Transform }` record for an `ExprCall` with a non-empty authored name -- the case where the callee identity leaf is already the first positional edge -- so the subject representation gained the constructor the check matches on and the RED became authorable over the live substrate. The arm NOT taken is the one that looks equally available and is not: widening `node_is_call` to also accept the Conj spelling would have made the check green again by ratifying the fork, leaving the substrate with two definitions of a call and the compiler's own lowered-body carrier advisory. A check may not be taught the producer's spelling; the producer must emit the substrate's carrier.", "**THE FIELD TELL IS VISIBLE BY READING, BEFORE ANYTHING RUNS: A PRIVATE COPY OF THE SHARED PREDICATE.** `v2.lens.no_dual_representation_test.scan` carried `skeleton_is_call`, `skeleton_is_callee_atom` and `skeleton_call_argument_targets` -- `v2.std.node_query`'s `node_is_call` / `call_callee_target` / `call_argument_targets` respelled against `TypeNode { connective: Conj }` -- because the shared authority could not read its own substrate. A SECOND PREDICATE ANSWERING ONE QUESTION IS EVIDENCE THAT THE FIRST DOES NOT APPLY TO SOMEBODY'S INPUT, and it is a static, greppable symptom of this class that needs no execution to find. The fork and the defect shipped together: the workaround is what let the unrepresentable subject go unnoticed. It is deleted rather than parameterised in #10282, and `replace_param_in_output` in that same module -- which descended `Conj` alone -- would have returned every body containing a call UNSUBSTITUTED the moment the carrier appeared, a fail-open the repair itself would have created.", diff --git a/dag/std/bounded_nat.dag b/dag/std/bounded_nat.dag index 2379d09b3c4..be3e8bb8876 100644 --- a/dag/std/bounded_nat.dag +++ b/dag/std/bounded_nat.dag @@ -13,10 +13,27 @@ import std.bitwise { bitwise_pow2 } // operator per type. // // WHY THIS IS NOT A SECOND AUTHORITY FOR THE TYPES. std.integer declares WHICH bounded naturals -// exist; this module declares WHAT ARITHMETIC ON ONE MEANS. The binding between them -- UInt32's -// width read from its own declaration -- is the one part that cannot be written yet: it needs the N -// of MachineWidth reified at compile time (operator ruling, 2026-09-20). Until that lands the -// width arrives as a value and NOTHING here names a width. +// exist; this module declares WHAT ARITHMETIC ON ONE MEANS. Reification of the N in MachineWidth +// (operator ruling, 2026-09-20) would let a CALLER stop naming a width; until it lands the width +// arrives as a value and nothing here names one. +// +// REIFICATION IS NOT WHAT STANDS BETWEEN THIS FAMILY AND ITS FIRST CONSUMER, AND SAYING SO WAS A +// TRIGGER STATED AT THE WRONG GRAIN (review 69054 of gunbc#11645; DESIGN 4b(3): a trigger naming +// less than the capability it gates is satisfied while the capability stays dead). The operations +// take a width as a VALUE and the witnesses pass bit_width(count: 32) today, so nothing stops +// std.bitwise passing one too. +// +// THE ACTUAL BLOCKER IS AN IMPORT CYCLE. std.bounded_nat imports std.bitwise for bitwise_pow2, so +// std.bitwise cannot import std.bounded_nat back, and DESIGN 3 makes acyclicity the import graph's +// ONLY structural law -- a wall rather than a preference. It is breakable: relocate bitwise_pow2 +// below both and the cycle is gone. +// +// THE SECOND COST, STATED BESIDE IT so the next reader sees the real shape rather than a one-line +// blocker: after the cycle is broken, every word32_* operation answers a BoundedNatArith, a six-arm +// coproduct, and threading that through SHA-256's 64-round compression fold is the crypto +// restructuring this family's first consumer actually costs. It is deferred to its own change with +// the P-256 suite as its gate (operator-lane ruling, 2026-09-20), in that order -- because the only +// suite covering that fold has never returned a result. // // THE DEFECT THIS EXISTS TO REMOVE is gunbc.recurring_failure_mode // bounded_natural_arithmetic_evaluated_as_unbounded_int: the v1 interpreter evaluates infix From d3de6681685c7f506ad4d7e0f4d15dd83e0161bf Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 20 Sep 2026 10:43:09 +0000 Subject: [PATCH 30/31] word64_add: the repair the 32-bit family got, applied to the 64-bit one Review 69076, confirmed and correct. word64_add was `let lo = a.lo + b.lo` over two UInt32 halves -- reaching 2^33 - 2 while typed UInt32 -- and `a.hi + b.hi + carry` doing it again. That is the same population of bounded_natural_arithmetic_evaluated_as_unbounded_int that the note I added three commits ago says word32_xor, word32_or and word32_add were rewritten to leave. I APPLIED THE REPAIR TO THE 32-BIT FAMILY AND SKIPPED THE 64-BIT ONE IN THE SAME DIFF, while writing the explanation of why it was needed. The defect survived inside its own fix. It was live rather than latent: sha256_fips_witness_test drives lo to exactly 2^32 on every run. FIXED BY REUSING THE ALREADY-CORRECTED OPERATION rather than repeating its reasoning. The carry is decided before either sum is formed, by the same comparison word32_add uses, and both halves are produced by word32_add itself instead of a raw `%` -- so the wrap is computed once, in one place. I SCANNED THE REST OF THE FILE RATHER THAN FIXING ONLY THE SITE NAMED, since "applied here, skipped there" is the defect. Six arithmetic sites remain over UInt32 halves -- word32_shl, word32_rotr, word32_add's guarded branch, word64_shr and both word64_rotr halves -- and every one is a sum of DISJOINT bit ranges. Measured over 80,000 random inputs: largest intermediate 4294964487, inside 2^32. word64_add was the only defect. EVIDENCE: SHA-256 7/7 passes, and the control is discriminating rather than a formality -- replacing the carry with a constant 0 reds word64_add_carries_across_the_halves_and_rotr_swaps_them by name, so the witness catches the carry and not merely the shape. The proposed implementation was also checked against 64-bit modular addition over 80,000 random pairs plus the edges, including the witness's own all-ones + 1: zero mismatches. Co-Authored-By: Claude Opus 5 (1M context) --- dag/std/bitwise.dag | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/dag/std/bitwise.dag b/dag/std/bitwise.dag index 1c62ad877bd..28322700ba5 100644 --- a/dag/std/bitwise.dag +++ b/dag/std/bitwise.dag @@ -172,9 +172,22 @@ fn word64_not(a: Word64) -> Word64 { Word64 { hi: word32_not(a: a.hi), lo: word32_not(a: a.lo) } } +// THE SAME REPAIR AS word32_add, WHICH THIS SKIPPED IN THE DIFF THAT MADE IT (review 69076 of +// gunbc#11645). It was `let lo = a.lo + b.lo` over two UInt32 halves, reaching 2^33 - 2 while +// typed UInt32, and `a.hi + b.hi + carry` doing it again -- the same population the note above +// says word32_xor, word32_or and word32_add were rewritten to leave. Applying a repair to the +// 32-bit family and not the 64-bit one in one change is the defect surviving inside its own fix, +// and it was LIVE rather than latent: sha256_fips_witness_test drives lo to exactly 2^32. +// +// The carry is decided BEFORE either sum is formed, by the same comparison word32_add uses, and +// both halves are then produced by word32_add itself rather than by a raw `%` -- so the wrap is +// computed once, in one place, and no intermediate here leaves [0, 2^32). fn word64_add(a: Word64, b: Word64) -> Word64 { - let lo = a.lo + b.lo - Word64 { hi: (a.hi + b.hi + lo / word32_modulus) % word32_modulus, lo: lo % word32_modulus } + let carry = if a.lo >= word32_modulus - b.lo { 1 } else { 0 } + Word64 { + hi: word32_add(a: word32_add(a: a.hi, b: b.hi), b: carry), + lo: word32_add(a: a.lo, b: b.lo), + } } // Shift right by n in [0, 64). From bb50768c93e2f06be9936def4f556ffe8c4796fa Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 20 Sep 2026 11:26:57 +0000 Subject: [PATCH 31/31] nist_p256: the header named a consumer that does not exist, in the present tense Review 69091, confirmed. The header said this module is "the producer behind extdeps.crypto.signature p256_ecdsa_verify". That module is real and owns the suite, the encodings and the refusal vocabulary -- but it declares no p256_ecdsa_verify, and neither does anything else: the name exists nowhere outside this module and its witnesses. The only importers of all five new modules are the four witness files. THIS IS THE CLASS THIS PR CITES TWICE IN ITS OWN RECEIPTS. unlanded_citation_indistinguishable_at_the_citing_end is exactly a name that reads as landed at the point it is cited. Those receipt rows flag their unlanded names scrupulously -- "UNLANDED NAMES, read as such" -- and this header, the one sentence a reader consults to learn who consumes 1,772 lines of new crypto, did not. Being the plan does not make a citation true. Restated as the DESIGN 3c frontier it is: nothing in production consumes this today, the consumers are the witnesses, and the trigger is named -- extdeps.crypto.signature gains p256_ecdsa_verify binding p256_ecdsa_verify_octets through signature_verification_from_implementation. I CHECKED THE OTHER FOUR NEW MODULES RATHER THAN ONLY THE SITE NAMED, since applying a repair at one site and skipping its siblings is the defect I shipped one round ago. Two make similar claims and BOTH ARE ACCURATE: std.bitwise cites std.bit's Word32, which exists (dag/std/bit.dag:16), and extdeps.crypto.sha2 cites extdeps.crypto.hash's Digest carrier, which exists (dag/extdeps/crypto/hash.dag:34). nist_p256 was the only fictional citation. Module resolves after the edit. --- dag/extdeps/crypto/nist_p256.dag | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/dag/extdeps/crypto/nist_p256.dag b/dag/extdeps/crypto/nist_p256.dag index 84a51306377..f8433f5ff5c 100644 --- a/dag/extdeps/crypto/nist_p256.dag +++ b/dag/extdeps/crypto/nist_p256.dag @@ -12,9 +12,26 @@ import extdeps.external_authority { ExternalAuthority } import extdeps.uri { Uri, Https } // The NIST P-256 curve (SP 800-186 §3.2.1.3) and ECDSA verification over it with SHA-256 -// (FIPS 186-5 §6.4.2), computed in the substrate on std.bignat's Montgomery arithmetic. This is the -// producer behind extdeps.crypto.signature p256_ecdsa_verify; that module keeps the suite, the -// encodings and the refusal vocabulary. +// (FIPS 186-5 §6.4.2), computed in the substrate on std.bignat's Montgomery arithmetic. +// +// WHO CONSUMES THIS TODAY, STATED AS THE FRONTIER IT IS (DESIGN 3c; review 69091 of gunbc#11645). +// NOTHING IN PRODUCTION DOES. The only importers of this module are the witness files under +// dag/test/claim. An earlier version of this header said it was "the producer behind +// extdeps.crypto.signature p256_ecdsa_verify" in the PRESENT TENSE -- and that module declares no +// such name. extdeps.crypto.signature is real and owns the suite, the encodings and the refusal +// vocabulary (SignatureSuite, SignatureVerification, signature_verification_from_implementation), +// but p256_ecdsa_verify does not exist in it or anywhere else outside this module and its tests. +// +// THAT IS THE CLASS THIS REPOSITORY ALREADY ROSTERS AS +// gunbc.recurring_failure_mode unlanded_citation_indistinguishable_at_the_citing_end, and this +// PR's own failure-mode receipts flag their unlanded names scrupulously ("UNLANDED NAMES, read as +// such") while this header -- the one sentence a reader consults to learn who consumes the module +// -- did not. A citation is not made true by being the plan. +// +// DECLARED FRONTIER WITH ITS TRIGGER: extdeps.crypto.signature gains p256_ecdsa_verify, binding +// p256_ecdsa_verify_octets below through signature_verification_from_implementation with the +// decoded octet lengths. Until that declaration exists, this module's consumers are its witnesses +// and nothing else, and no reader should infer otherwise from this comment. data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { uri: Uri { scheme: Https