1use 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#[derive(Clone)]
25pub struct MintedSecret {
26 pub secret: Secret,
27 pub expires_at_ms: u64,
28}
29
30pub trait CredentialProvider: Send + Sync {
35 fn mint(
37 &self,
38 now_ms: u64,
39 ) -> Pin<Box<dyn Future<Output = Result<MintedSecret, String>> + Send + '_>>;
40
41 fn refresh_skew_ms(&self) -> u64 {
44 60_000
45 }
46}
47
48pub 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 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 pub fn insert_static(&self, id: impl Into<String>, secret: Secret) {
73 self.static_secrets.write().insert(id.into(), secret);
74 }
75
76 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 self.cache.read().get(id).map(|m| m.secret.clone())
112 }
113}
114
115pub 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
135pub 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
151const EKS_TOKEN_TTL_MS: u64 = 15 * 60 * 1000;
153const EKS_PRESIGN_EXPIRES: u64 = 900;
155
156impl EksGetTokenProvider {
157 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 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(¶ms);
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
218pub struct GitHubAppProvider {
226 pub app_id: String,
227 pub installation_id: String,
228 pub private_key_pkcs8_der: Vec<u8>,
230 pub api_base: String,
232 pub client: reqwest::Client,
233}
234
235const GH_TOKEN_TTL_MS: u64 = 55 * 60 * 1000;
237
238impl GitHubAppProvider {
239 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
309pub 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
323fn b64url(bytes: &[u8]) -> String {
328 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
329}
330
331fn 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
381fn 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 assert!(!url.contains("prod-cluster"));
438 assert_eq!(token, p.token(now));
440 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"#)); assert!(claims.contains(r#""exp":1700000540"#)); 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 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 assert_eq!(store.resolve("ghs").unwrap().expose(), "static-secret");
518 assert!(store.resolve("eks").is_none());
519
520 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 assert!(store.refresh_due(2_000).await.is_empty());
527 assert_eq!(store.resolve("eks").unwrap().expose(), "minted@1000");
528
529 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}