Skip to content

Commit a0d970e

Browse files
authored
POST /a for iOS attestation; Android returns 400 (#141)
* feat(CORPLAT-603): POST /a for iOS attestation; Android returns 400 Add /a handler with Apple App Attest verification, nonce consumption, and KMS-signed integrity token. Re-export apple initial-attestation API and optional kid on public_key_to_jwk. Android bundle identifiers receive bad_request without attestation verification (no AndroidAttestationService on this branch). Made-with: Cursor * Provide cnf.jwk.kid
1 parent b02e1db commit a0d970e

File tree

6 files changed

+260
-12
lines changed

6 files changed

+260
-12
lines changed

attestation-gateway/src/apple/mod.rs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ pub struct Assertion {
149149

150150
#[allow(clippy::upper_case_acronyms)]
151151
#[derive(Debug, PartialEq, Clone, Copy)]
152-
enum AAGUID {
152+
pub enum AAGUID {
153153
AppAttest,
154154
AppAttestDevelop,
155155
}
@@ -169,7 +169,7 @@ impl FromStr for AAGUID {
169169

170170
impl AAGUID {
171171
/// Returns the list of allowed `AAGUID`s for a given bundle identifier.
172-
fn allowed_for_bundle_identifier(
172+
pub fn allowed_for_bundle_identifier(
173173
bundle_identifier: &BundleIdentifier,
174174
) -> eyre::Result<Vec<Self>> {
175175
match bundle_identifier {
@@ -188,18 +188,19 @@ impl AAGUID {
188188
}
189189

190190
#[derive(Debug)]
191-
struct InitialAttestationOutput {
191+
pub struct InitialAttestationOutput {
192192
pub public_key: String,
193193
pub receipt: String,
194194
pub key_id: String,
195+
pub key_public_key: Vec<u8>,
195196
}
196197

197198
/// Implements the verification of `DeviceCheck` *attestations* for iOS.
198199
/// Attestations are sent the first time to attest to the validity of a specific public key.
199200
/// <https://developer.apple.com/documentation/devicecheck/validating_apps_that_connect_to_your_server#3576643>
200-
fn decode_and_validate_initial_attestation(
201+
pub fn decode_and_validate_initial_attestation(
201202
apple_initial_attestation: String,
202-
request_hash: &str,
203+
challenge: &str,
203204
expected_app_id: &str,
204205
allowed_aaguid: &[AAGUID],
205206
apple_root_ca_pem: &[u8],
@@ -233,9 +234,9 @@ fn decode_and_validate_initial_attestation(
233234
let store = store_builder.build();
234235
internal_verify_cert_chain_with_store(&attestation, &store)?;
235236

236-
// Step 2 and 3: create clientDataHash from the "challenge" (internally called `request_hash`)
237+
// Step 2 and 3: create clientDataHash from the `challenge` (internally called "request_hash")
237238
let mut hasher = Sha256::new();
238-
hasher.update(request_hash.as_bytes());
239+
hasher.update(challenge.as_bytes());
239240
let client_data_hash = hasher.finish();
240241

241242
// Step 3: create nonce as composite item
@@ -318,6 +319,7 @@ fn decode_and_validate_initial_attestation(
318319
public_key: general_purpose::STANDARD.encode(public_key_der),
319320
receipt: general_purpose::STANDARD.encode(attestation.att_stmt.receipt.as_ref()),
320321
key_id: general_purpose::STANDARD.encode(credential_id),
322+
key_public_key: res.public_key().subject_public_key.clone().data.into(),
321323
})
322324
}
323325

attestation-gateway/src/keys/mod.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ async fn generate_new_key(
134134

135135
let public_key = PKey::public_key_from_der(&public_key_der)?;
136136

137-
let jwk = public_key_to_jwk(&public_key, key_definition.id.clone())?;
137+
let jwk = public_key_to_jwk(&public_key, Some(key_definition.id.clone()))?;
138138

139139
let signing_key = SigningKey {
140140
key_definition,
@@ -280,7 +280,10 @@ async fn release_lock(redis: &mut ConnectionManager) -> eyre::Result<()> {
280280

281281
/// Converts a DER public key to a JWK.
282282
/// Forked from `josekit::jwk::alg::ec::EcKeyPair.to_jwk` because the original function does not support using public-only keys.
283-
fn public_key_to_jwk(public_key: &PKey<Public>, key_id: String) -> eyre::Result<josekit::jwk::Jwk> {
283+
pub fn public_key_to_jwk(
284+
public_key: &PKey<Public>,
285+
key_id: Option<String>,
286+
) -> eyre::Result<josekit::jwk::Jwk> {
284287
let mut jwk = josekit::jwk::Jwk::new("EC");
285288

286289
let ec_key = public_key.ec_key()?;
@@ -311,12 +314,15 @@ fn public_key_to_jwk(public_key: &PKey<Public>, key_id: String) -> eyre::Result<
311314
key.as_str();
312315

313316
jwk.set_algorithm("ES256");
314-
jwk.set_key_id(key_id);
315317
jwk.set_parameter(
316318
"crv",
317319
Some(Value::String(SIGNING_CONFIG.curve_str.to_string())),
318320
)?;
319321

322+
if let Some(key_id) = key_id {
323+
jwk.set_key_id(key_id);
324+
}
325+
320326
Ok(jwk)
321327
}
322328

attestation-gateway/src/keys/tests.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ hnX+pFZq78Se67+BOJDjy1rpIDxDAJgXMy7QbKbztaUGOIrSiRCeMc8lhg==
290290

291291
let jwk = public_key_to_jwk(
292292
&public_key,
293-
"key_b9734d0e56ef4ad68e1fee2086a6e8e9".to_string(),
293+
Some("key_b9734d0e56ef4ad68e1fee2086a6e8e9".to_string()),
294294
)
295295
.unwrap();
296296

attestation-gateway/src/nonces/nonce_db.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ impl NonceDb {
5656
/// # Errors
5757
///
5858
/// When Redis `GETDEL` fails, the value is missing, or JSON does not decode to [`TokenDetails`].
59-
#[allow(dead_code)] // Used by `POST /a` (separate PR); kept so nonce flow stays in one module.
6059
pub async fn consume_nonce(&mut self, nonce: &str) -> Result<TokenDetails, NonceDbError> {
6160
let key = format!("nonce:{nonce}");
6261
let value = self

attestation-gateway/src/routes.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use std::time::Duration;
66
use tower_http::timeout::TimeoutLayer;
77
use tower_http::trace::TraceLayer;
88

9+
mod a;
910
mod c;
1011
mod generate_token;
1112
mod health;
@@ -21,6 +22,7 @@ pub fn get_timeout_layer() -> TimeoutLayer {
2122

2223
pub fn handler() -> ApiRouter {
2324
ApiRouter::new()
25+
.api_route("/a", post(a::handler))
2426
.api_route("/c", post(c::handler))
2527
.api_route("/g", post(generate_token::handler))
2628
.api_route("/.well-known/jwks.json", get(jwks::handler))
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
use std::time::SystemTime;
2+
3+
use aws_config::SdkConfig;
4+
5+
use axum::{Extension, Json};
6+
use base64::{Engine, engine::general_purpose::STANDARD as Base64};
7+
use chrono::{DateTime, Utc};
8+
use josekit::jwt::JwtPayload;
9+
use openssl::{
10+
bn::BigNum,
11+
ec::{EcGroup, EcKey},
12+
nid::Nid,
13+
pkey::PKey,
14+
sha::sha256,
15+
};
16+
use redis::aio::ConnectionManager;
17+
use schemars::JsonSchema;
18+
19+
use crate::{
20+
apple, keys, kms_jws,
21+
nonces::NonceDb,
22+
utils::{BundleIdentifier, ErrorCode, GlobalConfig, Platform, RequestError},
23+
};
24+
25+
#[derive(Debug, serde::Deserialize, serde::Serialize, JsonSchema)]
26+
pub struct Request {
27+
pub nonce: String,
28+
pub app_version: String,
29+
pub bundle_identifier: BundleIdentifier,
30+
pub apple_attestation: Option<String>,
31+
pub android_attestation: Option<Vec<String>>,
32+
}
33+
34+
#[derive(Debug, serde::Deserialize, serde::Serialize, JsonSchema)]
35+
pub struct Response {
36+
pub integrity_token: String,
37+
}
38+
39+
#[derive(Debug)]
40+
pub struct IntegrityTokenPayload {
41+
pub v: String,
42+
pub platform: Platform,
43+
pub app_version: String,
44+
pub aud: String,
45+
pub cnf: Vec<u8>,
46+
pub pass: bool,
47+
pub exp: DateTime<Utc>,
48+
}
49+
50+
impl IntegrityTokenPayload {
51+
pub fn generate(&self) -> eyre::Result<JwtPayload> {
52+
if self.cnf.len() != 65 {
53+
return Err(eyre::eyre!("Invalid device public key"));
54+
}
55+
56+
let cnf_ec_key = EcKey::from_public_key_affine_coordinates(
57+
&EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap(),
58+
&BigNum::from_slice(&self.cnf[1..33]).unwrap(),
59+
&BigNum::from_slice(&self.cnf[33..65]).unwrap(),
60+
)
61+
.map_err(|_| RequestError {
62+
code: ErrorCode::BadRequest,
63+
details: Some("Invalid device public key".to_string()),
64+
})?;
65+
66+
let cnf_pkey = PKey::from_ec_key(cnf_ec_key).map_err(|_| RequestError {
67+
code: ErrorCode::BadRequest,
68+
details: Some("Invalid device public key".to_string()),
69+
})?;
70+
71+
let cnf_key_id = Base64.encode(sha256(&self.cnf));
72+
let cnf_jwk = keys::public_key_to_jwk(&cnf_pkey, Some(cnf_key_id))?;
73+
74+
let mut cfn = josekit::Map::new();
75+
cfn.insert("jwk".to_string(), josekit::Value::Object(cnf_jwk.into()));
76+
77+
let mut payload = JwtPayload::new();
78+
payload.set_issued_at(&SystemTime::now());
79+
payload.set_issuer("attestation.worldcoin.org"); // TODO: what about attestation.worldcoin.dev?
80+
payload.set_expires_at(&self.exp.into());
81+
payload.set_claim("v", Some(josekit::Value::String(self.v.clone())))?;
82+
payload.set_claim(
83+
"app_version",
84+
Some(josekit::Value::String(self.app_version.clone())),
85+
)?;
86+
payload.set_claim(
87+
"platform",
88+
Some(josekit::Value::String(self.platform.to_string())),
89+
)?;
90+
payload.set_claim("aud", Some(josekit::Value::String(self.aud.clone())))?;
91+
payload.set_claim("cnf", Some(josekit::Value::Object(cfn)))?;
92+
payload.set_claim("pass", Some(josekit::Value::Bool(self.pass)))?;
93+
94+
Ok(payload)
95+
}
96+
}
97+
98+
pub async fn handler(
99+
Extension(global_config): Extension<GlobalConfig>,
100+
Extension(mut redis): Extension<ConnectionManager>,
101+
Extension(mut nonce_db): Extension<NonceDb>,
102+
Extension(aws_config): Extension<SdkConfig>,
103+
Json(request): Json<Request>,
104+
) -> Result<Json<Response>, RequestError> {
105+
let tracing_span = tracing::span!(tracing::Level::DEBUG, "a", endpoint = "/a");
106+
let _enter = tracing_span.enter();
107+
108+
if !global_config
109+
.enabled_bundle_identifiers
110+
.contains(&request.bundle_identifier)
111+
{
112+
return Err(RequestError {
113+
code: ErrorCode::BadRequest,
114+
details: Some("This bundle identifier is currently unavailable.".to_string()),
115+
});
116+
}
117+
118+
let challenge = format!("n={},av={}", request.nonce, request.app_version);
119+
let platform = request.bundle_identifier.platform();
120+
121+
let device_public_key = match platform {
122+
Platform::AppleIOS => {
123+
let apple_attestation = request.apple_attestation.ok_or_else(|| RequestError {
124+
code: ErrorCode::BadRequest,
125+
details: Some("Apple attestation is required".to_string()),
126+
})?;
127+
128+
validate_apple_attestation_and_get_device_public_key(
129+
&global_config.apple_root_ca_pem,
130+
&challenge,
131+
&request.bundle_identifier,
132+
apple_attestation,
133+
)?
134+
}
135+
Platform::Android => {
136+
return Err(RequestError {
137+
code: ErrorCode::BadRequest,
138+
details: Some("Android attestation is not supported on this endpoint.".to_string()),
139+
});
140+
}
141+
};
142+
143+
let token_details = nonce_db.consume_nonce(&request.nonce).await.map_err(|e| {
144+
tracing::error!(error = ?e, "Error consuming token nonce");
145+
RequestError {
146+
code: ErrorCode::InternalServerError,
147+
details: None,
148+
}
149+
})?;
150+
151+
let integrity_token = generate_integrity_token(
152+
&mut redis,
153+
&aws_config,
154+
IntegrityTokenPayload {
155+
v: "1".to_string(),
156+
platform,
157+
app_version: request.app_version,
158+
aud: token_details.aud,
159+
cnf: device_public_key,
160+
pass: true,
161+
exp: token_details.exp,
162+
},
163+
)
164+
.await?;
165+
166+
Ok(Json(Response { integrity_token }))
167+
}
168+
169+
fn validate_apple_attestation_and_get_device_public_key(
170+
apple_root_ca_pem: &[u8],
171+
challenge: &str,
172+
bundle_identifier: &BundleIdentifier,
173+
apple_attestation: String,
174+
) -> Result<Vec<u8>, RequestError> {
175+
let app_id = bundle_identifier.apple_app_id().ok_or(RequestError {
176+
code: ErrorCode::BadRequest,
177+
details: Some("Invalid bundle identifier".to_string()),
178+
})?;
179+
180+
let allowed_aaguid_vec = apple::AAGUID::allowed_for_bundle_identifier(bundle_identifier)
181+
.map_err(|_| RequestError {
182+
code: ErrorCode::BadRequest,
183+
details: Some("Invalid bundle identifier".to_string()),
184+
})?;
185+
186+
let initial_attestation = apple::decode_and_validate_initial_attestation(
187+
apple_attestation,
188+
challenge,
189+
app_id,
190+
allowed_aaguid_vec.as_slice(),
191+
apple_root_ca_pem,
192+
)
193+
.map_err(|e| RequestError {
194+
code: ErrorCode::BadRequest,
195+
details: Some(e.to_string()),
196+
})?;
197+
198+
Ok(initial_attestation.key_public_key)
199+
}
200+
201+
async fn generate_integrity_token(
202+
redis: &mut ConnectionManager,
203+
aws_config: &SdkConfig,
204+
integrity_token_payload: IntegrityTokenPayload,
205+
) -> Result<String, RequestError> {
206+
let integrity_token_payload = integrity_token_payload.generate().map_err(|e| {
207+
tracing::error!(error = ?e, "Error generating integrity token payload");
208+
RequestError {
209+
code: ErrorCode::InternalServerError,
210+
details: None,
211+
}
212+
})?;
213+
214+
let kms_key = keys::fetch_active_key(redis, aws_config)
215+
.await
216+
.map_err(|e| {
217+
tracing::error!(error = ?e, "Error fetching active key");
218+
RequestError {
219+
code: ErrorCode::InternalServerError,
220+
details: None,
221+
}
222+
})?;
223+
224+
let integrity_token = kms_jws::generate_output_token(
225+
aws_config,
226+
kms_key.key_definition.arn,
227+
integrity_token_payload,
228+
)
229+
.await
230+
.map_err(|e| {
231+
tracing::error!(error = ?e, "Error generating output token");
232+
RequestError {
233+
code: ErrorCode::InternalServerError,
234+
details: None,
235+
}
236+
})?;
237+
238+
Ok(integrity_token)
239+
}

0 commit comments

Comments
 (0)