Skip to main content

arkhe_forge_core/
user.rs

1//! User primitive — Identity Subject.
2//!
3//! Runtime-global identity carrier. `UserProfile` holds the registration
4//! facts; `UserGdprState` tracks the GDPR lifecycle pointer; one
5//! or more `AuthCredential`s attach Argon2id / Scrypt KDF secrets. The User
6//! is intentionally shell-agnostic — legal / billing / GDPR obligations cross
7//! shell boundaries.
8
9use arkhe_kernel::abi::{EntityId, Tick};
10use serde::{Deserialize, Serialize};
11
12use crate::action::ActionCompute;
13use crate::context::{ActionContext, ActionError};
14use crate::event::UserErasureScheduled;
15use crate::ArkheAction;
16use crate::ArkheComponent;
17// E14.L1-Deny enforcement on Action::compute.
18use crate::arkhe_pure;
19
20/// Opaque handle into the runtime User namespace.
21#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
22#[serde(transparent)]
23pub struct UserId(EntityId);
24
25impl UserId {
26    /// Construct a `UserId` from a runtime-allocated `EntityId`. Callers must
27    /// hold proof (spawn event, admin scope, or test fixture) that the id
28    /// belongs to the User namespace — this constructor does not verify.
29    #[inline]
30    #[must_use]
31    pub fn new(id: EntityId) -> Self {
32        Self(id)
33    }
34
35    /// Underlying entity handle.
36    #[inline]
37    #[must_use]
38    pub fn get(self) -> EntityId {
39        self.0
40    }
41}
42
43/// Authentication channel family. `#[non_exhaustive]` so new variants
44/// (WebAuthn extensions, social federation) can append without breaking
45/// compatibility.
46#[non_exhaustive]
47#[repr(u8)]
48#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
49pub enum AuthKind {
50    /// WebAuthn / FIDO2 credential.
51    Passkey = 0,
52    /// Email OTP / magic link.
53    Email = 1,
54    /// Platform handle + password.
55    Handle = 2,
56    /// Wallet / on-chain address.
57    Address = 3,
58}
59
60/// GDPR lifecycle state. Any non-`Active` state — `ErasurePending` (erasure
61/// requested) or the terminal `Erased` (crypto-erasure completed) — blocks all
62/// actor-originated Actions on the user (compute MC gate, contract #5).
63#[non_exhaustive]
64#[repr(u8)]
65#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
66pub enum GdprStatus {
67    /// Normal state — user can operate.
68    Active = 0,
69    /// Scheduled for crypto-erasure; cascade observer completes soon.
70    ErasurePending = 1,
71    /// Crypto-erasure completed — DEK shredded, no content recoverable.
72    Erased = 2,
73}
74
75/// Password-hashing algorithm family.
76#[non_exhaustive]
77#[repr(u8)]
78#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
79pub enum KdfKind {
80    /// Argon2id (OWASP 2024 recommended default).
81    Argon2id = 0,
82    /// Scrypt (fallback for legacy imports).
83    Scrypt = 1,
84}
85
86/// KDF cost parameters.
87#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
88pub struct KdfParams {
89    /// Memory cost (KiB for Argon2id).
90    pub m_cost: u32,
91    /// Time cost (iteration count).
92    pub t_cost: u32,
93    /// Parallelism factor.
94    pub p_cost: u32,
95}
96
97/// User profile Component — exactly one per User entity (invariant E-user-1).
98///
99/// The GDPR lifecycle pointer lives in its own [`UserGdprState`] component,
100/// NOT here: a lifecycle transition must be writable on the viewless
101/// dispatch-path compute (which cannot read existing state to do a
102/// read-modify-write), so the pointer is split out to keep `UserProfile`'s
103/// immutable-after-registration fields untouched by erasure transitions.
104#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
105#[arkhe(type_code = 0x0003_0001, schema_version = 1)]
106pub struct UserProfile {
107    /// Wire-level schema version tag (A15 succession).
108    pub schema_version: u16,
109    /// Tick at which `RegisterUser` completed.
110    pub created_tick: Tick,
111    /// Auth family of the initial `AuthCredential`.
112    pub primary_auth_kind: AuthKind,
113}
114
115/// GDPR lifecycle pointer — its own single-field Component (exactly one per
116/// User) and the sole source of truth for erasure eligibility.
117///
118/// Split out of [`UserProfile`] so a lifecycle transition is a blind, TOTAL
119/// `set_component` write: the viewless dispatch-path compute (see
120/// [`crate::bridge`]) cannot read existing kernel state, so it cannot
121/// read-modify-write a bundled struct without clobbering its other fields.
122/// A standalone single-field component makes the transition a correct total
123/// write with no read. The admission gate
124/// ([`ActionContext::ensure_actor_eligible`](crate::context::ActionContext::ensure_actor_eligible))
125/// reads THIS component; absence is treated as `Active` (a freshly registered
126/// user always carries one — `RegisterUser` seeds `Active`).
127#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
128#[arkhe(type_code = 0x0003_0003, schema_version = 1)]
129pub struct UserGdprState {
130    /// Wire-level schema version tag (A15 succession).
131    pub schema_version: u16,
132    /// GDPR lifecycle pointer.
133    pub status: GdprStatus,
134}
135
136/// Stored authentication credential — at least one per User (invariant
137/// E-user-2). Secret material is a KDF output; no raw password is stored.
138#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
139#[arkhe(type_code = 0x0003_0002, schema_version = 1)]
140pub struct AuthCredential {
141    /// Wire-level schema version tag.
142    pub schema_version: u16,
143    /// Channel family.
144    pub kind: AuthKind,
145    /// Hash family. `Argon2id` is the runtime default.
146    pub kdf: KdfKind,
147    /// Per-credential random salt (16 bytes).
148    pub salt: [u8; 16],
149    /// KDF output: `kdf(password, salt, params)`.
150    pub credential_hash: [u8; 32],
151    /// Cost parameters used for `credential_hash`.
152    pub kdf_params: KdfParams,
153    /// Optional rotation deadline (S8 anchor).
154    pub expires_tick: Option<Tick>,
155    /// Tick at which the credential was bound.
156    pub bound_tick: Tick,
157}
158
159impl AuthCredential {
160    /// Runtime default KDF — OWASP 2024 recommendation.
161    pub const DEFAULT_KDF: KdfKind = KdfKind::Argon2id;
162    /// Minimum Argon2id memory cost (19 MiB).
163    pub const MIN_ARGON2ID_M_COST: u32 = 19_456;
164    /// Minimum Argon2id iteration count.
165    pub const MIN_ARGON2ID_T_COST: u32 = 2;
166    /// Minimum Argon2id parallelism.
167    pub const MIN_ARGON2ID_P_COST: u32 = 1;
168    /// Minimum Scrypt cost N (power-of-two, ≥ 2^15).
169    pub const MIN_SCRYPT_N_COST: u32 = 1 << 15;
170    /// Minimum Scrypt block-size `r`.
171    pub const MIN_SCRYPT_R_COST: u32 = 8;
172
173    /// L1-compute validator for KDF parameters — rejects weak settings.
174    #[must_use]
175    pub fn validate_kdf_params(kdf: KdfKind, p: &KdfParams) -> bool {
176        match kdf {
177            KdfKind::Argon2id => {
178                p.m_cost >= Self::MIN_ARGON2ID_M_COST
179                    && p.t_cost >= Self::MIN_ARGON2ID_T_COST
180                    && p.p_cost >= Self::MIN_ARGON2ID_P_COST
181            }
182            KdfKind::Scrypt => {
183                // Scrypt's N (cost) parameter MUST be a power of two — both
184                // the `MIN_SCRYPT_N_COST` doc (2^15) and RFC 7914 §2 require
185                // it. A non-power-of-two N is rejected by the scrypt
186                // primitive at hash time; enforce it here so a weak / invalid
187                // credential never reaches `set_component`.
188                p.m_cost >= Self::MIN_SCRYPT_N_COST
189                    && p.m_cost.is_power_of_two()
190                    && p.t_cost >= Self::MIN_SCRYPT_R_COST
191                    && p.p_cost >= 1
192            }
193        }
194    }
195}
196
197/// Register a fresh `User` with the supplied profile and credential.
198#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
199#[arkhe(type_code = 0x0001_0001, schema_version = 1, band = 1)]
200pub struct RegisterUser {
201    /// Wire-level schema version tag.
202    pub schema_version: u16,
203    /// Profile Component contents.
204    pub profile: UserProfile,
205    /// Initial credential.
206    pub credential: AuthCredential,
207}
208
209/// Request GDPR crypto-erasure for an existing User. Lease — actual cascade
210/// runs via the erasure-cascade observer with p95 < 24h SLA.
211#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
212#[arkhe(type_code = 0x0001_0003, schema_version = 1, band = 1)]
213pub struct GdprEraseUser {
214    /// Wire-level schema version tag.
215    pub schema_version: u16,
216    /// Target User.
217    pub user: UserId,
218}
219
220impl ActionCompute for RegisterUser {
221    #[arkhe_pure]
222    fn compute<'i>(&self, ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
223        if !AuthCredential::validate_kdf_params(self.credential.kdf, &self.credential.kdf_params) {
224            return Err(ActionError::InvalidInput("KDF params below minimum"));
225        }
226        let user_entity = ctx.spawn_entity_for::<UserProfile>()?;
227        ctx.set_component(user_entity, &self.profile)?;
228        ctx.set_component(user_entity, &self.credential)?;
229        // A freshly registered user is Active. The lifecycle pointer is its
230        // own component (see `UserGdprState`) so later erasure transitions
231        // need no read-modify-write of `UserProfile`.
232        ctx.set_component(
233            user_entity,
234            &UserGdprState {
235                schema_version: 1,
236                status: GdprStatus::Active,
237            },
238        )?;
239        Ok(())
240    }
241}
242
243impl ActionCompute for GdprEraseUser {
244    #[arkhe_pure]
245    fn compute<'i>(&self, ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
246        // Transition the GDPR lifecycle pointer to `ErasurePending` with a
247        // blind, total write of the standalone `UserGdprState` component — no
248        // read required, so this works on the viewless dispatch path. This is
249        // what makes the E-user-3 C3 admission gate LIVE: from the instant
250        // erasure is requested, the gate (which reads `UserGdprState`) rejects
251        // any further actor-originated action by this user. The cascade
252        // observer still drives the actual DEK shred / PII tombstone off the
253        // event below; the terminal `Erased` transition is a symmetric blind
254        // write the integrator drives once the cascade reports completion.
255        ctx.set_component(
256            self.user.get(),
257            &UserGdprState {
258                schema_version: 1,
259                status: GdprStatus::ErasurePending,
260            },
261        )?;
262        let event = UserErasureScheduled {
263            schema_version: 1,
264            user: self.user,
265            scheduled_tick: ctx.tick(),
266        };
267        ctx.emit_event(&event)?;
268        Ok(())
269    }
270}
271
272#[cfg(test)]
273#[allow(clippy::unwrap_used, clippy::expect_used)]
274mod tests {
275    use super::*;
276
277    fn make_uid(v: u64) -> UserId {
278        UserId::new(EntityId::new(v).unwrap())
279    }
280
281    #[test]
282    fn user_id_preserves_underlying_entity() {
283        let uid = make_uid(42);
284        assert_eq!(uid.get().get(), 42);
285    }
286
287    #[test]
288    fn auth_credential_validates_default_argon2id_params() {
289        let params = KdfParams {
290            m_cost: AuthCredential::MIN_ARGON2ID_M_COST,
291            t_cost: AuthCredential::MIN_ARGON2ID_T_COST,
292            p_cost: AuthCredential::MIN_ARGON2ID_P_COST,
293        };
294        assert!(AuthCredential::validate_kdf_params(
295            KdfKind::Argon2id,
296            &params
297        ));
298    }
299
300    #[test]
301    fn auth_credential_rejects_under_cost_argon2id() {
302        let params = KdfParams {
303            m_cost: 1024,
304            t_cost: 1,
305            p_cost: 1,
306        };
307        assert!(!AuthCredential::validate_kdf_params(
308            KdfKind::Argon2id,
309            &params
310        ));
311    }
312
313    #[test]
314    fn scrypt_accepts_power_of_two_n_cost() {
315        // #6 — a valid Scrypt config with N = 2^15 (power of two) passes.
316        let params = KdfParams {
317            m_cost: AuthCredential::MIN_SCRYPT_N_COST,
318            t_cost: AuthCredential::MIN_SCRYPT_R_COST,
319            p_cost: 1,
320        };
321        assert!(AuthCredential::validate_kdf_params(
322            KdfKind::Scrypt,
323            &params
324        ));
325        // 2^16 is also a valid power of two above the minimum.
326        let bigger = KdfParams {
327            m_cost: 1 << 16,
328            t_cost: AuthCredential::MIN_SCRYPT_R_COST,
329            p_cost: 1,
330        };
331        assert!(AuthCredential::validate_kdf_params(
332            KdfKind::Scrypt,
333            &bigger
334        ));
335    }
336
337    #[test]
338    fn scrypt_rejects_non_power_of_two_n_cost() {
339        // #6 regression — N above the minimum but not a power of two must be
340        // rejected (RFC 7914 §2). `MIN_SCRYPT_N_COST + 1` = 2^15 + 1 is the
341        // tightest such case.
342        let params = KdfParams {
343            m_cost: AuthCredential::MIN_SCRYPT_N_COST + 1,
344            t_cost: AuthCredential::MIN_SCRYPT_R_COST,
345            p_cost: 1,
346        };
347        assert!(!AuthCredential::validate_kdf_params(
348            KdfKind::Scrypt,
349            &params
350        ));
351        // A large non-power-of-two value is likewise rejected.
352        let odd = KdfParams {
353            m_cost: 100_000,
354            t_cost: AuthCredential::MIN_SCRYPT_R_COST,
355            p_cost: 1,
356        };
357        assert!(!AuthCredential::validate_kdf_params(KdfKind::Scrypt, &odd));
358    }
359
360    #[test]
361    fn gdpr_status_roundtrip_via_postcard() {
362        let s = GdprStatus::ErasurePending;
363        let b = postcard::to_stdvec(&s).unwrap();
364        let back: GdprStatus = postcard::from_bytes(&b).unwrap();
365        assert_eq!(s, back);
366    }
367}