Skip to main content

hennery_kernel/
hosts.rs

1//! Host identity and pairing (kernel spec §4): the `hosts` registry, one-time
2//! pairing codes, and the check of a host's `hello` proof (ACP core §3.5).
3//! Every query names the database's owner (kernel spec §1), bound when the
4//! registry opens (`db::kernel_owner`): another owner's host is unknown
5//! here, and another owner's code pairs nothing.
6//!
7//! Every time is seconds since the Unix epoch and is passed in by the
8//! caller (`secret::unix_now()` in production), so expiry is testable
9//! without sleeping.
10
11use 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
19/// A pairing code is valid this long (kernel spec §4.1).
20pub const PAIRING_CODE_TTL_SECS: i64 = 10 * 60;
21
22/// At most this many pairing codes are live (unspent and unexpired) at
23/// once; a mint past it is refused with `TooManyPairingCodes`.
24pub const MAX_LIVE_PAIRING_CODES: usize = 16;
25
26/// A mint refused because `MAX_LIVE_PAIRING_CODES` codes are live.
27#[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
41/// Crockford's base32 alphabet: no `I`, `L`, `O` or `U`.
42const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
43
44/// Longest accepted host name, version and platform string.
45const MAX_FIELD: usize = 64;
46
47/// The most workspace roots stored per host (decision 7).
48pub const MAX_ROOTS: usize = 32;
49
50/// The most agents of a `hello.mcp_isolation` the registry keeps (plan 8e).
51pub const MAX_AGENTS: usize = 32;
52
53/// The longest path the collector stores or shows from a host, in bytes.
54pub const MAX_PATH: usize = 4096;
55
56/// A freshly minted pairing code, shown once.
57#[derive(Debug, Clone, PartialEq)]
58pub struct PairingCode {
59    /// `XXXX-XXXX`.
60    pub code: String,
61    pub expires_at: i64,
62}
63
64/// What a host sends to enroll (kernel spec §4.1), besides the code.
65#[derive(Debug, Clone, PartialEq)]
66pub struct Enrollment {
67    /// The host's Ed25519 public key, 64 lowercase hex characters.
68    pub public_key: String,
69    pub name: String,
70    pub host_version: String,
71    pub platform: String,
72}
73
74impl Enrollment {
75    /// Why this enrollment cannot be stored, if it cannot.
76    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
93/// Whether `value` is 1 to `MAX_FIELD` printable characters once trimmed:
94/// the shape required of a host's `name`, `host_version` and `platform`
95/// (kernel spec §4.1), and re-checked on every `hello` (kernel spec §4.3) so
96/// an authenticated host cannot later overwrite a good value with a
97/// disguised or oversized one.
98pub(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/// The outcome of `Hosts::enroll`.
106#[derive(Debug, Clone, PartialEq)]
107pub enum EnrollOutcome {
108    Enrolled {
109        host_id: String,
110    },
111    /// Unknown, expired or already used: one answer for all three.
112    InvalidCode,
113    /// A host with this public key is paired already; the code is not used.
114    AlreadyPaired {
115        host_id: String,
116    },
117    /// The enrollment itself is malformed (why); the code is not used.
118    Invalid(String),
119}
120
121/// The outcome of `Hosts::register`.
122#[derive(Debug, Clone, PartialEq)]
123pub enum Registered {
124    Created,
125    AlreadyPaired { host_id: String },
126}
127
128/// The verdict on a `hello` (ACP core §3.5).
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum HelloCheck {
131    Accepted,
132    /// The proof is valid, and the host is revoked.
133    Revoked,
134    /// Unknown host, or a proof that does not verify.
135    BadProof,
136}
137
138/// The outcome of `Hosts::revoke`.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum Revoke {
141    Revoked,
142    AlreadyRevoked,
143    NotFound,
144}
145
146/// One paired host, as `GET /api/hosts` lists it.
147#[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    /// From its latest accepted `hello`.
154    pub capabilities: Capabilities,
155    /// The hat of its sessions that no path rule claims (kernel spec §5.1).
156    pub default_hat_id: String,
157    /// From its latest reconciled connection (decision 7).
158    pub workspace_roots: Vec<String>,
159    pub created_at: i64,
160    pub last_seen_at: Option<i64>,
161    pub revoked_at: Option<i64>,
162    /// Per agent, how it isolates MCP servers, from its latest accepted
163    /// `hello` (plan 8e decision E7); `None` until one is recorded.
164    pub mcp_isolation: Option<AgentIsolation>,
165}
166
167/// Whether text a host sent can be shown (the review's A6): at most `max`
168/// bytes, with no control or invisible format character.
169pub 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
173/// Whether a path a host reported can be stored and shown (decision 7, the
174/// review's A6): absolute and `is_displayable_text` within `MAX_PATH`. It
175/// is shown, never trusted: the host enforces its own fence.
176pub fn is_displayable_path(path: &str) -> bool {
177    path.starts_with('/') && is_displayable_text(path, MAX_PATH)
178}
179
180/// Invisible Unicode format characters (bidi overrides and isolates,
181/// zero-width characters, the byte-order mark): a host name holding one can
182/// display as another host's name.
183pub(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
189/// A pairing code as typed, reduced to its eight canonical characters:
190/// case, dashes and spaces ignored, `O` read as `0`, `I` and `L` as `1`.
191pub 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
208/// A new random code, `XXXX-XXXX` (40 bits).
209fn 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
230/// A hex Ed25519 public key that is a valid point and not of small order.
231pub 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
237/// Whether `proof` (hex) is `public_key`'s signature over the hello message
238/// for this nonce, host id and protocol version.
239pub 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    /// The database's owner (`db::kernel_owner`), whom every query names.
257    owner: String,
258}
259
260impl Hosts {
261    /// Open the kernel's tables in `hennery.db` (shared with the sessions
262    /// store; each migrates only its own tables).
263    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    /// The owner whose hosts and codes these are.
284    pub fn owner_id(&self) -> &str {
285        &self.owner
286    }
287
288    /// Mint a single-use pairing code valid for `PAIRING_CODE_TTL_SECS`.
289    /// Only its hash is stored. Spent and expired codes are deleted first,
290    /// even when the mint is then refused (`TooManyPairingCodes`) because
291    /// `MAX_LIVE_PAIRING_CODES` are live, so the table stays bounded.
292    pub fn mint_pairing_code(&self, now: i64) -> Result<PairingCode> {
293        self.mint(now, true)
294    }
295
296    /// `mint_pairing_code` for `hennery up`'s own host, once at start: not
297    /// capped, so live codes minted by the operator can never stop the
298    /// all-in-one from pairing its host (a host that cannot pair ends
299    /// `up`). Spent and expired codes are still deleted.
300    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    /// Pair a host with a code (kernel spec §4.1): check the code, create the
332    /// host with a fresh id, and use the code up, in one transaction. A
333    /// wrong code changes nothing, so other outstanding codes stay valid.
334    ///
335    /// The spend at the end repeats the same guard as the read at the start
336    /// (`used_at IS NULL AND expires_at > now`), so a second writer that
337    /// raced past the read still cannot double-spend the code: whichever
338    /// commits first wins, and the loser's `UPDATE` matches zero rows and is
339    /// rolled back along with the host it tentatively inserted. Today the
340    /// per-`Hosts` mutex already serializes every call through one
341    /// connection, so this only matters across two `Hosts` instances (or
342    /// connections) open on the same file — which is already possible (the
343    /// sessions store opens its own) — and matters more once §1's single
344    /// writer thread is not the only thing standing between two writers.
345    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            // Someone else spent it between our read and our write: drop
379            // this transaction (rolling back the host we just inserted)
380            // rather than commit a second host onto a single-use code.
381            return Ok(EnrollOutcome::InvalidCode);
382        }
383        tx.commit()?;
384        Ok(EnrollOutcome::Enrolled { host_id })
385    }
386
387    /// Store a host under a known id, without a code: what `enroll` does once
388    /// the code checks out. Also how tests pair a host under a fixed id.
389    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    /// Check a `hello` (ACP core §3.5). The proof is verified before the
403    /// revocation is looked at, so `revoked` is only ever told to the
404    /// holder of the key.
405    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    /// Update what an accepted `hello` reports (kernel spec §4.3).
428    ///
429    /// `host_version` is checked against the same shape enrollment requires
430    /// (kernel spec §4.1) before it is stored. The host is authenticated at
431    /// this point, but its self-reported version string is still its own
432    /// words, not ours: a host misbehaving or compromised after pairing
433    /// should not be able to overwrite a good version with an oversized one
434    /// or one hiding characters, the same way a name could (decision 5). A
435    /// `hello` failing only this check is not otherwise refused — the
436    /// signature is genuine, `capabilities` and `last_seen_at` still move —
437    /// the least surprising reading of a hello that is real but reports a
438    /// version nobody should have to display.
439    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    /// Store the per-agent MCP isolation of an accepted `hello` (plan 8e
462    /// decision E7), for the host list while the host is away. The host's
463    /// own words, bounded as the other reported fields are: at most
464    /// `MAX_AGENTS` agents, each id displayable and at most 64 bytes;
465    /// others are left out.
466    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    /// Store the workspace roots a reconciled connection of `host_id`
484    /// reported (decision 7): the first `MAX_ROOTS` that
485    /// `is_displayable_path` accepts. Only a reconciled connection's count:
486    /// `hennery host join`'s probe sends a `hello` that knows nothing of
487    /// `host run`'s flags.
488    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    /// Mark a host revoked (kernel spec §4.3). Its future `hello`s get
521    /// `revoked`; closing its connection and parking its sessions is the
522    /// caller's part.
523    ///
524    /// The read above `try_revoke` is only ever a hint, not a guarantee:
525    /// unlike `enroll`'s check-then-spend, this is two separate statements
526    /// on an autocommit connection, not one transaction, so nothing but
527    /// `try_revoke`'s own guard stops a second writer that already read
528    /// "not revoked" from overwriting a revoke that has since landed. If it
529    /// matches nothing for that reason, that is exactly `AlreadyRevoked` — a
530    /// repeated revoke is harmless either way.
531    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    /// Every paired host, revoked ones included, oldest first.
562    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        // Lenient, as a `hello` is read: an unknown mechanism is `none`.
590        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
598/// Store a host of `owner`'s, with the hat new hosts get as its default
599/// hat (kernel spec §4.1, plan 5a decision 2). A key paired already under
600/// another owner is not found here, and the insert fails on the key's
601/// uniqueness (3b-iii decision 7): an error, which nothing in v1 can reach.
602fn insert_host(
603    tx: &rusqlite::Transaction<'_>,
604    owner: &str,
605    host_id: &str,
606    e: &Enrollment,
607    now: i64,
608) -> Result<Registered> {
609    // Stored lowercase, so the same key in another case is the same key.
610    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    // The migration gives every owner a default hat; an owner without one
638    // pairs nothing rather than a hat-less host.
639    anyhow::ensure!(inserted == 1, "the owner has no default hat for new hosts");
640    Ok(Registered::Created)
641}
642
643/// The guarded write at the heart of `revoke`: matches a host that is not
644/// yet revoked, and nothing otherwise — including a host another writer
645/// revoked since the caller's own read. Returns whether it matched. Kept as
646/// its own function so a test can call the exact statement `revoke` runs
647/// without going through `revoke`'s own read first, which is the only way
648/// to exercise this guard: a second, freshly-called `revoke` would simply
649/// see the row already revoked and never reach this write at all.
650fn 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    /// `revoke`'s read and its write are not one transaction (unlike
675    /// `enroll`'s check-then-spend), so a second writer that already read
676    /// "not revoked" — mirrored here by calling `try_revoke` directly,
677    /// the only way to reach it without `revoke`'s own up-to-date read
678    /// short-circuiting first — must not be able to overwrite a revoke
679    /// that has since landed with its own, different timestamp.
680    #[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        // A second, independent connection: not the in-process mutex.
688        let second = db::open(&path).unwrap();
689
690        assert_eq!(hosts.revoke("host-1", 100).unwrap(), Revoke::Revoked);
691
692        // The second writer's own attempt, after the fact, must match
693        // nothing rather than stamp over the timestamp already committed.
694        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}