|
| 1 | +//! End-to-end test of WebAuthn largeBlob.read against the virt authenticator. |
| 2 | +
|
| 3 | +use std::time::Duration; |
| 4 | + |
| 5 | +use libwebauthn::ops::webauthn::{ |
| 6 | + GetAssertionLargeBlobExtension, GetAssertionRequest, GetAssertionRequestExtensions, |
| 7 | + MakeCredentialLargeBlobExtension, MakeCredentialLargeBlobExtensionInput, |
| 8 | + MakeCredentialRequest, MakeCredentialsRequestExtensions, ResidentKeyRequirement, |
| 9 | + UserVerificationRequirement, |
| 10 | +}; |
| 11 | +use libwebauthn::proto::ctap2::{ |
| 12 | + Ctap2, Ctap2CredentialType, Ctap2GetAssertionRequest, Ctap2LargeBlobsRequest, |
| 13 | + Ctap2PublicKeyCredentialDescriptor, Ctap2PublicKeyCredentialRpEntity, |
| 14 | + Ctap2PublicKeyCredentialUserEntity, |
| 15 | +}; |
| 16 | +use libwebauthn::transport::{Channel, Device}; |
| 17 | +use libwebauthn::webauthn::WebAuthn; |
| 18 | +use libwebauthn::UvUpdate; |
| 19 | +use libwebauthn_tests::virt::get_virtual_device; |
| 20 | +use rand::{thread_rng, Rng}; |
| 21 | +use test_log::test; |
| 22 | +use tokio::sync::broadcast::Receiver; |
| 23 | + |
| 24 | +const TIMEOUT: Duration = Duration::from_secs(10); |
| 25 | +const RP: &str = "example.org"; |
| 26 | + |
| 27 | +async fn handle_updates(mut state_recv: Receiver<UvUpdate>) { |
| 28 | + // MakeCredential update |
| 29 | + assert_eq!(state_recv.recv().await, Ok(UvUpdate::PresenceRequired)); |
| 30 | + // GetAssertion update |
| 31 | + assert_eq!(state_recv.recv().await, Ok(UvUpdate::PresenceRequired)); |
| 32 | +} |
| 33 | + |
| 34 | +#[test(tokio::test)] |
| 35 | +async fn test_webauthn_large_blob_read_returns_planted_blob() { |
| 36 | + let mut device = get_virtual_device(); |
| 37 | + let mut channel = device.channel().await.unwrap(); |
| 38 | + |
| 39 | + let user_id: [u8; 32] = thread_rng().gen(); |
| 40 | + let challenge: [u8; 32] = thread_rng().gen(); |
| 41 | + |
| 42 | + let make = MakeCredentialRequest { |
| 43 | + origin: RP.into(), |
| 44 | + challenge: challenge.to_vec(), |
| 45 | + relying_party: Ctap2PublicKeyCredentialRpEntity::new(RP, RP), |
| 46 | + user: Ctap2PublicKeyCredentialUserEntity::new(&user_id, "alice", "Alice"), |
| 47 | + resident_key: Some(ResidentKeyRequirement::Required), |
| 48 | + user_verification: UserVerificationRequirement::Discouraged, |
| 49 | + algorithms: vec![Ctap2CredentialType::default()], |
| 50 | + exclude: None, |
| 51 | + extensions: Some(MakeCredentialsRequestExtensions { |
| 52 | + large_blob: Some(MakeCredentialLargeBlobExtensionInput { |
| 53 | + support: MakeCredentialLargeBlobExtension::Required, |
| 54 | + }), |
| 55 | + ..Default::default() |
| 56 | + }), |
| 57 | + timeout: TIMEOUT, |
| 58 | + top_origin: None, |
| 59 | + }; |
| 60 | + |
| 61 | + let state_recv = channel.get_ux_update_receiver(); |
| 62 | + let update_handle = tokio::spawn(handle_updates(state_recv)); |
| 63 | + |
| 64 | + let response = channel |
| 65 | + .webauthn_make_credential(&make) |
| 66 | + .await |
| 67 | + .expect("MakeCredential should succeed"); |
| 68 | + assert_eq!( |
| 69 | + response |
| 70 | + .unsigned_extensions_output |
| 71 | + .large_blob |
| 72 | + .as_ref() |
| 73 | + .and_then(|lb| lb.supported), |
| 74 | + Some(true), |
| 75 | + "device must report largeBlob.supported=true" |
| 76 | + ); |
| 77 | + let credential: Ctap2PublicKeyCredentialDescriptor = |
| 78 | + (&response.authenticator_data).try_into().unwrap(); |
| 79 | + |
| 80 | + let key = capture_large_blob_key(&mut channel, &credential, &challenge).await; |
| 81 | + let plaintext = b"hello, planted largeBlob".to_vec(); |
| 82 | + let nonce: [u8; 12] = thread_rng().gen(); |
| 83 | + let serialized = encode_serialized_array(&[encode_entry(&key, &nonce, &plaintext)]); |
| 84 | + plant_large_blob_array(&mut channel, serialized).await; |
| 85 | + |
| 86 | + let ga = GetAssertionRequest { |
| 87 | + relying_party_id: RP.into(), |
| 88 | + origin: RP.into(), |
| 89 | + challenge: challenge.to_vec(), |
| 90 | + allow: vec![credential], |
| 91 | + user_verification: UserVerificationRequirement::Discouraged, |
| 92 | + extensions: Some(GetAssertionRequestExtensions { |
| 93 | + appid: None, |
| 94 | + cred_blob: false, |
| 95 | + prf: None, |
| 96 | + large_blob: Some(GetAssertionLargeBlobExtension::Read), |
| 97 | + }), |
| 98 | + timeout: TIMEOUT, |
| 99 | + top_origin: None, |
| 100 | + }; |
| 101 | + let ga_response = channel |
| 102 | + .webauthn_get_assertion(&ga) |
| 103 | + .await |
| 104 | + .expect("GetAssertion should succeed"); |
| 105 | + let blob = ga_response.assertions[0] |
| 106 | + .unsigned_extensions_output |
| 107 | + .as_ref() |
| 108 | + .and_then(|u| u.large_blob.as_ref()) |
| 109 | + .and_then(|lb| lb.blob.as_ref()) |
| 110 | + .expect("largeBlob.blob populated"); |
| 111 | + assert_eq!(blob.as_slice(), plaintext.as_slice()); |
| 112 | + |
| 113 | + update_handle.await.unwrap(); |
| 114 | +} |
| 115 | + |
| 116 | +/// Capture the per-credential AES key via a direct CTAP GetAssertion. |
| 117 | +async fn capture_large_blob_key( |
| 118 | + channel: &mut libwebauthn::transport::hid::channel::HidChannel<'_>, |
| 119 | + credential: &Ctap2PublicKeyCredentialDescriptor, |
| 120 | + challenge: &[u8; 32], |
| 121 | +) -> [u8; 32] { |
| 122 | + let ga_for_key = GetAssertionRequest { |
| 123 | + relying_party_id: RP.into(), |
| 124 | + origin: RP.into(), |
| 125 | + challenge: challenge.to_vec(), |
| 126 | + allow: vec![credential.clone()], |
| 127 | + user_verification: UserVerificationRequirement::Discouraged, |
| 128 | + extensions: Some(GetAssertionRequestExtensions { |
| 129 | + appid: None, |
| 130 | + cred_blob: false, |
| 131 | + prf: None, |
| 132 | + large_blob: Some(GetAssertionLargeBlobExtension::Read), |
| 133 | + }), |
| 134 | + timeout: TIMEOUT, |
| 135 | + top_origin: None, |
| 136 | + }; |
| 137 | + let ctap_req: Ctap2GetAssertionRequest = ga_for_key.into(); |
| 138 | + let ctap_resp = channel |
| 139 | + .ctap2_get_assertion(&ctap_req, TIMEOUT) |
| 140 | + .await |
| 141 | + .expect("CTAP get_assertion succeeds"); |
| 142 | + let key_buf = ctap_resp |
| 143 | + .large_blob_key |
| 144 | + .expect("device returns largeBlobKey when extension requested"); |
| 145 | + key_buf |
| 146 | + .as_slice() |
| 147 | + .try_into() |
| 148 | + .expect("largeBlobKey is 32 bytes") |
| 149 | +} |
| 150 | + |
| 151 | +/// Plant a serialized largeBlobArray via direct CTAP set (no-PIN path). |
| 152 | +async fn plant_large_blob_array( |
| 153 | + channel: &mut libwebauthn::transport::hid::channel::HidChannel<'_>, |
| 154 | + serialized: Vec<u8>, |
| 155 | +) { |
| 156 | + let length = serialized.len() as u32; |
| 157 | + let req = Ctap2LargeBlobsRequest { |
| 158 | + get: None, |
| 159 | + set: Some(serde_bytes::ByteBuf::from(serialized)), |
| 160 | + offset: 0, |
| 161 | + length: Some(length), |
| 162 | + pin_uv_auth_param: None, |
| 163 | + pin_uv_auth_protocol: None, |
| 164 | + }; |
| 165 | + channel |
| 166 | + .ctap2_large_blobs(&req, TIMEOUT) |
| 167 | + .await |
| 168 | + .expect("authenticatorLargeBlobs(set) succeeds without PIN"); |
| 169 | +} |
| 170 | + |
| 171 | +/// Encode one largeBlobMap entry per CTAP 2.1 §6.10.3. |
| 172 | +fn encode_entry(key: &[u8; 32], nonce: &[u8; 12], plaintext: &[u8]) -> Vec<u8> { |
| 173 | + use aes_gcm::aead::Aead; |
| 174 | + use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce}; |
| 175 | + use flate2::write::DeflateEncoder; |
| 176 | + use flate2::Compression; |
| 177 | + use serde_cbor_2::value::Value as CborVal; |
| 178 | + use std::collections::BTreeMap; |
| 179 | + use std::io::Write; |
| 180 | + |
| 181 | + let mut compressed = Vec::new(); |
| 182 | + { |
| 183 | + let mut enc = DeflateEncoder::new(&mut compressed, Compression::default()); |
| 184 | + enc.write_all(plaintext).unwrap(); |
| 185 | + enc.finish().unwrap(); |
| 186 | + } |
| 187 | + let mut ad = b"blob".to_vec(); |
| 188 | + ad.extend_from_slice(&(plaintext.len() as u64).to_le_bytes()); |
| 189 | + let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key)); |
| 190 | + let ciphertext = cipher |
| 191 | + .encrypt( |
| 192 | + Nonce::from_slice(nonce), |
| 193 | + aes_gcm::aead::Payload { |
| 194 | + msg: &compressed, |
| 195 | + aad: &ad, |
| 196 | + }, |
| 197 | + ) |
| 198 | + .unwrap(); |
| 199 | + |
| 200 | + let mut map = BTreeMap::new(); |
| 201 | + map.insert(CborVal::Integer(1), CborVal::Bytes(ciphertext)); |
| 202 | + map.insert(CborVal::Integer(2), CborVal::Bytes(nonce.to_vec())); |
| 203 | + map.insert( |
| 204 | + CborVal::Integer(3), |
| 205 | + CborVal::Integer(plaintext.len() as i128), |
| 206 | + ); |
| 207 | + let mut buf = Vec::new(); |
| 208 | + serde_cbor_2::to_writer(&mut buf, &CborVal::Map(map)).unwrap(); |
| 209 | + buf |
| 210 | +} |
| 211 | + |
| 212 | +/// Wrap entries in a CBOR array + 16-byte left-SHA-256 trailer (CTAP 2.1 §6.10.3). |
| 213 | +fn encode_serialized_array(entries: &[Vec<u8>]) -> Vec<u8> { |
| 214 | + use sha2::{Digest, Sha256}; |
| 215 | + assert!(entries.len() <= 23, "test fixture uses short-form CBOR array"); |
| 216 | + let mut out = vec![0x80 | entries.len() as u8]; |
| 217 | + for e in entries { |
| 218 | + out.extend_from_slice(e); |
| 219 | + } |
| 220 | + let h = Sha256::digest(&out); |
| 221 | + out.extend_from_slice(&h[..16]); |
| 222 | + out |
| 223 | +} |
0 commit comments