diff --git a/host/src/cred/entry.rs b/host/src/cred/entry.rs new file mode 100644 index 0000000..59a24dc --- /dev/null +++ b/host/src/cred/entry.rs @@ -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, + pub iv: [u8; 12], + pub ciphertext: Vec, +} + +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 { + 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 { + 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> { + 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::::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")) + } +} diff --git a/host/src/cred/webauthn.rs b/host/src/cred/webauthn.rs new file mode 100644 index 0000000..f40417f --- /dev/null +++ b/host/src/cred/webauthn.rs @@ -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 { + pub id: String, + pub options: T, +} + +struct Pending { + deadline: Instant, + value: T, +} + +pub struct Passkeys<'a> { + db: &'a Connection, + webauthn: Webauthn, + registrations: HashMap>, + assertions: HashMap>, +} + +impl<'a> Passkeys<'a> { + pub fn new(db: &'a Connection, rp_id: &str, origin: &str) -> Result { + 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> { + 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, + ) -> Result> { + 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 { + 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> { + 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(map: &mut HashMap>, id: &str) -> Result { + 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(()) + } +} diff --git a/host/tests/cred_entry.rs b/host/tests/cred_entry.rs new file mode 100644 index 0000000..12bb86c --- /dev/null +++ b/host/tests/cred_entry.rs @@ -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(()) +} diff --git a/host/tests/cred_webauthn.rs b/host/tests/cred_webauthn.rs new file mode 100644 index 0000000..da078a0 --- /dev/null +++ b/host/tests/cred_webauthn.rs @@ -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(()) +}