arkhe-forge-core 0.15.0

L1 primitives for ArkheForge Runtime: Core 5 (User / Actor / Space / Entry / Activity) + ShellBrand invariant-lifetime isolation + deterministic entity-id derivation. Pure compute, no I/O.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
//! User primitive — Identity Subject.
//!
//! Runtime-global identity carrier. `UserProfile` holds the registration
//! facts; `UserGdprState` tracks the GDPR lifecycle pointer; one
//! or more `AuthCredential`s attach Argon2id / Scrypt KDF secrets. The User
//! is intentionally shell-agnostic — legal / billing / GDPR obligations cross
//! shell boundaries.

use arkhe_kernel::abi::{EntityId, Tick};
use serde::{Deserialize, Serialize};

use crate::action::{ActionCompute, ArkheAction as _};
use crate::component::ArkheComponent as _;
use crate::context::{ensure_schema_version, ActionContext, ActionError};
use crate::event::UserErasureScheduled;
use crate::ArkheAction;
use crate::ArkheComponent;
// E14.L1-Deny enforcement on Action::compute.
use crate::arkhe_pure;

/// Opaque handle into the runtime User namespace.
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
#[serde(transparent)]
pub struct UserId(EntityId);

impl UserId {
    /// Construct a `UserId` from a runtime-allocated `EntityId`. Callers must
    /// hold proof (spawn event, admin scope, or test fixture) that the id
    /// belongs to the User namespace — this constructor does not verify.
    #[inline]
    #[must_use]
    pub fn new(id: EntityId) -> Self {
        Self(id)
    }

    /// Underlying entity handle.
    #[inline]
    #[must_use]
    pub fn get(self) -> EntityId {
        self.0
    }
}

/// Authentication channel family. `#[non_exhaustive]` so new variants
/// (WebAuthn extensions, social federation) can append without breaking
/// compatibility.
#[non_exhaustive]
#[repr(u8)]
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
pub enum AuthKind {
    /// WebAuthn / FIDO2 credential.
    Passkey = 0,
    /// Email OTP / magic link.
    Email = 1,
    /// Platform handle + password.
    Handle = 2,
    /// Wallet / on-chain address.
    Address = 3,
}

/// GDPR lifecycle state. Any non-`Active` state — `ErasurePending` (erasure
/// requested) or the terminal `Erased` (crypto-erasure completed) — blocks all
/// actor-originated Actions on the user (compute MC gate, contract #5).
#[non_exhaustive]
#[repr(u8)]
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
pub enum GdprStatus {
    /// Normal state — user can operate.
    Active = 0,
    /// Scheduled for crypto-erasure; cascade observer completes soon.
    ErasurePending = 1,
    /// Crypto-erasure completed — DEK shredded, no content recoverable.
    Erased = 2,
}

/// Password-hashing algorithm family.
#[non_exhaustive]
#[repr(u8)]
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
pub enum KdfKind {
    /// Argon2id (OWASP 2024 recommended default).
    Argon2id = 0,
    /// Scrypt (fallback for legacy imports).
    Scrypt = 1,
}

/// KDF cost parameters.
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct KdfParams {
    /// Memory cost (KiB for Argon2id).
    pub m_cost: u32,
    /// Time cost (iteration count).
    pub t_cost: u32,
    /// Parallelism factor.
    pub p_cost: u32,
}

/// User profile Component — exactly one per User entity (invariant E-user-1).
///
/// The GDPR lifecycle pointer lives in its own [`UserGdprState`] component,
/// NOT here: a lifecycle transition must be writable on the viewless
/// dispatch-path compute (which cannot read existing state to do a
/// read-modify-write), so the pointer is split out to keep `UserProfile`'s
/// immutable-after-registration fields untouched by erasure transitions.
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
#[arkhe(type_code = 0x0003_0001, schema_version = 1)]
pub struct UserProfile {
    /// Wire-level schema version tag (A15 succession).
    pub schema_version: u16,
    /// Tick at which `RegisterUser` completed.
    pub created_tick: Tick,
    /// Auth family of the initial `AuthCredential`.
    pub primary_auth_kind: AuthKind,
}

/// GDPR lifecycle pointer — its own single-field Component (exactly one per
/// User) and the sole source of truth for erasure eligibility.
///
/// Split out of [`UserProfile`] so a lifecycle transition is a blind, TOTAL
/// `set_component` write: the viewless dispatch-path compute (see
/// [`crate::bridge`]) cannot read existing kernel state, so it cannot
/// read-modify-write a bundled struct without clobbering its other fields.
/// A standalone single-field component makes the transition a correct total
/// write with no read. The blind write commits only for a SPAWNED user
/// entity — the kernel ledger treats `SetComponent` on an unknown entity as
/// a no-op — which `RegisterUser` guarantees (it spawns the user entity
/// before seeding this component), so every registered user is reachable by
/// the transition. The admission gate
/// ([`ActionContext::ensure_actor_eligible`](crate::context::ActionContext::ensure_actor_eligible))
/// reads THIS component; a resolved binding whose user lacks one fails
/// closed
/// ([`ActionError::UserLifecycleUnresolved`](crate::context::ActionError))
/// — a freshly registered user always carries one (`RegisterUser` seeds
/// `Active`).
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
#[arkhe(type_code = 0x0003_0003, schema_version = 1)]
pub struct UserGdprState {
    /// Wire-level schema version tag (A15 succession).
    pub schema_version: u16,
    /// GDPR lifecycle pointer.
    pub status: GdprStatus,
}

/// Stored authentication credential — at least one per User (invariant
/// E-user-2). Secret material is a KDF output; no raw password is stored.
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
#[arkhe(type_code = 0x0003_0002, schema_version = 1)]
pub struct AuthCredential {
    /// Wire-level schema version tag.
    pub schema_version: u16,
    /// Channel family.
    pub kind: AuthKind,
    /// Hash family. `Argon2id` is the runtime default.
    pub kdf: KdfKind,
    /// Per-credential random salt (16 bytes).
    pub salt: [u8; 16],
    /// KDF output: `kdf(password, salt, params)`.
    pub credential_hash: [u8; 32],
    /// Cost parameters used for `credential_hash`.
    pub kdf_params: KdfParams,
    /// Optional rotation deadline (S8 anchor).
    pub expires_tick: Option<Tick>,
    /// Tick at which the credential was bound.
    pub bound_tick: Tick,
}

impl AuthCredential {
    /// Runtime default KDF — OWASP 2024 recommendation.
    pub const DEFAULT_KDF: KdfKind = KdfKind::Argon2id;
    /// Minimum Argon2id memory cost (19 MiB).
    pub const MIN_ARGON2ID_M_COST: u32 = 19_456;
    /// Minimum Argon2id iteration count.
    pub const MIN_ARGON2ID_T_COST: u32 = 2;
    /// Minimum Argon2id parallelism.
    pub const MIN_ARGON2ID_P_COST: u32 = 1;
    /// Minimum Scrypt cost N (power-of-two, ≥ 2^15).
    pub const MIN_SCRYPT_N_COST: u32 = 1 << 15;
    /// Minimum Scrypt block-size `r`.
    pub const MIN_SCRYPT_R_COST: u32 = 8;

    /// L1-compute validator for KDF parameters — rejects weak settings.
    #[must_use]
    pub fn validate_kdf_params(kdf: KdfKind, p: &KdfParams) -> bool {
        match kdf {
            KdfKind::Argon2id => {
                p.m_cost >= Self::MIN_ARGON2ID_M_COST
                    && p.t_cost >= Self::MIN_ARGON2ID_T_COST
                    && p.p_cost >= Self::MIN_ARGON2ID_P_COST
            }
            KdfKind::Scrypt => {
                // Scrypt's N (cost) parameter MUST be a power of two — both
                // the `MIN_SCRYPT_N_COST` doc (2^15) and RFC 7914 §2 require
                // it. A non-power-of-two N is rejected by the scrypt
                // primitive at hash time; enforce it here so a weak / invalid
                // credential never reaches `set_component`.
                p.m_cost >= Self::MIN_SCRYPT_N_COST
                    && p.m_cost.is_power_of_two()
                    && p.t_cost >= Self::MIN_SCRYPT_R_COST
                    && p.p_cost >= 1
            }
        }
    }
}

/// Register a fresh `User` with the supplied profile and credential.
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
#[arkhe(type_code = 0x0001_0001, schema_version = 1, band = 1)]
pub struct RegisterUser {
    /// Wire-level schema version tag.
    pub schema_version: u16,
    /// Profile Component contents.
    pub profile: UserProfile,
    /// Initial credential.
    pub credential: AuthCredential,
}

/// Request GDPR crypto-erasure for an existing User. Lease — actual cascade
/// runs via the erasure-cascade observer with p95 < 24h SLA.
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
#[arkhe(type_code = 0x0001_0003, schema_version = 1, band = 1)]
pub struct GdprEraseUser {
    /// Wire-level schema version tag.
    pub schema_version: u16,
    /// Target User.
    pub user: UserId,
}

impl ActionCompute for RegisterUser {
    #[arkhe_pure]
    fn compute<'i>(&self, ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
        // Validate-then-copy: wire schema versions are checked against the
        // canonical constants before any other gate, so a stale or forged
        // version never reaches the stored components.
        ensure_schema_version(Self::SCHEMA_VERSION, self.schema_version)?;
        ensure_schema_version(UserProfile::SCHEMA_VERSION, self.profile.schema_version)?;
        ensure_schema_version(
            AuthCredential::SCHEMA_VERSION,
            self.credential.schema_version,
        )?;

        if !AuthCredential::validate_kdf_params(self.credential.kdf, &self.credential.kdf_params) {
            return Err(ActionError::InvalidInput("KDF params below minimum"));
        }
        let user_entity = ctx.spawn_entity_for::<UserProfile>()?;
        ctx.set_component(user_entity, &self.profile)?;
        ctx.set_component(user_entity, &self.credential)?;
        // A freshly registered user is Active. The lifecycle pointer is its
        // own component (see `UserGdprState`) so later erasure transitions
        // need no read-modify-write of `UserProfile`.
        ctx.set_component(
            user_entity,
            &UserGdprState {
                schema_version: 1,
                status: GdprStatus::Active,
            },
        )?;
        Ok(())
    }
}

impl ActionCompute for GdprEraseUser {
    #[arkhe_pure]
    fn compute<'i>(&self, ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
        ensure_schema_version(Self::SCHEMA_VERSION, self.schema_version)?;
        // Transition the GDPR lifecycle pointer to `ErasurePending` with a
        // blind, total write of the standalone `UserGdprState` component — no
        // read required, so this works on the viewless dispatch path. This is
        // half of what makes the E-user-3 C3 admission gate LIVE: from the
        // instant erasure is requested, the gate (which reads `UserGdprState`)
        // rejects any further action by an actor whose `UserBinding` (written
        // by `RegisterActor`) names this user. The write commits
        // because `RegisterUser` spawned the user entity (the kernel ledger
        // no-ops a SetComponent on an unknown entity). An erase request for a
        // never-registered user id no-ops — and an actor bound to such a user
        // is not thereby ungateable: the gate fails closed on a resolved
        // binding whose user has no `UserGdprState`
        // (`ActionError::UserLifecycleUnresolved`). The cascade
        // observer still drives the actual DEK shred / PII tombstone off the
        // event below; the terminal `Erased` transition is a symmetric blind
        // write the integrator drives once the cascade reports completion.
        ctx.set_component(
            self.user.get(),
            &UserGdprState {
                schema_version: 1,
                status: GdprStatus::ErasurePending,
            },
        )?;
        let event = UserErasureScheduled {
            schema_version: 1,
            user: self.user,
            scheduled_tick: ctx.tick(),
        };
        ctx.emit_event(&event)?;
        Ok(())
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    use super::*;

    fn make_uid(v: u64) -> UserId {
        UserId::new(EntityId::new(v).unwrap())
    }

    #[test]
    fn user_id_preserves_underlying_entity() {
        let uid = make_uid(42);
        assert_eq!(uid.get().get(), 42);
    }

    #[test]
    fn auth_credential_validates_default_argon2id_params() {
        let params = KdfParams {
            m_cost: AuthCredential::MIN_ARGON2ID_M_COST,
            t_cost: AuthCredential::MIN_ARGON2ID_T_COST,
            p_cost: AuthCredential::MIN_ARGON2ID_P_COST,
        };
        assert!(AuthCredential::validate_kdf_params(
            KdfKind::Argon2id,
            &params
        ));
    }

    #[test]
    fn auth_credential_rejects_under_cost_argon2id() {
        let params = KdfParams {
            m_cost: 1024,
            t_cost: 1,
            p_cost: 1,
        };
        assert!(!AuthCredential::validate_kdf_params(
            KdfKind::Argon2id,
            &params
        ));
    }

    #[test]
    fn scrypt_accepts_power_of_two_n_cost() {
        // #6 — a valid Scrypt config with N = 2^15 (power of two) passes.
        let params = KdfParams {
            m_cost: AuthCredential::MIN_SCRYPT_N_COST,
            t_cost: AuthCredential::MIN_SCRYPT_R_COST,
            p_cost: 1,
        };
        assert!(AuthCredential::validate_kdf_params(
            KdfKind::Scrypt,
            &params
        ));
        // 2^16 is also a valid power of two above the minimum.
        let bigger = KdfParams {
            m_cost: 1 << 16,
            t_cost: AuthCredential::MIN_SCRYPT_R_COST,
            p_cost: 1,
        };
        assert!(AuthCredential::validate_kdf_params(
            KdfKind::Scrypt,
            &bigger
        ));
    }

    #[test]
    fn scrypt_rejects_non_power_of_two_n_cost() {
        // #6 regression — N above the minimum but not a power of two must be
        // rejected (RFC 7914 §2). `MIN_SCRYPT_N_COST + 1` = 2^15 + 1 is the
        // tightest such case.
        let params = KdfParams {
            m_cost: AuthCredential::MIN_SCRYPT_N_COST + 1,
            t_cost: AuthCredential::MIN_SCRYPT_R_COST,
            p_cost: 1,
        };
        assert!(!AuthCredential::validate_kdf_params(
            KdfKind::Scrypt,
            &params
        ));
        // A large non-power-of-two value is likewise rejected.
        let odd = KdfParams {
            m_cost: 100_000,
            t_cost: AuthCredential::MIN_SCRYPT_R_COST,
            p_cost: 1,
        };
        assert!(!AuthCredential::validate_kdf_params(KdfKind::Scrypt, &odd));
    }

    #[test]
    fn gdpr_status_roundtrip_via_postcard() {
        let s = GdprStatus::ErasurePending;
        let b = postcard::to_stdvec(&s).unwrap();
        let back: GdprStatus = postcard::from_bytes(&b).unwrap();
        assert_eq!(s, back);
    }

    fn test_ctx() -> ActionContext<'static> {
        use arkhe_kernel::abi::{CapabilityMask, InstanceId, Principal};
        ActionContext::new(
            [0u8; 32],
            InstanceId::new(1).unwrap(),
            Tick(7),
            Principal::System,
            CapabilityMask::SYSTEM,
        )
    }

    fn valid_register_user() -> RegisterUser {
        RegisterUser {
            schema_version: 1,
            profile: UserProfile {
                schema_version: 1,
                created_tick: Tick(0),
                primary_auth_kind: AuthKind::Passkey,
            },
            credential: AuthCredential {
                schema_version: 1,
                kind: AuthKind::Passkey,
                kdf: KdfKind::Argon2id,
                salt: [0u8; 16],
                credential_hash: [0u8; 32],
                kdf_params: KdfParams {
                    m_cost: AuthCredential::MIN_ARGON2ID_M_COST,
                    t_cost: AuthCredential::MIN_ARGON2ID_T_COST,
                    p_cost: AuthCredential::MIN_ARGON2ID_P_COST,
                },
                expires_tick: None,
                bound_tick: Tick(0),
            },
        }
    }

    fn assert_schema_mismatch(err: &ActionError) {
        assert!(
            matches!(
                err,
                ActionError::SchemaMismatch {
                    expected: 1,
                    got: 0xBEEF,
                }
            ),
            "got {err:?}",
        );
    }

    #[test]
    fn register_user_rejects_wire_schema_mismatch() {
        let mut c = test_ctx();

        // Action-level wire field — first check, fires before KDF validation.
        let mut act = valid_register_user();
        act.schema_version = 0xBEEF;
        assert_schema_mismatch(&act.compute(&mut c).expect_err("action field"));
        assert!(c.ops().is_empty(), "no Ops on rejection");

        // Nested profile field — validated before the copy into storage.
        let mut act = valid_register_user();
        act.profile.schema_version = 0xBEEF;
        assert_schema_mismatch(&act.compute(&mut c).expect_err("profile field"));
        assert!(c.ops().is_empty(), "no Ops on rejection");

        // Nested credential field — same gate.
        let mut act = valid_register_user();
        act.credential.schema_version = 0xBEEF;
        assert_schema_mismatch(&act.compute(&mut c).expect_err("credential field"));
        assert!(c.ops().is_empty(), "no Ops on rejection");

        // Matching versions proceed.
        valid_register_user()
            .compute(&mut c)
            .expect("matching versions → Ok");
    }

    #[test]
    fn gdpr_erase_user_rejects_wire_schema_mismatch() {
        let mut c = test_ctx();
        let err = GdprEraseUser {
            schema_version: 0xBEEF,
            user: make_uid(42),
        }
        .compute(&mut c)
        .expect_err("wire schema mismatch must reject");
        assert_schema_mismatch(&err);
        assert!(c.ops().is_empty(), "no Ops on rejection");

        GdprEraseUser {
            schema_version: 1,
            user: make_uid(42),
        }
        .compute(&mut c)
        .expect("matching version → Ok");
    }
}