Skip to main content

attested_request/
device.rs

1//! The attested device key and its platform signature encodings.
2
3use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
4use p256::{
5    EncodedPoint, PublicKey,
6    ecdsa::{Signature, VerifyingKey, signature::hazmat::PrehashVerifier as _},
7};
8use serde::Deserialize;
9use sha2::{Digest, Sha256};
10
11use crate::profile::Platform;
12
13const COORDINATE_BYTES: usize = 32;
14
15/// The P-256 public key the Attestation Gateway attested, from the token's `cnf.jwk`.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct DeviceKey(VerifyingKey);
18
19/// Why a `cnf.jwk` is not a usable device key.
20#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
21pub enum DeviceKeyError {
22    /// The key is not an EC P-256 key.
23    #[error("device key is not EC P-256")]
24    UnsupportedKeyType,
25    /// A coordinate is not base64url or is longer than P-256 allows.
26    #[error("device key coordinates are malformed")]
27    MalformedCoordinates,
28    /// The coordinates are not a point on the curve.
29    #[error("device key is not a point on P-256")]
30    NotOnCurve,
31}
32
33/// The public members of an EC JWK.
34#[derive(Debug, Clone, Deserialize)]
35pub(crate) struct EcJwk {
36    pub(crate) kty: String,
37    pub(crate) crv: String,
38    pub(crate) x: String,
39    pub(crate) y: String,
40}
41
42impl EcJwk {
43    pub(crate) fn to_verifying_key(&self) -> Result<VerifyingKey, DeviceKeyError> {
44        if self.kty != "EC" || self.crv != "P-256" {
45            return Err(DeviceKeyError::UnsupportedKeyType);
46        }
47        let x = coordinate(&self.x)?;
48        let y = coordinate(&self.y)?;
49        let point = EncodedPoint::from_affine_coordinates(&x.into(), &y.into(), false);
50        // Decoding the SEC 1 point is what rejects coordinates off the curve.
51        let key =
52            PublicKey::from_sec1_bytes(point.as_bytes()).map_err(|_| DeviceKeyError::NotOnCurve)?;
53        Ok(VerifyingKey::from(key))
54    }
55}
56
57// Encoders may strip leading zero bytes from a coordinate, so shorter values are left-padded.
58fn coordinate(encoded: &str) -> Result<[u8; COORDINATE_BYTES], DeviceKeyError> {
59    let bytes = URL_SAFE_NO_PAD
60        .decode(encoded)
61        .map_err(|_| DeviceKeyError::MalformedCoordinates)?;
62    let padding = COORDINATE_BYTES
63        .checked_sub(bytes.len())
64        .ok_or(DeviceKeyError::MalformedCoordinates)?;
65    let mut coordinate = [0u8; COORDINATE_BYTES];
66    coordinate[padding..].copy_from_slice(&bytes);
67    Ok(coordinate)
68}
69
70/// Why a platform signature did not verify.
71#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
72pub enum PlatformSignatureError {
73    /// The iOS signature is not a CBOR App Attest assertion.
74    #[error("iOS signature is not a CBOR App Attest assertion")]
75    MalformedAssertion,
76    /// The ECDSA signature is not strict DER.
77    #[error("ECDSA signature is not DER")]
78    MalformedEcdsa,
79    /// The signature does not verify under the device key.
80    #[error("signature does not verify under the device key")]
81    Mismatch,
82}
83
84/// An iOS App Attest assertion (`DCAppAttestService.generateAssertion`).
85#[derive(Deserialize)]
86struct Assertion {
87    #[serde(with = "serde_bytes")]
88    signature: Vec<u8>,
89    #[serde(rename = "authenticatorData", with = "serde_bytes")]
90    authenticator_data: Vec<u8>,
91}
92
93impl DeviceKey {
94    /// Wraps a P-256 verifying key.
95    #[must_use]
96    pub const fn new(key: VerifyingKey) -> Self {
97        Self(key)
98    }
99
100    /// The underlying P-256 verifying key.
101    #[must_use]
102    pub const fn verifying_key(&self) -> &VerifyingKey {
103        &self.0
104    }
105
106    /// The RFC 7638 JWK thumbprint (base64url SHA-256): a stable identifier for this device key,
107    /// for example to rate limit on.
108    #[must_use]
109    pub fn thumbprint(&self) -> String {
110        let point = self.0.to_encoded_point(false);
111        let (Some(x), Some(y)) = (point.x(), point.y()) else {
112            unreachable!("an uncompressed point has both coordinates");
113        };
114        let canonical = format!(
115            r#"{{"crv":"P-256","kty":"EC","x":"{}","y":"{}"}}"#,
116            URL_SAFE_NO_PAD.encode(x),
117            URL_SAFE_NO_PAD.encode(y),
118        );
119        URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes()))
120    }
121
122    /// Verifies a platform signature over `client_data_hash`, which is `SHA-256(signature base)`.
123    ///
124    /// Android keys sign the hash directly. App Attest signs `SHA-256(authenticatorData ‖
125    /// clientDataHash)` and wraps the signature in a CBOR assertion. The rpId and counter inside
126    /// `authenticatorData` are not checked: App Attest keys are app-scoped, the Attestation
127    /// Gateway verified the app when it attested this key, and freshness comes from `created`.
128    ///
129    /// # Errors
130    ///
131    /// Returns a [`PlatformSignatureError`] when the signature is malformed or does not verify.
132    pub fn verify(
133        &self,
134        platform: Platform,
135        client_data_hash: &[u8; 32],
136        signature: &[u8],
137    ) -> Result<(), PlatformSignatureError> {
138        let (digest, der): ([u8; 32], Vec<u8>) = match platform {
139            Platform::Android => (*client_data_hash, signature.to_vec()),
140            Platform::Ios => {
141                let assertion: Assertion = ciborium::from_reader(signature)
142                    .map_err(|_| PlatformSignatureError::MalformedAssertion)?;
143                let nonce = Sha256::new()
144                    .chain_update(&assertion.authenticator_data)
145                    .chain_update(client_data_hash)
146                    .finalize();
147                (Sha256::digest(nonce).into(), assertion.signature)
148            }
149        };
150        let signature =
151            Signature::from_der(&der).map_err(|_| PlatformSignatureError::MalformedEcdsa)?;
152        self.0
153            .verify_prehash(&digest, &signature)
154            .map_err(|_| PlatformSignatureError::Mismatch)
155    }
156}