KeiSeiKit-1.0/_primitives/_rust/kei-auth-magiclink/src/provider.rs
Parfii-bot 71f17337fe fix(security): cortex /term env_clear + bind guard, agent-stub-scan stdin, magiclink revoke
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>
2026-05-03 15:38:23 +08:00

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)
}