Files
sausage/host/src/cred/items.rs
T

509 lines
17 KiB
Rust

//! 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,
#[serde(default)]
pub port: Option<u16>,
pub hosts: Vec<String>,
pub path_prefixes: Vec<String>,
pub methods: Option<Vec<String>>,
}
/// Match a request on the scheme's default port.
pub fn rule_matches(r: &Rule, scheme: &str, host: &str, path: &str, method: &str) -> bool {
rule_matches_port(r, scheme, host, path, method, None)
}
/// Missing ports mean the scheme's default, not any port.
pub fn rule_matches_port(
r: &Rule,
scheme: &str,
host: &str,
path: &str,
method: &str,
port: Option<u16>,
) -> bool {
let default = match scheme.to_ascii_lowercase().as_str() {
"http" => Some(80),
"https" => Some(443),
"ssh" => Some(22),
_ => None,
};
let host = host.trim_start_matches('[').trim_end_matches(']');
safe_path(path)
&& r.port.or(default) == port.or(default)
&& 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)))
}
pub(crate) fn safe_path(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
!path.contains('\\')
&& !lower.contains("%2e")
&& !lower.contains("%2f")
&& !lower.contains("%5c")
&& !path.split('/').any(|s| s == "." || s == "..")
}
fn local_host(host: &str) -> bool {
let host = host.strip_prefix("*.").unwrap_or(host);
host.eq_ignore_ascii_case("localhost")
|| host.to_ascii_lowercase().ends_with(".localhost")
|| host.parse::<std::net::IpAddr>().is_ok_and(|ip| match ip {
std::net::IpAddr::V4(ip) => ip.is_loopback() || ip.is_link_local(),
std::net::IpAddr::V6(ip) => {
ip.is_loopback()
|| ip.is_unicast_link_local()
|| ip
.to_ipv4_mapped()
.is_some_and(|v4| v4.is_loopback() || v4.is_link_local())
}
})
}
#[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.port == Some(0)
|| (r.port.is_none() && r.hosts.iter().any(|h| local_host(h)))
|| r.path_prefixes
.iter()
.any(|p| !p.starts_with('/') || !safe_path(p))
|| 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);
p.eq_ignore_ascii_case("host.sausage")
|| 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(())
}
/// Report every item whose rules can match the same outgoing request.
pub(crate) fn overlaps(db: &Connection, item: &Item) -> Result<Vec<String>> {
let mut q = db.prepare("SELECT config FROM items WHERE id<>? ORDER BY id")?;
let mut ids = Vec::new();
for row in q.query_map([&item.id], |r| r.get::<_, String>(0))? {
let other: Item = serde_json::from_str(&row?)?;
if item
.rules
.iter()
.any(|a| other.rules.iter().any(|b| rules_overlap(a, b)))
{
ids.push(other.id);
}
}
Ok(ids)
}
fn rules_overlap(a: &Rule, b: &Rule) -> bool {
let default = match a.scheme.to_ascii_lowercase().as_str() {
"http" => Some(80),
"https" => Some(443),
"ssh" => Some(22),
_ => None,
};
a.scheme.eq_ignore_ascii_case(&b.scheme)
&& a.port.or(default) == b.port.or(default)
&& a.path_prefixes.iter().any(|p| {
b.path_prefixes
.iter()
.any(|q| p.starts_with(q) || q.starts_with(p))
})
&& match (&a.methods, &b.methods) {
(Some(a), Some(b)) => a
.iter()
.any(|m| b.iter().any(|n| m.eq_ignore_ascii_case(n))),
(Some(m), None) | (None, Some(m)) => !m.is_empty(),
(None, None) => true,
}
&& a.hosts.iter().any(|p| {
b.hosts.iter().any(|q| {
let p = p.to_ascii_lowercase();
let q = q.to_ascii_lowercase();
if p == q {
return true;
}
match (p.strip_prefix("*."), q.strip_prefix("*.")) {
(Some(p), Some(q)) => {
p.ends_with(&format!(".{q}")) || q.ends_with(&format!(".{p}"))
}
(Some(p), None) => q.ends_with(&format!(".{p}")),
(None, Some(q)) => p.ends_with(&format!(".{q}")),
(None, None) => false,
}
})
})
}
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,
}
/// Plain entry is bootstrap-only. Verified entry decrypts the exact bound envelope here.
pub enum FieldValue<'a> {
Plain(&'a str),
Encrypted {
key: &'a super::entry::EntryKey,
envelope: &'a super::entry::Envelope,
},
}
/// A secret-entry value with optional replacement metadata. Debug never includes the value.
pub struct FieldInput<'a> {
pub value: FieldValue<'a>,
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 {
/// Create an item only with an exact proof or a transaction-checked bootstrap value.
pub fn create_item(
&self,
item: Item,
caller: &Caller,
proof: impl Into<super::webauthn::Proof>,
) -> Result<Item> {
let mut s = self.access()?;
let tx =
s.db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
validate(&item)?;
let binding =
super::webauthn::Binding::mutation(super::webauthn::Mutation::CreateItem, &item)?;
let approver = proof.into().check(&tx, &binding)?;
let item = insert_new(&tx, item, caller, approver.as_deref())?;
tx.commit()?;
s.refresh_rules()?;
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,
proof: impl Into<super::webauthn::Proof>,
) -> Result<()> {
self.set_field_input(
item,
field,
&FieldInput {
value: FieldValue::Plain(value),
field_type: None,
key_type: None,
},
caller,
proof,
)
}
pub fn set_field_input(
&self,
item: &str,
field: &str,
input: &FieldInput<'_>,
caller: &Caller,
proof: impl Into<super::webauthn::Proof>,
) -> Result<()> {
let mut s = self.access()?;
let key = s.master.clone();
let tx =
s.db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let proof = proof.into();
if matches!(&proof, super::webauthn::Proof::Verified(_))
&& matches!(&input.value, FieldValue::Plain(_))
{
return Err(Error("binding_mismatch"));
}
let hash = match &input.value {
FieldValue::Plain(_) => String::new(),
FieldValue::Encrypted { envelope, .. } => envelope.sha256(),
};
let approver = proof.check(
&tx,
&super::webauthn::Binding::SecretEntry {
item: item.into(),
field: field.into(),
ciphertext_sha256: hash,
field_type: input.field_type.clone(),
key_type: input.key_type.clone(),
},
)?;
let decrypted;
let value = match &input.value {
FieldValue::Plain(value) => *value,
FieldValue::Encrypted { key, envelope } => {
decrypted = zeroize::Zeroizing::new(
key.decrypt(envelope)
.map_err(|_| Error("secret_envelope"))?,
);
std::str::from_utf8(&decrypted).map_err(|_| Error("field_invalid"))?
}
};
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), approver.as_deref())?;
tx.commit()?;
s.refresh_rules()?;
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())
}