diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dc3b41d..f74afda 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -86,6 +86,9 @@ jobs:
- name: Measure coverage
id: coverage
shell: bash
+ env:
+ # Keep report text free of ANSI so TOTAL extraction is reliable.
+ CARGO_TERM_COLOR: never
run: |
set +e
cargo llvm-cov --all-targets --all-features --no-report
@@ -99,7 +102,18 @@ jobs:
cargo llvm-cov report --lcov --output-path lcov.info
cargo llvm-cov report --text --summary-only > coverage-summary.txt
- grep -E '^(Filename|TOTAL|-{5,})' coverage-summary.txt > coverage-table.txt || cp coverage-summary.txt coverage-table.txt
+
+ # PR comments are capped at 65 KiB. Keep only the TOTAL line(s) —
+ # never fall back to the full per-file summary (that is what blew
+ # up once ANSI-colored headers stopped matching the Filename grep).
+ {
+ grep -E '^Filename' coverage-summary.txt | head -n 1 || true
+ grep -E '^-{5,}' coverage-summary.txt | head -n 1 || true
+ grep -E '^TOTAL' coverage-summary.txt || true
+ } > coverage-table.txt
+ if [[ ! -s coverage-table.txt ]]; then
+ tail -n 5 coverage-summary.txt > coverage-table.txt
+ fi
set +e
cargo llvm-cov report \
@@ -125,18 +139,14 @@ jobs:
echo
echo "${status}"
echo
- echo ""
- echo "Summary
"
- echo
echo '```'
cat coverage-table.txt
echo '```'
- echo
- echo " "
} > coverage-comment.md
- name: Post coverage comment
if: github.event_name == 'pull_request'
+ continue-on-error: true
uses: marocchino/sticky-pull-request-comment@v2
with:
header: coverage
diff --git a/.gitignore b/.gitignore
index 0c1c2f6..12ff7c0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,9 @@ secrets/
config/local.toml
config/*.local.toml
+# Public Apple App Attest root CA bundled for local verification
+!config/apple_app_attestation_root_ca.pem
+
# OS / editor
.DS_Store
*.swp
diff --git a/Cargo.lock b/Cargo.lock
index 8a2f83c..09f61f6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -26,6 +26,45 @@ dependencies = [
"rustversion",
]
+[[package]]
+name = "asn1-rs"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
+dependencies = [
+ "asn1-rs-derive",
+ "asn1-rs-impl",
+ "displaydoc",
+ "nom",
+ "num-traits",
+ "rusticata-macros",
+ "thiserror",
+ "time",
+]
+
+[[package]]
+name = "asn1-rs-derive"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "asn1-rs-impl"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -228,6 +267,33 @@ dependencies = [
"rand_core 0.10.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 = "cmake"
version = "0.1.58"
@@ -281,6 +347,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
[[package]]
name = "crypto-bigint"
version = "0.5.5"
@@ -344,6 +416,12 @@ dependencies = [
"parking_lot_core",
]
+[[package]]
+name = "data-encoding"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
+
[[package]]
name = "der"
version = "0.7.10"
@@ -351,9 +429,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
+ "pem-rfc7468",
"zeroize",
]
+[[package]]
+name = "der-parser"
+version = "10.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
+dependencies = [
+ "asn1-rs",
+ "displaydoc",
+ "nom",
+ "num-bigint",
+ "num-traits",
+ "rusticata-macros",
+]
+
[[package]]
name = "deranged"
version = "0.5.8"
@@ -449,6 +542,7 @@ dependencies = [
"ff",
"generic-array",
"group",
+ "pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
"sec1",
@@ -686,6 +780,17 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "half"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
+dependencies = [
+ "cfg-if",
+ "crunchy",
+ "zerocopy",
+]
+
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -1126,6 +1231,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
[[package]]
name = "mio"
version = "1.2.1"
@@ -1137,6 +1248,16 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -1180,12 +1301,33 @@ dependencies = [
"autocfg",
]
+[[package]]
+name = "oid-registry"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
+dependencies = [
+ "asn1-rs",
+]
+
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[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 = "parking_lot"
version = "0.12.5"
@@ -1242,6 +1384,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 = "percent-encoding"
version = "2.3.2"
@@ -1298,6 +1449,15 @@ dependencies = [
"syn",
]
+[[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"
@@ -1601,6 +1761,15 @@ dependencies = [
"semver",
]
+[[package]]
+name = "rusticata-macros"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
+dependencies = [
+ "nom",
+]
+
[[package]]
name = "rustls"
version = "0.23.40"
@@ -1684,6 +1853,16 @@ dependencies = [
"serde_derive",
]
+[[package]]
+name = "serde_bytes"
+version = "0.11.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
[[package]]
name = "serde_core"
version = "1.0.228"
@@ -2253,6 +2432,7 @@ dependencies = [
"async-trait",
"axum",
"base64",
+ "ciborium",
"dashmap",
"ed25519-dalek",
"figment",
@@ -2260,10 +2440,12 @@ dependencies = [
"http-body-util",
"jsonwebtoken",
"k256",
+ "p256",
"rand 0.8.6",
"redis",
"reqwest",
"serde",
+ "serde_bytes",
"serde_json",
"sha2",
"sha3",
@@ -2278,6 +2460,7 @@ dependencies = [
"trust-auth",
"url",
"uuid",
+ "x509-parser",
]
[[package]]
@@ -2714,6 +2897,24 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+[[package]]
+name = "x509-parser"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
+dependencies = [
+ "asn1-rs",
+ "data-encoding",
+ "der-parser",
+ "lazy_static",
+ "nom",
+ "oid-registry",
+ "ring",
+ "rusticata-macros",
+ "thiserror",
+ "time",
+]
+
[[package]]
name = "yansi"
version = "1.0.1"
diff --git a/Cargo.toml b/Cargo.toml
index 7470113..0011d40 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -61,6 +61,12 @@ sha2 = "0.10"
hex = "0.4"
url = "2"
+# Platform attestation — Apple App Attest CBOR, X.509 chain, and P-256 assertions
+ciborium = "0.2.2"
+serde_bytes = "0.11.19"
+x509-parser = { version = "0.18.1", features = ["verify"] }
+p256 = { version = "0.13", features = ["ecdsa"] }
+
[dev-dependencies]
signinwithethereum = "0.8"
http-body-util = "0.1"
diff --git a/config/apple_app_attestation_root_ca.pem b/config/apple_app_attestation_root_ca.pem
new file mode 100644
index 0000000..4cff227
--- /dev/null
+++ b/config/apple_app_attestation_root_ca.pem
@@ -0,0 +1,14 @@
+-----BEGIN CERTIFICATE-----
+MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw
+JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK
+QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa
+Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv
+biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y
+bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh
+NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au
+Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/
+MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw
+CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn
+53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV
+oyFraWVIyd/dganmrduC1bmTBGwD
+-----END CERTIFICATE-----
diff --git a/config/default.toml b/config/default.toml
index 5351f5c..bb3900f 100644
--- a/config/default.toml
+++ b/config/default.toml
@@ -37,6 +37,8 @@ challenge_ttl_secs = 600
attestation_ttl_secs = 604800
reproof_interval_secs = 86400
grace_secs = 86400
+# Empty uses the bundled Apple App Attestation Root CA.
+apple_root_ca_pem = ""
[attestation.android]
attestation_ttl_secs = 604800
diff --git a/src/bootstrap.rs b/src/bootstrap.rs
index 4a75e53..6fa1c2c 100644
--- a/src/bootstrap.rs
+++ b/src/bootstrap.rs
@@ -56,8 +56,11 @@ pub async fn build_attestation_store(
pub fn build_attestation_service(
config: &Config,
store: Arc,
-) -> Arc {
- Arc::new(AttestationService::new(store, config.attestation.clone()))
+) -> Result, AppError> {
+ Ok(Arc::new(AttestationService::new(
+ store,
+ config.attestation.clone(),
+ )?))
}
pub fn build_token_service(config: &Config) -> Result, AppError> {
diff --git a/src/config.rs b/src/config.rs
index 1b730f7..d65ab37 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -189,6 +189,9 @@ pub struct IosAttestationConfig {
pub reproof_interval_secs: u32,
#[serde(default = "default_attestation_grace_secs")]
pub grace_secs: u32,
+ /// Optional PEM override for the bundled Apple App Attestation Root CA.
+ #[serde(default)]
+ pub apple_root_ca_pem: String,
#[serde(default)]
pub apps: Vec,
}
@@ -199,6 +202,7 @@ impl Default for IosAttestationConfig {
attestation_ttl_secs: default_attestation_ttl_secs(),
reproof_interval_secs: default_ios_reproof_interval_secs(),
grace_secs: default_attestation_grace_secs(),
+ apple_root_ca_pem: String::new(),
apps: Vec::new(),
}
}
diff --git a/src/routes/attest.rs b/src/routes/attest.rs
index 0d3d5da..29f411a 100644
--- a/src/routes/attest.rs
+++ b/src/routes/attest.rs
@@ -4,7 +4,12 @@ use axum::{extract::State, http::HeaderMap, Json};
use serde::{Deserialize, Serialize};
use time::format_description::well_known::Rfc3339;
-use crate::{error::AppError, models::Platform, state::AppState};
+use crate::{
+ error::AppError,
+ models::{AttestationLevel, Platform},
+ state::AppState,
+ store::attestation::AttestationRecord,
+};
use super::auth::verified_claims;
@@ -21,6 +26,42 @@ pub struct ChallengeResponse {
pub expires_at: String,
}
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct IosAttestationRequest {
+ pub key_id: String,
+ pub attestation: String,
+ pub challenge: String,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct AttestationRefreshRequest {
+ pub platform: Platform,
+ pub key_id: String,
+ pub assertion: String,
+ pub challenge: String,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct AttestationResponse {
+ pub access_token: String,
+ pub token_type: &'static str,
+ pub expires_in: u32,
+ pub attestation: AttestationSummary,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct AttestationSummary {
+ pub platform: Platform,
+ pub app: String,
+ pub level: AttestationLevel,
+ pub attested_at: i64,
+ pub expires_at: i64,
+}
+
/// Issue a single-use challenge bound to the bearer wallet, platform, and app.
pub async fn post_challenge(
State(state): State,
@@ -42,3 +83,69 @@ pub async fn post_challenge(
expires_at,
}))
}
+
+/// Verify an initial Apple App Attest object and upgrade the caller's token.
+pub async fn post_ios_attestation(
+ State(state): State,
+ headers: HeaderMap,
+ Json(body): Json,
+) -> Result, AppError> {
+ let claims = verified_claims(&state, &headers)?;
+ state.revocation.ensure_wallet_active(&claims.sub).await?;
+ let record = state
+ .attestation
+ .verify_ios_attestation(
+ &claims.sub,
+ &body.challenge,
+ &body.key_id,
+ &body.attestation,
+ )
+ .await?;
+ attestation_response(&state, &claims, record)
+}
+
+/// Re-prove iOS key possession using a counter-protected App Attest assertion.
+pub async fn post_attestation_refresh(
+ State(state): State,
+ headers: HeaderMap,
+ Json(body): Json,
+) -> Result, AppError> {
+ let claims = verified_claims(&state, &headers)?;
+ state.revocation.ensure_wallet_active(&claims.sub).await?;
+ if body.platform != Platform::Ios {
+ return Err(AppError::InvalidRequest(
+ "platform refresh is not implemented".into(),
+ ));
+ }
+ let record = state
+ .attestation
+ .verify_ios_assertion(&claims.sub, &body.challenge, &body.key_id, &body.assertion)
+ .await?;
+ attestation_response(&state, &claims, record)
+}
+
+fn attestation_response(
+ state: &AppState,
+ claims: &crate::models::AccessTokenClaims,
+ record: AttestationRecord,
+) -> Result, AppError> {
+ let scopes: Vec<&str> = claims.scope.split_whitespace().collect();
+ let access_token = state.token.mint_access_token(
+ &claims.sub,
+ &scopes,
+ claims.chain_id.unwrap_or(state.config.auth.chain_id),
+ Some(record.claim()),
+ )?;
+ Ok(Json(AttestationResponse {
+ access_token,
+ token_type: "Bearer",
+ expires_in: state.config.signing.access_token_ttl_secs,
+ attestation: AttestationSummary {
+ platform: record.platform,
+ app: record.app,
+ level: record.level,
+ attested_at: record.attested_at,
+ expires_at: record.expires_at,
+ },
+ }))
+}
diff --git a/src/routes/mod.rs b/src/routes/mod.rs
index d886a36..67d0721 100644
--- a/src/routes/mod.rs
+++ b/src/routes/mod.rs
@@ -30,6 +30,8 @@ fn wallet_auth_routes(state: AppState) -> Router {
.route("/v1/auth/logout", post(auth::post_logout))
.route("/v1/auth/quota", get(auth::get_quota))
.route("/v1/attest/challenge", post(attest::post_challenge))
+ .route("/v1/attest/ios", post(attest::post_ios_attestation))
+ .route("/v1/attest/refresh", post(attest::post_attestation_refresh))
.with_state(state)
.layer(RequestBodyLimitLayer::new(MAX_BODY_BYTES))
}
diff --git a/src/services/attestation.rs b/src/services/attestation.rs
index 327810c..1707178 100644
--- a/src/services/attestation.rs
+++ b/src/services/attestation.rs
@@ -1,5 +1,7 @@
//! Platform-attestation challenge issuance and wallet-state lookup.
+pub mod ios;
+
use std::sync::Arc;
use base64::Engine;
@@ -8,15 +10,18 @@ use sha2::{Digest, Sha256};
use time::OffsetDateTime;
use crate::{
- config::AttestationConfig,
+ config::{AttestationConfig, IosAppConfig},
error::AppError,
- models::{AttestationClaim, Platform},
+ models::{AttestationClaim, AttestationLevel, Platform},
store::attestation::{AttestationChallenge, AttestationRecord, AttestationStore},
};
+use self::ios::IosAttestationVerifier;
+
pub struct AttestationService {
store: Arc,
config: AttestationConfig,
+ ios_verifier: IosAttestationVerifier,
}
#[derive(Debug, Clone)]
@@ -26,8 +31,17 @@ pub struct IssuedAttestationChallenge {
}
impl AttestationService {
- pub fn new(store: Arc, config: AttestationConfig) -> Self {
- Self { store, config }
+ pub fn new(
+ store: Arc,
+ config: AttestationConfig,
+ ) -> Result {
+ let ios_verifier = IosAttestationVerifier::new(&config.ios.apple_root_ca_pem)
+ .map_err(|error| AppError::Internal(error.to_string()))?;
+ Ok(Self {
+ store,
+ config,
+ ios_verifier,
+ })
}
pub async fn issue_challenge(
@@ -68,9 +82,7 @@ impl AttestationService {
challenge: &str,
wallet: &str,
platform: Platform,
- app: &str,
) -> Result {
- self.ensure_trusted_app(platform, app)?;
let hash = challenge_hash(challenge);
let record = self.store.take_challenge(&hash).await?.ok_or_else(|| {
AppError::InvalidRequest("attestation challenge invalid or used".into())
@@ -80,14 +92,162 @@ impl AttestationService {
"attestation challenge expired".into(),
));
}
- if record.sub != normalize_wallet(wallet)?
- || record.platform != platform
- || record.app != app
- {
+ if record.sub != normalize_wallet(wallet)? || record.platform != platform {
return Err(AppError::InvalidRequest(
"attestation challenge binding mismatch".into(),
));
}
+ self.ensure_trusted_app(record.platform, &record.app)?;
+ Ok(record)
+ }
+
+ pub async fn verify_ios_attestation(
+ &self,
+ wallet: &str,
+ challenge: &str,
+ key_id: &str,
+ attestation: &str,
+ ) -> Result {
+ let wallet = normalize_wallet(wallet)?;
+ let key_id_bytes = decode_base64(key_id, "keyId")?;
+ if key_id_bytes.len() != 32 {
+ return Err(AppError::InvalidRequest(
+ "keyId must decode to 32 bytes".into(),
+ ));
+ }
+ let canonical_key_id = base64::engine::general_purpose::STANDARD.encode(&key_id_bytes);
+ let attestation_bytes = decode_base64(attestation, "attestation")?;
+ let evidence_sha256 = hex::encode(Sha256::digest(&attestation_bytes));
+ let request_challenge_sha256 = challenge_hash(challenge);
+ let now = OffsetDateTime::now_utc().unix_timestamp();
+
+ if let Some(existing) = self.store.get_wallet_record(&wallet).await? {
+ let same_key = existing.ios_key_id.as_deref() == Some(canonical_key_id.as_str());
+ if existing.platform == Platform::Ios
+ && same_key
+ && existing.evidence_sha256 == evidence_sha256
+ && existing.challenge_sha256.as_deref() == Some(request_challenge_sha256.as_str())
+ && now.saturating_sub(existing.attested_at)
+ <= i64::from(self.config.challenge_ttl_secs)
+ && existing.grace_expires_at > now
+ {
+ return Ok(existing);
+ }
+ }
+
+ let challenge_record = self
+ .consume_challenge(challenge, &wallet, Platform::Ios)
+ .await?;
+ let app = self.ios_app(&challenge_record.app)?;
+ if let Some(owner) = self.store.get_ios_key_owner(&canonical_key_id).await? {
+ if owner != wallet {
+ return Err(AppError::InvalidRequest(
+ "App Attest key is already associated with another wallet".into(),
+ ));
+ }
+ }
+
+ let client_data_hash = Sha256::digest(challenge.as_bytes());
+ let verified = self
+ .ios_verifier
+ .verify_attestation(
+ &attestation_bytes,
+ &key_id_bytes,
+ &client_data_hash,
+ &challenge_record.app,
+ app.environment,
+ now,
+ )
+ .map_err(|error| AppError::InvalidRequest(error.to_string()))?;
+ let expires_at = now + i64::from(self.config.ios.attestation_ttl_secs);
+ let record = AttestationRecord {
+ platform: Platform::Ios,
+ app: challenge_record.app,
+ level: AttestationLevel::Strong,
+ attested_at: now,
+ expires_at,
+ grace_expires_at: expires_at + i64::from(self.config.ios.grace_secs),
+ ios_key_id: Some(canonical_key_id),
+ ios_public_key: Some(
+ base64::engine::general_purpose::STANDARD.encode(verified.public_key_sec1),
+ ),
+ ios_counter: Some(0),
+ evidence_sha256,
+ challenge_sha256: Some(request_challenge_sha256),
+ };
+ self.put_verified_record(&wallet, &record).await?;
+ Ok(record)
+ }
+
+ pub async fn verify_ios_assertion(
+ &self,
+ wallet: &str,
+ challenge: &str,
+ key_id: &str,
+ assertion: &str,
+ ) -> Result {
+ let wallet = normalize_wallet(wallet)?;
+ let key_id_bytes = decode_base64(key_id, "keyId")?;
+ if key_id_bytes.len() != 32 {
+ return Err(AppError::InvalidRequest(
+ "keyId must decode to 32 bytes".into(),
+ ));
+ }
+ let canonical_key_id = base64::engine::general_purpose::STANDARD.encode(key_id_bytes);
+ let assertion_bytes = decode_base64(assertion, "assertion")?;
+ let challenge_record = self
+ .consume_challenge(challenge, &wallet, Platform::Ios)
+ .await?;
+ let mut record = self
+ .store
+ .get_wallet_record(&wallet)
+ .await?
+ .ok_or_else(|| AppError::InvalidRequest("no iOS attestation record".into()))?;
+ if record.platform != Platform::Ios
+ || record.app != challenge_record.app
+ || record.ios_key_id.as_deref() != Some(canonical_key_id.as_str())
+ {
+ return Err(AppError::InvalidRequest(
+ "iOS assertion does not match the attestation record".into(),
+ ));
+ }
+ let public_key = record
+ .ios_public_key
+ .as_deref()
+ .ok_or_else(|| AppError::Internal("iOS attestation public key is missing".into()))
+ .and_then(|value| decode_base64(value, "stored iOS public key"))?;
+ let previous_counter = record
+ .ios_counter
+ .ok_or_else(|| AppError::Internal("iOS attestation counter is missing".into()))?;
+ let counter = self
+ .ios_verifier
+ .verify_assertion(
+ &assertion_bytes,
+ &public_key,
+ &Sha256::digest(challenge.as_bytes()),
+ &record.app,
+ previous_counter,
+ )
+ .map_err(|error| AppError::InvalidRequest(error.to_string()))?;
+
+ let now = OffsetDateTime::now_utc().unix_timestamp();
+ let expires_at = now + i64::from(self.config.ios.attestation_ttl_secs);
+ record.attested_at = now;
+ record.expires_at = expires_at;
+ record.grace_expires_at = expires_at + i64::from(self.config.ios.grace_secs);
+ record.ios_counter = Some(counter);
+ record.evidence_sha256 = hex::encode(Sha256::digest(&assertion_bytes));
+ record.challenge_sha256 = Some(challenge_hash(challenge));
+ let ttl_secs = record.grace_expires_at.saturating_sub(now).max(1) as u64;
+ let updated = self
+ .store
+ .put_ios_assertion_record(&wallet, previous_counter, &record, ttl_secs)
+ .await?;
+ if !updated {
+ return Err(AppError::InvalidRequest(
+ "assertion counter did not increase".into(),
+ ));
+ }
Ok(record)
}
@@ -98,8 +258,9 @@ impl AttestationService {
}
let wallet = normalize_wallet(wallet)?;
let record = self.store.get_wallet_record(&wallet).await?;
+ let now = OffsetDateTime::now_utc().unix_timestamp();
Ok(record
- .filter(|record| record.grace_expires_at > OffsetDateTime::now_utc().unix_timestamp())
+ .filter(|record| record.grace_expires_at > now)
.map(|record| record.claim()))
}
@@ -149,12 +310,33 @@ impl AttestationService {
))
}
}
+
+ fn ios_app(&self, app_id: &str) -> Result<&IosAppConfig, AppError> {
+ self.config
+ .ios
+ .apps
+ .iter()
+ .find(|app| format!("{}.{}", app.team_id, app.bundle_id) == app_id)
+ .ok_or_else(|| {
+ AppError::InvalidRequest("app is not configured for iOS attestation".into())
+ })
+ }
}
fn challenge_hash(challenge: &str) -> String {
hex::encode(Sha256::digest(challenge.as_bytes()))
}
+fn decode_base64(value: &str, field: &str) -> Result, AppError> {
+ [
+ &base64::engine::general_purpose::STANDARD,
+ &base64::engine::general_purpose::URL_SAFE_NO_PAD,
+ ]
+ .into_iter()
+ .find_map(|engine| engine.decode(value.trim()).ok())
+ .ok_or_else(|| AppError::InvalidRequest(format!("{field} must be valid base64")))
+}
+
fn normalize_wallet(wallet: &str) -> Result {
let wallet = wallet.trim().to_ascii_lowercase();
if wallet.len() != 42
@@ -191,7 +373,7 @@ mod tests {
},
..AttestationConfig::default()
};
- AttestationService::new(Arc::new(InMemoryAttestationStore::default()), config)
+ AttestationService::new(Arc::new(InMemoryAttestationStore::default()), config).unwrap()
}
#[tokio::test]
@@ -203,21 +385,11 @@ mod tests {
.await
.unwrap();
service
- .consume_challenge(
- &issued.challenge,
- wallet,
- Platform::Android,
- "io.nodle.cash",
- )
+ .consume_challenge(&issued.challenge, wallet, Platform::Android)
.await
.unwrap();
assert!(service
- .consume_challenge(
- &issued.challenge,
- wallet,
- Platform::Android,
- "io.nodle.cash",
- )
+ .consume_challenge(&issued.challenge, wallet, Platform::Android,)
.await
.is_err());
}
diff --git a/src/services/attestation/ios.rs b/src/services/attestation/ios.rs
new file mode 100644
index 0000000..cb14fb0
--- /dev/null
+++ b/src/services/attestation/ios.rs
@@ -0,0 +1,489 @@
+//! Apple App Attest object and assertion verification.
+
+use ciborium::de::from_reader;
+use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey};
+use serde::Deserialize;
+use serde_bytes::ByteBuf;
+use sha2::{Digest, Sha256};
+use thiserror::Error;
+use x509_parser::{
+ parse_x509_certificate,
+ pem::parse_x509_pem,
+ prelude::{ASN1Time, X509Certificate},
+};
+
+use crate::config::IosEnvironment;
+
+const APPLE_NONCE_OID: &str = "1.2.840.113635.100.8.2";
+const PRODUCTION_AAGUID: &[u8; 16] = b"appattest\0\0\0\0\0\0\0";
+const DEVELOPMENT_AAGUID: &[u8; 16] = b"appattestdevelop";
+const BUNDLED_APPLE_ROOT_CA_PEM: &str =
+ include_str!("../../../config/apple_app_attestation_root_ca.pem");
+
+#[derive(Debug, Error)]
+pub enum IosVerificationError {
+ #[error("invalid attestation object")]
+ InvalidAttestationObject,
+ #[error("unexpected attestation format")]
+ UnexpectedFormat,
+ #[error("invalid Apple certificate chain")]
+ InvalidCertificateChain,
+ #[error("Apple certificate is not valid at the verification time")]
+ InvalidCertificateTime,
+ #[error("invalid App Attest credential certificate")]
+ InvalidCredentialCertificate,
+ #[error("attestation nonce does not match the challenge")]
+ NonceMismatch,
+ #[error("attestation key ID does not match the credential")]
+ KeyIdMismatch,
+ #[error("attestation app identity does not match")]
+ AppMismatch,
+ #[error("invalid initial attestation counter")]
+ InvalidInitialCounter,
+ #[error("attestation environment does not match")]
+ EnvironmentMismatch,
+ #[error("invalid assertion object")]
+ InvalidAssertionObject,
+ #[error("assertion counter did not increase")]
+ CounterDidNotIncrease,
+ #[error("invalid assertion signature")]
+ InvalidAssertionSignature,
+ #[error("invalid Apple root certificate")]
+ InvalidRootCertificate,
+}
+
+#[derive(Debug)]
+pub struct VerifiedIosAttestation {
+ pub public_key_sec1: Vec,
+ pub receipt: Vec,
+}
+
+pub struct IosAttestationVerifier {
+ root_der: Vec,
+}
+
+impl IosAttestationVerifier {
+ pub fn new(root_ca_pem: &str) -> Result {
+ let pem = if root_ca_pem.trim().is_empty() {
+ BUNDLED_APPLE_ROOT_CA_PEM.as_bytes()
+ } else {
+ root_ca_pem.as_bytes()
+ };
+ let (remaining, root) =
+ parse_x509_pem(pem).map_err(|_| IosVerificationError::InvalidRootCertificate)?;
+ if !remaining.iter().all(u8::is_ascii_whitespace) {
+ return Err(IosVerificationError::InvalidRootCertificate);
+ }
+ let (_, certificate) = parse_x509_certificate(&root.contents)
+ .map_err(|_| IosVerificationError::InvalidRootCertificate)?;
+ validate_root(&certificate)?;
+ Ok(Self {
+ root_der: root.contents,
+ })
+ }
+
+ pub fn verify_attestation(
+ &self,
+ attestation_object: &[u8],
+ key_id: &[u8],
+ client_data_hash: &[u8],
+ app_id: &str,
+ environment: IosEnvironment,
+ now_unix: i64,
+ ) -> Result {
+ let object: AttestationObject = from_reader(attestation_object)
+ .map_err(|_| IosVerificationError::InvalidAttestationObject)?;
+ if object.format != "apple-appattest" || object.statement.certificates.len() != 2 {
+ return Err(IosVerificationError::UnexpectedFormat);
+ }
+
+ let leaf_der = object.statement.certificates[0].as_ref();
+ let intermediate_der = object.statement.certificates[1].as_ref();
+ let (remaining, leaf) = parse_x509_certificate(leaf_der)
+ .map_err(|_| IosVerificationError::InvalidCredentialCertificate)?;
+ if !remaining.is_empty() {
+ return Err(IosVerificationError::InvalidCredentialCertificate);
+ }
+ let (remaining, intermediate) = parse_x509_certificate(intermediate_der)
+ .map_err(|_| IosVerificationError::InvalidCertificateChain)?;
+ if !remaining.is_empty() {
+ return Err(IosVerificationError::InvalidCertificateChain);
+ }
+ let (_, root) = parse_x509_certificate(&self.root_der)
+ .map_err(|_| IosVerificationError::InvalidRootCertificate)?;
+
+ verify_certificate_chain(&leaf, &intermediate, &root, now_unix)?;
+
+ let public_key = leaf.public_key().subject_public_key.data.as_ref();
+ if public_key.len() != 65
+ || public_key.first() != Some(&0x04)
+ || VerifyingKey::from_sec1_bytes(public_key).is_err()
+ {
+ return Err(IosVerificationError::InvalidCredentialCertificate);
+ }
+
+ let auth_data = object.authenticator_data.as_ref();
+ let parsed = parse_attestation_auth_data(auth_data)?;
+ let expected_key_id = Sha256::digest(public_key);
+ if key_id != expected_key_id.as_slice()
+ || parsed.credential_id != key_id
+ || parsed.credential_id != expected_key_id.as_slice()
+ {
+ return Err(IosVerificationError::KeyIdMismatch);
+ }
+
+ let expected_rp_id = Sha256::digest(app_id.as_bytes());
+ if parsed.rp_id_hash != expected_rp_id.as_slice() {
+ return Err(IosVerificationError::AppMismatch);
+ }
+ if parsed.counter != 0 {
+ return Err(IosVerificationError::InvalidInitialCounter);
+ }
+ let expected_aaguid = match environment {
+ IosEnvironment::Production => PRODUCTION_AAGUID,
+ IosEnvironment::Development => DEVELOPMENT_AAGUID,
+ };
+ if parsed.aaguid != expected_aaguid {
+ return Err(IosVerificationError::EnvironmentMismatch);
+ }
+
+ let mut nonce_input = Vec::with_capacity(auth_data.len() + client_data_hash.len());
+ nonce_input.extend_from_slice(auth_data);
+ nonce_input.extend_from_slice(client_data_hash);
+ let expected_nonce = Sha256::digest(&nonce_input);
+ let certificate_nonce = certificate_nonce(&leaf)?;
+ if certificate_nonce != expected_nonce.as_slice() {
+ return Err(IosVerificationError::NonceMismatch);
+ }
+
+ Ok(VerifiedIosAttestation {
+ public_key_sec1: public_key.to_vec(),
+ receipt: object.statement.receipt.into_vec(),
+ })
+ }
+
+ pub fn verify_assertion(
+ &self,
+ assertion_object: &[u8],
+ public_key_sec1: &[u8],
+ client_data_hash: &[u8],
+ app_id: &str,
+ previous_counter: u32,
+ ) -> Result {
+ let assertion: AssertionObject = from_reader(assertion_object)
+ .map_err(|_| IosVerificationError::InvalidAssertionObject)?;
+ let auth_data = assertion.authenticator_data.as_ref();
+ if auth_data.len() != 37 {
+ return Err(IosVerificationError::InvalidAssertionObject);
+ }
+
+ let expected_rp_id = Sha256::digest(app_id.as_bytes());
+ if &auth_data[..32] != expected_rp_id.as_slice() {
+ return Err(IosVerificationError::AppMismatch);
+ }
+ let counter = u32::from_be_bytes(
+ auth_data[33..37]
+ .try_into()
+ .map_err(|_| IosVerificationError::InvalidAssertionObject)?,
+ );
+ if counter <= previous_counter {
+ return Err(IosVerificationError::CounterDidNotIncrease);
+ }
+
+ let verifying_key = VerifyingKey::from_sec1_bytes(public_key_sec1)
+ .map_err(|_| IosVerificationError::InvalidCredentialCertificate)?;
+ let signature = Signature::from_der(assertion.signature.as_ref())
+ .map_err(|_| IosVerificationError::InvalidAssertionObject)?;
+ let mut signed_data = Vec::with_capacity(auth_data.len() + client_data_hash.len());
+ signed_data.extend_from_slice(auth_data);
+ signed_data.extend_from_slice(client_data_hash);
+ verifying_key
+ .verify(&signed_data, &signature)
+ .map_err(|_| IosVerificationError::InvalidAssertionSignature)?;
+ Ok(counter)
+ }
+}
+
+#[derive(Deserialize)]
+struct AttestationObject {
+ #[serde(rename = "fmt")]
+ format: String,
+ #[serde(rename = "attStmt")]
+ statement: AttestationStatement,
+ #[serde(rename = "authData")]
+ authenticator_data: ByteBuf,
+}
+
+#[derive(Deserialize)]
+struct AttestationStatement {
+ #[serde(rename = "x5c")]
+ certificates: Vec,
+ receipt: ByteBuf,
+}
+
+#[derive(Deserialize)]
+struct AssertionObject {
+ signature: ByteBuf,
+ #[serde(rename = "authenticatorData")]
+ authenticator_data: ByteBuf,
+}
+
+struct ParsedAttestationAuthData<'a> {
+ rp_id_hash: &'a [u8],
+ counter: u32,
+ aaguid: &'a [u8],
+ credential_id: &'a [u8],
+}
+
+fn parse_attestation_auth_data(
+ auth_data: &[u8],
+) -> Result, IosVerificationError> {
+ if auth_data.len() < 55 {
+ return Err(IosVerificationError::InvalidAttestationObject);
+ }
+ let counter = u32::from_be_bytes(
+ auth_data[33..37]
+ .try_into()
+ .map_err(|_| IosVerificationError::InvalidAttestationObject)?,
+ );
+ let credential_length = usize::from(u16::from_be_bytes(
+ auth_data[53..55]
+ .try_into()
+ .map_err(|_| IosVerificationError::InvalidAttestationObject)?,
+ ));
+ let credential_end = 55usize
+ .checked_add(credential_length)
+ .filter(|end| *end <= auth_data.len())
+ .ok_or(IosVerificationError::InvalidAttestationObject)?;
+ Ok(ParsedAttestationAuthData {
+ rp_id_hash: &auth_data[..32],
+ counter,
+ aaguid: &auth_data[37..53],
+ credential_id: &auth_data[55..credential_end],
+ })
+}
+
+fn verify_certificate_chain(
+ leaf: &X509Certificate<'_>,
+ intermediate: &X509Certificate<'_>,
+ root: &X509Certificate<'_>,
+ now_unix: i64,
+) -> Result<(), IosVerificationError> {
+ if leaf.issuer() != intermediate.subject()
+ || intermediate.issuer() != root.subject()
+ || leaf.is_ca()
+ || !intermediate.is_ca()
+ || !root.is_ca()
+ {
+ return Err(IosVerificationError::InvalidCertificateChain);
+ }
+ let leaf_usage = leaf
+ .key_usage()
+ .map_err(|_| IosVerificationError::InvalidCertificateChain)?
+ .ok_or(IosVerificationError::InvalidCertificateChain)?;
+ let intermediate_usage = intermediate
+ .key_usage()
+ .map_err(|_| IosVerificationError::InvalidCertificateChain)?
+ .ok_or(IosVerificationError::InvalidCertificateChain)?;
+ if !leaf_usage.value.digital_signature() || !intermediate_usage.value.key_cert_sign() {
+ return Err(IosVerificationError::InvalidCertificateChain);
+ }
+
+ leaf.verify_signature(Some(intermediate.public_key()))
+ .map_err(|_| IosVerificationError::InvalidCertificateChain)?;
+ intermediate
+ .verify_signature(Some(root.public_key()))
+ .map_err(|_| IosVerificationError::InvalidCertificateChain)?;
+
+ let now = ASN1Time::from_timestamp(now_unix)
+ .map_err(|_| IosVerificationError::InvalidCertificateTime)?;
+ if !leaf.validity().is_valid_at(now)
+ || !intermediate.validity().is_valid_at(now)
+ || !root.validity().is_valid_at(now)
+ {
+ return Err(IosVerificationError::InvalidCertificateTime);
+ }
+ Ok(())
+}
+
+fn validate_root(root: &X509Certificate<'_>) -> Result<(), IosVerificationError> {
+ if root.subject() != root.issuer() || !root.is_ca() {
+ return Err(IosVerificationError::InvalidRootCertificate);
+ }
+ let usage = root
+ .key_usage()
+ .map_err(|_| IosVerificationError::InvalidRootCertificate)?
+ .ok_or(IosVerificationError::InvalidRootCertificate)?;
+ if !usage.value.key_cert_sign() {
+ return Err(IosVerificationError::InvalidRootCertificate);
+ }
+ root.verify_signature(None)
+ .map_err(|_| IosVerificationError::InvalidRootCertificate)
+}
+
+fn certificate_nonce<'a>(
+ certificate: &'a X509Certificate<'_>,
+) -> Result<&'a [u8], IosVerificationError> {
+ let extension = certificate
+ .extensions()
+ .iter()
+ .find(|extension| extension.oid.to_id_string() == APPLE_NONCE_OID)
+ .ok_or(IosVerificationError::InvalidCredentialCertificate)?;
+ const PREFIX: &[u8] = &[0x30, 0x24, 0xa1, 0x22, 0x04, 0x20];
+ if extension.value.len() != PREFIX.len() + 32 || !extension.value.starts_with(PREFIX) {
+ return Err(IosVerificationError::InvalidCredentialCertificate);
+ }
+ Ok(&extension.value[PREFIX.len()..])
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use base64::Engine;
+ use p256::ecdsa::{signature::Signer, SigningKey};
+ use serde::Serialize;
+ use time::OffsetDateTime;
+
+ const APPLE_FIXTURE: &[u8] =
+ include_bytes!("../../../tests/fixtures/apple_app_attest/attestation.cbor");
+ const APP_ID: &str = "1234567890.com.example.myapp";
+ const CHALLENGE: &[u8] = b"example_server_challenge";
+ const KEY_ID: &str = "zgSY9YSD+7TaDXssY6WlOPVS1K3Lmk+pFhlcSWE+ZV0=";
+ const FIXTURE_TIME: i64 = 1_776_795_192;
+
+ #[test]
+ fn verifies_apple_attestation_fixture() {
+ let verifier = IosAttestationVerifier::new("").unwrap();
+ let key_id = base64::engine::general_purpose::STANDARD
+ .decode(KEY_ID)
+ .unwrap();
+ let verified = verifier
+ .verify_attestation(
+ APPLE_FIXTURE,
+ &key_id,
+ CHALLENGE,
+ APP_ID,
+ IosEnvironment::Production,
+ FIXTURE_TIME,
+ )
+ .unwrap();
+ assert_eq!(verified.public_key_sec1.len(), 65);
+ assert!(!verified.receipt.is_empty());
+ assert_eq!(
+ Sha256::digest(&verified.public_key_sec1).as_slice(),
+ key_id.as_slice()
+ );
+ }
+
+ #[test]
+ fn fixture_rejects_wrong_challenge_and_environment() {
+ let verifier = IosAttestationVerifier::new("").unwrap();
+ let key_id = base64::engine::general_purpose::STANDARD
+ .decode(KEY_ID)
+ .unwrap();
+ let challenge_error = verifier
+ .verify_attestation(
+ APPLE_FIXTURE,
+ &key_id,
+ b"wrong",
+ APP_ID,
+ IosEnvironment::Production,
+ FIXTURE_TIME,
+ )
+ .unwrap_err();
+ assert!(matches!(
+ challenge_error,
+ IosVerificationError::NonceMismatch
+ ));
+
+ let environment_error = verifier
+ .verify_attestation(
+ APPLE_FIXTURE,
+ &key_id,
+ CHALLENGE,
+ APP_ID,
+ IosEnvironment::Development,
+ FIXTURE_TIME,
+ )
+ .unwrap_err();
+ assert!(matches!(
+ environment_error,
+ IosVerificationError::EnvironmentMismatch
+ ));
+ }
+
+ #[test]
+ fn fixture_uses_injected_certificate_time() {
+ let verifier = IosAttestationVerifier::new("").unwrap();
+ let key_id = base64::engine::general_purpose::STANDARD
+ .decode(KEY_ID)
+ .unwrap();
+ let error = verifier
+ .verify_attestation(
+ APPLE_FIXTURE,
+ &key_id,
+ CHALLENGE,
+ APP_ID,
+ IosEnvironment::Production,
+ OffsetDateTime::now_utc().unix_timestamp(),
+ )
+ .unwrap_err();
+ assert!(matches!(
+ error,
+ IosVerificationError::InvalidCertificateTime
+ ));
+ }
+
+ #[derive(Serialize)]
+ struct TestAssertion {
+ signature: ByteBuf,
+ #[serde(rename = "authenticatorData")]
+ authenticator_data: ByteBuf,
+ }
+
+ #[test]
+ fn verifies_assertion_and_rejects_replayed_counter() {
+ let verifier = IosAttestationVerifier::new("").unwrap();
+ let signing_key = SigningKey::from_bytes(&[7u8; 32].into()).unwrap();
+ let public_key = signing_key.verifying_key().to_encoded_point(false);
+ let challenge = b"assertion-challenge";
+
+ let mut auth_data = Vec::with_capacity(37);
+ auth_data.extend_from_slice(&Sha256::digest(APP_ID.as_bytes()));
+ auth_data.push(0);
+ auth_data.extend_from_slice(&1u32.to_be_bytes());
+ let client_data_hash = Sha256::digest(challenge);
+ let mut signed_data = auth_data.clone();
+ signed_data.extend_from_slice(&client_data_hash);
+ let signature: Signature = signing_key.sign(&signed_data);
+ let assertion = TestAssertion {
+ signature: ByteBuf::from(signature.to_der().as_bytes().to_vec()),
+ authenticator_data: ByteBuf::from(auth_data),
+ };
+ let mut encoded = Vec::new();
+ ciborium::ser::into_writer(&assertion, &mut encoded).unwrap();
+
+ assert_eq!(
+ verifier
+ .verify_assertion(
+ &encoded,
+ public_key.as_bytes(),
+ &client_data_hash,
+ APP_ID,
+ 0,
+ )
+ .unwrap(),
+ 1
+ );
+ let error = verifier
+ .verify_assertion(
+ &encoded,
+ public_key.as_bytes(),
+ &client_data_hash,
+ APP_ID,
+ 1,
+ )
+ .unwrap_err();
+ assert!(matches!(error, IosVerificationError::CounterDidNotIncrease));
+ }
+}
diff --git a/src/state.rs b/src/state.rs
index d903c9b..2d20f8e 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -34,7 +34,7 @@ impl AppState {
let nonce = bootstrap::build_nonce_service(&config, store);
let nonce_rate_limiter = bootstrap::build_nonce_rate_limiter(&config);
let attestation_store = bootstrap::build_attestation_store(&config).await?;
- let attestation = bootstrap::build_attestation_service(&config, attestation_store);
+ let attestation = bootstrap::build_attestation_service(&config, attestation_store)?;
let token = bootstrap::build_token_service(&config)?;
let revocation_store = bootstrap::build_revocation_store(&config).await?;
let revocation = bootstrap::build_revocation_service(revocation_store);
diff --git a/src/store/attestation.rs b/src/store/attestation.rs
index f3e949c..de7278e 100644
--- a/src/store/attestation.rs
+++ b/src/store/attestation.rs
@@ -39,6 +39,8 @@ pub struct AttestationRecord {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ios_counter: Option,
pub evidence_sha256: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub challenge_sha256: Option,
}
impl AttestationRecord {
@@ -93,6 +95,16 @@ pub trait AttestationStore: Send + Sync {
wallet: &str,
) -> Result