extern crate bytes;
extern crate chromesync;
extern crate crypto;
extern crate prost;
use super::{
debug, get_secret_from_request, open_aes_256_gcm, AuthLevel, Authentication, DirtyFlag,
OneTimeUV, PINState, ParsedState, Reauth, RequestError, SourceOfSecret, COUNTER_ID_KEY,
KEY_PURPOSE_SECURITY_DOMAIN_SECRET, PUB_KEY, VAULT_HANDLE_WITHOUT_TYPE_KEY,
WRAPPED_PIN_DATA_KEY, WRAPPED_SECRET_KEY,
};
use crate::pin::VaultCohortDetails;
use crate::{pin, MetricsUpdate, CERT_XML_SERIAL_NUMBER_KEY, COHORT_PUBLIC_KEY_KEY};
use alloc::collections::BTreeMap;
use alloc::string::String;
use alloc::vec::Vec;
use base64::Engine;
use cbor::{MapKey, MapKeyRef, MapLookupKey, Value};
use chromesync::pb::webauthn_credential_specifics::EncryptedData;
use chromesync::pb::WebauthnCredentialSpecifics;
use core::ops::Deref;
use crypto::EcdsaKeyPair;
use prost::Message;
map_keys! {
CLAIMED_PIN, CLAIMED_PIN_KEY = "claimed_pin",
CLIENT_DATA_JSON, CLIENT_DATA_JSON_KEY = "client_data_json",
CLIENT_DATA_JSON_HASH, CLIENT_DATA_JSON_HASH_KEY = "client_data_json_hash",
COSE_ALGORITHM, COSE_ALGORITHM_KEY = "alg",
ENCRYPTED, ENCRYPTED_KEY = "encrypted",
EVAL, EVAL_KEY = "eval",
EVAL_BY_CREDENTIAL, EVAL_BY_CREDENTIAL_KEY = "evalByCredential",
EXTENSIONS, EXTENSIONS_KEY = "extensions",
FIRST, FIRST_KEY = "first",
LARGE_BLOB, LARGE_BLOB_KEY = "largeBlob",
LARGE_BLOB_READ, LARGE_BLOB_READ_KEY = "read",
LARGE_BLOB_WRITE, LARGE_BLOB_WRITE_KEY = "write",
LARGE_BLOB_DATA, LARGE_BLOB_DATA_KEY = "largeBlobData",
LARGE_BLOB_SIZE, LARGE_BLOB_SIZE_KEY = "largeBlobSize",
LARGE_BLOB_SUPPORTED, LARGE_BLOB_SUPPORTED_KEY = "largeBlobSupported",
LARGE_BLOB_WRITTEN, LARGE_BLOB_WRITTEN_KEY = "largeBlobWritten",
PIN_CLAIM_KEY, PIN_CLAIM_KEY_KEY = "pin_claim_key",
PIN_HASH, PIN_HASH_KEY = "pin_hash",
PRF, PRF_KEY = "prf",
PROTOBUF, PROTOBUF_KEY = "protobuf",
PUB_KEY_CRED_PARAMS, PUB_KEY_CRED_PARAMS_KEY = "pubKeyCredParams",
RP_ID, RP_ID_KEY = "rpId",
SECOND, SECOND_KEY = "second",
VERSION, VERSION_KEY = "version",
WEBAUTHN_REQUEST, WEBAUTHN_REQUEST_KEY = "request",
}
const PRIVATE_KEY_FIELD_AAD: &[u8] = b"";
const ENCRYPTED_FIELD_AAD: &[u8] = b"WebauthnCredentialSpecifics.Encrypted";
pub(crate) const PIN_CLAIM_AAD: &[u8] = b"PIN claim";
const FLAG_USER_PRESENT: u8 = 1 << 0;
const FLAG_USER_VERIFIED: u8 = 1 << 2;
const FLAG_BACKUP_ELIGIBLE: u8 = 1 << 3;
const FLAG_BACKED_UP: u8 = 1 << 4;
const ZERO_SIGNATURE_COUNTER: &[u8; 4] = &[0u8; 4];
const COSE_ALGORITHM_ECDSA_P256_SHA256: i64 = -7;
const MAX_PIN_ATTEMPTS: i64 = 5;
fn key(k: &str) -> MapKey {
MapKey::String(String::from(k))
}
pub(crate) fn do_assert(
metrics: &mut MetricsUpdate,
auth: &Authentication,
state: &mut DirtyFlag<ParsedState>,
request: BTreeMap<MapKey, Value>,
) -> Result<cbor::Value, RequestError> {
let Authentication::Device(device_id, auth_level, one_time_uv, _) = auth else {
return debug("device identity required");
};
let Some(Value::Bytestring(proto_bytes)) = request.get(PROTOBUF_KEY) else {
return debug("protobuf required");
};
let client_data_json = match request.get(CLIENT_DATA_JSON_KEY) {
Some(Value::String(client_data_json)) => Some(client_data_json),
Some(_) => return debug("clientDataJson is not a string"),
None => None,
};
let client_data_json_hash = if let Some(client_data_json) = client_data_json {
crypto::sha256(client_data_json.as_bytes())
} else {
match request.get(CLIENT_DATA_JSON_HASH_KEY) {
Some(Value::Bytestring(client_data_json_hash)) => {
client_data_json_hash.to_vec().try_into().map_err(|_| {
RequestError::Debug("clientDataJsonHash does not match expected length")
})?
}
Some(_) => return debug("clientDataJsonHash is not a bytestring"),
None => return debug("either clientDataJson or clientDataJsonHash are required"),
}
};
let Some(Value::Map(webauthn_request)) = request.get(WEBAUTHN_REQUEST_KEY) else {
return debug("WebAuthn request required");
};
let Some(Value::String(rp_id)) = webauthn_request.get(RP_ID_KEY) else {
return debug("rpId required");
};
let rp_id_hash = crypto::sha256(rp_id.as_bytes());
let (security_domain_secret, secret_source) =
get_secret_from_request(state, &request, device_id)?;
let proto = WebauthnCredentialSpecifics::decode(proto_bytes.deref())
.map_err(|_| RequestError::Debug("failed to decode protobuf"))?;
let Some(ref credential_id) = proto.credential_id else {
return debug("protobuf is missing credential ID");
};
let Some(ref user_id) = proto.user_id else {
return debug("protobuf is missing user ID");
};
let mut entity_secrets = entity_secrets_from_proto(&security_domain_secret, &proto)?;
let pin_verified =
maybe_validate_pin_from_request(&request, state, device_id, &security_domain_secret)?;
let user_verification = matches!(auth_level, AuthLevel::UserVerification)
|| matches!(auth_level, AuthLevel::SoftwareUserVerification)
|| pin_verified
|| matches!(secret_source, SourceOfSecret::Direct)
|| matches!(one_time_uv, OneTimeUV::Consumed);
let flags = [FLAG_BACKUP_ELIGIBLE
| FLAG_BACKED_UP
| FLAG_USER_PRESENT
| if user_verification {
FLAG_USER_VERIFIED
} else {
0
}];
let authenticator_data = [rp_id_hash.as_slice(), &flags, ZERO_SIGNATURE_COUNTER].concat();
let signed_data = [&authenticator_data, client_data_json_hash.as_slice()].concat();
let signature = entity_secrets
.primary_key
.sign(&signed_data)
.map_err(|_| RequestError::Debug("signing failed"))?;
let mut assertion_response_json = BTreeMap::<MapKey, Value>::from([
(key("authenticatorData"), Value::from(authenticator_data)),
(key("signature"), Value::from(signature.as_ref())),
(key("userHandle"), Value::from(user_id.to_vec())),
]);
if let Some(client_data_json) = client_data_json {
assertion_response_json.insert(
key("clientDataJSON"),
Value::String(client_data_json.clone()),
);
}
let mut response = BTreeMap::from([(key("response"), Value::Map(assertion_response_json))]);
if let Some(prf_result) = handle_prf(
webauthn_request,
&entity_secrets.hmac_secret,
Some(credential_id.as_ref()),
)? {
response.insert(key(PRF), prf_result);
}
if let Some(large_blob_out) = handle_large_blob(
webauthn_request,
&mut entity_secrets,
&security_domain_secret,
)? {
response.insert(key(LARGE_BLOB), large_blob_out);
}
metrics.passkeys_assert += 1;
Ok(Value::Map(response))
}
pub(crate) fn do_create(
metrics: &mut MetricsUpdate,
auth: &Authentication,
state: &mut DirtyFlag<ParsedState>,
request: BTreeMap<MapKey, Value>,
) -> Result<cbor::Value, RequestError> {
let Authentication::Device(device_id, _, _, _) = auth else {
return debug("device identity required");
};
let (security_domain_secret, _) = get_secret_from_request(state, &request, device_id)?;
let Some(Value::Map(webauthn_request)) = request.get(WEBAUTHN_REQUEST_KEY) else {
return debug("WebAuthn request required");
};
let Some(Value::Array(pub_key_cred_params)) = webauthn_request.get(PUB_KEY_CRED_PARAMS_KEY)
else {
return debug("missing pubKeyCredParams array");
};
let cose_algorithms: Result<Vec<i64>, RequestError> = pub_key_cred_params
.iter()
.map(|cred_param| match cred_param {
Value::Map(i) => {
let Some(Value::Int(alg)) = i.get(COSE_ALGORITHM_KEY) else {
return debug("missing algorithm");
};
Ok(*alg)
}
_ => debug("invalid algorithm type"),
})
.collect();
let cose_algorithms = cose_algorithms?;
if !cose_algorithms.contains(&COSE_ALGORITHM_ECDSA_P256_SHA256) {
return Err(RequestError::NoSupportedAlgorithm);
}
maybe_validate_pin_from_request(&request, state, device_id, &security_domain_secret)?;
let pkcs8 = EcdsaKeyPair::generate_pkcs8();
let key = EcdsaKeyPair::from_pkcs8(pkcs8.as_ref())
.map_err(|_| RequestError::Debug("failed to parse private key"))?;
let pub_key = key.public_key();
let mut hmac_secret = [0u8; 32];
crypto::rand_bytes(&mut hmac_secret);
let pb = chromesync::pb::webauthn_credential_specifics::Encrypted {
private_key: Some(pkcs8.as_ref().to_vec()),
hmac_secret: Some(hmac_secret.to_vec()),
cred_blob: None,
large_blob: None,
large_blob_uncompressed_size: None,
};
let ciphertext = encrypt(
&security_domain_secret,
pb.encode_to_vec(),
ENCRYPTED_FIELD_AAD,
)?;
let mut result = BTreeMap::from([
(
MapKey::String(String::from(ENCRYPTED)),
Value::from(ciphertext),
),
(
MapKey::String(String::from(PUB_KEY)),
Value::from(pub_key.as_ref().to_vec()),
),
]);
if let Some(prf_result) = handle_prf(webauthn_request, &hmac_secret, None)? {
result.insert(MapKey::String(String::from(PRF)), prf_result);
}
result.insert(
MapKey::String(String::from(LARGE_BLOB_SUPPORTED)),
Value::Boolean(true),
);
metrics.passkeys_create += 1;
Ok(Value::Map(result))
}
fn maybe_validate_pin_from_request(
request: &BTreeMap<MapKey, Value>,
state: &mut DirtyFlag<'_, ParsedState>,
device_id: &[u8],
security_domain_secret: &[u8; 32],
) -> Result<bool, RequestError> {
if let Some(Value::Bytestring(wrapped_pin_data)) = request.get(WRAPPED_PIN_DATA_KEY) {
let Some(Value::Bytestring(claimed_pin)) = request.get(CLAIMED_PIN_KEY) else {
return debug("claimed PIN required");
};
validate_pin(
state,
device_id,
security_domain_secret.as_ref(),
claimed_pin,
wrapped_pin_data,
)?;
Ok(true)
} else {
Ok(false)
}
}
struct EntitySecrets {
primary_key: EcdsaKeyPair,
hmac_secret: [u8; 32],
encrypted: Option<chromesync::pb::webauthn_credential_specifics::Encrypted>,
}
fn entity_secrets_from_proto(
security_domain_secret: &[u8; 32],
proto: &WebauthnCredentialSpecifics,
) -> Result<EntitySecrets, RequestError> {
let Some(encrypted_data) = &proto.encrypted_data else {
return debug("sync entity missing encrypted data");
};
match encrypted_data {
EncryptedData::PrivateKey(ciphertext) => {
let plaintext = decrypt(ciphertext, security_domain_secret, PRIVATE_KEY_FIELD_AAD)?;
let primary_key = EcdsaKeyPair::from_pkcs8(&plaintext)
.map_err(|_| RequestError::Debug("PKCS#8 parse failed"))?;
let hmac_secret = derive_hmac_secret_from_private_key(&plaintext);
Ok(EntitySecrets {
primary_key,
hmac_secret,
encrypted: None,
})
}
EncryptedData::Encrypted(ciphertext) => {
let plaintext = decrypt(ciphertext, security_domain_secret, ENCRYPTED_FIELD_AAD)?;
let encrypted = chromesync::pb::webauthn_credential_specifics::Encrypted::decode(
plaintext.as_slice(),
)
.map_err(|_| RequestError::Debug("failed to decode encrypted data"))?;
let Some(ref private_key_bytes) = encrypted.private_key else {
return debug("missing private key");
};
let primary_key = EcdsaKeyPair::from_pkcs8(private_key_bytes.as_slice())
.map_err(|_| RequestError::Debug("PKCS#8 parse failed"))?;
let hmac_secret = encrypted
.hmac_secret
.as_ref()
.and_then(|vec| <[u8; 32]>::try_from(vec.as_slice()).ok())
.unwrap_or_else(|| {
derive_hmac_secret_from_private_key(private_key_bytes.as_slice())
});
Ok(EntitySecrets {
primary_key,
hmac_secret,
encrypted: Some(encrypted),
})
}
}
}
fn derive_hmac_secret_from_private_key(pkcs8_bytes: &[u8]) -> [u8; 32] {
let mut ret = [0u8; 32];
crypto::hkdf_sha256(pkcs8_bytes, &[], b"derived PRF HMAC secret", &mut ret).unwrap();
ret
}
fn encrypt(
security_domain_secret: &[u8; 32],
mut plaintext: Vec<u8>,
aad: &[u8],
) -> Result<Vec<u8>, RequestError> {
let mut encryption_key = [0u8; 32];
security_domain_secret_to_encryption_key(security_domain_secret, &mut encryption_key);
let mut nonce_bytes = [0u8; 12];
crypto::rand_bytes(&mut nonce_bytes);
crypto::aes_256_gcm_seal_in_place(&encryption_key, &nonce_bytes, aad, &mut plaintext);
Ok([nonce_bytes.as_slice(), &plaintext].concat())
}
fn decrypt(
ciphertext: &[u8],
security_domain_secret: &[u8; 32],
aad: &[u8],
) -> Result<Vec<u8>, RequestError> {
let mut encryption_key = [0u8; 32];
security_domain_secret_to_encryption_key(security_domain_secret, &mut encryption_key);
open_aes_256_gcm(&encryption_key, ciphertext, aad)
.ok_or(RequestError::Debug("decryption failed"))
}
fn security_domain_secret_to_encryption_key(
security_domain_secret: &[u8; 32],
out_passkey_key: &mut [u8; 32],
) {
crypto::hkdf_sha256(
security_domain_secret,
&[],
b"KeychainApplicationKey:gmscore_module:com.google.android.gms.fido",
out_passkey_key,
)
.unwrap();
}
fn constant_time_compare(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut rand_bytes = [0; 32];
crypto::rand_bytes(&mut rand_bytes);
crypto::sha256_two_part(&rand_bytes, a) == crypto::sha256_two_part(&rand_bytes, b)
}
fn validate_pin(
state: &mut DirtyFlag<ParsedState>,
device_id: &[u8],
security_domain_secret: &[u8],
claim: &[u8],
wrapped_pin_data: &[u8],
) -> Result<(), RequestError> {
let PINState { attempts } = state.get_pin_state(device_id)?;
if attempts >= MAX_PIN_ATTEMPTS {
return Err(RequestError::PINLocked);
}
let pin_data = pin::Data::from_wrapped(wrapped_pin_data, security_domain_secret)?;
let claimed_pin_hash = open_aes_256_gcm(&pin_data.claim_key, claim, PIN_CLAIM_AAD)
.ok_or(RequestError::Debug("failed to decrypt PIN claim"))?;
if !constant_time_compare(&claimed_pin_hash, &pin_data.pin_hash) {
state.get_mut().set_pin_state(
device_id,
PINState {
attempts: attempts + 1,
},
)?;
return Err(RequestError::IncorrectPIN);
}
if attempts > 0 {
state
.get_mut_for_minor_change()
.set_pin_state(device_id, PINState { attempts: 0 })?;
}
Ok(())
}
pub(crate) fn do_wrap_pin(
metrics: &mut MetricsUpdate,
auth: &Authentication,
state: &mut DirtyFlag<ParsedState>,
request: BTreeMap<MapKey, Value>,
) -> Result<cbor::Value, RequestError> {
let device_id = match auth {
Authentication::Device(device_id, _, _, Reauth::Done) => device_id,
_ => return debug("PIN change needs reauth via RAPT token"),
};
let Some(Value::Bytestring(pin_hash)) = request.get(PIN_HASH_KEY) else {
return debug("pin_hash required");
};
let Some(Value::Bytestring(claim_key)) = request.get(PIN_CLAIM_KEY_KEY) else {
return debug("pin_claim_key required");
};
let Some(Value::Bytestring(wrapped_secret)) = request.get(WRAPPED_SECRET_KEY) else {
return debug("wrapped secret required");
};
let Some(Value::Bytestring(counter_id)) = request.get(COUNTER_ID_KEY) else {
return debug("counter ID required");
};
let Some(Value::Bytestring(vault_handle_without_type)) =
request.get(VAULT_HANDLE_WITHOUT_TYPE_KEY)
else {
return debug("vault handle required");
};
let cert_xml_serial_number = request.get(CERT_XML_SERIAL_NUMBER_KEY);
let cohort_public_key = request.get(COHORT_PUBLIC_KEY_KEY);
if cert_xml_serial_number.is_none() && cohort_public_key.is_some() {
return debug("cert xml serial number required");
}
if cert_xml_serial_number.is_some() && cohort_public_key.is_none() {
return debug("cohort public key required");
}
let cert_xml_serial_number = match cert_xml_serial_number {
None => None,
Some(Value::Int(cert_xml_serial_number)) => Some(cert_xml_serial_number),
_ => return debug("cert xml serial number has wrong format"),
};
let cohort_public_key = match cohort_public_key {
None => None,
Some(Value::Bytestring(cohort_public_key)) => Some(cohort_public_key),
_ => return debug("cohort public key has wrong format"),
};
let vault_cohort_details = match (cert_xml_serial_number, cohort_public_key) {
(Some(cert_xml_serial_number), Some(cohort_public_key)) => Some(VaultCohortDetails {
cert_xml_serial_number: *cert_xml_serial_number,
cohort_public_key: cohort_public_key.to_vec(),
}),
_ => None,
};
let security_domain_secret = state.unwrap(
device_id,
wrapped_secret,
KEY_PURPOSE_SECURITY_DOMAIN_SECRET,
)?;
let pin_data = pin::Data {
pin_hash: pin_hash
.as_ref()
.try_into()
.map_err(|_| RequestError::Debug("incorrect length PIN hash"))?,
claim_key: claim_key
.as_ref()
.try_into()
.map_err(|_| RequestError::Debug("incorrect length claim key"))?,
counter_id: counter_id
.as_ref()
.try_into()
.map_err(|_| RequestError::Debug("incorrect length counter id"))?,
vault_handle_without_type: vault_handle_without_type
.as_ref()
.try_into()
.map_err(|_| RequestError::Debug("incorrect length vault handle"))?,
vault_cohort_details,
};
metrics.passkeys_wrap_pin += 1;
Ok(Value::from(pin_data.encrypt(&security_domain_secret)))
}
struct PRFValues {
first: [u8; 32],
second: Option<[u8; 32]>,
}
impl PRFValues {
fn hmac(self, hmac_key: &[u8; 32]) -> Self {
PRFValues {
first: crypto::hmac_sha256(hmac_key, &self.first),
second: self
.second
.map(|second| crypto::hmac_sha256(hmac_key, &second)),
}
}
fn into_cbor(self) -> Value {
let mut ret = BTreeMap::from([(key(FIRST), Value::from(&self.first))]);
if let Some(second) = self.second {
ret.insert(key(SECOND), Value::from(&second));
}
Value::Map(ret)
}
}
impl TryFrom<&Value> for PRFValues {
type Error = RequestError;
fn try_from(v: &Value) -> Result<Self, Self::Error> {
let Value::Map(prf) = v else {
return debug("PRF value is not a map");
};
fn get_value(value: &Value) -> Result<[u8; 32], RequestError> {
let Value::String(value) = value else {
return debug("invalid PRF value");
};
let value = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(value)
.map_err(|_| RequestError::Debug("invalid PRF base64url"))?;
Ok(hash_prf_value(&value))
}
let first = get_value(
prf.get(FIRST_KEY)
.ok_or(RequestError::Debug("missing PRF first value"))?,
)?;
let second = prf.get(SECOND_KEY).map(get_value).transpose()?;
Ok(PRFValues { first, second })
}
}
fn hash_prf_value(input: &[u8]) -> [u8; 32] {
const PREFIX: &[u8] = b"WebAuthn PRF\x00";
crypto::sha256_two_part(PREFIX, input)
}
fn handle_prf(
webauthn_request: &BTreeMap<MapKey, Value>,
hmac_secret: &[u8; 32],
credential_id: Option<&[u8]>,
) -> Result<Option<Value>, RequestError> {
let Some(Value::Map(extensions)) = webauthn_request.get(EXTENSIONS_KEY) else {
return Ok(None);
};
let Some(Value::Map(prf)) = extensions.get(PRF_KEY) else {
return Ok(None);
};
if credential_id.is_none() && prf.is_empty() {
return Ok(Some(Value::Boolean(true)));
}
Ok(prf_values_by_id(prf, credential_id)?
.or(prf_default_values(prf)?)
.map(|values| values.hmac(hmac_secret))
.map(PRFValues::into_cbor))
}
fn prf_values_by_id(
prf: &BTreeMap<MapKey, Value>,
credential_id: Option<&[u8]>,
) -> Result<Option<PRFValues>, RequestError> {
let Some(credential_id) = credential_id else {
return Ok(None);
};
let Some(Value::Map(by_credential)) = prf.get(EVAL_BY_CREDENTIAL_KEY) else {
return Ok(None);
};
let base64url_credential_id =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(credential_id);
let Some(values) = by_credential.get(&MapKey::String(base64url_credential_id)) else {
return Ok(None);
};
Ok(Some(values.try_into()?))
}
fn prf_default_values(prf: &BTreeMap<MapKey, Value>) -> Result<Option<PRFValues>, RequestError> {
let Some(eval) = prf.get(EVAL_KEY) else {
return Ok(None);
};
Ok(Some(eval.try_into()?))
}
fn handle_large_blob(
webauthn_req: &BTreeMap<MapKey, Value>,
entity_secrets: &mut EntitySecrets,
security_domain_secret: &[u8; 32],
) -> Result<Option<Value>, RequestError> {
let Some(Value::Map(exts)) = webauthn_req.get(EXTENSIONS_KEY) else {
return Ok(None);
};
let Some(Value::Map(large_blob_req)) = exts.get(LARGE_BLOB_KEY) else {
return Ok(None);
};
if large_blob_req.contains_key(LARGE_BLOB_READ_KEY)
&& large_blob_req.contains_key(LARGE_BLOB_WRITE_KEY)
{
return debug("invalid largeBlob request: read + write");
}
if let Some(read_resp) = handle_read_large_blob(large_blob_req, entity_secrets) {
return Ok(Some(read_resp));
}
if let Some(write_resp) =
handle_write_large_blob(large_blob_req, entity_secrets, security_domain_secret)?
{
return Ok(Some(write_resp));
}
debug("invalid large blob request")
}
fn handle_read_large_blob(
large_blob_req: &BTreeMap<MapKey, Value>,
entity_secrets: &EntitySecrets,
) -> Option<Value> {
if !matches!(
large_blob_req.get(LARGE_BLOB_READ_KEY),
Some(Value::Boolean(true))
) {
return None;
}
let (data, size_val) = match entity_secrets.encrypted.as_ref() {
Some(enc) if enc.large_blob.is_some() && enc.large_blob_uncompressed_size.is_some() => {
let blob = enc.large_blob.as_ref().unwrap();
let size = enc.large_blob_uncompressed_size.unwrap();
(Value::from(blob.as_slice()), Value::Int(size as i64))
}
_ => (Value::from(&[] as &[u8]), Value::Int(0_i64)),
};
Some(Value::Map(BTreeMap::from([
(key(LARGE_BLOB_DATA), data),
(key(LARGE_BLOB_SIZE), size_val),
])))
}
fn handle_write_large_blob(
large_blob_req: &BTreeMap<MapKey, Value>,
entity_secrets: &mut EntitySecrets,
security_domain_secret: &[u8; 32],
) -> Result<Option<Value>, RequestError> {
let new_blob: Vec<u8> = match large_blob_req.get(LARGE_BLOB_WRITE_KEY) {
Some(Value::String(s)) => base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(s)
.map_err(|_| RequestError::Debug("invalid base64 in largeBlob.write"))?,
_ => return Ok(None),
};
const MAX_LARGE_BLOB_LEN: usize = 8 * 1024;
if new_blob.len() > MAX_LARGE_BLOB_LEN {
return Err(RequestError::Debug("large blob write > 8KiB"));
}
let Some(Value::Int(uncompressed_len)) = large_blob_req.get(LARGE_BLOB_SIZE_KEY) else {
return debug("largeBlobSize required");
};
if *uncompressed_len < 0 {
return debug("largeBlobSize must be non-negative");
}
let Some(enc) = entity_secrets.encrypted.as_mut() else {
return Ok(Some(Value::Map(BTreeMap::from([(
key(LARGE_BLOB_WRITTEN),
Value::Boolean(false),
)]))));
};
enc.large_blob = Some(new_blob.to_vec());
enc.large_blob_uncompressed_size = Some(*uncompressed_len as u64);
let ciphertext = encrypt(
security_domain_secret,
enc.encode_to_vec(),
ENCRYPTED_FIELD_AAD,
)?;
let resp = Value::Map(BTreeMap::from([
(key(LARGE_BLOB_WRITTEN), Value::Boolean(true)),
(key(ENCRYPTED), Value::from(ciphertext)),
]));
Ok(Some(resp))
}
#[cfg(test)]
pub mod tests {
use super::*;
use crate::recovery_key_store;
use crate::tests::{
PROTOBUF2_BYTES, PROTOBUF_BYTES, SAMPLE_SECURITY_DOMAIN_SECRET,
WEBAUTHN_SECRETS_ENCRYPTION_KEY,
};
use alloc::vec;
use cbor::cbor;
lazy_static! {
static ref PROTOBUF: WebauthnCredentialSpecifics =
WebauthnCredentialSpecifics::decode(PROTOBUF_BYTES).unwrap();
static ref PROTOBUF2: WebauthnCredentialSpecifics =
WebauthnCredentialSpecifics::decode(PROTOBUF2_BYTES).unwrap();
}
#[test]
fn test_decrypt() {
let Some(EncryptedData::PrivateKey(ciphertext)) = &PROTOBUF.encrypted_data else {
panic!("bad protobuf");
};
let pkcs8 = decrypt(
&ciphertext,
SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap(),
&[],
)
.unwrap();
assert!(EcdsaKeyPair::from_pkcs8(&pkcs8).is_ok());
assert!(decrypt(
&[0u8; 8],
SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap(),
&[]
)
.is_err());
}
#[test]
fn test_entity_secrets_from_proto() {
let protobuf1: &WebauthnCredentialSpecifics = &PROTOBUF;
let protobuf2: &WebauthnCredentialSpecifics = &PROTOBUF2;
for proto in [protobuf1, protobuf2] {
let result =
entity_secrets_from_proto(SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap(), proto);
assert!(result.is_ok(), "{:?}", proto);
}
}
#[test]
fn test_derived_hmac_secret() {
let secrets =
entity_secrets_from_proto(SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap(), &PROTOBUF)
.unwrap();
assert_eq!(&secrets.hmac_secret, b"\x78\xbd\x3f\x1a\xbb\x66\x52\xe3\x2d\xc1\x50\x7d\x75\x83\x73\xdc\xeb\xa5\x8a\x17\x02\x9c\xe5\x12\x73\xee\x3f\x85\xd6\xc9\x2e\x21");
let secrets = entity_secrets_from_proto(
SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap(),
&PROTOBUF2,
)
.unwrap();
assert_eq!(&secrets.hmac_secret, b"\x08\xa2\xe8\x8e\xd3\x78\xbf\xcd\x82\x5f\x0b\x06\xde\xd5\x6d\x2d\x03\xa2\x47\xff\x34\xd0\x81\x40\x52\xec\x6d\xe5\x1a\x98\x22\x91");
}
#[test]
fn test_encrypt() {
let plaintext = b"hello";
let ciphertext = encrypt(
SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap(),
plaintext.to_vec(),
&[],
)
.unwrap();
let plaintext2 = decrypt(
&ciphertext,
SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap(),
&[],
)
.unwrap();
assert_eq!(plaintext, plaintext2.as_slice());
}
#[test]
fn test_security_domain_secret_to_encryption_key() {
let mut calculated = [0u8; 32];
security_domain_secret_to_encryption_key(
SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap(),
&mut calculated,
);
assert_eq!(WEBAUTHN_SECRETS_ENCRYPTION_KEY, &calculated);
}
#[test]
fn test_constant_time_compare() {
struct Test {
a: &'static [u8],
b: &'static [u8],
expected: bool,
}
let tests: &[Test] = &[
Test {
a: b"",
b: b"",
expected: true,
},
Test {
a: b"a",
b: b"",
expected: false,
},
Test {
a: b"",
b: b"b",
expected: false,
},
Test {
a: b"a",
b: b"b",
expected: false,
},
Test {
a: b"a",
b: b"a",
expected: true,
},
Test {
a: b"abcde",
b: b"abcde",
expected: true,
},
Test {
a: b"abcdf",
b: b"abcde",
expected: false,
},
];
for (i, test) in tests.iter().enumerate() {
if constant_time_compare(test.a, test.b) != test.expected {
panic!("failed at #{}", i)
}
}
}
#[test]
fn test_pin_data() {
let pin_data = pin::Data {
pin_hash: [1u8; 32],
claim_key: [2u8; 32],
counter_id: [3u8; recovery_key_store::COUNTER_ID_LEN],
vault_handle_without_type: [4u8; recovery_key_store::VAULT_HANDLE_LEN - 1],
vault_cohort_details: None,
};
let pin_data2: pin::Data = pin_data.to_bytes().try_into().unwrap();
assert_eq!(pin_data, pin_data2);
let security_domain_secret = [3u8; 32];
let encrypted = pin_data.encrypt(&security_domain_secret);
let decrypted = pin::Data::from_wrapped(&encrypted, &security_domain_secret).unwrap();
assert_eq!(pin_data, decrypted);
}
#[test]
fn test_pin_data_with_cohort_details() {
let pin_data = pin::Data {
pin_hash: [1u8; 32],
claim_key: [2u8; 32],
counter_id: [3u8; recovery_key_store::COUNTER_ID_LEN],
vault_handle_without_type: [4u8; recovery_key_store::VAULT_HANDLE_LEN - 1],
vault_cohort_details: Some(VaultCohortDetails {
cert_xml_serial_number: 42,
cohort_public_key: vec![1, 2, 3, 4],
}),
};
let pin_data2: pin::Data = pin_data.to_bytes().try_into().unwrap();
assert_eq!(pin_data, pin_data2);
let security_domain_secret = [3u8; 32];
let encrypted = pin_data.encrypt(&security_domain_secret);
let decrypted = pin::Data::from_wrapped(&encrypted, &security_domain_secret).unwrap();
assert_eq!(pin_data, decrypted);
}
fn make_req(large_blob_val: Value) -> BTreeMap<MapKey, Value> {
let Value::Map(map) = cbor!({
"extensions": {
"largeBlob": large_blob_val
}}) else {
panic!("expected map");
};
map
}
fn empty_secrets() -> EntitySecrets {
entity_secrets_from_proto(
&SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap(),
&PROTOBUF2,
)
.unwrap()
}
#[test]
fn test_large_blob_write_then_read() {
let new_blob_content = b"new blob data".to_vec();
let b64_blob = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&new_blob_content);
let uncompressed_size = new_blob_content.len() + 10;
let webauthn_req_write = make_req(cbor!({
"write": (b64_blob.as_str()),
"largeBlobSize": (uncompressed_size as i64),
}));
let mut secrets_for_write = entity_secrets_from_proto(
&SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap(),
&PROTOBUF2,
)
.unwrap();
let enc = secrets_for_write
.encrypted
.as_mut()
.expect("encrypted should be present");
enc.large_blob = None;
enc.large_blob_uncompressed_size = None;
let result = handle_large_blob(
&webauthn_req_write,
&mut secrets_for_write,
&SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap(),
)
.unwrap();
let response_val = result.expect("expected a response");
let Value::Map(response_map) = response_val else {
panic!("expected a map response");
};
assert_eq!(
response_map.get(LARGE_BLOB_WRITTEN_KEY),
Some(&Value::Boolean(true))
);
let Value::Bytestring(new_ciphertext_bs) =
response_map.get(ENCRYPTED_KEY).expect("ciphertext present")
else {
panic!("encrypted not bytestring");
};
let plaintext = decrypt(
&new_ciphertext_bs,
&SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap(),
ENCRYPTED_FIELD_AAD,
)
.unwrap();
let encrypted_proto =
chromesync::pb::webauthn_credential_specifics::Encrypted::decode(plaintext.as_slice())
.unwrap();
assert_eq!(encrypted_proto.large_blob.unwrap(), new_blob_content);
assert_eq!(
secrets_for_write
.encrypted
.as_ref()
.and_then(|e| e.large_blob.as_ref()),
Some(&new_blob_content)
);
assert_eq!(
encrypted_proto.large_blob_uncompressed_size,
Some(uncompressed_size as u64)
);
use chromesync::pb::webauthn_credential_specifics::EncryptedData;
let mut proto_after_write = (*PROTOBUF2).clone();
proto_after_write.encrypted_data =
Some(EncryptedData::Encrypted(new_ciphertext_bs.to_vec().into()));
let mut secrets_for_read = entity_secrets_from_proto(
&SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap(),
&proto_after_write,
)
.unwrap();
let webauthn_req_read = make_req(cbor!({
"read": true
}));
let read_resp = handle_large_blob(
&webauthn_req_read,
&mut secrets_for_read,
&SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap(),
)
.unwrap()
.expect("read response");
let Value::Map(ref _read_map) = read_resp else {
panic!("map");
};
assert_eq!(
read_resp,
cbor!({
"largeBlobData": (new_blob_content.clone()),
"largeBlobSize": (uncompressed_size as i64)
})
);
}
#[test]
fn test_large_blob_write_too_large() {
let huge_blob = vec![0u8; 8 * 1024 + 1];
let huge_blob_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&huge_blob);
let req = make_req(cbor!({
"write": (huge_blob_b64.as_str()),
"largeBlobSize": (huge_blob.len() as i64)
}));
assert!(matches!(
handle_large_blob(&req,
&mut empty_secrets(),
&SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap()),
Err(RequestError::Debug(m)) if m.contains("8KiB")
));
}
#[test]
fn test_large_blob_read_and_write() {
let b64_blob = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"abc");
let req = make_req(cbor!({
"read": (true),
"write": (b64_blob.as_str()),
"largeBlobSize": (3)
}));
assert!(matches!(
handle_large_blob(&req,
&mut empty_secrets(),
&SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap()),
Err(RequestError::Debug(m)) if m.contains("read + write")
));
}
#[test]
fn test_large_blob_write_missing_size() {
let b64_blob = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"abc");
let req = make_req(cbor!({
"write": (b64_blob.as_str()),
}));
assert!(matches!(
handle_large_blob(&req,
&mut empty_secrets(),
&SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap()),
Err(RequestError::Debug(m)) if m.contains("largeBlobSize required")
));
}
#[test]
fn test_large_blob_negative_size() {
let b64_blob = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"abc");
let req = make_req(cbor!({
"write": (b64_blob.as_str()),
"largeBlobSize": (-1),
}));
assert!(matches!(
handle_large_blob(&req,
&mut empty_secrets(),
&SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap()),
Err(RequestError::Debug(m)) if m.contains("non-negative")
));
}
#[test]
fn test_large_blob_extension_absent() {
let req_no_large_blob: BTreeMap<MapKey, Value> = {
let Value::Map(map) = cbor!({
"extensions": {
}
}) else {
unreachable!();
};
map
};
let no_blob_result = handle_large_blob(
&req_no_large_blob,
&mut empty_secrets(),
&SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap(),
)
.unwrap();
assert!(
no_blob_result.is_none(),
"unexpected large blob handling when field is absent"
);
}
#[test]
fn test_large_blob_invalid_base64() {
let bad_b64 = "***not-b64***";
let req = make_req(cbor!({
"write": (bad_b64),
"largeBlobSize": 0_i64
}));
assert!(matches!(
handle_large_blob(
&req,
&mut empty_secrets(),
&SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap()
),
Err(RequestError::Debug(m)) if m.contains("invalid base64 in largeBlob.write")
));
}
#[test]
fn test_write_large_blob_on_old_credential_type() {
let new_blob_content = b"new blob data".to_vec();
let b64_blob = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&new_blob_content);
let uncompressed_size = new_blob_content.len() + 10;
let webauthn_req_write = make_req(cbor!({
"write": (b64_blob.as_str()),
"largeBlobSize": (uncompressed_size as i64),
}));
let mut secrets_for_write = entity_secrets_from_proto(
&SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap(),
&PROTOBUF,
)
.unwrap();
assert!(secrets_for_write.encrypted.is_none());
let result = handle_large_blob(
&webauthn_req_write,
&mut secrets_for_write,
&SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap(),
)
.unwrap();
let response_val = result.expect("expected a response");
let Value::Map(response_map) = response_val else {
panic!("expected a map response");
};
assert_eq!(
response_map.get(LARGE_BLOB_WRITTEN_KEY),
Some(&Value::Boolean(false))
);
}
#[test]
fn test_read_large_blob_on_old_credential_type() {
let webauthn_req_read = make_req(cbor!({
"read": true
}));
let mut secrets_for_read = entity_secrets_from_proto(
&SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap(),
&PROTOBUF,
)
.unwrap();
assert!(secrets_for_read.encrypted.is_none());
let result = handle_large_blob(
&webauthn_req_read,
&mut secrets_for_read,
&SAMPLE_SECURITY_DOMAIN_SECRET.try_into().unwrap(),
)
.unwrap();
let response_val = result.expect("expected a response");
assert_eq!(
response_val,
cbor!({
"largeBlobData": (&[] as &[u8]),
"largeBlobSize": 0,
})
);
}
}