//! 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, pub hosts: Vec, pub path_prefixes: Vec, pub methods: Option>, } /// 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, ) -> 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::().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, #[serde(default)] pub needs_value: bool, pub field_type: FieldType, pub key_type: Option, } 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, pub validate_js: Option, pub transform_js: String, pub fields: Vec, } pub(crate) fn load(db: &Connection, id: &str) -> Result { let json: Option = 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::().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> { 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 { 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, pub key_type: Option, } 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, ) -> Result { 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 { load(&self.access()?.db, id) } pub fn item_list(&self) -> Result> { 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> { 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, ) -> 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, ) -> 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 { 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 { 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> = 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()) }