Skip to main content

ciranda/
lib.rs

1//! ![Apache2.0/MIT][license-badge] [![GitHub][github-badge]][repo]
2//!
3//! Deterministic password generation.
4//!
5//! Ciranda derives the same password from the same seed, context, password
6//! settings, and Argon2 settings.
7//!
8//! Start with [`enhance`] for the main API.
9//!
10//! [license-badge]: https://img.shields.io/badge/license-Apache2.0/MIT-blue.svg
11//! [github-badge]: https://img.shields.io/badge/GitHub-repo-black?logo=github
12//! [repo]: https://github.com/w4m3r/ciranda
13
14mod kdf;
15mod password_construction;
16
17pub const MIN_PASSWORD_LENGTH: u32 = 4;
18pub const MAX_PASSWORD_LENGTH: u32 = 128;
19
20const DEVELOPMENT_M_COST: u32 = 8;
21const DEVELOPMENT_T_COST: u32 = 1;
22const DEVELOPMENT_P_COST: u32 = 1;
23const STANDARD_M_COST: u32 = 65_536;
24const STANDARD_T_COST: u32 = 3;
25const STANDARD_P_COST: u32 = 4;
26const HARDENED_M_COST: u32 = 2_097_152;
27const HARDENED_T_COST: u32 = 1;
28const HARDENED_P_COST: u32 = 4;
29
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31/// Character groups available to the deterministic password-construction stage.
32///
33/// Selected groups define the output alphabet. Each selected group contributes
34/// at least one character, and unselected groups are excluded entirely.
35pub struct CharacterSets {
36    pub uppercase: bool,
37    pub lowercase: bool,
38    pub digits: bool,
39    pub special: bool,
40}
41
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
43/// A single selectable password character group.
44///
45/// The order in [`CharacterSet::ALL`] is the canonical bucket-construction
46/// order used by the password-construction stage.
47pub enum CharacterSet {
48    Uppercase,
49    Lowercase,
50    Digits,
51    Special,
52}
53
54impl CharacterSet {
55    pub const ALL: [Self; 4] = [
56        Self::Uppercase,
57        Self::Lowercase,
58        Self::Digits,
59        Self::Special,
60    ];
61
62    pub fn label(self) -> &'static str {
63        match self {
64            Self::Uppercase => "Uppercase",
65            Self::Lowercase => "Lowercase",
66            Self::Digits => "Digits",
67            Self::Special => "Special",
68        }
69    }
70
71    pub fn description(self) -> &'static str {
72        match self {
73            Self::Uppercase => "uppercase letters (A-Z)",
74            Self::Lowercase => "lowercase letters (a-z)",
75            Self::Digits => "digits (0-9)",
76            Self::Special => "special characters (!@#$%^&*)",
77        }
78    }
79}
80
81impl CharacterSets {
82    pub const ALL: Self = Self::new(true, true, true, true);
83
84    pub const fn new(uppercase: bool, lowercase: bool, digits: bool, special: bool) -> Self {
85        Self {
86            uppercase,
87            lowercase,
88            digits,
89            special,
90        }
91    }
92
93    pub(crate) const fn selected_count(self) -> usize {
94        self.uppercase as usize
95            + self.lowercase as usize
96            + self.digits as usize
97            + self.special as usize
98    }
99
100    pub fn with(self, character_set: CharacterSet, selected: bool) -> Self {
101        match character_set {
102            CharacterSet::Uppercase => Self {
103                uppercase: selected,
104                ..self
105            },
106            CharacterSet::Lowercase => Self {
107                lowercase: selected,
108                ..self
109            },
110            CharacterSet::Digits => Self {
111                digits: selected,
112                ..self
113            },
114            CharacterSet::Special => Self {
115                special: selected,
116                ..self
117            },
118        }
119    }
120}
121
122#[derive(Clone, Copy, Debug, Eq, PartialEq)]
123/// Password construction settings.
124///
125/// The length must be between [`MIN_PASSWORD_LENGTH`] and
126/// [`MAX_PASSWORD_LENGTH`]. At least one character group must be selected.
127pub struct PasswordSettings {
128    pub length: u32,
129    pub character_sets: CharacterSets,
130}
131
132impl PasswordSettings {
133    pub const fn new(length: u32, character_sets: CharacterSets) -> Self {
134        Self {
135            length,
136            character_sets,
137        }
138    }
139}
140
141#[derive(Clone, Copy, Debug, Eq, PartialEq)]
142/// Named Argon2id presets for common use cases.
143///
144/// [`Argon2Profile::Standard`] is inteded for normal interactive use.
145/// [`Argon2Profile::Development`] is only for tests, examples, and fast local
146/// iteration. [`Argon2Profile::Hardened`] is intended for capable machines that
147/// can tolerate substantially higher memory use.
148///
149/// Profile changes affect the derived password, but they are not the preferred
150/// rotation mechanism. Rotate by changing the seed or by versioning the
151/// context.
152pub enum Argon2Profile {
153    Development,
154    Standard,
155    Hardened,
156}
157
158#[derive(Clone, Copy, Debug, Eq, PartialEq)]
159pub struct Argon2Settings {
160    pub m_cost: u32,
161    pub t_cost: u32,
162    pub p_cost: u32,
163}
164
165impl Argon2Settings {
166    pub const fn new(m_cost: u32, t_cost: u32, p_cost: u32) -> Self {
167        Self {
168            m_cost,
169            t_cost,
170            p_cost,
171        }
172    }
173}
174
175impl Argon2Profile {
176    pub const ALL: [Self; 3] = [Self::Development, Self::Standard, Self::Hardened];
177
178    pub fn label(self) -> &'static str {
179        match self {
180            Self::Development => "Development",
181            Self::Standard => "Standard",
182            Self::Hardened => "Hardened",
183        }
184    }
185
186    pub fn description(self) -> &'static str {
187        match self {
188            Self::Development => "8 KiB memory, 1 iteration, 1 lane",
189            Self::Standard => "64 MiB memory, 3 iterations, 4 lanes",
190            Self::Hardened => "2 GiB memory, 1 iteration, 4 lanes",
191        }
192    }
193
194    pub fn settings(self) -> Argon2Settings {
195        match self {
196            Self::Development => {
197                Argon2Settings::new(DEVELOPMENT_M_COST, DEVELOPMENT_T_COST, DEVELOPMENT_P_COST)
198            }
199            Self::Standard => {
200                Argon2Settings::new(STANDARD_M_COST, STANDARD_T_COST, STANDARD_P_COST)
201            }
202            Self::Hardened => {
203                Argon2Settings::new(HARDENED_M_COST, HARDENED_T_COST, HARDENED_P_COST)
204            }
205        }
206    }
207}
208
209/// Derives a password from a seed and context using Argon2id KDF and BLAKE3 hashing.
210///
211/// The process:
212/// 1. Hash context with BLAKE3 to produce a 16-byte salt
213/// 2. Use Argon2id KDF to derive a 64-byte key from the seed and salt
214/// 3. Expand the key into deterministic BLAKE3 XOF byte streams
215/// 4. Deterministically shuffle character buckets and select one character per bucket
216///
217/// # Arguments
218/// * `seed` - The master password/seed bytes
219/// * `context` - Context bytes (e.g., service name) to derive unique passwords
220/// * `password_settings` - Password length and selected character sets. For
221///   normal use, start with `PasswordSettings::new(length, CharacterSets::ALL)`.
222/// * `settings` - Argon2 settings (m_cost, t_cost, p_cost). Output length is fixed internally.
223///   For normal use, prefer `Argon2Profile::Standard.settings()`. Reserve
224///   `Argon2Profile::Development.settings()` for tests and examples, and use
225///   `Argon2Profile::Hardened.settings()` only when the higher memory cost is
226///   intentional and acceptable.
227/// # Example
228///
229/// ```
230/// use ciranda::{Argon2Profile, CharacterSets, PasswordSettings, enhance};
231/// let settings = Argon2Profile::Development.settings();
232/// let password_settings = PasswordSettings::new(32, CharacterSets::ALL);
233/// let password = enhance(b"seed", b"context", &password_settings, &settings);
234/// assert_eq!(password, "n&C7WAOp5vg!mx0vLHLhu^eDxd1PDQgK");
235/// ```
236pub fn enhance(
237    seed: &[u8],
238    context: &[u8],
239    password_settings: &PasswordSettings,
240    settings: &Argon2Settings,
241) -> String {
242    let salt = kdf::hash_salt(context);
243    let key = kdf::derive_key(seed, &salt, settings);
244    password_construction::construct_password(&key, password_settings)
245}
246
247#[cfg(test)]
248mod tests {
249    use super::{
250        Argon2Profile, DEVELOPMENT_M_COST, DEVELOPMENT_P_COST, DEVELOPMENT_T_COST, HARDENED_M_COST,
251        HARDENED_P_COST, HARDENED_T_COST, STANDARD_M_COST, STANDARD_P_COST, STANDARD_T_COST,
252    };
253
254    #[test]
255    fn argon2_profiles_use_expected_settings() {
256        let development = Argon2Profile::Development.settings();
257        let standard = Argon2Profile::Standard.settings();
258        let hardened = Argon2Profile::Hardened.settings();
259
260        assert_eq!(
261            (development.m_cost, development.t_cost, development.p_cost),
262            (DEVELOPMENT_M_COST, DEVELOPMENT_T_COST, DEVELOPMENT_P_COST)
263        );
264        assert_eq!(
265            (standard.m_cost, standard.t_cost, standard.p_cost),
266            (STANDARD_M_COST, STANDARD_T_COST, STANDARD_P_COST)
267        );
268        assert_eq!(
269            (hardened.m_cost, hardened.t_cost, hardened.p_cost),
270            (HARDENED_M_COST, HARDENED_T_COST, HARDENED_P_COST)
271        );
272    }
273}