Three independent security hardenings from cross-cutting audits.
1. cortex /term PTY env leak + bind guard (HIGH — Sonnet Cross-cutting + Opus)
- kei-cortex/src/handlers/term_pty.rs: PTY spawn was inheriting daemon's
full process env (KEI_AUTH_KEY, ANTHROPIC_API_KEY, FAL_KEY, etc.) into
every authenticated /term shell. Combined with default cors_origin =
https://keisei.app, one stored XSS on keisei.app + one bearer token =
full local shell with all daemon secrets.
Added apply_safe_env() helper: env_clear() + re-set only HOME, PATH,
USER, LANG, TERM. Spawn helper invokes it before spawn_command.
- kei-cortex/src/main.rs: extracted build_config() helper; added
enforce_loopback_or_local_cors() guard called before serve.bind. Refuses
to start if bind addr is non-loopback AND cors_origin is a public
domain — prevents the XSS-to-shell scenario in production.
2. agent-stub-scan.sh stdin parsing (HIGH — multiple audits)
- hooks/agent-stub-scan.sh: previously read $CLAUDE_AGENT_TRANSCRIPT env
var which Claude Code does NOT set on PostToolUse:Agent. Hook silently
exited 0 — RULE 0.16 enforcement was dead-code in production.
Rewrote to read stdin JSON via jq, flatten .tool_response recursively
(string|array|object via the same pattern as agent-event-done.sh),
guard on .tool_name == "Agent" and command -v jq. Maintained WARN-tier
exit-0 with TODO marker for ENFORCE flip on 2026-05-05 (per RULE 0.16
§2 ladder).
3. magiclink revoke() silent no-op (HIGH — Opus Rust + Sonnet Cross-cutting)
- kei-auth-magiclink/src/{error,provider}.rs: revoke() previously returned
Ok(()) without doing anything. Operators expecting "revoke a session"
semantics from the AuthProvider trait got false success. Stolen magic-
link URLs remained valid until the 15-minute TTL.
Added Error::Unsupported variant. revoke() now returns
Err(Unsupported(...)) with explicit guidance: "rotate KEI_MAGICLINK_HMAC_
KEY to invalidate all live tokens, or maintain a deny-list at the caller
layer". Test provider_revoke_returns_unsupported_error confirms the
error variant is wired.
Tests: cargo check + cargo test both PASS. 444 functional tests across
kei-cortex (428 lib) + kei-auth-magiclink (16 lib + smoke). Pre-existing
openai_loop_wiring.rs 502 failures in routes/openai/{chat,responses}.rs are
NOT introduced by these fixes — separate unrelated triage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
180 lines
6.3 KiB
Rust
180 lines
6.3 KiB
Rust
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright 2026 <author org>
|
|
|
|
//! [`MagicLinkProvider`] — passwordless `AuthProvider` impl.
|
|
//!
|
|
//! Stateless HMAC-signed tokens. No DB. The provider is a value-typed
|
|
//! cube: `dna`, `parent`, `hmac_key`, `ttl_secs`. Construct via
|
|
//! [`MagicLinkProvider::new`] (explicit) or [`MagicLinkProvider::from_env`]
|
|
//! (reads `MAGICLINK_HMAC_KEY` and `MAGICLINK_TTL_SECS`).
|
|
//!
|
|
//! ## Trait convention quirk
|
|
//!
|
|
//! [`AuthChallenge::MagicLink`] only carries an `email` field. Two paths use it:
|
|
//!
|
|
//! - [`MagicLinkProvider::issue_challenge`] — `email` is the user's address.
|
|
//! The provider does NOT send the email itself (no dependency on
|
|
//! `kei-notify-*`); callers build the URL via
|
|
//! [`MagicLinkProvider::build_magic_url`] and dispatch through their own
|
|
//! notify channel.
|
|
//! - [`MagicLinkProvider::verify`] — `email` MUST be the FULL token string
|
|
//! returned in the URL's `?token=…` query param. Callers wire the web
|
|
//! handler to slot the token into the `email` field. This is the minimum
|
|
//! change consistent with the trait surface as of v0.1; a future
|
|
//! `AuthChallenge::MagicLinkVerify { token }` variant would be cleaner.
|
|
|
|
use crate::env::{read_env, MIN_KEY_LEN};
|
|
use crate::error::{Error, Result};
|
|
use crate::token::{build_token, parse_token};
|
|
use async_trait::async_trait;
|
|
use kei_runtime_core::dna::{Dna, DnaBuilder, HasDna};
|
|
use kei_runtime_core::traits::auth::{AuthChallenge, AuthProvider, AuthSession};
|
|
|
|
/// Stateless HMAC-SHA256 magic-link provider.
|
|
#[derive(Debug)]
|
|
pub struct MagicLinkProvider {
|
|
dna: Dna,
|
|
parent: Dna,
|
|
hmac_key: Vec<u8>,
|
|
ttl_secs: i64,
|
|
}
|
|
|
|
impl MagicLinkProvider {
|
|
/// Construct with an explicit parent DNA, key bytes, and TTL.
|
|
pub fn new(parent: Dna, hmac_key: Vec<u8>, ttl_secs: i64) -> Result<Self> {
|
|
if hmac_key.len() < MIN_KEY_LEN {
|
|
return Err(Error::KeyMissing(format!(
|
|
"hmac key must be ≥ {MIN_KEY_LEN} bytes, got {}",
|
|
hmac_key.len()
|
|
)));
|
|
}
|
|
let dna = build_provider_dna()?;
|
|
Ok(Self { dna, parent, hmac_key, ttl_secs })
|
|
}
|
|
|
|
/// Construct from environment. See [`crate::env`] for variable names.
|
|
pub fn from_env(parent: Dna) -> Result<Self> {
|
|
let (key, ttl) = read_env()?;
|
|
Self::new(parent, key, ttl)
|
|
}
|
|
|
|
/// Build the URL the caller emails to the user.
|
|
pub fn build_magic_url(&self, base_url: &str, email: &str) -> String {
|
|
let expires = now_unix_ms().saturating_add(self.ttl_secs * 1000);
|
|
let token = build_token(email, expires, &self.hmac_key);
|
|
format!("{}/auth/magic?token={}", base_url.trim_end_matches('/'), token)
|
|
}
|
|
|
|
/// Configured TTL in seconds.
|
|
pub fn ttl_secs(&self) -> i64 {
|
|
self.ttl_secs
|
|
}
|
|
}
|
|
|
|
impl HasDna for MagicLinkProvider {
|
|
fn dna(&self) -> &Dna {
|
|
&self.dna
|
|
}
|
|
fn parent_dna(&self) -> Option<&Dna> {
|
|
Some(&self.parent)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AuthProvider for MagicLinkProvider {
|
|
fn provider_name(&self) -> &'static str {
|
|
"magiclink"
|
|
}
|
|
|
|
async fn issue_challenge(
|
|
&self,
|
|
c: &AuthChallenge,
|
|
) -> kei_runtime_core::Result<()> {
|
|
match c {
|
|
AuthChallenge::MagicLink { email } if !email.is_empty() => {
|
|
// Stateless: issuing IS building a token. Caller emails it.
|
|
// We pre-flight build to fail fast if HMAC machinery breaks.
|
|
let expires = now_unix_ms().saturating_add(self.ttl_secs * 1000);
|
|
let _ = build_token(email, expires, &self.hmac_key);
|
|
Ok(())
|
|
}
|
|
AuthChallenge::MagicLink { .. } => Err(kei_runtime_core::Error::Auth(
|
|
"magiclink: empty email".into(),
|
|
)),
|
|
_ => Err(kei_runtime_core::Error::Auth(
|
|
"magiclink: unsupported challenge variant".into(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn verify(
|
|
&self,
|
|
c: &AuthChallenge,
|
|
) -> kei_runtime_core::Result<AuthSession> {
|
|
let token = match c {
|
|
AuthChallenge::MagicLink { email } => email,
|
|
_ => {
|
|
return Err(kei_runtime_core::Error::Auth(
|
|
"magiclink: unsupported challenge variant".into(),
|
|
))
|
|
}
|
|
};
|
|
let (email, expires_unix_ms) =
|
|
parse_token(token, &self.hmac_key, now_unix_ms()).map_err(|e: Error| -> kei_runtime_core::Error { e.into() })?;
|
|
let session_dna = build_session_dna(&email).map_err(|e: Error| -> kei_runtime_core::Error { e.into() })?;
|
|
Ok(AuthSession {
|
|
dna: session_dna,
|
|
parent_dna: self.parent.clone(),
|
|
user_id: email,
|
|
expires_unix_ms,
|
|
user_agent: None,
|
|
})
|
|
}
|
|
|
|
async fn revoke(&self, _session: &Dna) -> kei_runtime_core::Result<()> {
|
|
// SECURITY: do NOT silently return Ok here. Stateless HMAC tokens
|
|
// cannot be server-side revoked, and a silent Ok() would lie to
|
|
// every caller that thought it had killed a session. Surface the
|
|
// truth so callers can either rotate the HMAC key (kills ALL live
|
|
// tokens) or maintain an external deny-list keyed on the token's
|
|
// first two parts (email + expiry).
|
|
Err(Error::Unsupported(
|
|
"magiclink: stateless tokens cannot be server-side revoked. \
|
|
Rotate KEI_MAGICLINK_HMAC_KEY to invalidate all live tokens, \
|
|
OR maintain a deny-list at the caller layer keyed on the \
|
|
token's first two parts."
|
|
.into(),
|
|
)
|
|
.into())
|
|
}
|
|
|
|
fn is_passwordless(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
fn build_provider_dna() -> Result<Dna> {
|
|
DnaBuilder::new("primitive")
|
|
.caps(["PR", "AP", "ML"])
|
|
.scope("keiseikit.dev/primitives/kei-auth-magiclink")
|
|
.body(b"magiclink-v1")
|
|
.build()
|
|
.map_err(|e| Error::Dna(e.to_string()))
|
|
}
|
|
|
|
fn build_session_dna(email: &str) -> Result<Dna> {
|
|
DnaBuilder::new("session")
|
|
.caps(["AS", "ML"])
|
|
.scope("keiseikit.dev/sessions/magiclink")
|
|
.body(email.as_bytes())
|
|
.build()
|
|
.map_err(|e| Error::Dna(e.to_string()))
|
|
}
|
|
|
|
fn now_unix_ms() -> i64 {
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as i64)
|
|
.unwrap_or(0)
|
|
}
|