Skip to main content

arkhe_forge_core/
user.rs

1//! User primitive — Identity Subject.
2//!
3//! Runtime-global identity carrier. `UserProfile` tracks GDPR lifecycle; one
4//! or more `AuthCredential`s attach Argon2id / Scrypt KDF secrets. The User
5//! is intentionally shell-agnostic — legal / billing / GDPR obligations cross
6//! shell boundaries.
7
8use arkhe_kernel::abi::{EntityId, Tick};
9use serde::{Deserialize, Serialize};
10
11use crate::action::ActionCompute;
12use crate::context::{ActionContext, ActionError};
13use crate::event::UserErasureScheduled;
14use crate::ArkheAction;
15use crate::ArkheComponent;
16// E14.L1-Deny enforcement on Action::compute.
17use crate::arkhe_pure;
18
19/// Opaque handle into the runtime User namespace.
20#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
21#[serde(transparent)]
22pub struct UserId(EntityId);
23
24impl UserId {
25    /// Construct a `UserId` from a runtime-allocated `EntityId`. Callers must
26    /// hold proof (spawn event, admin scope, or test fixture) that the id
27    /// belongs to the User namespace — this constructor does not verify.
28    #[inline]
29    #[must_use]
30    pub fn new(id: EntityId) -> Self {
31        Self(id)
32    }
33
34    /// Underlying entity handle.
35    #[inline]
36    #[must_use]
37    pub fn get(self) -> EntityId {
38        self.0
39    }
40}
41
42/// Authentication channel family. `#[non_exhaustive]` so new variants
43/// (WebAuthn extensions, social federation) can append without breaking
44/// compatibility.
45#[non_exhaustive]
46#[repr(u8)]
47#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
48pub enum AuthKind {
49    /// WebAuthn / FIDO2 credential.
50    Passkey = 0,
51    /// Email OTP / magic link.
52    Email = 1,
53    /// Platform handle + password.
54    Handle = 2,
55    /// Wallet / on-chain address.
56    Address = 3,
57}
58
59/// GDPR lifecycle state. Transition to `ErasurePending` blocks all
60/// actor-originated Actions on the user (compute MC gate, contract #5).
61#[non_exhaustive]
62#[repr(u8)]
63#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
64pub enum GdprStatus {
65    /// Normal state — user can operate.
66    Active = 0,
67    /// Scheduled for crypto-erasure; cascade observer completes soon.
68    ErasurePending = 1,
69    /// Crypto-erasure completed — DEK shredded, no content recoverable.
70    Erased = 2,
71}
72
73/// Password-hashing algorithm family.
74#[non_exhaustive]
75#[repr(u8)]
76#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
77pub enum KdfKind {
78    /// Argon2id (OWASP 2024 recommended default).
79    Argon2id = 0,
80    /// Scrypt (fallback for legacy imports).
81    Scrypt = 1,
82}
83
84/// KDF cost parameters.
85#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
86pub struct KdfParams {
87    /// Memory cost (KiB for Argon2id).
88    pub m_cost: u32,
89    /// Time cost (iteration count).
90    pub t_cost: u32,
91    /// Parallelism factor.
92    pub p_cost: u32,
93}
94
95/// User profile Component — exactly one per User entity (invariant E-user-1).
96#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
97#[arkhe(type_code = 0x0003_0001, schema_version = 1)]
98pub struct UserProfile {
99    /// Wire-level schema version tag (A15 succession).
100    pub schema_version: u16,
101    /// Tick at which `RegisterUser` completed.
102    pub created_tick: Tick,
103    /// Auth family of the initial `AuthCredential`.
104    pub primary_auth_kind: AuthKind,
105    /// GDPR lifecycle pointer.
106    pub gdpr_status: GdprStatus,
107}
108
109/// Stored authentication credential — at least one per User (invariant
110/// E-user-2). Secret material is a KDF output; no raw password is stored.
111#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
112#[arkhe(type_code = 0x0003_0002, schema_version = 1)]
113pub struct AuthCredential {
114    /// Wire-level schema version tag.
115    pub schema_version: u16,
116    /// Channel family.
117    pub kind: AuthKind,
118    /// Hash family. `Argon2id` is the runtime default.
119    pub kdf: KdfKind,
120    /// Per-credential random salt (16 bytes).
121    pub salt: [u8; 16],
122    /// KDF output: `kdf(password, salt, params)`.
123    pub credential_hash: [u8; 32],
124    /// Cost parameters used for `credential_hash`.
125    pub kdf_params: KdfParams,
126    /// Optional rotation deadline (S8 anchor).
127    pub expires_tick: Option<Tick>,
128    /// Tick at which the credential was bound.
129    pub bound_tick: Tick,
130}
131
132impl AuthCredential {
133    /// Runtime default KDF — OWASP 2024 recommendation.
134    pub const DEFAULT_KDF: KdfKind = KdfKind::Argon2id;
135    /// Minimum Argon2id memory cost (19 MiB).
136    pub const MIN_ARGON2ID_M_COST: u32 = 19_456;
137    /// Minimum Argon2id iteration count.
138    pub const MIN_ARGON2ID_T_COST: u32 = 2;
139    /// Minimum Argon2id parallelism.
140    pub const MIN_ARGON2ID_P_COST: u32 = 1;
141    /// Minimum Scrypt cost N (power-of-two, ≥ 2^15).
142    pub const MIN_SCRYPT_N_COST: u32 = 1 << 15;
143    /// Minimum Scrypt block-size `r`.
144    pub const MIN_SCRYPT_R_COST: u32 = 8;
145
146    /// L1-compute validator for KDF parameters — rejects weak settings.
147    #[must_use]
148    pub fn validate_kdf_params(kdf: KdfKind, p: &KdfParams) -> bool {
149        match kdf {
150            KdfKind::Argon2id => {
151                p.m_cost >= Self::MIN_ARGON2ID_M_COST
152                    && p.t_cost >= Self::MIN_ARGON2ID_T_COST
153                    && p.p_cost >= Self::MIN_ARGON2ID_P_COST
154            }
155            KdfKind::Scrypt => {
156                p.m_cost >= Self::MIN_SCRYPT_N_COST
157                    && p.t_cost >= Self::MIN_SCRYPT_R_COST
158                    && p.p_cost >= 1
159            }
160        }
161    }
162}
163
164/// Register a fresh `User` with the supplied profile and credential.
165#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
166#[arkhe(type_code = 0x0001_0001, schema_version = 1, band = 1)]
167pub struct RegisterUser {
168    /// Wire-level schema version tag.
169    pub schema_version: u16,
170    /// Profile Component contents.
171    pub profile: UserProfile,
172    /// Initial credential.
173    pub credential: AuthCredential,
174}
175
176/// Request GDPR crypto-erasure for an existing User. Lease — actual cascade
177/// runs via the erasure-cascade observer with p95 < 24h SLA.
178#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
179#[arkhe(type_code = 0x0001_0003, schema_version = 1, band = 1)]
180pub struct GdprEraseUser {
181    /// Wire-level schema version tag.
182    pub schema_version: u16,
183    /// Target User.
184    pub user: UserId,
185}
186
187impl ActionCompute for RegisterUser {
188    #[arkhe_pure]
189    fn compute<'i>(&self, ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
190        if !AuthCredential::validate_kdf_params(self.credential.kdf, &self.credential.kdf_params) {
191            return Err(ActionError::InvalidInput("KDF params below minimum"));
192        }
193        let user_entity = ctx.spawn_entity_for::<UserProfile>()?;
194        ctx.set_component(user_entity, &self.profile)?;
195        ctx.set_component(user_entity, &self.credential)?;
196        Ok(())
197    }
198}
199
200impl ActionCompute for GdprEraseUser {
201    #[arkhe_pure]
202    fn compute<'i>(&self, ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
203        let event = UserErasureScheduled {
204            schema_version: 1,
205            user: self.user,
206            scheduled_tick: ctx.tick(),
207        };
208        ctx.emit_event(&event)?;
209        Ok(())
210    }
211}
212
213#[cfg(test)]
214#[allow(clippy::unwrap_used, clippy::expect_used)]
215mod tests {
216    use super::*;
217
218    fn make_uid(v: u64) -> UserId {
219        UserId::new(EntityId::new(v).unwrap())
220    }
221
222    #[test]
223    fn user_id_preserves_underlying_entity() {
224        let uid = make_uid(42);
225        assert_eq!(uid.get().get(), 42);
226    }
227
228    #[test]
229    fn auth_credential_validates_default_argon2id_params() {
230        let params = KdfParams {
231            m_cost: AuthCredential::MIN_ARGON2ID_M_COST,
232            t_cost: AuthCredential::MIN_ARGON2ID_T_COST,
233            p_cost: AuthCredential::MIN_ARGON2ID_P_COST,
234        };
235        assert!(AuthCredential::validate_kdf_params(
236            KdfKind::Argon2id,
237            &params
238        ));
239    }
240
241    #[test]
242    fn auth_credential_rejects_under_cost_argon2id() {
243        let params = KdfParams {
244            m_cost: 1024,
245            t_cost: 1,
246            p_cost: 1,
247        };
248        assert!(!AuthCredential::validate_kdf_params(
249            KdfKind::Argon2id,
250            &params
251        ));
252    }
253
254    #[test]
255    fn gdpr_status_roundtrip_via_postcard() {
256        let s = GdprStatus::ErasurePending;
257        let b = postcard::to_stdvec(&s).unwrap();
258        let back: GdprStatus = postcard::from_bytes(&b).unwrap();
259        assert_eq!(s, back);
260    }
261}