mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(mobile-infra): support development App Attest (#7744)
Development-signed iOS apps cannot enroll with the current gateway because their Apple App Attest AAGUID is rejected. Add an explicit personal development build feature and environment setting so a development gateway can use a developer's own application identity and sandbox APNs credentials. Ordinary gateway builds remain production-only. Special builds also default to production, enforce exactly the selected signed AAGUID, and retain certificate-chain, pinned-root, nonce, app-ID, key, credential-ID, and counter verification. Document the matching mobile signing configuration and expose the feature through an optional Docker build argument. ### Validation The development image deployed successfully to a personal Railway stack; private readiness, enrollment challenges, authenticated relay access, and APNs sandbox TLS/HTTP2 connectivity passed. Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
33
.github/workflows/docker.yml
vendored
33
.github/workflows/docker.yml
vendored
@@ -569,6 +569,39 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
push-gateway-personal-dev:
|
||||
name: Build personal development push gateway
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- name: Build personal development image without publishing
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.push-gateway
|
||||
platforms: linux/amd64
|
||||
build-args: BUZZ_PUSH_CARGO_FEATURES=personal-dev-app-attest
|
||||
load: true
|
||||
tags: buzz-push-gateway:personal-dev-test
|
||||
cache-from: type=gha,scope=push-gateway-personal-dev
|
||||
cache-to: type=gha,scope=push-gateway-personal-dev,mode=max
|
||||
- name: Verify runtime dependencies and startup
|
||||
run: |
|
||||
set +e
|
||||
output=$(docker run --rm buzz-push-gateway:personal-dev-test 2>&1)
|
||||
status=$?
|
||||
set -e
|
||||
test "$status" -eq 1
|
||||
[[ "$output" == *'Missing("BUZZ_PUSH_GRANT_KEYS")'* ]]
|
||||
|
||||
push-gateway-merge:
|
||||
name: Publish public push gateway image
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
223
Cargo.lock
generated
223
Cargo.lock
generated
@@ -147,7 +147,10 @@ dependencies = [
|
||||
"aws-lc-rs",
|
||||
"base64 0.22.1",
|
||||
"byteorder",
|
||||
"ciborium",
|
||||
"minicbor",
|
||||
"openssl",
|
||||
"p256",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
]
|
||||
@@ -603,6 +606,12 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "1.0.0"
|
||||
@@ -1681,6 +1690,33 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
|
||||
dependencies = [
|
||||
"ciborium-io",
|
||||
"ciborium-ll",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium-io"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
|
||||
|
||||
[[package]]
|
||||
name = "ciborium-ll"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
|
||||
dependencies = [
|
||||
"ciborium-io",
|
||||
"half",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -1857,6 +1893,12 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
@@ -2124,6 +2166,18 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[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 0.6.4",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -2439,14 +2493,25 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid 0.9.6",
|
||||
"pem-rfc7468 0.7.0",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"pem-rfc7468",
|
||||
"const-oid 0.10.2",
|
||||
"pem-rfc7468 1.0.0",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -2561,6 +2626,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"const-oid 0.9.6",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
@@ -2572,7 +2638,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.0",
|
||||
"const-oid",
|
||||
"const-oid 0.10.2",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
@@ -2668,13 +2734,27 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||
dependencies = [
|
||||
"der 0.7.10",
|
||||
"digest 0.10.7",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature 2.2.0",
|
||||
"spki 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"pkcs8 0.11.0",
|
||||
"serdect",
|
||||
"signature 3.0.0",
|
||||
]
|
||||
@@ -2704,6 +2784,26 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct 0.2.0",
|
||||
"crypto-bigint",
|
||||
"digest 0.10.7",
|
||||
"ff",
|
||||
"generic-array",
|
||||
"group",
|
||||
"pem-rfc7468 0.7.0",
|
||||
"pkcs8 0.10.2",
|
||||
"rand_core 0.6.4",
|
||||
"sec1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.4.0"
|
||||
@@ -2918,6 +3018,16 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
@@ -3352,6 +3462,17 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.16"
|
||||
@@ -3371,6 +3492,17 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.4",
|
||||
"crunchy",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hash32"
|
||||
version = "0.3.1"
|
||||
@@ -6612,6 +6744,18 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[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 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "palette"
|
||||
version = "0.7.6"
|
||||
@@ -6724,6 +6868,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "1.0.0"
|
||||
@@ -6901,14 +7054,24 @@ dependencies = [
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der 0.7.10",
|
||||
"spki 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
"der 0.8.2",
|
||||
"spki 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7096,6 +7259,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[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-macro-crate"
|
||||
version = "3.5.0"
|
||||
@@ -8012,6 +8184,16 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||
dependencies = [
|
||||
"hmac 0.12.1",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -8405,6 +8587,20 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct 0.2.0",
|
||||
"der 0.7.10",
|
||||
"generic-array",
|
||||
"pkcs8 0.10.2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1"
|
||||
version = "0.29.1"
|
||||
@@ -8690,7 +8886,7 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"base16ct 1.0.0",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -8829,6 +9025,7 @@ version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
@@ -9088,6 +9285,16 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der 0.7.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.8.0"
|
||||
@@ -9095,7 +9302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
"der 0.8.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -11,13 +11,14 @@ COPY . .
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
FROM chef AS builder
|
||||
ARG BUZZ_PUSH_CARGO_FEATURES=""
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential pkg-config libssl-dev ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=planner /build/recipe.json recipe.json
|
||||
RUN cargo chef cook --release --recipe-path recipe.json
|
||||
RUN cargo chef cook --release --recipe-path recipe.json --features "$BUZZ_PUSH_CARGO_FEATURES"
|
||||
COPY . .
|
||||
RUN cargo build --release --locked -p buzz-push-gateway --bin buzz-push-gateway \
|
||||
RUN cargo build --release --locked -p buzz-push-gateway --bin buzz-push-gateway --features "$BUZZ_PUSH_CARGO_FEATURES" \
|
||||
&& strip target/release/buzz-push-gateway
|
||||
|
||||
FROM debian:${DEBIAN_VERSION}-slim AS runtime
|
||||
|
||||
1
Justfile
1
Justfile
@@ -400,6 +400,7 @@ test-unit:
|
||||
# Gateway unit and black-box HTTP tests are infra-free. Postgres-backed
|
||||
# contract/race tests run in the dedicated CI job below.
|
||||
cargo nextest run -p buzz-push-gateway
|
||||
cargo nextest run -p buzz-push-gateway --features personal-dev-app-attest
|
||||
# Kubernetes backend provider: the decision layers (state machine, GC
|
||||
# planner, env precedence, naming, wire) are pure functions with a fake
|
||||
# substrate, so they belong in the unit job. Enumerated explicitly
|
||||
|
||||
@@ -15,6 +15,10 @@ path = "src/lib.rs"
|
||||
name = "buzz-push-gateway"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
# Personal device development only. Production builds remain production-only.
|
||||
personal-dev-app-attest = ["appattest/testing"]
|
||||
|
||||
[dependencies]
|
||||
aes-gcm = "0.10"
|
||||
appattest = { version = "0.1.1", default-features = false }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Narrow App Attest verification boundary. Production enrollment accepts only
|
||||
//! Apple production AAGUID material; unsupported devices have no bypass path.
|
||||
//! Apple production AAGUID material by default; personal development requires
|
||||
//! an explicit build feature and environment. No cryptographic checks are skipped.
|
||||
use crate::config::AppAttestEnvironment;
|
||||
use appattest::{assertion::Assertion, attestation::Attestation};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use byteorder::{BigEndian, ByteOrder};
|
||||
@@ -31,9 +33,24 @@ pub enum AppAttestError {
|
||||
pub struct AppAttestVerifier {
|
||||
app_id: String,
|
||||
apple_root_cert_pem: Vec<u8>,
|
||||
environment: AppAttestEnvironment,
|
||||
}
|
||||
impl AppAttestVerifier {
|
||||
/// Construct a production-only verifier.
|
||||
pub fn new(app_id: String, apple_root_cert_pem: Vec<u8>) -> Result<Self, AppAttestError> {
|
||||
Self::with_environment(
|
||||
app_id,
|
||||
apple_root_cert_pem,
|
||||
AppAttestEnvironment::Production,
|
||||
)
|
||||
}
|
||||
|
||||
/// Construct a verifier for exactly one server-selected attestation environment.
|
||||
pub fn with_environment(
|
||||
app_id: String,
|
||||
apple_root_cert_pem: Vec<u8>,
|
||||
environment: AppAttestEnvironment,
|
||||
) -> Result<Self, AppAttestError> {
|
||||
if app_id.is_empty()
|
||||
|| Sha256::digest(&apple_root_cert_pem).as_slice() != APPLE_APP_ATTEST_ROOT_PEM_SHA256
|
||||
{
|
||||
@@ -42,8 +59,14 @@ impl AppAttestVerifier {
|
||||
Ok(Self {
|
||||
app_id,
|
||||
apple_root_cert_pem,
|
||||
environment,
|
||||
})
|
||||
}
|
||||
/// Exact server-selected environment accepted by this verifier.
|
||||
pub fn environment(&self) -> AppAttestEnvironment {
|
||||
self.environment
|
||||
}
|
||||
|
||||
/// `client_data` is the exact canonical enrollment transcript represented by
|
||||
/// the challenge string passed to `attestKey`; callers must include every
|
||||
/// authority-bearing enrollment field in it.
|
||||
@@ -59,6 +82,7 @@ impl AppAttestVerifier {
|
||||
if cbor.is_empty() || cbor.len() > crate::model::MAX_APP_ATTESTATION_BYTES {
|
||||
return Err(AppAttestError::Invalid);
|
||||
}
|
||||
verify_attestation_environment(&cbor, self.environment)?;
|
||||
let challenge = std::str::from_utf8(client_data).map_err(|_| AppAttestError::Invalid)?;
|
||||
let att = Attestation::from_cbor_bytes(&cbor).map_err(|_| AppAttestError::Invalid)?;
|
||||
let (public_key, _) = att
|
||||
@@ -112,6 +136,83 @@ impl AppAttestVerifier {
|
||||
}
|
||||
}
|
||||
|
||||
// The dependency's development feature accepts both Apple environments. Fence
|
||||
// the exact signed authData here, then let its full verifier validate those same
|
||||
// bytes (chain, nonce, app ID, counter, public key and credential ID).
|
||||
fn verify_attestation_environment(
|
||||
cbor: &[u8],
|
||||
environment: AppAttestEnvironment,
|
||||
) -> Result<(), AppAttestError> {
|
||||
let mut decoder = minicbor::Decoder::new(cbor);
|
||||
let count = decoder
|
||||
.map()
|
||||
.map_err(|_| AppAttestError::Invalid)?
|
||||
.ok_or(AppAttestError::Invalid)?;
|
||||
if count != 3 {
|
||||
return Err(AppAttestError::Invalid);
|
||||
}
|
||||
let mut auth_data = None;
|
||||
let mut format_seen = false;
|
||||
let mut statement_seen = false;
|
||||
for _ in 0..count {
|
||||
match decoder.str().map_err(|_| AppAttestError::Invalid)? {
|
||||
"authData" if auth_data.is_none() => {
|
||||
auth_data = Some(decoder.bytes().map_err(|_| AppAttestError::Invalid)?);
|
||||
}
|
||||
"fmt" if !format_seen => {
|
||||
if decoder.str().map_err(|_| AppAttestError::Invalid)? != "apple-appattest" {
|
||||
return Err(AppAttestError::Invalid);
|
||||
}
|
||||
format_seen = true;
|
||||
}
|
||||
"attStmt" if !statement_seen => {
|
||||
// appattest 0.1.1 treats indefinite containers as empty without
|
||||
// consuming their contents. Accept only the definite schema so
|
||||
// its cursor cannot reinterpret nested fields as root fields.
|
||||
if decoder.map().map_err(|_| AppAttestError::Invalid)? != Some(2) {
|
||||
return Err(AppAttestError::Invalid);
|
||||
}
|
||||
let mut certs_seen = false;
|
||||
let mut receipt_seen = false;
|
||||
for _ in 0..2 {
|
||||
match decoder.str().map_err(|_| AppAttestError::Invalid)? {
|
||||
"x5c" if !certs_seen => {
|
||||
let count = decoder
|
||||
.array()
|
||||
.map_err(|_| AppAttestError::Invalid)?
|
||||
.filter(|count| (1..=3).contains(count))
|
||||
.ok_or(AppAttestError::Invalid)?;
|
||||
for _ in 0..count {
|
||||
decoder.bytes().map_err(|_| AppAttestError::Invalid)?;
|
||||
}
|
||||
certs_seen = true;
|
||||
}
|
||||
"receipt" if !receipt_seen => {
|
||||
decoder.bytes().map_err(|_| AppAttestError::Invalid)?;
|
||||
receipt_seen = true;
|
||||
}
|
||||
_ => return Err(AppAttestError::Invalid),
|
||||
}
|
||||
}
|
||||
statement_seen = true;
|
||||
}
|
||||
_ => return Err(AppAttestError::Invalid),
|
||||
}
|
||||
}
|
||||
if decoder.position() != cbor.len() {
|
||||
return Err(AppAttestError::Invalid);
|
||||
}
|
||||
let expected: &[u8] = match environment {
|
||||
AppAttestEnvironment::Production => b"appattest\0\0\0\0\0\0\0",
|
||||
#[cfg(feature = "personal-dev-app-attest")]
|
||||
AppAttestEnvironment::Development => b"appattestdevelop",
|
||||
};
|
||||
if auth_data.and_then(|data| data.get(37..53)) != Some(expected) {
|
||||
return Err(AppAttestError::Invalid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// App Attest assertion CBOR is a closed two-field map. Extracting signCount
|
||||
/// from authenticatorData is safe only after the library verifies the same
|
||||
/// bytes' RP ID, signature, and monotonic relation.
|
||||
@@ -173,6 +274,7 @@ mod tests {
|
||||
AppAttestVerifier {
|
||||
app_id: app_id.to_owned(),
|
||||
apple_root_cert_pem: root_cert_pem.to_vec(),
|
||||
environment: AppAttestEnvironment::Production,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +390,7 @@ mod tests {
|
||||
fn wrong_aaguid_is_rejected_as_invalid_aaguid() {
|
||||
let fixture = fixture(WRONG_AAGUID_FIXTURE_JSON);
|
||||
assert_eq!(fixture.aaguid, "appattestdevelop");
|
||||
#[cfg(not(feature = "personal-dev-app-attest"))]
|
||||
assert_eq!(
|
||||
verify_dependency(
|
||||
&fixture,
|
||||
@@ -307,6 +410,194 @@ mod tests {
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[cfg(feature = "personal-dev-app-attest")]
|
||||
#[test]
|
||||
fn development_mode_preserves_verification_and_rejects_production() {
|
||||
let dev = fixture(WRONG_AAGUID_FIXTURE_JSON);
|
||||
let mut v = verifier(&dev.app_id, dev.root_cert_pem.as_bytes());
|
||||
v.environment = AppAttestEnvironment::Development;
|
||||
let cbor = STANDARD.decode(&dev.attestation_b64).unwrap();
|
||||
assert!(
|
||||
verify_attestation_environment(&cbor, v.environment).is_ok(),
|
||||
"environment parser"
|
||||
);
|
||||
verify_dependency(
|
||||
&dev,
|
||||
&dev.app_id,
|
||||
&dev.challenge,
|
||||
&dev.key_id_b64,
|
||||
dev.root_cert_pem.as_bytes(),
|
||||
)
|
||||
.expect("development dependency");
|
||||
assert!(v
|
||||
.verify_attestation(
|
||||
&dev.attestation_b64,
|
||||
&dev.key_id_b64,
|
||||
dev.challenge.as_bytes()
|
||||
)
|
||||
.is_ok());
|
||||
let prod = fixture(GOOD_FIXTURE_JSON);
|
||||
assert!(v
|
||||
.verify_attestation(
|
||||
&prod.attestation_b64,
|
||||
&prod.key_id_b64,
|
||||
prod.challenge.as_bytes()
|
||||
)
|
||||
.is_err());
|
||||
assert!(v
|
||||
.verify_attestation(&dev.attestation_b64, &dev.key_id_b64, b"wrong challenge")
|
||||
.is_err());
|
||||
assert!(v
|
||||
.verify_attestation(
|
||||
&dev.attestation_b64,
|
||||
&STANDARD.encode([0; 32]),
|
||||
dev.challenge.as_bytes()
|
||||
)
|
||||
.is_err());
|
||||
v.app_id = "OTHER.wrong.app".into();
|
||||
assert!(v
|
||||
.verify_attestation(
|
||||
&dev.attestation_b64,
|
||||
&dev.key_id_b64,
|
||||
dev.challenge.as_bytes()
|
||||
)
|
||||
.is_err());
|
||||
v.app_id = dev.app_id;
|
||||
v.apple_root_cert_pem = fixture(WRONG_ROOT_FIXTURE_JSON).root_cert_pem.into_bytes();
|
||||
assert!(v
|
||||
.verify_attestation(
|
||||
&dev.attestation_b64,
|
||||
&dev.key_id_b64,
|
||||
dev.challenge.as_bytes()
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[cfg(feature = "personal-dev-app-attest")]
|
||||
#[test]
|
||||
fn nested_statement_cannot_bypass_environment_fence() {
|
||||
for (json, environment, decoy_aaguid) in [
|
||||
(
|
||||
WRONG_AAGUID_FIXTURE_JSON,
|
||||
AppAttestEnvironment::Production,
|
||||
&b"appattest\0\0\0\0\0\0\0"[..],
|
||||
),
|
||||
(
|
||||
GOOD_FIXTURE_JSON,
|
||||
AppAttestEnvironment::Development,
|
||||
&b"appattestdevelop"[..],
|
||||
),
|
||||
] {
|
||||
let fixture = fixture(json);
|
||||
let original = STANDARD.decode(&fixture.attestation_b64).unwrap();
|
||||
let mut decoder = minicbor::Decoder::new(&original);
|
||||
let count = decoder.map().unwrap().unwrap();
|
||||
let mut auth = &[][..];
|
||||
let mut statement = &[][..];
|
||||
for _ in 0..count {
|
||||
let key = decoder.str().unwrap();
|
||||
let start = decoder.position();
|
||||
decoder.skip().unwrap();
|
||||
match key {
|
||||
"authData" => auth = &original[start..decoder.position()],
|
||||
"attStmt" => statement = &original[start..decoder.position()],
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut decoy = [0u8; 53];
|
||||
decoy[37..53].copy_from_slice(decoy_aaguid);
|
||||
let mut buffer = vec![0; original.len() + 256];
|
||||
let mut encoder =
|
||||
minicbor::Encoder::new(minicbor::encode::write::Cursor::new(buffer.as_mut_slice()));
|
||||
encoder
|
||||
.map(5)
|
||||
.unwrap()
|
||||
.str("fmt")
|
||||
.unwrap()
|
||||
.str("apple-appattest")
|
||||
.unwrap()
|
||||
.str("authData")
|
||||
.unwrap()
|
||||
.bytes(&decoy)
|
||||
.unwrap()
|
||||
.str("attStmt")
|
||||
.unwrap()
|
||||
.begin_map()
|
||||
.unwrap()
|
||||
.str("attStmt")
|
||||
.unwrap();
|
||||
use minicbor::encode::Write;
|
||||
encoder.writer_mut().write_all(statement).unwrap();
|
||||
encoder.str("authData").unwrap();
|
||||
encoder.writer_mut().write_all(auth).unwrap();
|
||||
encoder
|
||||
.end()
|
||||
.unwrap()
|
||||
.str("padding1")
|
||||
.unwrap()
|
||||
.u8(0)
|
||||
.unwrap()
|
||||
.str("padding2")
|
||||
.unwrap()
|
||||
.u8(0)
|
||||
.unwrap();
|
||||
let length = encoder.writer().position();
|
||||
let malicious = &buffer[..length];
|
||||
// Prove this envelope reaches valid signed material in the dependency.
|
||||
Attestation::from_cbor_bytes(malicious)
|
||||
.unwrap()
|
||||
.verify(
|
||||
&fixture.challenge,
|
||||
&fixture.app_id,
|
||||
&fixture.key_id_b64,
|
||||
fixture.root_cert_pem.as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut verifier = verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes());
|
||||
verifier.environment = environment;
|
||||
assert!(
|
||||
verifier
|
||||
.verify_attestation(
|
||||
&STANDARD.encode(malicious),
|
||||
&fixture.key_id_b64,
|
||||
fixture.challenge.as_bytes(),
|
||||
)
|
||||
.is_err(),
|
||||
"cross-environment nested statement accepted: {environment:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_parser_rejects_ambiguous_or_truncated_auth_data() {
|
||||
for data in [vec![], vec![0xa0], vec![0xa1, 0x68], vec![0xbf, 0xff]] {
|
||||
assert!(
|
||||
verify_attestation_environment(&data, AppAttestEnvironment::Production).is_err()
|
||||
);
|
||||
}
|
||||
let mut data = [0u8; 256];
|
||||
let mut encoder =
|
||||
minicbor::Encoder::new(minicbor::encode::write::Cursor::new(data.as_mut_slice()));
|
||||
let mut auth = [0u8; 53];
|
||||
auth[37..53].copy_from_slice(b"appattest\0\0\0\0\0\0\0");
|
||||
encoder
|
||||
.map(2)
|
||||
.unwrap()
|
||||
.str("authData")
|
||||
.unwrap()
|
||||
.bytes(&auth)
|
||||
.unwrap()
|
||||
.str("authData")
|
||||
.unwrap()
|
||||
.bytes(&auth)
|
||||
.unwrap();
|
||||
let length = encoder.writer().position();
|
||||
assert!(
|
||||
verify_attestation_environment(&data[..length], AppAttestEnvironment::Production)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_and_oversize_key_ids_are_rejected() {
|
||||
let fixture = fixture(GOOD_FIXTURE_JSON);
|
||||
|
||||
@@ -11,11 +11,23 @@ pub enum ApnsEnvironment {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppProfileConfig {
|
||||
pub app_attest_app_id: String,
|
||||
/// Exact Apple attestation environment accepted for enrollment.
|
||||
pub app_attest_environment: AppAttestEnvironment,
|
||||
pub apns_cert_path: PathBuf,
|
||||
pub apns_topic: String,
|
||||
pub apns_environment: ApnsEnvironment,
|
||||
}
|
||||
|
||||
/// Apple App Attest environment, independent of the APNs transport environment.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AppAttestEnvironment {
|
||||
/// Distributed applications, also the default for personal development builds.
|
||||
Production,
|
||||
/// Development-signed applications in an explicitly opted-in gateway build.
|
||||
#[cfg(feature = "personal-dev-app-attest")]
|
||||
Development,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct KeyConfig {
|
||||
pub id: String,
|
||||
@@ -143,6 +155,15 @@ fn parse_profile(e: &HashMap<String, String>) -> Result<AppProfileConfig, Config
|
||||
.ok_or(ConfigError::Missing(key))
|
||||
};
|
||||
let app_attest_app_id = required(app_id_key)?.to_owned();
|
||||
let app_attest_environment = match e
|
||||
.get("BUZZ_PUSH_APP_ATTEST_ENVIRONMENT")
|
||||
.map(String::as_str)
|
||||
{
|
||||
None | Some("production") => AppAttestEnvironment::Production,
|
||||
#[cfg(feature = "personal-dev-app-attest")]
|
||||
Some("development") => AppAttestEnvironment::Development,
|
||||
Some(_) => return Err(ConfigError::Invalid("BUZZ_PUSH_APP_ATTEST_ENVIRONMENT")),
|
||||
};
|
||||
let apns_topic = required(topic_key)?.to_owned();
|
||||
let apns_cert_path = PathBuf::from(required(cert_key)?);
|
||||
let apns_environment = match e.get(environment_key).map(String::as_str) {
|
||||
@@ -152,6 +173,7 @@ fn parse_profile(e: &HashMap<String, String>) -> Result<AppProfileConfig, Config
|
||||
};
|
||||
Ok(AppProfileConfig {
|
||||
app_attest_app_id,
|
||||
app_attest_environment,
|
||||
apns_cert_path,
|
||||
apns_topic,
|
||||
apns_environment,
|
||||
@@ -258,6 +280,47 @@ impl Config {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn attestation_environment_is_explicit_and_build_gated() {
|
||||
let mut env = base();
|
||||
assert_eq!(
|
||||
Config::from_map(&env)
|
||||
.unwrap()
|
||||
.profile
|
||||
.app_attest_environment,
|
||||
AppAttestEnvironment::Production
|
||||
);
|
||||
for value in ["", "sandbox", "Production", "unknown"] {
|
||||
env.insert("BUZZ_PUSH_APP_ATTEST_ENVIRONMENT".into(), value.into());
|
||||
assert!(Config::from_map(&env).is_err());
|
||||
}
|
||||
env.insert(
|
||||
"BUZZ_PUSH_APP_ATTEST_ENVIRONMENT".into(),
|
||||
"production".into(),
|
||||
);
|
||||
assert_eq!(
|
||||
Config::from_map(&env)
|
||||
.unwrap()
|
||||
.profile
|
||||
.app_attest_environment,
|
||||
AppAttestEnvironment::Production
|
||||
);
|
||||
env.insert(
|
||||
"BUZZ_PUSH_APP_ATTEST_ENVIRONMENT".into(),
|
||||
"development".into(),
|
||||
);
|
||||
#[cfg(feature = "personal-dev-app-attest")]
|
||||
assert_eq!(
|
||||
Config::from_map(&env)
|
||||
.unwrap()
|
||||
.profile
|
||||
.app_attest_environment,
|
||||
AppAttestEnvironment::Development
|
||||
);
|
||||
#[cfg(not(feature = "personal-dev-app-attest"))]
|
||||
assert!(Config::from_map(&env).is_err());
|
||||
}
|
||||
|
||||
fn base() -> HashMap<String, String> {
|
||||
HashMap::from([
|
||||
(
|
||||
|
||||
@@ -38,15 +38,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let app_attest_root = fs::read(&c.app_attest_root_cert_path)?;
|
||||
let configured = &c.profile;
|
||||
let profile = {
|
||||
let apple = AppAttestVerifier::with_environment(
|
||||
configured.app_attest_app_id.clone(),
|
||||
app_attest_root,
|
||||
configured.app_attest_environment,
|
||||
)?;
|
||||
tracing::info!(environment = ?apple.environment(), "App Attest verifier configured");
|
||||
let transport = Arc::new(ApnsTransport::certificate(
|
||||
&fs::read(&configured.apns_cert_path)?,
|
||||
configured.apns_topic.clone(),
|
||||
configured.apns_environment,
|
||||
)?);
|
||||
let apple = AppAttestVerifier::new(
|
||||
configured.app_attest_app_id.clone(),
|
||||
app_attest_root.clone(),
|
||||
)?;
|
||||
buzz_push_gateway::http::ProfileRuntime {
|
||||
app_attest: Arc::new(apple),
|
||||
transport,
|
||||
|
||||
86
crates/buzz-push-gateway/tests/startup_app_attest.rs
Normal file
86
crates/buzz-push-gateway/tests/startup_app_attest.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
//! Exercise the actual executable's parsed configuration and verifier constructor.
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use std::{
|
||||
process::{Command, Stdio},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn parsed_configuration_reaches_executable_verifier() {
|
||||
for setting in [None, Some("production"), Some("development")] {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_buzz-push-gateway"));
|
||||
command.env_clear().envs([
|
||||
(
|
||||
"BUZZ_PUSH_GRANT_KEYS",
|
||||
format!("grant:{}", STANDARD.encode([1; 32])),
|
||||
),
|
||||
(
|
||||
"BUZZ_PUSH_TOKEN_KEYS",
|
||||
format!("token:{}", STANDARD.encode([2; 32])),
|
||||
),
|
||||
("BUZZ_PUSH_GATEWAY_ORIGIN", "https://push.example".into()),
|
||||
("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "2592000".into()),
|
||||
("DATABASE_URL", "unused".into()),
|
||||
(
|
||||
"BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID",
|
||||
"TEAM.test.app".into(),
|
||||
),
|
||||
(
|
||||
"BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH",
|
||||
format!(
|
||||
"{}/tests/fixtures/apple-app-attestation-root.pem",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
),
|
||||
),
|
||||
// Startup stops immediately after configuring the real verifier.
|
||||
("BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH", "".into()),
|
||||
("BUZZ_PUSH_DOGFOOD_APNS_TOPIC", "test.app".into()),
|
||||
("RUST_LOG", "info".into()),
|
||||
]);
|
||||
// A directory cannot be read as an APNs certificate, on every platform.
|
||||
command.env(
|
||||
"BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH",
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
);
|
||||
if let Some(setting) = setting {
|
||||
command.env("BUZZ_PUSH_APP_ATTEST_ENVIRONMENT", setting);
|
||||
}
|
||||
let mut child = command
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while child.try_wait().unwrap().is_none() {
|
||||
if Instant::now() >= deadline {
|
||||
child.kill().unwrap();
|
||||
child.wait().unwrap();
|
||||
panic!("gateway startup did not terminate");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let output = child.wait_with_output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
if setting == Some("development") && !cfg!(feature = "personal-dev-app-attest") {
|
||||
assert!(
|
||||
stderr.contains("BUZZ_PUSH_APP_ATTEST_ENVIRONMENT"),
|
||||
"{stderr}"
|
||||
);
|
||||
assert!(!stdout.contains("App Attest verifier configured"));
|
||||
} else {
|
||||
let event = stdout
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
|
||||
.find(|event| event["fields"]["message"] == "App Attest verifier configured")
|
||||
.unwrap_or_else(|| panic!("verifier was not constructed: {stdout} {stderr}"));
|
||||
let expected = if setting == Some("development") {
|
||||
"Development"
|
||||
} else {
|
||||
"Production"
|
||||
};
|
||||
assert_eq!(event["fields"]["environment"], expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,32 @@ select among multiple application profiles.
|
||||
|
||||
Optional endpoint quota policy variables are `BUZZ_PUSH_ENDPOINT_QUOTA_WINDOW_SECONDS` (default `10`, max `86400`) and `BUZZ_PUSH_ENDPOINT_QUOTA_MAX_DELIVERIES` (default `10`, max `10000`). These are Buzz policy hypotheses, not Apple-published limits; tune under load while retaining a hard ceiling.
|
||||
|
||||
## Personal device development
|
||||
|
||||
A development-signed iOS app uses Apple's development App Attest environment.
|
||||
The ordinary gateway binary rejects those attestations. For an isolated personal
|
||||
stack, build with Cargo feature `personal-dev-app-attest` (Docker build argument
|
||||
`BUZZ_PUSH_CARGO_FEATURES=personal-dev-app-attest`) and set
|
||||
`BUZZ_PUSH_APP_ATTEST_ENVIRONMENT=development`. The default remains `production`,
|
||||
including in that special build; ordinary builds reject the development setting.
|
||||
The gateway accepts exactly the selected AAGUID and still verifies the pinned
|
||||
Apple root, certificate chain, nonce, application identity, public key,
|
||||
credential ID, and counter. This is not a simulator or attestation bypass.
|
||||
|
||||
Set `BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID` to the personal `TEAMID.bundle-id`,
|
||||
`BUZZ_PUSH_DOGFOOD_APNS_TOPIC` to that same bundle ID,
|
||||
`BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT=sandbox`, and supply its APNs certificate.
|
||||
This isolated stack reuses the single `buzz-ios-dogfood` wire profile for its
|
||||
server-owned personal identity; it does not add a production application profile.
|
||||
Do not point distributed dogfood clients at this personal gateway.
|
||||
|
||||
Build the mobile client with the personal team and parent bundle ID, its matching
|
||||
`.NotificationService` extension, development APNs/App Attest entitlements, and
|
||||
an explicit `BUZZ_PUSH_GATEWAY_URL`. See `mobile/README.md` for gitignored signing
|
||||
overrides. Both targets need matching provisioning profiles; the parent profile
|
||||
must include the capabilities in `Runner.entitlements`. Validate enrollment and
|
||||
notification presentation on a physical device, not a simulator.
|
||||
|
||||
## Secret and key rotation rules
|
||||
|
||||
Mount the App Attest root read-only and startup will reject any byte mismatch. The sole accepted artifact is Apple’s **Apple App Attestation Root CA** from `https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem`: certificate SHA-256 fingerprint `1C:B9:82:3B:A2:8B:A6:AD:2D:33:A0:06:94:1D:E2:AE:4F:51:3E:F1:D4:E8:31:B9:F7:E0:FA:7B:62:42:C9:32`; exact PEM-file SHA-256 `c778d09ac341f7fd9f8f3b19e2b815af6aed4ad4490e1e92c05cb355212a5013`. Treat an Apple root rotation as a reviewed code/config rollout, not an unpinned mount replacement. Mount the APNs certificate identity and both AEAD keyrings from a secret manager; never place values in an image, manifest, log, or metrics label. Keep the current AEAD key first and retain decrypt-only predecessors until every capability/token encrypted under them has expired or been re-encrypted. Grant and token key ids and bytes must be distinct. Rotation is an operator rollout: add the new current key while retaining predecessors, deploy, wait through the retention window, then remove the old key.
|
||||
|
||||
@@ -140,20 +140,22 @@ For local physical-device development, override the identity and sandbox
|
||||
environments in the gitignored `mobile/ios/Flutter/AppOverrides.xcconfig`:
|
||||
|
||||
```xcconfig
|
||||
BUNDLE_IDENTIFIER = xyz.block.buzz.mobile
|
||||
BUZZ_DEVELOPMENT_TEAM = EYF346PHUG
|
||||
BUNDLE_IDENTIFIER = com.example.buzz.mobile
|
||||
BUZZ_DEVELOPMENT_TEAM = YOUR_TEAM_ID
|
||||
BUZZ_IOS_PUSH_ENVIRONMENT = development
|
||||
BUZZ_APP_ATTEST_ENVIRONMENT = development
|
||||
BUZZ_PUSH_GATEWAY_URL = https:/$()/push.example
|
||||
```
|
||||
|
||||
This exercises the client, extension, relay, and gateway integration without
|
||||
requiring a dogfood development signing identity. It uses the canonical
|
||||
gateway's server-owned App Store profile configured for sandbox in the local
|
||||
development gateway; it does not validate the internally distributed dogfood
|
||||
artifact or enable the App Store profile in production. Validate dogfood APNs
|
||||
end to end by cutting an internal release, waiting for it to reach Mobile
|
||||
Releases/Comp Portal, and installing that signed artifact on a physical device.
|
||||
Use your personal bundle ID and team above. Provision both the parent and its
|
||||
`.NotificationService` extension. Configure an isolated gateway with the matching
|
||||
App Attest application ID, APNs topic and sandbox certificate. Development
|
||||
attestation requires the explicit `personal-dev-app-attest` gateway build feature
|
||||
and `BUZZ_PUSH_APP_ATTEST_ENVIRONMENT=development`; ordinary gateway builds accept
|
||||
production attestation only. See `docs/push-gateway-deployment.md`.
|
||||
This validates the personal client/relay/gateway integration, not the internally
|
||||
distributed dogfood artifact. Validate dogfood separately using the signed
|
||||
internal release and its production gateway configuration.
|
||||
|
||||
Parent app identifiers require Apple's Communication
|
||||
Notifications capability and a regenerated app provisioning profile. The
|
||||
|
||||
@@ -120,6 +120,8 @@ run_unit_tests() {
|
||||
|
||||
run_test_step "buzz-push-gateway tests" \
|
||||
cargo test -p buzz-push-gateway -- --nocapture
|
||||
run_test_step "buzz-push-gateway personal development tests" \
|
||||
cargo test -p buzz-push-gateway --features personal-dev-app-attest -- --nocapture
|
||||
|
||||
# Kubernetes backend provider: pure decision layers driven by a fake
|
||||
# substrate, no cluster. Mirrors the nextest path in `just test-unit` —
|
||||
|
||||
Reference in New Issue
Block a user