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, ArkheAction as _};
13use crate::component::ArkheComponent as _;
14use crate::context::{ensure_schema_version, ActionContext, ActionError};
15use crate::event::UserErasureScheduled;
16use crate::ArkheAction;
17use crate::ArkheComponent;
18// E14.L1-Deny enforcement on Action::compute.
19use crate::arkhe_pure;
20
21/// Opaque handle into the runtime User namespace.
22#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
23#[serde(transparent)]
24pub struct UserId(EntityId);
25
26impl UserId {
27    /// Construct a `UserId` from a runtime-allocated `EntityId`. Callers must
28    /// hold proof (spawn event, admin scope, or test fixture) that the id
29    /// belongs to the User namespace — this constructor does not verify.
30    #[inline]
31    #[must_use]
32    pub fn new(id: EntityId) -> Self {
33        Self(id)
34    }
35
36    /// Underlying entity handle.
37    #[inline]
38    #[must_use]
39    pub fn get(self) -> EntityId {
40        self.0
41    }
42}
43
44/// Authentication channel family. `#[non_exhaustive]` so new variants
45/// (WebAuthn extensions, social federation) can append without breaking
46/// compatibility.
47#[non_exhaustive]
48#[repr(u8)]
49#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
50pub enum AuthKind {
51    /// WebAuthn / FIDO2 credential.
52    Passkey = 0,
53    /// Email OTP / magic link.
54    Email = 1,
55    /// Platform handle + password.
56    Handle = 2,
57    /// Wallet / on-chain address.
58    Address = 3,
59}
60
61/// GDPR lifecycle state. Any non-`Active` state — `ErasurePending` (erasure
62/// requested) or the terminal `Erased` (crypto-erasure completed) — blocks all
63/// actor-originated Actions on the user (compute MC gate, contract #5).
64#[non_exhaustive]
65#[repr(u8)]
66#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
67pub enum GdprStatus {
68    /// Normal state — user can operate.
69    Active = 0,
70    /// Scheduled for crypto-erasure; cascade observer completes soon.
71    ErasurePending = 1,
72    /// Crypto-erasure completed — DEK shredded, no content recoverable.
73    Erased = 2,
74}
75
76/// Password-hashing algorithm family.
77#[non_exhaustive]
78#[repr(u8)]
79#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
80pub enum KdfKind {
81    /// Argon2id (OWASP 2024 recommended default).
82    Argon2id = 0,
83    /// Scrypt (fallback for legacy imports).
84    Scrypt = 1,
85}
86
87/// KDF cost parameters.
88#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
89pub struct KdfParams {
90    /// Memory cost (KiB for Argon2id).
91    pub m_cost: u32,
92    /// Time cost (iteration count).
93    pub t_cost: u32,
94    /// Parallelism factor.
95    pub p_cost: u32,
96}
97
98/// User profile Component — exactly one per User entity (invariant E-user-1).
99///
100/// The GDPR lifecycle pointer lives in its own [`UserGdprState`] component,
101/// NOT here: a lifecycle transition must be writable on the viewless
102/// dispatch-path compute (which cannot read existing state to do a
103/// read-modify-write), so the pointer is split out to keep `UserProfile`'s
104/// immutable-after-registration fields untouched by erasure transitions.
105#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
106#[arkhe(type_code = 0x0003_0001, schema_version = 1)]
107pub struct UserProfile {
108    /// Wire-level schema version tag (A15 succession).
109    pub schema_version: u16,
110    /// Tick at which `RegisterUser` completed.
111    pub created_tick: Tick,
112    /// Auth family of the initial `AuthCredential`.
113    pub primary_auth_kind: AuthKind,
114}
115
116/// GDPR lifecycle pointer — its own single-field Component (exactly one per
117/// User) and the sole source of truth for erasure eligibility.
118///
119/// Split out of [`UserProfile`] so a lifecycle transition is a blind, TOTAL
120/// `set_component` write: the viewless dispatch-path compute (see
121/// [`crate::bridge`]) cannot read existing kernel state, so it cannot
122/// read-modify-write a bundled struct without clobbering its other fields.
123/// A standalone single-field component makes the transition a correct total
124/// write with no read. The blind write commits only for a SPAWNED user
125/// entity — the kernel ledger treats `SetComponent` on an unknown entity as
126/// a no-op — which `RegisterUser` guarantees (it spawns the user entity
127/// before seeding this component), so every registered user is reachable by
128/// the transition. The admission gate
129/// ([`ActionContext::ensure_actor_eligible`](crate::context::ActionContext::ensure_actor_eligible))
130/// reads THIS component; a resolved binding whose user lacks one fails
131/// closed
132/// ([`ActionError::UserLifecycleUnresolved`](crate::context::ActionError))
133/// — a freshly registered user always carries one (`RegisterUser` seeds
134/// `Active`).
135#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
136#[arkhe(type_code = 0x0003_0003, schema_version = 1)]
137pub struct UserGdprState {
138    /// Wire-level schema version tag (A15 succession).
139    pub schema_version: u16,
140    /// GDPR lifecycle pointer.
141    pub status: GdprStatus,
142}
143
144/// Stored authentication credential — at least one per User (invariant
145/// E-user-2). Secret material is a KDF output; no raw password is stored.
146#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
147#[arkhe(type_code = 0x0003_0002, schema_version = 1)]
148pub struct AuthCredential {
149    /// Wire-level schema version tag.
150    pub schema_version: u16,
151    /// Channel family.
152    pub kind: AuthKind,
153    /// Hash family. `Argon2id` is the runtime default.
154    pub kdf: KdfKind,
155    /// Per-credential random salt (16 bytes).
156    pub salt: [u8; 16],
157    /// KDF output: `kdf(password, salt, params)`.
158    pub credential_hash: [u8; 32],
159    /// Cost parameters used for `credential_hash`.
160    pub kdf_params: KdfParams,
161    /// Optional rotation deadline (S8 anchor).
162    pub expires_tick: Option<Tick>,
163    /// Tick at which the credential was bound.
164    pub bound_tick: Tick,
165}
166
167impl AuthCredential {
168    /// Runtime default KDF — OWASP 2024 recommendation.
169    pub const DEFAULT_KDF: KdfKind = KdfKind::Argon2id;
170    /// Minimum Argon2id memory cost (19 MiB).
171    pub const MIN_ARGON2ID_M_COST: u32 = 19_456;
172    /// Minimum Argon2id iteration count.
173    pub const MIN_ARGON2ID_T_COST: u32 = 2;
174    /// Minimum Argon2id parallelism.
175    pub const MIN_ARGON2ID_P_COST: u32 = 1;
176    /// Minimum Scrypt cost N (power-of-two, ≥ 2^15).
177    pub const MIN_SCRYPT_N_COST: u32 = 1 << 15;
178    /// Minimum Scrypt block-size `r`.
179    pub const MIN_SCRYPT_R_COST: u32 = 8;
180
181    /// L1-compute validator for KDF parameters — rejects weak settings.
182    #[must_use]
183    pub fn validate_kdf_params(kdf: KdfKind, p: &KdfParams) -> bool {
184        match kdf {
185            KdfKind::Argon2id => {
186                p.m_cost >= Self::MIN_ARGON2ID_M_COST
187                    && p.t_cost >= Self::MIN_ARGON2ID_T_COST
188                    && p.p_cost >= Self::MIN_ARGON2ID_P_COST
189            }
190            KdfKind::Scrypt => {
191                // Scrypt's N (cost) parameter MUST be a power of two — both
192                // the `MIN_SCRYPT_N_COST` doc (2^15) and RFC 7914 §2 require
193                // it. A non-power-of-two N is rejected by the scrypt
194                // primitive at hash time; enforce it here so a weak / invalid
195                // credential never reaches `set_component`.
196                p.m_cost >= Self::MIN_SCRYPT_N_COST
197                    && p.m_cost.is_power_of_two()
198                    && p.t_cost >= Self::MIN_SCRYPT_R_COST
199                    && p.p_cost >= 1
200            }
201        }
202    }
203}
204
205/// Register a fresh `User` with the supplied profile and credential.
206#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
207#[arkhe(type_code = 0x0001_0001, schema_version = 1, band = 1)]
208pub struct RegisterUser {
209    /// Wire-level schema version tag.
210    pub schema_version: u16,
211    /// Profile Component contents.
212    pub profile: UserProfile,
213    /// Initial credential.
214    pub credential: AuthCredential,
215}
216
217/// Request GDPR crypto-erasure for an existing User. Lease — actual cascade
218/// runs via the erasure-cascade observer with p95 < 24h SLA.
219#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
220#[arkhe(type_code = 0x0001_0003, schema_version = 1, band = 1)]
221pub struct GdprEraseUser {
222    /// Wire-level schema version tag.
223    pub schema_version: u16,
224    /// Target User.
225    pub user: UserId,
226}
227
228impl ActionCompute for RegisterUser {
229    #[arkhe_pure]
230    fn compute<'i>(&self, ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
231        // Validate-then-copy: wire schema versions are checked against the
232        // canonical constants before any other gate, so a stale or forged
233        // version never reaches the stored components.
234        ensure_schema_version(Self::SCHEMA_VERSION, self.schema_version)?;
235        ensure_schema_version(UserProfile::SCHEMA_VERSION, self.profile.schema_version)?;
236        ensure_schema_version(
237            AuthCredential::SCHEMA_VERSION,
238            self.credential.schema_version,
239        )?;
240
241        if !AuthCredential::validate_kdf_params(self.credential.kdf, &self.credential.kdf_params) {
242            return Err(ActionError::InvalidInput("KDF params below minimum"));
243        }
244        let user_entity = ctx.spawn_entity_for::<UserProfile>()?;
245        ctx.set_component(user_entity, &self.profile)?;
246        ctx.set_component(user_entity, &self.credential)?;
247        // A freshly registered user is Active. The lifecycle pointer is its
248        // own component (see `UserGdprState`) so later erasure transitions
249        // need no read-modify-write of `UserProfile`.
250        ctx.set_component(
251            user_entity,
252            &UserGdprState {
253                schema_version: 1,
254                status: GdprStatus::Active,
255            },
256        )?;
257        Ok(())
258    }
259}
260
261impl ActionCompute for GdprEraseUser {
262    #[arkhe_pure]
263    fn compute<'i>(&self, ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
264        ensure_schema_version(Self::SCHEMA_VERSION, self.schema_version)?;
265        // Transition the GDPR lifecycle pointer to `ErasurePending` with a
266        // blind, total write of the standalone `UserGdprState` component — no
267        // read required, so this works on the viewless dispatch path. This is
268        // half of what makes the E-user-3 C3 admission gate LIVE: from the
269        // instant erasure is requested, the gate (which reads `UserGdprState`)
270        // rejects any further action by an actor whose `UserBinding` (written
271        // by `RegisterActor`) names this user. The write commits
272        // because `RegisterUser` spawned the user entity (the kernel ledger
273        // no-ops a SetComponent on an unknown entity). An erase request for a
274        // never-registered user id no-ops — and an actor bound to such a user
275        // is not thereby ungateable: the gate fails closed on a resolved
276        // binding whose user has no `UserGdprState`
277        // (`ActionError::UserLifecycleUnresolved`). The cascade
278        // observer still drives the actual DEK shred / PII tombstone off the
279        // event below; the terminal `Erased` transition is a symmetric blind
280        // write the integrator drives once the cascade reports completion.
281        ctx.set_component(
282            self.user.get(),
283            &UserGdprState {
284                schema_version: 1,
285                status: GdprStatus::ErasurePending,
286            },
287        )?;
288        let event = UserErasureScheduled {
289            schema_version: 1,
290            user: self.user,
291            scheduled_tick: ctx.tick(),
292        };
293        ctx.emit_event(&event)?;
294        Ok(())
295    }
296}
297
298#[cfg(test)]
299#[allow(clippy::unwrap_used, clippy::expect_used)]
300mod tests {
301    use super::*;
302
303    fn make_uid(v: u64) -> UserId {
304        UserId::new(EntityId::new(v).unwrap())
305    }
306
307    #[test]
308    fn user_id_preserves_underlying_entity() {
309        let uid = make_uid(42);
310        assert_eq!(uid.get().get(), 42);
311    }
312
313    #[test]
314    fn auth_credential_validates_default_argon2id_params() {
315        let params = KdfParams {
316            m_cost: AuthCredential::MIN_ARGON2ID_M_COST,
317            t_cost: AuthCredential::MIN_ARGON2ID_T_COST,
318            p_cost: AuthCredential::MIN_ARGON2ID_P_COST,
319        };
320        assert!(AuthCredential::validate_kdf_params(
321            KdfKind::Argon2id,
322            &params
323        ));
324    }
325
326    #[test]
327    fn auth_credential_rejects_under_cost_argon2id() {
328        let params = KdfParams {
329            m_cost: 1024,
330            t_cost: 1,
331            p_cost: 1,
332        };
333        assert!(!AuthCredential::validate_kdf_params(
334            KdfKind::Argon2id,
335            &params
336        ));
337    }
338
339    #[test]
340    fn scrypt_accepts_power_of_two_n_cost() {
341        // #6 — a valid Scrypt config with N = 2^15 (power of two) passes.
342        let params = KdfParams {
343            m_cost: AuthCredential::MIN_SCRYPT_N_COST,
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        // 2^16 is also a valid power of two above the minimum.
352        let bigger = KdfParams {
353            m_cost: 1 << 16,
354            t_cost: AuthCredential::MIN_SCRYPT_R_COST,
355            p_cost: 1,
356        };
357        assert!(AuthCredential::validate_kdf_params(
358            KdfKind::Scrypt,
359            &bigger
360        ));
361    }
362
363    #[test]
364    fn scrypt_rejects_non_power_of_two_n_cost() {
365        // #6 regression — N above the minimum but not a power of two must be
366        // rejected (RFC 7914 §2). `MIN_SCRYPT_N_COST + 1` = 2^15 + 1 is the
367        // tightest such case.
368        let params = KdfParams {
369            m_cost: AuthCredential::MIN_SCRYPT_N_COST + 1,
370            t_cost: AuthCredential::MIN_SCRYPT_R_COST,
371            p_cost: 1,
372        };
373        assert!(!AuthCredential::validate_kdf_params(
374            KdfKind::Scrypt,
375            &params
376        ));
377        // A large non-power-of-two value is likewise rejected.
378        let odd = KdfParams {
379            m_cost: 100_000,
380            t_cost: AuthCredential::MIN_SCRYPT_R_COST,
381            p_cost: 1,
382        };
383        assert!(!AuthCredential::validate_kdf_params(KdfKind::Scrypt, &odd));
384    }
385
386    #[test]
387    fn gdpr_status_roundtrip_via_postcard() {
388        let s = GdprStatus::ErasurePending;
389        let b = postcard::to_stdvec(&s).unwrap();
390        let back: GdprStatus = postcard::from_bytes(&b).unwrap();
391        assert_eq!(s, back);
392    }
393
394    fn test_ctx() -> ActionContext<'static> {
395        use arkhe_kernel::abi::{CapabilityMask, InstanceId, Principal};
396        ActionContext::new(
397            [0u8; 32],
398            InstanceId::new(1).unwrap(),
399            Tick(7),
400            Principal::System,
401            CapabilityMask::SYSTEM,
402        )
403    }
404
405    fn valid_register_user() -> RegisterUser {
406        RegisterUser {
407            schema_version: 1,
408            profile: UserProfile {
409                schema_version: 1,
410                created_tick: Tick(0),
411                primary_auth_kind: AuthKind::Passkey,
412            },
413            credential: AuthCredential {
414                schema_version: 1,
415                kind: AuthKind::Passkey,
416                kdf: KdfKind::Argon2id,
417                salt: [0u8; 16],
418                credential_hash: [0u8; 32],
419                kdf_params: KdfParams {
420                    m_cost: AuthCredential::MIN_ARGON2ID_M_COST,
421                    t_cost: AuthCredential::MIN_ARGON2ID_T_COST,
422                    p_cost: AuthCredential::MIN_ARGON2ID_P_COST,
423                },
424                expires_tick: None,
425                bound_tick: Tick(0),
426            },
427        }
428    }
429
430    fn assert_schema_mismatch(err: &ActionError) {
431        assert!(
432            matches!(
433                err,
434                ActionError::SchemaMismatch {
435                    expected: 1,
436                    got: 0xBEEF,
437                }
438            ),
439            "got {err:?}",
440        );
441    }
442
443    #[test]
444    fn register_user_rejects_wire_schema_mismatch() {
445        let mut c = test_ctx();
446
447        // Action-level wire field — first check, fires before KDF validation.
448        let mut act = valid_register_user();
449        act.schema_version = 0xBEEF;
450        assert_schema_mismatch(&act.compute(&mut c).expect_err("action field"));
451        assert!(c.ops().is_empty(), "no Ops on rejection");
452
453        // Nested profile field — validated before the copy into storage.
454        let mut act = valid_register_user();
455        act.profile.schema_version = 0xBEEF;
456        assert_schema_mismatch(&act.compute(&mut c).expect_err("profile field"));
457        assert!(c.ops().is_empty(), "no Ops on rejection");
458
459        // Nested credential field — same gate.
460        let mut act = valid_register_user();
461        act.credential.schema_version = 0xBEEF;
462        assert_schema_mismatch(&act.compute(&mut c).expect_err("credential field"));
463        assert!(c.ops().is_empty(), "no Ops on rejection");
464
465        // Matching versions proceed.
466        valid_register_user()
467            .compute(&mut c)
468            .expect("matching versions → Ok");
469    }
470
471    #[test]
472    fn gdpr_erase_user_rejects_wire_schema_mismatch() {
473        let mut c = test_ctx();
474        let err = GdprEraseUser {
475            schema_version: 0xBEEF,
476            user: make_uid(42),
477        }
478        .compute(&mut c)
479        .expect_err("wire schema mismatch must reject");
480        assert_schema_mismatch(&err);
481        assert!(c.ops().is_empty(), "no Ops on rejection");
482
483        GdprEraseUser {
484            schema_version: 1,
485            user: make_uid(42),
486        }
487        .compute(&mut c)
488        .expect("matching version → Ok");
489    }
490}