Skip to main content

auths_pairing_protocol/
subkey_chain.rs

1//! Per-pairing subkey chains (`auths-device-subkey-v1`).
2//!
3//! Privacy mitigation: instead of submitting the stable bootstrap DID
4//! pubkey to every controller the phone pairs with (a cross-controller
5//! correlation oracle), the phone generates a fresh session-only
6//! keypair and asks the bootstrap key to sign a binding message. The
7//! daemon observes the per-session subkey on the wire; if the daemon
8//! requests proof of continuity, the phone supplies the chain.
9//!
10//! # Binding message format (v1)
11//!
12//! ```text
13//! binding_message = "auths-device-subkey-v1" || session_id || subkey_pubkey_compressed_sec1
14//! ```
15//!
16//! - The domain separator `auths-device-subkey-v1` pins the chain to
17//!   this version; a future v2 lands under `subkey-chain-v2`.
18//! - `session_id` is the pairing session id from the URI (variable-
19//!   length UTF-8 string; no length-prefix — callers must treat the
20//!   session_id as opaque bytes).
21//! - `subkey_pubkey` is the 33-byte compressed SEC1 encoding of the
22//!   P-256 pubkey currently bound to this session (same form carried
23//!   in `SubmitResponseRequest.device_signing_pubkey` for P-256).
24//!
25//! # On the wire
26//!
27//! ```json
28//! {
29//!   "subkey_chain": {
30//!     "bootstrap_pubkey": "<base64url-no-pad of 33-byte compressed SEC1>",
31//!     "subkey_binding_signature": "<base64url-no-pad of 64-byte raw r||s>"
32//!   }
33//! }
34//! ```
35//!
36//! Both fields are P-256 (ADRs 002 / 003). No alternate-encoding
37//! path — the daemon rejects malformed chains outright.
38
39use serde::{Deserialize, Serialize};
40
41use crate::types::Base64UrlEncoded;
42
43#[cfg(feature = "subkey-chain-v1")]
44use p256::ecdsa::signature::Verifier;
45
46/// Domain separator pinned to chain version 1. See
47/// [`crate::subkey_chain`] module docs for the binding-message format.
48#[cfg(feature = "subkey-chain-v1")]
49pub const SUBKEY_CHAIN_V1_DOMAIN: &[u8] = b"auths-device-subkey-v1";
50
51/// Optional chain appended to a `SubmitResponseRequest` proving that
52/// the submitted `device_signing_pubkey` was authorized by a more
53/// stable bootstrap key held by the same phone.
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
56pub struct SubkeyChain {
57    /// Bootstrap pubkey (P-256 compressed SEC1, 33 B, base64url-no-pad).
58    /// Stable across sessions for a given phone; cross-session
59    /// revocation pivots on this.
60    pub bootstrap_pubkey: Base64UrlEncoded,
61    /// Raw r‖s (64 B) P-256 signature by `bootstrap_pubkey` over the
62    /// canonical binding message. Base64url-no-pad-encoded.
63    pub subkey_binding_signature: Base64UrlEncoded,
64}
65
66/// Errors produced by [`verify_subkey_chain`].
67#[cfg(feature = "subkey-chain-v1")]
68#[derive(Debug, thiserror::Error)]
69pub enum SubkeyChainError {
70    /// `bootstrap_pubkey` base64url decode failed.
71    #[error("bootstrap pubkey decode failed: {0}")]
72    BootstrapPubkeyDecode(String),
73    /// `bootstrap_pubkey` bytes were the wrong length (expect 33).
74    #[error("bootstrap pubkey wrong length: got {0}, expected 33")]
75    BootstrapPubkeyLength(usize),
76    /// `bootstrap_pubkey` bytes do not parse as a valid P-256 point.
77    #[error("bootstrap pubkey not a valid P-256 point: {0}")]
78    BootstrapPubkeyInvalid(String),
79    /// `subkey_binding_signature` base64url decode failed.
80    #[error("binding signature decode failed: {0}")]
81    SignatureDecode(String),
82    /// Signature bytes were the wrong length (expect 64).
83    #[error("binding signature wrong length: got {0}, expected 64")]
84    SignatureLength(usize),
85    /// The supplied `subkey_pubkey` bytes were not 33 (compressed SEC1).
86    #[error("subkey pubkey wrong length: got {0}, expected 33")]
87    SubkeyPubkeyLength(usize),
88    /// Signature did not verify against the binding message under the
89    /// supplied bootstrap pubkey.
90    #[error("binding signature does not verify under bootstrap pubkey")]
91    VerifyFailed,
92    /// Chain references a bootstrap pubkey identical to the subkey
93    /// pubkey — self-referential chains are rejected because they do
94    /// not prove continuity.
95    #[error("chain is self-referential (bootstrap == subkey)")]
96    SelfReferential,
97}
98
99/// Build the canonical binding message for chain version 1.
100///
101/// Args:
102/// * `session_id`: pairing session id from the URI.
103/// * `subkey_pubkey_compressed`: 33-byte compressed SEC1 pubkey bound
104///   to the session.
105#[cfg(feature = "subkey-chain-v1")]
106pub fn build_binding_message_v1(session_id: &str, subkey_pubkey_compressed: &[u8]) -> Vec<u8> {
107    let mut out = Vec::with_capacity(
108        SUBKEY_CHAIN_V1_DOMAIN.len() + session_id.len() + subkey_pubkey_compressed.len(),
109    );
110    out.extend_from_slice(SUBKEY_CHAIN_V1_DOMAIN);
111    out.extend_from_slice(session_id.as_bytes());
112    out.extend_from_slice(subkey_pubkey_compressed);
113    out
114}
115
116/// Verify a subkey chain and return the bootstrap pubkey's raw bytes
117/// (33-byte compressed SEC1) on success.
118///
119/// Callers record the returned bootstrap pubkey as the session's
120/// stable phone identifier for cross-session revocation.
121///
122/// Args:
123/// * `chain`: the chain to verify.
124/// * `subkey_pubkey_compressed`: the 33-byte compressed SEC1 pubkey
125///   currently bound to the session (from `SubmitResponseRequest.device_signing_pubkey`).
126/// * `session_id`: pairing session id.
127///
128/// Usage:
129/// ```ignore
130/// let bootstrap = verify_subkey_chain(&chain, &subkey_pk, "sess-abc")?;
131/// session.record_bootstrap_pubkey(bootstrap);
132/// ```
133#[cfg(feature = "subkey-chain-v1")]
134pub fn verify_subkey_chain(
135    chain: &SubkeyChain,
136    subkey_pubkey_compressed: &[u8],
137    session_id: &str,
138) -> Result<[u8; 33], SubkeyChainError> {
139    if subkey_pubkey_compressed.len() != 33 {
140        return Err(SubkeyChainError::SubkeyPubkeyLength(
141            subkey_pubkey_compressed.len(),
142        ));
143    }
144
145    let bootstrap_bytes = chain
146        .bootstrap_pubkey
147        .decode()
148        .map_err(|e| SubkeyChainError::BootstrapPubkeyDecode(e.to_string()))?;
149    if bootstrap_bytes.len() != 33 {
150        return Err(SubkeyChainError::BootstrapPubkeyLength(
151            bootstrap_bytes.len(),
152        ));
153    }
154    let mut bootstrap_arr = [0u8; 33];
155    bootstrap_arr.copy_from_slice(&bootstrap_bytes);
156
157    if bootstrap_arr == subkey_pubkey_compressed[..] {
158        return Err(SubkeyChainError::SelfReferential);
159    }
160
161    let bootstrap_vk = p256::ecdsa::VerifyingKey::from_sec1_bytes(&bootstrap_arr)
162        .map_err(|e| SubkeyChainError::BootstrapPubkeyInvalid(e.to_string()))?;
163
164    let sig_bytes = chain
165        .subkey_binding_signature
166        .decode()
167        .map_err(|e| SubkeyChainError::SignatureDecode(e.to_string()))?;
168    if sig_bytes.len() != 64 {
169        return Err(SubkeyChainError::SignatureLength(sig_bytes.len()));
170    }
171    let sig = p256::ecdsa::Signature::from_slice(&sig_bytes)
172        .map_err(|e| SubkeyChainError::BootstrapPubkeyInvalid(e.to_string()))?;
173
174    let msg = build_binding_message_v1(session_id, subkey_pubkey_compressed);
175
176    bootstrap_vk
177        .verify(&msg, &sig)
178        .map_err(|_| SubkeyChainError::VerifyFailed)?;
179
180    Ok(bootstrap_arr)
181}
182
183#[cfg(all(test, feature = "subkey-chain-v1"))]
184mod tests {
185    use super::*;
186    use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
187    use p256::ecdsa::signature::Signer;
188    use p256::ecdsa::{Signature, SigningKey};
189    use rand::rngs::OsRng;
190
191    fn random_keypair() -> (SigningKey, [u8; 33]) {
192        let sk = SigningKey::random(&mut OsRng);
193        let compressed = sk.verifying_key().to_encoded_point(true);
194        let mut arr = [0u8; 33];
195        arr.copy_from_slice(compressed.as_bytes());
196        (sk, arr)
197    }
198
199    fn sign_chain(bootstrap: &SigningKey, session_id: &str, subkey: &[u8; 33]) -> SubkeyChain {
200        let msg = build_binding_message_v1(session_id, subkey);
201        let sig: Signature = bootstrap.sign(&msg);
202        let raw: [u8; 64] = sig.to_bytes().into();
203        let bootstrap_compressed = bootstrap.verifying_key().to_encoded_point(true);
204        SubkeyChain {
205            bootstrap_pubkey: Base64UrlEncoded::from_raw(
206                URL_SAFE_NO_PAD.encode(bootstrap_compressed.as_bytes()),
207            ),
208            subkey_binding_signature: Base64UrlEncoded::from_raw(URL_SAFE_NO_PAD.encode(raw)),
209        }
210    }
211
212    #[test]
213    fn valid_chain_verifies_and_returns_bootstrap_bytes() {
214        let (bootstrap_sk, bootstrap_pk) = random_keypair();
215        let (_subkey_sk, subkey_pk) = random_keypair();
216        let chain = sign_chain(&bootstrap_sk, "sess-abc", &subkey_pk);
217
218        let out =
219            verify_subkey_chain(&chain, &subkey_pk, "sess-abc").expect("valid chain must verify");
220        assert_eq!(out, bootstrap_pk);
221    }
222
223    #[test]
224    fn binding_message_format_is_domain_plus_session_plus_subkey() {
225        let (_sk, pk) = random_keypair();
226        let msg = build_binding_message_v1("sess-XYZ", &pk);
227        assert!(msg.starts_with(SUBKEY_CHAIN_V1_DOMAIN));
228        assert_eq!(
229            &msg[SUBKEY_CHAIN_V1_DOMAIN.len()..SUBKEY_CHAIN_V1_DOMAIN.len() + 8],
230            b"sess-XYZ"
231        );
232        assert_eq!(&msg[SUBKEY_CHAIN_V1_DOMAIN.len() + 8..], &pk[..]);
233    }
234
235    #[test]
236    fn different_session_id_fails_verification() {
237        let (bootstrap_sk, _) = random_keypair();
238        let (_, subkey_pk) = random_keypair();
239        let chain = sign_chain(&bootstrap_sk, "sess-abc", &subkey_pk);
240
241        let err = verify_subkey_chain(&chain, &subkey_pk, "sess-different").unwrap_err();
242        assert!(matches!(err, SubkeyChainError::VerifyFailed));
243    }
244
245    #[test]
246    fn different_subkey_pubkey_fails_verification() {
247        let (bootstrap_sk, _) = random_keypair();
248        let (_, subkey_a) = random_keypair();
249        let (_, subkey_b) = random_keypair();
250        let chain = sign_chain(&bootstrap_sk, "sess-abc", &subkey_a);
251
252        // Chain was signed over subkey_a; presenting subkey_b must fail.
253        let err = verify_subkey_chain(&chain, &subkey_b, "sess-abc").unwrap_err();
254        assert!(matches!(err, SubkeyChainError::VerifyFailed));
255    }
256
257    #[test]
258    fn self_referential_chain_rejected() {
259        let (sk, pk) = random_keypair();
260        // bootstrap == subkey — chain signed over itself.
261        let chain = sign_chain(&sk, "sess-abc", &pk);
262        let err = verify_subkey_chain(&chain, &pk, "sess-abc").unwrap_err();
263        assert!(matches!(err, SubkeyChainError::SelfReferential));
264    }
265
266    #[test]
267    fn wrong_subkey_length_errors() {
268        let (sk, _) = random_keypair();
269        let (_, subkey_pk) = random_keypair();
270        let chain = sign_chain(&sk, "sess-abc", &subkey_pk);
271        let err = verify_subkey_chain(&chain, &[0u8; 32], "sess-abc").unwrap_err();
272        assert!(matches!(err, SubkeyChainError::SubkeyPubkeyLength(32)));
273    }
274
275    #[test]
276    fn wrong_bootstrap_pubkey_length_errors() {
277        let (_, subkey_pk) = random_keypair();
278        let chain = SubkeyChain {
279            bootstrap_pubkey: Base64UrlEncoded::from_raw(URL_SAFE_NO_PAD.encode([0u8; 32])),
280            subkey_binding_signature: Base64UrlEncoded::from_raw(URL_SAFE_NO_PAD.encode([0u8; 64])),
281        };
282        let err = verify_subkey_chain(&chain, &subkey_pk, "sess-abc").unwrap_err();
283        assert!(matches!(err, SubkeyChainError::BootstrapPubkeyLength(32)));
284    }
285
286    #[test]
287    fn wrong_signature_length_errors() {
288        let (bootstrap_sk, _) = random_keypair();
289        let (_, subkey_pk) = random_keypair();
290        let bootstrap_compressed = bootstrap_sk.verifying_key().to_encoded_point(true);
291        let chain = SubkeyChain {
292            bootstrap_pubkey: Base64UrlEncoded::from_raw(
293                URL_SAFE_NO_PAD.encode(bootstrap_compressed.as_bytes()),
294            ),
295            // Truncated signature.
296            subkey_binding_signature: Base64UrlEncoded::from_raw(URL_SAFE_NO_PAD.encode([0u8; 32])),
297        };
298        let err = verify_subkey_chain(&chain, &subkey_pk, "sess-abc").unwrap_err();
299        assert!(matches!(err, SubkeyChainError::SignatureLength(32)));
300    }
301}