Skip to main content

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}