feat(extensions): 将执行许可绑定到完整启动声明
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -39,3 +39,6 @@ pub mod extension_dependencies;
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod extension_unpack;
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod extension_permit;
|
||||
|
||||
Reference in New Issue
Block a user