Merge v3 with T15 into v3-t22

This commit is contained in:
2026-11-06 22:20:05 +00:00
171 changed files with 33754 additions and 481 deletions
+75
View File
@@ -0,0 +1,75 @@
//! Metadata-only audit ledger. Do not accept arbitrary script data.
use super::{
error::Result,
store::{Cred, now},
types::Caller,
};
use rusqlite::{Connection, params};
#[derive(Debug, serde::Serialize)]
pub struct Row {
pub id: i64,
pub time: i64,
pub event: String,
pub pid: u32,
pub program: String,
pub conversation: Option<String>,
pub item: Option<String>,
pub approver: Option<String>,
pub payload_sha256: Option<String>,
}
pub(crate) fn record(
db: &Connection,
event: &str,
caller: &Caller,
item: Option<&str>,
approver: Option<&str>,
) -> Result<()> {
db.execute(
"INSERT INTO audit(time,event,pid,conversation,item,approver,program) VALUES(?1,?2,?3,?4,?5,?6,?7)",
params![
now(),
event,
caller.pid,
caller.conversations.first(),
item,
approver,
caller.program
],
)?;
Ok(())
}
impl Cred {
/// Record a completed signature. The signing pipeline supplies the caller and item.
pub fn audit_sign(&self, item: &str, caller: &Caller, payload: &[u8]) -> Result<()> {
use sha2::{Digest, Sha256};
let mut s = self.access()?;
let tx = s.db.transaction()?;
record(&tx, "sign", caller, Some(item), None)?;
tx.execute(
"UPDATE audit SET payload_sha256=?1 WHERE id=?2",
params![hex::encode(Sha256::digest(payload)), tx.last_insert_rowid()],
)?;
tx.commit()?;
Ok(())
}
pub fn audit_list(&self) -> Result<Vec<Row>> {
let s = self.access()?;
let mut q = s.db.prepare(
"SELECT id,time,event,pid,conversation,item,approver,payload_sha256,program FROM audit ORDER BY id",
)?;
Ok(q.query_map([], |r| {
Ok(Row {
id: r.get(0)?,
time: r.get(1)?,
event: r.get(2)?,
pid: r.get(3)?,
program: r.get(8)?,
conversation: r.get(4)?,
item: r.get(5)?,
approver: r.get(6)?,
payload_sha256: r.get(7)?,
})
})?
.collect::<rusqlite::Result<_>>()?)
}
}
+622
View File
@@ -0,0 +1,622 @@
//! Reviewable full-configuration edits. Approval rechecks versions in its transaction.
//! Filing checks public values. Approval checks current secrets and preserves operation order.
use super::{
audit,
error::{Error, Result},
items::{self, Item},
seal,
types::Caller,
};
use rusqlite::{Connection, OptionalExtension, params};
/// Explicit public operations. No operation accepts a secret field value.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Operation {
CreateItem {
config: Item,
},
GenerateEs256Item {
config: Item,
},
SetName {
name: String,
},
SetRules {
rules: Vec<items::Rule>,
},
SetFreeUse {
enabled: bool,
},
SetValidateJs {
value: Option<String>,
},
SetTransformJs {
value: String,
},
AddField {
field: items::Field,
},
SetFieldValue {
field: String,
value: String,
},
SetFieldType {
field: String,
field_type: super::types::FieldType,
key_type: Option<String>,
},
RenameField {
field: String,
name: String,
},
RemoveField {
field: String,
},
DeleteItem,
ItemRevert {
version: i64,
},
GrantCreate {
grant: super::grants::Grant,
},
GrantRevoke {
target: String,
},
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct OperationRequest {
pub item: Option<String>,
pub base_version: Option<i64>,
pub change: Vec<Operation>,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Edit {
pub item: String,
pub base_version: i64,
pub config: Item,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Diff {
pub old: Item,
pub new: Item,
pub discards_values: bool,
#[serde(default)]
pub stale: bool,
}
fn check_secret(
db: &Connection,
master: &[u8; 32],
item: &str,
field: &items::Field,
) -> Result<()> {
let cipher: Option<Vec<u8>> = db
.query_row(
"SELECT ciphertext FROM fields WHERE item=?1 AND id=?2",
params![item, field.field_id],
|r| r.get(0),
)
.optional()?;
if let Some(cipher) = cipher {
let value = seal::open(master, &format!("{item}|{}", field.field_id), &cipher)?;
super::types::validate(
&field.field_type,
field.key_type.as_deref(),
false,
Some(std::str::from_utf8(&value).map_err(|_| Error("invalid_value"))?),
)?;
}
Ok(())
}
pub(crate) fn plan(db: &Connection, master: Option<&[u8; 32]>, edit: &Edit) -> Result<Diff> {
let old = items::load(db, &edit.item)?;
if old.version != edit.base_version {
return Err(Error("stale"));
}
let mut new = edit.config.clone();
new.id = old.id.clone();
new.version = old
.version
.checked_add(1)
.ok_or(Error("version_exhausted"))?;
items::validate(&new)?;
items::unique_name(db, &new.name, &old.id)?;
for f in &mut new.fields {
if let Some(prior) = old.fields.iter().find(|p| p.field_id == f.field_id) {
if prior.public != f.public {
return Err(Error("immutable_visibility"));
}
if f.public {
f.value = f.value.clone().or_else(|| prior.value.clone());
f.needs_value = f.value.is_none();
} else {
f.needs_value = prior.needs_value;
if let Some(master) = master
&& (f.field_type != prior.field_type || f.key_type != prior.key_type)
{
check_secret(db, master, &old.id, f)?;
}
}
} else {
if !f.field_id.is_empty() {
return Err(Error("field_missing"));
}
f.needs_value = f.value.is_none();
}
}
items::validate(&new)?;
let discards_values = old
.fields
.iter()
.any(|f| !f.needs_value && !new.fields.iter().any(|n| n.field_id == f.field_id));
Ok(Diff {
old,
new,
discards_values,
stale: false,
})
}
pub(crate) fn apply(
db: &Connection,
master: &[u8; 32],
edit: &Edit,
caller: &Caller,
approver: Option<&str>,
) -> Result<()> {
let mut diff = plan(db, Some(master), edit)?;
db.execute(
"INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)",
params![edit.item, serde_json::to_string(&diff.old)?],
)?;
for old in &diff.old.fields {
if !diff.new.fields.iter().any(|f| f.field_id == old.field_id) {
db.execute(
"DELETE FROM fields WHERE item=?1 AND id=?2",
params![edit.item, old.field_id],
)?;
}
}
for f in &mut diff.new.fields {
if f.field_id.is_empty() {
f.field_id = seal::identity();
}
}
items::save(db, &diff.new)?;
audit::record(db, "item.edit", caller, Some(&edit.item), approver)
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Revert {
pub item: String,
pub base_version: i64,
pub version: i64,
}
pub(crate) fn revert_revision(db: &Connection, item: &str) -> Result<i64> {
if let Ok(item) = items::load(db, item) {
return Ok(item.version);
}
let latest: Option<i64> = db.query_row(
"SELECT max(json_extract(config,'$.version')) FROM versions WHERE item=?",
[item],
|r| r.get(0),
)?;
latest
.ok_or(Error("item_deleted"))?
.checked_add(1)
.ok_or(Error("version_exhausted"))
}
pub(crate) fn plan_revert(db: &Connection, master: Option<&[u8; 32]>, r: &Revert) -> Result<Diff> {
if revert_revision(db, &r.item)? != r.base_version {
return Err(Error("stale"));
}
let saved:String=db.query_row("SELECT config FROM versions WHERE item=?1 AND json_extract(config,'$.version')=?2 ORDER BY rowid DESC LIMIT 1",params![r.item,r.version],|row|row.get(0))?;
let mut new: Item = serde_json::from_str(&saved)?;
let current = items::load(db, &r.item).ok();
for f in &mut new.fields {
let prior = current
.as_ref()
.and_then(|i| i.fields.iter().find(|p| p.field_id == f.field_id));
if let Some(prior) = prior {
if prior.public != f.public {
return Err(Error("immutable_visibility"));
}
f.needs_value = prior.needs_value;
if f.public {
f.value = prior.value.clone();
} else if let Some(master) = master {
check_secret(db, master, &r.item, f)?;
}
} else {
f.needs_value = f.value.is_none();
}
}
items::validate(&new)?;
items::unique_name(db, &new.name, &new.id)?;
new.version = r
.base_version
.checked_add(1)
.ok_or(Error("version_exhausted"))?;
let old = current.unwrap_or_else(|| {
let mut old = new.clone();
old.version = r.base_version;
old.fields.clear();
old
});
let discards_values = old
.fields
.iter()
.any(|f| !f.needs_value && !new.fields.iter().any(|n| n.field_id == f.field_id));
Ok(Diff {
old,
new,
discards_values,
stale: false,
})
}
pub(crate) fn apply_revert(
db: &Connection,
master: &[u8; 32],
r: &Revert,
caller: &Caller,
approver: Option<&str>,
) -> Result<()> {
let diff = plan_revert(db, Some(master), r)?;
if items::load(db, &r.item).is_ok() {
db.execute(
"INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)",
params![r.item, serde_json::to_string(&diff.old)?],
)?;
items::save(db, &diff.new)?;
} else {
db.execute(
"INSERT INTO items VALUES(?1,?2,?3)",
params![r.item, diff.new.name, serde_json::to_string(&diff.new)?],
)?;
}
for f in &diff.old.fields {
if !diff.new.fields.iter().any(|n| n.field_id == f.field_id) {
db.execute(
"DELETE FROM fields WHERE item=?1 AND id=?2",
params![r.item, f.field_id],
)?;
}
}
audit::record(db, "item.revert", caller, Some(&r.item), approver)
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Delete {
pub item: String,
pub base_version: i64,
}
pub(crate) fn apply_delete(
db: &Connection,
delete: &Delete,
caller: &Caller,
approver: Option<&str>,
) -> Result<()> {
let item = items::load(db, &delete.item)?;
if item.version != delete.base_version {
return Err(Error("stale"));
}
db.execute(
"INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)",
params![item.id, serde_json::to_string(&item)?],
)?;
db.execute("DELETE FROM items WHERE id=?", [&item.id])?;
db.execute("UPDATE grants SET revoked=1 WHERE EXISTS(SELECT 1 FROM json_each(grants.items) WHERE value=?)", [&item.id])?;
audit::record(db, "item.delete", caller, Some(&item.id), approver)
}
pub(crate) fn apply_changes(
mut config: Item,
change: &[Operation],
start: usize,
secrets: Option<(&Connection, &[u8; 32])>,
) -> Result<Item> {
for operation in &change[start..] {
let field = |id: &str| {
if id.is_empty() {
return Err(Error("field_missing"));
}
config
.fields
.iter()
.position(|f| f.field_id == id)
.ok_or(Error("field_missing"))
};
match operation {
Operation::SetName { name } => config.name = name.clone(),
Operation::SetRules { rules } => config.rules = rules.clone(),
Operation::SetFreeUse { enabled } => config.free_use = *enabled,
Operation::SetValidateJs { value } => config.validate_js = value.clone(),
Operation::SetTransformJs { value } => config.transform_js = value.clone(),
Operation::AddField { field } => {
let mut field = field.clone();
if !field.field_id.is_empty() {
return Err(Error("invalid_params"));
}
if !field.public && field.value.is_some() {
return Err(Error("secret_field"));
}
super::types::validate(
&field.field_type,
field.key_type.as_deref(),
field.public,
field.value.as_deref(),
)?;
field.needs_value = field.value.is_none();
config.fields.push(field);
}
Operation::SetFieldValue { field: id, value } => {
let n = field(id)?;
if !config.fields[n].public {
return Err(Error("secret_field"));
}
super::types::validate(
&config.fields[n].field_type,
config.fields[n].key_type.as_deref(),
true,
Some(value),
)?;
config.fields[n].value = Some(value.clone());
config.fields[n].needs_value = false;
}
Operation::SetFieldType {
field: id,
field_type,
key_type,
} => {
let n = field(id)?;
config.fields[n].field_type = field_type.clone();
config.fields[n].key_type = key_type.clone();
let f = &config.fields[n];
super::types::validate(
&f.field_type,
f.key_type.as_deref(),
f.public,
f.value.as_deref(),
)?;
if !f.public
&& let Some((db, master)) = secrets
{
check_secret(db, master, &config.id, f)?;
}
}
Operation::RenameField { field: id, name } => {
let n = field(id)?;
config.fields[n].name = name.clone();
}
Operation::RemoveField { field: id } => {
let n = field(id)?;
config.fields.remove(n);
}
_ => return Err(Error("invalid_params")),
}
}
Ok(config)
}
impl super::Cred {
pub fn file_operation_request(
&self,
caller: &Caller,
key: &str,
request: OperationRequest,
) -> Result<String> {
if request.change.is_empty() {
return Err(Error("invalid_params"));
}
if request.change.len() == 1 {
match &request.change[0] {
Operation::DeleteItem => {
return self.file_delete_request(
caller,
key,
Delete {
item: request.item.clone().ok_or(Error("invalid_params"))?,
base_version: request.base_version.ok_or(Error("invalid_params"))?,
},
);
}
Operation::ItemRevert { version } => {
return self.file_revert_request(
caller,
key,
request.item.as_deref().ok_or(Error("invalid_params"))?,
request.base_version.ok_or(Error("invalid_params"))?,
*version,
);
}
Operation::GrantCreate { grant } => {
let mut grant = grant.clone();
grant.id.clear();
grant.uses = 0;
grant.revoked = false;
grant.items.sort();
grant.items.dedup();
let payload = serde_json::json!({"grant_create":grant});
if let Some(id) = self.replay(caller, key, &payload)? {
return Ok(id);
}
super::grants::validate(&self.access()?.db, &grant)?;
return self.file_payload(
caller,
key,
serde_json::json!({"grant_create":grant}),
);
}
Operation::GrantRevoke { target } => {
self.grant_status(target)?;
return self.file_payload(
caller,
key,
serde_json::json!({"grant_revoke":target}),
);
}
_ => {}
}
}
let intent = serde_json::json!({"requested":request});
if let Some(id) = self.replay(caller, key, &intent)? {
return Ok(id);
}
let mut create = false;
let mut generate = false;
let config = if let Some(id) = &request.item {
self.item_get(id)?
} else {
create = true;
match &request.change[0] {
Operation::CreateItem { config } => config.clone(),
Operation::GenerateEs256Item { config } => {
generate = true;
config.clone()
}
_ => return Err(Error("invalid_params")),
}
};
if create {
items::validate(&config)?;
}
let config = apply_changes(config, &request.change, usize::from(create), None)?;
if create {
if request.base_version.is_some() {
return Err(Error("invalid_params"));
}
items::validate(&config)?;
if generate
&& !config.fields.iter().any(|f| {
!f.public
&& f.field_type == super::types::FieldType::SshPrivateKey
&& f.key_type.as_deref() == Some("es256")
})
{
return Err(Error("invalid_params"));
}
items::unique_name(&self.access()?.db, &config.name, "")?;
return self.file_payload(
caller,
key,
serde_json::json!({"create":config,"generate_es256":generate,"requested":request}),
);
}
let edit = Edit {
item: request.item.clone().ok_or(Error("invalid_params"))?,
base_version: request.base_version.ok_or(Error("invalid_params"))?,
config,
};
let diff = {
let s = self.access()?;
plan(&s.db, None, &edit)?
};
self.file_payload(
caller,
key,
serde_json::json!({"edit":edit,"diff":diff,"requested":request}),
)
}
pub fn file_delete_request(
&self,
caller: &Caller,
key: &str,
delete: Delete,
) -> Result<String> {
if let Some(id) = self.replay(caller, key, &serde_json::json!({"delete":delete}))? {
return Ok(id);
}
let item = self.item_get(&delete.item)?;
if item.version != delete.base_version {
return Err(Error("stale"));
}
self.file_payload(caller, key, serde_json::json!({"delete":delete,"old":item}))
}
/// Revert public configuration. Removed secrets remain absent.
pub fn file_revert_request(
&self,
caller: &Caller,
key: &str,
item: &str,
base_version: i64,
version: i64,
) -> Result<String> {
let revert = Revert {
item: item.into(),
base_version,
version,
};
if let Some(id) = self.replay(caller, key, &serde_json::json!({"revert":revert}))? {
return Ok(id);
}
let diff = {
let s = self.access()?;
plan_revert(&s.db, None, &revert)?
};
self.file_payload(
caller,
key,
serde_json::json!({"revert":revert,"diff":diff}),
)
}
pub fn file_edit_request(&self, caller: &Caller, key: &str, edit: Edit) -> Result<String> {
if let Some(id) = self.replay(caller, key, &serde_json::json!({"edit":edit}))? {
return Ok(id);
}
let diff = {
let s = self.access()?;
plan(&s.db, None, &edit)?
};
self.file_payload(caller, key, serde_json::json!({"edit":edit,"diff":diff}))
}
/// Replace a pending edit's base version after the UI reviews the current item.
pub fn resolve_stale(&self, id: &str, base_version: i64) -> Result<()> {
let mut s = self.access()?;
let tx =
s.db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let (payload, state): (String, String) =
tx.query_row("SELECT payload,state FROM requests WHERE id=?", [id], |r| {
Ok((r.get(0)?, r.get(1)?))
})?;
if state != "pending" {
return Err(Error("already_decided"));
}
let mut payload: serde_json::Value = serde_json::from_str(&payload)?;
if let Some(revert) = payload.get("revert") {
let mut revert: Revert = serde_json::from_value(revert.clone())?;
revert.base_version = base_version;
let diff = plan_revert(&tx, None, &revert)?;
payload["revert"] = serde_json::to_value(revert)?;
payload["diff"] = serde_json::to_value(diff)?;
} else {
let mut edit: Edit =
serde_json::from_value(payload.get("edit").ok_or(Error("not_edit"))?.clone())?;
edit.base_version = base_version;
if let Some(requested) = payload.get("requested") {
let requested: OperationRequest = serde_json::from_value(requested.clone())?;
edit.config =
apply_changes(items::load(&tx, &edit.item)?, &requested.change, 0, None)?;
}
let diff = plan(&tx, None, &edit)?;
payload["edit"] = serde_json::to_value(edit)?;
payload["diff"] = serde_json::to_value(diff)?;
}
tx.execute(
"UPDATE requests SET payload=?2 WHERE id=?1",
params![id, serde_json::to_string(&payload)?],
)?;
tx.commit()?;
Ok(())
}
pub fn edit_diff(&self, id: &str) -> Result<Diff> {
let payload = self.request_diff(id)?;
let mut diff: Diff =
serde_json::from_value(payload.get("diff").ok_or(Error("not_edit"))?.clone())?;
if let Some(revert) = payload.get("revert") {
let revert: Revert = serde_json::from_value(revert.clone())?;
diff.stale = revert_revision(&self.access()?.db, &revert.item)
.map_or(true, |v| v != revert.base_version);
} else {
let edit: Edit =
serde_json::from_value(payload.get("edit").ok_or(Error("not_edit"))?.clone())?;
diff.stale = self
.item_get(&edit.item)
.map_or(true, |i| i.version != edit.base_version);
}
Ok(diff)
}
}
+86
View File
@@ -0,0 +1,86 @@
//! Browser secret entry: ECDH P-256, HKDF-SHA256, AES-256-GCM.
//! JSON envelopes contain byte arrays: `ephemeral_public_key` (65-byte SEC1
//! uncompressed point), `iv` (12 bytes), and `ciphertext` (includes the GCM tag).
//! HKDF uses an empty salt and info `sausage/secret-entry/v1`. There is no AAD.
//! The binding hash is lowercase hex SHA-256(ephemeral_public_key || iv || ciphertext).
//! The public key uses the same SEC1 format for WebCrypto's `raw` import.
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
use anyhow::{Result, bail};
use hkdf::Hkdf;
use p256::{PublicKey, SecretKey, ecdh::diffie_hellman, elliptic_curve::sec1::ToEncodedPoint};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{fs::OpenOptions, io::Write, os::unix::fs::OpenOptionsExt, path::Path};
#[derive(Serialize, Deserialize)]
pub struct Envelope {
pub ephemeral_public_key: Vec<u8>,
pub iv: [u8; 12],
pub ciphertext: Vec<u8>,
}
impl Envelope {
pub fn sha256(&self) -> String {
let mut hash = Sha256::new();
hash.update(&self.ephemeral_public_key);
hash.update(self.iv);
hash.update(&self.ciphertext);
format!("{:x}", hash.finalize())
}
}
pub struct EntryKey(SecretKey);
impl EntryKey {
/// The caller supplies a path in the host folder, never in the world.
pub fn open(path: &Path) -> Result<Self> {
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let key = SecretKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
file.write_all(&key.to_bytes())?;
file.sync_all()?;
return Ok(Self(key));
}
Err(e) => return Err(e.into()),
};
Ok(Self(
SecretKey::from_slice(&bytes).map_err(|_| anyhow::anyhow!("invalid entry key"))?,
))
}
pub fn public_key(&self) -> Vec<u8> {
self.0
.public_key()
.to_encoded_point(false)
.as_bytes()
.to_vec()
}
/// The caller must first verify a SecretEntry assertion and compare its
/// item, field, and envelope hash. This method does not authorize a write.
pub fn decrypt(&self, envelope: &Envelope) -> Result<Vec<u8>> {
if envelope.ephemeral_public_key.len() != 65 || envelope.ephemeral_public_key[0] != 4 {
bail!("invalid secret envelope");
}
let peer = PublicKey::from_sec1_bytes(&envelope.ephemeral_public_key)
.map_err(|_| anyhow::anyhow!("invalid secret envelope"))?;
let shared = diffie_hellman(self.0.to_nonzero_scalar(), peer.as_affine());
let mut key = [0u8; 32];
Hkdf::<Sha256>::new(None, shared.raw_secret_bytes())
.expand(b"sausage/secret-entry/v1", &mut key)
.map_err(|_| anyhow::anyhow!("invalid secret envelope"))?;
Aes256Gcm::new_from_slice(&key)
.expect("AES key length")
.decrypt(
Nonce::from_slice(&envelope.iv),
envelope.ciphertext.as_ref(),
)
.map_err(|_| anyhow::anyhow!("invalid secret envelope"))
}
}
+20
View File
@@ -0,0 +1,20 @@
//! Stable failures. Never include input values or script exception messages.
#[derive(Debug, thiserror::Error)]
#[error("credential operation failed: {0}")]
pub struct Error(pub &'static str);
pub type Result<T> = std::result::Result<T, Error>;
impl From<rusqlite::Error> for Error {
fn from(_: rusqlite::Error) -> Self {
Self("storage")
}
}
impl From<std::io::Error> for Error {
fn from(_: std::io::Error) -> Self {
Self("io")
}
}
impl From<serde_json::Error> for Error {
fn from(_: serde_json::Error) -> Self {
Self("json")
}
}
+170
View File
@@ -0,0 +1,170 @@
//! Conversation-subtree grant ledger. Reservations use an immediate transaction.
use super::{
audit,
error::{Error, Result},
seal,
store::{Cred, now},
types::{Caller, Subject},
};
use rusqlite::{Connection, TransactionBehavior, params};
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Grant {
#[serde(default)]
pub id: String,
pub items: Vec<String>,
pub subject: Subject,
pub exp: Option<i64>,
pub max_uses: Option<u64>,
#[serde(default)]
pub uses: u64,
#[serde(default)]
pub revoked: bool,
pub validator: Option<String>,
pub note: Option<String>,
}
pub(crate) fn list(db: &Connection) -> Result<Vec<Grant>> {
let mut q=db.prepare("SELECT id,items,subject,exp,max_uses,uses,revoked,validator,note FROM grants ORDER BY rowid")?;
Ok(q.query_map([], |r| {
Ok(Grant {
id: r.get(0)?,
items: serde_json::from_str(&r.get::<_, String>(1)?)
.map_err(|_| rusqlite::Error::InvalidQuery)?,
subject: serde_json::from_str(&r.get::<_, String>(2)?)
.map_err(|_| rusqlite::Error::InvalidQuery)?,
exp: r.get(3)?,
max_uses: r
.get::<_, Option<i64>>(4)?
.map(u64::try_from)
.transpose()
.map_err(|_| rusqlite::Error::InvalidQuery)?,
uses: u64::try_from(r.get::<_, i64>(5)?).map_err(|_| rusqlite::Error::InvalidQuery)?,
revoked: r.get(6)?,
validator: r.get(7)?,
note: r.get(8)?,
})
})?
.collect::<rusqlite::Result<_>>()?)
}
pub(crate) fn insert(
db: &Connection,
grant: &Grant,
caller: &Caller,
approver: Option<&str>,
) -> Result<()> {
validate(db, grant)?;
db.execute(
"INSERT INTO grants VALUES(?1,?2,?3,?4,?5,0,0,?6,?7)",
params![
grant.id,
serde_json::to_string(&grant.items)?,
serde_json::to_string(&grant.subject)?,
grant.exp,
grant.max_uses.map(|m| m as i64),
grant.validator,
grant.note
],
)?;
audit::record(
db,
"grant.create",
caller,
grant.items.first().map(String::as_str),
approver,
)
}
pub(crate) fn validate(db: &Connection, grant: &Grant) -> Result<()> {
if grant.max_uses == Some(0) || grant.max_uses.is_some_and(|m| m > i64::MAX as u64) {
return Err(Error("invalid_uses"));
}
if !grant.subject.valid() {
return Err(Error("invalid_subject"));
}
if grant.items.is_empty() {
return Err(Error("invalid_items"));
}
for item in &grant.items {
super::items::load(db, item)?;
}
Ok(())
}
impl Cred {
pub fn grant_create(
&self,
mut grant: Grant,
caller: &Caller,
approver: Option<&str>,
) -> Result<Grant> {
grant.id = seal::identity();
grant.uses = 0;
grant.revoked = false;
grant.items.sort();
grant.items.dedup();
let mut s = self.access()?;
let tx =
s.db.transaction_with_behavior(TransactionBehavior::Immediate)?;
insert(&tx, &grant, caller, approver)?;
tx.commit()?;
Ok(grant)
}
pub fn grant_list(&self) -> Result<Vec<Grant>> {
list(&self.access()?.db)
}
pub fn grant_status(&self, id: &str) -> Result<Grant> {
self.grant_list()?
.into_iter()
.find(|g| g.id == id)
.ok_or(Error("grant_missing"))
}
pub fn revoke_subjects(
&self,
subjects: &[String],
caller: &Caller,
approver: Option<&str>,
) -> Result<()> {
let mut s = self.access()?;
let tx = s.db.transaction()?;
for subject in subjects {
tx.execute(
"UPDATE grants SET revoked=1 WHERE subject=?",
[serde_json::to_string(&Subject::Conversation(
subject.clone(),
))?],
)?;
}
audit::record(&tx, "grant.revoke", caller, None, approver)?;
tx.commit()?;
Ok(())
}
pub(crate) fn reserve(
&self,
item: &super::items::Item,
caller: &Caller,
) -> Result<Option<(Grant, serde_json::Value)>> {
let mut s = self.access()?;
let master = s.master.clone();
let tx =
s.db.transaction_with_behavior(TransactionBehavior::Immediate)?;
let grant = list(&tx)?.into_iter().find(|g| {
g.items.iter().any(|i| i == &item.id)
&& g.subject.covers(caller)
&& !g.revoked
&& g.exp.is_none_or(|e| e > now())
&& g.max_uses.is_none_or(|m| g.uses < m)
});
if let Some(mut g) = grant {
if super::items::load(&tx, &item.id)?.version != item.version {
return Err(Error("stale"));
}
let credential = super::items::credential(&tx, &master, item, true)?;
let changed=tx.execute("UPDATE grants SET uses=uses+1 WHERE id=?1 AND revoked=0 AND (exp IS NULL OR exp>?2) AND (max_uses IS NULL OR uses<max_uses)",params![g.id,now()])?;
if changed != 1 {
return Err(Error("grant_exhausted"));
}
g.uses += 1;
tx.commit()?;
Ok(Some((g, credential)))
} else {
Ok(None)
}
}
}
+173
View File
@@ -0,0 +1,173 @@
//! Transform one outgoing request. Never expose script exception text to the world.
use super::{
error::{Error, Result},
items::Item,
requests::GrantRequest,
script::{Limits, Script},
store::Cred,
types::Caller,
};
use http::request::Parts;
use serde_json::{Value, json};
pub enum Outcome {
Forward(Parts),
Denied { item: String, request: String },
}
fn matches(item: &Item, p: &Parts) -> bool {
item.rules.iter().any(|r| {
super::items::rule_matches(
r,
p.uri.scheme_str().unwrap_or(""),
p.uri.host().unwrap_or(""),
p.uri.path(),
p.method.as_str(),
)
})
}
fn request(p: &Parts) -> Result<Value> {
let mut headers = Vec::new();
for (name, value) in &p.headers {
headers.push(json!({"name":name.as_str(),"value":value.to_str().map_err(|_|Error("invalid_headers"))?}));
}
Ok(json!({"method":p.method.as_str(),"url":p.uri.to_string(),"headers":headers}))
}
fn evaluate(script: &dyn Script, source: &str, context: &Value) -> Result<Value> {
let input = serde_json::to_string(context)?;
let result = script
.evaluate(source, &input, None, Limits::default())
.map_err(|_| Error("script"))?;
serde_json::from_str(&result).map_err(|_| Error("script"))
}
impl Cred {
pub fn inject(
&self,
mut parts: Parts,
caller: &Caller,
script: &dyn Script,
) -> Result<Outcome> {
let candidates: Vec<_> = self
.item_list()?
.into_iter()
.filter(|i| matches(i, &parts))
.collect();
if candidates.len() > 1 {
return Err(Error("ambiguous_item"));
}
let Some(item) = candidates.into_iter().next() else {
return Ok(Outcome::Forward(parts));
};
let original_request = request(&parts)?;
if item
.fields
.iter()
.any(|f| f.field_type == super::types::FieldType::SshPrivateKey)
{
return Err(Error("ssh_item"));
}
let prepared = if item.free_use {
Some((None, self.credential(&item, true)?))
} else {
self.reserve(&item, caller)?
.map(|(grant, credential)| (Some(grant), credential))
};
let Some((grant, secret_credential)) = prepared else {
let request = self.file_request(
caller,
&format!("inject:{}", item.id),
GrantRequest {
items: vec![item.id.clone()],
duration_secs: None,
max_uses: None,
validator: None,
},
)?;
return Ok(Outcome::Denied {
item: item.name,
request,
});
};
let result = (|| {
let public = self.credential(&item, false)?;
let ctx = json!({"request":original_request,"credential":public,"item":{"id":item.id,"name":item.name},"grant":grant.as_ref().map(|g|json!({"conversation":match &g.subject {super::types::Subject::Conversation(id)=>Some(id),super::types::Subject::Program(_)=>None},"subject":g.subject,"uses_left":g.max_uses.map(|m|m-g.uses),"exp":g.exp}))});
for source in [
item.validate_js.as_deref(),
grant.as_ref().and_then(|g| g.validator.as_deref()),
]
.into_iter()
.flatten()
{
let value = evaluate(script, source, &ctx)?;
let allow = value
.as_bool()
.or_else(|| value.get("allow").and_then(Value::as_bool))
.ok_or(Error("script"))?;
if !allow {
return Err(Error("validator_denied"));
}
}
if !item.transform_js.is_empty() {
let mut ctx = json!({"request":original_request,"credential":secret_credential});
let url =
url::Url::parse(&parts.uri.to_string()).map_err(|_| Error("invalid_url"))?;
let mut base = url.clone();
base.set_query(None);
base.set_fragment(None);
ctx["url"] = json!({"base":base.as_str(),"query":url.query_pairs().collect::<Vec<_>>(),"fragment":url.fragment().map(|f|format!("#{f}")).unwrap_or_default()});
let output = evaluate(script, &item.transform_js, &ctx)?;
let object = output.as_object().ok_or(Error("transform"))?;
if object
.keys()
.any(|k| k != "url" && k != "headers" && k != "method")
|| output
.get("method")
.is_some_and(|m| m.as_str() != Some(parts.method.as_str()))
{
return Err(Error("transform"));
}
if let Some(url) = output.get("url") {
parts.uri = url
.as_str()
.ok_or(Error("transform"))?
.parse()
.map_err(|_| Error("transform"))?;
}
if let Some(headers) = output.get("headers") {
let mut new = http::HeaderMap::new();
for header in headers.as_array().ok_or(Error("transform"))? {
let name = header
.get("name")
.and_then(Value::as_str)
.ok_or(Error("transform"))?;
let value = header
.get("value")
.and_then(Value::as_str)
.ok_or(Error("transform"))?;
new.append(
http::header::HeaderName::from_bytes(name.as_bytes())
.map_err(|_| Error("transform"))?,
http::HeaderValue::from_str(value).map_err(|_| Error("transform"))?,
);
}
parts.headers = new;
}
}
if !matches(&item, &parts) {
return Err(Error("rule_recheck"));
}
Ok(())
})();
super::audit::record(
&self.access()?.db,
if result.is_ok() {
"fetch.use"
} else {
"fetch.denied"
},
caller,
Some(&item.id),
None,
)?;
result?;
Ok(Outcome::Forward(parts))
}
}
+352
View File
@@ -0,0 +1,352 @@
//! Public item snapshots and host-only field entry. Snapshots never contain secret values.
use super::{
error::{Error, Result},
seal,
store::Cred,
types::{Caller, FieldType},
};
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Rule {
pub scheme: String,
pub hosts: Vec<String>,
pub path_prefixes: Vec<String>,
pub methods: Option<Vec<String>>,
}
pub fn rule_matches(r: &Rule, scheme: &str, host: &str, path: &str, method: &str) -> bool {
r.scheme.eq_ignore_ascii_case(scheme)
&& r.hosts.iter().any(|p| {
if let Some(s) = p.strip_prefix("*.") {
host.to_ascii_lowercase()
.strip_suffix(&format!(".{}", s.to_ascii_lowercase()))
.is_some_and(|p| !p.is_empty())
} else {
p.eq_ignore_ascii_case(host)
}
})
&& r.path_prefixes.iter().any(|p| path.starts_with(p))
&& r.methods
.as_ref()
.is_none_or(|ms| ms.iter().any(|m| m.eq_ignore_ascii_case(method)))
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Field {
#[serde(default)]
pub field_id: String,
pub name: String,
#[serde(default)]
pub public: bool,
pub value: Option<String>,
#[serde(default)]
pub needs_value: bool,
pub field_type: FieldType,
pub key_type: Option<String>,
}
impl std::fmt::Debug for Field {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Field")
.field("field_id", &self.field_id)
.field("name", &self.name)
.field("public", &self.public)
.field("needs_value", &self.needs_value)
.field("field_type", &self.field_type)
.field("key_type", &self.key_type)
.field(
"value",
&if self.public {
self.value.as_deref()
} else {
None
},
)
.finish()
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Item {
#[serde(default)]
pub id: String,
pub name: String,
#[serde(default)]
pub version: i64,
#[serde(default)]
pub free_use: bool,
pub rules: Vec<Rule>,
pub validate_js: Option<String>,
pub transform_js: String,
pub fields: Vec<Field>,
}
pub(crate) fn load(db: &Connection, id: &str) -> Result<Item> {
let json: Option<String> = db
.query_row("SELECT config FROM items WHERE id=?", [id], |r| r.get(0))
.optional()?;
Ok(serde_json::from_str(&json.ok_or(Error("item_deleted"))?)?)
}
pub(crate) fn save(db: &Connection, item: &Item) -> Result<()> {
db.execute(
"UPDATE items SET name=?2,config=?3 WHERE id=?1",
params![item.id, item.name, serde_json::to_string(item)?],
)?;
Ok(())
}
pub(crate) fn validate(item: &Item) -> Result<()> {
if item.name.trim().is_empty() {
return Err(Error("invalid_params"));
}
let mut names = std::collections::HashSet::new();
let mut ids = std::collections::HashSet::new();
for f in &item.fields {
if !f.public && f.value.is_some() {
return Err(Error("secret_field"));
}
super::types::validate(
&f.field_type,
f.key_type.as_deref(),
f.public,
f.value.as_deref(),
)?;
if f.name.trim().is_empty()
|| !names.insert(&f.name)
|| (!f.field_id.is_empty() && !ids.insert(&f.field_id))
{
return Err(Error("invalid_params"));
}
}
if item
.fields
.iter()
.filter(|f| f.field_type == FieldType::Oauth2RefreshToken)
.count()
> 1
{
return Err(Error("invalid_params"));
}
for r in &item.rules {
if !matches!(
r.scheme.to_ascii_lowercase().as_str(),
"http" | "https" | "ssh"
) || r.path_prefixes.iter().any(|p| !p.starts_with('/'))
|| r.methods.as_ref().is_some_and(|ms| {
ms.iter().any(|m| {
m.is_empty() || !m.bytes().all(|b| b.is_ascii_alphabetic() || b == b'-')
})
})
|| r.hosts.iter().any(|p| {
let h = p.strip_prefix("*.").unwrap_or(p);
h.is_empty()
|| h.starts_with('.')
|| h.ends_with('.')
|| h.contains("..")
|| h.bytes().any(|b| {
b.is_ascii_whitespace() || matches!(b, b'*' | b'/' | b'?' | b'#' | b'@')
})
|| (h.contains(':') && h.parse::<std::net::Ipv6Addr>().is_err())
})
{
return Err(Error("invalid_params"));
}
}
Ok(())
}
pub(crate) fn unique_name(db: &Connection, name: &str, except: &str) -> Result<()> {
let exists: bool = db.query_row(
"SELECT EXISTS(SELECT 1 FROM items WHERE name=?1 AND id<>?2)",
params![name, except],
|r| r.get(0),
)?;
if exists {
Err(Error("name_conflict"))
} else {
Ok(())
}
}
pub(crate) fn insert_new(
db: &Connection,
mut item: Item,
caller: &Caller,
approver: Option<&str>,
) -> Result<Item> {
validate(&item)?;
item.id = seal::identity();
item.version = 1;
for field in &mut item.fields {
field.field_id = seal::identity();
field.needs_value = field.value.is_none();
}
unique_name(db, &item.name, "")?;
db.execute(
"INSERT INTO items VALUES(?1,?2,?3)",
params![item.id, item.name, serde_json::to_string(&item)?],
)?;
super::audit::record(db, "item.create", caller, Some(&item.id), approver)?;
Ok(item)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ItemVersion {
pub created: i64,
pub config: Item,
}
/// A secret-entry value with optional replacement metadata. Debug never includes the value.
pub struct FieldInput<'a> {
pub value: &'a str,
pub field_type: Option<FieldType>,
pub key_type: Option<String>,
}
impl std::fmt::Debug for FieldInput<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FieldInput")
.field("field_type", &self.field_type)
.field("key_type", &self.key_type)
.finish_non_exhaustive()
}
}
impl Cred {
/// Trusted host mutation. The route layer must verify a passkey before this call.
pub fn create_item(&self, item: Item, caller: &Caller, approver: &str) -> Result<Item> {
let mut s = self.access()?;
let tx =
s.db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let item = insert_new(&tx, item, caller, Some(approver))?;
tx.commit()?;
Ok(item)
}
pub fn item_get(&self, id: &str) -> Result<Item> {
load(&self.access()?.db, id)
}
pub fn item_list(&self) -> Result<Vec<Item>> {
let s = self.access()?;
let mut q = s.db.prepare("SELECT config FROM items ORDER BY name")?;
q.query_map([], |r| r.get::<_, String>(0))?
.map(|r| Ok(serde_json::from_str(&r?)?))
.collect()
}
pub fn item_versions(&self, id: &str) -> Result<Vec<ItemVersion>> {
let s = self.access()?;
let mut q =
s.db.prepare("SELECT created,config FROM versions WHERE item=? ORDER BY rowid")?;
q.query_map([id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?
.map(|r| {
let (created, config) = r?;
Ok(ItemVersion {
created,
config: serde_json::from_str(&config)?,
})
})
.collect()
}
/// Trusted secret entry core. Never put the value into a public snapshot.
pub fn set_field(
&self,
item: &str,
field: &str,
value: &str,
caller: &Caller,
approver: &str,
) -> Result<()> {
self.set_field_input(
item,
field,
&FieldInput {
value,
field_type: None,
key_type: None,
},
caller,
approver,
)
}
pub fn set_field_input(
&self,
item: &str,
field: &str,
input: &FieldInput<'_>,
caller: &Caller,
approver: &str,
) -> Result<()> {
let value = input.value;
let mut s = self.access()?;
let key = s.master.clone();
let tx = s.db.transaction()?;
let mut config = load(&tx, item)?;
let index = config
.fields
.iter()
.position(|f| f.field_id == field)
.ok_or(Error("field_missing"))?;
let f = &config.fields[index];
let kind = input.field_type.as_ref().unwrap_or(&f.field_type).clone();
let key_type = if input.field_type.is_some() {
input.key_type.clone()
} else {
input.key_type.clone().or_else(|| f.key_type.clone())
};
super::types::validate(&kind, key_type.as_deref(), f.public, Some(value))?;
let previous = serde_json::to_string(&config)?;
if config.fields[index].public {
config.fields[index].value = Some(value.to_owned());
} else {
let encrypted = seal::seal(&key, &format!("{item}|{field}"), value.as_bytes())?;
tx.execute(
"INSERT OR REPLACE INTO fields VALUES(?1,?2,?3)",
params![item, field, encrypted],
)?;
}
config.fields[index].needs_value = false;
config.fields[index].field_type = kind;
config.fields[index].key_type = key_type;
validate(&config)?;
tx.execute(
"INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)",
params![item, previous],
)?;
config.version = config
.version
.checked_add(1)
.ok_or(Error("version_exhausted"))?;
save(&tx, &config)?;
super::audit::record(&tx, "field.set", caller, Some(item), Some(approver))?;
tx.commit()?;
Ok(())
}
pub(crate) fn credential(&self, item: &Item, secrets: bool) -> Result<serde_json::Value> {
let s = self.access()?;
if secrets && load(&s.db, &item.id)?.version != item.version {
return Err(Error("stale"));
}
credential(&s.db, &s.master, item, secrets)
}
}
pub(crate) fn credential(
db: &Connection,
master: &[u8; 32],
item: &Item,
secrets: bool,
) -> Result<serde_json::Value> {
if secrets && item.fields.iter().any(|f| f.needs_value) {
return Err(Error("needs_value"));
}
let mut values = serde_json::Map::new();
for f in &item.fields {
if f.public {
if let Some(v) = &f.value {
values.insert(f.name.clone(), v.clone().into());
}
} else if secrets {
let cipher: Option<Vec<u8>> = db
.query_row(
"SELECT ciphertext FROM fields WHERE item=?1 AND id=?2",
params![item.id, f.field_id],
|r| r.get(0),
)
.optional()?;
let cipher = cipher.ok_or(Error("needs_value"))?;
{
let bytes = seal::open(master, &format!("{}|{}", item.id, f.field_id), &cipher)?;
let value = std::str::from_utf8(&bytes).map_err(|_| Error("field_invalid"))?;
values.insert(f.name.clone(), value.into());
}
}
}
Ok(values.into())
}
+305
View File
@@ -0,0 +1,305 @@
//! Key readers and signature/public-key codecs. No grant or storage policy here.
use super::sign::{Result, SignResult, error, invalid_key};
use base64::{
Engine,
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
};
use ed25519_dalek::{Signer, SigningKey};
use hmac::{Hmac, Mac};
use rsa::{
pkcs1::DecodeRsaPrivateKey,
pkcs8::{DecodePrivateKey, EncodePublicKey},
traits::PublicKeyParts,
};
use serde_json::json;
use sha2::{Digest, Sha256};
use sha2::{Sha384, Sha512};
use zeroize::Zeroizing;
pub(super) enum Key {
Ed(SigningKey),
Ec(p256::ecdsa::SigningKey),
Rsa(rsa::RsaPrivateKey),
Mac(Zeroizing<Vec<u8>>),
}
fn decode(s: &str) -> Result<Zeroizing<Vec<u8>>> {
Ok(Zeroizing::new(
STANDARD
.decode(s)
.or_else(|_| URL_SAFE_NO_PAD.decode(s))
.map_err(|_| invalid_key())?,
))
}
fn family(format: &str) -> &str {
match format {
"ed25519-raw" => "ed25519",
"rs256" | "rsa-sha2-256" | "rsa-sha2-512" => "rsa",
s if s.starts_with("hmac-") => "hmac",
s => s,
}
}
pub(super) fn read(value: &str, format: &str, passphrase: Option<&str>) -> Result<Key> {
let value = value.trim();
let family = family(format);
let key = if value.starts_with("-----BEGIN OPENSSH PRIVATE KEY-----") {
let key = ssh_key::PrivateKey::from_openssh(value).map_err(|_| invalid_key())?;
let key = if key.is_encrypted() {
key.decrypt(passphrase.ok_or_else(invalid_key)?)
.map_err(|_| invalid_key())?
} else {
key
};
match key.key_data() {
ssh_key::private::KeypairData::Ed25519(k) => {
Key::Ed(SigningKey::from_bytes(&k.private.to_bytes()))
}
ssh_key::private::KeypairData::Ecdsa(
k @ ssh_key::private::EcdsaKeypair::NistP256 { .. },
) => Key::Ec(
p256::ecdsa::SigningKey::from_slice(k.private_key_bytes())
.map_err(|_| invalid_key())?,
),
ssh_key::private::KeypairData::Rsa(k) => {
// ssh-key 0.6.7's TryFrom<&RsaKeypair> accidentally passes p twice.
// Convert the parsed components explicitly, preserving p and q.
let int = |n: &ssh_key::Mpint| rsa::BigUint::try_from(n).map_err(|_| invalid_key());
let key = rsa::RsaPrivateKey::from_components(
int(&k.public.n)?,
int(&k.public.e)?,
int(&k.private.d)?,
vec![int(&k.private.p)?, int(&k.private.q)?],
)
.map_err(|_| invalid_key())?;
key.validate().map_err(|_| invalid_key())?;
Key::Rsa(key)
}
_ => return Err(invalid_key()),
}
} else if value.starts_with("-----BEGIN ") {
match family {
"ed25519" => Key::Ed(SigningKey::from_pkcs8_pem(value).map_err(|_| invalid_key())?),
"es256" => {
let key = if value.starts_with("-----BEGIN EC PRIVATE KEY-----") {
p256::SecretKey::from_sec1_pem(value).map_err(|_| invalid_key())?
} else {
p256::SecretKey::from_pkcs8_pem(value).map_err(|_| invalid_key())?
};
Key::Ec(key.into())
}
"rsa" => Key::Rsa(if value.starts_with("-----BEGIN RSA PRIVATE KEY-----") {
rsa::RsaPrivateKey::from_pkcs1_pem(value).map_err(|_| invalid_key())?
} else {
rsa::RsaPrivateKey::from_pkcs8_pem(value).map_err(|_| invalid_key())?
}),
_ => return Err(invalid_key()),
}
} else if value.starts_with('{') {
let j: serde_json::Value = serde_json::from_str(value).map_err(|_| invalid_key())?;
let bytes = |name: &str| -> Result<Zeroizing<Vec<u8>>> {
decode(j[name].as_str().ok_or_else(invalid_key)?)
};
match j["kty"].as_str() {
Some("oct") => Key::Mac(bytes("k")?),
Some("OKP") if j["crv"] == "Ed25519" => {
let d = bytes("d")?;
let k = SigningKey::from_bytes(d.as_slice().try_into().map_err(|_| invalid_key())?);
if bytes("x")?.as_slice() != k.verifying_key().as_bytes() {
return Err(invalid_key());
}
Key::Ed(k)
}
Some("EC") if j["crv"] == "P-256" => {
let k =
p256::ecdsa::SigningKey::from_slice(&bytes("d")?).map_err(|_| invalid_key())?;
let point = k.verifying_key().to_encoded_point(false);
if bytes("x")?.as_slice() != point.x().ok_or_else(invalid_key)?.as_slice()
|| bytes("y")?.as_slice() != point.y().ok_or_else(invalid_key)?.as_slice()
{
return Err(invalid_key());
}
Key::Ec(k)
}
Some("RSA") => {
let int = |name| -> Result<rsa::BigUint> {
Ok(rsa::BigUint::from_bytes_be(&bytes(name)?))
};
let k = rsa::RsaPrivateKey::from_components(
int("n")?,
int("e")?,
int("d")?,
vec![int("p")?, int("q")?],
)
.map_err(|_| invalid_key())?;
k.validate().map_err(|_| invalid_key())?;
Key::Rsa(k)
}
_ => return Err(invalid_key()),
}
} else {
let bytes = decode(value)?;
match family {
"ed25519" => Key::Ed(SigningKey::from_bytes(
bytes.as_slice().try_into().map_err(|_| invalid_key())?,
)),
"es256" if bytes.len() == 32 => {
Key::Ec(p256::ecdsa::SigningKey::from_slice(&bytes).map_err(|_| invalid_key())?)
}
"hmac" => Key::Mac(bytes),
_ => return Err(invalid_key()),
}
};
if !matches!(
(&key, family),
(Key::Ed(_), "ed25519")
| (Key::Ec(_), "es256")
| (Key::Rsa(_), "rsa")
| (Key::Mac(_), "hmac")
) {
return Err(invalid_key());
}
Ok(key)
}
pub(super) fn check(format: &str, padding: Option<&str>, hash: Option<&str>) -> Result<()> {
match format {
"ed25519" | "ed25519-raw" | "es256" | "hmac-sha256" | "hmac-sha384" | "hmac-sha512"
if padding.is_none() && hash.is_none() =>
{
Ok(())
}
"rs256" | "rsa-sha2-256" | "rsa-sha2-512"
if padding == Some("pkcs1v15")
&& hash
== Some(if format == "rsa-sha2-512" {
"sha512"
} else {
"sha256"
}) =>
{
Ok(())
}
_ => Err(error(
"invalid_params",
"unknown algorithm or inconsistent padding/hash; RSA requires padding=pkcs1v15 and explicit sha256/sha512 matching the algorithm",
)),
}
}
pub(super) fn sign_key(key: &Key, format: &str, payload: &[u8]) -> Result<Vec<u8>> {
Ok(match key {
Key::Ed(k) => k.sign(payload).to_bytes().to_vec(),
Key::Ec(k) => {
let s: p256::ecdsa::Signature = k.sign(payload);
s.to_bytes().to_vec()
}
Key::Rsa(k) => if format == "rsa-sha2-512" {
k.sign(rsa::Pkcs1v15Sign::new::<Sha512>(), &Sha512::digest(payload))
} else {
k.sign(rsa::Pkcs1v15Sign::new::<Sha256>(), &Sha256::digest(payload))
}
.map_err(|_| invalid_key())?,
Key::Mac(k) => {
macro_rules! mac {
($h:ty) => {{
let mut mac = Hmac::<$h>::new_from_slice(k).map_err(|_| invalid_key())?;
mac.update(payload);
mac.finalize().into_bytes().to_vec()
}};
}
match format {
"hmac-sha384" => mac!(Sha384),
"hmac-sha512" => mac!(Sha512),
_ => mac!(Sha256),
}
}
})
}
fn string(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes);
}
fn mpint(out: &mut Vec<u8>, bytes: &[u8]) {
let bytes = &bytes[bytes.iter().position(|b| *b != 0).unwrap_or(bytes.len())..];
let mut positive = Vec::new();
if bytes.first().is_some_and(|b| b & 128 != 0) {
positive.push(0);
}
positive.extend_from_slice(bytes);
string(out, &positive);
}
fn wire_signature(format: &str, signature: &[u8]) -> Result<Vec<u8>> {
let (algorithm, signature) = match format {
"ed25519" | "ed25519-raw" => ("ssh-ed25519", signature.to_vec()),
"rs256" | "rsa-sha2-256" => ("rsa-sha2-256", signature.to_vec()),
"rsa-sha2-512" => ("rsa-sha2-512", signature.to_vec()),
"es256" => {
let mut s = Vec::new();
mpint(&mut s, &signature[..32]);
mpint(&mut s, &signature[32..]);
("ecdsa-sha2-nistp256", s)
}
_ => {
return Err(error(
"invalid_params",
"SSH wire output requires an asymmetric key",
));
}
};
let mut out = Vec::new();
string(&mut out, algorithm.as_bytes());
string(&mut out, &signature);
Ok(out)
}
pub(super) fn output(format: &str, output: &str, signature: Vec<u8>) -> Result<SignResult> {
let (signature, bytes) = match output {
"raw" => (String::new(), Some(signature)),
"hex" => (hex::encode(signature), None),
"jws" => (URL_SAFE_NO_PAD.encode(signature), None),
"ssh" => (STANDARD.encode(wire_signature(format, &signature)?), None),
"base64" => (STANDARD.encode(signature), None),
_ => return Err(error("invalid_params", "unknown output format")),
};
Ok(SignResult { signature, bytes })
}
pub(super) fn public(key: &Key, output: &str) -> Result<String> {
let raw = match key {
Key::Ed(k) => k.verifying_key().to_bytes().to_vec(),
Key::Ec(k) => k
.verifying_key()
.to_encoded_point(false)
.as_bytes()
.to_vec(),
Key::Rsa(k) => k
.to_public_key()
.to_public_key_der()
.map_err(|_| invalid_key())?
.as_bytes()
.to_vec(),
Key::Mac(_) => return Err(error("invalid_params", "symmetric keys have no public key")),
};
Ok(match output {
"base64" => STANDARD.encode(&raw),
"hex" => hex::encode(&raw),
"pem" => match key {
Key::Ed(k) => k.verifying_key().to_public_key_pem(Default::default()),
Key::Ec(k) => k.verifying_key().to_public_key_pem(Default::default()),
Key::Rsa(k) => k.to_public_key().to_public_key_pem(Default::default()),
_ => unreachable!(),
}.map_err(|_| invalid_key())?,
"jwk" => match key {
Key::Ed(_) => json!({"kty":"OKP","crv":"Ed25519","x":URL_SAFE_NO_PAD.encode(&raw)}),
Key::Ec(_) => json!({"kty":"EC","crv":"P-256","x":URL_SAFE_NO_PAD.encode(&raw[1..33]),"y":URL_SAFE_NO_PAD.encode(&raw[33..])}),
Key::Rsa(k) => json!({"kty":"RSA","n":URL_SAFE_NO_PAD.encode(k.n().to_bytes_be()),"e":URL_SAFE_NO_PAD.encode(k.e().to_bytes_be())}),
_ => unreachable!(),
}.to_string(),
"ssh" => {
let mut out = Vec::new();
match key {
Key::Ed(_) => { string(&mut out, b"ssh-ed25519"); string(&mut out, &raw); },
Key::Ec(_) => { string(&mut out, b"ecdsa-sha2-nistp256"); string(&mut out, b"nistp256"); string(&mut out, &raw); },
Key::Rsa(k) => { string(&mut out, b"ssh-rsa"); mpint(&mut out, &k.e().to_bytes_be()); mpint(&mut out, &k.n().to_bytes_be()); },
_ => unreachable!(),
}
STANDARD.encode(out)
}
_ => return Err(error("invalid_params", "unknown public key output")),
})
}
+33
View File
@@ -1,3 +1,36 @@
//! The credentials manager (P2, plan §4.4): items, grants, requests and audit in the host,
//! outside the world. Programs in the world never see secret values.
//!
//! `Cred::open` loads `cred.sqlite` and the host master key. Read methods return public snapshots.
//! `Caller` carries a binary path and the nearest-first conversation ancestry.
//! `types::Subject` binds grants to a conversation subtree or a binary path.
//! `file_request` normalizes item identities and replays keyed requests. Edit requests carry public diffs.
//! `approve` and `deny` trust the route layer's verified approver identity.
//! That identity can be `None` during bootstrap, before any passkey registration.
//! Each decision commits one SQLite transaction. `subscribe` reports committed decisions across threads.
//! Subscribers must read SQLite after a lag or restart. The durable task layer calls `expire_requests`.
//! `inject` transforms one request's URL and headers. The body stays outside this library.
//! An unmatched request forwards unchanged. An unauthorized match names the item and its filed request.
//! `audit_sign` records signature metadata without the signed payload.
pub mod entry;
pub mod webauthn;
pub mod store;
pub mod schema;
pub mod seal;
pub mod items;
pub mod types;
pub mod grants;
pub mod requests;
pub mod edits;
pub mod summary;
pub mod audit;
pub mod inject;
pub mod error;
pub use store::Cred;
pub use error::{Error, Result};
pub use types::Caller;
pub mod script;
pub mod keys;
pub mod sign;
pub mod oauth2;
pub mod v8;
+119
View File
@@ -0,0 +1,119 @@
//! OAuth2 refresh exchange and a process-local cache keyed by item and version.
use std::{
collections::BTreeMap,
time::{Duration, Instant},
};
use zeroize::Zeroizing;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Error;
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("OAuth refresh failed or returned an invalid response")
}
}
impl std::error::Error for Error {}
/// HTTP adapter. Implementations must reject unsuccessful HTTP status codes.
pub trait Http: Sync {
fn send(
&self,
request: &Request,
deadline: Instant,
) -> impl std::future::Future<Output = Result<Zeroizing<Vec<u8>>, Error>> + Send;
}
/// Contains credentials. Deliberately does not implement Debug or Serialize.
pub struct Request {
pub url: String,
pub method: &'static str,
pub headers: Vec<(&'static str, String)>,
pub body: Zeroizing<String>,
}
pub fn request(
token_url: &str,
refresh_token: &str,
client_id: &str,
client_secret: &str,
) -> Result<Request, Error> {
let target = url::Url::parse(token_url).map_err(|_| Error)?;
if target.scheme() != "https"
|| !target.username().is_empty()
|| target.password().is_some()
|| target.fragment().is_some()
|| [refresh_token, client_id, client_secret].contains(&"")
{
return Err(Error);
}
let body = url::form_urlencoded::Serializer::new(String::new())
.append_pair("grant_type", "refresh_token")
.append_pair("refresh_token", refresh_token)
.append_pair("client_id", client_id)
.append_pair("client_secret", client_secret)
.finish();
Ok(Request {
url: token_url.into(),
method: "POST",
headers: vec![
("Content-Type", "application/x-www-form-urlencoded".into()),
("Content-Length", body.len().to_string()),
],
body: Zeroizing::new(body),
})
}
#[derive(Clone)]
pub struct Token {
pub value: Zeroizing<String>,
pub scopes: Option<Vec<String>>,
pub expires: Instant,
}
pub fn decode(bytes: &[u8]) -> Result<Token, Error> {
#[derive(serde::Deserialize)]
struct Response {
access_token: String,
token_type: String,
expires_in: u64,
scope: Option<String>,
}
let r: Response = serde_json::from_slice(bytes).map_err(|_| Error)?;
if r.access_token.is_empty()
|| !r.token_type.eq_ignore_ascii_case("bearer")
|| r.expires_in == 0
|| http::header::HeaderValue::from_str(&format!("Bearer {}", r.access_token)).is_err()
{
return Err(Error);
}
Ok(Token {
value: Zeroizing::new(r.access_token),
scopes: r
.scope
.map(|s| s.split_whitespace().map(str::to_owned).collect()),
expires: Instant::now() + Duration::from_secs(r.expires_in.min(86400).saturating_sub(30)),
})
}
/// The caller owns synchronization and endpoint policy. No lock crosses an HTTP call here.
#[derive(Default)]
pub struct Cache {
tokens: BTreeMap<(String, i64), Token>,
}
impl Cache {
pub fn clear(&mut self) {
self.tokens.clear();
}
pub async fn refresh(
&mut self,
item: &str,
version: i64,
request: &Request,
http: &impl Http,
deadline: Instant,
) -> Result<Token, Error> {
let key = (item.to_owned(), version);
if let Some(t) = self.tokens.get(&key).filter(|t| t.expires > Instant::now()) {
return Ok(t.clone());
}
self.tokens.retain(|(id, _), _| id != item);
let token = decode(&http.send(request, deadline).await?)?;
self.tokens.insert(key, token.clone());
Ok(token)
}
}
+437
View File
@@ -0,0 +1,437 @@
//! Keyed grant requests, transactional decisions, explicit expiry and decision notifications.
use super::{
audit,
error::{Error, Result},
grants::{self, Grant},
seal,
store::{Cred, now},
types::{Caller, Subject},
};
use rusqlite::{OptionalExtension, TransactionBehavior, params};
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct GrantRequest {
pub items: Vec<String>,
pub duration_secs: Option<u64>,
pub max_uses: Option<u64>,
pub validator: Option<String>,
}
#[derive(Clone, Debug, serde::Serialize)]
pub struct Request {
pub id: String,
pub subject: Subject,
pub payload: serde_json::Value,
pub state: String,
pub expires: i64,
pub pid: u32,
pub program: String,
pub decision: Option<String>,
pub approver: Option<String>,
}
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct Overrides {
#[serde(default)]
pub unlimited: bool,
pub duration_secs: Option<u64>,
pub max_uses: Option<u64>,
pub validator: Option<String>,
}
fn normalised(payload: &serde_json::Value) -> serde_json::Value {
let mut value = if let Some(requested) = payload.get("requested") {
serde_json::json!({"requested":requested})
} else {
payload.clone()
};
if let Some(object) = value.as_object_mut() {
object.remove("diff");
object.remove("old");
}
fn sets(value: &mut serde_json::Value, ordered: bool) {
match value {
serde_json::Value::Object(object) => {
for (key, value) in object {
sets(value, key == "change" || key == "fields");
}
}
serde_json::Value::Array(values) => {
for value in values.iter_mut() {
sets(value, false);
}
if !ordered {
values.sort_by_cached_key(|v| v.to_string());
values.dedup();
}
}
_ => {}
}
}
sets(&mut value, false);
value
}
impl Cred {
pub(crate) fn replay(
&self,
caller: &Caller,
key: &str,
payload: &serde_json::Value,
) -> Result<Option<String>> {
let s = self.access()?;
let subject = serde_json::to_string(&Subject::requester(caller))?;
let old:Option<(String,String,String,i64,Option<String>)>=s.db.query_row("SELECT id,payload,state,expires,grant_id FROM requests WHERE subject=?1 AND key=?2 ORDER BY rowid DESC LIMIT 1",params![subject,key],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?))).optional()?;
let Some((id, prior, state, expires, grant_id)) = old else {
return Ok(None);
};
if normalised(&serde_json::from_str(&prior)?) != normalised(payload) {
return Err(Error("key_conflict"));
}
let usable = match grant_id {
None => true,
Some(id) => grants::list(&s.db)?.iter().any(|g| {
g.id == id
&& !g.revoked
&& g.exp.is_none_or(|e| e > now())
&& g.max_uses.is_none_or(|m| g.uses < m)
}),
};
Ok(
((state == "pending" && expires > now()) || (state == "approved" && usable))
.then_some(id),
)
}
pub fn file_request(
&self,
caller: &Caller,
key: &str,
mut payload: GrantRequest,
) -> Result<String> {
if payload.items.is_empty() {
return Err(Error("invalid_items"));
}
{
let s = self.access()?;
for item in &mut payload.items {
*item =
s.db.query_row(
"SELECT id FROM items WHERE id=?1 OR name=?1 ORDER BY id=?1 DESC LIMIT 1",
[&*item],
|r| r.get(0),
)
.optional()?
.unwrap_or_else(|| item.clone());
}
}
payload.items.sort();
payload.items.dedup();
payload.duration_secs = Some(payload.duration_secs.unwrap_or(3600));
if payload.validator.as_deref() == Some("") {
payload.validator = None;
}
let value = serde_json::to_value(&payload)?;
if let Some(id) = self.replay(caller, key, &value)? {
return Ok(id);
}
{
let s = self.access()?;
for item in &payload.items {
super::items::load(&s.db, item)?;
}
}
self.file_payload(caller, key, value)
}
pub(crate) fn file_payload(
&self,
caller: &Caller,
key: &str,
payload: serde_json::Value,
) -> Result<String> {
let mut s = self.access()?;
let tx =
s.db.transaction_with_behavior(TransactionBehavior::Immediate)?;
let mut expired_id = None;
let subject = Subject::requester(caller);
let subject_json = serde_json::to_string(&subject)?;
let encoded = serde_json::to_string(&payload)?;
let old:Option<(String,String,String,i64,Option<String>)>=tx.query_row("SELECT id,payload,state,expires,grant_id FROM requests WHERE subject IS ?1 AND key=?2 ORDER BY rowid DESC LIMIT 1",params![subject_json,key],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?))).optional()?;
if let Some((id, prior, state, expires, grant_id)) = old {
if normalised(&serde_json::from_str(&prior)?) != normalised(&payload) {
return Err(Error("key_conflict"));
}
if state == "pending" && expires > now() {
return Ok(id);
}
if state == "approved"
&& match grant_id {
None => true,
Some(grant_id) => grants::list(&tx)?.iter().any(|g| {
g.id == grant_id
&& !g.revoked
&& g.exp.is_none_or(|e| e > now())
&& g.max_uses.is_none_or(|m| g.uses < m)
}),
}
{
return Ok(id);
}
if state == "pending" {
tx.execute("UPDATE requests SET state='expired' WHERE id=?", [&id])?;
expired_id = Some(id);
}
}
let id = seal::identity();
tx.execute("INSERT INTO requests(id,subject,key,payload,state,expires,pid,program) VALUES(?1,?2,?3,?4,'pending',?5,?6,?7)",params![id,subject_json,key,encoded,now()+86400,caller.pid,caller.program])?;
tx.commit()?;
if let Some(expired) = expired_id {
let _ = self.decided.send(expired);
}
let _ = self.filed.send(id.clone());
Ok(id)
}
pub fn request_list(&self) -> Result<Vec<Request>> {
let s = self.access()?;
let mut q=s.db.prepare("SELECT id,subject,payload,state,expires,pid,decision,approver,program FROM requests ORDER BY rowid")?;
let rows = q.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
r.get(6)?,
r.get(7)?,
r.get(8)?,
))
})?;
rows.map(|r| {
let (id, subject, payload, state, expires, pid, decision, approver, program) = r?;
Ok(Request {
id,
subject: serde_json::from_str(&subject)?,
payload: serde_json::from_str(&payload)?,
state,
expires,
pid,
program,
decision,
approver,
})
})
.collect()
}
pub fn request_diff(&self, id: &str) -> Result<serde_json::Value> {
self.request_list()?
.into_iter()
.find(|r| r.id == id)
.map(|r| r.payload)
.ok_or(Error("request_missing"))
}
pub fn request_decisions(&self) -> Result<Vec<Request>> {
Ok(self
.request_list()?
.into_iter()
.filter(|r| r.state != "pending")
.collect())
}
pub fn approve(
&self,
id: &str,
decision: &str,
overrides: Overrides,
approver: Option<&str>,
) -> Result<()> {
self.decide(id, decision, Some(overrides), approver)
}
pub fn deny(&self, id: &str, decision: &str, approver: Option<&str>) -> Result<()> {
self.decide(id, decision, None, approver)
}
fn decide(
&self,
id: &str,
decision: &str,
overrides: Option<Overrides>,
approver: Option<&str>,
) -> Result<()> {
let mut s = self.access()?;
let master = s.master.clone();
let tx =
s.db.transaction_with_behavior(TransactionBehavior::Immediate)?;
let (subject, payload, state, expires, pid, program): (
String,
String,
String,
i64,
u32,
String,
) = tx.query_row(
"SELECT subject,payload,state,expires,pid,program FROM requests WHERE id=?",
[id],
|r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
))
},
)?;
let subject: Subject = serde_json::from_str(&subject)?;
if state == "expired" {
return Err(Error("request_expired"));
}
if state != "pending" {
return Err(Error("already_decided"));
}
if expires <= now() {
tx.execute("UPDATE requests SET state='expired' WHERE id=?", [id])?;
tx.commit()?;
let _ = self.decided.send(id.to_owned());
return Err(Error("request_expired"));
}
let caller = Caller {
pid,
program,
conversations: match &subject {
Subject::Conversation(id) => vec![id.clone()],
Subject::Program(_) => vec![],
},
};
let mut issued_grant = None;
let state = if let Some(o) = overrides {
let parsed: serde_json::Value = serde_json::from_str(&payload)?;
if let Some(edit) = parsed.get("edit") {
let mut edit: super::edits::Edit = serde_json::from_value(edit.clone())?;
if let Some(requested) = parsed.get("requested") {
let requested: super::edits::OperationRequest =
serde_json::from_value(requested.clone())?;
let current = super::items::load(&tx, &edit.item)?;
if current.version != edit.base_version {
return Err(Error("stale"));
}
edit.config = super::edits::apply_changes(
current,
&requested.change,
0,
Some((&tx, &master)),
)?;
}
super::edits::apply(&tx, &master, &edit, &caller, approver)?;
} else if let Some(revert) = parsed.get("revert") {
super::edits::apply_revert(
&tx,
&master,
&serde_json::from_value(revert.clone())?,
&caller,
approver,
)?;
} else if let Some(create) = parsed.get("create") {
let mut item = super::items::insert_new(
&tx,
serde_json::from_value(create.clone())?,
&caller,
approver,
)?;
if parsed.get("generate_es256").and_then(|v| v.as_bool()) == Some(true) {
let private =
super::sign::generate_es256().map_err(|_| Error("key_generation"))?;
let index = item
.fields
.iter()
.position(|f| {
!f.public
&& f.field_type == super::types::FieldType::SshPrivateKey
&& f.key_type.as_deref() == Some("es256")
})
.ok_or(Error("invalid_params"))?;
let field = &item.fields[index].field_id;
let cipher =
seal::seal(&master, &format!("{}|{field}", item.id), private.as_bytes())?;
tx.execute(
"INSERT INTO fields VALUES(?1,?2,?3)",
params![item.id, field, cipher],
)?;
item.fields[index].needs_value = false;
super::items::save(&tx, &item)?;
}
} else if let Some(grant) = parsed.get("grant_create") {
let mut grant: Grant = serde_json::from_value(grant.clone())?;
grant.id = seal::identity();
grant.uses = 0;
grant.revoked = false;
grants::insert(&tx, &grant, &caller, approver)?;
} else if let Some(target) = parsed.get("grant_revoke") {
let target = target.as_str().ok_or(Error("invalid_params"))?;
if tx.execute("UPDATE grants SET revoked=1 WHERE id=?", [target])? != 1 {
return Err(Error("grant_missing"));
}
audit::record(&tx, "grant.revoke", &caller, None, approver)?;
} else if let Some(delete) = parsed.get("delete") {
super::edits::apply_delete(
&tx,
&serde_json::from_value(delete.clone())?,
&caller,
approver,
)?;
} else {
let p: GrantRequest = serde_json::from_value(parsed)?;
let duration = o.duration_secs.or(p.duration_secs).unwrap_or(3600);
if duration == 0 {
return Err(Error("invalid_duration"));
}
let duration = i64::try_from(duration).map_err(|_| Error("invalid_duration"))?;
let g = Grant {
id: seal::identity(),
items: p.items,
subject,
exp: Some(
now()
.checked_add(duration)
.ok_or(Error("invalid_duration"))?,
),
max_uses: if o.unlimited {
None
} else {
o.max_uses.or(p.max_uses)
},
uses: 0,
revoked: false,
validator: o.validator.or(p.validator),
note: None,
};
grants::insert(&tx, &g, &caller, approver)?;
issued_grant = Some(g.id);
}
"approved"
} else {
"denied"
};
tx.execute(
"UPDATE requests SET state=?2,decision=?3,approver=?4,grant_id=?5 WHERE id=?1",
params![id, state, decision, approver, issued_grant],
)?;
audit::record(&tx, state, &caller, None, approver)?;
tx.commit()?;
let _ = self.decided.send(id.to_owned());
Ok(())
}
pub fn expire_requests(&self) -> Result<Vec<String>> {
let mut s = self.access()?;
let tx =
s.db.transaction_with_behavior(TransactionBehavior::Immediate)?;
let ids = {
let mut q =
tx.prepare("SELECT id FROM requests WHERE state='pending' AND expires<=?")?;
q.query_map([now()], |r| r.get::<_, String>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?
};
tx.execute(
"UPDATE requests SET state='expired' WHERE state='pending' AND expires<=?",
[now()],
)?;
tx.commit()?;
for id in &ids {
let _ = self.decided.send(id.clone());
}
Ok(ids)
}
}
+12
View File
@@ -0,0 +1,12 @@
//! Fresh credentials schema. No v2 migrations or vault lifecycle.
pub(crate) const DDL: &str = "
PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS items(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, config TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS fields(item TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, id TEXT NOT NULL, ciphertext BLOB NOT NULL, PRIMARY KEY(item,id));
CREATE TABLE IF NOT EXISTS versions(item TEXT NOT NULL, created INTEGER NOT NULL, config TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS grants(id TEXT PRIMARY KEY, items TEXT NOT NULL, subject TEXT NOT NULL, exp INTEGER, max_uses INTEGER, uses INTEGER NOT NULL DEFAULT 0, revoked INTEGER NOT NULL DEFAULT 0, validator TEXT, note TEXT);
CREATE TABLE IF NOT EXISTS requests(id TEXT PRIMARY KEY, subject TEXT, key TEXT NOT NULL, payload TEXT NOT NULL, state TEXT NOT NULL, expires INTEGER NOT NULL, pid INTEGER NOT NULL, program TEXT NOT NULL, decision TEXT, approver TEXT, grant_id TEXT);
CREATE TABLE IF NOT EXISTS audit(id INTEGER PRIMARY KEY, time INTEGER NOT NULL, event TEXT NOT NULL, pid INTEGER NOT NULL, program TEXT NOT NULL, conversation TEXT, item TEXT, approver TEXT, payload_sha256 TEXT);
CREATE INDEX IF NOT EXISTS request_keys ON requests(subject,key);
CREATE INDEX IF NOT EXISTS request_expiry ON requests(state,expires);
";
+57
View File
@@ -0,0 +1,57 @@
//! Authenticated field encryption. The field identity binds each ciphertext.
use super::error::{Error, Result};
use chacha20poly1305::{
XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit, OsRng, Payload, rand_core::RngCore},
};
pub(crate) fn random<const N: usize>() -> [u8; N] {
let mut bytes = [0; N];
OsRng.fill_bytes(&mut bytes);
bytes
}
pub(crate) fn identity() -> String {
hex::encode(random::<16>())
}
pub(crate) fn seal(key: &[u8; 32], aad: &str, value: &[u8]) -> Result<Vec<u8>> {
let nonce = random::<24>();
let cipher = XChaCha20Poly1305::new(key.into());
let ciphertext = cipher
.encrypt(
XNonce::from_slice(&nonce),
Payload {
msg: value,
aad: aad.as_bytes(),
},
)
.map_err(|_| Error("seal"))?;
Ok([nonce.to_vec(), ciphertext].concat())
}
pub(crate) fn open(key: &[u8; 32], aad: &str, value: &[u8]) -> Result<zeroize::Zeroizing<Vec<u8>>> {
if value.len() < 24 {
return Err(Error("seal"));
}
XChaCha20Poly1305::new(key.into())
.decrypt(
XNonce::from_slice(&value[..24]),
Payload {
msg: &value[24..],
aad: aad.as_bytes(),
},
)
.map(zeroize::Zeroizing::new)
.map_err(|_| Error("seal"))
}
#[cfg(test)]
mod tests {
#[test]
fn field_seal_rejects_tamper_and_wrong_identity() {
let key = super::random::<32>();
let mut cipher = super::seal(&key, "item|field", b"value").unwrap();
assert!(super::open(&key, "item|field", &cipher).unwrap().as_slice() == b"value");
assert!(super::open(&key, "other|field", &cipher).is_err());
assert!(super::open(&super::random::<32>(), "item|field", &cipher).is_err());
let end = cipher.len() - 1;
cipher[end] ^= 1;
assert!(super::open(&key, "item|field", &cipher).is_err());
}
}
+61
View File
@@ -0,0 +1,61 @@
//! Pure signing and public-key export over decrypted field values. No storage or grant policy.
use super::keys;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Error {
pub kind: &'static str,
pub message: &'static str,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.message)
}
}
impl std::error::Error for Error {}
pub type Result<T> = std::result::Result<T, Error>;
pub(super) fn error(kind: &'static str, message: &'static str) -> Error {
Error { kind, message }
}
pub(super) fn invalid_key() -> Error {
error("invalid_key", "invalid signing key encoding or length")
}
#[derive(Debug, serde::Serialize)]
pub struct SignResult {
pub signature: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes: Option<Vec<u8>>,
}
/// Sign bytes. RSA requires explicit, matching padding and digest parameters.
pub fn sign(
value: &str,
passphrase: Option<&str>,
format: &str,
payload: &[u8],
output: &str,
padding: Option<&str>,
hash: Option<&str>,
) -> Result<SignResult> {
keys::check(format, padding, hash)?;
let key = keys::read(value, format, passphrase)?;
keys::output(format, output, keys::sign_key(&key, format, payload)?)
}
/// Export only the public part of an asymmetric key.
pub fn public_key(
value: &str,
passphrase: Option<&str>,
format: &str,
output: &str,
) -> Result<String> {
keys::public(&keys::read(value, format, passphrase)?, output)
}
/// Generate a P-256 private key in PKCS#8 PEM format for ES256.
pub fn generate_es256() -> Result<zeroize::Zeroizing<String>> {
use p256::pkcs8::EncodePrivateKey;
p256::SecretKey::random(&mut rsa::rand_core::OsRng)
.to_pkcs8_pem(Default::default())
.map_err(|_| invalid_key())
}
+78
View File
@@ -0,0 +1,78 @@
//! Host-only database and master key. One mutex protects synchronous transactions.
use super::{
error::{Error, Result},
schema, seal,
};
use std::{
io::{Read, Write},
os::unix::fs::OpenOptionsExt,
path::Path,
sync::{Mutex, MutexGuard},
};
pub(crate) struct Store {
pub db: rusqlite::Connection,
pub master: zeroize::Zeroizing<[u8; 32]>,
}
pub struct Cred {
pub(crate) store: Mutex<Store>,
pub(crate) decided: tokio::sync::broadcast::Sender<String>,
pub(crate) filed: tokio::sync::broadcast::Sender<String>,
}
impl Cred {
pub fn open(host_dir: &Path) -> Result<Self> {
std::fs::create_dir_all(host_dir)?;
let path = std::env::var_os("CREDENTIALS_DIRECTORY")
.map(|p| std::path::PathBuf::from(p).join("sausage-cred-key"))
.unwrap_or_else(|| host_dir.join("cred.key"));
if std::env::var_os("CREDENTIALS_DIRECTORY").is_none() {
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
{
Ok(mut file) => {
file.write_all(&seal::random::<32>())?;
file.sync_all()?;
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e.into()),
}
}
let mut bytes = zeroize::Zeroizing::new(Vec::new());
std::fs::File::open(path)?.read_to_end(&mut bytes)?;
let master = zeroize::Zeroizing::new(
bytes
.as_slice()
.try_into()
.map_err(|_| Error("master_key"))?,
);
let db = rusqlite::Connection::open(host_dir.join("cred.sqlite"))?;
db.busy_timeout(std::time::Duration::from_secs(5))?;
db.execute_batch(schema::DDL)?;
let (decided, _) = tokio::sync::broadcast::channel(64);
let (filed, _) = tokio::sync::broadcast::channel(64);
let cred = Self {
store: Mutex::new(Store { db, master }),
decided,
filed,
};
cred.expire_requests()?;
Ok(cred)
}
pub(crate) fn access(&self) -> Result<MutexGuard<'_, Store>> {
self.store.lock().map_err(|_| Error("storage"))
}
pub fn subscribe_filed(&self) -> tokio::sync::broadcast::Receiver<String> {
self.filed.subscribe()
}
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<String> {
self.decided.subscribe()
}
}
pub(crate) fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock before epoch")
.as_secs() as i64
}
+111
View File
@@ -0,0 +1,111 @@
//! Public approval summaries. Do not interpolate field values or script sources.
pub fn human_duration(mut seconds: u64) -> String {
let mut parts = Vec::new();
for (size, unit) in [
(86400, "day"),
(3600, "hour"),
(60, "minute"),
(1, "second"),
] {
let count = seconds / size;
seconds %= size;
if count != 0 {
parts.push(format!(
"{count} {unit}{}",
if count == 1 { "" } else { "s" }
));
}
}
if parts.is_empty() {
"0 seconds".into()
} else {
parts.join(" ")
}
}
fn text(value: &str) -> String {
let mut out = String::new();
for c in value.chars() {
match c {
'\n' | '\r' => out.push(' '),
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'\\' | '`' | '*' | '_' | '[' | ']' | '#' | '|' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out
}
pub fn operation_summary(operation: &super::edits::Operation) -> String {
use super::edits::Operation::*;
match operation {
CreateItem { config } => format!("Create item “{}”.", text(&config.name)),
GenerateEs256Item { config } => {
format!("Create item “{}” with a new ES256 key.", text(&config.name))
}
SetName { name } => format!("Rename the item to “{}”.", text(name)),
SetRules { .. } => "Replace the access rules. Review the rules in Details.".into(),
SetFreeUse { enabled: true } => {
"Allow anyone to use this item without a grant, within its rules.".into()
}
SetFreeUse { enabled: false } => "Require a grant to use this item.".into(),
SetValidateJs { value: None } => "Remove the validation script.".into(),
SetValidateJs { .. } => "Replace the validation script.".into(),
SetTransformJs { .. } => "Replace the transformation script.".into(),
AddField { field } => format!(
"Add {} field “{}”.",
if field.public { "public" } else { "secret" },
text(&field.name)
),
SetFieldValue { .. } => "Change a public field value.".into(),
SetFieldType { .. } => "Change a field type. Review the type in Details.".into(),
RenameField { name, .. } => format!("Rename a field to “{}”.", text(name)),
RemoveField { .. } => "Remove a field and permanently discard its value.".into(),
DeleteItem => "Delete the item and permanently discard its field values.".into(),
ItemRevert { version } => format!(
"Restore public configuration from revision {version}. Discarded secret values cannot be recovered."
),
GrantCreate { grant } => format!(
"Give access to {} credential items. Review the subject and limits in Details.",
grant.items.len()
),
GrantRevoke { .. } => "Revoke the grant. All access through this grant will stop.".into(),
}
}
/// UI presets. The runtime never interprets a preset name.
pub fn script(preset: &str) -> super::Result<String> {
let field = "const get = name => { if (typeof credential[name] !== 'string') throw Error('credential field is missing'); return credential[name]; };";
if let Some(name) = preset.strip_prefix("query:") {
let name = serde_json::to_string(name)?;
return Ok(format!(
r#"export default ({{request,credential,url}}) => {{
{field}
const name = {name};
const encode = value => encodeURIComponent(value).replace(/[!'()~]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()).replace(/%20/g, '+');
const pairs = url.query.filter(([key]) => key !== name);
pairs.push([name,get('token')]);
return {{...request,url:url.base+'?'+pairs.map(([k,v])=>encode(k)+'='+encode(v)).join('&')+url.fragment}};
}}"#
));
}
let (name, value) = match preset {
"bearer" => ("Authorization", "'Bearer ' + get('token')"),
"basic" => (
"Authorization",
"'Basic ' + btoa(unescape(encodeURIComponent(get('username') + ':' + get('password'))))",
),
p if p.starts_with("header:") => (&p[7..], "get('token')"),
_ => return Err(super::Error("invalid_preset")),
};
let name = serde_json::to_string(name)?;
Ok(format!(
r#"export default ({{request,credential}}) => {{
{field}
const name = {name};
return {{...request,headers:[...request.headers.filter(h=>h.name.toLowerCase()!==name.toLowerCase()),{{name,value:{value}}}]}};
}}"#
))
}
+108
View File
@@ -0,0 +1,108 @@
//! Caller identity and mandatory field metadata. Reject mismatched key algorithms.
use super::error::{Error, Result};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Caller {
pub program: String,
pub pid: u32,
pub conversations: Vec<String>,
}
/// Conversation subjects cover descendants. Program subjects cover the same binary path.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "id", rename_all = "snake_case")]
pub enum Subject {
Conversation(String),
Program(String),
}
impl Subject {
pub fn covers(&self, caller: &Caller) -> bool {
match self {
Self::Conversation(id) => caller.conversations.contains(id),
Self::Program(path) => caller.program == *path,
}
}
pub(crate) fn requester(caller: &Caller) -> Self {
caller.conversations.first().map_or_else(
|| Self::Program(caller.program.clone()),
|id| Self::Conversation(id.clone()),
)
}
pub(crate) fn valid(&self) -> bool {
match self {
Self::Conversation(id) => !id.is_empty(),
Self::Program(path) => path.starts_with('/'),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FieldType {
Text,
Note,
SshPublicKey,
SshPrivateKey,
Oauth2RefreshToken,
}
fn matches_algorithm(algorithm: ssh_key::Algorithm, format: &str) -> bool {
match algorithm {
ssh_key::Algorithm::Ed25519 => matches!(format, "ed25519" | "ed25519-raw"),
ssh_key::Algorithm::Ecdsa {
curve: ssh_key::EcdsaCurve::NistP256,
} => format == "es256",
ssh_key::Algorithm::Rsa { .. } => {
matches!(format, "rs256" | "rsa-sha2-256" | "rsa-sha2-512")
}
_ => false,
}
}
pub(crate) fn validate(
kind: &FieldType,
key_type: Option<&str>,
public: bool,
value: Option<&str>,
) -> Result<()> {
let key_format = key_type.is_some_and(|k| {
matches!(
k,
"ed25519" | "ed25519-raw" | "es256" | "rs256" | "rsa-sha2-256" | "rsa-sha2-512"
)
});
match kind {
FieldType::Text | FieldType::Note if key_type.is_none() => {}
FieldType::Oauth2RefreshToken if !public && key_type.is_none() => {}
FieldType::SshPublicKey if public && key_format => {}
FieldType::SshPrivateKey if !public && key_format => {}
_ => return Err(Error("invalid_type")),
}
if let Some(value) = value {
match kind {
FieldType::Text | FieldType::Oauth2RefreshToken if value.contains(['\r', '\n']) => {
return Err(Error("invalid_value"));
}
FieldType::SshPublicKey => {
let key =
ssh_key::PublicKey::from_openssh(value).map_err(|_| Error("invalid_value"))?;
if !matches_algorithm(key.algorithm(), key_type.unwrap_or("")) {
return Err(Error("invalid_value"));
}
}
FieldType::SshPrivateKey => {
if value
.trim()
.starts_with("-----BEGIN OPENSSH PRIVATE KEY-----")
{
let key = ssh_key::PrivateKey::from_openssh(value)
.map_err(|_| Error("invalid_value"))?;
if !matches_algorithm(key.algorithm(), key_type.unwrap_or("")) {
return Err(Error("invalid_value"));
}
} else {
super::keys::read(value, key_type.unwrap_or(""), None)
.map_err(|_| Error("invalid_value"))?;
}
}
_ => {}
}
}
Ok(())
}
+199
View File
@@ -0,0 +1,199 @@
//! Fresh, bounded V8 isolates for credential scripts. Each call owns a dedicated worker thread.
use super::script::{Error, ErrorKind, Limits, Script};
use deno_core::{JsRuntime, RuntimeOptions, v8};
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
},
time::Duration,
};
#[derive(Default)]
pub struct V8;
fn failure(kind: ErrorKind) -> Error {
Error {
kind,
message: match kind {
ErrorKind::Limit => "script time, memory or stack limit exceeded",
ErrorKind::Syntax => "invalid script or JSON",
ErrorKind::Throw => "script threw or rejected",
ErrorKind::NonJson => "result is not JSON",
ErrorKind::Engine => "script engine failure",
}
.into(),
}
}
impl Script for V8 {
fn evaluate(
&self,
source: &str,
input: &str,
export: Option<&str>,
limits: Limits,
) -> Result<String, Error> {
let source = source.to_owned();
let input = input.to_owned();
let export = export.unwrap_or("default").to_owned();
std::thread::spawn(move || evaluate(source, input, export, limits))
.join()
.map_err(|_| failure(ErrorKind::Engine))?
}
}
fn evaluate(
source: String,
input: String,
export: String,
limits: Limits,
) -> Result<String, Error> {
let heap = limits
.max_memory_bytes
.unwrap_or(8 * 1024 * 1024)
.clamp(256 * 1024, 32 * 1024 * 1024);
// V8 uses Tokio for internal delayed tasks, even without guest extensions.
let executor = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.map_err(|_| failure(ErrorKind::Engine))?;
let _entered = executor.enter();
let mut runtime = JsRuntime::new(RuntimeOptions {
// Bootstrap before imposing the caller's budget: V8 cannot recover from
// bootstrap OOM because the runtime cannot install its callback yet.
create_params: Some(v8::CreateParams::default().heap_limits(0, 32 * 1024 * 1024)),
..Default::default()
});
if runtime.v8_isolate().get_heap_statistics().total_heap_size() > heap {
return Err(failure(ErrorKind::Limit));
}
// Removing a temporary callback sets the heap limit after bootstrap.
runtime.add_near_heap_limit_callback(|current, _| current);
runtime.remove_near_heap_limit_callback(heap);
let handle = runtime.v8_isolate().thread_safe_handle();
let limited = Arc::new(AtomicBool::new(false));
let flag = limited.clone();
let heap_handle = handle.clone();
runtime.add_near_heap_limit_callback(move |current, _| {
flag.store(true, Ordering::SeqCst);
heap_handle.terminate_execution();
// V8 needs emergency headroom to unwind after termination.
current + 16 * 1024 * 1024
});
let (done, wait) = mpsc::channel();
let flag = limited.clone();
let watchdog = std::thread::spawn(move || {
if wait
.recv_timeout(Duration::from_millis(
limits.max_time_ms.unwrap_or(100).clamp(1, 1000),
))
.is_err()
{
flag.store(true, Ordering::SeqCst);
handle.terminate_execution();
}
});
let task = async {
runtime
.execute_script("sandbox.js", BOOTSTRAP)
.map_err(|_| failure(ErrorKind::Engine))?;
let input = runtime
.execute_script(
"input.js",
format!("JSON.parse({})", serde_json::to_string(&input).unwrap()),
)
.map_err(|_| failure(ErrorKind::Syntax))?;
let spec = deno_core::ModuleSpecifier::parse("file:///credential.js").unwrap();
let module = runtime
.load_main_es_module_from_code(&spec, source)
.await
.map_err(|_| failure(ErrorKind::Syntax))?;
let evaluated = runtime.mod_evaluate(module);
runtime
.with_event_loop_promise(evaluated, Default::default())
.await
.map_err(|e| {
if e.to_string().contains("call stack") {
failure(ErrorKind::Limit)
} else {
failure(ErrorKind::Throw)
}
})?;
let ns = runtime
.get_module_namespace(module)
.map_err(|_| failure(ErrorKind::Engine))?;
{
deno_core::scope!(scope, &mut runtime);
let ns = v8::Local::new(scope, ns);
let global = scope.get_current_context().global(scope);
let key =
v8::String::new(scope, "__module").ok_or_else(|| failure(ErrorKind::Limit))?;
global.set(scope, key.into(), ns.into());
let input = v8::Local::new(scope, input);
let key = v8::String::new(scope, "__input").ok_or_else(|| failure(ErrorKind::Limit))?;
global.set(scope, key.into(), input);
}
let code = format!(
r#"(async()=>{{
const f=__module[{}];
if(typeof f!=='function') return JSON.stringify({{kind:'Engine'}});
let value;
try {{ value=await f(__input); }} catch(e) {{ const message=String(e?.message); return JSON.stringify({{kind: e instanceof RangeError && message.includes('call stack') ? 'Limit':'Throw'}}); }}
try {{ const text=JSON.stringify(value); return JSON.stringify(text===undefined?{{kind:'NonJson'}}:{{text}}); }} catch(e) {{ return JSON.stringify({{kind:e instanceof RangeError && String(e.message).includes('call stack') ? 'Limit':'NonJson'}}); }}
}})()"#,
serde_json::to_string(&export).unwrap()
);
let value = runtime
.execute_script("call.js", code)
.map_err(|_| failure(ErrorKind::Throw))?;
let resolve = runtime.resolve(value);
let value = runtime
.with_event_loop_promise(resolve, Default::default())
.await
.map_err(|_| failure(ErrorKind::Engine))?;
let text = {
deno_core::scope!(scope, &mut runtime);
v8::Local::new(scope, value).to_rust_string_lossy(scope)
};
let result: serde_json::Value =
serde_json::from_str(&text).map_err(|_| failure(ErrorKind::Engine))?;
match result["text"].as_str() {
Some(s) => Ok(s.to_owned()),
None => Err(failure(match result["kind"].as_str() {
Some("NonJson") => ErrorKind::NonJson,
Some("Throw") => ErrorKind::Throw,
Some("Limit") => ErrorKind::Limit,
_ => ErrorKind::Engine,
})),
}
};
let result = executor.block_on(task);
let _ = done.send(());
let _ = watchdog.join();
if limited.load(Ordering::SeqCst) {
Err(failure(ErrorKind::Limit))
} else {
result
}
}
const BOOTSTRAP: &str = r#"
delete globalThis.Date; delete Math.random;
for (const name of ['Deno','__bootstrap','console','performance']) delete globalThis[name];
const alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
globalThis.btoa = value => {
const s=String(value); let out='';
for(let i=0;i<s.length;i+=3){
const a=s.charCodeAt(i), b=s.charCodeAt(i+1), c=s.charCodeAt(i+2);
if(a>255||b>255||c>255) throw Error('invalid base64 input');
const n=(a<<16)|((b||0)<<8)|(c||0);
out+=alphabet[(n>>>18)&63]+alphabet[(n>>>12)&63]+(i+1<s.length?alphabet[(n>>>6)&63]:'=')+(i+2<s.length?alphabet[n&63]:'=');
} return out;
};
globalThis.atob = value => {
let s=String(value).replace(/[\t\n\f\r ]/g,'');
if(s.length%4===0) s=s.replace(/==?$/,'');
if(s.length%4===1||/[^A-Za-z0-9+/]/.test(s)) throw Error('invalid base64 input');
let out='', bits=0, n=0;
for(const c of s){n=(n<<6)|alphabet.indexOf(c);bits+=6;if(bits>=8){bits-=8;out+=String.fromCharCode((n>>>bits)&255);}}
return out;
};
"#;
+247
View File
@@ -0,0 +1,247 @@
//! Passkey enrollment and single-use assertions for host-owned bindings.
//! The caller supplies cred.sqlite and serializes access to this manager.
//! Browser responses and challenges use WebAuthn JSON. Enrollment assertions
//! must bind the exact new label. Neither decision nor secret-entry assertions
//! authorize enrollment. Without passkeys, the host is unlocked and permits
//! first registration without a gate. Restart drops all pending challenges.
use anyhow::{Result, bail};
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
time::{Duration, Instant},
};
use webauthn_rs::prelude::*;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Binding {
Decision {
request_id: String,
decision: String,
overrides: serde_json::Value,
},
SecretEntry {
item: String,
field: String,
ciphertext_sha256: String,
},
Enrollment {
label: String,
},
}
#[derive(Serialize, Deserialize)]
pub struct Gate {
pub id: String,
pub response: PublicKeyCredential,
}
#[derive(Serialize, Deserialize)]
pub struct Challenge<T> {
pub id: String,
pub options: T,
}
struct Pending<T> {
deadline: Instant,
value: T,
}
pub struct Passkeys<'a> {
db: &'a Connection,
webauthn: Webauthn,
registrations: HashMap<String, Pending<(String, bool, PasskeyRegistration)>>,
assertions: HashMap<String, Pending<(Binding, PasskeyAuthentication)>>,
}
impl<'a> Passkeys<'a> {
pub fn new(db: &'a Connection, rp_id: &str, origin: &str) -> Result<Self> {
let origin = Url::parse(origin)?;
let webauthn = WebauthnBuilder::new(rp_id, &origin)?.build()?;
db.execute_batch(
"CREATE TABLE IF NOT EXISTS passkey_credentials (
id TEXT PRIMARY KEY, label TEXT NOT NULL, credential TEXT NOT NULL)",
)?;
Ok(Self {
db,
webauthn,
registrations: HashMap::new(),
assertions: HashMap::new(),
})
}
/// Database errors leave the manager locked.
pub fn is_unlocked(&self) -> bool {
self.db
.query_row(
"SELECT NOT EXISTS(SELECT 1 FROM passkey_credentials)",
[],
|row| row.get(0),
)
.unwrap_or(false)
}
fn credentials(&self) -> Result<Vec<Passkey>> {
let mut stmt = self
.db
.prepare("SELECT credential FROM passkey_credentials")?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
rows.map(|row| Ok(serde_json::from_str(&row?)?)).collect()
}
pub fn start_registration(
&mut self,
label: String,
gate: Option<Gate>,
) -> Result<Challenge<CreationChallengeResponse>> {
let gated = gate.is_some();
match gate {
None if !self.is_unlocked() => bail!("enrollment assertion required"),
None => {}
Some(Gate { id, response }) => {
let (_, binding) = self.finish_assertion(&id, response)?;
if binding
!= (Binding::Enrollment {
label: label.clone(),
})
{
bail!("invalid enrollment gate");
}
}
}
let exclude = self
.credentials()?
.iter()
.map(|p| p.cred_id().clone())
.collect();
let (options, state) = self.webauthn.start_passkey_registration(
Uuid::new_v4(),
&label,
&label,
Some(exclude),
)?;
self.registrations
.retain(|_, p| p.deadline > Instant::now());
let id = Uuid::new_v4().to_string();
self.registrations.insert(
id.clone(),
Pending {
deadline: Instant::now() + Duration::from_secs(300),
value: (label, gated, state),
},
);
Ok(Challenge { id, options })
}
pub fn finish_registration(
&mut self,
id: &str,
response: RegisterPublicKeyCredential,
) -> Result<Passkey> {
let (label, gated, state) = take(&mut self.registrations, id)?;
if !gated && !self.is_unlocked() {
bail!("enrollment assertion required");
}
let credential = self
.webauthn
.finish_passkey_registration(&response, &state)?;
self.db.execute(
"INSERT INTO passkey_credentials (id, label, credential) VALUES (?1, ?2, ?3)",
params![
serde_json::to_string(credential.cred_id())?,
label,
serde_json::to_string(&credential)?
],
)?;
Ok(credential)
}
pub fn start_assertion(
&mut self,
binding: Binding,
) -> Result<Challenge<RequestChallengeResponse>> {
let (options, state) = self
.webauthn
.start_passkey_authentication(&self.credentials()?)?;
self.assertions.retain(|_, p| p.deadline > Instant::now());
let id = Uuid::new_v4().to_string();
self.assertions.insert(
id.clone(),
Pending {
deadline: Instant::now() + Duration::from_secs(300),
value: (binding, state),
},
);
Ok(Challenge { id, options })
}
pub fn finish_assertion(
&mut self,
id: &str,
response: PublicKeyCredential,
) -> Result<(CredentialID, Binding)> {
let (binding, state) = take(&mut self.assertions, id)?;
let result = self
.webauthn
.finish_passkey_authentication(&response, &state)?;
let mut credentials = self.credentials()?;
let credential = credentials
.iter_mut()
.find(|p| p.cred_id() == result.cred_id())
.ok_or_else(|| anyhow::anyhow!("unregistered passkey"))?;
// Check the latest stored counter too. Another outstanding assertion may
// contain an older snapshot of this credential.
let stored = serde_json::to_value(&*credential)?;
let counter = stored["cred"]["counter"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("invalid stored passkey counter"))?;
if (counter > 0 || result.counter() > 0) && u64::from(result.counter()) <= counter {
bail!("passkey counter did not increase");
}
credential.update_credential(&result);
self.db.execute(
"UPDATE passkey_credentials SET credential = ?1 WHERE id = ?2",
params![
serde_json::to_string(credential)?,
serde_json::to_string(result.cred_id())?
],
)?;
Ok((result.cred_id().clone(), binding))
}
}
fn take<T>(map: &mut HashMap<String, Pending<T>>, id: &str) -> Result<T> {
let pending = map
.remove(id)
.ok_or_else(|| anyhow::anyhow!("unknown or consumed challenge"))?;
if pending.deadline <= Instant::now() {
bail!("expired challenge");
}
Ok(pending.value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expired_assertion() -> Result<()> {
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
let db = Connection::open_in_memory()?;
let mut host = Passkeys::new(&db, "localhost", "http://localhost:8000")?;
let start = host.start_registration("test".into(), None)?;
let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true));
let origin = Url::parse("http://localhost:8000")?;
let response = auth.do_registration(origin.clone(), start.options)?;
host.finish_registration(&start.id, response)?;
let start = host.start_assertion(Binding::Enrollment {
label: "next".into(),
})?;
let response = auth.do_authentication(origin, start.options)?;
host.assertions.get_mut(&start.id).unwrap().deadline = Instant::now();
assert!(host.finish_assertion(&start.id, response).is_err());
assert!(!host.assertions.contains_key(&start.id));
Ok(())
}
}
+162
View File
@@ -0,0 +1,162 @@
//! Credential request snapshots and host-only model proxy configuration.
use crate::cred::{store::Cred, types::Subject};
use deno_core::{OpState, op2};
use deno_error::JsErrorBox;
use serde_json::{Value, json};
use std::{cell::RefCell, rc::Rc, sync::Arc};
pub(super) struct Notifications {
filed: tokio::sync::Mutex<tokio::sync::broadcast::Receiver<String>>,
}
impl Notifications {
pub(super) fn new(cred: &Cred) -> Rc<Self> {
Rc::new(Self {
filed: tokio::sync::Mutex::new(cred.subscribe_filed()),
})
}
}
#[op2]
pub(super) async fn op_credential_next(state: Rc<RefCell<OpState>>) {
let notifications = state.borrow().borrow::<Rc<Notifications>>().clone();
let mut filed = notifications.filed.lock().await;
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), filed.recv()).await;
}
#[op2]
#[serde]
pub(super) fn op_credential_requests(state: &mut OpState) -> Result<serde_json::Value, JsErrorBox> {
let cred = state.borrow::<Arc<Cred>>();
let work = || -> anyhow::Result<Value> {
cred.expire_requests()?;
// TODO(T27): prune retained records once a day after the library adds Cred::prune.
let mut rows = Vec::new();
for request in cred.request_list()? {
let Subject::Conversation(conversation) = request.subject else {
continue;
};
let names = request
.payload
.get("items")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(|id| {
cred.item_get(id)
.map(|item| format!("`{}`", item.name))
.unwrap_or_else(|_| "the credential".into())
})
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_else(|| "the credential change".into());
let message = match request.state.as_str() {
"approved" => format!("Your request for {names} was approved. Retry the command."),
"denied" => format!("Your request for {names} was denied."),
"expired" => format!(
"Your request for {names} expired. File a new request if you still need it."
),
_ => String::new(),
};
rows.push(json!({"id":request.id,"conversation":conversation,"state":request.state,"message":message}));
}
Ok(json!(rows))
};
work().map_err(|_| JsErrorBox::generic("Cannot read credential requests"))
}
pub(super) fn proxy(cred: &Cred) -> anyhow::Result<Option<Value>> {
use crate::cred::{
items::{Field, Item},
types::{Caller, FieldType},
};
let caller = Caller {
pid: 0,
program: "host".into(),
conversations: vec![],
};
let item = cred
.item_list()?
.into_iter()
.find(|item| item.name == "llm-proxy");
let item = match item {
Some(item) => item,
None => {
let (Ok(base), Ok(key)) = (
std::env::var("PI_PROXY_BASE_URL"),
std::env::var("PI_PROXY_API_KEY"),
) else {
return Ok(None);
};
let mut store = cred.access()?;
let master = store.master.clone();
let tx = store
.db
.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let mut item = crate::cred::items::insert_new(
&tx,
Item {
id: String::new(),
name: "llm-proxy".into(),
version: 0,
free_use: false,
rules: vec![],
validate_js: None,
transform_js: "export default (ctx) => ctx.request".into(),
fields: vec![
Field {
field_id: String::new(),
name: "base_url".into(),
public: true,
value: Some(base),
needs_value: false,
field_type: FieldType::Text,
key_type: None,
},
Field {
field_id: String::new(),
name: "api_key".into(),
public: false,
value: None,
needs_value: true,
field_type: FieldType::Text,
key_type: None,
},
],
},
&caller,
Some("host bootstrap"),
)?;
let field = &item
.fields
.iter()
.find(|f| f.name == "api_key")
.unwrap()
.field_id;
let sealed =
crate::cred::seal::seal(&master, &format!("{}|{field}", item.id), key.as_bytes())?;
tx.execute(
"INSERT INTO fields VALUES(?1,?2,?3)",
rusqlite::params![item.id, field, sealed],
)?;
item.fields
.iter_mut()
.find(|f| f.name == "api_key")
.unwrap()
.needs_value = false;
crate::cred::items::save(&tx, &item)?;
tx.commit()?;
item
}
};
Ok(Some(cred.credential(&item, true)?))
}
#[op2]
#[serde]
pub(super) fn op_model_proxy(state: &mut OpState) -> Result<serde_json::Value, JsErrorBox> {
proxy(state.borrow::<Arc<Cred>>())
.map(|v| v.unwrap_or(Value::Null))
.map_err(|_| JsErrorBox::generic("Cannot read llm-proxy credential"))
}
+41 -10
View File
@@ -1,4 +1,5 @@
//! Durable V8 harness, host-side SQLite storage, and world tool operations.
mod credentials;
mod environment;
use crate::{exec::Exec, world::World};
use deno_core::{JsRuntime, OpState, RuntimeOptions, op2};
@@ -78,6 +79,9 @@ deno_core::extension!(
storage,
ops = [
op_sql,
credentials::op_credential_requests,
credentials::op_model_proxy,
credentials::op_credential_next,
environment::op_file,
environment::op_exec,
environment::op_exec_next,
@@ -112,6 +116,18 @@ pub async fn run_with_exec(world: World, exec: Arc<dyn Exec>) -> anyhow::Result<
/// Evaluate a bundled harness module with the same storage and tool operations as the console.
/// The caller must use a current-thread Tokio runtime.
pub async fn run_module(world: World, exec: Arc<dyn Exec>, source: &str) -> anyhow::Result<()> {
let cred = Arc::new(crate::cred::store::Cred::open(&world.host_dir)?);
run_module_with_credentials(world, exec, cred, source).await
}
/// Run a harness with the credentials handle shared by the host API.
pub async fn run_module_with_credentials(
world: World,
exec: Arc<dyn Exec>,
cred: Arc<crate::cred::store::Cred>,
source: &str,
) -> anyhow::Result<()> {
let proxy = credentials::proxy(&cred)?;
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![
deno_telemetry::deno_telemetry::init(),
@@ -162,11 +178,11 @@ pub async fn run_module(world: World, exec: Arc<dyn Exec>, source: &str) -> anyh
deno_permissions::RuntimePermissionDescriptorParser::new(sys_traits::impls::RealSys),
)));
runtime.execute_script("bootstrap.js", include_str!("../../js/bootstrap.js"))?;
let env: serde_json::Map<String, Value> = ["PI_PROXY_BASE_URL", "PI_PROXY_API_KEY"]
.into_iter()
.filter_map(|key| std::env::var(key).ok().map(|v| (key.to_owned(), json!(v))))
.collect();
runtime.execute_script("env.js", format!("process.env = {};", Value::Object(env)))?;
runtime
.op_state()
.borrow_mut()
.put(credentials::Notifications::new(&cred));
runtime.op_state().borrow_mut().put(cred);
runtime
.op_state()
.borrow_mut()
@@ -186,11 +202,26 @@ pub async fn run_module(world: World, exec: Arc<dyn Exec>, source: &str) -> anyh
.load_main_es_module_from_code(&specifier, source.to_owned())
.await?;
let result = runtime.mod_evaluate(module);
runtime
.with_event_loop_promise(result, Default::default())
.await?;
runtime.run_event_loop(Default::default()).await?;
Ok(())
let result = async {
runtime
.with_event_loop_promise(result, Default::default())
.await?;
runtime.run_event_loop(Default::default()).await?;
Ok::<_, anyhow::Error>(())
}
.await;
result.map_err(|error| {
let mut text = error.to_string();
if let Some(key) = proxy
.as_ref()
.and_then(|v| v.get("api_key"))
.and_then(Value::as_str)
&& !key.is_empty()
{
text = text.replace(key, "[redacted]");
}
anyhow::anyhow!(text)
})
}
/// Open a world and resume its durable root conversation.
+8 -1
View File
@@ -5,6 +5,9 @@ use std::path::PathBuf;
#[derive(Parser)]
#[command(version, about)]
struct Cli {
/// Shared host-user compile cache (otherwise SAUSAGE_CACHE or the XDG cache folder).
#[arg(long, global = true)]
cache: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
@@ -29,7 +32,11 @@ enum Command {
#[tokio::main]
async fn main() -> anyhow::Result<()> {
match Cli::parse().command {
let cli = Cli::parse();
if let Some(cache) = cli.cache {
sausage::process::configure_cache(cache)?;
}
match cli.command {
Command::Run { world, userland } => {
let bundle =
userland.or_else(|| std::env::var_os("SAUSAGE_USERLAND").map(PathBuf::from));
+236
View File
@@ -0,0 +1,236 @@
//! Shares compiled programs and single-flight compilation across worlds in one host process.
use super::{Compiled, State, adapter, bindings};
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::{Arc, Mutex, OnceLock},
};
use tokio::sync::watch;
type Compilation = watch::Receiver<Option<Result<Compiled, String>>>;
use wasmtime::{
Engine,
component::{Component, Linker},
};
use wasmtime_wasi::p2::bindings::CommandPre;
static DIRECTORY: OnceLock<PathBuf> = OnceLock::new();
static SHARED: Mutex<Option<Arc<Compiler>>> = Mutex::new(None);
/// Select the host user's cache before opening a world.
pub fn configure(directory: PathBuf) -> anyhow::Result<()> {
DIRECTORY
.set(directory)
.map_err(|_| anyhow::anyhow!("cache directory already configured"))
}
fn directory() -> anyhow::Result<PathBuf> {
if let Some(dir) = DIRECTORY.get() {
return Ok(dir.clone());
}
if let Some(dir) = std::env::var_os("SAUSAGE_CACHE") {
return Ok(dir.into());
}
if let Some(dir) = std::env::var_os("XDG_CACHE_HOME") {
return Ok(PathBuf::from(dir).join("sausage"));
}
Ok(
PathBuf::from(std::env::var_os("HOME").ok_or_else(|| anyhow::anyhow!("HOME is not set"))?)
.join(".cache/sausage"),
)
}
pub(super) fn shared() -> anyhow::Result<Arc<Compiler>> {
let mut shared = SHARED.lock().unwrap();
if shared.is_none() {
*shared = Some(Compiler::new(&directory()?)?);
}
Ok(shared.as_ref().unwrap().clone())
}
pub(super) struct Compiler {
pub engine: Engine,
programs: Mutex<HashMap<Vec<u8>, Compilation>>,
#[cfg(test)]
before_compile: Mutex<Option<Box<dyn FnOnce() + Send>>>,
#[cfg(test)]
compilations: std::sync::atomic::AtomicUsize,
}
impl Compiler {
pub fn new(directory: &Path) -> anyhow::Result<Arc<Self>> {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let mut config = wasmtime::Config::new();
config.epoch_interruption(true);
// Keep Cranelift's default optimization: compiles are cached per content, and guests run
// many times. OptLevel::None saved only 12.7% of compile time (T15) but slows every run.
let mut cache = wasmtime::CacheConfig::new();
cache.with_directory(directory);
config.cache(Some(wasmtime::Cache::new(cache)?));
Ok(Arc::new(Self {
engine: Engine::new(&config)?,
programs: Mutex::new(HashMap::new()),
#[cfg(test)]
before_compile: Mutex::new(None),
#[cfg(test)]
compilations: std::sync::atomic::AtomicUsize::new(0),
}))
}
pub async fn get(self: &Arc<Self>, bytes: Vec<u8>) -> anyhow::Result<Compiled> {
let mut result = self
.programs
.lock()
.unwrap()
.entry(bytes.clone())
.or_insert_with(|| {
let (tx, rx) = watch::channel(None);
let compiler = self.clone();
// The job owns completion even if a command or a precompile waiter is cancelled.
tokio::spawn(async move {
let result =
tokio::task::spawn_blocking(move || compiler.compile(&bytes)).await;
tx.send_replace(Some(
result
.map_err(anyhow::Error::from)
.and_then(|r| r)
.map_err(|e| format!("{e:#}")),
));
});
rx
})
.clone();
loop {
if let Some(result) = result.borrow().clone() {
return result.map_err(|e| anyhow::anyhow!(e));
}
result.changed().await?;
}
}
fn compile(&self, bytes: &[u8]) -> anyhow::Result<Compiled> {
#[cfg(test)]
{
self.compilations
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if let Some(hook) = self.before_compile.lock().unwrap().take() {
hook();
}
}
if bytes.get(..8) == Some(b"\0asm\x01\0\0\0") {
let module = wasmtime::Module::new(&self.engine, bytes)?;
let mut linker = wasmtime::Linker::new(&self.engine);
sausage_vfs::preview1::add_to_linker(&mut linker, |s: &mut State| {
s.preview1.as_mut().unwrap()
})?;
Ok(Compiled::Module(linker.instantiate_pre(&module)?))
} else {
let component = Component::new(&self.engine, bytes)?;
let mut linker = Linker::new(&self.engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
adapter::add_to_linker(&mut linker, |s: &mut State| adapter::View {
adapter: &mut s.fs,
table: &mut s.table,
})?;
bindings::sausage::process::host::add_to_linker::<_, wasmtime::component::HasSelf<_>>(
&mut linker,
|s| s,
)?;
Ok(Compiled::Component(CommandPre::new(
linker.instantiate_pre(&component)?,
)?))
}
}
}
/// Compile programs in both command folders without blocking world startup.
pub(super) fn precompile(
compiler: Arc<Compiler>,
root: Arc<dyn sausage_vfs::Layer>,
) -> tokio::task::JoinHandle<anyhow::Result<usize>> {
tokio::spawn(async move {
let mut tasks = tokio::task::JoinSet::new();
// Leave CPU and file bandwidth for commands during startup.
let slots = Arc::new(tokio::sync::Semaphore::new(2));
for folder in ["/bin", "/usr/local/bin"] {
let entries = match root.list(folder).await {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e.into()),
};
for entry in entries.into_iter().filter(|entry| !entry.directory) {
let root = root.clone();
let compiler = compiler.clone();
let path = format!("{folder}/{}", entry.name);
let slots = slots.clone();
tasks.spawn(async move {
let _permit = slots.acquire().await?;
let file = root
.open(
&path,
sausage_vfs::Open {
follow: true,
..Default::default()
},
)
.await?;
let bytes = file.read(0, file.stat().await?.size as usize).await?;
if bytes.get(..4) != Some(b"\0asm") {
return Ok::<_, anyhow::Error>(false);
}
compiler.get(bytes).await?;
Ok(true)
});
}
}
let mut count = 0;
while let Some(result) = tasks.join_next().await {
if result?? {
count += 1;
}
}
Ok(count)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::exec::{Command, Exec, Options};
use std::{future::Future, sync::atomic::Ordering};
#[tokio::test(flavor = "multi_thread")]
async fn command_joins_precompile_even_after_waiter_cancellation() {
let temp = tempfile::tempdir().unwrap();
let compiler = Compiler::new(&temp.path().join("cache")).unwrap();
let world = crate::world::World::open(&temp.path().join("world")).unwrap();
let bytes =
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "_start")))"#)
.unwrap();
std::fs::create_dir_all(temp.path().join("world/world/bin")).unwrap();
std::fs::write(temp.path().join("world/world/bin/program.wasm"), &bytes).unwrap();
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (finish_tx, finish_rx) = std::sync::mpsc::channel();
*compiler.before_compile.lock().unwrap() = Some(Box::new(move || {
started_tx.send(()).unwrap();
finish_rx.recv().unwrap();
}));
// Startup returns while the compiler remains blocked behind the test gate.
let runtime = super::super::Runtime::with_compiler(world, compiler.clone()).unwrap();
started_rx.await.unwrap();
let background = compiler.clone();
let precompile = tokio::spawn(async move { background.get(bytes).await });
let mut output = |_: crate::exec::Stream, _: &[u8]| {};
let command = runtime.exec(
Command::Argv(vec!["/bin/program".into()]),
Options::default(),
&mut output,
);
tokio::pin!(command);
let pending = std::future::poll_fn(|cx| {
std::task::Poll::Ready(command.as_mut().poll(cx).is_pending())
})
.await;
assert!(pending);
assert_eq!(compiler.compilations.load(Ordering::Relaxed), 1);
precompile.abort();
let _ = precompile.await;
finish_tx.send(()).unwrap();
assert_eq!(command.await.unwrap(), 0);
assert_eq!(compiler.compilations.load(Ordering::Relaxed), 1);
assert!(!temp.path().join("world/host/compile-cache").exists());
}
}
+60 -47
View File
@@ -5,7 +5,7 @@ use crate::{
};
use sausage_vfs::{
Mounts,
adapter::{self, Adapter, View},
adapter::{self, Adapter},
};
use std::{
collections::HashMap,
@@ -22,7 +22,7 @@ use tokio::{
};
use wasmtime::{
Engine, Store,
component::{Component, Linker, Resource, ResourceTable},
component::{Resource, ResourceTable},
};
use wasmtime_wasi::{
WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView,
@@ -46,6 +46,8 @@ mod bindings {
});
}
use bindings::sausage::process::host::{Host, HostChild, Request, Stdio};
mod cache;
pub use cache::configure as configure_cache;
mod services;
mod watcher;
pub use watcher::Watcher;
@@ -80,7 +82,8 @@ struct Entry {
pub struct Runtime {
world: World,
engine: Engine,
cache: Mutex<HashMap<Vec<u8>, Compiled>>,
compiler: Arc<cache::Compiler>,
cache: Mutex<std::collections::HashSet<Vec<u8>>>,
entries: Mutex<HashMap<u32, Entry>>,
next: AtomicU32,
alive: Arc<AtomicBool>,
@@ -141,14 +144,14 @@ impl Runtime {
Self::new(World::open(path)?)
}
pub fn new(world: World) -> anyhow::Result<Arc<Self>> {
// Deno and WASI HTTP enable different providers. Select one before HTTPS creates a client.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let mut config = wasmtime::Config::new();
config.epoch_interruption(true);
let mut cache = wasmtime::CacheConfig::new();
cache.with_directory(world.host_dir.join("compile-cache"));
config.cache(Some(wasmtime::Cache::new(cache)?));
let engine = Engine::new(&config)?;
Self::with_compiler(world, cache::shared()?)
}
/// Open an independent engine using an explicit shared disk cache.
pub fn new_with_cache(world: World, directory: &Path) -> anyhow::Result<Arc<Self>> {
Self::with_compiler(world, cache::Compiler::new(directory)?)
}
fn with_compiler(world: World, compiler: Arc<cache::Compiler>) -> anyhow::Result<Arc<Self>> {
let engine = compiler.engine.clone();
let alive = Arc::new(AtomicBool::new(true));
let ticking = alive.clone();
let clock = engine.clone();
@@ -158,19 +161,57 @@ impl Runtime {
clock.increment_epoch();
}
});
Ok(Arc::new(Self {
let runtime = Arc::new(Self {
world,
engine,
alive,
cache: Mutex::new(HashMap::new()),
compiler,
cache: Mutex::new(Default::default()),
entries: Mutex::new(HashMap::new()),
next: AtomicU32::new(1),
}))
});
cache::precompile(runtime.compiler.clone(), runtime.world.root.clone());
Ok(runtime)
}
/// Number of distinct program contents compiled by this runtime.
pub fn compiled_programs(&self) -> usize {
self.cache.lock().unwrap().len()
}
async fn resolve_program(
&self,
cwd: &str,
name: &str,
env: &[(String, String)],
) -> anyhow::Result<String> {
let paths = if name.contains('/') {
vec![sausage_vfs::resolve(cwd, name)]
} else {
let path = env
.iter()
.find(|(key, _)| key == "PATH")
.map(|(_, value)| value.as_str())
.unwrap_or("/usr/local/bin:/bin");
path.split(':')
.map(|folder| {
sausage_vfs::resolve(
cwd,
&std::path::Path::new(folder).join(name).to_string_lossy(),
)
})
.collect()
};
for path in paths {
for candidate in [path.clone(), format!("{path}.wasm")] {
match self.world.root.stat(&candidate, true).await {
Ok(stat) if !stat.directory => return Ok(candidate),
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
}
}
anyhow::bail!("program not found: {name}")
}
fn kill_command(&self, command: u32) {
for entry in self.entries.lock().unwrap().values() {
if entry.command == command {
@@ -225,7 +266,9 @@ impl Runtime {
request.cwd = sausage_vfs::resolve("/", &request.cwd);
request.env.retain(|(name, _)| name != "PWD");
request.env.push(("PWD".into(), request.cwd.clone()));
let executable = sausage_vfs::resolve(&request.cwd, &request.program);
let executable = self
.resolve_program(&request.cwd, &request.program, &request.env)
.await?;
let file = self
.world
.root
@@ -240,38 +283,8 @@ impl Runtime {
let bytes = file
.read(0, usize::try_from(file.stat().await?.size)?)
.await?;
let pre = {
let mut cache = self.cache.lock().unwrap();
if let Some(pre) = cache.get(&bytes) {
pre.clone()
} else {
let pre = if bytes.get(..8) == Some(b"\0asm\x01\0\0\0") {
let module = wasmtime::Module::new(&self.engine, &bytes)?;
let mut linker = wasmtime::Linker::new(&self.engine);
sausage_vfs::preview1::add_to_linker(&mut linker, |s: &mut State| {
s.preview1.as_mut().unwrap()
})?;
Compiled::Module(linker.instantiate_pre(&module)?)
} else {
let component = Component::new(&self.engine, &bytes)?;
let mut linker = Linker::new(&self.engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
// The library linker includes exit-with-code. Only Wasmtime's CLI gates it.
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
adapter::add_to_linker(&mut linker, |s: &mut State| View {
adapter: &mut s.fs,
table: &mut s.table,
})?;
bindings::sausage::process::host::add_to_linker::<
_,
wasmtime::component::HasSelf<_>,
>(&mut linker, |s| s)?;
Compiled::Component(CommandPre::new(linker.instantiate_pre(&component)?)?)
};
cache.insert(bytes, pre.clone());
pre
}
};
let pre = self.compiler.get(bytes.clone()).await?;
self.cache.lock().unwrap().insert(bytes);
let pid = self.next.fetch_add(1, Ordering::Relaxed);
let command = if command == 0 { pid } else { command };
let group = match request.group {
+23 -9
View File
@@ -19,17 +19,37 @@ pub struct World {
pub processes: crate::process::Processes,
}
fn copy_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(destination)?;
for entry in std::fs::read_dir(source)? {
let entry = entry?;
let target = destination.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_tree(&entry.path(), &target)?;
} else {
std::fs::copy(entry.path(), target)?;
}
}
Ok(())
}
impl World {
/// Open (and create if missing) the world directory `dir`, including `/proc` and `/run` mounts.
pub fn open(dir: &Path) -> anyhow::Result<Self> {
Self::open_with_userland(dir, None)
}
/// Mount an optional host bundle read-only at `/bin` (D43).
/// Copy an optional bundle into a new world. Existing worlds retain their programs.
pub fn open_with_userland(dir: &Path, userland: Option<&Path>) -> anyhow::Result<Self> {
let root_dir = dir.join("world");
let host_dir = dir.join("host");
let fresh = !root_dir.exists();
std::fs::create_dir_all(root_dir.join("usr/local/bin"))?;
if fresh && let Some(bundle) = userland {
for folder in ["bin", "src"] {
copy_tree(&bundle.join(folder), &root_dir.join(folder))?;
}
}
std::fs::create_dir_all(&host_dir)?;
let mut mounts = Mounts::default();
mounts.mount(
@@ -55,17 +75,11 @@ impl World {
}
Ok(text.into_bytes())
});
if let Some(bundle) = userland {
mounts.mount(
"/bin",
Arc::new(Host::new(bundle, Process::default())?),
true,
);
}
mounts.mount("/proc", Arc::new(proc), true);
mounts.mount("/run", Arc::new(Memory::default()), false);
let root: Arc<dyn Layer> = Arc::new(mounts);
Ok(World {
root: Arc::new(mounts),
root,
host_dir,
processes,
})