Skip to main content

atomcode_core/coding_plan/
crypto.rs

1// Public interface for per-request signing of AtomGit LLM gateway calls.
2//
3// Open-source build (`codingplan-crypto` feature off — the default):
4// `signer()` returns `UnavailableSigner`, so any AtomGit-bound request
5// fails-fast with a localised "official build required" hint.
6//
7// Official build (`codingplan-crypto` feature on): `signer()` returns
8// `RealSigner`, a thin pass-through to `atomcode_codingplan_crypto::sign_v1`.
9// The closed-source crate owns all wire-format details (body hashing,
10// header names, hex encoding, canonical-message construction, master
11// secret, HMAC primitive); the wrapper here only marshals trait inputs
12// into primitive-typed arguments and returns the resulting headers
13// unchanged. The public source tree carries a stub crate at
14// `crates/atomcode-codingplan-crypto/` with the same API surface; the
15// official build pipeline replaces that directory with the private
16// overlay before turning the feature on.
17
18use thiserror::Error;
19
20/// Sign a single outbound request. The body stays plaintext; the impl
21/// returns the headers the caller must merge onto the outbound
22/// `reqwest::RequestBuilder`.
23pub trait RequestSigner: Send + Sync {
24    fn sign(&self, req: SignInput<'_>) -> Result<SignOutput, SignError>;
25    /// One-byte selector identifying which signing scheme the impl
26    /// emits. `0` is reserved for `UnavailableSigner`; real algorithms
27    /// start at `1`.
28    fn algorithm_version(&self) -> u8;
29}
30
31pub struct SignInput<'a> {
32    pub method: &'a str,
33    pub path: &'a str,
34    pub body: &'a [u8],
35    pub oauth_token: &'a str,
36    pub user_id: &'a str,
37    pub timestamp_unix: u64,
38    pub nonce: [u8; 16],
39}
40
41#[derive(Debug)]
42pub struct SignOutput {
43    pub headers: Vec<(&'static str, String)>,
44}
45
46#[derive(Debug, Error)]
47pub enum SignError {
48    #[error("signer unavailable in this build")]
49    Unavailable,
50    #[error("signing-key derivation failed: {0}")]
51    Derive(String),
52}
53
54/// Zero-sized stub. Always errors with `Unavailable`.
55pub struct UnavailableSigner;
56
57impl RequestSigner for UnavailableSigner {
58    fn sign(&self, _req: SignInput<'_>) -> Result<SignOutput, SignError> {
59        Err(SignError::Unavailable)
60    }
61    fn algorithm_version(&self) -> u8 {
62        0
63    }
64}
65
66#[cfg(not(feature = "codingplan-crypto"))]
67static UNAVAILABLE_SIGNER: UnavailableSigner = UnavailableSigner;
68
69/// Accessor used by every caller. Returns `UnavailableSigner` in the
70/// open-source build; with `codingplan-crypto` on, returns `RealSigner`
71/// which forwards into the closed-source `atomcode-codingplan-crypto`
72/// crate.
73#[cfg(not(feature = "codingplan-crypto"))]
74pub fn signer() -> &'static dyn RequestSigner {
75    &UNAVAILABLE_SIGNER
76}
77
78#[cfg(feature = "codingplan-crypto")]
79struct RealSigner;
80
81#[cfg(feature = "codingplan-crypto")]
82impl RequestSigner for RealSigner {
83    fn sign(&self, req: SignInput<'_>) -> Result<SignOutput, SignError> {
84        // env!() expands at compile time to atomcode-core's package
85        // version (which inherits version.workspace = true, so it
86        // equals the atomcode binary's version). Threading it in here
87        // — rather than through SignInput — keeps the call sites in
88        // provider/openai.rs unaware of the version-binding mechanism.
89        Ok(SignOutput {
90            headers: atomcode_codingplan_crypto::sign_v1(
91                req.method,
92                req.path,
93                req.body,
94                req.oauth_token,
95                req.user_id,
96                req.timestamp_unix,
97                &req.nonce,
98                env!("CARGO_PKG_VERSION"),
99            ),
100        })
101    }
102
103    fn algorithm_version(&self) -> u8 {
104        atomcode_codingplan_crypto::ALGORITHM_VERSION
105    }
106}
107
108#[cfg(feature = "codingplan-crypto")]
109static REAL_SIGNER: RealSigner = RealSigner;
110
111#[cfg(feature = "codingplan-crypto")]
112pub fn signer() -> &'static dyn RequestSigner {
113    &REAL_SIGNER
114}
115
116/// True iff the given base URL points at an AtomGit-operated LLM
117/// gateway that REQUIRES per-request signing.
118///
119/// Host-based — does NOT trust provider config keys. Rejects non-
120/// HTTP(S) schemes and subdomain spoofs.
121///
122/// Both production hostnames sign:
123///   * `llm-api.atomgit.com` — current dedicated host.
124///   * `api-ai.gitcode.com`  — pre-P3 host. Kept signing-enforced
125///     so any legacy provider config silently upgraded to signing
126///     after the P3 cutover; users with `api-ai.gitcode.com` in their
127///     config get the same protection as `llm-api.atomgit.com` users
128///     and don't need to edit their config to migrate.
129pub(crate) fn is_atomgit_gateway(base_url: &str) -> bool {
130    let url = match url::Url::parse(base_url) {
131        Ok(u) => u,
132        Err(_) => return false,
133    };
134    match url.scheme() {
135        "https" | "http" => {}
136        _ => return false,
137    }
138    matches!(
139        url.host_str(),
140        Some("llm-api.atomgit.com") | Some("api-ai.gitcode.com")
141    )
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn unavailable_signer_returns_unavailable_error() {
150        let s = UnavailableSigner;
151        let input = SignInput {
152            method: "POST",
153            path: "/v1/chat/completions",
154            body: b"{}",
155            oauth_token: "any-token",
156            user_id: "user-1",
157            timestamp_unix: 1_700_000_000,
158            nonce: [0u8; 16],
159        };
160        let err = s.sign(input).expect_err("UnavailableSigner must error");
161        assert!(matches!(err, SignError::Unavailable));
162    }
163
164    #[test]
165    fn unavailable_signer_reports_algorithm_version_zero() {
166        let s = UnavailableSigner;
167        assert_eq!(s.algorithm_version(), 0);
168    }
169
170    #[cfg(not(feature = "codingplan-crypto"))]
171    #[test]
172    fn default_signer_in_open_source_build_is_unavailable() {
173        let input = SignInput {
174            method: "POST",
175            path: "/v1/chat/completions",
176            body: b"{}",
177            oauth_token: "any-token",
178            user_id: "user-1",
179            timestamp_unix: 1_700_000_000,
180            nonce: [0u8; 16],
181        };
182        let err = signer().sign(input).expect_err("open-source must error");
183        assert!(matches!(err, SignError::Unavailable));
184    }
185
186    #[test]
187    fn is_atomgit_gateway_matches_official_host() {
188        assert!(is_atomgit_gateway("https://llm-api.atomgit.com/v1"));
189        assert!(is_atomgit_gateway("https://llm-api.atomgit.com/v1/chat/completions"));
190    }
191
192    #[test]
193    fn is_atomgit_gateway_matches_legacy_codingplan_host() {
194        // Post-P3 cutover: `api-ai.gitcode.com` is now also a signing-
195        // enforced gateway. Previously this test asserted the opposite
196        // (legacy host plaintext until P3) — the inversion is the
197        // contract change.
198        assert!(is_atomgit_gateway("https://api-ai.gitcode.com/v1"));
199        assert!(is_atomgit_gateway("https://api-ai.gitcode.com/v1/chat/completions"));
200    }
201
202    #[test]
203    fn is_atomgit_gateway_rejects_third_party_hosts() {
204        assert!(!is_atomgit_gateway("https://api.anthropic.com"));
205        assert!(!is_atomgit_gateway("https://api.openai.com/v1"));
206        assert!(!is_atomgit_gateway("http://localhost:11434"));
207    }
208
209    #[test]
210    fn is_atomgit_gateway_rejects_subdomains_and_lookalikes() {
211        assert!(!is_atomgit_gateway("https://llm-api.atomgit.com.evil.example"));
212        assert!(!is_atomgit_gateway("https://evil.llm-api.atomgit.com"));
213        assert!(!is_atomgit_gateway("https://atomgit.com"));
214    }
215
216    #[test]
217    fn is_atomgit_gateway_rejects_malformed_input() {
218        assert!(!is_atomgit_gateway(""));
219        assert!(!is_atomgit_gateway("not a url"));
220        assert!(!is_atomgit_gateway("ftp://llm-api.atomgit.com"));
221    }
222}