1use crate::db;
12use crate::secret::{random_bytes, sha256_hex};
13use anyhow::Result;
14use ed25519_dalek::{Signature, VerifyingKey};
15use hennery_proto::frames::{AgentIsolation, Capabilities};
16use rusqlite::{Connection, OptionalExtension, params};
17use std::path::Path;
18
19pub const PAIRING_CODE_TTL_SECS: i64 = 10 * 60;
21
22pub const MAX_LIVE_PAIRING_CODES: usize = 16;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct TooManyPairingCodes;
29
30impl std::fmt::Display for TooManyPairingCodes {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 write!(
33 f,
34 "{MAX_LIVE_PAIRING_CODES} pairing codes are live already; use one or wait for one to expire"
35 )
36 }
37}
38
39impl std::error::Error for TooManyPairingCodes {}
40
41const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
43
44const MAX_FIELD: usize = 64;
46
47pub const MAX_ROOTS: usize = 32;
49
50pub const MAX_AGENTS: usize = 32;
52
53pub const MAX_PATH: usize = 4096;
55
56#[derive(Debug, Clone, PartialEq)]
58pub struct PairingCode {
59 pub code: String,
61 pub expires_at: i64,
62}
63
64#[derive(Debug, Clone, PartialEq)]
66pub struct Enrollment {
67 pub public_key: String,
69 pub name: String,
70 pub host_version: String,
71 pub platform: String,
72}
73
74impl Enrollment {
75 pub fn problem(&self) -> Option<String> {
77 if parse_public_key(&self.public_key).is_none() {
78 return Some("public_key must be an Ed25519 public key in hex".into());
79 }
80 for (field, value) in [
81 ("name", &self.name),
82 ("host_version", &self.host_version),
83 ("platform", &self.platform),
84 ] {
85 if !is_valid_display_field(value) {
86 return Some(format!("{field} must be 1 to {MAX_FIELD} printable characters"));
87 }
88 }
89 None
90 }
91}
92
93pub(crate) fn is_valid_display_field(value: &str) -> bool {
99 let value = value.trim();
100 !value.is_empty()
101 && value.chars().count() <= MAX_FIELD
102 && !value.chars().any(|c| c.is_control() || is_format_char(c))
103}
104
105#[derive(Debug, Clone, PartialEq)]
107pub enum EnrollOutcome {
108 Enrolled {
109 host_id: String,
110 },
111 InvalidCode,
113 AlreadyPaired {
115 host_id: String,
116 },
117 Invalid(String),
119}
120
121#[derive(Debug, Clone, PartialEq)]
123pub enum Registered {
124 Created,
125 AlreadyPaired { host_id: String },
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum HelloCheck {
131 Accepted,
132 Revoked,
134 BadProof,
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum Revoke {
141 Revoked,
142 AlreadyRevoked,
143 NotFound,
144}
145
146#[derive(Debug, Clone, PartialEq)]
148pub struct HostRecord {
149 pub id: String,
150 pub name: String,
151 pub platform: String,
152 pub host_version: String,
153 pub capabilities: Capabilities,
155 pub default_hat_id: String,
157 pub workspace_roots: Vec<String>,
159 pub created_at: i64,
160 pub last_seen_at: Option<i64>,
161 pub revoked_at: Option<i64>,
162 pub mcp_isolation: Option<AgentIsolation>,
165}
166
167pub fn is_displayable_text(text: &str, max: usize) -> bool {
170 text.len() <= max && !text.chars().any(|c| c.is_control() || is_format_char(c))
171}
172
173pub fn is_displayable_path(path: &str) -> bool {
177 path.starts_with('/') && is_displayable_text(path, MAX_PATH)
178}
179
180pub(crate) fn is_format_char(c: char) -> bool {
184 matches!(c,
185 '\u{00AD}' | '\u{061C}' | '\u{180E}' | '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}'
186 | '\u{2060}'..='\u{2064}' | '\u{2066}'..='\u{206F}' | '\u{FEFF}' | '\u{FFF9}'..='\u{FFFB}')
187}
188
189pub fn normalize_code(input: &str) -> Option<String> {
192 let mut out = String::with_capacity(8);
193 for c in input.chars() {
194 let c = match c.to_ascii_uppercase() {
195 '-' | ' ' | '\t' => continue,
196 'O' => '0',
197 'I' | 'L' => '1',
198 c => c,
199 };
200 if !c.is_ascii() || !ALPHABET.contains(&(c as u8)) {
201 return None;
202 }
203 out.push(c);
204 }
205 (out.len() == 8).then_some(out)
206}
207
208fn new_code() -> String {
210 let bits = u64::from_be_bytes({
211 let mut b = [0u8; 8];
212 b[3..].copy_from_slice(&random_bytes::<5>());
213 b
214 });
215 let mut out = String::with_capacity(9);
216 for i in 0..8 {
217 if i == 4 {
218 out.push('-');
219 }
220 let index = (bits >> (35 - 5 * i)) & 0x1f;
221 out.push(ALPHABET[index as usize] as char);
222 }
223 out
224}
225
226fn code_hash(normalized: &str) -> String {
227 sha256_hex(normalized.as_bytes())
228}
229
230pub fn parse_public_key(hex_key: &str) -> Option<VerifyingKey> {
232 let bytes: [u8; 32] = hex::decode(hex_key).ok()?.try_into().ok()?;
233 let key = VerifyingKey::from_bytes(&bytes).ok()?;
234 (!key.is_weak()).then_some(key)
235}
236
237pub fn verify_proof(public_key: &str, nonce: &[u8], host_id: &str, protocol_version: &str, proof: &str) -> bool {
240 let Some(key) = parse_public_key(public_key) else {
241 return false;
242 };
243 let Some(signature) = hex::decode(proof)
244 .ok()
245 .and_then(|b| <[u8; 64]>::try_from(b).ok())
246 .map(|b| Signature::from_bytes(&b))
247 else {
248 return false;
249 };
250 let message = hennery_proto::hello_proof_message(nonce, host_id, protocol_version);
251 key.verify_strict(&message, &signature).is_ok()
252}
253
254pub struct Hosts {
255 conn: crate::db::CoordinatedConnection,
256 owner: String,
258}
259
260impl Hosts {
261 pub fn open(path: &Path) -> Result<Self> {
264 Self::init(db::open(path)?)
265 }
266
267 pub fn open_in_memory() -> Result<Self> {
268 Self::init(db::open_in_memory()?)
269 }
270
271 fn init(mut conn: crate::db::OwnedConnection) -> Result<Self> {
272 let owner = db::kernel_owner(&mut conn)?;
273 Ok(Self {
274 conn: crate::db::CoordinatedConnection::new(conn),
275 owner,
276 })
277 }
278
279 pub(crate) fn conn(&self) -> crate::db::ConnectionGuard<'_> {
280 self.conn.lock().expect("hosts lock")
281 }
282
283 pub fn owner_id(&self) -> &str {
285 &self.owner
286 }
287
288 pub fn mint_pairing_code(&self, now: i64) -> Result<PairingCode> {
293 self.mint(now, true)
294 }
295
296 pub fn mint_local_pairing_code(&self, now: i64) -> Result<PairingCode> {
301 self.mint(now, false)
302 }
303
304 fn mint(&self, now: i64, capped: bool) -> Result<PairingCode> {
305 let code = new_code();
306 let expires_at = now + PAIRING_CODE_TTL_SECS;
307 let normalized = normalize_code(&code).expect("a minted code is canonical");
308 let mut conn = self.conn();
309 let tx = conn.transaction()?;
310 tx.execute(
311 "DELETE FROM pairing_codes WHERE (used_at IS NOT NULL OR expires_at <= ?1) AND owner_id = ?2",
312 params![now, self.owner],
313 )?;
314 let live: i64 = tx.query_row(
315 "SELECT count(*) FROM pairing_codes WHERE owner_id = ?1",
316 [&self.owner],
317 |r| r.get(0),
318 )?;
319 if capped && live >= MAX_LIVE_PAIRING_CODES as i64 {
320 tx.commit()?;
321 return Err(TooManyPairingCodes.into());
322 }
323 tx.execute(
324 "INSERT INTO pairing_codes(code_hash, owner_id, created_at, expires_at) VALUES (?1, ?2, ?3, ?4)",
325 params![code_hash(&normalized), self.owner, now, expires_at],
326 )?;
327 tx.commit()?;
328 Ok(PairingCode { code, expires_at })
329 }
330
331 pub fn enroll(&self, code: &str, enrollment: &Enrollment, now: i64) -> Result<EnrollOutcome> {
346 if let Some(problem) = enrollment.problem() {
347 return Ok(EnrollOutcome::Invalid(problem));
348 }
349 let Some(normalized) = normalize_code(code) else {
350 return Ok(EnrollOutcome::InvalidCode);
351 };
352 let hash = code_hash(&normalized);
353 let mut conn = self.conn();
354 let tx = conn.transaction()?;
355 let usable = tx
356 .query_row(
357 "SELECT 1 FROM pairing_codes
358 WHERE code_hash = ?1 AND used_at IS NULL AND expires_at > ?2 AND owner_id = ?3",
359 params![hash, now, self.owner],
360 |_| Ok(()),
361 )
362 .optional()?
363 .is_some();
364 if !usable {
365 return Ok(EnrollOutcome::InvalidCode);
366 }
367 let host_id = new_host_id();
368 match insert_host(&tx, &self.owner, &host_id, enrollment, now)? {
369 Registered::Created => {}
370 Registered::AlreadyPaired { host_id } => return Ok(EnrollOutcome::AlreadyPaired { host_id }),
371 }
372 let spent = tx.execute(
373 "UPDATE pairing_codes SET used_at = ?2
374 WHERE code_hash = ?1 AND used_at IS NULL AND expires_at > ?2 AND owner_id = ?3",
375 params![hash, now, self.owner],
376 )?;
377 if spent == 0 {
378 return Ok(EnrollOutcome::InvalidCode);
382 }
383 tx.commit()?;
384 Ok(EnrollOutcome::Enrolled { host_id })
385 }
386
387 pub fn register(&self, host_id: &str, enrollment: &Enrollment, now: i64) -> Result<Registered> {
390 anyhow::ensure!(
391 enrollment.problem().is_none(),
392 "invalid enrollment: {:?}",
393 enrollment.problem()
394 );
395 let mut conn = self.conn();
396 let tx = conn.transaction()?;
397 let registered = insert_host(&tx, &self.owner, host_id, enrollment, now)?;
398 tx.commit()?;
399 Ok(registered)
400 }
401
402 pub fn check_hello(&self, host_id: &str, nonce: &[u8], protocol_version: &str, proof: &str) -> Result<HelloCheck> {
406 let row: Option<(String, Option<i64>)> = self
407 .conn()
408 .query_row(
409 "SELECT public_key, revoked_at FROM hosts WHERE id = ?1 AND owner_id = ?2",
410 [host_id, &self.owner],
411 |r| Ok((r.get(0)?, r.get(1)?)),
412 )
413 .optional()?;
414 let Some((public_key, revoked_at)) = row else {
415 return Ok(HelloCheck::BadProof);
416 };
417 if !verify_proof(&public_key, nonce, host_id, protocol_version, proof) {
418 return Ok(HelloCheck::BadProof);
419 }
420 Ok(if revoked_at.is_some() {
421 HelloCheck::Revoked
422 } else {
423 HelloCheck::Accepted
424 })
425 }
426
427 pub fn record_hello(&self, host_id: &str, host_version: &str, capabilities: &Capabilities, now: i64) -> Result<()> {
440 let capabilities = serde_json::to_string(capabilities)?;
441 if is_valid_display_field(host_version) {
442 self.conn().execute(
443 "UPDATE hosts SET host_version = ?2, capabilities = ?3, last_seen_at = ?4
444 WHERE id = ?1 AND owner_id = ?5",
445 params![host_id, host_version.trim(), capabilities, now, self.owner],
446 )?;
447 } else {
448 tracing::warn!(
449 %host_id,
450 ?host_version,
451 "hello reported a malformed host_version; keeping the one already stored"
452 );
453 self.conn().execute(
454 "UPDATE hosts SET capabilities = ?2, last_seen_at = ?3 WHERE id = ?1 AND owner_id = ?4",
455 params![host_id, capabilities, now, self.owner],
456 )?;
457 }
458 Ok(())
459 }
460
461 pub fn record_mcp_isolation(&self, host_id: &str, isolation: &AgentIsolation) -> Result<()> {
467 let kept = AgentIsolation(
468 isolation
469 .0
470 .iter()
471 .filter(|(agent, _)| !agent.is_empty() && is_displayable_text(agent, 64))
472 .take(MAX_AGENTS)
473 .map(|(agent, how)| (agent.clone(), *how))
474 .collect(),
475 );
476 self.conn().execute(
477 "UPDATE hosts SET mcp_isolation = ?2 WHERE id = ?1 AND owner_id = ?3",
478 params![host_id, serde_json::to_string(&kept)?, self.owner],
479 )?;
480 Ok(())
481 }
482
483 pub fn record_workspace_roots(&self, host_id: &str, reported: &[String]) -> Result<()> {
489 let kept: Vec<&String> = reported
490 .iter()
491 .filter(|root| is_displayable_path(root))
492 .take(MAX_ROOTS)
493 .collect();
494 if kept.len() != reported.len() {
495 tracing::warn!(
496 %host_id,
497 dropped = reported.len() - kept.len(),
498 "dropped workspace roots that are not absolute, too long, too many or hold hidden characters"
499 );
500 }
501 self.conn().execute(
502 "UPDATE hosts SET workspace_roots = ?2 WHERE id = ?1 AND revoked_at IS NULL AND owner_id = ?3",
503 params![host_id, serde_json::to_string(&kept)?, self.owner],
504 )?;
505 Ok(())
506 }
507
508 pub fn is_revoked(&self, host_id: &str) -> Result<bool> {
509 let revoked: Option<Option<i64>> = self
510 .conn()
511 .query_row(
512 "SELECT revoked_at FROM hosts WHERE id = ?1 AND owner_id = ?2",
513 [host_id, &self.owner],
514 |r| r.get(0),
515 )
516 .optional()?;
517 Ok(matches!(revoked, Some(Some(_))))
518 }
519
520 pub fn revoke(&self, host_id: &str, now: i64) -> Result<Revoke> {
532 let conn = self.conn();
533 let revoked: Option<Option<i64>> = conn
534 .query_row(
535 "SELECT revoked_at FROM hosts WHERE id = ?1 AND owner_id = ?2",
536 [host_id, &self.owner],
537 |r| r.get(0),
538 )
539 .optional()?;
540 Ok(match revoked {
541 None => Revoke::NotFound,
542 Some(Some(_)) => Revoke::AlreadyRevoked,
543 Some(None) => {
544 if try_revoke(&conn, &self.owner, host_id, now)? {
545 Revoke::Revoked
546 } else {
547 Revoke::AlreadyRevoked
548 }
549 }
550 })
551 }
552
553 pub fn host(&self, host_id: &str) -> Result<Option<HostRecord>> {
554 let conn = self.conn();
555 let mut stmt = conn.prepare(&format!(
556 "SELECT {HOST_COLUMNS} FROM hosts WHERE id = ?1 AND owner_id = ?2"
557 ))?;
558 Ok(stmt.query_row([host_id, &self.owner], read_host).optional()?)
559 }
560
561 pub fn list(&self) -> Result<Vec<HostRecord>> {
563 let conn = self.conn();
564 let mut stmt = conn.prepare(&format!(
565 "SELECT {HOST_COLUMNS} FROM hosts WHERE owner_id = ?1 ORDER BY created_at, id"
566 ))?;
567 let rows = stmt.query_map([&self.owner], read_host)?;
568 Ok(rows.collect::<rusqlite::Result<_>>()?)
569 }
570}
571
572const HOST_COLUMNS: &str = "id, name, platform, host_version, capabilities, default_hat_id, created_at, last_seen_at, revoked_at, workspace_roots, mcp_isolation";
573
574fn read_host(r: &rusqlite::Row<'_>) -> rusqlite::Result<HostRecord> {
575 let capabilities: String = r.get(4)?;
576 let workspace_roots: String = r.get(9)?;
577 let mcp_isolation: Option<String> = r.get(10)?;
578 Ok(HostRecord {
579 id: r.get(0)?,
580 name: r.get(1)?,
581 platform: r.get(2)?,
582 host_version: r.get(3)?,
583 capabilities: serde_json::from_str(&capabilities).unwrap_or_default(),
584 default_hat_id: r.get(5)?,
585 created_at: r.get(6)?,
586 last_seen_at: r.get(7)?,
587 revoked_at: r.get(8)?,
588 workspace_roots: serde_json::from_str(&workspace_roots).unwrap_or_default(),
589 mcp_isolation: mcp_isolation.map(|json| serde_json::from_str(&json).unwrap_or_default()),
591 })
592}
593
594fn new_host_id() -> String {
595 format!("host-{}", hex::encode(random_bytes::<8>()))
596}
597
598fn insert_host(
603 tx: &rusqlite::Transaction<'_>,
604 owner: &str,
605 host_id: &str,
606 e: &Enrollment,
607 now: i64,
608) -> Result<Registered> {
609 let public_key = e.public_key.to_ascii_lowercase();
611 let existing: Option<String> = tx
612 .query_row(
613 "SELECT id FROM hosts WHERE public_key = ?1 AND owner_id = ?2",
614 [&public_key, owner],
615 |r| r.get(0),
616 )
617 .optional()?;
618 if let Some(host_id) = existing {
619 return Ok(Registered::AlreadyPaired { host_id });
620 }
621 let inserted = tx.execute(
622 "INSERT INTO hosts(id, owner_id, name, public_key, platform, host_version, default_hat_id, created_at)
623 SELECT ?1, ?2, ?3, ?4, ?5, ?6, h.id, ?7
624 FROM settings s JOIN hats h ON h.id = s.value AND h.owner_id = s.owner_id
625 WHERE s.owner_id = ?2 AND s.key = ?8",
626 params![
627 host_id,
628 owner,
629 e.name.trim(),
630 public_key,
631 e.platform.trim(),
632 e.host_version.trim(),
633 now,
634 crate::hats::DEFAULT_HAT_KEY
635 ],
636 )?;
637 anyhow::ensure!(inserted == 1, "the owner has no default hat for new hosts");
640 Ok(Registered::Created)
641}
642
643fn try_revoke(conn: &Connection, owner: &str, host_id: &str, now: i64) -> Result<bool> {
651 let changed = conn.execute(
652 "UPDATE hosts SET revoked_at = ?2 WHERE id = ?1 AND revoked_at IS NULL AND owner_id = ?3",
653 params![host_id, now, owner],
654 )?;
655 Ok(changed > 0)
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use ed25519_dalek::SigningKey;
662
663 const NOW: i64 = 1_800_000_000;
664
665 fn enrollment(seed: u8) -> Enrollment {
666 Enrollment {
667 public_key: hex::encode(SigningKey::from_bytes(&[seed; 32]).verifying_key().as_bytes()),
668 name: "laptop".into(),
669 host_version: "0.0.0".into(),
670 platform: "macos-aarch64".into(),
671 }
672 }
673
674 #[test]
681 fn try_revoke_matches_nothing_once_another_writer_already_revoked() {
682 let dir = tempfile::tempdir().unwrap();
683 let path = dir.path().join("hennery.db");
684 let hosts = Hosts::open(&path).unwrap();
685 hosts.register("host-1", &enrollment(1), NOW).unwrap();
686
687 let second = db::open(&path).unwrap();
689
690 assert_eq!(hosts.revoke("host-1", 100).unwrap(), Revoke::Revoked);
691
692 assert!(!try_revoke(&second, hosts.owner_id(), "host-1", 200).unwrap());
695 assert_eq!(hosts.host("host-1").unwrap().unwrap().revoked_at, Some(100));
696 }
697}