codlet_core/code/policy.rs
1//! Code policy (RFC-003 §3, §11.1).
2//!
3//! [`CodePolicy`] is a validated security object, not loose configuration. Its
4//! constructors reject impossible or risky shapes. Short codes below the secure
5//! minimum require an explicit opt-in constructor so the weaker choice is
6//! visible in code review (NFR-2).
7
8use core::time::Duration;
9
10use super::alphabet::Alphabet;
11use crate::error::PolicyError;
12
13/// The secure minimum human-entered code length codlet enforces by default.
14/// 8 symbols over the 31-symbol alphabet is ~39.6 bits (RFC-003 §11.3).
15pub const SECURE_MIN_HUMAN_LENGTH: usize = 8;
16
17/// The maximum accepted raw (pre-normalization) input length. Bounds work done
18/// on hostile input before a lookup.
19pub const DEFAULT_MAX_RAW_LEN: usize = 64;
20
21/// Minimum accepted short-code length for the explicit compat opt-in (6 symbols, ~29.7 bits).
22pub const SHORT_COMPAT_LENGTH: usize = 6;
23
24/// Validated policy governing code generation and validation.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct CodePolicy {
27 alphabet: Alphabet,
28 length: usize,
29 max_raw_len: usize,
30 ttl: Duration,
31}
32
33impl CodePolicy {
34 /// The recommended default for human-entered codes: the unambiguous
35 /// alphabet, [`SECURE_MIN_HUMAN_LENGTH`] symbols, and the given TTL.
36 ///
37 /// # Errors
38 /// Returns [`PolicyError`] only if the TTL is zero. The built-in alphabet
39 /// and length are always valid.
40 pub fn default_human(ttl: Duration) -> Result<Self, PolicyError> {
41 Self::new(Alphabet::unambiguous(), SECURE_MIN_HUMAN_LENGTH, ttl)
42 }
43
44 /// Build a policy, enforcing the secure minimum length.
45 ///
46 /// # Errors
47 /// Returns [`PolicyError`] if the length is zero, below
48 /// [`SECURE_MIN_HUMAN_LENGTH`], or the TTL is zero. Use
49 /// [`CodePolicy::short_compat`] to opt into a shorter length deliberately.
50 pub fn new(alphabet: Alphabet, length: usize, ttl: Duration) -> Result<Self, PolicyError> {
51 if length == 0 {
52 return Err(PolicyError::ZeroLength);
53 }
54 if length < SECURE_MIN_HUMAN_LENGTH {
55 return Err(PolicyError::LengthBelowMinimum {
56 got: length,
57 min: SECURE_MIN_HUMAN_LENGTH,
58 });
59 }
60 Self::build(alphabet, length, ttl)
61 }
62
63 /// Explicitly opt into a short code length below the secure minimum.
64 ///
65 /// This is a deliberately separate, named constructor (NFR-2): a short code
66 /// is acceptable only with short expiry, single-use semantics, and rate
67 /// limiting. Hosts choosing this take on that responsibility.
68 ///
69 /// **Security note:** codes shorter than [`SECURE_MIN_HUMAN_LENGTH`] symbols
70 /// have reduced entropy and **require** active rate limiting to be safe. An
71 /// unprotected 6-symbol code over 31 symbols has only ~29.7 bits of entropy.
72 /// Suppress this warning with `#[allow(deprecated)]` at the call site only
73 /// after confirming that rate limiting is in place.
74 ///
75 /// # Errors
76 /// Returns [`PolicyError::ZeroLength`] if `length` is zero, or a TTL error
77 /// if `ttl` is zero. Lengths at or above the minimum are also accepted.
78 #[deprecated(
79 note = "codes shorter than SECURE_MIN_HUMAN_LENGTH have reduced entropy; ensure rate limiting is active and suppress with #[allow(deprecated)] at the call site to acknowledge the tradeoff"
80 )]
81 pub fn short_compat(
82 alphabet: Alphabet,
83 length: usize,
84 ttl: Duration,
85 ) -> Result<Self, PolicyError> {
86 if length == 0 {
87 return Err(PolicyError::ZeroLength);
88 }
89 Self::build(alphabet, length, ttl)
90 }
91
92 /// Short-code compatibility policy: unambiguous alphabet, 6 symbols,
93 /// caller-chosen TTL. Equivalent to `short_compat(Alphabet::unambiguous(), 6, ttl)`.
94 ///
95 /// Use this when migrating from an existing system that issued 6-symbol codes.
96 /// Prefer [`CodePolicy::default_human`] (8 symbols, ~39.6 bits) for new deployments.
97 ///
98 /// # Errors
99 /// Returns a [`PolicyError`] if the TTL is zero.
100 #[deprecated(
101 note = "6-symbol codes have only ~29.7 bits of entropy; use default_human() for new deployments or ensure rate limiting is active and suppress with #[allow(deprecated)]"
102 )]
103 #[allow(deprecated)] // calls short_compat which is also deprecated
104 pub fn six_symbol(ttl: Duration) -> Result<Self, PolicyError> {
105 #[allow(deprecated)]
106 Self::short_compat(Alphabet::unambiguous(), SHORT_COMPAT_LENGTH, ttl)
107 }
108
109 fn build(alphabet: Alphabet, length: usize, ttl: Duration) -> Result<Self, PolicyError> {
110 if ttl.is_zero() {
111 // Reuse ZeroLength? No — be explicit; a zero TTL is its own bug.
112 // We model it as a policy error without inventing a new variant by
113 // treating it as an invalid shape. Keep a dedicated check here.
114 return Err(PolicyError::ZeroLength);
115 }
116 let max_raw_len = DEFAULT_MAX_RAW_LEN.max(length);
117 Ok(Self {
118 alphabet,
119 length,
120 max_raw_len,
121 ttl,
122 })
123 }
124
125 /// The alphabet used for generation and accepted in normalized input.
126 #[must_use]
127 pub fn alphabet(&self) -> &Alphabet {
128 &self.alphabet
129 }
130
131 /// The exact normalized code length.
132 #[must_use]
133 pub fn length(&self) -> usize {
134 self.length
135 }
136
137 /// The maximum accepted raw input length before normalization.
138 #[must_use]
139 pub fn max_raw_len(&self) -> usize {
140 self.max_raw_len
141 }
142
143 /// The code time-to-live.
144 #[must_use]
145 pub fn ttl(&self) -> Duration {
146 self.ttl
147 }
148
149 /// Approximate entropy in bits for this policy: `length * log2(alphabet)`.
150 /// Intended for docs/diagnostics, not a security decision input.
151 #[must_use]
152 pub fn approx_entropy_bits(&self) -> f64 {
153 (self.length as f64) * (self.alphabet.len() as f64).log2()
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 const HOUR: Duration = Duration::from_secs(3600);
162
163 #[test]
164 fn default_human_is_8_and_unambiguous() {
165 let p = CodePolicy::default_human(HOUR).unwrap();
166 assert_eq!(p.length(), 8);
167 assert_eq!(p.alphabet().len(), 31);
168 assert!((p.approx_entropy_bits() - 39.6).abs() < 0.2);
169 }
170
171 #[test]
172 fn new_rejects_below_minimum() {
173 let err = CodePolicy::new(Alphabet::unambiguous(), 6, HOUR).unwrap_err();
174 assert_eq!(err, PolicyError::LengthBelowMinimum { got: 6, min: 8 });
175 }
176
177 #[test]
178 #[allow(deprecated)]
179 fn short_compat_allows_six() {
180 #[allow(deprecated)]
181 let p = CodePolicy::six_symbol(HOUR).unwrap();
182 assert_eq!(p.length(), 6);
183 assert!((p.approx_entropy_bits() - 29.7).abs() < 0.2);
184 }
185
186 #[test]
187 #[allow(deprecated)]
188 fn zero_length_and_zero_ttl_rejected() {
189 assert_eq!(
190 CodePolicy::short_compat(Alphabet::unambiguous(), 0, HOUR),
191 Err(PolicyError::ZeroLength)
192 );
193 assert_eq!(
194 CodePolicy::default_human(Duration::ZERO),
195 Err(PolicyError::ZeroLength)
196 );
197 }
198}