初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+460
View File
@@ -0,0 +1,460 @@
//! 司命授权: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());
}
}