tear_types/genesis.rs
1//! Genesis + Guid — the *immutable* half of a session's two keys.
2//!
3//! A [`Guid`] is a BLAKE3 commitment to a session's **genesis**: the
4//! program that runs it, the intent it was spawned with, and the
5//! context it was born into. It is the answer to "which session is
6//! this, forever?", and it never changes, because none of its inputs
7//! can change after birth.
8//!
9//! The companion key is [`crate::Address`] — mutable, unique, dotted,
10//! and used for lookup and aggregation. Durable records key on the
11//! `Guid`; nothing keys on the `Address`. That is the whole point:
12//! renaming or re-parenting a session moves the alias and leaves the
13//! identity — and therefore the data — exactly where it was.
14//!
15//! ## What is NOT hashed
16//!
17//! The session's **current** address. There is no field for it here,
18//! so a rename cannot reach the hash even by accident — the omission
19//! is structural, not a convention someone has to remember.
20//!
21//! What *is* hashed is [`Genesis::requested_address`]: the address
22//! asked for at birth. That is a historical fact about the spawn, as
23//! immutable as the cwd it ran in, and including it makes two
24//! otherwise-identical spawns into two distinct sessions.
25//!
26//! ## Why a Merkle root and not one flat hash
27//!
28//! Modelled on tameshi's `CertificationArtifact`: the three inputs are
29//! separate **leaves** composed into a root, with RFC 9162 / Certificate
30//! Transparency domain separation —
31//!
32//! ```text
33//! leaf(x) = BLAKE3(0x00 || framed-fields)
34//! node(l, r) = BLAKE3(0x01 || l || r)
35//!
36//! root = node(node(L0, L1), L2)
37//! / \
38//! node(L0, L1) L2 context
39//! / \ (cwd, parent)
40//! L0 program L1 intent
41//! (requested address, args)
42//! ```
43//!
44//! The distinct `0x00` / `0x01` prefixes mean a leaf hash and an
45//! interior hash can never collide, so no attacker-supplied leaf
46//! content can be re-read as an interior node (the second-preimage
47//! attack RFC 9162 §2.1.1 exists to stop). Within a leaf every field
48//! is length-framed (`u64` little-endian length, then bytes), so
49//! `["a", "b"]` and `["ab"]` are different commitments rather than the
50//! same concatenation.
51//!
52//! Leaf structure also leaves room to hand out an inclusion proof for
53//! one leaf later without revealing the others; nothing here needs
54//! that yet, and nothing here forecloses it.
55//!
56//! ## Full 256 bits, deliberately
57//!
58//! [`Guid`] keeps the whole BLAKE3 root — 32 bytes, 64 hex characters.
59//! It does **not** truncate the way [`crate::SessionId::from_seed`]
60//! does (first 8 little-endian bytes, 64 bits). A 64-bit identifier has
61//! a ~50% collision probability around 5 billion values and is birthday-
62//! attackable by anyone who can influence a seed; an attestable identity
63//! cannot be built on that. `SessionId` stays as it is — this is a new,
64//! wider key alongside it, not a change to the old one.
65//!
66//! ## No way to invent one
67//!
68//! [`Guid`]'s byte array is private to this module, so no other module
69//! — inside this crate or outside it — can name it. The only public
70//! function returning a `Guid` is [`Genesis::guid`]. There is
71//! deliberately no `FromStr`, no `From<[u8; 32]>`, and no
72//! `Deserialize`: a peer cannot hand the daemon an identity, because
73//! there is no code path that turns bytes back into one.
74//!
75//! `Serialize` is implemented (as lowercase hex) for the same reason
76//! [`crate::Shutai`] implements it — identity is *reported* outward to
77//! audit logs, `tear list`, and MCP reads. Information flows out;
78//! authority does not flow in.
79
80use core::fmt;
81
82use serde::{Serialize, Serializer};
83
84use crate::address::Address;
85
86/// RFC 9162 leaf-domain prefix.
87const LEAF_DOMAIN: u8 = 0x00;
88
89/// RFC 9162 interior-node-domain prefix.
90const NODE_DOMAIN: u8 = 0x01;
91
92/// Leaf label: the program that runs the session.
93const LABEL_PROGRAM: &str = "tear.genesis.program.v1";
94
95/// Leaf label: what the spawn asked for.
96const LABEL_INTENT: &str = "tear.genesis.intent.v1";
97
98/// Leaf label: where and under whom it was born.
99const LABEL_CONTEXT: &str = "tear.genesis.context.v1";
100
101/// A session's immutable identity — the full 256-bit BLAKE3 root of
102/// its [`Genesis`].
103///
104/// Obtainable only from [`Genesis::guid`]. See the module docs for why
105/// there is no other constructor and no `Deserialize`.
106#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
107pub struct Guid([u8; 32]);
108
109impl Guid {
110 /// The full 32-byte root.
111 #[must_use]
112 pub fn as_bytes(&self) -> &[u8; 32] {
113 &self.0
114 }
115
116 /// Lowercase hex, 64 characters — the canonical text form.
117 #[must_use]
118 pub fn to_hex(&self) -> String {
119 let mut out = String::with_capacity(64);
120 for b in self.0 {
121 out.push_str(&format!("{b:02x}"));
122 }
123 out
124 }
125
126 /// The first 16 hex characters, for logs and `tear list` columns.
127 /// A display abbreviation only — the identity is always the full
128 /// root, and nothing accepts a short form as input.
129 #[must_use]
130 pub fn short(&self) -> String {
131 self.to_hex()[..16].to_string()
132 }
133}
134
135impl fmt::Display for Guid {
136 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137 f.write_str(&self.to_hex())
138 }
139}
140
141impl fmt::Debug for Guid {
142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143 write!(f, "Guid({})", self.to_hex())
144 }
145}
146
147impl Serialize for Guid {
148 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
149 serializer.serialize_str(&self.to_hex())
150 }
151}
152
153/// Everything a session is committed to at birth.
154///
155/// Three groups, one per Merkle leaf:
156///
157/// | leaf | fields | what it pins |
158/// |---|---|---|
159/// | 0 | [`program`](Genesis::program) | the image that runs |
160/// | 1 | [`requested_address`](Genesis::requested_address), [`args`](Genesis::args) | what the spawn asked for |
161/// | 2 | [`cwd`](Genesis::cwd), [`parent`](Genesis::parent) | where, and under whom |
162///
163/// There is no field for the session's *current* address — see the
164/// module docs.
165///
166/// Like [`Guid`] and [`crate::Shutai`], `Genesis` is `Serialize` but
167/// not `Deserialize`: it is minted from a spawn the daemon is
168/// performing, never parsed from a payload a peer sent.
169#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
170pub struct Genesis {
171 /// The shell or program image that runs the session — the resolved
172 /// path where one is known (`/run/current-system/sw/bin/zsh`), the
173 /// bare name otherwise.
174 pub program: String,
175 /// The address the spawn asked for. A historical fact about the
176 /// birth, not the session's live alias; the live alias is not an
177 /// input to the hash at all.
178 pub requested_address: Address,
179 /// Arguments the spawn requested, in order, excluding `argv[0]`.
180 pub args: Vec<String>,
181 /// The working directory the session was born in.
182 pub cwd: String,
183 /// The spawning session, when there was one. `None` is a
184 /// top-level spawn. Because a [`Guid`] can only come from a
185 /// [`Genesis`], a parent link is unforgeable by construction: you
186 /// cannot claim a parent you never derived.
187 pub parent: Option<Guid>,
188}
189
190impl Genesis {
191 /// A top-level genesis: program, requested address, cwd. Add args
192 /// with [`Genesis::with_args`] and a parent with
193 /// [`Genesis::with_parent`].
194 #[must_use]
195 pub fn new(program: impl Into<String>, requested_address: Address, cwd: impl Into<String>) -> Self {
196 Self {
197 program: program.into(),
198 requested_address,
199 args: Vec::new(),
200 cwd: cwd.into(),
201 parent: None,
202 }
203 }
204
205 /// Set the spawn arguments.
206 #[must_use]
207 pub fn with_args<I, S>(mut self, args: I) -> Self
208 where
209 I: IntoIterator<Item = S>,
210 S: Into<String>,
211 {
212 self.args = args.into_iter().map(Into::into).collect();
213 self
214 }
215
216 /// Record the spawning session.
217 #[must_use]
218 pub fn with_parent(mut self, parent: Guid) -> Self {
219 self.parent = Some(parent);
220 self
221 }
222
223 /// Derive this genesis's [`Guid`] — the only way a `Guid` comes
224 /// into existence.
225 ///
226 /// Pure and total: the same `Genesis` always yields the same
227 /// `Guid`, on any host, in any process, forever.
228 #[must_use]
229 pub fn guid(&self) -> Guid {
230 let [program, intent, context] = self.leaves();
231 Guid(node(&node(&program, &intent), &context))
232 }
233
234 /// The three domain-separated leaf hashes, in tree order.
235 fn leaves(&self) -> [[u8; 32]; 3] {
236 let program = leaf(LABEL_PROGRAM, |h| {
237 frame(h, self.program.as_bytes());
238 });
239
240 let intent = leaf(LABEL_INTENT, |h| {
241 frame(h, self.requested_address.to_string().as_bytes());
242 frame_len(h, self.args.len());
243 for arg in &self.args {
244 frame(h, arg.as_bytes());
245 }
246 });
247
248 let context = leaf(LABEL_CONTEXT, |h| {
249 frame(h, self.cwd.as_bytes());
250 match &self.parent {
251 None => {
252 h.update(&[0u8]);
253 }
254 Some(parent) => {
255 h.update(&[1u8]);
256 h.update(parent.as_bytes());
257 }
258 }
259 });
260
261 [program, intent, context]
262 }
263}
264
265/// Length-framed field: `u64` little-endian length, then the bytes.
266/// Framing is what makes `["a", "b"]` and `["ab"]` different
267/// commitments.
268fn frame(h: &mut blake3::Hasher, bytes: &[u8]) {
269 frame_len(h, bytes.len());
270 h.update(bytes);
271}
272
273fn frame_len(h: &mut blake3::Hasher, len: usize) {
274 h.update(&(len as u64).to_le_bytes());
275}
276
277/// `BLAKE3(0x00 || framed(label) || body)` — RFC 9162 leaf domain.
278fn leaf(label: &str, body: impl FnOnce(&mut blake3::Hasher)) -> [u8; 32] {
279 let mut h = blake3::Hasher::new();
280 h.update(&[LEAF_DOMAIN]);
281 frame(&mut h, label.as_bytes());
282 body(&mut h);
283 *h.finalize().as_bytes()
284}
285
286/// `BLAKE3(0x01 || left || right)` — RFC 9162 interior-node domain.
287fn node(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
288 let mut h = blake3::Hasher::new();
289 h.update(&[NODE_DOMAIN]);
290 h.update(left);
291 h.update(right);
292 *h.finalize().as_bytes()
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use crate::address::Address;
299
300 fn addr(s: &str) -> Address {
301 Address::parse(s).expect("test fixture must be a legal address")
302 }
303
304 fn sample() -> Genesis {
305 // Compile-checked literal: if `Genesis` gains, loses, or
306 // renames a field this test stops COMPILING. That is the
307 // forcing function behind "the current address is not an input
308 // to the hash" — the only way to break the invariant is to add
309 // a field here, and doing so cannot happen silently.
310 Genesis {
311 program: "/bin/zsh".into(),
312 requested_address: addr("work.akeyless.helm-charts.build"),
313 args: vec!["-l".into()],
314 cwd: "/code/akeylesslabs/helm-charts".into(),
315 parent: None,
316 }
317 }
318
319 // ── reproducibility ────────────────────────────────────────────
320
321 #[test]
322 fn the_same_genesis_always_derives_the_same_guid() {
323 assert_eq!(sample().guid(), sample().guid());
324 // and repeated derivation from one value is stable too
325 let g = sample();
326 assert_eq!(g.guid(), g.guid());
327 }
328
329 #[test]
330 fn the_guid_is_a_full_256_bit_root_not_a_truncation() {
331 let guid = sample().guid();
332 assert_eq!(guid.as_bytes().len(), 32);
333 assert_eq!(guid.to_hex().len(), 64);
334 // The bug being avoided: SessionId::from_seed keeps only the
335 // first 8 bytes. Prove the other 24 carry real entropy.
336 assert!(
337 guid.as_bytes()[8..].iter().any(|b| *b != 0),
338 "bytes past the first 8 must not be dropped"
339 );
340 assert_eq!(guid.short(), &guid.to_hex()[..16]);
341 }
342
343 // ── one leaf differs → a different Guid ────────────────────────
344
345 #[test]
346 fn a_different_program_derives_a_different_guid() {
347 let mut other = sample();
348 other.program = "/bin/bash".into();
349 assert_ne!(sample().guid(), other.guid());
350 }
351
352 #[test]
353 fn a_different_requested_address_derives_a_different_guid() {
354 let mut other = sample();
355 other.requested_address = addr("work.akeyless.helm-charts.test");
356 assert_ne!(sample().guid(), other.guid());
357 }
358
359 #[test]
360 fn different_args_derive_a_different_guid() {
361 let mut other = sample();
362 other.args = vec!["-i".into()];
363 assert_ne!(sample().guid(), other.guid());
364
365 let mut none = sample();
366 none.args = Vec::new();
367 assert_ne!(sample().guid(), none.guid());
368 assert_ne!(other.guid(), none.guid());
369 }
370
371 #[test]
372 fn a_different_cwd_derives_a_different_guid() {
373 let mut other = sample();
374 other.cwd = "/code/akeylesslabs/cli".into();
375 assert_ne!(sample().guid(), other.guid());
376 }
377
378 #[test]
379 fn a_different_parent_derives_a_different_guid() {
380 let parent_a = sample().guid();
381 let parent_b = Genesis::new("/bin/bash", addr("work.other"), "/tmp").guid();
382 assert_ne!(parent_a, parent_b);
383
384 let orphan = sample();
385 let under_a = sample().with_parent(parent_a);
386 let under_b = sample().with_parent(parent_b);
387
388 assert_ne!(orphan.guid(), under_a.guid());
389 assert_ne!(orphan.guid(), under_b.guid());
390 assert_ne!(under_a.guid(), under_b.guid());
391 }
392
393 #[test]
394 fn every_leaf_is_load_bearing() {
395 // One assertion covering the whole claim: perturb each leaf's
396 // fields one at a time; all five perturbations, plus the
397 // baseline, must be six distinct Guids.
398 let base = sample();
399 let mut variants = vec![base.guid()];
400
401 let mut v = base.clone();
402 v.program = "/bin/bash".into();
403 variants.push(v.guid());
404
405 let mut v = base.clone();
406 v.requested_address = addr("work.other");
407 variants.push(v.guid());
408
409 let mut v = base.clone();
410 v.args = vec!["-l".into(), "-i".into()];
411 variants.push(v.guid());
412
413 let mut v = base.clone();
414 v.cwd = "/elsewhere".into();
415 variants.push(v.guid());
416
417 let v = base.clone().with_parent(base.guid());
418 variants.push(v.guid());
419
420 let mut seen = std::collections::BTreeSet::new();
421 for g in &variants {
422 assert!(seen.insert(g.to_hex()), "leaf perturbation collided: {g:?}");
423 }
424 assert_eq!(seen.len(), 6);
425 }
426
427 // ── framing + domain separation ────────────────────────────────
428
429 #[test]
430 fn args_are_length_framed_not_concatenated() {
431 let split = sample().with_args(["a", "b"]);
432 let joined = sample().with_args(["ab"]);
433 let dotted = sample().with_args(["a.b"]);
434 assert_ne!(split.guid(), joined.guid());
435 assert_ne!(split.guid(), dotted.guid());
436 assert_ne!(joined.guid(), dotted.guid());
437
438 // A trailing empty arg is a real difference, not a no-op.
439 let trailing_empty = sample().with_args(["a", ""]);
440 let bare = sample().with_args(["a"]);
441 assert_ne!(trailing_empty.guid(), bare.guid());
442 }
443
444 #[test]
445 fn leaf_and_interior_domains_cannot_collide() {
446 // The RFC 9162 guarantee, tested directly: the same body under
447 // the leaf prefix and under the node prefix are different
448 // hashes, so no leaf can be re-read as an interior node.
449 let l = leaf("x", |h| frame(h, b"left"));
450 let r = leaf("x", |h| frame(h, b"right"));
451 let interior = node(&l, &r);
452
453 // A leaf whose body is byte-identical to the interior body.
454 let leafish = {
455 let mut h = blake3::Hasher::new();
456 h.update(&[LEAF_DOMAIN]);
457 h.update(&l);
458 h.update(&r);
459 *h.finalize().as_bytes()
460 };
461 assert_ne!(interior, leafish, "domain prefixes must separate");
462 assert_ne!(LEAF_DOMAIN, NODE_DOMAIN);
463
464 // And a domain-separated leaf is not the raw BLAKE3 of its body.
465 let raw = *blake3::hash(b"left").as_bytes();
466 assert_ne!(l, raw);
467 }
468
469 #[test]
470 fn the_root_is_the_documented_tree_shape() {
471 let g = sample();
472 let [l0, l1, l2] = g.leaves();
473 let expected = node(&node(&l0, &l1), &l2);
474 assert_eq!(g.guid().as_bytes(), &expected);
475 // Leaves are mutually distinct — the labels do their job.
476 assert_ne!(l0, l1);
477 assert_ne!(l1, l2);
478 assert_ne!(l0, l2);
479 }
480
481 // ── identity vs alias ──────────────────────────────────────────
482
483 #[test]
484 fn a_parent_link_can_only_be_a_derived_guid() {
485 // The chain is closed: the only value `with_parent` accepts is
486 // one that came out of some Genesis, so an invented ancestry is
487 // unrepresentable rather than merely discouraged.
488 let parent = Genesis::new("/bin/zsh", addr("work"), "/code");
489 let child = Genesis::new("/bin/zsh", addr("work.build"), "/code")
490 .with_parent(parent.guid());
491 assert_eq!(child.parent, Some(parent.guid()));
492 assert_ne!(child.guid(), parent.guid());
493 }
494
495 #[test]
496 fn renaming_is_structurally_outside_the_hash() {
497 // The session's live alias is a separate value that Genesis
498 // never sees. Moving it — even to something wildly different —
499 // touches nothing the Guid was derived from.
500 let genesis = sample();
501 let identity = genesis.guid();
502
503 let mut live_alias = genesis.requested_address.clone();
504 assert_eq!(live_alias.to_string(), "work.akeyless.helm-charts.build");
505 live_alias = addr("archive.2026.helm-charts.build");
506
507 assert_eq!(genesis.guid(), identity);
508 assert_ne!(live_alias, genesis.requested_address);
509 }
510
511 // ── outward reporting ──────────────────────────────────────────
512
513 #[test]
514 fn guid_serializes_outward_as_lowercase_hex() {
515 let guid = sample().guid();
516 let json = serde_json::to_string(&guid).unwrap();
517 assert_eq!(json, format!("\"{}\"", guid.to_hex()));
518 assert_eq!(json.len(), 66);
519 assert!(guid.to_hex().chars().all(|c| c.is_ascii_hexdigit()));
520 assert!(!guid.to_hex().chars().any(|c| c.is_ascii_uppercase()));
521 }
522
523 #[test]
524 fn genesis_serializes_with_its_address_as_a_plain_string() {
525 let json = serde_json::to_value(sample()).unwrap();
526 assert_eq!(
527 json["requested_address"],
528 serde_json::json!("work.akeyless.helm-charts.build")
529 );
530 assert_eq!(json["parent"], serde_json::Value::Null);
531 }
532
533 #[test]
534 fn guid_display_and_debug_agree_on_the_hex() {
535 let guid = sample().guid();
536 assert_eq!(guid.to_string(), guid.to_hex());
537 assert_eq!(format!("{guid:?}"), format!("Guid({})", guid.to_hex()));
538 }
539
540 // ── structural forcing functions ───────────────────────────────
541
542 #[test]
543 fn genesis_commits_to_exactly_program_intent_and_context() {
544 // An exhaustive destructure. Adding a field to `Genesis` breaks
545 // this at COMPILE time, which is what keeps "the live address is
546 // not an input" from decaying into a convention someone forgets.
547 let Genesis {
548 program,
549 requested_address,
550 args,
551 cwd,
552 parent,
553 } = sample();
554 assert_eq!(program, "/bin/zsh");
555 assert_eq!(requested_address.to_string(), "work.akeyless.helm-charts.build");
556 assert_eq!(args, vec!["-l".to_string()]);
557 assert_eq!(cwd, "/code/akeylesslabs/helm-charts");
558 assert!(parent.is_none());
559 }
560
561 /// ★ THE STRUCTURAL PROPERTY, as a forcing function.
562 ///
563 /// A `Guid` must never become inventable. Absence of a trait impl
564 /// cannot be asserted at runtime, so this is a comment-stripped
565 /// source scan — the same construction as `shutai.rs`'s
566 /// `shutai_never_becomes_deserializable`.
567 #[test]
568 fn guid_never_gains_a_constructor_other_than_genesis() {
569 let src = include_str!("genesis.rs");
570 let code: String = src
571 .lines()
572 .map(str::trim_start)
573 .filter(|l| !l.starts_with("//"))
574 .collect::<Vec<_>>()
575 .join("\n");
576 let code = code.split("mod tests").next().unwrap_or(&code);
577
578 assert!(
579 !code.contains("Deserialize"),
580 "`Deserialize` appeared in genesis.rs. A peer could then SEND an \
581 identity instead of deriving one, which is the whole thing this \
582 type prevents."
583 );
584 assert!(
585 !code.contains("FromStr"),
586 "`FromStr` would let any string become a Guid"
587 );
588 assert!(
589 !code.contains("impl From<"),
590 "a `From` impl would be a second way to mint a Guid"
591 );
592 assert!(
593 code.contains("pub struct Guid([u8; 32]);"),
594 "Guid's bytes must stay private — a `pub` field is a constructor"
595 );
596 assert_eq!(
597 code.matches("-> Guid").count(),
598 1,
599 "exactly one function may return a Guid, and it is Genesis::guid"
600 );
601
602 // Anti-vacuity: the scan must be looking at real code.
603 assert!(code.contains("pub fn guid(&self) -> Guid"));
604 assert!(
605 code.contains("impl Serialize for Guid"),
606 "a Guid must still report OUTWARD (audit, list, MCP reads)"
607 );
608 }
609}