461 lines
19 KiB
Rust
461 lines
19 KiB
Rust
//! 司命授权:HMAC-SHA256 签名的短期凭证。
|
||
//!
|
||
//! 凭证格式:`<payload_b64url>.<sig_b64url>`
|
||
//! - payload: JSON `{exp, trace_id, risk_level, tool, character_id, run_id, body_hash, rlimit_overrides}`,
|
||
//! 字段顺序与后端 Python 客户端一致。
|
||
//! - sig: HMAC-SHA256(secret, payload_b64url) 的 base64url(无填充)。
|
||
//!
|
||
//! 安全语义:沙箱不做风险判定,只校验"调用方是否持有由司命签发的有效凭证"。
|
||
//! `body_hash` 把凭证绑死到具体执行内容(argv + stdin 摘要),防 TTL 内重放执行任意命令;
|
||
//! `rlimit_overrides` 携带司命授予的资源配额上限(授权属性,不进 body_hash),验签后信任并 clamp。
|
||
//! verify 支持多 secret 并存(主 + previous),支持无中断轮换。
|
||
//! 验签失败、过期或 body_hash 不匹配一律 401。
|
||
//! 设计依据见 docs/architecture/service-boundaries.md 与 cross-language-integration-design.md。
|
||
|
||
use std::collections::BTreeMap;
|
||
use std::sync::Arc;
|
||
use std::time::{SystemTime, UNIX_EPOCH};
|
||
|
||
use axum::extract::{Request, State};
|
||
use axum::http::{header, StatusCode};
|
||
use axum::middleware::Next;
|
||
use axum::response::{IntoResponse, Response};
|
||
use axum::Json;
|
||
use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine as _};
|
||
use hmac::{Hmac, Mac};
|
||
use serde::{Deserialize, Serialize};
|
||
use sha2::{Digest, Sha256};
|
||
|
||
use crate::AppState;
|
||
|
||
type HmacSha256 = Hmac<Sha256>;
|
||
|
||
/// 授权凭证 payload(验签通过后插入请求扩展,供 handler 关联 trace、body_hash 校验与资源配额)。
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct AuthPayload {
|
||
/// 过期时间(Unix 秒)。
|
||
pub exp: u64,
|
||
#[serde(default)]
|
||
pub trace_id: Option<String>,
|
||
#[serde(default)]
|
||
pub risk_level: Option<String>,
|
||
#[serde(default)]
|
||
pub tool: Option<String>,
|
||
#[serde(default)]
|
||
pub character_id: Option<String>,
|
||
#[serde(default)]
|
||
pub run_id: Option<String>,
|
||
/// 请求内容摘要 SHA256(canonical JSON `{argv, stdin}`),绑定凭证与具体执行内容,
|
||
/// 防止凭证在 TTL 内被重放去执行任意命令;None 表示不绑定(仅 auth_enabled=false 本地联调)。
|
||
#[serde(default)]
|
||
pub body_hash: Option<String>,
|
||
/// 司命授予该上下文的资源配额上限(key 见 `RlimitKind::as_str`:fsize/as/cpu/nproc/nofile)。
|
||
/// 随 HMAC 凭证下发,sandbox 验签后信任并 clamp 到全局天花板;属授权属性,不进 body_hash。
|
||
#[serde(default)]
|
||
pub rlimit_overrides: Option<BTreeMap<String, u64>>,
|
||
/// 文件操作摘要 SHA256(canonical JSON `{op, path}`),绑定凭证与具体文件操作
|
||
/// (read/write/list/stat),防 TTL 内重放执行任意文件操作。exec 凭证为 None、file 凭证
|
||
/// 为 Some;与 body_hash 互斥使用,不参与 body_hash 计算。
|
||
#[serde(default)]
|
||
pub file_op_hash: Option<String>,
|
||
/// 隔离模式授权(L0-L2 → process,L3-L4 → container)。随 HMAC 凭证下发,sandbox 验签后
|
||
/// 据此路由执行路径;P0 期间 container 降级 process。属授权属性,不进 body_hash,与
|
||
/// rlimit_overrides 同链路。None 表示司命未指定,按 SANDBOX_ISOLATION_DEFAULT 兜底。
|
||
#[serde(default)]
|
||
pub isolation_mode: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub enum AuthError {
|
||
Missing,
|
||
Malformed,
|
||
BadSignature,
|
||
Expired,
|
||
}
|
||
|
||
impl std::fmt::Display for AuthError {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
let s = match self {
|
||
AuthError::Missing => "缺少 Authorization 头",
|
||
AuthError::Malformed => "授权凭证格式不合法",
|
||
AuthError::BadSignature => "授权凭证签名校验失败",
|
||
AuthError::Expired => "授权凭证已过期",
|
||
};
|
||
f.write_str(s)
|
||
}
|
||
}
|
||
|
||
/// 签发授权凭证(与后端 Python `sign_authorization` 共用同一算法与密钥)。
|
||
#[allow(dead_code)] // 沙箱端仅校验(verify);sign 供对称测试与未来本地签发场景保留
|
||
pub fn sign(secret: &str, payload: &AuthPayload) -> anyhow::Result<String> {
|
||
let json = serde_json::to_vec(payload)?;
|
||
let payload_b64 = BASE64_URL_SAFE_NO_PAD.encode(&json);
|
||
let mut mac =
|
||
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC 接受任意长度密钥");
|
||
mac.update(payload_b64.as_bytes());
|
||
let sig_b64 = BASE64_URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
|
||
Ok(format!("{payload_b64}.{sig_b64}"))
|
||
}
|
||
|
||
/// 校验授权凭证(多 secret):逐个尝试 secrets,任一验签通过即放行。
|
||
///
|
||
/// 返回 `(payload, 命中 secret 的索引)`:0 为主 secret(`SANDBOX_AUTH_SECRET`),
|
||
/// \>0 为旧 secret(`SANDBOX_AUTH_SECRET_PREVIOUS` 第几个)。索引供 metric 区分轮换进度。
|
||
/// 格式 / 过期错误与 secret 无关,只判一次;仅验签阶段遍历多 secret(支持无中断轮换的并存窗口)。
|
||
pub fn verify_any(secrets: &[String], token: &str) -> Result<(AuthPayload, usize), AuthError> {
|
||
let (payload_b64, sig_b64) = token.rsplit_once('.').ok_or(AuthError::Malformed)?;
|
||
let sig_bytes = BASE64_URL_SAFE_NO_PAD
|
||
.decode(sig_b64.as_bytes())
|
||
.map_err(|_| AuthError::Malformed)?;
|
||
|
||
// 验签:任一 secret 通过即可(常量时间比较由 hmac verify_slice 提供)。
|
||
let mut slot = None;
|
||
for (i, secret) in secrets.iter().enumerate() {
|
||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
|
||
.map_err(|_| AuthError::Malformed)?;
|
||
mac.update(payload_b64.as_bytes());
|
||
if mac.verify_slice(&sig_bytes).is_ok() {
|
||
slot = Some(i);
|
||
break;
|
||
}
|
||
}
|
||
let slot = slot.ok_or(AuthError::BadSignature)?;
|
||
|
||
let payload_bytes = BASE64_URL_SAFE_NO_PAD
|
||
.decode(payload_b64.as_bytes())
|
||
.map_err(|_| AuthError::Malformed)?;
|
||
let payload: AuthPayload =
|
||
serde_json::from_slice(&payload_bytes).map_err(|_| AuthError::Malformed)?;
|
||
|
||
let now = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.map(|d| d.as_secs())
|
||
.unwrap_or(0);
|
||
if payload.exp <= now {
|
||
return Err(AuthError::Expired);
|
||
}
|
||
Ok((payload, slot))
|
||
}
|
||
|
||
/// 单 secret 校验(测试便捷包装:内部转 `verify_any`)。
|
||
/// 仅测试用——生产路径 `require_authorization` 直连 `verify_any` 以支持多 secret 轮换。
|
||
#[cfg(test)]
|
||
pub fn verify(secret: &str, token: &str) -> Result<AuthPayload, AuthError> {
|
||
verify_any(&[secret.to_string()], token).map(|(p, _)| p)
|
||
}
|
||
|
||
/// 计算请求内容摘要:SHA256(canonical JSON of `{argv, stdin}`)。
|
||
///
|
||
/// 与后端 Python `compute_body_hash` 字节级一致:
|
||
/// - JSON 紧凑(无空格),字段顺序固定 `argv, stdin`;`serde_json` 默认紧凑输出 + Option None → null,
|
||
/// 对齐 Python `json.dumps(..., separators=(",",":"), ensure_ascii=False)`
|
||
/// - 返回小写十六进制(64 字符),对齐 `hashlib.sha256().hexdigest()`
|
||
/// - 注意:`rlimit_overrides` 不参与摘要(属授权属性,非执行内容)
|
||
pub fn compute_body_hash(argv: &[String], stdin: &Option<String>) -> String {
|
||
#[derive(Serialize)]
|
||
struct BodyHashInput<'a> {
|
||
argv: &'a [String],
|
||
stdin: &'a Option<String>,
|
||
}
|
||
let bytes = serde_json::to_vec(&BodyHashInput { argv, stdin })
|
||
.expect("argv/stdin 序列化不会失败");
|
||
let digest = Sha256::digest(&bytes);
|
||
digest.iter().map(|b| format!("{:02x}", b)).collect()
|
||
}
|
||
|
||
/// 计算 code 模式请求内容摘要:SHA256(canonical JSON `{language, code, stdin}`)。
|
||
///
|
||
/// 与后端 Python `compute_body_hash_code` 字节级一致:
|
||
/// - JSON 紧凑、字段顺序固定 `language, code, stdin`;Option None → null
|
||
/// - 返回小写十六进制(64 字符)
|
||
/// - code 模式凭证 body_hash 用此函数(argv 模式仍用 compute_body_hash),把凭证绑死到
|
||
/// 具体代码内容,防 TTL 内重放执行任意代码。
|
||
pub fn compute_body_hash_code(language: &str, code: &str, stdin: &Option<String>) -> String {
|
||
#[derive(Serialize)]
|
||
struct CodeHashInput<'a> {
|
||
language: &'a str,
|
||
code: &'a str,
|
||
stdin: &'a Option<String>,
|
||
}
|
||
let bytes = serde_json::to_vec(&CodeHashInput {
|
||
language,
|
||
code,
|
||
stdin,
|
||
})
|
||
.expect("language/code/stdin 序列化不会失败");
|
||
let digest = Sha256::digest(&bytes);
|
||
digest.iter().map(|b| format!("{:02x}", b)).collect()
|
||
}
|
||
|
||
/// 计算文件操作摘要:SHA256(canonical JSON `{op, path}`)。
|
||
///
|
||
/// 与后端 Python `compute_file_op_hash` 字节级一致:紧凑 JSON、字段顺序固定 `op, path`、
|
||
/// 小写十六进制。把凭证绑死到具体文件操作,防 TTL 内重放执行任意文件读写。
|
||
pub fn compute_file_op_hash(op: &str, path: &str) -> String {
|
||
#[derive(Serialize)]
|
||
struct FileOpHashInput<'a> {
|
||
op: &'a str,
|
||
path: &'a str,
|
||
}
|
||
let bytes = serde_json::to_vec(&FileOpHashInput { op, path })
|
||
.expect("op/path 序列化不会失败");
|
||
let digest = Sha256::digest(&bytes);
|
||
digest.iter().map(|b| format!("{:02x}", b)).collect()
|
||
}
|
||
|
||
/// axum 中间件:校验 `Authorization: Bearer <token>`;`auth_enabled=false` 时直接放行。
|
||
pub async fn require_authorization(
|
||
State(state): State<Arc<AppState>>,
|
||
mut req: Request,
|
||
next: Next,
|
||
) -> Response {
|
||
if !state.config.auth_enabled {
|
||
// 本地联调:跳过验签,但仍插入占位 payload,使 handler 能统一从扩展取 AuthPayload。
|
||
req.extensions_mut().insert(AuthPayload {
|
||
exp: 0,
|
||
trace_id: None,
|
||
risk_level: None,
|
||
tool: None,
|
||
character_id: None,
|
||
run_id: None,
|
||
body_hash: None,
|
||
rlimit_overrides: None,
|
||
file_op_hash: None,
|
||
isolation_mode: None,
|
||
});
|
||
return next.run(req).await;
|
||
}
|
||
|
||
let token = req
|
||
.headers()
|
||
.get(header::AUTHORIZATION)
|
||
.and_then(|v| v.to_str().ok())
|
||
.and_then(|s| s.strip_prefix("Bearer ").map(str::trim));
|
||
|
||
match token {
|
||
Some(t) => {
|
||
// 主 secret + previous secrets 一并尝试(无中断轮换的并存窗口)。
|
||
let mut secrets = vec![state.config.auth_secret.clone()];
|
||
secrets.extend(state.config.auth_secret_previous.iter().cloned());
|
||
match verify_any(&secrets, t) {
|
||
Ok((payload, slot)) => {
|
||
state.executor.metrics().record_auth_secret(slot);
|
||
req.extensions_mut().insert(payload);
|
||
next.run(req).await
|
||
}
|
||
Err(e) => unauthorized(e),
|
||
}
|
||
}
|
||
None => unauthorized(AuthError::Missing),
|
||
}
|
||
}
|
||
|
||
fn unauthorized(err: AuthError) -> Response {
|
||
let body = Json(serde_json::json!({
|
||
"error": { "code": "sandbox_unauthorized", "message": err.to_string() }
|
||
}));
|
||
(StatusCode::UNAUTHORIZED, body).into_response()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
const SECRET: &str = "test-shared-secret";
|
||
|
||
fn payload(exp_offset_secs: i64) -> AuthPayload {
|
||
let now = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.map(|d| d.as_secs())
|
||
.unwrap_or(0);
|
||
AuthPayload {
|
||
exp: (now as i64 + exp_offset_secs) as u64,
|
||
trace_id: Some("trace_abc".into()),
|
||
risk_level: Some("L1".into()),
|
||
tool: Some("shell".into()),
|
||
character_id: Some("character_01H".into()),
|
||
run_id: Some("run_01H".into()),
|
||
body_hash: Some("deadbeef".into()),
|
||
rlimit_overrides: None,
|
||
file_op_hash: None,
|
||
isolation_mode: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn sign_then_verify_roundtrip() {
|
||
let token = sign(SECRET, &payload(60)).unwrap();
|
||
let claims = verify(SECRET, &token).expect("合法凭证应校验通过");
|
||
assert_eq!(claims.trace_id.as_deref(), Some("trace_abc"));
|
||
assert_eq!(claims.risk_level.as_deref(), Some("L1"));
|
||
assert_eq!(claims.body_hash.as_deref(), Some("deadbeef"));
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_wrong_secret() {
|
||
let token = sign(SECRET, &payload(60)).unwrap();
|
||
assert!(matches!(
|
||
verify("wrong-secret", &token),
|
||
Err(AuthError::BadSignature)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_tampered_token() {
|
||
let mut token = sign(SECRET, &payload(60)).unwrap();
|
||
// 篡改签名末位
|
||
let last = token.len() - 1;
|
||
token.replace_range(last.., if &token[last..] == "A" { "B" } else { "A" });
|
||
assert!(verify(SECRET, &token).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_expired() {
|
||
let token = sign(SECRET, &payload(-10)).unwrap();
|
||
assert!(matches!(verify(SECRET, &token), Err(AuthError::Expired)));
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_malformed() {
|
||
assert!(matches!(
|
||
verify(SECRET, "not-a-valid-token"),
|
||
Err(AuthError::Malformed)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn body_hash_known_vector_matches_python() {
|
||
// 与后端 Python compute_body_hash 共享同一固定向量,保证跨端字节级一致。
|
||
// 向量:{"argv":["sh","-c","echo hi"],"stdin":null}
|
||
let argv = vec!["sh".to_string(), "-c".to_string(), "echo hi".to_string()];
|
||
let hash = compute_body_hash(&argv, &None);
|
||
assert_eq!(
|
||
hash,
|
||
"f5fa97ea2ee92c505174672da52c59c952e49c32fa56a1db5bd983c10a829540"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn body_hash_distinguishes_none_and_empty_stdin() {
|
||
let argv = vec!["sh".to_string()];
|
||
// None → "stdin":null;Some("") → "stdin":"",两者字节不同
|
||
assert_ne!(
|
||
compute_body_hash(&argv, &None),
|
||
compute_body_hash(&argv, &Some(String::new()))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn file_op_hash_known_vector_matches_python() {
|
||
// 与后端 Python compute_file_op_hash 共享同一固定向量,保证跨端字节级一致。
|
||
// 向量:{"op":"read","path":"data/note.txt"}
|
||
let hash = compute_file_op_hash("read", "data/note.txt");
|
||
assert_eq!(
|
||
hash,
|
||
"717f67252c61cf1681cb3c6018d3801b79a511564664567b57d12d775bb8fa53"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn file_op_hash_differs_per_op_and_path() {
|
||
// 凭证绑死具体文件操作:op 或 path 不同则摘要不同,杜绝重放任意文件读写。
|
||
let read_a = compute_file_op_hash("read", "a.txt");
|
||
let write_a = compute_file_op_hash("write", "a.txt");
|
||
let read_b = compute_file_op_hash("read", "b.txt");
|
||
assert_ne!(read_a, write_a); // 同 path 不同 op
|
||
assert_ne!(read_a, read_b); // 同 op 不同 path
|
||
}
|
||
|
||
#[test]
|
||
fn verify_any_accepts_previous_secret() {
|
||
// 轮换并存窗口:主 secret + 旧 secret 都应被接受,且命中 slot 不同。
|
||
let token_old = sign("old-secret", &payload(60)).unwrap();
|
||
let token_new = sign("new-secret", &payload(60)).unwrap();
|
||
let secrets = vec!["new-secret".to_string(), "old-secret".to_string()];
|
||
// 新 secret 签发 → 命中 slot 0(primary)
|
||
let (p, slot) = verify_any(&secrets, &token_new).unwrap();
|
||
assert_eq!(slot, 0);
|
||
assert_eq!(p.trace_id.as_deref(), Some("trace_abc"));
|
||
// 旧 secret 签发 → 命中 slot 1(previous)
|
||
let (_, slot) = verify_any(&secrets, &token_old).unwrap();
|
||
assert_eq!(slot, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn verify_any_rejects_when_no_secret_matches() {
|
||
let token = sign("actual-secret", &payload(60)).unwrap();
|
||
let secrets = vec!["wrong1".to_string(), "wrong2".to_string()];
|
||
assert!(matches!(
|
||
verify_any(&secrets, &token),
|
||
Err(AuthError::BadSignature)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn rlimit_overrides_roundtrip_through_payload() {
|
||
// rlimit_overrides 随凭证序列化/反序列化,且不进 body_hash
|
||
let mut p = payload(60);
|
||
let mut overrides = BTreeMap::new();
|
||
overrides.insert("fsize".to_string(), 200_000_000u64);
|
||
p.rlimit_overrides = Some(overrides);
|
||
let token = sign(SECRET, &p).unwrap();
|
||
let claims = verify(SECRET, &token).unwrap();
|
||
assert_eq!(
|
||
claims.rlimit_overrides.as_ref().unwrap().get("fsize"),
|
||
Some(&200_000_000u64)
|
||
);
|
||
// body_hash 不受 rlimit_overrides 影响(测试用占位值,未绑定真实 argv/stdin)
|
||
assert_eq!(claims.body_hash.as_deref(), Some("deadbeef"));
|
||
}
|
||
|
||
#[test]
|
||
fn body_hash_code_known_vector_matches_python() {
|
||
// 与后端 Python compute_body_hash_code 共享固定向量,保证跨端字节级一致。
|
||
// 向量:{"language":"python","code":"print(1+1)","stdin":null}
|
||
let hash = compute_body_hash_code("python", "print(1+1)", &None);
|
||
assert_eq!(
|
||
hash,
|
||
"e7d633605676c3b19c642ee2437d91f9fb22562aaffad40f217d84c587322b69"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn body_hash_code_distinguishes_language_and_stdin() {
|
||
let base = compute_body_hash_code("python", "print(1+1)", &None);
|
||
// 不同 language → 不同摘要
|
||
assert_ne!(base, compute_body_hash_code("node", "print(1+1)", &None));
|
||
// 不同 stdin(None vs Some)→ 不同摘要
|
||
assert_ne!(
|
||
base,
|
||
compute_body_hash_code("python", "print(1+1)", &Some("42".into()))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn isolation_mode_roundtrip_and_backward_compat() {
|
||
// 新字段随凭证序列化/反序列化
|
||
let mut p = payload(60);
|
||
p.isolation_mode = Some("container".into());
|
||
let token = sign(SECRET, &p).unwrap();
|
||
let claims = verify(SECRET, &token).unwrap();
|
||
assert_eq!(claims.isolation_mode.as_deref(), Some("container"));
|
||
|
||
// 向后兼容:手工构造不含 isolation_mode 的旧 payload JSON,仍可验签且字段为 None。
|
||
let now = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.map(|d| d.as_secs())
|
||
.unwrap_or(0);
|
||
let legacy_json = format!(
|
||
"{{\"exp\":{},\"trace_id\":\"trace_abc\",\"risk_level\":\"L1\",\"tool\":\"shell\",\"character_id\":\"character_01H\",\"run_id\":\"run_01H\",\"body_hash\":\"deadbeef\",\"rlimit_overrides\":null,\"file_op_hash\":null}}",
|
||
now as i64 + 60
|
||
);
|
||
let payload_b64 = BASE64_URL_SAFE_NO_PAD.encode(legacy_json.as_bytes());
|
||
let mut mac = HmacSha256::new_from_slice(SECRET.as_bytes()).unwrap();
|
||
mac.update(payload_b64.as_bytes());
|
||
let sig_b64 = BASE64_URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
|
||
let token = format!("{payload_b64}.{sig_b64}");
|
||
let claims = verify(SECRET, &token).expect("旧凭证(无 isolation_mode)应可验签");
|
||
assert!(claims.isolation_mode.is_none());
|
||
}
|
||
}
|