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 /// Tiny in-test ULEB128 decoder so we don't have to plumb the
470 /// `crate::crypto::multi_key::uleb128_decode` private helper through
471 /// this module's tests.
472 fn decode_uleb128(bytes: &[u8]) -> (usize, usize) {
473 let mut value: usize = 0;
474 let mut shift = 0;
475 let mut i = 0;
476 loop {
477 let b = bytes[i];
478 value |= ((b & 0x7F) as usize) << shift;
479 i += 1;
480 if (b & 0x80) == 0 {
481 break;
482 }
483 shift += 7;
484 }
485 (value, i)
486 }
487}