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