beck_rt/identity.rs
1//! Who is asking, as a thing the runtime **decides** rather than a thing the client asserts.
2//!
3//! [`docs/42-security-assurance.md`](../../../../../docs/42-security-assurance.md) §42.6's first
4//! bullet: "**Claim any identity.** `actor` arrives in the client's own `hello` frame … Every
5//! ownership check in every corpus program is therefore enforced against a value the caller
6//! chooses." [`docs/43`](../../../../../docs/43-threat-model.md) §43.4 records it as the gap that
7//! makes the difference between §3.5's *proven* properties and a program's own rules, and §42.5
8//! names it as the most likely misquotation of this project's security story: "a capability
9//! required outside the chokepoint has no holder" is true and proven; "only the owner may toggle
10//! their todo" was, until this module, enforced against a self-asserted string.
11//!
12//! # What this is, and what it is not
13//!
14//! It is a **seam**, in the sense `beck_core::clock` is one: a trait with the current behaviour as
15//! one implementation and a verifying implementation as another, so that identity is a thing an
16//! operator *chooses* rather than a thing the runtime assumes. Two implementations, because a seam
17//! with one is an abstraction nobody has checked.
18//!
19//! What it does is remove the thing that made the gap structural: an actor arrives through one
20//! function that can **refuse**, and nothing else in the runtime can mint one.
21//!
22//! # The third implementation is in [`crate::oidc`]
23//!
24//! Both providers here are **symmetric or nothing**: `DevIdentity` verifies nothing, and
25//! `SignedIdentity` verifies a secret this process also holds, so neither can tell "the user
26//! authenticated" from "this process said so". [`crate::oidc::RelyingParty`] is the asymmetric one
27//! — [`10`](../../../../../docs/10-decisions.md) D6's OIDC relying party — and it is a third
28//! implementation of this trait rather than a change to it, which is what the seam existed for.
29
30use std::collections::BTreeMap;
31use std::sync::Arc;
32
33/// A verified identity. Nothing constructs one except an [`Identity`] implementation.
34///
35/// The privacy of the field is the point: a `String` from a frame cannot become an `Actor` by
36/// being assigned to one, so "where did this actor come from" has exactly one answer everywhere it
37/// is asked.
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub struct Actor {
40 name: Arc<str>,
41 claims: BTreeMap<Arc<str>, Arc<str>>,
42}
43
44impl Actor {
45 /// The one constructor, and it is `pub(crate)` because every [`Identity`] implementation is in
46 /// this crate. A `String` that arrived in a frame becomes an actor by being *verified*, which
47 /// is one function call away from being audited rather than spread over the runtime.
48 pub(crate) fn verified(name: &str, claims: BTreeMap<Arc<str>, Arc<str>>) -> Actor {
49 Actor {
50 name: Arc::from(name),
51 claims,
52 }
53 }
54
55 pub fn name(&self) -> &str {
56 &self.name
57 }
58
59 /// The claims this identity carries.
60 ///
61 /// D6 asks for "claims → `Session` capability mapping", and both halves are here now: these
62 /// are the claims [`crate::oidc`] verified, and [`crate::program`] puts them on the `Session`
63 /// the program sees. They do **not** reach the log — an envelope carries the actor's name and
64 /// nothing else, because a fold that read a claim would be a fold whose replay depended on
65 /// what the issuer was saying at the time
66 /// ([`docs/48`](../../../../../docs/48-identity-report.md) §48.6).
67 pub fn claims(&self) -> &BTreeMap<Arc<str>, Arc<str>> {
68 &self.claims
69 }
70}
71
72/// An actor is who a view is rendered for and who a command is proposed by.
73///
74/// The trait is [`beck_host::program::Viewer`] and this impl is here rather than beside it, because
75/// what distinguishes an `Actor` from the other things that can be a viewer is precisely that a
76/// *credential* was checked — and checking credentials is the host's job, not the program's.
77impl beck_host::program::Viewer for Actor {
78 fn actor(&self) -> &str {
79 self.name()
80 }
81
82 fn claims(&self) -> &BTreeMap<Arc<str>, Arc<str>> {
83 Actor::claims(self)
84 }
85}
86
87/// Whoever a proposal is charged to, on the way into [`crate::App::propose`].
88///
89/// A wrapper rather than an `impl From<String> for Actor`, and the difference is the whole point:
90/// a conversion on `Actor` itself would be a public way to make one out of a string, which is what
91/// [`Actor`]'s private field exists to prevent. This converts into a *proposal's* actor — a
92/// harness naming one, a benchmark, `beck test` — and the wire path does not use it, because
93/// `session.rs` already holds an `Actor` that [`Identity::verify`] produced.
94#[derive(Clone, Debug)]
95pub struct Proposer(pub(crate) Actor);
96
97impl From<Actor> for Proposer {
98 fn from(actor: Actor) -> Proposer {
99 Proposer(actor)
100 }
101}
102
103impl From<String> for Proposer {
104 fn from(name: String) -> Proposer {
105 Proposer(Actor::verified(&name, BTreeMap::new()))
106 }
107}
108
109impl From<&str> for Proposer {
110 fn from(name: &str) -> Proposer {
111 Proposer(Actor::verified(name, BTreeMap::new()))
112 }
113}
114
115/// Why an identity was refused. One reason per way a connection can be wrong about who it is.
116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
117pub enum Rejected {
118 /// No credential at all where one was required.
119 Missing,
120 /// A credential that does not verify — a wrong signature, or one for a different secret.
121 Invalid,
122 /// A credential that verifies and has expired.
123 Expired,
124}
125
126impl Rejected {
127 /// What the client is told. Deliberately coarse: a client learns that it was refused and not
128 /// which of the three it was, because the difference is useful to an attacker and to nobody
129 /// else. The *operator* gets the distinction, in the log.
130 pub fn message(&self) -> &'static str {
131 "unauthenticated"
132 }
133
134 pub fn reason(&self) -> &'static str {
135 match self {
136 Rejected::Missing => "no credential",
137 Rejected::Invalid => "credential does not verify",
138 Rejected::Expired => "credential has expired",
139 }
140 }
141}
142
143/// How a claimed identity becomes a verified one.
144pub trait Identity: Send + Sync + std::fmt::Debug {
145 /// Verify what a client said about itself.
146 ///
147 /// `claim` is the raw string from the `hello` frame or the `?actor=` query — whatever the
148 /// client sent, unmodified, including empty.
149 fn verify(&self, claim: &str) -> Result<Actor, Rejected>;
150
151 /// What this provider is, for the dashboard and for the startup line. An operator who cannot
152 /// tell from the logs whether authentication is on does not have authentication.
153 fn kind(&self) -> &'static str;
154
155 /// Whether this provider verifies anything at all.
156 ///
157 /// Exists so the runtime can *say* it is unauthenticated rather than leaving it to be
158 /// inferred. `docs/42` §42.6's whole point is that an absent control was invisible.
159 fn verifies(&self) -> bool {
160 true
161 }
162
163 /// The browser-facing half, for a provider that can run a login flow.
164 ///
165 /// `None` for both of this module's providers, and that is the honest answer rather than a
166 /// missing feature: neither *issues* anything, so neither has anywhere to send a browser.
167 /// [`crate::oidc::RelyingParty`] does, and the HTTP edge asks this rather than being told which
168 /// provider is configured.
169 fn login(&self) -> Option<&crate::oidc::RelyingParty> {
170 None
171 }
172}
173
174/// Believe whatever the client says. The behaviour every phase before this had.
175///
176/// It is the right default for `beck run` on a laptop and for the corpus harnesses, and it is
177/// wrong for anything reachable by a stranger — which is why it now has a name, a `kind()` that
178/// says "dev", and a `verifies()` that says no.
179#[derive(Clone, Copy, Debug, Default)]
180pub struct DevIdentity;
181
182impl Identity for DevIdentity {
183 fn verify(&self, claim: &str) -> Result<Actor, Rejected> {
184 // An empty actor is still refused: a program's ownership checks compare against it, and
185 // "" matching "" would make every anonymous client the owner of every anonymous record.
186 if claim.is_empty() {
187 return Err(Rejected::Missing);
188 }
189 Ok(Actor::verified(claim, BTreeMap::new()))
190 }
191
192 fn kind(&self) -> &'static str {
193 "dev"
194 }
195
196 fn verifies(&self) -> bool {
197 false
198 }
199}
200
201/// A credential signed with a secret this process holds.
202///
203/// The credential is `<payload>.<mac>`, where `payload` is
204/// `actor;expiry_millis;key=value;key=value…` and `mac` is a keyed BLAKE3 of it under the shared
205/// secret, hex-encoded. Verification is a constant-time comparison of the recomputed tag.
206///
207/// **This is a symmetric scheme, and its limits are the point of writing them here.** It suits a
208/// gateway that mints credentials for a Beck process behind it — the shape a rung-1 deployment
209/// actually has — and it does not suit a public identity provider, because everything that can
210/// verify a credential can also mint one. An asymmetric verifier is D6's OIDC work and needs a
211/// signature library ([`48`](../../../../../docs/48-identity-report.md) §48.13).
212///
213/// BLAKE3's keyed mode is a MAC by construction and is already in this workspace's dependency
214/// graph, so this costs no new dependency and no hand-rolled cryptography — the two ways a module
215/// like this usually goes wrong.
216#[derive(Debug)]
217pub struct SignedIdentity {
218 key: [u8; 32],
219 clock: Arc<dyn beck_core::clock::Clock>,
220}
221
222impl SignedIdentity {
223 /// A verifier for a shared secret of any length, stretched to BLAKE3's key size by its own
224 /// derivation function rather than by truncation or padding.
225 pub fn new(secret: &str, clock: Arc<dyn beck_core::clock::Clock>) -> SignedIdentity {
226 SignedIdentity {
227 key: blake3::derive_key("beck identity credential v1", secret.as_bytes()),
228 clock,
229 }
230 }
231
232 /// Mint a credential. Present so a test, a gateway written in Beck, or `beck run --auth` can
233 /// produce one — and so the format has exactly one implementation rather than a description.
234 pub fn mint(&self, actor: &str, expires_at_millis: i64, claims: &[(&str, &str)]) -> String {
235 let payload = Self::payload(actor, expires_at_millis, claims);
236 let mac = blake3::keyed_hash(&self.key, payload.as_bytes());
237 format!("{payload}.{}", mac.to_hex())
238 }
239
240 fn payload(actor: &str, expires_at_millis: i64, claims: &[(&str, &str)]) -> String {
241 let mut out = format!("{actor};{expires_at_millis}");
242 for (k, v) in claims {
243 out.push(';');
244 out.push_str(k);
245 out.push('=');
246 out.push_str(v);
247 }
248 out
249 }
250}
251
252impl Identity for SignedIdentity {
253 fn verify(&self, claim: &str) -> Result<Actor, Rejected> {
254 if claim.is_empty() {
255 return Err(Rejected::Missing);
256 }
257 let (payload, mac) = claim.rsplit_once('.').ok_or(Rejected::Invalid)?;
258 let expected = blake3::keyed_hash(&self.key, payload.as_bytes());
259 // `Hash`'s `PartialEq` is constant-time, which is why the comparison is written this way
260 // round rather than against the hex string.
261 let given: blake3::Hash = mac.parse().map_err(|_| Rejected::Invalid)?;
262 if expected != given {
263 return Err(Rejected::Invalid);
264 }
265
266 let mut parts = payload.split(';');
267 let name = parts
268 .next()
269 .filter(|s| !s.is_empty())
270 .ok_or(Rejected::Invalid)?;
271 let expiry: i64 = parts
272 .next()
273 .ok_or(Rejected::Invalid)?
274 .parse()
275 .map_err(|_| Rejected::Invalid)?;
276 // The clock is the injected one, so a test states the instant and a replay is not at the
277 // mercy of when it ran (`beck_core::clock`, and F11's constraint).
278 if self.clock.now_millis() >= expiry {
279 return Err(Rejected::Expired);
280 }
281 let claims = parts
282 .filter_map(|kv| kv.split_once('='))
283 .map(|(k, v)| (Arc::from(k), Arc::from(v)))
284 .collect();
285 Ok(Actor::verified(name, claims))
286 }
287
288 fn kind(&self) -> &'static str {
289 "signed"
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use beck_core::clock::ManualClock;
297
298 fn at(ms: i64) -> Arc<ManualClock> {
299 Arc::new(ManualClock::at(ms))
300 }
301
302 #[test]
303 fn dev_identity_believes_the_client_and_refuses_an_empty_name() {
304 let id = DevIdentity;
305 assert_eq!(id.verify("alice").expect("believed").name(), "alice");
306 assert_eq!(id.verify(""), Err(Rejected::Missing));
307 assert!(!id.verifies(), "and it says it is not verifying anything");
308 }
309
310 #[test]
311 fn a_signed_credential_round_trips_with_its_claims() {
312 let clock = at(1_000);
313 let id = SignedIdentity::new("a shared secret", clock.clone());
314 let token = id.mint("alice", 2_000, &[("role", "admin"), ("tenant", "acme")]);
315 let actor = id.verify(&token).expect("it verifies");
316 assert_eq!(actor.name(), "alice");
317 assert_eq!(
318 actor.claims().get("role").map(|s| s.as_ref()),
319 Some("admin")
320 );
321 assert_eq!(
322 actor.claims().get("tenant").map(|s| s.as_ref()),
323 Some("acme")
324 );
325 }
326
327 /// The whole point: a client cannot name itself.
328 #[test]
329 fn a_name_without_a_signature_is_refused() {
330 let id = SignedIdentity::new("a shared secret", at(1_000));
331 assert_eq!(id.verify("alice"), Err(Rejected::Invalid));
332 assert_eq!(id.verify("alice.deadbeef"), Err(Rejected::Invalid));
333 assert_eq!(id.verify(""), Err(Rejected::Missing));
334 }
335
336 /// Nor can it borrow somebody else's: the payload is what is signed, so editing the name
337 /// invalidates the tag.
338 #[test]
339 fn a_credential_cannot_be_edited_into_another_actors() {
340 let id = SignedIdentity::new("a shared secret", at(1_000));
341 let token = id.mint("alice", 2_000, &[("role", "reader")]);
342 let forged = token.replacen("alice", "admin", 1);
343 assert_eq!(id.verify(&forged), Err(Rejected::Invalid));
344
345 // Nor into a better claim.
346 let escalated = token.replacen("role=reader", "role=admin!", 1);
347 assert_eq!(id.verify(&escalated), Err(Rejected::Invalid));
348 }
349
350 #[test]
351 fn a_credential_for_another_secret_does_not_verify() {
352 let mint = SignedIdentity::new("one secret", at(1_000));
353 let check = SignedIdentity::new("another secret", at(1_000));
354 let token = mint.mint("alice", 2_000, &[]);
355 assert_eq!(check.verify(&token), Err(Rejected::Invalid));
356 }
357
358 /// Expiry is read from the injected clock, so this is a statement about an instant rather than
359 /// about how long the test took to run.
360 #[test]
361 fn a_credential_expires_against_the_clock_it_was_given() {
362 let clock = at(1_000);
363 let id = SignedIdentity::new("a shared secret", clock.clone());
364 let token = id.mint("alice", 2_000, &[]);
365 assert!(id.verify(&token).is_ok());
366 clock.set(2_000);
367 assert_eq!(id.verify(&token), Err(Rejected::Expired));
368 }
369
370 /// A client is told it was refused and not which of the three ways, because the difference is
371 /// useful to an attacker and to nobody else.
372 #[test]
373 fn the_client_is_not_told_which_refusal_it_was() {
374 for r in [Rejected::Missing, Rejected::Invalid, Rejected::Expired] {
375 assert_eq!(r.message(), "unauthenticated");
376 }
377 assert_ne!(Rejected::Missing.reason(), Rejected::Invalid.reason());
378 }
379}