use crate::decide::{page, param};
use crate::file_root;
use base64::Engine;
use ikigai_core::{
ActionSpec, Description, Endpoint, Error, Invocation, ReprType, Representation, Result, Verb,
};
use serde_json::json;
use std::collections::HashMap;
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant};
pub const CAP_PASSKEY_ENROLL: &str = "urn:cap:passkey:enroll";
const CHALLENGE_TTL: Duration = Duration::from_secs(120);
const ENROLL_WINDOW_SECONDS: i64 = 300;
fn now_secs() -> i64 {
chrono::Utc::now().timestamp()
}
fn b64() -> base64::engine::general_purpose::GeneralPurpose {
base64::engine::general_purpose::URL_SAFE_NO_PAD
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RelyingParty {
rp_id: String,
origin: String,
}
impl RelyingParty {
pub fn new(rp_id: &str, origin: Option<&str>) -> std::result::Result<Self, String> {
let rp_id = rp_id.trim();
if rp_id.is_empty()
|| rp_id
.chars()
.any(|c| matches!(c, '/' | ':' | '?' | '#' | '@') || c.is_whitespace())
{
return Err(format!(
"a passkey RP id is a bare domain such as `ikigai-rs.dev`, not `{rp_id}`"
));
}
let origin = match origin {
Some(o) => o.trim().trim_end_matches('/').to_string(),
None => format!("https://{rp_id}"),
};
let host = origin
.strip_prefix("https://")
.or_else(|| origin.strip_prefix("http://"))
.ok_or_else(|| format!("a passkey origin is `https://host[:port]`, not `{origin}`"))?;
if host.is_empty() || host.contains(['/', '?', '#', '@']) {
return Err(format!(
"a passkey origin is `https://host[:port]` with no path, not `{origin}`"
));
}
Ok(Self {
rp_id: rp_id.to_string(),
origin,
})
}
pub fn rp_id(&self) -> &str {
&self.rp_id
}
pub fn origin(&self) -> &str {
&self.origin
}
}
static RELYING_PARTY: Mutex<Option<RelyingParty>> = Mutex::new(None);
pub fn set_relying_party(rp: RelyingParty) {
*RELYING_PARTY.lock().unwrap_or_else(|e| e.into_inner()) = Some(rp);
}
#[cfg(not(test))]
fn relying_party() -> Option<RelyingParty> {
RELYING_PARTY
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
#[cfg(test)]
fn relying_party() -> Option<RelyingParty> {
tests::test_rp()
}
fn store_root() -> std::path::PathBuf {
#[cfg(test)]
if let Some(p) = tests::test_root() {
return p;
}
file_root()
}
fn credentials_path() -> std::path::PathBuf {
store_root().join("passkey-credentials.json")
}
fn enroll_window_path() -> std::path::PathBuf {
store_root().join("passkey-enroll.open")
}
fn challenges() -> &'static Mutex<HashMap<String, Instant>> {
static STORE: OnceLock<Mutex<HashMap<String, Instant>>> = OnceLock::new();
STORE.get_or_init(|| Mutex::new(HashMap::new()))
}
#[allow(clippy::disallowed_methods)]
fn monotonic_now() -> Instant {
Instant::now()
}
fn issue_challenge() -> Result<String> {
issue_challenge_at(monotonic_now())
}
fn issue_challenge_at(now: Instant) -> Result<String> {
let mut raw = [0u8; 32];
getrandom::getrandom(&mut raw)
.map_err(|e| Error::Endpoint(format!("no OS randomness for a challenge: {e}")))?;
let challenge = b64().encode(raw);
let mut store = challenges().lock().expect("challenge store poisoned");
store.retain(|_, exp| *exp > now); store.insert(challenge.clone(), now + CHALLENGE_TTL);
Ok(challenge)
}
fn consume_challenge(challenge: &str) -> bool {
consume_challenge_at(challenge, monotonic_now())
}
fn consume_challenge_at(challenge: &str, now: Instant) -> bool {
let mut store = challenges().lock().expect("challenge store poisoned");
match store.remove(challenge) {
Some(exp) => exp > now,
None => false,
}
}
#[derive(serde::Serialize, serde::Deserialize, Clone)]
struct StoredCredential {
id: String,
spki: String,
#[serde(default)]
sign_count: u32,
}
fn credential_lock() -> MutexGuard<'static, ()> {
static LOCK: Mutex<()> = Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn load_credentials() -> std::result::Result<Vec<StoredCredential>, String> {
let path = credentials_path();
match std::fs::read(&path) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| {
format!(
"{} is present but not a credential list: {e}",
path.display()
)
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(format!("{} is present but unreadable: {e}", path.display())),
}
}
fn store_unreadable(detail: &str) {
eprintln!(
"ikigai passkey: REFUSING every passkey-gated decision: {detail}. Repair or remove the \
file at the box (removing it un-enrolls; enroll again with `sink urn:passkey:enroll-open`)."
);
}
fn save_credentials(creds: &[StoredCredential]) -> Result<()> {
let json = serde_json::to_vec_pretty(creds)
.map_err(|e| Error::Endpoint(format!("cannot serialize credentials: {e}")))?;
write_atomic(&credentials_path(), &json)
.map_err(|e| Error::Endpoint(format!("cannot write credentials: {e}")))
}
fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let dir = path.parent().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent")
})?;
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let tmp = dir.join(format!(".{name}.tmp-{}", std::process::id()));
let _ = std::fs::remove_file(&tmp); let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
let write_then_rename = |opts: &std::fs::OpenOptions| -> std::io::Result<()> {
let mut f = opts.open(&tmp)?;
f.write_all(bytes)?;
f.sync_all()?;
std::fs::rename(&tmp, path)
};
let written = write_then_rename(&opts);
if written.is_err() {
let _ = std::fs::remove_file(&tmp);
return written;
}
if let Ok(d) = std::fs::File::open(dir) {
let _ = d.sync_all();
}
Ok(())
}
pub fn is_enrolled() -> bool {
load_credentials().map_or(true, |c| !c.is_empty())
}
pub(crate) const DECISION_PASSKEY_JS: &str =
"<p id=pkstatus style=\"color:#666\"></p><script src=\"/passkey/app.js\"></script>";
const REGISTER_PAGE_BODY: &str = r#"<p>Create a passkey for this site — your device will ask you to confirm with Face/Touch ID.</p>
<button id=reg style="font:inherit;padding:.6rem 1.2rem">Create passkey</button>
<p id=status style="color:#666"></p>
<script src="/passkey/app.js"></script>"#;
const NOT_CONFIGURED_BODY: &str = "<p>This edge has no passkey relying party configured, so a \
passkey registered here would be bound to nothing. Restart it with <code>--passkey-rp-id \
<domain></code> (and <code>--passkey-origin</code> if the origin is not \
<code>https://<domain></code>), then open an enrollment window.</p>";
const PASSKEY_APP_JS: &str = r#"(function(){
var form=document.getElementById('act');
var reg=document.getElementById('reg');
if(form) decision(form);
if(reg) register(reg);
function decision(form){
var status=document.getElementById('pkstatus');
form.addEventListener('submit', function(ev){ ev.preventDefault();
status.textContent='';
fetch('/passkey/challenge',{headers:{accept:'application/json'}})
.then(function(r){return r.json();})
.then(function(opt){
if(!opt.enrolled){ form.submit(); return; }
status.textContent='Confirm with your passkey…';
return navigator.credentials.get({publicKey:{
challenge:u(opt.challenge), rpId:opt.rpId,
allowCredentials:(opt.allowCredentials||[]).map(function(c){return {type:'public-key',id:u(c.id)};}),
userVerification:opt.userVerification||'preferred', timeout:60000
}}).then(function(a){
add(form,'pk_id',b(a.rawId)); add(form,'pk_auth',b(a.response.authenticatorData));
add(form,'pk_client',b(a.response.clientDataJSON)); add(form,'pk_sig',b(a.response.signature));
form.submit();
});
})
.catch(function(e){ status.textContent='Passkey step failed ('+((e&&e.message)||e)+'). Reopen the link to retry.'; });
});
}
function register(btn){
var status=document.getElementById('status');
btn.addEventListener('click', function(){
status.textContent='Creating…';
fetch('/passkey/challenge').then(function(r){return r.json();}).then(function(opt){
return navigator.credentials.create({publicKey:{
rp:{id:opt.rpId, name:'ikigai'},
user:{id:new TextEncoder().encode('ikigai-owner'), name:'owner', displayName:'ikigai owner'},
challenge:u(opt.challenge),
pubKeyCredParams:[{type:'public-key',alg:-7}],
authenticatorSelection:{userVerification:'preferred',residentKey:'preferred'},
timeout:60000, attestation:'none'
}});
}).then(function(cred){
var spki=cred.response.getPublicKey && cred.response.getPublicKey();
if(!spki){ throw new Error('this browser did not expose the public key (needs WebAuthn L2)'); }
var body='id='+encodeURIComponent(b(cred.rawId))+'&spki='+encodeURIComponent(b(spki));
return fetch('/passkey/register',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:body});
}).then(function(res){ return res.text(); }).then(function(html){ document.open(); document.write(html); document.close(); })
.catch(function(e){ status.textContent='Registration failed: '+((e&&e.message)||e); });
});
}
function add(f,n,v){ var i=document.createElement('input'); i.type='hidden'; i.name=n; i.value=v; f.appendChild(i); }
function u(s){ s=s.replace(/-/g,'+').replace(/_/g,'/'); while(s.length%4)s+='='; var x=atob(s),y=new Uint8Array(x.length); for(var i=0;i<x.length;i++)y[i]=x.charCodeAt(i); return y.buffer; }
function b(buf){ var y=new Uint8Array(buf),s=''; for(var i=0;i<y.length;i++)s+=String.fromCharCode(y[i]); return btoa(s).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); }
})();
"#;
pub struct PasskeyJs;
#[async_trait::async_trait]
impl Endpoint for PasskeyJs {
async fn invoke(&self, _inv: &Invocation<'_>) -> Result<Representation> {
Ok(Representation::new(
ReprType::new("application/javascript").with_param("charset", "utf-8"),
PASSKEY_APP_JS.as_bytes().to_vec(),
))
}
fn name(&self) -> &str {
"passkey-js"
}
fn describe(&self) -> Description {
Description::new("passkey-js")
.title("The passkey ceremony script")
.summary("The same-origin script the decision and register pages load to run the WebAuthn ceremony.")
.action(
ActionSpec::new(Verb::Source)
.summary("the script")
.output("application/javascript; charset=utf-8"),
)
.output("application/javascript; charset=utf-8")
}
}
pub fn require_passkey(inv: &Invocation<'_>) -> Result<()> {
let denied = || Error::Denied("this action needs your passkey".to_string());
let _held = credential_lock();
let creds = match load_credentials() {
Ok(creds) => creds,
Err(detail) => {
store_unreadable(&detail);
return Err(denied());
}
};
if creds.is_empty() {
return Ok(()); }
let Some(rp) = relying_party() else {
eprintln!(
"ikigai passkey: REFUSING every passkey-gated decision: a credential is enrolled but \
this edge has no relying party configured (start it with `--passkey-rp-id <domain>`)."
);
return Err(denied());
};
let (id_b64, auth_b64, client_b64, sig_b64) = (
param(inv, "pk_id"),
param(inv, "pk_auth"),
param(inv, "pk_client"),
param(inv, "pk_sig"),
);
if id_b64.is_empty() || auth_b64.is_empty() || client_b64.is_empty() || sig_b64.is_empty() {
return Err(denied());
}
let decode = |s: &str| b64().decode(s).map_err(|_| denied());
let (cred_id, auth_data, client_data, signature) = (
decode(&id_b64)?,
decode(&auth_b64)?,
decode(&client_b64)?,
decode(&sig_b64)?,
);
let client_json: serde_json::Value =
serde_json::from_slice(&client_data).map_err(|_| denied())?;
let challenge = client_json
.get("challenge")
.and_then(|v| v.as_str())
.ok_or_else(denied)?
.to_string();
if !consume_challenge(&challenge) {
return Err(denied()); }
let stored = creds
.iter()
.find(|c| c.id == id_b64)
.ok_or_else(denied)?
.clone();
let spki = b64().decode(&stored.spki).map_err(|_| denied())?;
let registered =
ikigai_passkey::RegisteredCredential::from_spki_der(cred_id, &spki, stored.sign_count)
.map_err(|_| denied())?;
let policy = ikigai_passkey::Policy {
rp_id: rp.rp_id(),
origin: rp.origin(),
challenge_b64url: &challenge,
require_user_verified: true,
};
let assertion = ikigai_passkey::Assertion {
credential_id: ®istered.id,
authenticator_data: &auth_data,
client_data_json: &client_data,
signature: &signature,
};
let new_count =
ikigai_passkey::verify(®istered, &assertion, &policy).map_err(|_| denied())?;
#[cfg(test)]
tests::after_verify();
if new_count > stored.sign_count {
let mut all = creds;
if let Some(c) = all.iter_mut().find(|c| c.id == id_b64) {
c.sign_count = new_count;
}
if let Err(e) = save_credentials(&all) {
eprintln!("ikigai passkey: could not persist the advanced signature counter: {e}");
}
}
Ok(())
}
pub struct PasskeyChallenge;
#[async_trait::async_trait]
impl Endpoint for PasskeyChallenge {
async fn invoke(&self, _inv: &Invocation<'_>) -> Result<Representation> {
let unavailable = || Error::Endpoint("passkey sign-in is unavailable on this edge".into());
let creds = load_credentials().map_err(|detail| {
store_unreadable(&detail);
unavailable()
})?;
let rp = relying_party();
if rp.is_none() && !creds.is_empty() {
eprintln!(
"ikigai passkey: a credential is enrolled but this edge has no relying party \
configured (start it with `--passkey-rp-id <domain>`)."
);
return Err(unavailable());
}
let challenge = issue_challenge()?;
let allow: Vec<_> = creds
.into_iter()
.map(|c| json!({ "type": "public-key", "id": c.id }))
.collect();
let body = json!({
"challenge": challenge,
"rpId": rp.as_ref().map(RelyingParty::rp_id),
"allowCredentials": allow,
"userVerification": "preferred",
"enrolled": !allow.is_empty(),
});
Ok(Representation::new(
ReprType::new("application/json").with_param("charset", "utf-8"),
serde_json::to_vec(&body).expect("serializable").to_vec(),
))
}
fn name(&self) -> &str {
"passkey-challenge"
}
fn describe(&self) -> Description {
Description::new("passkey-challenge")
.title("Issue a WebAuthn challenge")
.summary("A fresh single-use challenge plus the registered credential ids, for a login ceremony.")
.action(
ActionSpec::new(Verb::Source)
.summary("mint a challenge")
.output("application/json; charset=utf-8"),
)
.output("application/json; charset=utf-8")
}
}
pub struct PasskeyEnrollOpen;
#[async_trait::async_trait]
impl Endpoint for PasskeyEnrollOpen {
async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
if !inv.capability.allows(CAP_PASSKEY_ENROLL) {
return Err(Error::Denied(format!(
"opening enrollment requires `{CAP_PASSKEY_ENROLL}`"
)));
}
let until = now_secs() + ENROLL_WINDOW_SECONDS;
std::fs::write(enroll_window_path(), until.to_string())
.map_err(|e| Error::Endpoint(format!("cannot open the enrollment window: {e}")))?;
Ok(Representation::new(
ReprType::new("text/plain").with_param("charset", "utf-8"),
format!("enrollment open for {ENROLL_WINDOW_SECONDS}s — register your passkey now\n")
.into_bytes(),
))
}
fn name(&self) -> &str {
"passkey-enroll-open"
}
fn describe(&self) -> Description {
Description::new("passkey-enroll-open")
.title("Open a passkey enrollment window")
.summary("Opens a short window during which one passkey may be registered. Run from the box.")
.action(
ActionSpec::new(Verb::Sink)
.summary("open the window")
.output("text/plain; charset=utf-8")
.requires(CAP_PASSKEY_ENROLL),
)
.output("text/plain; charset=utf-8")
}
}
fn enrollment_open() -> bool {
match std::fs::read_to_string(enroll_window_path()) {
Ok(s) => s
.trim()
.parse::<i64>()
.map(|u| u > now_secs())
.unwrap_or(false),
Err(_) => false,
}
}
fn close_enrollment() {
let _ = std::fs::remove_file(enroll_window_path());
}
pub struct PasskeyRegister;
#[async_trait::async_trait]
impl Endpoint for PasskeyRegister {
async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
match inv.request.verb {
Verb::Source => {
if relying_party().is_none() {
return Ok(page("Passkeys are not configured", NOT_CONFIGURED_BODY));
}
if !enrollment_open() {
return Ok(page(
"Enrollment closed",
"<p>Open a window at the box first: <code>ikigai -c 'sink \
urn:passkey:enroll-open'</code>, then reload.</p>",
));
}
Ok(page("Register a passkey", REGISTER_PAGE_BODY))
}
Verb::Sink => {
if relying_party().is_none() {
return Err(Error::Denied(
"this edge has no passkey relying party configured".to_string(),
));
}
if !enrollment_open() {
return Err(Error::Denied(
"no enrollment window is open — open one at the box first".to_string(),
));
}
let (id_b64, spki_b64) = (param(inv, "id"), param(inv, "spki"));
if id_b64.is_empty() || spki_b64.is_empty() {
return Err(Error::InvalidArgument {
name: "id/spki".to_string(),
detail: "both the credential id and the SPKI public key are required"
.to_string(),
});
}
let spki = b64()
.decode(&spki_b64)
.map_err(|_| Error::InvalidArgument {
name: "spki".to_string(),
detail: "not base64url".to_string(),
})?;
ikigai_passkey::RegisteredCredential::from_spki_der(b"probe".to_vec(), &spki, 0)
.map_err(|e| Error::InvalidArgument {
name: "spki".to_string(),
detail: format!("not a usable public key: {e}"),
})?;
let _held = credential_lock();
let mut creds = load_credentials().map_err(|detail| {
store_unreadable(&detail);
Error::Endpoint(
"the passkey store on this edge is unreadable; repair or remove it at \
the box, then enroll again"
.to_string(),
)
})?;
creds.retain(|c| c.id != id_b64);
creds.push(StoredCredential {
id: id_b64,
spki: spki_b64,
sign_count: 0,
});
save_credentials(&creds)?;
close_enrollment();
Ok(page(
"Passkey registered",
"<p>Done. Decision links now ask for your passkey before they act.</p>",
))
}
other => Err(Error::Endpoint(format!(
"register is GET to show or POST to store, not {other:?}"
))),
}
}
fn name(&self) -> &str {
"passkey-register"
}
fn describe(&self) -> Description {
Description::new("passkey-register")
.title("Register a passkey")
.summary("GET shows the enrolment page; POST stores a credential while an enrollment window is open.")
.action(
ActionSpec::new(Verb::Source)
.summary("show the enrolment page")
.output("text/html; charset=utf-8"),
)
.action(
ActionSpec::new(Verb::Sink)
.summary("store a credential (window must be open)")
.output("text/html; charset=utf-8"),
)
.output("text/html; charset=utf-8")
}
}
#[cfg(test)]
mod tests {
use super::*;
use ikigai_core::{ArgRef, Capability, Iri, Kernel, Request};
use p256::ecdsa::{signature::Signer, Signature, SigningKey, VerifyingKey};
use p256::pkcs8::EncodePublicKey;
use sha2::{Digest, Sha256};
thread_local! {
static TEST_ROOT: std::cell::RefCell<Option<std::path::PathBuf>> =
const { std::cell::RefCell::new(None) };
static TEST_RP: std::cell::RefCell<Option<RelyingParty>> =
const { std::cell::RefCell::new(None) };
}
pub(super) fn test_root() -> Option<std::path::PathBuf> {
TEST_ROOT.with(|r| r.borrow().clone())
}
pub(super) fn test_rp() -> Option<RelyingParty> {
TEST_RP.with(|r| r.borrow().clone())
}
const RP_ID: &str = "edge.test";
const ORIGIN: &str = "https://edge.test";
fn test_relying_party() -> RelyingParty {
RelyingParty::new(RP_ID, None).unwrap()
}
fn unconfigure() {
TEST_RP.with(|r| *r.borrow_mut() = None);
}
fn isolate(name: &str) {
let dir = std::env::temp_dir().join(format!("ikigai-passkey-ep-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TEST_ROOT.with(|r| *r.borrow_mut() = Some(dir));
TEST_RP.with(|r| *r.borrow_mut() = Some(test_relying_party()));
}
struct Device {
key: SigningKey,
}
impl Device {
fn new() -> Self {
Self {
key: SigningKey::from_slice(&[0x33u8; 32]).unwrap(),
}
}
fn spki_b64(&self) -> String {
let der = VerifyingKey::from(&self.key).to_public_key_der().unwrap();
b64().encode(der.as_bytes())
}
fn register(&self) {
let creds = vec![StoredCredential {
id: b64().encode(b"cred-1"),
spki: self.spki_b64(),
sign_count: 0,
}];
save_credentials(&creds).unwrap();
}
fn assert_fields(&self, challenge: &str, flags: u8, count: u32) -> String {
let client = format!(
r#"{{"type":"webauthn.get","challenge":"{challenge}","origin":"{ORIGIN}"}}"#
)
.into_bytes();
let mut auth = Sha256::digest(RP_ID.as_bytes()).to_vec();
auth.push(flags);
auth.extend_from_slice(&count.to_be_bytes());
let mut msg = auth.clone();
msg.extend_from_slice(&Sha256::digest(&client));
let sig: Signature = self.key.sign(&msg);
format!(
"pk_id={}&pk_auth={}&pk_client={}&pk_sig={}",
b64().encode(b"cred-1"),
b64().encode(&auth),
b64().encode(&client),
b64().encode(sig.to_der().as_bytes()),
)
}
}
fn post_inv(body: &str) -> (Kernel, Request) {
let kernel = Kernel::new(std::sync::Arc::new(ikigai_core::EndpointSpace::new()));
let req = Request::new(Verb::Sink, Iri::parse("urn:x").unwrap())
.with_arg("content", ArgRef::Inline(body.as_bytes().to_vec()));
(kernel, req)
}
fn run_gate(body: &str) -> Result<()> {
struct Gate;
#[async_trait::async_trait]
impl Endpoint for Gate {
async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
require_passkey(inv)?;
Ok(Representation::new(
ReprType::new("text/plain"),
b"ok".to_vec(),
))
}
fn name(&self) -> &str {
"gate"
}
fn describe(&self) -> Description {
Description::new("gate").verb(Verb::Sink)
}
}
let kernel = Kernel::new(std::sync::Arc::new(
ikigai_core::EndpointSpace::new().bind(ikigai_core::Exact::new("urn:x"), Gate),
));
let (_k, req) = post_inv(body);
futures::executor::block_on(kernel.issue(req, &Capability::root())).map(|_| ())
}
#[test]
fn the_gate_is_inert_until_a_credential_is_enrolled() {
isolate("inert");
assert!(run_gate("id=abc&exp=1").is_ok());
}
#[test]
fn a_valid_assertion_passes_the_gate_and_a_forged_one_does_not() {
isolate("valid");
let d = Device::new();
d.register();
let challenge = issue_challenge().unwrap();
let body = d.assert_fields(&challenge, 0x05, 1); assert!(run_gate(&body).is_ok(), "a valid assertion should pass");
let challenge2 = issue_challenge().unwrap();
let good = d.assert_fields(&challenge2, 0x05, 2);
assert!(run_gate(&good).is_ok());
assert!(run_gate(&good).is_err(), "the challenge is single-use");
}
#[test]
fn an_enrolled_gate_refuses_a_post_with_no_assertion() {
isolate("missing");
Device::new().register();
assert!(
run_gate("id=abc&exp=1").is_err(),
"enrolled + no passkey = refused"
);
}
#[test]
fn a_forged_signature_is_refused() {
isolate("forged");
let real = Device::new();
real.register();
let imposter = Device {
key: SigningKey::from_slice(&[0x44u8; 32]).unwrap(),
};
let challenge = issue_challenge().unwrap();
let body = imposter.assert_fields(&challenge, 0x05, 1);
assert!(
run_gate(&body).is_err(),
"a foreign signature must be refused"
);
}
#[test]
fn a_never_issued_challenge_is_refused() {
isolate("nochallenge");
let d = Device::new();
d.register();
let body = d.assert_fields("bmV2ZXItaXNzdWVk", 0x05, 1);
assert!(
run_gate(&body).is_err(),
"an unissued challenge must be refused"
);
}
#[test]
fn challenges_are_single_use_and_expire() {
isolate("challenge");
let c = issue_challenge().unwrap();
assert!(consume_challenge(&c), "a fresh challenge consumes once");
assert!(!consume_challenge(&c), "and not twice");
assert!(
!consume_challenge("never-issued"),
"unknown challenge is refused"
);
}
thread_local! {
static AFTER_VERIFY: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
const { std::cell::RefCell::new(None) };
}
pub(super) fn after_verify() {
if let Some(hook) = AFTER_VERIFY.with(|h| h.borrow_mut().take()) {
hook();
}
}
fn stored_count() -> u32 {
let bytes = std::fs::read(credentials_path()).unwrap();
let creds: Vec<StoredCredential> = serde_json::from_slice(&bytes).unwrap();
creds[0].sign_count
}
#[test]
fn a_corrupt_credential_file_fails_closed() {
isolate("corrupt");
Device::new().register();
let full = std::fs::read(credentials_path()).unwrap();
std::fs::write(credentials_path(), &full[..full.len() / 2]).unwrap();
assert!(
run_gate("id=abc&exp=1").is_err(),
"a present but unparsable credential store must refuse, not read as `not enrolled`"
);
std::fs::write(credentials_path(), b"").unwrap();
assert!(
run_gate("id=abc&exp=1").is_err(),
"a zero-byte store refuses"
);
}
#[cfg(unix)]
#[test]
fn the_credential_write_replaces_the_file_rather_than_rewriting_it_in_place() {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
isolate("atomic");
let d = Device::new();
d.register();
let before = std::fs::metadata(credentials_path()).unwrap().ino();
d.register();
let meta = std::fs::metadata(credentials_path()).unwrap();
assert_ne!(
before,
meta.ino(),
"the store must be replaced by rename, not truncated and rewritten in place"
);
assert_eq!(meta.permissions().mode() & 0o777, 0o600, "owner-only");
let leftovers: Vec<_> = std::fs::read_dir(store_root())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n != "passkey-credentials.json")
.collect();
assert!(leftovers.is_empty(), "stray files: {leftovers:?}");
}
#[test]
fn interleaved_assertions_never_lower_the_stored_counter() {
isolate("race");
let root = test_root().unwrap();
let d = Device::new();
d.register();
let c_low = issue_challenge().unwrap();
let c_high = issue_challenge().unwrap();
let low = d.assert_fields(&c_low, 0x05, 2);
let high = d.assert_fields(&c_high, 0x05, 3);
let (done_tx, done_rx) = std::sync::mpsc::channel();
let b = std::sync::Arc::new(std::sync::Mutex::new(None));
let b_slot = b.clone();
AFTER_VERIFY.with(|h| {
*h.borrow_mut() = Some(Box::new(move || {
let handle = std::thread::spawn(move || {
TEST_ROOT.with(|r| *r.borrow_mut() = Some(root));
TEST_RP.with(|r| *r.borrow_mut() = Some(test_relying_party()));
let r = run_gate(&high);
let _ = done_tx.send(());
r
});
let _ = done_rx.recv_timeout(std::time::Duration::from_millis(300));
*b_slot.lock().unwrap() = Some(handle);
}))
});
assert!(run_gate(&low).is_ok(), "A's assertion is valid");
let handle = b.lock().unwrap().take().expect("the hook ran");
assert!(handle.join().unwrap().is_ok(), "B's assertion is valid");
assert_eq!(
stored_count(),
3,
"the stored counter must be the highest seen, never a stale write-back"
);
}
fn call(
iri: &str,
endpoint: impl Endpoint + 'static,
verb: Verb,
body: Option<&str>,
) -> Result<Representation> {
let kernel = Kernel::new(std::sync::Arc::new(
ikigai_core::EndpointSpace::new().bind(ikigai_core::Exact::new(iri), endpoint),
));
let mut req = Request::new(verb, Iri::parse(iri).unwrap());
if let Some(body) = body {
req = req.with_arg("content", ArgRef::Inline(body.as_bytes().to_vec()));
}
futures::executor::block_on(kernel.issue(req, &Capability::root()))
}
fn open_window() {
std::fs::write(enroll_window_path(), (now_secs() + 60).to_string()).unwrap();
}
#[test]
fn a_corrupt_store_errors_the_challenge_and_is_not_overwritten_by_registration() {
isolate("corrupt-faces");
std::fs::write(credentials_path(), b"[{\"id\":").unwrap();
assert!(
call(
"urn:passkey:challenge",
PasskeyChallenge,
Verb::Source,
None
)
.is_err(),
"the page must not be told `not enrolled` and submit token-only"
);
open_window();
let body = format!(
"id={}&spki={}",
b64().encode(b"cred-2"),
Device::new().spki_b64()
);
assert!(
call(
"urn:passkey:register",
PasskeyRegister,
Verb::Sink,
Some(&body)
)
.is_err(),
"registration does not paper over a broken store from the public face"
);
assert_eq!(std::fs::read(credentials_path()).unwrap(), b"[{\"id\":");
assert!(is_enrolled(), "a broken store counts as armed");
}
#[test]
fn an_enrolled_gate_with_no_relying_party_refuses_everything() {
isolate("no-rp-enrolled");
let d = Device::new();
d.register();
let challenge = issue_challenge().unwrap();
let body = d.assert_fields(&challenge, 0x05, 1);
unconfigure();
assert!(run_gate(&body).is_err(), "no RP to check against = refuse");
assert!(
call(
"urn:passkey:challenge",
PasskeyChallenge,
Verb::Source,
None
)
.is_err(),
"and the challenge says so rather than naming no RP"
);
}
#[test]
fn an_unconfigured_edge_with_nothing_enrolled_stays_inert() {
isolate("no-rp-inert");
unconfigure();
assert!(
run_gate("id=abc&exp=1").is_ok(),
"deploying cannot brick the links"
);
let rep = call(
"urn:passkey:challenge",
PasskeyChallenge,
Verb::Source,
None,
)
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&rep.bytes).unwrap();
assert_eq!(v["enrolled"], false);
assert!(
v["rpId"].is_null(),
"no RP is reported as none, never a default"
);
}
#[test]
fn registration_needs_a_relying_party() {
isolate("no-rp-register");
open_window();
let body = format!(
"id={}&spki={}",
b64().encode(b"cred-1"),
Device::new().spki_b64()
);
unconfigure();
assert!(
call(
"urn:passkey:register",
PasskeyRegister,
Verb::Sink,
Some(&body)
)
.is_err(),
"nothing to bind a credential to"
);
assert!(!credentials_path().exists());
let page = call("urn:passkey:register", PasskeyRegister, Verb::Source, None).unwrap();
assert!(String::from_utf8_lossy(&page.bytes).contains("--passkey-rp-id"));
TEST_RP.with(|r| *r.borrow_mut() = Some(test_relying_party()));
assert!(call(
"urn:passkey:register",
PasskeyRegister,
Verb::Sink,
Some(&body)
)
.is_ok());
assert!(is_enrolled());
}
#[test]
fn a_challenge_expires_on_the_monotonic_clock() {
isolate("ttl");
let t0 = monotonic_now();
let fresh = issue_challenge_at(t0).unwrap();
assert!(consume_challenge_at(
&fresh,
t0 + CHALLENGE_TTL - Duration::from_secs(1)
));
let stale = issue_challenge_at(t0).unwrap();
assert!(
!consume_challenge_at(&stale, t0 + CHALLENGE_TTL),
"a challenge is dead at its TTL"
);
}
#[test]
fn enrollment_window_gates_registration() {
isolate("enroll");
assert!(!enrollment_open(), "closed by default");
let kernel = Kernel::new(std::sync::Arc::new(ikigai_core::EndpointSpace::new().bind(
ikigai_core::Exact::new("urn:passkey:enroll-open"),
PasskeyEnrollOpen,
)));
let open = |cap: &Capability| {
futures::executor::block_on(kernel.issue(
Request::new(Verb::Sink, Iri::parse("urn:passkey:enroll-open").unwrap()),
cap,
))
};
assert!(
open(&Capability::scoped(Vec::<String>::new())).is_err(),
"no cap, no open"
);
assert!(open(&Capability::root()).is_ok(), "root opens it");
assert!(enrollment_open(), "now open");
}
}