509 lines
17 KiB
Rust
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())
|
|
}
|