protocol_droid/adapters/secondary/oauth/secrets.rs
1//! An [`AuthorizationSecrets`] that asks the operating system.
2//!
3//! [`AuthorizationSecrets`]
4//! is a port with no production implementation in this crate, on purpose: the
5//! right source depends on where the client runs, and a wrong default is a
6//! predictable token rather than a compile error. This is the implementation to
7//! copy instead.
8//!
9//! # Why 32 bytes, and why base64url
10//!
11//! RFC 7636 §7.1 asks for 32 octets of entropy in the `code_verifier`, and 32
12//! is also what §4.1's length rule implies: base64url encodes three bytes into
13//! four characters, so 32 bytes is 43 characters after the padding is dropped —
14//! exactly the minimum, and not one character of it wasted on a `=`.
15//!
16//! The alphabet matters as much as the length does. RFC 7636 §4.1 admits only
17//! the unreserved set, and the verifier is hashed with `ASCII` rather than
18//! UTF-8, so a value outside ASCII is not a different verifier, it is a
19//! different flow. Base64url's alphabet is `A-Z a-z 0-9 - _`, a subset of
20//! unreserved and entirely ASCII, so the encoding cannot produce a value the
21//! value object refuses. That is why [`SecretError::Rejected`] is documented as
22//! an invariant breach rather than as a runtime possibility.
23//!
24//! The same encoding serves `state`, and there for a second reason: the value
25//! travels back through a redirect URI query string, so it has to survive a URL
26//! unchanged. Base64url is designed for that path and needs no percent-encoding;
27//! a raw byte encoding would have to be escaped on the way out and unescaped on
28//! the way back, and any asymmetry between the two is a flow that silently
29//! fails its own `state` check.
30//!
31//! # Why the `state` is not derived from the verifier
32//!
33//! It could be — one draw, split in half — and it is tempting because it halves
34//! the syscalls. It is not done here because `state` and `code_verifier` answer
35//! different questions. `state` proves the response belongs to this request and
36//! is compared by a client that may hold many outstanding flows; the verifier
37//! is compared by the authorization server against the challenge it was sent.
38//! Sharing entropy between them is safe, but sharing a *draw* couples their
39//! lifetimes for no saving worth the coupling.
40//!
41//! # Errors
42//!
43//! `getrandom::getrandom` is a runtime facility, not a link-time
44//! constant, and it can fail — the operating system's entropy pool is not
45//! always available, least of all early in boot. Both trait methods return the
46//! failure rather than falling back to something weaker, because the fallback is
47//! the bug this type exists to prevent: a guessable `state` accepts a callback
48//! an attacker forged, and a guessable verifier is a code anyone can redeem.
49
50use base64::Engine as _;
51use base64::engine::general_purpose::URL_SAFE_NO_PAD;
52use getrandom;
53
54use crate::application::oauth::client::{AuthorizationSecrets, SecretError, State};
55use crate::domain::oauth::pkce::CodeVerifier;
56
57/// RFC 7636 §7.1's suggested verifier entropy, and the draw size for `state`
58/// as well. Both are drawn independently; see the module documentation.
59const ENTROPY_BYTES: usize = 32;
60
61/// Draws authorization secrets from the operating system's CSPRNG.
62///
63/// Zero-sized and stateless: every call reads fresh entropy, so there is
64/// nothing to construct, seed, or keep alive. `Default` is safe here precisely
65/// because there is no seed to get wrong — the warning in
66/// [`RandomIds`](crate::adapters::secondary::id_source::RandomIds) about a
67/// zero-seeded source does not apply to a type that never holds one.
68///
69/// # Examples
70///
71/// ```
72/// use protocol_droid::adapters::secondary::oauth::secrets::OsSecrets;
73/// use protocol_droid::application::oauth::client::AuthorizationSecrets;
74///
75/// let secrets = OsSecrets;
76/// let state = secrets.new_state()?;
77/// let verifier = secrets.new_code_verifier()?;
78///
79/// // 32 bytes of entropy, base64url-encoded, unpadded.
80/// assert_eq!(verifier.as_str().len(), 43);
81/// assert!(!state.as_str().is_empty());
82/// # Ok::<(), protocol_droid::application::oauth::client::SecretError>(())
83/// ```
84#[derive(Debug, Clone, Copy, Default)]
85pub struct OsSecrets;
86
87impl AuthorizationSecrets for OsSecrets {
88 fn new_state(&self) -> Result<State, SecretError> {
89 // `State` does not constrain its charset the way `CodeVerifier` does, so
90 // nothing here can be rejected — but a state that is not URL-safe is a
91 // state that fails to survive the redirect, so the encoding is the same
92 // one used for the verifier rather than an arbitrary one.
93 Ok(State::new(URL_SAFE_NO_PAD.encode(draw()?)))
94 }
95
96 fn new_code_verifier(&self) -> Result<CodeVerifier, SecretError> {
97 // The value object re-checks length and charset. It cannot fail, and
98 // `Rejected` says so: it is reported as an invariant breach rather than
99 // swallowed, because a silent fallback here would be the exact
100 // predictability bug this adapter is written to avoid.
101 CodeVerifier::new(URL_SAFE_NO_PAD.encode(draw()?)).map_err(|_| SecretError::Rejected)
102 }
103}
104
105/// [`ENTROPY_BYTES`] from the operating system's CSPRNG.
106fn draw() -> Result<[u8; ENTROPY_BYTES], SecretError> {
107 let mut bytes = [0_u8; ENTROPY_BYTES];
108 // `getrandom::Error` is a struct over a reserved code space, not an enum, so
109 // "this platform has no entropy source" is a specific constant rather than a
110 // variant. It stays its own case because it is the one failure a
111 // deployment cannot retry its way out of: there is nothing to retry, the
112 // platform has no such facility.
113 //
114 // `getrandom` 0.4 renamed the high-level entry point to `fill`; the
115 // `Error::UNSUPPORTED` associated constant is unchanged. Calling the
116 // function through `fill` keeps the same syscall (Linux `getrandom(2)`,
117 // macOS `getentropy(2)`, Windows `ProcessPrng`) — the CSPRNG invariant the
118 // test in this module pins is preserved by the call site, not the
119 // function name.
120 getrandom::fill(&mut bytes).map_err(|error| match error {
121 getrandom::Error::UNSUPPORTED => SecretError::EntropyUnsupported,
122 _ => SecretError::EntropyUnavailable,
123 })?;
124 Ok(bytes)
125}
126
127#[cfg(test)]
128mod tests {
129 use base64::Engine as _;
130 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
131
132 use super::{ENTROPY_BYTES, OsSecrets};
133 use crate::application::oauth::client::{AuthorizationSecrets, SecretError};
134 use crate::domain::oauth::pkce::ChallengeMethod;
135
136 /// The two draws that must not repeat. Asserting a 256-bit draw differs is
137 /// safe; the test fails only if the source has collapsed to a constant, and
138 /// a constant is the failure this type exists to prevent.
139 #[test]
140 fn two_draws_are_not_the_same_value() -> Result<(), SecretError> {
141 let secrets = OsSecrets;
142 let first = secrets.new_code_verifier()?;
143 let second = secrets.new_code_verifier()?;
144
145 assert_ne!(first.as_str(), second.as_str());
146 Ok(())
147 }
148
149 /// A counter — or a seeded counter, or one draw reused — passes a
150 /// "not the same value" check on a single pair and fails here, because a
151 /// counter advances and this does not.
152 #[test]
153 fn a_sequence_of_draws_does_not_advance() -> Result<(), SecretError> {
154 let secrets = OsSecrets;
155 let values: Vec<String> = (0..16)
156 .map(|_| {
157 secrets
158 .new_code_verifier()
159 .map(|verifier| verifier.as_str().to_owned())
160 })
161 .collect::<Result<_, SecretError>>()?;
162
163 let mut sorted = values.clone();
164 sorted.sort_unstable();
165
166 // A counter would arrive already sorted; 16 random draws will not.
167 assert_ne!(values, sorted, "the draws came out ordered: {values:?}");
168 Ok(())
169 }
170
171 /// The verifier must survive the encoding this adapter chose, and come back
172 /// to the entropy it started as. This is what makes the length claim in the
173 /// module documentation checkable rather than asserted.
174 #[test]
175 fn a_verifier_decodes_back_to_the_entropy_it_was_drawn_from() -> Result<(), SecretError> {
176 let secrets = OsSecrets;
177 let verifier = secrets.new_code_verifier()?;
178
179 let decoded = URL_SAFE_NO_PAD
180 .decode(verifier.as_str())
181 .map_err(|_| SecretError::Rejected)?;
182
183 assert_eq!(decoded.len(), ENTROPY_BYTES);
184 Ok(())
185 }
186
187 /// RFC 7636 §4.1 sets 43 as the minimum and 128 as the maximum. This
188 /// adapter lands on the minimum exactly, which is what 32 bytes of entropy
189 /// encodes to with the padding removed.
190 #[test]
191 fn a_verifier_is_exactly_the_rfc_minimum_length() -> Result<(), SecretError> {
192 let secrets = OsSecrets;
193
194 assert_eq!(secrets.new_code_verifier()?.as_str().len(), 43);
195 Ok(())
196 }
197
198 /// The value object checks the charset on construction, so a verifier that
199 /// built at all is already known to be within the unreserved set. Asserting
200 /// it again here would only restate the constructor; asserting the encoded
201 /// form is ASCII is the part the hash actually depends on, because RFC 7636
202 /// §4.2 hashes `ASCII(code_verifier)` and a multi-byte character would
203 /// silently produce a different digest than the server computes.
204 #[test]
205 fn a_verifier_is_ascii_and_url_safe() -> Result<(), SecretError> {
206 let secrets = OsSecrets;
207 let verifier = secrets.new_code_verifier()?;
208
209 assert!(
210 verifier.as_str().bytes().all(|byte| byte.is_ascii()),
211 "the verifier is not ASCII: {}",
212 verifier.as_str()
213 );
214 assert!(
215 verifier
216 .as_str()
217 .bytes()
218 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_'),
219 "the verifier left the base64url alphabet: {}",
220 verifier.as_str()
221 );
222 Ok(())
223 }
224
225 /// The challenge derived from a real verifier must itself be a legal S256
226 /// value. This exercises the two halves together, which is the only way to
227 /// show the encoding and the challenge derivation agree on the same bytes.
228 #[test]
229 fn a_drawn_verifier_yields_a_usable_challenge() -> Result<(), SecretError> {
230 let secrets = OsSecrets;
231 let verifier = secrets.new_code_verifier()?;
232
233 let challenge = verifier.challenge(ChallengeMethod::S256);
234
235 // S256 is BASE64URL(SHA256(ASCII(verifier))), so 32 bytes of digest is
236 // 43 characters with no padding — the same shape as the verifier itself.
237 assert_eq!(challenge.as_str().len(), 43);
238 Ok(())
239 }
240
241 /// The state travels back through a redirect URI query string. A value that
242 /// needed percent-encoding would be re-read in a different form on the way
243 /// back and fail its own comparison.
244 #[test]
245 fn a_state_survives_a_url_query_unchanged() -> Result<(), SecretError> {
246 let secrets = OsSecrets;
247 let state = secrets.new_state()?;
248
249 let encoded =
250 url::form_urlencoded::byte_serialize(state.as_str().as_bytes()).collect::<String>();
251 assert_eq!(encoded, state.as_str(), "the state was escaped: {encoded}");
252 Ok(())
253 }
254
255 /// The two are drawn independently, so they are not equal even though the
256 /// flow holds both at once.
257 #[test]
258 fn a_state_is_not_the_verifier() -> Result<(), SecretError> {
259 let secrets = OsSecrets;
260
261 assert_ne!(
262 secrets.new_state()?.as_str(),
263 secrets.new_code_verifier()?.as_str()
264 );
265 Ok(())
266 }
267
268 /// Every value the adapter produces must be usable as-is, which is the
269 /// claim behind `SecretError::Rejected` being an invariant breach rather
270 /// than a runtime possibility. Ninety draws, because a charset or length
271 /// rule that failed one value in ninety would fail eventually in production.
272 #[test]
273 fn ninety_draws_are_all_accepted() -> Result<(), SecretError> {
274 let secrets = OsSecrets;
275
276 for _ in 0..90 {
277 let _ = secrets.new_state()?;
278 let _ = secrets.new_code_verifier()?;
279 }
280 Ok(())
281 }
282
283 /// Both trait methods report a failure rather than degrading. The
284 /// implementation under test cannot be made to fail on demand, so this
285 /// asserts the shape of the contract instead: the error type separates an
286 /// environment that cannot supply entropy from an encoder that produced
287 /// something illegal, and both are distinct from each other.
288 #[test]
289 fn an_entropy_failure_and_an_encoding_failure_are_distinguishable() {
290 assert_ne!(
291 SecretError::EntropyUnavailable,
292 SecretError::EntropyUnsupported
293 );
294 assert_ne!(SecretError::EntropyUnavailable, SecretError::Rejected);
295 assert_ne!(SecretError::EntropyUnsupported, SecretError::Rejected);
296 }
297}