use crate::db;
use crate::secret::{random_bytes, sha256_hex};
use anyhow::Result;
use ed25519_dalek::{Signature, VerifyingKey};
use hennery_proto::frames::{AgentIsolation, Capabilities};
use rusqlite::{Connection, OptionalExtension, params};
use std::path::Path;
pub const PAIRING_CODE_TTL_SECS: i64 = 10 * 60;
pub const MAX_LIVE_PAIRING_CODES: usize = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TooManyPairingCodes;
impl std::fmt::Display for TooManyPairingCodes {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{MAX_LIVE_PAIRING_CODES} pairing codes are live already; use one or wait for one to expire"
)
}
}
impl std::error::Error for TooManyPairingCodes {}
const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const MAX_FIELD: usize = 64;
pub const MAX_ROOTS: usize = 32;
pub const MAX_AGENTS: usize = 32;
pub const MAX_PATH: usize = 4096;
#[derive(Debug, Clone, PartialEq)]
pub struct PairingCode {
pub code: String,
pub expires_at: i64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Enrollment {
pub public_key: String,
pub name: String,
pub host_version: String,
pub platform: String,
}
impl Enrollment {
pub fn problem(&self) -> Option<String> {
if parse_public_key(&self.public_key).is_none() {
return Some("public_key must be an Ed25519 public key in hex".into());
}
for (field, value) in [
("name", &self.name),
("host_version", &self.host_version),
("platform", &self.platform),
] {
if !is_valid_display_field(value) {
return Some(format!("{field} must be 1 to {MAX_FIELD} printable characters"));
}
}
None
}
}
pub(crate) fn is_valid_display_field(value: &str) -> bool {
let value = value.trim();
!value.is_empty()
&& value.chars().count() <= MAX_FIELD
&& !value.chars().any(|c| c.is_control() || is_format_char(c))
}
#[derive(Debug, Clone, PartialEq)]
pub enum EnrollOutcome {
Enrolled {
host_id: String,
},
InvalidCode,
AlreadyPaired {
host_id: String,
},
Invalid(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Registered {
Created,
AlreadyPaired { host_id: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HelloCheck {
Accepted,
Revoked,
BadProof,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Revoke {
Revoked,
AlreadyRevoked,
NotFound,
}
#[derive(Debug, Clone, PartialEq)]
pub struct HostRecord {
pub id: String,
pub name: String,
pub platform: String,
pub host_version: String,
pub capabilities: Capabilities,
pub default_hat_id: String,
pub workspace_roots: Vec<String>,
pub created_at: i64,
pub last_seen_at: Option<i64>,
pub revoked_at: Option<i64>,
pub mcp_isolation: Option<AgentIsolation>,
}
pub fn is_displayable_text(text: &str, max: usize) -> bool {
text.len() <= max && !text.chars().any(|c| c.is_control() || is_format_char(c))
}
pub fn is_displayable_path(path: &str) -> bool {
path.starts_with('/') && is_displayable_text(path, MAX_PATH)
}
pub(crate) fn is_format_char(c: char) -> bool {
matches!(c,
'\u{00AD}' | '\u{061C}' | '\u{180E}' | '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}'
| '\u{2060}'..='\u{2064}' | '\u{2066}'..='\u{206F}' | '\u{FEFF}' | '\u{FFF9}'..='\u{FFFB}')
}
pub fn normalize_code(input: &str) -> Option<String> {
let mut out = String::with_capacity(8);
for c in input.chars() {
let c = match c.to_ascii_uppercase() {
'-' | ' ' | '\t' => continue,
'O' => '0',
'I' | 'L' => '1',
c => c,
};
if !c.is_ascii() || !ALPHABET.contains(&(c as u8)) {
return None;
}
out.push(c);
}
(out.len() == 8).then_some(out)
}
fn new_code() -> String {
let bits = u64::from_be_bytes({
let mut b = [0u8; 8];
b[3..].copy_from_slice(&random_bytes::<5>());
b
});
let mut out = String::with_capacity(9);
for i in 0..8 {
if i == 4 {
out.push('-');
}
let index = (bits >> (35 - 5 * i)) & 0x1f;
out.push(ALPHABET[index as usize] as char);
}
out
}
fn code_hash(normalized: &str) -> String {
sha256_hex(normalized.as_bytes())
}
pub fn parse_public_key(hex_key: &str) -> Option<VerifyingKey> {
let bytes: [u8; 32] = hex::decode(hex_key).ok()?.try_into().ok()?;
let key = VerifyingKey::from_bytes(&bytes).ok()?;
(!key.is_weak()).then_some(key)
}
pub fn verify_proof(public_key: &str, nonce: &[u8], host_id: &str, protocol_version: &str, proof: &str) -> bool {
let Some(key) = parse_public_key(public_key) else {
return false;
};
let Some(signature) = hex::decode(proof)
.ok()
.and_then(|b| <[u8; 64]>::try_from(b).ok())
.map(|b| Signature::from_bytes(&b))
else {
return false;
};
let message = hennery_proto::hello_proof_message(nonce, host_id, protocol_version);
key.verify_strict(&message, &signature).is_ok()
}
pub struct Hosts {
conn: crate::db::CoordinatedConnection,
owner: String,
}
impl Hosts {
pub fn open(path: &Path) -> Result<Self> {
Self::init(db::open(path)?)
}
pub fn open_in_memory() -> Result<Self> {
Self::init(db::open_in_memory()?)
}
fn init(mut conn: crate::db::OwnedConnection) -> Result<Self> {
let owner = db::kernel_owner(&mut conn)?;
Ok(Self {
conn: crate::db::CoordinatedConnection::new(conn),
owner,
})
}
pub(crate) fn conn(&self) -> crate::db::ConnectionGuard<'_> {
self.conn.lock().expect("hosts lock")
}
pub fn owner_id(&self) -> &str {
&self.owner
}
pub fn mint_pairing_code(&self, now: i64) -> Result<PairingCode> {
self.mint(now, true)
}
pub fn mint_local_pairing_code(&self, now: i64) -> Result<PairingCode> {
self.mint(now, false)
}
fn mint(&self, now: i64, capped: bool) -> Result<PairingCode> {
let code = new_code();
let expires_at = now + PAIRING_CODE_TTL_SECS;
let normalized = normalize_code(&code).expect("a minted code is canonical");
let mut conn = self.conn();
let tx = conn.transaction()?;
tx.execute(
"DELETE FROM pairing_codes WHERE (used_at IS NOT NULL OR expires_at <= ?1) AND owner_id = ?2",
params![now, self.owner],
)?;
let live: i64 = tx.query_row(
"SELECT count(*) FROM pairing_codes WHERE owner_id = ?1",
[&self.owner],
|r| r.get(0),
)?;
if capped && live >= MAX_LIVE_PAIRING_CODES as i64 {
tx.commit()?;
return Err(TooManyPairingCodes.into());
}
tx.execute(
"INSERT INTO pairing_codes(code_hash, owner_id, created_at, expires_at) VALUES (?1, ?2, ?3, ?4)",
params![code_hash(&normalized), self.owner, now, expires_at],
)?;
tx.commit()?;
Ok(PairingCode { code, expires_at })
}
pub fn enroll(&self, code: &str, enrollment: &Enrollment, now: i64) -> Result<EnrollOutcome> {
if let Some(problem) = enrollment.problem() {
return Ok(EnrollOutcome::Invalid(problem));
}
let Some(normalized) = normalize_code(code) else {
return Ok(EnrollOutcome::InvalidCode);
};
let hash = code_hash(&normalized);
let mut conn = self.conn();
let tx = conn.transaction()?;
let usable = tx
.query_row(
"SELECT 1 FROM pairing_codes
WHERE code_hash = ?1 AND used_at IS NULL AND expires_at > ?2 AND owner_id = ?3",
params![hash, now, self.owner],
|_| Ok(()),
)
.optional()?
.is_some();
if !usable {
return Ok(EnrollOutcome::InvalidCode);
}
let host_id = new_host_id();
match insert_host(&tx, &self.owner, &host_id, enrollment, now)? {
Registered::Created => {}
Registered::AlreadyPaired { host_id } => return Ok(EnrollOutcome::AlreadyPaired { host_id }),
}
let spent = tx.execute(
"UPDATE pairing_codes SET used_at = ?2
WHERE code_hash = ?1 AND used_at IS NULL AND expires_at > ?2 AND owner_id = ?3",
params![hash, now, self.owner],
)?;
if spent == 0 {
return Ok(EnrollOutcome::InvalidCode);
}
tx.commit()?;
Ok(EnrollOutcome::Enrolled { host_id })
}
pub fn register(&self, host_id: &str, enrollment: &Enrollment, now: i64) -> Result<Registered> {
anyhow::ensure!(
enrollment.problem().is_none(),
"invalid enrollment: {:?}",
enrollment.problem()
);
let mut conn = self.conn();
let tx = conn.transaction()?;
let registered = insert_host(&tx, &self.owner, host_id, enrollment, now)?;
tx.commit()?;
Ok(registered)
}
pub fn check_hello(&self, host_id: &str, nonce: &[u8], protocol_version: &str, proof: &str) -> Result<HelloCheck> {
let row: Option<(String, Option<i64>)> = self
.conn()
.query_row(
"SELECT public_key, revoked_at FROM hosts WHERE id = ?1 AND owner_id = ?2",
[host_id, &self.owner],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let Some((public_key, revoked_at)) = row else {
return Ok(HelloCheck::BadProof);
};
if !verify_proof(&public_key, nonce, host_id, protocol_version, proof) {
return Ok(HelloCheck::BadProof);
}
Ok(if revoked_at.is_some() {
HelloCheck::Revoked
} else {
HelloCheck::Accepted
})
}
pub fn record_hello(&self, host_id: &str, host_version: &str, capabilities: &Capabilities, now: i64) -> Result<()> {
let capabilities = serde_json::to_string(capabilities)?;
if is_valid_display_field(host_version) {
self.conn().execute(
"UPDATE hosts SET host_version = ?2, capabilities = ?3, last_seen_at = ?4
WHERE id = ?1 AND owner_id = ?5",
params![host_id, host_version.trim(), capabilities, now, self.owner],
)?;
} else {
tracing::warn!(
%host_id,
?host_version,
"hello reported a malformed host_version; keeping the one already stored"
);
self.conn().execute(
"UPDATE hosts SET capabilities = ?2, last_seen_at = ?3 WHERE id = ?1 AND owner_id = ?4",
params![host_id, capabilities, now, self.owner],
)?;
}
Ok(())
}
pub fn record_mcp_isolation(&self, host_id: &str, isolation: &AgentIsolation) -> Result<()> {
let kept = AgentIsolation(
isolation
.0
.iter()
.filter(|(agent, _)| !agent.is_empty() && is_displayable_text(agent, 64))
.take(MAX_AGENTS)
.map(|(agent, how)| (agent.clone(), *how))
.collect(),
);
self.conn().execute(
"UPDATE hosts SET mcp_isolation = ?2 WHERE id = ?1 AND owner_id = ?3",
params![host_id, serde_json::to_string(&kept)?, self.owner],
)?;
Ok(())
}
pub fn record_workspace_roots(&self, host_id: &str, reported: &[String]) -> Result<()> {
let kept: Vec<&String> = reported
.iter()
.filter(|root| is_displayable_path(root))
.take(MAX_ROOTS)
.collect();
if kept.len() != reported.len() {
tracing::warn!(
%host_id,
dropped = reported.len() - kept.len(),
"dropped workspace roots that are not absolute, too long, too many or hold hidden characters"
);
}
self.conn().execute(
"UPDATE hosts SET workspace_roots = ?2 WHERE id = ?1 AND revoked_at IS NULL AND owner_id = ?3",
params![host_id, serde_json::to_string(&kept)?, self.owner],
)?;
Ok(())
}
pub fn is_revoked(&self, host_id: &str) -> Result<bool> {
let revoked: Option<Option<i64>> = self
.conn()
.query_row(
"SELECT revoked_at FROM hosts WHERE id = ?1 AND owner_id = ?2",
[host_id, &self.owner],
|r| r.get(0),
)
.optional()?;
Ok(matches!(revoked, Some(Some(_))))
}
pub fn revoke(&self, host_id: &str, now: i64) -> Result<Revoke> {
let conn = self.conn();
let revoked: Option<Option<i64>> = conn
.query_row(
"SELECT revoked_at FROM hosts WHERE id = ?1 AND owner_id = ?2",
[host_id, &self.owner],
|r| r.get(0),
)
.optional()?;
Ok(match revoked {
None => Revoke::NotFound,
Some(Some(_)) => Revoke::AlreadyRevoked,
Some(None) => {
if try_revoke(&conn, &self.owner, host_id, now)? {
Revoke::Revoked
} else {
Revoke::AlreadyRevoked
}
}
})
}
pub fn host(&self, host_id: &str) -> Result<Option<HostRecord>> {
let conn = self.conn();
let mut stmt = conn.prepare(&format!(
"SELECT {HOST_COLUMNS} FROM hosts WHERE id = ?1 AND owner_id = ?2"
))?;
Ok(stmt.query_row([host_id, &self.owner], read_host).optional()?)
}
pub fn list(&self) -> Result<Vec<HostRecord>> {
let conn = self.conn();
let mut stmt = conn.prepare(&format!(
"SELECT {HOST_COLUMNS} FROM hosts WHERE owner_id = ?1 ORDER BY created_at, id"
))?;
let rows = stmt.query_map([&self.owner], read_host)?;
Ok(rows.collect::<rusqlite::Result<_>>()?)
}
}
const HOST_COLUMNS: &str = "id, name, platform, host_version, capabilities, default_hat_id, created_at, last_seen_at, revoked_at, workspace_roots, mcp_isolation";
fn read_host(r: &rusqlite::Row<'_>) -> rusqlite::Result<HostRecord> {
let capabilities: String = r.get(4)?;
let workspace_roots: String = r.get(9)?;
let mcp_isolation: Option<String> = r.get(10)?;
Ok(HostRecord {
id: r.get(0)?,
name: r.get(1)?,
platform: r.get(2)?,
host_version: r.get(3)?,
capabilities: serde_json::from_str(&capabilities).unwrap_or_default(),
default_hat_id: r.get(5)?,
created_at: r.get(6)?,
last_seen_at: r.get(7)?,
revoked_at: r.get(8)?,
workspace_roots: serde_json::from_str(&workspace_roots).unwrap_or_default(),
mcp_isolation: mcp_isolation.map(|json| serde_json::from_str(&json).unwrap_or_default()),
})
}
fn new_host_id() -> String {
format!("host-{}", hex::encode(random_bytes::<8>()))
}
fn insert_host(
tx: &rusqlite::Transaction<'_>,
owner: &str,
host_id: &str,
e: &Enrollment,
now: i64,
) -> Result<Registered> {
let public_key = e.public_key.to_ascii_lowercase();
let existing: Option<String> = tx
.query_row(
"SELECT id FROM hosts WHERE public_key = ?1 AND owner_id = ?2",
[&public_key, owner],
|r| r.get(0),
)
.optional()?;
if let Some(host_id) = existing {
return Ok(Registered::AlreadyPaired { host_id });
}
let inserted = tx.execute(
"INSERT INTO hosts(id, owner_id, name, public_key, platform, host_version, default_hat_id, created_at)
SELECT ?1, ?2, ?3, ?4, ?5, ?6, h.id, ?7
FROM settings s JOIN hats h ON h.id = s.value AND h.owner_id = s.owner_id
WHERE s.owner_id = ?2 AND s.key = ?8",
params![
host_id,
owner,
e.name.trim(),
public_key,
e.platform.trim(),
e.host_version.trim(),
now,
crate::hats::DEFAULT_HAT_KEY
],
)?;
anyhow::ensure!(inserted == 1, "the owner has no default hat for new hosts");
Ok(Registered::Created)
}
fn try_revoke(conn: &Connection, owner: &str, host_id: &str, now: i64) -> Result<bool> {
let changed = conn.execute(
"UPDATE hosts SET revoked_at = ?2 WHERE id = ?1 AND revoked_at IS NULL AND owner_id = ?3",
params![host_id, now, owner],
)?;
Ok(changed > 0)
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
const NOW: i64 = 1_800_000_000;
fn enrollment(seed: u8) -> Enrollment {
Enrollment {
public_key: hex::encode(SigningKey::from_bytes(&[seed; 32]).verifying_key().as_bytes()),
name: "laptop".into(),
host_version: "0.0.0".into(),
platform: "macos-aarch64".into(),
}
}
#[test]
fn try_revoke_matches_nothing_once_another_writer_already_revoked() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hennery.db");
let hosts = Hosts::open(&path).unwrap();
hosts.register("host-1", &enrollment(1), NOW).unwrap();
let second = db::open(&path).unwrap();
assert_eq!(hosts.revoke("host-1", 100).unwrap(), Revoke::Revoked);
assert!(!try_revoke(&second, hosts.owner_id(), "host-1", 200).unwrap());
assert_eq!(hosts.host("host-1").unwrap().unwrap().revoked_at, Some(100));
}
}