cred: add passkey challenges and encrypted secret entry
Validation remains blocked by rusqlite u64 conversions in cred/grants.rs, which T19 owns.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
//! Browser secret entry: ECDH P-256, HKDF-SHA256, AES-256-GCM.
|
||||
//! JSON envelopes contain byte arrays: `ephemeral_public_key` (65-byte SEC1
|
||||
//! uncompressed point), `iv` (12 bytes), and `ciphertext` (includes the GCM tag).
|
||||
//! HKDF uses an empty salt and info `sausage/secret-entry/v1`. There is no AAD.
|
||||
//! The binding hash is lowercase hex SHA-256(ephemeral_public_key || iv || ciphertext).
|
||||
//! The public key uses the same SEC1 format for WebCrypto's `raw` import.
|
||||
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
|
||||
use anyhow::{Result, bail};
|
||||
use hkdf::Hkdf;
|
||||
use p256::{PublicKey, SecretKey, ecdh::diffie_hellman, elliptic_curve::sec1::ToEncodedPoint};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{fs::OpenOptions, io::Write, os::unix::fs::OpenOptionsExt, path::Path};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Envelope {
|
||||
pub ephemeral_public_key: Vec<u8>,
|
||||
pub iv: [u8; 12],
|
||||
pub ciphertext: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Envelope {
|
||||
pub fn sha256(&self) -> String {
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(&self.ephemeral_public_key);
|
||||
hash.update(self.iv);
|
||||
hash.update(&self.ciphertext);
|
||||
format!("{:x}", hash.finalize())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EntryKey(SecretKey);
|
||||
|
||||
impl EntryKey {
|
||||
/// The caller supplies a path in the host folder, never in the world.
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
let key = SecretKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(path)?;
|
||||
file.write_all(&key.to_bytes())?;
|
||||
file.sync_all()?;
|
||||
return Ok(Self(key));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
Ok(Self(
|
||||
SecretKey::from_slice(&bytes).map_err(|_| anyhow::anyhow!("invalid entry key"))?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn public_key(&self) -> Vec<u8> {
|
||||
self.0
|
||||
.public_key()
|
||||
.to_encoded_point(false)
|
||||
.as_bytes()
|
||||
.to_vec()
|
||||
}
|
||||
|
||||
/// The caller must first verify a SecretEntry assertion and compare its
|
||||
/// item, field, and envelope hash. This method does not authorize a write.
|
||||
pub fn decrypt(&self, envelope: &Envelope) -> Result<Vec<u8>> {
|
||||
if envelope.ephemeral_public_key.len() != 65 || envelope.ephemeral_public_key[0] != 4 {
|
||||
bail!("invalid secret envelope");
|
||||
}
|
||||
let peer = PublicKey::from_sec1_bytes(&envelope.ephemeral_public_key)
|
||||
.map_err(|_| anyhow::anyhow!("invalid secret envelope"))?;
|
||||
let shared = diffie_hellman(self.0.to_nonzero_scalar(), peer.as_affine());
|
||||
let mut key = [0u8; 32];
|
||||
Hkdf::<Sha256>::new(None, shared.raw_secret_bytes())
|
||||
.expand(b"sausage/secret-entry/v1", &mut key)
|
||||
.map_err(|_| anyhow::anyhow!("invalid secret envelope"))?;
|
||||
Aes256Gcm::new_from_slice(&key)
|
||||
.expect("AES key length")
|
||||
.decrypt(
|
||||
Nonce::from_slice(&envelope.iv),
|
||||
envelope.ciphertext.as_ref(),
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("invalid secret envelope"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! Passkey enrollment and single-use assertions for host-owned bindings.
|
||||
//! The caller supplies cred.sqlite and serializes access to this manager.
|
||||
//! Browser responses and challenges use WebAuthn JSON. Enrollment assertions
|
||||
//! must bind the exact new label. Neither decision nor secret-entry assertions
|
||||
//! authorize enrollment. Without passkeys, the host is unlocked and permits
|
||||
//! first registration without a gate. Restart drops all pending challenges.
|
||||
use anyhow::{Result, bail};
|
||||
use rusqlite::{Connection, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use webauthn_rs::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum Binding {
|
||||
Decision {
|
||||
request_id: String,
|
||||
decision: String,
|
||||
overrides: serde_json::Value,
|
||||
},
|
||||
SecretEntry {
|
||||
item: String,
|
||||
field: String,
|
||||
ciphertext_sha256: String,
|
||||
},
|
||||
Enrollment {
|
||||
label: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Gate {
|
||||
pub id: String,
|
||||
pub response: PublicKeyCredential,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Challenge<T> {
|
||||
pub id: String,
|
||||
pub options: T,
|
||||
}
|
||||
|
||||
struct Pending<T> {
|
||||
deadline: Instant,
|
||||
value: T,
|
||||
}
|
||||
|
||||
pub struct Passkeys<'a> {
|
||||
db: &'a Connection,
|
||||
webauthn: Webauthn,
|
||||
registrations: HashMap<String, Pending<(String, bool, PasskeyRegistration)>>,
|
||||
assertions: HashMap<String, Pending<(Binding, PasskeyAuthentication)>>,
|
||||
}
|
||||
|
||||
impl<'a> Passkeys<'a> {
|
||||
pub fn new(db: &'a Connection, rp_id: &str, origin: &str) -> Result<Self> {
|
||||
let origin = Url::parse(origin)?;
|
||||
let webauthn = WebauthnBuilder::new(rp_id, &origin)?.build()?;
|
||||
db.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS passkey_credentials (
|
||||
id TEXT PRIMARY KEY, label TEXT NOT NULL, credential TEXT NOT NULL)",
|
||||
)?;
|
||||
Ok(Self {
|
||||
db,
|
||||
webauthn,
|
||||
registrations: HashMap::new(),
|
||||
assertions: HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Database errors leave the manager locked.
|
||||
pub fn is_unlocked(&self) -> bool {
|
||||
self.db
|
||||
.query_row(
|
||||
"SELECT NOT EXISTS(SELECT 1 FROM passkey_credentials)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn credentials(&self) -> Result<Vec<Passkey>> {
|
||||
let mut stmt = self
|
||||
.db
|
||||
.prepare("SELECT credential FROM passkey_credentials")?;
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
|
||||
rows.map(|row| Ok(serde_json::from_str(&row?)?)).collect()
|
||||
}
|
||||
|
||||
pub fn start_registration(
|
||||
&mut self,
|
||||
label: String,
|
||||
gate: Option<Gate>,
|
||||
) -> Result<Challenge<CreationChallengeResponse>> {
|
||||
let gated = gate.is_some();
|
||||
match gate {
|
||||
None if !self.is_unlocked() => bail!("enrollment assertion required"),
|
||||
None => {}
|
||||
Some(Gate { id, response }) => {
|
||||
let (_, binding) = self.finish_assertion(&id, response)?;
|
||||
if binding
|
||||
!= (Binding::Enrollment {
|
||||
label: label.clone(),
|
||||
})
|
||||
{
|
||||
bail!("invalid enrollment gate");
|
||||
}
|
||||
}
|
||||
}
|
||||
let exclude = self
|
||||
.credentials()?
|
||||
.iter()
|
||||
.map(|p| p.cred_id().clone())
|
||||
.collect();
|
||||
let (options, state) = self.webauthn.start_passkey_registration(
|
||||
Uuid::new_v4(),
|
||||
&label,
|
||||
&label,
|
||||
Some(exclude),
|
||||
)?;
|
||||
self.registrations
|
||||
.retain(|_, p| p.deadline > Instant::now());
|
||||
let id = Uuid::new_v4().to_string();
|
||||
self.registrations.insert(
|
||||
id.clone(),
|
||||
Pending {
|
||||
deadline: Instant::now() + Duration::from_secs(300),
|
||||
value: (label, gated, state),
|
||||
},
|
||||
);
|
||||
Ok(Challenge { id, options })
|
||||
}
|
||||
|
||||
pub fn finish_registration(
|
||||
&mut self,
|
||||
id: &str,
|
||||
response: RegisterPublicKeyCredential,
|
||||
) -> Result<Passkey> {
|
||||
let (label, gated, state) = take(&mut self.registrations, id)?;
|
||||
if !gated && !self.is_unlocked() {
|
||||
bail!("enrollment assertion required");
|
||||
}
|
||||
let credential = self
|
||||
.webauthn
|
||||
.finish_passkey_registration(&response, &state)?;
|
||||
self.db.execute(
|
||||
"INSERT INTO passkey_credentials (id, label, credential) VALUES (?1, ?2, ?3)",
|
||||
params![
|
||||
serde_json::to_string(credential.cred_id())?,
|
||||
label,
|
||||
serde_json::to_string(&credential)?
|
||||
],
|
||||
)?;
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
pub fn start_assertion(
|
||||
&mut self,
|
||||
binding: Binding,
|
||||
) -> Result<Challenge<RequestChallengeResponse>> {
|
||||
let (options, state) = self
|
||||
.webauthn
|
||||
.start_passkey_authentication(&self.credentials()?)?;
|
||||
self.assertions.retain(|_, p| p.deadline > Instant::now());
|
||||
let id = Uuid::new_v4().to_string();
|
||||
self.assertions.insert(
|
||||
id.clone(),
|
||||
Pending {
|
||||
deadline: Instant::now() + Duration::from_secs(300),
|
||||
value: (binding, state),
|
||||
},
|
||||
);
|
||||
Ok(Challenge { id, options })
|
||||
}
|
||||
|
||||
pub fn finish_assertion(
|
||||
&mut self,
|
||||
id: &str,
|
||||
response: PublicKeyCredential,
|
||||
) -> Result<(CredentialID, Binding)> {
|
||||
let (binding, state) = take(&mut self.assertions, id)?;
|
||||
let result = self
|
||||
.webauthn
|
||||
.finish_passkey_authentication(&response, &state)?;
|
||||
let mut credentials = self.credentials()?;
|
||||
let credential = credentials
|
||||
.iter_mut()
|
||||
.find(|p| p.cred_id() == result.cred_id())
|
||||
.ok_or_else(|| anyhow::anyhow!("unregistered passkey"))?;
|
||||
// Check the latest stored counter too. Another outstanding assertion may
|
||||
// contain an older snapshot of this credential.
|
||||
let stored = serde_json::to_value(&*credential)?;
|
||||
let counter = stored["cred"]["counter"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid stored passkey counter"))?;
|
||||
if (counter > 0 || result.counter() > 0) && u64::from(result.counter()) <= counter {
|
||||
bail!("passkey counter did not increase");
|
||||
}
|
||||
credential.update_credential(&result);
|
||||
self.db.execute(
|
||||
"UPDATE passkey_credentials SET credential = ?1 WHERE id = ?2",
|
||||
params![
|
||||
serde_json::to_string(credential)?,
|
||||
serde_json::to_string(result.cred_id())?
|
||||
],
|
||||
)?;
|
||||
Ok((result.cred_id().clone(), binding))
|
||||
}
|
||||
}
|
||||
|
||||
fn take<T>(map: &mut HashMap<String, Pending<T>>, id: &str) -> Result<T> {
|
||||
let pending = map
|
||||
.remove(id)
|
||||
.ok_or_else(|| anyhow::anyhow!("unknown or consumed challenge"))?;
|
||||
if pending.deadline <= Instant::now() {
|
||||
bail!("expired challenge");
|
||||
}
|
||||
Ok(pending.value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn expired_assertion() -> Result<()> {
|
||||
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
|
||||
let db = Connection::open_in_memory()?;
|
||||
let mut host = Passkeys::new(&db, "localhost", "http://localhost:8000")?;
|
||||
let start = host.start_registration("test".into(), None)?;
|
||||
let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
||||
let origin = Url::parse("http://localhost:8000")?;
|
||||
let response = auth.do_registration(origin.clone(), start.options)?;
|
||||
host.finish_registration(&start.id, response)?;
|
||||
let start = host.start_assertion(Binding::Enrollment {
|
||||
label: "next".into(),
|
||||
})?;
|
||||
let response = auth.do_authentication(origin, start.options)?;
|
||||
host.assertions.get_mut(&start.id).unwrap().deadline = Instant::now();
|
||||
assert!(host.finish_assertion(&start.id, response).is_err());
|
||||
assert!(!host.assertions.contains_key(&start.id));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! WebCrypto interoperability and host key persistence for secret entry.
|
||||
use deno_core::{JsRuntime, RuntimeOptions};
|
||||
use sausage::cred::entry::{EntryKey, Envelope};
|
||||
use std::{os::unix::fs::PermissionsExt, sync::Arc};
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn webcrypto_entry() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("entry.key");
|
||||
let key = EntryKey::open(&path)?;
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path)?.permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(key.public_key(), EntryKey::open(&path)?.public_key());
|
||||
let mut runtime = JsRuntime::new(RuntimeOptions {
|
||||
extensions: vec![
|
||||
deno_webidl::deno_webidl::init(),
|
||||
deno_web::deno_web::init(
|
||||
Arc::new(deno_web::BlobStore::default()),
|
||||
None,
|
||||
false,
|
||||
Default::default(),
|
||||
),
|
||||
deno_crypto::deno_crypto::init(None),
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
runtime.execute_script(
|
||||
"crypto.js",
|
||||
"globalThis.crypto = Deno.core.loadExtScript('ext:deno_crypto/00_crypto.js').crypto;",
|
||||
)?;
|
||||
runtime.execute_script("encrypt.js", format!(r#"
|
||||
globalThis.done = (async () => {{
|
||||
const host = await crypto.subtle.importKey('raw', new Uint8Array({}), {{name:'ECDH', namedCurve:'P-256'}}, false, []);
|
||||
const pair = await crypto.subtle.generateKey({{name:'ECDH', namedCurve:'P-256'}}, true, ['deriveBits']);
|
||||
const bits = await crypto.subtle.deriveBits({{name:'ECDH', public:host}}, pair.privateKey, 256);
|
||||
const material = await crypto.subtle.importKey('raw', bits, 'HKDF', false, ['deriveKey']);
|
||||
const info = new Uint8Array(Array.from('sausage/secret-entry/v1', c => c.charCodeAt(0)));
|
||||
const aes = await crypto.subtle.deriveKey({{name:'HKDF', hash:'SHA-256', salt:new Uint8Array(), info}}, material, {{name:'AES-GCM', length:256}}, false, ['encrypt']);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await crypto.subtle.encrypt({{name:'AES-GCM', iv}}, aes, new Uint8Array(Array.from('browser-only-value', c => c.charCodeAt(0))));
|
||||
globalThis.envelope = JSON.stringify({{ephemeral_public_key:Array.from(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))), iv:Array.from(iv), ciphertext:Array.from(new Uint8Array(ciphertext))}});
|
||||
}})();
|
||||
"#, serde_json::to_string(&key.public_key())?))?;
|
||||
runtime.run_event_loop(Default::default()).await?;
|
||||
let value = runtime.execute_script("result.js", "envelope")?;
|
||||
let text = {
|
||||
deno_core::scope!(scope, &mut runtime);
|
||||
let value = deno_core::v8::Local::new(scope, value);
|
||||
value.to_rust_string_lossy(scope)
|
||||
};
|
||||
let mut envelope: Envelope = serde_json::from_str(&text)?;
|
||||
let expected = b"browser-only-value";
|
||||
assert!(key.decrypt(&envelope)? == expected);
|
||||
let hash = envelope.sha256();
|
||||
envelope.ciphertext[0] ^= 1;
|
||||
assert_ne!(hash, envelope.sha256());
|
||||
let error = key.decrypt(&envelope).unwrap_err().to_string();
|
||||
assert!(!error.contains(std::str::from_utf8(expected)?));
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Software-authenticator coverage for enrollment gates and bound assertions.
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use sausage::cred::webauthn::{Binding, Gate, Passkeys};
|
||||
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
|
||||
use webauthn_rs::prelude::*;
|
||||
|
||||
fn binding() -> Binding {
|
||||
Binding::Decision {
|
||||
request_id: "request-1".into(),
|
||||
decision: "approve".into(),
|
||||
overrides: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrollment_and_assertions() -> anyhow::Result<()> {
|
||||
let db = rusqlite::Connection::open_in_memory()?;
|
||||
let origin = Url::parse("http://localhost:8000")?;
|
||||
let mut host = Passkeys::new(&db, "localhost", origin.as_str())?;
|
||||
let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
||||
assert!(host.is_unlocked());
|
||||
let start = host.start_registration("first".into(), None)?;
|
||||
let racing = host.start_registration("racing".into(), None)?;
|
||||
let response = auth.do_registration(origin.clone(), start.options)?;
|
||||
host.finish_registration(&start.id, response)?;
|
||||
assert!(!host.is_unlocked());
|
||||
assert!(host.start_registration("second".into(), None).is_err());
|
||||
let response = auth.do_registration(origin.clone(), racing.options)?;
|
||||
assert!(host.finish_registration(&racing.id, response).is_err());
|
||||
let gate = host.start_assertion(Binding::Enrollment {
|
||||
label: "second".into(),
|
||||
})?;
|
||||
let response = auth.do_authentication(origin.clone(), gate.options)?;
|
||||
let start = host.start_registration(
|
||||
"second".into(),
|
||||
Some(Gate {
|
||||
id: gate.id,
|
||||
response,
|
||||
}),
|
||||
)?;
|
||||
let response = auth.do_registration(origin.clone(), start.options)?;
|
||||
host.finish_registration(&start.id, response)?;
|
||||
|
||||
let start = host.start_assertion(binding())?;
|
||||
let response = auth.do_authentication(origin.clone(), start.options)?;
|
||||
let replay = serde_json::to_value(&response)?;
|
||||
assert_eq!(host.finish_assertion(&start.id, response)?.1, binding());
|
||||
assert!(
|
||||
host.finish_assertion(&start.id, serde_json::from_value(replay)?)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let gate = host.start_assertion(binding())?;
|
||||
let response = auth.do_authentication(origin.clone(), gate.options)?;
|
||||
assert!(
|
||||
host.start_registration(
|
||||
"unauthorized".into(),
|
||||
Some(Gate {
|
||||
id: gate.id,
|
||||
response
|
||||
})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
drop(host);
|
||||
let mut host = Passkeys::new(&db, "localhost", origin.as_str())?;
|
||||
let older = host.start_assertion(binding())?;
|
||||
let newer = host.start_assertion(binding())?;
|
||||
let old_response = auth.do_authentication(origin.clone(), older.options)?;
|
||||
let new_response = auth.do_authentication(origin.clone(), newer.options)?;
|
||||
host.finish_assertion(&newer.id, new_response)?;
|
||||
assert!(host.finish_assertion(&older.id, old_response).is_err());
|
||||
|
||||
// Sign a different, valid random challenge with the same credential.
|
||||
let a = host.start_assertion(binding())?;
|
||||
let b = host.start_assertion(binding())?;
|
||||
let response = auth.do_authentication(origin.clone(), b.options)?;
|
||||
assert!(host.finish_assertion(&a.id, response).is_err());
|
||||
|
||||
let a = host.start_assertion(binding())?;
|
||||
let response = auth.do_authentication(Url::parse("http://localhost:8001")?, a.options)?;
|
||||
assert!(host.finish_assertion(&a.id, response).is_err());
|
||||
|
||||
let mut a = host.start_assertion(binding())?;
|
||||
a.options.public_key.user_verification =
|
||||
serde_json::from_value(serde_json::json!("discouraged"))?;
|
||||
let response = auth.do_authentication(origin.clone(), a.options)?;
|
||||
assert!(host.finish_assertion(&a.id, response).is_err());
|
||||
|
||||
for field in ["signature", "authenticatorData"] {
|
||||
let a = host.start_assertion(binding())?;
|
||||
let response = auth.do_authentication(origin.clone(), a.options)?;
|
||||
let mut json = serde_json::to_value(response)?;
|
||||
let mut bytes = URL_SAFE_NO_PAD.decode(json["response"][field].as_str().unwrap())?;
|
||||
let index = if field == "signature" {
|
||||
bytes.len() - 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
bytes[index] ^= 1; // Preserve signature encoding, or change rpIdHash.
|
||||
json["response"][field] = URL_SAFE_NO_PAD.encode(bytes).into();
|
||||
assert!(
|
||||
host.finish_assertion(&a.id, serde_json::from_value(json)?)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let foreign_db = rusqlite::Connection::open_in_memory()?;
|
||||
let mut foreign = Passkeys::new(&foreign_db, "localhost", origin.as_str())?;
|
||||
let mut foreign_auth = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
||||
let registration = foreign.start_registration("foreign".into(), None)?;
|
||||
let response = foreign_auth.do_registration(origin.clone(), registration.options)?;
|
||||
let credential = foreign.finish_registration(®istration.id, response)?;
|
||||
let mut a = host.start_assertion(binding())?;
|
||||
a.options.public_key.allow_credentials[0].id = credential.cred_id().to_vec().into();
|
||||
let response = foreign_auth.do_authentication(origin.clone(), a.options)?;
|
||||
assert!(host.finish_assertion(&a.id, response).is_err());
|
||||
|
||||
let a = host.start_assertion(binding())?;
|
||||
let response = auth.do_authentication(origin, a.options)?;
|
||||
db.execute("DELETE FROM passkey_credentials", [])?;
|
||||
assert!(host.finish_assertion(&a.id, response).is_err());
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user