Skip to main content

aptos_sdk/account/
webauthn.rs

1// Module docs reference many WebAuthn / RustCrypto identifiers that clippy's
2// pedantic `doc_markdown` lint flags even when they are intentionally rendered
3// as prose (e.g. "Aptos networks", "clientDataJSON", "RustCrypto"). Backticking
4// every occurrence harms readability without improving safety, so allow the
5// lint for this module only.
6#![allow(clippy::doc_markdown)]
7
8//! WebAuthn / Passkey account implementation.
9//!
10//! This module provides [`WebAuthnAccount`], an account type that signs
11//! Aptos transactions using a `secp256r1` / P-256 key but wraps the
12//! signature in the WebAuthn `PartialAuthenticatorAssertionResponse`
13//! envelope that the on-chain `AnySignature::WebAuthn` variant expects.
14//!
15//! # Why this exists
16//!
17//! On current Aptos networks (devnet, testnet, mainnet), the
18//! `AnySignature` enum stored inside `AccountAuthenticator::SingleKey` /
19//! `MultiKey` has variant index `2` reserved for **WebAuthn** -- not for
20//! bare `secp256r1` ECDSA signatures. A transaction signed by the historical
21//! [`super::Secp256r1Account`] is therefore rejected by the chain even
22//! though the signature itself is mathematically correct, because the
23//! chain interprets variant `2` as a `PartialAuthenticatorAssertionResponse`
24//! and the bare 64-byte ECDSA signature does not parse as one.
25//!
26//! `WebAuthnAccount` produces the correct envelope:
27//!
28//! 1. The `challenge` the chain expects is `SHA3-256(signing_message(raw_txn))`.
29//!    We compute it from the message passed into `Account::sign`.
30//! 2. We construct a minimal but valid `clientDataJSON` that carries this
31//!    challenge as a base64url (no-pad) string and a configurable
32//!    `origin` field.
33//! 3. We construct a 37-byte synthetic `authenticatorData`
34//!    (`rpIdHash || flags || signCount`). The chain does not enforce a
35//!    specific `rpIdHash` so we hash a developer-configurable `rp_id` into
36//!    32 bytes; defaults are deterministic.
37//! 4. We sign the binary concatenation
38//!    `authenticatorData || SHA-256(clientDataJSON)` with the
39//!    `secp256r1` key. The `k256` / `p256` stack hashes that buffer with
40//!    SHA-256 internally, exactly mirroring how the chain verifies via
41//!    `signature::Verifier::verify`.
42//! 5. We BCS-serialize the resulting
43//!    `PartialAuthenticatorAssertionResponse` and emit it as the
44//!    `AnySignature::WebAuthn` payload.
45//!
46//! The result is a fully self-contained synthetic-Passkey account that
47//! produces transactions a live Aptos node will accept and execute.
48//!
49//! # Example
50//!
51//! ```rust,no_run
52//! use aptos_sdk::account::{Account, WebAuthnAccount};
53//!
54//! let account = WebAuthnAccount::generate();
55//! // Use `account` anywhere an `&dyn Account` / `impl Account` is required.
56//! println!("WebAuthn account address: {}", account.address());
57//! ```
58
59use crate::account::account::{Account, AuthenticationKey};
60use crate::crypto::multi_key::uleb128_encode;
61use crate::crypto::{
62    SINGLE_KEY_SCHEME, Secp256r1PrivateKey, Secp256r1PublicKey, derive_authentication_key,
63    sha2_256, sha3_256,
64};
65use crate::error::AptosResult;
66use crate::types::AccountAddress;
67use std::fmt;
68
69/// Default WebAuthn relying-party identifier baked into the synthetic
70/// `authenticator_data.rpIdHash` field. The on-chain verifier does not
71/// enforce a specific value, so any deterministic 32-byte hash is fine.
72pub const DEFAULT_WEBAUTHN_RP_ID: &str = "aptos-rust-sdk";
73
74/// Default WebAuthn `origin` field baked into the synthetic
75/// `client_data_json.origin` value. Like `rp_id`, the on-chain verifier
76/// does not enforce a specific value.
77pub const DEFAULT_WEBAUTHN_ORIGIN: &str = "https://aptos-rust-sdk.local";
78
79/// On-chain BCS variant tag for `AnySignature::WebAuthn`.
80const ANY_SIGNATURE_WEBAUTHN_TAG: u8 = 0x02;
81
82/// On-chain BCS variant tag for `AssertionSignature::Secp256r1Ecdsa` (the
83/// only variant currently defined). Used inside
84/// `PartialAuthenticatorAssertionResponse.signature`.
85const ASSERTION_SIGNATURE_SECP256R1_TAG: u8 = 0x00;
86
87/// WebAuthn `AuthenticatorData` flags byte (UP | UV = User Present and
88/// User Verified). The chain does not enforce a specific flag combination,
89/// so we always assert both, since our software-only signer "presents the
90/// user" by definition every time `sign` is called.
91const AUTHENTICATOR_DATA_FLAGS: u8 = 0x05;
92
93/// Length of an Aptos secp256r1 signature in bytes (`r || s`).
94const SECP256R1_SIGNATURE_LENGTH: usize = 64;
95
96/// A WebAuthn / Passkey-style account.
97///
98/// Wraps a [`Secp256r1PrivateKey`] but produces transaction signatures in
99/// the on-chain `AnySignature::WebAuthn` format. See the module-level
100/// docs for the precise wire format.
101#[derive(Clone)]
102pub struct WebAuthnAccount {
103    private_key: Secp256r1PrivateKey,
104    public_key: Secp256r1PublicKey,
105    address: AccountAddress,
106    rp_id_hash: [u8; 32],
107    origin: String,
108}
109
110impl WebAuthnAccount {
111    /// Generates a new random WebAuthn account using the default
112    /// RP-ID and origin (see [`DEFAULT_WEBAUTHN_RP_ID`] /
113    /// [`DEFAULT_WEBAUTHN_ORIGIN`]).
114    #[must_use]
115    pub fn generate() -> Self {
116        Self::from_private_key(Secp256r1PrivateKey::generate())
117    }
118
119    /// Creates a WebAuthn account from an existing P-256 private key,
120    /// using the default RP-ID and origin.
121    #[must_use]
122    pub fn from_private_key(private_key: Secp256r1PrivateKey) -> Self {
123        Self::from_parts(private_key, DEFAULT_WEBAUTHN_RP_ID, DEFAULT_WEBAUTHN_ORIGIN)
124    }
125
126    /// Creates a WebAuthn account from a private key and explicit
127    /// RP-ID / origin strings.
128    ///
129    /// The on-chain verifier does not enforce particular values for these
130    /// fields, but if you are interoperating with a relying party that
131    /// records the `rpIdHash` / `origin` for off-chain auditing you may
132    /// wish to specify them.
133    #[must_use]
134    pub fn from_parts(private_key: Secp256r1PrivateKey, rp_id: &str, origin: &str) -> Self {
135        let public_key = private_key.public_key();
136        let address = public_key.to_address();
137        let rp_id_hash = sha2_256(rp_id.as_bytes());
138        Self {
139            private_key,
140            public_key,
141            address,
142            rp_id_hash,
143            origin: origin.to_owned(),
144        }
145    }
146
147    /// Creates a WebAuthn account from private-key bytes.
148    ///
149    /// # Errors
150    ///
151    /// Returns an error if `bytes` is not a valid 32-byte P-256 scalar.
152    pub fn from_private_key_bytes(bytes: &[u8]) -> AptosResult<Self> {
153        let private_key = Secp256r1PrivateKey::from_bytes(bytes)?;
154        Ok(Self::from_private_key(private_key))
155    }
156
157    /// Returns the account address (the on-chain authentication key).
158    #[must_use]
159    pub fn address(&self) -> AccountAddress {
160        self.address
161    }
162
163    /// Returns the underlying P-256 public key.
164    #[must_use]
165    pub fn public_key(&self) -> &Secp256r1PublicKey {
166        &self.public_key
167    }
168
169    /// Returns a reference to the underlying P-256 private key.
170    #[must_use]
171    pub fn private_key(&self) -> &Secp256r1PrivateKey {
172        &self.private_key
173    }
174
175    /// Builds the synthetic 37-byte `authenticatorData` blob the WebAuthn
176    /// envelope expects: `rpIdHash(32) || flags(1) || signCount(4 BE)`.
177    fn build_authenticator_data(&self) -> [u8; 37] {
178        let mut out = [0u8; 37];
179        out[..32].copy_from_slice(&self.rp_id_hash);
180        out[32] = AUTHENTICATOR_DATA_FLAGS;
181        // signCount = 0 in big-endian. The chain doesn't track counters so a
182        // fixed zero is fine.
183        out[33..37].copy_from_slice(&[0u8; 4]);
184        out
185    }
186
187    /// Builds the `clientDataJSON` byte string the WebAuthn envelope expects.
188    fn build_client_data_json(&self, challenge_b64url: &str) -> Vec<u8> {
189        // The exact JSON encoding doesn't matter as long as `serde_json` can
190        // parse it and `challenge` is the SHA3-256-of-signing-message in
191        // base64url-no-pad form. We construct a minimal string by hand to
192        // avoid pulling serde_json into the WebAuthn signing path. Origin
193        // strings produced by `WebAuthnAccount::from_parts` are reflected
194        // verbatim, but we still escape backslashes and double quotes
195        // defensively in case the caller passed unusual input.
196        let mut out = String::with_capacity(128 + challenge_b64url.len() + self.origin.len());
197        out.push_str(r#"{"type":"webauthn.get","challenge":""#);
198        out.push_str(challenge_b64url);
199        out.push_str(r#"","origin":""#);
200        Self::push_json_escaped(&mut out, &self.origin);
201        out.push_str(r#"","crossOrigin":false}"#);
202        out.into_bytes()
203    }
204
205    fn push_json_escaped(dst: &mut String, src: &str) {
206        use std::fmt::Write as _;
207        for ch in src.chars() {
208            match ch {
209                '"' => dst.push_str("\\\""),
210                '\\' => dst.push_str("\\\\"),
211                c if (c as u32) < 0x20 => {
212                    // ASCII control char. Emit \u00XX form. write! into String
213                    // never fails.
214                    let _ = write!(dst, "\\u{:04x}", c as u32);
215                }
216                c => dst.push(c),
217            }
218        }
219    }
220}
221
222impl Account for WebAuthnAccount {
223    fn address(&self) -> AccountAddress {
224        self.address
225    }
226
227    fn authentication_key(&self) -> AuthenticationKey {
228        // Address derivation is identical to `Secp256r1Account`: the chain
229        // canonicalises through `AnyPublicKey::Secp256r1Ecdsa` (variant 2)
230        // with the 65-byte SEC1 uncompressed encoding.
231        let uncompressed = self.public_key.to_uncompressed_bytes();
232        let mut bcs_bytes = Vec::with_capacity(1 + 1 + uncompressed.len());
233        bcs_bytes.push(0x02); // AnyPublicKey::Secp256r1Ecdsa
234        bcs_bytes.push(65); // ULEB128(65)
235        bcs_bytes.extend_from_slice(&uncompressed);
236        let key = derive_authentication_key(&bcs_bytes, SINGLE_KEY_SCHEME);
237        AuthenticationKey::new(key)
238    }
239
240    fn sign(&self, message: &[u8]) -> AptosResult<Vec<u8>> {
241        // Step 1: challenge = SHA3-256(signing_message(raw_txn)).
242        // `message` here is `signing_message(raw_txn)`; `Aptos::build_transaction`
243        // and `sign_transaction` produce that buffer.
244        let challenge = sha3_256(message);
245        let challenge_b64 = base64url_no_pad(&challenge);
246
247        // Step 2: build the WebAuthn envelope.
248        let authenticator_data = self.build_authenticator_data();
249        let client_data_json = self.build_client_data_json(&challenge_b64);
250
251        // Step 3: verification_data = authenticator_data || SHA-256(clientDataJSON)
252        let client_data_hash = sha2_256(&client_data_json);
253        let mut verification_data =
254            Vec::with_capacity(authenticator_data.len() + client_data_hash.len());
255        verification_data.extend_from_slice(&authenticator_data);
256        verification_data.extend_from_slice(&client_data_hash);
257
258        // Step 4: sign verification_data with secp256r1. p256's Signer::sign
259        // hashes with SHA-256 internally, so the resulting signature is over
260        // SHA-256(verification_data), matching the chain's verifier
261        // (`p256::ecdsa::VerifyingKey::verify(verification_data, sig)`).
262        let signature = self.private_key.sign(&verification_data);
263        let sig_bytes = signature.to_bytes();
264        debug_assert_eq!(sig_bytes.len(), SECP256R1_SIGNATURE_LENGTH);
265
266        // Step 5: BCS-encode the PartialAuthenticatorAssertionResponse.
267        //
268        //   variant tag = 0x00 (AssertionSignature::Secp256r1Ecdsa)
269        //   secp256r1_ecdsa::Signature BCS = ULEB128(64) || 64 sig bytes
270        //   authenticator_data Vec<u8> = ULEB128(37) || 37 bytes
271        //   client_data_json Vec<u8>  = ULEB128(len) || bytes
272        let mut paar = Vec::with_capacity(
273            1 + 1
274                + SECP256R1_SIGNATURE_LENGTH
275                + 1
276                + authenticator_data.len()
277                + 2
278                + client_data_json.len(),
279        );
280        paar.push(ASSERTION_SIGNATURE_SECP256R1_TAG);
281        paar.extend(uleb128_encode(SECP256R1_SIGNATURE_LENGTH));
282        paar.extend_from_slice(&sig_bytes);
283        paar.extend(uleb128_encode(authenticator_data.len()));
284        paar.extend_from_slice(&authenticator_data);
285        paar.extend(uleb128_encode(client_data_json.len()));
286        paar.extend_from_slice(&client_data_json);
287
288        // Step 6: wrap as AnySignature::WebAuthn (variant tag 2 in the on-chain
289        // AnySignature enum). The on-chain layout is
290        //   `AnySignature::WebAuthn { signature: PartialAuthenticatorAssertionResponse }`
291        // which BCS-serializes as `variant_tag(1) || BCS(PAAR fields inlined)`.
292        // PartialAuthenticatorAssertionResponse is a *struct*, so its fields
293        // appear directly after the variant tag with no outer length prefix.
294        let mut out = Vec::with_capacity(1 + paar.len());
295        out.push(ANY_SIGNATURE_WEBAUTHN_TAG);
296        out.extend_from_slice(&paar);
297        Ok(out)
298    }
299
300    fn public_key_bytes(&self) -> Vec<u8> {
301        // BCS-serialized `AnyPublicKey::Secp256r1Ecdsa`
302        // (variant=2, ULEB128(65), 65 bytes SEC1 uncompressed).
303        let uncompressed = self.public_key.to_uncompressed_bytes();
304        let mut out = Vec::with_capacity(1 + 1 + uncompressed.len());
305        out.push(0x02);
306        out.push(65);
307        out.extend_from_slice(&uncompressed);
308        out
309    }
310
311    fn signature_scheme(&self) -> u8 {
312        SINGLE_KEY_SCHEME
313    }
314}
315
316impl fmt::Debug for WebAuthnAccount {
317    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318        // SECURITY: intentionally omit `private_key`.
319        f.debug_struct("WebAuthnAccount")
320            .field("address", &self.address)
321            .field("public_key", &self.public_key)
322            .field("origin", &self.origin)
323            .finish_non_exhaustive()
324    }
325}
326
327/// Base64url (RFC 4648 ยง5) without padding. Pure ASCII output.
328fn base64url_no_pad(input: &[u8]) -> String {
329    const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
330    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
331    let mut i = 0;
332    while i + 3 <= input.len() {
333        let b0 = input[i];
334        let b1 = input[i + 1];
335        let b2 = input[i + 2];
336        out.push(ALPHABET[(b0 >> 2) as usize] as char);
337        out.push(ALPHABET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
338        out.push(ALPHABET[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char);
339        out.push(ALPHABET[(b2 & 0x3f) as usize] as char);
340        i += 3;
341    }
342    match input.len() - i {
343        1 => {
344            let b0 = input[i];
345            out.push(ALPHABET[(b0 >> 2) as usize] as char);
346            out.push(ALPHABET[((b0 & 0x03) << 4) as usize] as char);
347        }
348        2 => {
349            let b0 = input[i];
350            let b1 = input[i + 1];
351            out.push(ALPHABET[(b0 >> 2) as usize] as char);
352            out.push(ALPHABET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
353            out.push(ALPHABET[((b1 & 0x0f) << 2) as usize] as char);
354        }
355        _ => {}
356    }
357    out
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::account::Account;
364
365    #[test]
366    fn test_base64url_known_values() {
367        // RFC 4648 examples (no padding) plus the "f" sequence.
368        assert_eq!(base64url_no_pad(b""), "");
369        assert_eq!(base64url_no_pad(b"f"), "Zg");
370        assert_eq!(base64url_no_pad(b"fo"), "Zm8");
371        assert_eq!(base64url_no_pad(b"foo"), "Zm9v");
372        assert_eq!(base64url_no_pad(b"foob"), "Zm9vYg");
373        assert_eq!(base64url_no_pad(b"fooba"), "Zm9vYmE");
374        assert_eq!(base64url_no_pad(b"foobar"), "Zm9vYmFy");
375        // Distinguishing - and _ from + and / (base64 vs base64url).
376        // 0xFB is `1111_1011`, with one byte of input we get the high-6
377        // bits `111110 = 62` (the 63rd alphabet char => '-' in url-safe).
378        let one_byte_fb = base64url_no_pad(&[0xFBu8]);
379        assert!(one_byte_fb.starts_with('-'));
380    }
381
382    #[test]
383    fn test_webauthn_account_generate_deterministic_address() {
384        let key = Secp256r1PrivateKey::from_bytes(&[7u8; 32]).unwrap();
385        let a = WebAuthnAccount::from_private_key(key.clone());
386        let b = WebAuthnAccount::from_private_key(key);
387        assert_eq!(a.address(), b.address());
388        assert!(!a.address().is_zero());
389    }
390
391    #[test]
392    fn test_webauthn_account_address_matches_secp256r1() {
393        // A WebAuthn account uses the same `AnyPublicKey::Secp256r1Ecdsa`
394        // variant for auth-key derivation as a Secp256r1Account, so the
395        // derived addresses must match exactly for the same private key.
396        #![allow(deprecated)]
397        let key = Secp256r1PrivateKey::from_bytes(&[42u8; 32]).unwrap();
398        let webauthn = WebAuthnAccount::from_private_key(key.clone());
399        let plain = super::super::Secp256r1Account::from_private_key(key);
400        assert_eq!(webauthn.address(), plain.address());
401    }
402
403    #[test]
404    fn test_webauthn_account_signature_envelope_shape() {
405        let account =
406            WebAuthnAccount::from_private_key(Secp256r1PrivateKey::from_bytes(&[1u8; 32]).unwrap());
407        let signing_message = b"signing message under test";
408        let signed = account.sign(signing_message).unwrap();
409
410        // Outer wrapper: AnySignature::WebAuthn = variant 2, then the
411        // PartialAuthenticatorAssertionResponse struct's fields inlined
412        // (no outer length prefix -- BCS only adds length prefixes for
413        // dynamically-sized fields, not for typed struct fields of enum
414        // variants).
415        assert_eq!(signed[0], 0x02, "AnySignature variant must be WebAuthn (2)");
416
417        // Inner paar starts immediately after the variant byte:
418        //   variant 0 (Secp256r1Ecdsa), ULEB128(64), 64 sig bytes,
419        //   ULEB128(37) (authenticator_data), 37 bytes,
420        //   ULEB128(len), client_data_json bytes.
421        let paar = &signed[1..];
422        assert_eq!(
423            paar[0], 0x00,
424            "AssertionSignature variant must be Secp256r1Ecdsa (0)"
425        );
426        assert_eq!(paar[1], 64, "secp256r1 signature length prefix must be 64");
427        let auth_data_prefix = paar[1 + 1 + 64];
428        assert_eq!(
429            auth_data_prefix, 37,
430            "authenticator_data length prefix must be 37"
431        );
432        let auth_data_start = 1 + 1 + 64 + 1;
433        assert_eq!(
434            paar[auth_data_start + 32],
435            AUTHENTICATOR_DATA_FLAGS,
436            "flags byte must indicate UP|UV"
437        );
438    }
439
440    #[test]
441    fn test_webauthn_client_data_json_contains_challenge() {
442        let account =
443            WebAuthnAccount::from_private_key(Secp256r1PrivateKey::from_bytes(&[3u8; 32]).unwrap());
444        let signing_message = b"another signing message";
445        let signed = account.sign(signing_message).unwrap();
446
447        // Skip the AnySignature variant byte to find the PAAR struct fields.
448        let paar = &signed[1..];
449
450        // Skip AssertionSignature::Secp256r1Ecdsa header (1 + 1 + 64) and
451        // authenticator_data (1 + 37) to reach the client_data_json field.
452        let mut off = 1 + 1 + 64 + 1 + 37;
453        let (client_len, client_prefix_len) = decode_uleb128(&paar[off..]);
454        off += client_prefix_len;
455        let client_json = &paar[off..off + client_len];
456        let s = std::str::from_utf8(client_json).expect("client_data_json must be UTF-8");
457
458        // Expected challenge is SHA3-256(signing_message) base64url-no-pad.
459        let expected_challenge = base64url_no_pad(&sha3_256(signing_message));
460        assert!(
461            s.contains(&format!("\"challenge\":\"{expected_challenge}\"")),
462            "client_data_json must embed the challenge: {s}"
463        );
464        assert!(s.contains(r#""type":"webauthn.get""#));
465        assert!(s.contains(r#""origin":""#));
466        assert!(s.contains(r#""crossOrigin":false"#));
467    }
468
469    #[test]
470    fn test_base64url_url_safe_underscore() {
471        // 0xFF -> high six bits are `111111 = 63`, the 64th alphabet char,
472        // which is '_' in the url-safe alphabet (vs '/' in standard base64).
473        let out = base64url_no_pad(&[0xFFu8]);
474        assert!(out.starts_with('_'));
475    }
476
477    #[test]
478    fn test_from_private_key_bytes_valid_and_invalid() {
479        let account = WebAuthnAccount::from_private_key_bytes(&[5u8; 32]).unwrap();
480        assert!(!account.address().is_zero());
481
482        // Wrong length is rejected by the underlying P-256 scalar parser.
483        assert!(WebAuthnAccount::from_private_key_bytes(&[5u8; 16]).is_err());
484    }
485
486    #[test]
487    fn test_from_parts_custom_origin_reflected() {
488        let key = Secp256r1PrivateKey::from_bytes(&[8u8; 32]).unwrap();
489        let origin = "https://example.com";
490        let account = WebAuthnAccount::from_parts(key, "my-rp", origin);
491        let signed = account.sign(b"custom origin message").unwrap();
492
493        // Reach the client_data_json field and confirm the origin round-trips.
494        let paar = &signed[1..];
495        let mut off = 1 + 1 + 64 + 1 + 37;
496        let (client_len, prefix_len) = decode_uleb128(&paar[off..]);
497        off += prefix_len;
498        let json = std::str::from_utf8(&paar[off..off + client_len]).unwrap();
499        assert!(json.contains(&format!("\"origin\":\"{origin}\"")));
500    }
501
502    #[test]
503    fn test_from_parts_origin_json_escaping() {
504        // Origin containing a double-quote, backslash, and control char must be
505        // escaped so the resulting client_data_json stays valid JSON.
506        let key = Secp256r1PrivateKey::from_bytes(&[11u8; 32]).unwrap();
507        let origin = "a\"b\\c\u{0001}d";
508        let account = WebAuthnAccount::from_parts(key, "rp", origin);
509        let signed = account.sign(b"escaping message").unwrap();
510
511        let paar = &signed[1..];
512        let mut off = 1 + 1 + 64 + 1 + 37;
513        let (client_len, prefix_len) = decode_uleb128(&paar[off..]);
514        off += prefix_len;
515        let json = std::str::from_utf8(&paar[off..off + client_len]).unwrap();
516        assert!(json.contains("a\\\"b\\\\c\\u0001d"));
517        // Round-trips as valid JSON with the unescaped origin.
518        let parsed: serde_json::Value = serde_json::from_str(json).unwrap();
519        assert_eq!(parsed["origin"], origin);
520    }
521
522    #[test]
523    fn test_accessors_public_and_private_key() {
524        let key = Secp256r1PrivateKey::from_bytes(&[13u8; 32]).unwrap();
525        let account = WebAuthnAccount::from_private_key(key.clone());
526        assert_eq!(account.public_key().to_bytes(), key.public_key().to_bytes());
527        assert_eq!(account.private_key().to_bytes(), key.to_bytes());
528    }
529
530    #[test]
531    fn test_authentication_key_matches_address() {
532        let account = WebAuthnAccount::from_private_key(
533            Secp256r1PrivateKey::from_bytes(&[17u8; 32]).unwrap(),
534        );
535        let auth_key = account.authentication_key();
536        // Auth key equals the address for single-key accounts.
537        assert_eq!(auth_key.as_bytes(), &account.address().to_bytes());
538    }
539
540    #[test]
541    fn test_public_key_bytes_layout() {
542        let account = WebAuthnAccount::from_private_key(
543            Secp256r1PrivateKey::from_bytes(&[19u8; 32]).unwrap(),
544        );
545        let bytes = account.public_key_bytes();
546        assert_eq!(bytes.len(), 1 + 1 + 65);
547        assert_eq!(bytes[0], 0x02, "AnyPublicKey::Secp256r1Ecdsa variant");
548        assert_eq!(bytes[1], 65, "ULEB128(65) length prefix");
549    }
550
551    #[test]
552    fn test_signature_scheme_is_single_key() {
553        let account = WebAuthnAccount::generate();
554        assert_eq!(account.signature_scheme(), SINGLE_KEY_SCHEME);
555    }
556
557    #[test]
558    fn test_debug_omits_private_key() {
559        let account = WebAuthnAccount::from_private_key(
560            Secp256r1PrivateKey::from_bytes(&[23u8; 32]).unwrap(),
561        );
562        let debug = format!("{account:?}");
563        assert!(debug.contains("WebAuthnAccount"));
564        assert!(debug.contains("address"));
565        assert!(debug.contains("origin"));
566        assert!(!debug.contains("private_key"));
567    }
568
569    /// Tiny in-test ULEB128 decoder so we don't have to plumb the
570    /// `crate::crypto::multi_key::uleb128_decode` private helper through
571    /// this module's tests.
572    fn decode_uleb128(bytes: &[u8]) -> (usize, usize) {
573        let mut value: usize = 0;
574        let mut shift = 0;
575        let mut i = 0;
576        loop {
577            let b = bytes[i];
578            value |= ((b & 0x7F) as usize) << shift;
579            i += 1;
580            if (b & 0x80) == 0 {
581                break;
582            }
583            shift += 7;
584        }
585        (value, i)
586    }
587}