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 ¶ms
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 ¶ms
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 ¶ms
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 ¶ms
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}