aptos_sdk/account/
webauthn.rs1#![allow(clippy::doc_markdown)]
7
8use 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
69pub const DEFAULT_WEBAUTHN_RP_ID: &str = "aptos-rust-sdk";
73
74pub const DEFAULT_WEBAUTHN_ORIGIN: &str = "https://aptos-rust-sdk.local";
78
79const ANY_SIGNATURE_WEBAUTHN_TAG: u8 = 0x02;
81
82const ASSERTION_SIGNATURE_SECP256R1_TAG: u8 = 0x00;
86
87const AUTHENTICATOR_DATA_FLAGS: u8 = 0x05;
92
93const SECP256R1_SIGNATURE_LENGTH: usize = 64;
95
96#[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 #[must_use]
115 pub fn generate() -> Self {
116 Self::from_private_key(Secp256r1PrivateKey::generate())
117 }
118
119 #[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 #[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 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 #[must_use]
159 pub fn address(&self) -> AccountAddress {
160 self.address
161 }
162
163 #[must_use]
165 pub fn public_key(&self) -> &Secp256r1PublicKey {
166 &self.public_key
167 }
168
169 #[must_use]
171 pub fn private_key(&self) -> &Secp256r1PrivateKey {
172 &self.private_key
173 }
174
175 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 out[33..37].copy_from_slice(&[0u8; 4]);
184 out
185 }
186
187 fn build_client_data_json(&self, challenge_b64url: &str) -> Vec<u8> {
189 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 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 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); bcs_bytes.push(65); 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 let challenge = sha3_256(message);
245 let challenge_b64 = base64url_no_pad(&challenge);
246
247 let authenticator_data = self.build_authenticator_data();
249 let client_data_json = self.build_client_data_json(&challenge_b64);
250
251 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 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 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 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 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 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
327fn 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 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 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 #![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 assert_eq!(signed[0], 0x02, "AnySignature variant must be WebAuthn (2)");
416
417 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 let paar = &signed[1..];
449
450 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 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 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 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 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 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 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 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 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}