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}