arkhe-forge-core 0.14.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
//! 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;
use crate::context::{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 admission gate
/// ([`ActionContext::ensure_actor_eligible`](crate::context::ActionContext::ensure_actor_eligible))
/// reads THIS component; absence is treated as `Active` (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> {
        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> {
        // 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
        // what makes the E-user-3 C3 admission gate LIVE: from the instant
        // erasure is requested, the gate (which reads `UserGdprState`) rejects
        // any further actor-originated action by this user. 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);
    }
}