1use 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;
16use crate::arkhe_pure;
18
19#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
21#[serde(transparent)]
22pub struct UserId(EntityId);
23
24impl UserId {
25 #[inline]
29 #[must_use]
30 pub fn new(id: EntityId) -> Self {
31 Self(id)
32 }
33
34 #[inline]
36 #[must_use]
37 pub fn get(self) -> EntityId {
38 self.0
39 }
40}
41
42#[non_exhaustive]
46#[repr(u8)]
47#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
48pub enum AuthKind {
49 Passkey = 0,
51 Email = 1,
53 Handle = 2,
55 Address = 3,
57}
58
59#[non_exhaustive]
62#[repr(u8)]
63#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
64pub enum GdprStatus {
65 Active = 0,
67 ErasurePending = 1,
69 Erased = 2,
71}
72
73#[non_exhaustive]
75#[repr(u8)]
76#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
77pub enum KdfKind {
78 Argon2id = 0,
80 Scrypt = 1,
82}
83
84#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
86pub struct KdfParams {
87 pub m_cost: u32,
89 pub t_cost: u32,
91 pub p_cost: u32,
93}
94
95#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
97#[arkhe(type_code = 0x0003_0001, schema_version = 1)]
98pub struct UserProfile {
99 pub schema_version: u16,
101 pub created_tick: Tick,
103 pub primary_auth_kind: AuthKind,
105 pub gdpr_status: GdprStatus,
107}
108
109#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
112#[arkhe(type_code = 0x0003_0002, schema_version = 1)]
113pub struct AuthCredential {
114 pub schema_version: u16,
116 pub kind: AuthKind,
118 pub kdf: KdfKind,
120 pub salt: [u8; 16],
122 pub credential_hash: [u8; 32],
124 pub kdf_params: KdfParams,
126 pub expires_tick: Option<Tick>,
128 pub bound_tick: Tick,
130}
131
132impl AuthCredential {
133 pub const DEFAULT_KDF: KdfKind = KdfKind::Argon2id;
135 pub const MIN_ARGON2ID_M_COST: u32 = 19_456;
137 pub const MIN_ARGON2ID_T_COST: u32 = 2;
139 pub const MIN_ARGON2ID_P_COST: u32 = 1;
141 pub const MIN_SCRYPT_N_COST: u32 = 1 << 15;
143 pub const MIN_SCRYPT_R_COST: u32 = 8;
145
146 #[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#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
166#[arkhe(type_code = 0x0001_0001, schema_version = 1, band = 1)]
167pub struct RegisterUser {
168 pub schema_version: u16,
170 pub profile: UserProfile,
172 pub credential: AuthCredential,
174}
175
176#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
179#[arkhe(type_code = 0x0001_0003, schema_version = 1, band = 1)]
180pub struct GdprEraseUser {
181 pub schema_version: u16,
183 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 ¶ms
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 ¶ms
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}