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:
2026-11-06 19:04:42 +00:00
parent b5462cc366
commit 1dc09bc418
4 changed files with 519 additions and 0 deletions
+86
View File
@@ -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"))
}
}
+247
View File
@@ -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(())
}
}
+62
View File
@@ -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(())
}
+124
View File
@@ -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(&registration.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(())
}