#![allow(clippy::doc_markdown)]
use crate::account::account::{Account, AuthenticationKey};
use crate::crypto::multi_key::uleb128_encode;
use crate::crypto::{
SINGLE_KEY_SCHEME, Secp256r1PrivateKey, Secp256r1PublicKey, derive_authentication_key,
sha2_256, sha3_256,
};
use crate::error::AptosResult;
use crate::types::AccountAddress;
use std::fmt;
pub const DEFAULT_WEBAUTHN_RP_ID: &str = "aptos-rust-sdk";
pub const DEFAULT_WEBAUTHN_ORIGIN: &str = "https://aptos-rust-sdk.local";
const ANY_SIGNATURE_WEBAUTHN_TAG: u8 = 0x02;
const ASSERTION_SIGNATURE_SECP256R1_TAG: u8 = 0x00;
const AUTHENTICATOR_DATA_FLAGS: u8 = 0x05;
const SECP256R1_SIGNATURE_LENGTH: usize = 64;
#[derive(Clone)]
pub struct WebAuthnAccount {
private_key: Secp256r1PrivateKey,
public_key: Secp256r1PublicKey,
address: AccountAddress,
rp_id_hash: [u8; 32],
origin: String,
}
impl WebAuthnAccount {
#[must_use]
pub fn generate() -> Self {
Self::from_private_key(Secp256r1PrivateKey::generate())
}
#[must_use]
pub fn from_private_key(private_key: Secp256r1PrivateKey) -> Self {
Self::from_parts(private_key, DEFAULT_WEBAUTHN_RP_ID, DEFAULT_WEBAUTHN_ORIGIN)
}
#[must_use]
pub fn from_parts(private_key: Secp256r1PrivateKey, rp_id: &str, origin: &str) -> Self {
let public_key = private_key.public_key();
let address = public_key.to_address();
let rp_id_hash = sha2_256(rp_id.as_bytes());
Self {
private_key,
public_key,
address,
rp_id_hash,
origin: origin.to_owned(),
}
}
pub fn from_private_key_bytes(bytes: &[u8]) -> AptosResult<Self> {
let private_key = Secp256r1PrivateKey::from_bytes(bytes)?;
Ok(Self::from_private_key(private_key))
}
#[must_use]
pub fn address(&self) -> AccountAddress {
self.address
}
#[must_use]
pub fn public_key(&self) -> &Secp256r1PublicKey {
&self.public_key
}
#[must_use]
pub fn private_key(&self) -> &Secp256r1PrivateKey {
&self.private_key
}
fn build_authenticator_data(&self) -> [u8; 37] {
let mut out = [0u8; 37];
out[..32].copy_from_slice(&self.rp_id_hash);
out[32] = AUTHENTICATOR_DATA_FLAGS;
out[33..37].copy_from_slice(&[0u8; 4]);
out
}
fn build_client_data_json(&self, challenge_b64url: &str) -> Vec<u8> {
let mut out = String::with_capacity(128 + challenge_b64url.len() + self.origin.len());
out.push_str(r#"{"type":"webauthn.get","challenge":""#);
out.push_str(challenge_b64url);
out.push_str(r#"","origin":""#);
Self::push_json_escaped(&mut out, &self.origin);
out.push_str(r#"","crossOrigin":false}"#);
out.into_bytes()
}
fn push_json_escaped(dst: &mut String, src: &str) {
use std::fmt::Write as _;
for ch in src.chars() {
match ch {
'"' => dst.push_str("\\\""),
'\\' => dst.push_str("\\\\"),
c if (c as u32) < 0x20 => {
let _ = write!(dst, "\\u{:04x}", c as u32);
}
c => dst.push(c),
}
}
}
}
impl Account for WebAuthnAccount {
fn address(&self) -> AccountAddress {
self.address
}
fn authentication_key(&self) -> AuthenticationKey {
let uncompressed = self.public_key.to_uncompressed_bytes();
let mut bcs_bytes = Vec::with_capacity(1 + 1 + uncompressed.len());
bcs_bytes.push(0x02); bcs_bytes.push(65); bcs_bytes.extend_from_slice(&uncompressed);
let key = derive_authentication_key(&bcs_bytes, SINGLE_KEY_SCHEME);
AuthenticationKey::new(key)
}
fn sign(&self, message: &[u8]) -> AptosResult<Vec<u8>> {
let challenge = sha3_256(message);
let challenge_b64 = base64url_no_pad(&challenge);
let authenticator_data = self.build_authenticator_data();
let client_data_json = self.build_client_data_json(&challenge_b64);
let client_data_hash = sha2_256(&client_data_json);
let mut verification_data =
Vec::with_capacity(authenticator_data.len() + client_data_hash.len());
verification_data.extend_from_slice(&authenticator_data);
verification_data.extend_from_slice(&client_data_hash);
let signature = self.private_key.sign(&verification_data);
let sig_bytes = signature.to_bytes();
debug_assert_eq!(sig_bytes.len(), SECP256R1_SIGNATURE_LENGTH);
let mut paar = Vec::with_capacity(
1 + 1
+ SECP256R1_SIGNATURE_LENGTH
+ 1
+ authenticator_data.len()
+ 2
+ client_data_json.len(),
);
paar.push(ASSERTION_SIGNATURE_SECP256R1_TAG);
paar.extend(uleb128_encode(SECP256R1_SIGNATURE_LENGTH));
paar.extend_from_slice(&sig_bytes);
paar.extend(uleb128_encode(authenticator_data.len()));
paar.extend_from_slice(&authenticator_data);
paar.extend(uleb128_encode(client_data_json.len()));
paar.extend_from_slice(&client_data_json);
let mut out = Vec::with_capacity(1 + paar.len());
out.push(ANY_SIGNATURE_WEBAUTHN_TAG);
out.extend_from_slice(&paar);
Ok(out)
}
fn public_key_bytes(&self) -> Vec<u8> {
let uncompressed = self.public_key.to_uncompressed_bytes();
let mut out = Vec::with_capacity(1 + 1 + uncompressed.len());
out.push(0x02);
out.push(65);
out.extend_from_slice(&uncompressed);
out
}
fn signature_scheme(&self) -> u8 {
SINGLE_KEY_SCHEME
}
}
impl fmt::Debug for WebAuthnAccount {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WebAuthnAccount")
.field("address", &self.address)
.field("public_key", &self.public_key)
.field("origin", &self.origin)
.finish_non_exhaustive()
}
}
fn base64url_no_pad(input: &[u8]) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
let mut i = 0;
while i + 3 <= input.len() {
let b0 = input[i];
let b1 = input[i + 1];
let b2 = input[i + 2];
out.push(ALPHABET[(b0 >> 2) as usize] as char);
out.push(ALPHABET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
out.push(ALPHABET[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char);
out.push(ALPHABET[(b2 & 0x3f) as usize] as char);
i += 3;
}
match input.len() - i {
1 => {
let b0 = input[i];
out.push(ALPHABET[(b0 >> 2) as usize] as char);
out.push(ALPHABET[((b0 & 0x03) << 4) as usize] as char);
}
2 => {
let b0 = input[i];
let b1 = input[i + 1];
out.push(ALPHABET[(b0 >> 2) as usize] as char);
out.push(ALPHABET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
out.push(ALPHABET[((b1 & 0x0f) << 2) as usize] as char);
}
_ => {}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::account::Account;
#[test]
fn test_base64url_known_values() {
assert_eq!(base64url_no_pad(b""), "");
assert_eq!(base64url_no_pad(b"f"), "Zg");
assert_eq!(base64url_no_pad(b"fo"), "Zm8");
assert_eq!(base64url_no_pad(b"foo"), "Zm9v");
assert_eq!(base64url_no_pad(b"foob"), "Zm9vYg");
assert_eq!(base64url_no_pad(b"fooba"), "Zm9vYmE");
assert_eq!(base64url_no_pad(b"foobar"), "Zm9vYmFy");
let one_byte_fb = base64url_no_pad(&[0xFBu8]);
assert!(one_byte_fb.starts_with('-'));
}
#[test]
fn test_webauthn_account_generate_deterministic_address() {
let key = Secp256r1PrivateKey::from_bytes(&[7u8; 32]).unwrap();
let a = WebAuthnAccount::from_private_key(key.clone());
let b = WebAuthnAccount::from_private_key(key);
assert_eq!(a.address(), b.address());
assert!(!a.address().is_zero());
}
#[test]
fn test_webauthn_account_address_matches_secp256r1() {
#![allow(deprecated)]
let key = Secp256r1PrivateKey::from_bytes(&[42u8; 32]).unwrap();
let webauthn = WebAuthnAccount::from_private_key(key.clone());
let plain = super::super::Secp256r1Account::from_private_key(key);
assert_eq!(webauthn.address(), plain.address());
}
#[test]
fn test_webauthn_account_signature_envelope_shape() {
let account =
WebAuthnAccount::from_private_key(Secp256r1PrivateKey::from_bytes(&[1u8; 32]).unwrap());
let signing_message = b"signing message under test";
let signed = account.sign(signing_message).unwrap();
assert_eq!(signed[0], 0x02, "AnySignature variant must be WebAuthn (2)");
let paar = &signed[1..];
assert_eq!(
paar[0], 0x00,
"AssertionSignature variant must be Secp256r1Ecdsa (0)"
);
assert_eq!(paar[1], 64, "secp256r1 signature length prefix must be 64");
let auth_data_prefix = paar[1 + 1 + 64];
assert_eq!(
auth_data_prefix, 37,
"authenticator_data length prefix must be 37"
);
let auth_data_start = 1 + 1 + 64 + 1;
assert_eq!(
paar[auth_data_start + 32],
AUTHENTICATOR_DATA_FLAGS,
"flags byte must indicate UP|UV"
);
}
#[test]
fn test_webauthn_client_data_json_contains_challenge() {
let account =
WebAuthnAccount::from_private_key(Secp256r1PrivateKey::from_bytes(&[3u8; 32]).unwrap());
let signing_message = b"another signing message";
let signed = account.sign(signing_message).unwrap();
let paar = &signed[1..];
let mut off = 1 + 1 + 64 + 1 + 37;
let (client_len, client_prefix_len) = decode_uleb128(&paar[off..]);
off += client_prefix_len;
let client_json = &paar[off..off + client_len];
let s = std::str::from_utf8(client_json).expect("client_data_json must be UTF-8");
let expected_challenge = base64url_no_pad(&sha3_256(signing_message));
assert!(
s.contains(&format!("\"challenge\":\"{expected_challenge}\"")),
"client_data_json must embed the challenge: {s}"
);
assert!(s.contains(r#""type":"webauthn.get""#));
assert!(s.contains(r#""origin":""#));
assert!(s.contains(r#""crossOrigin":false"#));
}
fn decode_uleb128(bytes: &[u8]) -> (usize, usize) {
let mut value: usize = 0;
let mut shift = 0;
let mut i = 0;
loop {
let b = bytes[i];
value |= ((b & 0x7F) as usize) << shift;
i += 1;
if (b & 0x80) == 0 {
break;
}
shift += 7;
}
(value, i)
}
}