feat(extensions): 将执行许可绑定到完整启动声明

This commit is contained in:
2026-09-08 21:16:11 +08:00
parent cf03d2e0db
commit 67103cbe0e
3 changed files with 264 additions and 0 deletions
+253
View File
@@ -0,0 +1,253 @@
//! Host-only execution permit binding. No IPC caller can mint these permits.
//! The installer must complete user consent and current trust checks before issue.
use crate::workspace::{HostError, Result};
use hmac::{Hmac, Mac};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::{BTreeMap, BTreeSet};
use zeroize::Zeroize;
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", content = "value", deny_unknown_fields)]
pub enum Environment {
Literal(String),
// A scoped credential declaration, never resolved secret plaintext.
CredentialScope(String),
}
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Claims {
pub source: String,
pub namespace: String,
pub package_id: String,
pub version: String,
pub archive_sha256: String,
pub tree_sha256: String,
pub signer_sha256: String,
pub entry: String,
pub arguments: Vec<String>,
pub environment: BTreeMap<String, Environment>,
pub permissions: BTreeSet<String>,
pub vault_id: String,
pub platform: String,
pub policy_version: String,
pub expires_at_ms: u64,
}
/// Opaque authenticator; the Host retains claims separately. No paths, arguments
/// or credential declarations need to be passed to a renderer with the token.
pub struct Permit([u8; 32]);
pub struct Authority {
key: [u8; 32],
}
impl Drop for Authority {
fn drop(&mut self) {
self.key.zeroize();
}
}
impl Default for Authority {
fn default() -> Self {
let mut key = [0; 32];
rand::rngs::OsRng.fill_bytes(&mut key);
Self { key }
}
}
impl Claims {
fn encoded(&self, now_ms: u64) -> Result<Vec<u8>> {
let bad = || HostError::new("EXTENSION_PERMIT_INVALID");
if self.expires_at_ms <= now_ms {
return Err(HostError::new("EXTENSION_PERMIT_EXPIRED"));
}
let url = reqwest::Url::parse(&self.source).map_err(|_| bad())?;
if url.scheme() != "https"
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
|| self.source.len() > 2048
{
return Err(bad());
}
for slug in [&self.namespace, &self.package_id] {
if !(2..=64).contains(&slug.len())
|| !slug.as_bytes()[0].is_ascii_alphanumeric()
|| !slug
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
return Err(bad());
}
}
if semver::Version::parse(&self.version).is_err()
|| uuid::Uuid::parse_str(&self.vault_id).is_err()
{
return Err(bad());
}
for digest in [&self.archive_sha256, &self.tree_sha256, &self.signer_sha256] {
if digest.len() != 64
|| !digest
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
{
return Err(bad());
}
}
if self.entry.len() > 1024
|| self.entry.split('/').any(|p| {
p.is_empty()
|| p == "."
|| p == ".."
|| p.contains(['\\', ':'])
|| p.chars().any(char::is_control)
})
{
return Err(bad());
}
if self.arguments.len() > 128
|| self.environment.len() > 128
|| self.permissions.len() > 128
|| self
.arguments
.iter()
.any(|s| s.len() > 8192 || s.contains('\0'))
|| self
.permissions
.iter()
.any(|s| s.is_empty() || s.len() > 256 || s.chars().any(char::is_control))
|| self.platform.is_empty()
|| self.platform.len() > 64
|| self.policy_version.is_empty()
|| self.policy_version.len() > 128
{
return Err(bad());
}
for (name, declaration) in &self.environment {
if name.is_empty()
|| name.len() > 128
|| !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
{
return Err(bad());
}
let value = match declaration {
Environment::Literal(v) | Environment::CredentialScope(v) => v,
};
if value.len() > 8192 || value.contains('\0') {
return Err(bad());
}
}
let bytes = serde_json::to_vec(self).map_err(|_| bad())?;
if bytes.len() > 256 * 1024 {
return Err(bad());
}
Ok(bytes)
}
}
impl Authority {
/// Call only after consent and live trust validation. This authenticates the
/// decision; it does not establish sandbox availability or grant broker access.
pub fn issue(&self, claims: &Claims, now_ms: u64) -> Result<Permit> {
let encoded = claims.encoded(now_ms)?;
let mut mac = Hmac::<Sha256>::new_from_slice(&self.key).expect("HMAC key");
mac.update(b"OpenNexus execution permit v1\0");
mac.update(&encoded);
Ok(Permit(mac.finalize().into_bytes().into()))
}
pub fn verify(&self, permit: &Permit, actual: &Claims, now_ms: u64) -> Result<()> {
let encoded = actual.encoded(now_ms)?;
let mut mac = Hmac::<Sha256>::new_from_slice(&self.key).expect("HMAC key");
mac.update(b"OpenNexus execution permit v1\0");
mac.update(&encoded);
mac.verify_slice(&permit.0)
.map_err(|_| HostError::new("EXTENSION_PERMIT_MISMATCH"))
}
/// Lock/logout/policy invalidation may discard all permits. Restart creates a
/// fresh key, so an old process token cannot silently revive authorization.
pub fn invalidate_all(&mut self) {
self.key.zeroize();
rand::rngs::OsRng.fill_bytes(&mut self.key);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn claims() -> Claims {
Claims {
source: "https://catalog.example/".into(),
namespace: "examples".into(),
package_id: "note-reviewer".into(),
version: "1.0.0".into(),
archive_sha256: "a".repeat(64),
tree_sha256: "b".repeat(64),
signer_sha256: "c".repeat(64),
entry: "main.py".into(),
arguments: vec!["--stdio".into()],
environment: BTreeMap::from([("MODE".into(), Environment::Literal("test".into()))]),
permissions: BTreeSet::from(["notes.read".into()]),
vault_id: uuid::Uuid::new_v4().to_string(),
platform: "windows".into(),
policy_version: "1".into(),
expires_at_ms: 1000,
}
}
#[test]
fn every_claim_is_bound_expiry_and_key_rotation_fail_closed() {
let mut authority = Authority::default();
let original = claims();
let token = authority.issue(&original, 100).unwrap();
authority.verify(&token, &original, 999).unwrap();
let value = serde_json::to_value(&original).unwrap();
for field in value.as_object().unwrap().keys() {
let mut changed = value.clone();
match &mut changed[field] {
serde_json::Value::String(s) => {
*s = match field.as_str() {
"source" => "https://other.example/".into(),
"version" => "1.0.1".into(),
"archive_sha256" | "tree_sha256" | "signer_sha256" => "d".repeat(64),
"vault_id" => uuid::Uuid::new_v4().to_string(),
_ => format!("{s}x"),
};
}
serde_json::Value::Number(n) => *n = 1001.into(),
serde_json::Value::Array(a) => a.push("extra".into()),
serde_json::Value::Object(o) => {
o.clear();
}
_ => panic!("unexpected claim type"),
}
let changed: Claims = serde_json::from_value(changed).unwrap();
changed.encoded(100).unwrap();
assert!(
authority.verify(&token, &changed, 100).is_err(),
"unbound field {field}"
);
}
assert!(authority.verify(&token, &original, 1000).is_err());
assert!(Authority::default().verify(&token, &original, 100).is_err());
authority.invalidate_all();
assert!(authority.verify(&token, &original, 100).is_err());
}
#[test]
fn invalid_paths_environment_and_oversize_decisions_are_rejected() {
let authority = Authority::default();
for entry in ["../main.py", "C:/main.py", "\\main.py", "a//b", ""] {
let mut c = claims();
c.entry = entry.into();
assert!(authority.issue(&c, 100).is_err());
}
let mut c = claims();
c.environment
.insert("BAD=KEY".into(), Environment::Literal("v".into()));
assert!(authority.issue(&c, 100).is_err());
let mut c = claims();
c.arguments = vec!["x".repeat(8192); 128];
assert!(authority.issue(&c, 100).is_err());
let mut c = claims();
c.source = "http://catalog.example".into();
assert!(authority.issue(&c, 100).is_err());
}
}
+3
View File
@@ -39,3 +39,6 @@ pub mod extension_dependencies;
#[cfg(feature = "desktop")]
pub mod extension_unpack;
#[cfg(feature = "desktop")]
pub mod extension_permit;