1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
//! Server-owned facts consumed by protocol transitions.
//!
//! Everything here is a *binding-side fact*: secrets minted from the
//! operating system's entropy source, canonical injective digests of
//! non-secret request bodies, and monotonic clock reads. No lifecycle rule
//! lives here — the protocol crate decides what each fact means.
use liminal_protocol::wire::{DetachRequest, EnrollmentToken, LeaveRequest};
/// Fixed server-side fingerprint width shared by every lifecycle digest.
///
/// The four protocol fingerprint domains (enrollment, verifier, leave,
/// detach-cell) are instantiated with this one array type in the production
/// binding.
pub type Digest = [u8; 32];
/// Failure to mint a server-owned fact.
#[derive(Debug, thiserror::Error)]
pub enum FactsError {
/// The operating system entropy source could not be read.
#[error("failed to read entropy from {ENTROPY_SOURCE}: {0}")]
Entropy(std::io::Error),
/// The system clock reported a time before the Unix epoch or beyond the
/// millisecond `u64` domain.
#[error("system clock is outside the representable millisecond domain")]
Clock,
}
/// Path of the operating-system entropy source used for secret minting.
const ENTROPY_SOURCE: &str = "/dev/urandom";
/// Mints 32 cryptographically random bytes for a new attach secret.
///
/// Reads the operating system's entropy device directly so the server takes
/// no new library dependency. A short read or unreadable device is a loud
/// typed failure — a predictable attach secret must never be issued.
///
/// # Errors
///
/// Returns [`FactsError::Entropy`] when the entropy source cannot provide
/// exactly 32 bytes.
pub fn mint_secret_bytes() -> Result<[u8; 32], FactsError> {
use std::io::Read;
let mut bytes = [0_u8; 32];
let mut source = std::fs::File::open(ENTROPY_SOURCE).map_err(FactsError::Entropy)?;
source.read_exact(&mut bytes).map_err(FactsError::Entropy)?;
Ok(bytes)
}
/// Current wall-clock milliseconds since the Unix epoch.
///
/// # Errors
///
/// Returns [`FactsError::Clock`] when the clock precedes the epoch or the
/// millisecond count exceeds `u64` — receipt deadlines must never be derived
/// from a corrupted clock read.
pub fn now_unix_millis() -> Result<u64, FactsError> {
use std::time::{SystemTime, UNIX_EPOCH};
let elapsed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| FactsError::Clock)?;
u64::try_from(elapsed.as_millis()).map_err(|_| FactsError::Clock)
}
/// Canonical injective enrollment-token fingerprint.
///
/// The 16 token bytes embed verbatim into the first half of the digest; the
/// second half is zero. The mapping is injective over the token domain, which
/// is all the permanent token→identity mapping requires — the token itself is
/// already stored durably by the transition log, so no hiding property is
/// claimed or needed.
#[must_use]
pub fn enrollment_fingerprint(token: EnrollmentToken) -> Digest {
let mut digest = [0_u8; 32];
let bytes = token.into_bytes();
if let Some(prefix) = digest.get_mut(..bytes.len()) {
prefix.copy_from_slice(&bytes);
}
digest
}
/// Canonical injective non-secret detach-request verifier.
///
/// Layout: detach attempt token (16 bytes) | capability generation (8 bytes,
/// big-endian) | participant id (8 bytes, big-endian). Conversation id is
/// fixed per stream and therefore omitted without losing injectivity within
/// one conversation's verifier domain.
#[must_use]
pub fn detach_request_verifier(request: &DetachRequest) -> Digest {
let mut digest = [0_u8; 32];
let token = request.detach_attempt_token.into_bytes();
let generation = request.capability_generation.get().to_be_bytes();
let participant = request.participant_id.to_be_bytes();
for (slot, byte) in digest.iter_mut().zip(
token
.iter()
.chain(generation.iter())
.chain(participant.iter()),
) {
*slot = *byte;
}
digest
}
/// Stable non-reversible verifier for the presented Leave attach secret.
///
/// The token and public request body deliberately do not enter this proof:
/// retired lookup checks the token first and must still distinguish a verified
/// same-token generation conflict from a stale secret presentation.
#[must_use]
pub fn leave_request_verifier(request: &LeaveRequest) -> Digest {
let mut hasher = blake3::Hasher::new();
hasher.update(b"liminal/leave/secret-verifier/v2");
hasher.update(&request.attach_secret.into_bytes());
*hasher.finalize().as_bytes()
}
/// Stable domain-separated canonical Leave fingerprint for the tombstone.
#[must_use]
pub fn leave_fingerprint(request: &LeaveRequest) -> Digest {
leave_digest(b"liminal/leave/fingerprint/v2", request)
}
fn leave_digest(domain: &[u8], request: &LeaveRequest) -> Digest {
let mut hasher = blake3::Hasher::new();
hasher.update(domain);
hasher.update(&request.conversation_id.to_be_bytes());
hasher.update(&request.participant_id.to_be_bytes());
hasher.update(&request.capability_generation.get().to_be_bytes());
hasher.update(&request.attach_secret.into_bytes());
hasher.update(&request.leave_attempt_token.into_bytes());
*hasher.finalize().as_bytes()
}
/// Constant-time byte-slice equality for presented secrets and the
/// connection auth token — the crate's SINGLE implementation.
///
/// A short-circuiting `==` returns as soon as it hits the first differing
/// byte, so its running time leaks how many leading bytes a guess got right —
/// the classic timing side channel that lets an attacker recover a secret one
/// byte at a time. This folds an XOR of every overlapping byte pair into a
/// single accumulator and never returns early, so the loop's work depends
/// only on the input lengths, not on where (or whether) the first mismatch
/// occurs. A length difference is folded in up front so unequal-length inputs
/// still traverse the whole overlap and always report unequal.
///
/// Implemented locally rather than pulling a crate: the only
/// constant-time-compare dependency in the tree (`constant_time_eq`,
/// transitively via blake3) is not a direct workspace dependency, and this
/// five-line fold is the spec-sanctioned shape. Every secret comparison in
/// this crate (attach/leave secrets here, the connection auth token in the
/// connection layer) routes through this one definition so a future
/// hardening cannot miss a copy.
#[must_use]
pub fn constant_time_eq(expected: &[u8], candidate: &[u8]) -> bool {
let mut difference = u8::from(expected.len() != candidate.len());
for (left, right) in expected.iter().zip(candidate.iter()) {
difference |= left ^ right;
}
difference == 0
}