Skip to main content

cookie_agent_server/
token.rs

1//! Per-run localhost daemon bearer token and its stdout handoff frame.
2
3use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
4use serde::Serialize;
5use zeroize::Zeroizing;
6
7/// Random bytes encoded into each daemon token.
8const TOKEN_BYTES: usize = 32;
9/// Length in characters of a base64url-encoded 32-byte token.
10pub(crate) const TOKEN_ENCODED_BYTES: usize = 43;
11/// Literal prefix that marks the single machine-readable startup frame.
12pub const READY_LINE_PREFIX: &str = "daemon-ready ";
13
14/// Errors returned while preparing the per-run daemon authentication token.
15#[derive(Debug, thiserror::Error)]
16pub enum TokenError {
17    /// The operating system could not supply random bytes.
18    #[error("could not obtain randomness for the daemon authentication token")]
19    Random(#[source] getrandom::Error),
20}
21
22/// Generates a fresh random 32-byte daemon bearer token as 43 base64url
23/// characters. The token is kept only in memory and dies with the process.
24pub fn generate_token() -> Result<Zeroizing<String>, TokenError> {
25    let mut bytes = [0_u8; TOKEN_BYTES];
26    getrandom::getrandom(&mut bytes).map_err(TokenError::Random)?;
27    Ok(Zeroizing::new(URL_SAFE_NO_PAD.encode(bytes)))
28}
29
30/// Formats the single machine-readable startup frame a supervisor parses.
31///
32/// The daemon writes this as its first stdout line; every other stdout line is
33/// human logging that parsers must ignore. Parsers match [`READY_LINE_PREFIX`].
34pub fn ready_line(url: &str, token: &str) -> String {
35    #[derive(Serialize)]
36    struct ReadyFrame<'a> {
37        url: &'a str,
38        token: &'a str,
39    }
40
41    let frame = serde_json::to_string(&ReadyFrame { url, token })
42        .expect("serializing a two-string ready frame cannot fail");
43    format!("{READY_LINE_PREFIX}{frame}")
44}
45
46#[cfg(test)]
47mod tests {
48    use std::collections::HashSet;
49
50    use super::*;
51
52    #[test]
53    fn generated_tokens_are_43_char_base64url_and_distinct() {
54        let mut seen = HashSet::new();
55        for _ in 0..64 {
56            let token = generate_token().expect("token");
57            assert_eq!(token.len(), TOKEN_ENCODED_BYTES);
58            assert!(
59                token
60                    .bytes()
61                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')),
62                "{}",
63                token.as_str()
64            );
65            assert!(seen.insert(token.to_string()));
66        }
67    }
68
69    #[test]
70    fn ready_line_is_one_json_frame_with_the_documented_prefix() {
71        let token = "A".repeat(TOKEN_ENCODED_BYTES);
72        let line = ready_line("ws://127.0.0.1:7419/ws", &token);
73        let payload = line
74            .strip_prefix(READY_LINE_PREFIX)
75            .expect("daemon-ready prefix");
76        let value: serde_json::Value = serde_json::from_str(payload).expect("json frame");
77        assert_eq!(value["url"].as_str(), Some("ws://127.0.0.1:7419/ws"));
78        assert_eq!(value["token"].as_str(), Some(token.as_str()));
79        assert_eq!(
80            payload,
81            format!(r#"{{"url":"ws://127.0.0.1:7419/ws","token":"{token}"}}"#)
82        );
83    }
84}