Skip to main content

hackamore_control/
providers.rs

1//! Minting, rotating credential providers.
2//!
3//! The base [`crate::credentials::InMemoryCredentials`] vault is static: an id maps to a
4//! pre-provisioned secret. Real upstreams instead want *short-lived* credentials minted on
5//! demand and rotated before they expire — an AWS EKS `get-token` (a presigned STS URL,
6//! ~15 min) or a GitHub-App installation token (~1 h). This module adds that without
7//! changing the data plane: a [`CredentialProvider`] mints a secret, and
8//! [`CachingCredentials`] caches the latest minted value behind the *synchronous*
9//! [`CredentialStore`] the gateway already calls on the request path. A background refresher
10//! ([`CachingCredentials::refresh_due`], driven by [`spawn_refresher`]) re-mints before
11//! expiry, so `resolve` stays fast and never blocks — and **fails closed**: until a value is
12//! minted, `resolve` returns `None` and the request is denied.
13
14use crate::credentials::{CredentialStore, Secret};
15use base64::Engine;
16use parking_lot::RwLock;
17use ring::{digest, hmac, rand, signature};
18use std::collections::HashMap;
19use std::future::Future;
20use std::pin::Pin;
21use std::sync::Arc;
22
23/// A freshly minted secret and when it expires (epoch ms).
24#[derive(Clone)]
25pub struct MintedSecret {
26    pub secret: Secret,
27    pub expires_at_ms: u64,
28}
29
30/// Mints a short-lived upstream credential, and re-mints it on rotation. Async because real
31/// minters call out (the GitHub-App exchange is HTTP; the EKS presign is local but shares
32/// the signature). Returns the secret and its expiry; an error fails closed (the cache keeps
33/// the previous value until it too expires).
34pub trait CredentialProvider: Send + Sync {
35    /// Mint a fresh secret as of `now_ms`.
36    fn mint(
37        &self,
38        now_ms: u64,
39    ) -> Pin<Box<dyn Future<Output = Result<MintedSecret, String>> + Send + '_>>;
40
41    /// Re-mint this many milliseconds *before* the cached secret expires, to rotate without
42    /// a gap. Defaults to one minute.
43    fn refresh_skew_ms(&self) -> u64 {
44        60_000
45    }
46}
47
48/// A credential store that serves the latest minted value for each provider-backed id, and
49/// pre-seeded static secrets for the rest. The data plane calls [`CredentialStore::resolve`]
50/// (sync); minting happens out of band in [`Self::refresh_due`].
51pub struct CachingCredentials {
52    static_secrets: RwLock<HashMap<String, Secret>>,
53    providers: HashMap<String, Arc<dyn CredentialProvider>>,
54    cache: RwLock<HashMap<String, MintedSecret>>,
55}
56
57impl CachingCredentials {
58    /// A store with the given static secrets and minting providers. Ids must be disjoint;
59    /// a provider id shadows a static one of the same name.
60    pub fn new(
61        static_secrets: HashMap<String, Secret>,
62        providers: HashMap<String, Arc<dyn CredentialProvider>>,
63    ) -> Self {
64        Self {
65            static_secrets: RwLock::new(static_secrets),
66            providers,
67            cache: RwLock::new(HashMap::new()),
68        }
69    }
70
71    /// Register or replace a static secret (used in tests and for late-bound config).
72    pub fn insert_static(&self, id: impl Into<String>, secret: Secret) {
73        self.static_secrets.write().insert(id.into(), secret);
74    }
75
76    /// Mint every provider-backed credential whose cached value is missing or within its
77    /// refresh skew of expiry. Returns the ids (re)minted. Errors are logged and skipped so
78    /// one failing provider doesn't stall the others.
79    pub async fn refresh_due(&self, now_ms: u64) -> Vec<String> {
80        let mut refreshed = Vec::new();
81        for (id, provider) in &self.providers {
82            if !self.needs_refresh(id, provider.as_ref(), now_ms) {
83                continue;
84            }
85            match provider.mint(now_ms).await {
86                Ok(minted) => {
87                    self.cache.write().insert(id.clone(), minted);
88                    refreshed.push(id.clone());
89                }
90                Err(e) => tracing::warn!(credential = %id, "credential mint failed: {e}"),
91            }
92        }
93        refreshed
94    }
95
96    fn needs_refresh(&self, id: &str, provider: &dyn CredentialProvider, now_ms: u64) -> bool {
97        match self.cache.read().get(id) {
98            None => true,
99            Some(m) => now_ms.saturating_add(provider.refresh_skew_ms()) >= m.expires_at_ms,
100        }
101    }
102}
103
104impl CredentialStore for CachingCredentials {
105    fn resolve(&self, id: &str) -> Option<Secret> {
106        if let Some(s) = self.static_secrets.read().get(id) {
107            return Some(s.clone());
108        }
109        // Provider-backed: serve the cached minted value (the refresher keeps it fresh).
110        // Absent ⇒ not yet minted ⇒ fail closed.
111        self.cache.read().get(id).map(|m| m.secret.clone())
112    }
113}
114
115/// Spawn a background task that calls [`CachingCredentials::refresh_due`] every
116/// `interval`, using `clock` for the current time. Priming and rotation both flow through
117/// it. The task lives for the process; it is dropped when the runtime shuts down.
118pub fn spawn_refresher(
119    creds: Arc<CachingCredentials>,
120    clock: Arc<dyn Fn() -> u64 + Send + Sync>,
121    interval: std::time::Duration,
122) {
123    tokio::spawn(async move {
124        let mut ticker = tokio::time::interval(interval);
125        loop {
126            ticker.tick().await;
127            let refreshed = creds.refresh_due(clock()).await;
128            if !refreshed.is_empty() {
129                tracing::debug!(?refreshed, "rotated credentials");
130            }
131        }
132    });
133}
134
135// ---------------------------------------------------------------------------------------
136// AWS EKS get-token provider
137// ---------------------------------------------------------------------------------------
138
139/// Mints an EKS `get-token` credential: a presigned STS `GetCallerIdentity` URL (SigV4
140/// query auth, scoped to the cluster via the signed `x-k8s-aws-id` header), base64url-
141/// encoded with the `k8s-aws-v1.` prefix — exactly what `aws eks get-token` produces and
142/// what the kubelet/`kubectl` send as a bearer token. Fully local: no network, just the
143/// account credential and the SigV4 primitives.
144pub struct EksGetTokenProvider {
145    pub access_key_id: String,
146    pub secret_access_key: Secret,
147    pub region: String,
148    pub cluster_name: String,
149}
150
151/// EKS tokens are valid for 15 minutes; mint with that window.
152const EKS_TOKEN_TTL_MS: u64 = 15 * 60 * 1000;
153/// STS presign expiry (seconds) baked into the URL.
154const EKS_PRESIGN_EXPIRES: u64 = 900;
155
156impl EksGetTokenProvider {
157    /// Build the `k8s-aws-v1.<base64url(presigned-url)>` token for `now_ms`.
158    pub fn token(&self, now_ms: u64) -> String {
159        let url = self.presigned_url(now_ms);
160        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(url.as_bytes());
161        format!("k8s-aws-v1.{encoded}")
162    }
163
164    fn presigned_url(&self, now_ms: u64) -> String {
165        let host = format!("sts.{}.amazonaws.com", self.region);
166        let (amz_date, datestamp) = format_amz_datetime(now_ms);
167        let scope = format!("{datestamp}/{}/sts/aws4_request", self.region);
168        let signed_headers = "host;x-k8s-aws-id";
169        // Query params that participate in the signature (everything but X-Amz-Signature),
170        // already in sorted order (uppercase 'A' params sort before lowercase 'k'/'V').
171        let credential = format!("{}/{scope}", self.access_key_id);
172        let expires = EKS_PRESIGN_EXPIRES.to_string();
173        let params = [
174            ("Action", "GetCallerIdentity"),
175            ("Version", "2011-06-15"),
176            ("X-Amz-Algorithm", "AWS4-HMAC-SHA256"),
177            ("X-Amz-Credential", credential.as_str()),
178            ("X-Amz-Date", amz_date.as_str()),
179            ("X-Amz-Expires", expires.as_str()),
180            ("X-Amz-SignedHeaders", signed_headers),
181        ];
182        let canonical_query = canonical_query(&params);
183        let canonical_headers = format!("host:{host}\nx-k8s-aws-id:{}\n", self.cluster_name);
184        let payload_hash = sha256_hex(b"");
185        let canonical_request = format!(
186            "GET\n/\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
187        );
188        let string_to_sign = format!(
189            "AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}",
190            sha256_hex(canonical_request.as_bytes())
191        );
192        let signing_key = derive_signing_key(
193            self.secret_access_key.expose(),
194            &datestamp,
195            &self.region,
196            "sts",
197        );
198        let signature = to_hex(&hmac256(&signing_key, string_to_sign.as_bytes()));
199        format!("https://{host}/?{canonical_query}&X-Amz-Signature={signature}")
200    }
201}
202
203impl CredentialProvider for EksGetTokenProvider {
204    fn mint(
205        &self,
206        now_ms: u64,
207    ) -> Pin<Box<dyn Future<Output = Result<MintedSecret, String>> + Send + '_>> {
208        let token = self.token(now_ms);
209        Box::pin(async move {
210            Ok(MintedSecret {
211                secret: Secret::new(token),
212                expires_at_ms: now_ms.saturating_add(EKS_TOKEN_TTL_MS),
213            })
214        })
215    }
216}
217
218// ---------------------------------------------------------------------------------------
219// GitHub App installation-token provider
220// ---------------------------------------------------------------------------------------
221
222/// Mints a GitHub-App installation token: sign a short-lived RS256 JWT with the app's
223/// private key, then exchange it at `POST /app/installations/{id}/access_tokens` for an
224/// installation token (~1 h). The JWT signing is local; the exchange is one HTTP call.
225pub struct GitHubAppProvider {
226    pub app_id: String,
227    pub installation_id: String,
228    /// The app's RSA private key in PKCS#8 DER (parse from PEM with [`pkcs8_from_pem`]).
229    pub private_key_pkcs8_der: Vec<u8>,
230    /// API base, e.g. `https://api.github.com` (override for GHES or a test mock).
231    pub api_base: String,
232    pub client: reqwest::Client,
233}
234
235/// GitHub installation tokens last an hour; refresh well before then.
236const GH_TOKEN_TTL_MS: u64 = 55 * 60 * 1000;
237
238impl GitHubAppProvider {
239    /// Build the signed app JWT for `now_ms` (valid 60 s in the past to 9 min ahead, per
240    /// GitHub's guidance to tolerate clock skew). Public for testing.
241    pub fn app_jwt(&self, now_ms: u64) -> Result<String, String> {
242        let now_s = now_ms / 1000;
243        let header = b64url(br#"{"alg":"RS256","typ":"JWT"}"#);
244        let claims = b64url(
245            format!(
246                r#"{{"iat":{},"exp":{},"iss":"{}"}}"#,
247                now_s.saturating_sub(60),
248                now_s + 540,
249                self.app_id
250            )
251            .as_bytes(),
252        );
253        let signing_input = format!("{header}.{claims}");
254        let key = signature::RsaKeyPair::from_pkcs8(&self.private_key_pkcs8_der)
255            .map_err(|e| format!("invalid app private key: {e}"))?;
256        let mut sig = vec![0u8; key.public().modulus_len()];
257        key.sign(
258            &signature::RSA_PKCS1_SHA256,
259            &rand::SystemRandom::new(),
260            signing_input.as_bytes(),
261            &mut sig,
262        )
263        .map_err(|e| format!("jwt signing failed: {e}"))?;
264        Ok(format!("{signing_input}.{}", b64url(&sig)))
265    }
266}
267
268impl CredentialProvider for GitHubAppProvider {
269    fn mint(
270        &self,
271        now_ms: u64,
272    ) -> Pin<Box<dyn Future<Output = Result<MintedSecret, String>> + Send + '_>> {
273        Box::pin(async move {
274            let jwt = self.app_jwt(now_ms)?;
275            let url = format!(
276                "{}/app/installations/{}/access_tokens",
277                self.api_base.trim_end_matches('/'),
278                self.installation_id
279            );
280            let resp = self
281                .client
282                .post(&url)
283                .bearer_auth(&jwt)
284                .header(reqwest::header::ACCEPT, "application/vnd.github+json")
285                .header(reqwest::header::USER_AGENT, "hackamore")
286                .send()
287                .await
288                .map_err(|e| format!("installation-token request failed: {e}"))?;
289            if !resp.status().is_success() {
290                return Err(format!("installation-token HTTP {}", resp.status()));
291            }
292            let body: InstallationToken = resp
293                .json()
294                .await
295                .map_err(|e| format!("installation-token decode failed: {e}"))?;
296            Ok(MintedSecret {
297                secret: Secret::new(body.token),
298                expires_at_ms: now_ms.saturating_add(GH_TOKEN_TTL_MS),
299            })
300        })
301    }
302}
303
304#[derive(serde::Deserialize)]
305struct InstallationToken {
306    token: String,
307}
308
309/// Decode a PKCS#8 PEM private key (`-----BEGIN PRIVATE KEY-----`) into DER bytes for
310/// [`GitHubAppProvider::private_key_pkcs8_der`].
311pub fn pkcs8_from_pem(pem: &str) -> Result<Vec<u8>, String> {
312    let begin = "-----BEGIN PRIVATE KEY-----";
313    let end = "-----END PRIVATE KEY-----";
314    let start = pem.find(begin).ok_or("no PKCS#8 PRIVATE KEY block")?;
315    let after = &pem[start + begin.len()..];
316    let stop = after.find(end).ok_or("unterminated PRIVATE KEY block")?;
317    let body: String = after[..stop].split_whitespace().collect();
318    base64::engine::general_purpose::STANDARD
319        .decode(body.as_bytes())
320        .map_err(|e| format!("base64 decode key: {e}"))
321}
322
323// ---------------------------------------------------------------------------------------
324// SigV4 / encoding primitives (kept local to avoid a control→gateway dependency)
325// ---------------------------------------------------------------------------------------
326
327fn b64url(bytes: &[u8]) -> String {
328    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
329}
330
331/// Canonical SigV4 query string from already-sorted `(name, value)` params: URI-encode each
332/// (slashes included) and join with `&`.
333fn canonical_query(params: &[(&str, &str)]) -> String {
334    params
335        .iter()
336        .map(|(k, v)| format!("{}={}", uri_encode(k.as_bytes()), uri_encode(v.as_bytes())))
337        .collect::<Vec<_>>()
338        .join("&")
339}
340
341fn uri_encode(input: &[u8]) -> String {
342    let mut out = String::with_capacity(input.len());
343    for &b in input {
344        match b {
345            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'~' | b'.' => {
346                out.push(b as char)
347            }
348            _ => out.push_str(&format!("%{b:02X}")),
349        }
350    }
351    out
352}
353
354fn derive_signing_key(secret: &str, datestamp: &str, region: &str, service: &str) -> [u8; 32] {
355    let k_date = hmac256(format!("AWS4{secret}").as_bytes(), datestamp.as_bytes());
356    let k_region = hmac256(&k_date, region.as_bytes());
357    let k_service = hmac256(&k_region, service.as_bytes());
358    hmac256(&k_service, b"aws4_request")
359}
360
361fn hmac256(key: &[u8], data: &[u8]) -> [u8; 32] {
362    let k = hmac::Key::new(hmac::HMAC_SHA256, key);
363    let tag = hmac::sign(&k, data);
364    let mut out = [0u8; 32];
365    out.copy_from_slice(tag.as_ref());
366    out
367}
368
369fn sha256_hex(data: &[u8]) -> String {
370    to_hex(digest::digest(&digest::SHA256, data).as_ref())
371}
372
373fn to_hex(bytes: &[u8]) -> String {
374    let mut s = String::with_capacity(bytes.len() * 2);
375    for b in bytes {
376        s.push_str(&format!("{b:02x}"));
377    }
378    s
379}
380
381/// Format epoch ms as the SigV4 `YYYYMMDDTHHMMSSZ` and `YYYYMMDD` strings (UTC).
382fn format_amz_datetime(epoch_ms: u64) -> (String, String) {
383    let secs = (epoch_ms / 1000) as i64;
384    let days = secs.div_euclid(86_400);
385    let tod = secs.rem_euclid(86_400);
386    let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
387    let (y, m, d) = civil_from_days(days);
388    (
389        format!("{y:04}{m:02}{d:02}T{h:02}{mi:02}{s:02}Z"),
390        format!("{y:04}{m:02}{d:02}"),
391    )
392}
393
394fn civil_from_days(z: i64) -> (i64, u32, u32) {
395    let z = z + 719_468;
396    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
397    let doe = z - era * 146_097;
398    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
399    let y = yoe + era * 400;
400    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
401    let mp = (5 * doy + 2) / 153;
402    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
403    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
404    (if m <= 2 { y + 1 } else { y }, m, d)
405}
406
407#[cfg(test)]
408#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn eks_token_has_expected_shape_and_is_deterministic() {
414        let p = EksGetTokenProvider {
415            access_key_id: "AKIDTEST".into(),
416            secret_access_key: Secret::new("wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"),
417            region: "us-east-1".into(),
418            cluster_name: "prod-cluster".into(),
419        };
420        let now = 1_700_000_000_000;
421        let token = p.token(now);
422        assert!(token.starts_with("k8s-aws-v1."));
423        let url_b64 = token.strip_prefix("k8s-aws-v1.").unwrap();
424        let url = String::from_utf8(
425            base64::engine::general_purpose::URL_SAFE_NO_PAD
426                .decode(url_b64)
427                .unwrap(),
428        )
429        .unwrap();
430        assert!(url.starts_with("https://sts.us-east-1.amazonaws.com/?"));
431        assert!(url.contains("Action=GetCallerIdentity"));
432        assert!(url.contains("X-Amz-Credential=AKIDTEST%2F"));
433        assert!(url.contains("X-Amz-Expires=900"));
434        assert!(url.contains("X-Amz-SignedHeaders=host%3Bx-k8s-aws-id"));
435        assert!(url.contains("X-Amz-Signature="));
436        // The cluster is bound via the signed header, never in the URL query.
437        assert!(!url.contains("prod-cluster"));
438        // Same inputs → identical token (no hidden randomness).
439        assert_eq!(token, p.token(now));
440        // A later timestamp produces a different signature/date.
441        assert_ne!(token, p.token(now + 86_400_000));
442    }
443
444    #[test]
445    fn github_app_jwt_is_well_formed_and_signs() {
446        let pem = include_str!("../testdata/github_app_key.pem");
447        let der = pkcs8_from_pem(pem).unwrap();
448        let p = GitHubAppProvider {
449            app_id: "123456".into(),
450            installation_id: "789".into(),
451            private_key_pkcs8_der: der,
452            api_base: "https://api.github.com".into(),
453            client: reqwest::Client::new(),
454        };
455        let now = 1_700_000_000_000;
456        let jwt = p.app_jwt(now).unwrap();
457        let parts: Vec<&str> = jwt.split('.').collect();
458        assert_eq!(parts.len(), 3, "header.claims.signature");
459        let header = String::from_utf8(
460            base64::engine::general_purpose::URL_SAFE_NO_PAD
461                .decode(parts[0])
462                .unwrap(),
463        )
464        .unwrap();
465        assert!(header.contains("RS256"));
466        let claims = String::from_utf8(
467            base64::engine::general_purpose::URL_SAFE_NO_PAD
468                .decode(parts[1])
469                .unwrap(),
470        )
471        .unwrap();
472        assert!(claims.contains(r#""iss":"123456""#));
473        assert!(claims.contains(r#""iat":1699999940"#)); // now_s - 60
474        assert!(claims.contains(r#""exp":1700000540"#)); // now_s + 540
475        // RSA-2048 signature is 256 bytes → 342 base64url chars (no padding).
476        assert_eq!(
477            base64::engine::general_purpose::URL_SAFE_NO_PAD
478                .decode(parts[2])
479                .unwrap()
480                .len(),
481            256
482        );
483    }
484
485    /// A trivial provider whose secret encodes its mint time + expiry window, for exercising
486    /// the cache/refresh logic deterministically.
487    struct StubProvider {
488        ttl_ms: u64,
489    }
490    impl CredentialProvider for StubProvider {
491        fn mint(
492            &self,
493            now_ms: u64,
494        ) -> Pin<Box<dyn Future<Output = Result<MintedSecret, String>> + Send + '_>> {
495            let ttl = self.ttl_ms;
496            Box::pin(async move {
497                Ok(MintedSecret {
498                    secret: Secret::new(format!("minted@{now_ms}")),
499                    expires_at_ms: now_ms + ttl,
500                })
501            })
502        }
503        fn refresh_skew_ms(&self) -> u64 {
504            1_000
505        }
506    }
507
508    #[tokio::test]
509    async fn caching_store_fails_closed_then_serves_and_rotates() {
510        let mut providers: HashMap<String, Arc<dyn CredentialProvider>> = HashMap::new();
511        providers.insert("eks".into(), Arc::new(StubProvider { ttl_ms: 10_000 }));
512        let mut statics = HashMap::new();
513        statics.insert("ghs".to_string(), Secret::new("static-secret"));
514        let store = CachingCredentials::new(statics, providers);
515
516        // Static secret resolves immediately; provider-backed fails closed until minted.
517        assert_eq!(store.resolve("ghs").unwrap().expose(), "static-secret");
518        assert!(store.resolve("eks").is_none());
519
520        // Prime at t=1000 → resolves the minted value.
521        let refreshed = store.refresh_due(1_000).await;
522        assert_eq!(refreshed, vec!["eks".to_string()]);
523        assert_eq!(store.resolve("eks").unwrap().expose(), "minted@1000");
524
525        // Well within TTL → no rotation.
526        assert!(store.refresh_due(2_000).await.is_empty());
527        assert_eq!(store.resolve("eks").unwrap().expose(), "minted@1000");
528
529        // Within refresh skew of expiry (expires at 11_000, skew 1_000) → rotates.
530        let refreshed = store.refresh_due(10_500).await;
531        assert_eq!(refreshed, vec!["eks".to_string()]);
532        assert_eq!(store.resolve("eks").unwrap().expose(), "minted@10500");
533    }
534
535    #[test]
536    fn pkcs8_from_pem_round_trips() {
537        let pem = include_str!("../testdata/github_app_key.pem");
538        let der = pkcs8_from_pem(pem).unwrap();
539        assert!(signature::RsaKeyPair::from_pkcs8(&der).is_ok());
540        assert!(pkcs8_from_pem("not a key").is_err());
541    }
542}