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/// Minimum word count for a Joy passphrase. Diceware-style with
37/// Argon2id parameters Joy ships, three well-chosen words still give
38/// substantial brute-force resistance for the local-key-derivation
39/// threat model. See JOY-0171-50.
40pub const MIN_PASSPHRASE_WORDS: usize = 3;
41
42/// Validate that a passphrase has at least [`MIN_PASSPHRASE_WORDS`]
43/// whitespace-separated words.
44pub fn validate_passphrase(passphrase: &str) -> Result<(), JoyError> {
45 let word_count = passphrase.split_whitespace().count();
46 if word_count < MIN_PASSPHRASE_WORDS {
47 return Err(JoyError::PassphraseTooShort);
48 }
49 Ok(())
50}
51
52/// Result of unlocking a member's identity from a passphrase.
53pub struct UnlockedIdentity {
54 pub keypair: IdentityKeypair,
55 /// The 32-byte Ed25519 seed underlying the keypair. Under ADR-039
56 /// this value is stable across passphrase rotation; on legacy
57 /// projects (no seed_wrap_*) it equals the Argon2id-derived KEK.
58 pub seed: [u8; 32],
59}
60
61/// Verify a passphrase against a member entry and return the resulting
62/// `IdentityKeypair` and seed. Handles both the wrapped-seed model
63/// (ADR-039) and the legacy model where the Argon2id-derived key is the
64/// Ed25519 seed.
65///
66/// Does **not** perform lazy migration; that is `auth_with_passphrase`'s
67/// responsibility on the human-facing `joy auth` path. Other commands
68/// that need the acting human's keypair (`joy ai init`, `joy auth
69/// reset`, `joy auth token add`, `joy ai rotate`) call this helper and
70/// let the next `joy auth` migrate.
71pub fn unlock_identity(
72 member: &crate::model::project::Member,
73 passphrase: &str,
74) -> Result<UnlockedIdentity, JoyError> {
75 let salt_hex = member
76 .kdf_nonce
77 .as_ref()
78 .ok_or_else(|| JoyError::AuthFailed("member has no kdf_nonce".into()))?;
79 let salt = Salt::from_hex(salt_hex)?;
80 let verify_hex = member
81 .verify_key
82 .as_ref()
83 .ok_or_else(|| JoyError::AuthFailed("member has no verify_key".into()))?;
84 let verify_key = PublicKey::from_hex(verify_hex)?;
85
86 let (kp, seed_bytes) = if let Some(wrap_hex) = member.seed_wrap_passphrase.as_deref() {
87 let seed = seed::unwrap_seed_with_passphrase(wrap_hex, passphrase, &salt)?;
88 let bytes = *seed.as_bytes();
89 (IdentityKeypair::from_seed(&bytes), bytes)
90 } else {
91 let key = derive_key(passphrase, &salt)?;
92 let bytes = *key.as_bytes();
93 (IdentityKeypair::from_derived_key(&key), bytes)
94 };
95 if kp.public_key() != verify_key {
96 return Err(JoyError::AuthFailed("incorrect passphrase".into()));
97 }
98 Ok(UnlockedIdentity {
99 keypair: kp,
100 seed: seed_bytes,
101 })
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
109 fn passphrase_too_short() {
110 assert!(validate_passphrase("").is_err());
111 assert!(validate_passphrase("one").is_err());
112 assert!(validate_passphrase("one two").is_err());
113 }
114
115 #[test]
116 fn passphrase_valid() {
117 assert!(validate_passphrase("one two three").is_ok());
118 assert!(validate_passphrase("one two three four five six").is_ok());
119 assert!(validate_passphrase("a b c d e f g h").is_ok());
120 }
121}