Skip to main content

codoseo_web/agent/
keys.rs

1//! Generating and recognising API keys. A key is `cdo_` and 43 characters of URL-safe base64
2//! (256 random bits). Only its SHA-256 hash and a 12-character prefix are stored, so the key
3//! exists in the clear once, in the response that creates it.
4
5use crate::auth::session::{hash, random_token};
6use crate::config::Config;
7use crate::error::AppError;
8
9/// Every key starts with this.
10pub const KEY_PREFIX: &str = "cdo_";
11/// Characters of the key kept (unhashed) to tell keys apart on the settings screen.
12pub const DISPLAY_PREFIX_LEN: usize = 12;
13/// `cdo_` and the 43 characters of 32 random bytes.
14const KEY_LEN: usize = KEY_PREFIX.len() + 43;
15
16/// A new key: the plaintext (show once, never store), its hash and its display prefix.
17pub struct NewKey {
18    pub plaintext: String,
19    pub hash: Vec<u8>,
20    pub prefix: String,
21}
22
23pub fn generate() -> NewKey {
24    let plaintext = format!("{KEY_PREFIX}{}", random_token());
25    NewKey {
26        hash: hash_key(&plaintext),
27        prefix: plaintext.chars().take(DISPLAY_PREFIX_LEN).collect(),
28        plaintext,
29    }
30}
31
32/// What is stored and looked up instead of the key.
33pub fn hash_key(key: &str) -> Vec<u8> {
34    hash(key)
35}
36
37/// Whether `key` has the shape of a key we hand out. A value that doesn't is rejected before
38/// any lookup.
39pub fn is_well_formed(key: &str) -> bool {
40    key.len() == KEY_LEN
41        && key.starts_with(KEY_PREFIX)
42        && key[KEY_PREFIX.len()..]
43            .bytes()
44            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
45}
46
47/// The MCP server's address, `{BASE_URL}/mcp`.
48pub fn mcp_url(config: &Config) -> Result<String, AppError> {
49    Ok(config
50        .base_url
51        .join("mcp")
52        .map_err(AppError::internal)?
53        .to_string())
54}
55
56/// `claude mcp add ...` for the key (or a placeholder).
57pub fn claude_command(mcp_url: &str, key: &str) -> String {
58    format!(
59        "claude mcp add --transport http codoseo {mcp_url} --header \"Authorization: Bearer {key}\""
60    )
61}
62
63/// A `mcpServers` entry for MCP clients that take JSON, written out so the keys stay in the
64/// order a person reads them.
65pub fn mcp_servers_json(mcp_url: &str, key: &str) -> String {
66    // The URL and the key are plain ASCII with no quotes or backslashes.
67    format!(
68        "{{\n  \"mcpServers\": {{\n    \"codoseo\": {{\n      \"type\": \"http\",\n      \
69         \"url\": \"{mcp_url}\",\n      \"headers\": {{\n        \
70         \"Authorization\": \"Bearer {key}\"\n      }}\n    }}\n  }}\n}}"
71    )
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn the_json_snippet_is_valid_and_carries_the_url_and_the_bearer_key() {
80        let key = "cdo_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
81        let json = mcp_servers_json("https://codoseo.com/mcp", key);
82        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
83        let server = &v["mcpServers"]["codoseo"];
84        assert_eq!(server["type"], "http");
85        assert_eq!(server["url"], "https://codoseo.com/mcp");
86        assert_eq!(server["headers"]["Authorization"], format!("Bearer {key}"));
87        // Written in reading order, not alphabetically.
88        assert!(json.find("\"type\"").unwrap() < json.find("\"url\"").unwrap());
89        assert!(json.find("\"url\"").unwrap() < json.find("\"headers\"").unwrap());
90    }
91
92    #[test]
93    fn the_claude_command_names_the_url_and_the_bearer_key() {
94        assert_eq!(
95            claude_command("https://codoseo.com/mcp", "cdo_x"),
96            "claude mcp add --transport http codoseo https://codoseo.com/mcp --header \"Authorization: Bearer cdo_x\""
97        );
98    }
99
100    #[test]
101    fn a_key_is_cdo_and_43_url_safe_characters() {
102        let key = generate();
103        assert!(key.plaintext.starts_with("cdo_"));
104        assert_eq!(key.plaintext.len(), 4 + 43);
105        assert!(is_well_formed(&key.plaintext));
106    }
107
108    #[test]
109    fn the_hash_is_sha256_of_the_whole_key_and_the_prefix_its_first_12_characters() {
110        let key = generate();
111        assert_eq!(key.hash, hash_key(&key.plaintext));
112        assert_eq!(key.hash.len(), 32);
113        assert_eq!(key.prefix, key.plaintext[..12]);
114    }
115
116    #[test]
117    fn keys_do_not_repeat() {
118        assert_ne!(generate().plaintext, generate().plaintext);
119        assert_ne!(generate().hash, generate().hash);
120    }
121
122    #[test]
123    fn malformed_keys_are_not_well_formed() {
124        for bad in [
125            "",
126            "cdo_",
127            "cdo_short",
128            "xyz_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
129            "cdo_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
130            "cdo_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ",
131            "cdo_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+",
132        ] {
133            assert!(!is_well_formed(bad), "{bad:?}");
134        }
135        assert!(is_well_formed(
136            "cdo_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
137        ));
138    }
139}