Skip to main content

joy_core/auth/
mod.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Cryptographic identity for Joy's Trust Model.
5//!
6//! Auth provides passphrase-derived Ed25519 identity keys using Argon2id
7//! for key derivation. This is the Trustship pillar of AI Governance:
8//! it answers "who is this?" with cryptographic proof rather than
9//! self-declaration.
10//!
11//! Key hierarchy:
12//! ```text
13//! Passphrase + Salt --[Argon2id]--> DerivedKey --[Ed25519]--> Keypair
14//! ```
15//!
16//! Cryptographic primitives (KDF, AEAD, Ed25519, key wrapping) live in
17//! the `joy-crypt` crate (ADR-039 ยง"Crate boundary and dependency
18//! direction"). This module owns the identity application layer:
19//! sessions, tokens, OTPs, attestations, and the project.yaml schema.
20
21pub mod attestation;
22pub mod delegation;
23pub mod otp;
24pub mod seed;
25pub mod session;
26pub mod token;
27
28// Re-export joy-crypt primitives under joy-domain names. Callers within
29// joy-core/auth and joy-cli use these names; the underlying
30// implementation lives in joy-crypt (ADR-039).
31pub use joy_crypt::identity::{Keypair as IdentityKeypair, PublicKey};
32pub use joy_crypt::kdf::{derive_argon2id as derive_key, generate_salt, DerivedKey, Salt};
33
34use crate::error::JoyError;
35
36/// Validate that a passphrase has at least 6 whitespace-separated words.
37///
38/// Joy uses the Diceware convention: short list of dictionary words is
39/// easier to memorise than random characters and reaches comparable
40/// entropy at 6+ words.
41pub fn validate_passphrase(passphrase: &str) -> Result<(), JoyError> {
42    let word_count = passphrase.split_whitespace().count();
43    if word_count < 6 {
44        return Err(JoyError::PassphraseTooShort);
45    }
46    Ok(())
47}
48
49/// Result of unlocking a member's identity from a passphrase.
50pub struct UnlockedIdentity {
51    pub keypair: IdentityKeypair,
52    /// The 32-byte Ed25519 seed underlying the keypair. Under ADR-039
53    /// this value is stable across passphrase rotation; on legacy
54    /// projects (no seed_wrap_*) it equals the Argon2id-derived KEK.
55    pub seed: [u8; 32],
56}
57
58/// Verify a passphrase against a member entry and return the resulting
59/// `IdentityKeypair` and seed. Handles both the wrapped-seed model
60/// (ADR-039) and the legacy model where the Argon2id-derived key is the
61/// Ed25519 seed.
62///
63/// Does **not** perform lazy migration; that is `auth_with_passphrase`'s
64/// responsibility on the human-facing `joy auth` path. Other commands
65/// that need the acting human's keypair (`joy ai init`, `joy auth
66/// reset`, `joy auth token add`, `joy ai rotate`) call this helper and
67/// let the next `joy auth` migrate.
68pub fn unlock_identity(
69    member: &crate::model::project::Member,
70    passphrase: &str,
71) -> Result<UnlockedIdentity, JoyError> {
72    let salt_hex = member
73        .kdf_nonce
74        .as_ref()
75        .ok_or_else(|| JoyError::AuthFailed("member has no kdf_nonce".into()))?;
76    let salt = Salt::from_hex(salt_hex)?;
77    let verify_hex = member
78        .verify_key
79        .as_ref()
80        .ok_or_else(|| JoyError::AuthFailed("member has no verify_key".into()))?;
81    let verify_key = PublicKey::from_hex(verify_hex)?;
82
83    let (kp, seed_bytes) = if let Some(wrap_hex) = member.seed_wrap_passphrase.as_deref() {
84        let seed = seed::unwrap_seed_with_passphrase(wrap_hex, passphrase, &salt)?;
85        let bytes = *seed.as_bytes();
86        (IdentityKeypair::from_seed(&bytes), bytes)
87    } else {
88        let key = derive_key(passphrase, &salt)?;
89        let bytes = *key.as_bytes();
90        (IdentityKeypair::from_derived_key(&key), bytes)
91    };
92    if kp.public_key() != verify_key {
93        return Err(JoyError::AuthFailed("incorrect passphrase".into()));
94    }
95    Ok(UnlockedIdentity {
96        keypair: kp,
97        seed: seed_bytes,
98    })
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn passphrase_too_short() {
107        assert!(validate_passphrase("one two three").is_err());
108        assert!(validate_passphrase("one two three four five").is_err());
109    }
110
111    #[test]
112    fn passphrase_valid() {
113        assert!(validate_passphrase("one two three four five six").is_ok());
114        assert!(validate_passphrase("a b c d e f g h").is_ok());
115    }
116}