use base64::Engine as _;
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use link_assistant_router::storage::TokenRecord;
use super::{Coordinator, LABEL_KEY};
pub(super) const LABEL_SUFFIX: &str = "token-secret";
pub(super) const PROBE_ENV: &str = "ROUTER_DEPLOY_PROBE_TOKEN";
const FINGERPRINT_CONTEXT: &[u8] = b"link-assistant-router/deploy/token-secret-fingerprint/v1";
const PROBE_SCRIPT: &str = "const r=await fetch('http://127.0.0.1:8080/api/models',{signal:AbortSignal.timeout(5000),headers:{authorization:'Bearer '+process.env.ROUTER_DEPLOY_PROBE_TOKEN}});console.log(r.status)";
fn hmac_sha256(key: &[u8], message: &[u8]) -> Vec<u8> {
let signature =
jsonwebtoken::crypto::sign(message, &EncodingKey::from_secret(key), Algorithm::HS256)
.expect("HMAC-SHA256 accepts any key");
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(signature)
.expect("jsonwebtoken returns base64url")
}
pub(super) fn fingerprint(secret: &str) -> String {
let digest = hmac_sha256(secret.as_bytes(), FINGERPRINT_CONTEXT);
format!("hmac-sha256:{}", hex::encode(&digest[..16]))
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum SecretMatch {
Matches,
Changed,
Unknown,
NotSupplied,
}
impl SecretMatch {
pub(super) const fn as_str(self) -> &'static str {
match self {
Self::Matches => "matches",
Self::Changed => "changed",
Self::Unknown => "unknown",
Self::NotSupplied => "not-supplied",
}
}
}
pub(super) const fn probe_accepted(status: u16) -> bool {
status != 401 && status < 500
}
impl Coordinator<'_> {
pub(super) fn secret_match(&self, backend: &str) -> SecretMatch {
if link_assistant_router::token_secret::is_placeholder(self.token_secret) {
return SecretMatch::NotSupplied;
}
let running = self
.docker
.label(backend, &format!("{LABEL_KEY}.{LABEL_SUFFIX}"))
.or_else(|| {
self.docker
.env_value(backend, "TOKEN_SECRET")
.map(|secret| fingerprint(&secret))
});
match running {
None => SecretMatch::Unknown,
Some(running) if running == fingerprint(self.token_secret) => SecretMatch::Matches,
Some(_) => SecretMatch::Changed,
}
}
pub(super) fn print_secret(&self, backend: &str) -> bool {
let matched = self.secret_match(backend);
println!("token_secret={}", matched.as_str());
match matched {
SecretMatch::Changed => {
println!(
"blocker=token-secret-change reason=\"{backend} verifies tokens with a different TOKEN_SECRET; its issued client tokens would be rejected with HTTP 401\""
);
true
}
SecretMatch::Unknown => {
println!(
"blocker=token-secret-unknown reason=\"the TOKEN_SECRET {backend} was started with cannot be established\""
);
true
}
SecretMatch::Matches | SecretMatch::NotSupplied => false,
}
}
fn issued_client_tokens(&self, backend: &str) -> Option<usize> {
let now = chrono::Utc::now().timestamp();
let rendered = self.docker.token_inventory(backend).ok()?;
let records: Vec<TokenRecord> = serde_json::from_str(&rendered).ok()?;
Some(
records
.iter()
.filter(|record| !record.revoked && record.expires_at > now)
.count(),
)
}
pub(super) fn secret_preflight(&self, backend: &str) -> Result<(), String> {
let matched = self.secret_match(backend);
let reason = match matched {
SecretMatch::Matches => return Ok(()),
SecretMatch::NotSupplied => {
return Err(link_assistant_router::token_secret::refusal());
}
SecretMatch::Changed => format!(
"the supplied TOKEN_SECRET differs from the one {backend} verifies tokens with: every issued client token would be rejected with HTTP 401 and provider keys encrypted at rest could not be read. Rerun with the saved secret; if {backend} itself was started with a wrong secret, rerun with the correct one and --force-update"
),
SecretMatch::Unknown => format!(
"the TOKEN_SECRET {backend} was started with cannot be established, so token continuity cannot be proven; rerun with --force-update after confirming the secret"
),
};
if !self.force {
return Err(reason);
}
println!(
"force_update accepted token_secret_rotation token_secret={} issued_client_tokens={}",
matched.as_str(),
self.issued_client_tokens(backend)
.map_or_else(|| "unknown".to_string(), |count| count.to_string())
);
Ok(())
}
pub(super) fn probe_candidate(&self, candidate: &str, previous: &str) -> Result<(), String> {
if self.secret_match(previous) != SecretMatch::Matches {
println!("token_probe=skipped reason=forced TOKEN_SECRET rotation");
return Ok(());
}
let token = self.probe_token()?;
let answer = self
.docker
.exec_with_env(
candidate,
&[(PROBE_ENV, token.as_str())],
&["bun", "-e", PROBE_SCRIPT],
)
.map_err(|error| format!("token probe could not reach {candidate}: {error}"))?;
let status = answer
.lines()
.last()
.and_then(|line| line.trim().parse::<u16>().ok())
.ok_or_else(|| format!("token probe returned no HTTP status from {candidate}"))?;
if !probe_accepted(status) {
return Err(format!(
"{candidate} rejected a token signed with the running deployment's secret (HTTP {status}); issued client tokens would stop working"
));
}
println!("token_probe=accepted status={status}");
Ok(())
}
pub(super) fn probe_token(&self) -> Result<String, String> {
let now = chrono::Utc::now().timestamp();
let claims = link_assistant_router::token::TokenClaims {
sub: format!("deploy-probe-{}", uuid::Uuid::new_v4().simple()),
iat: now,
exp: now + 300,
label: "deploy-probe".to_string(),
scope: String::new(),
github_repos: Vec::new(),
client_kind: None,
principal_id: None,
};
let jwt = jsonwebtoken::encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(self.token_secret.as_bytes()),
)
.map_err(|error| format!("could not sign the token probe: {error}"))?;
Ok(format!(
"{}{jwt}",
link_assistant_router::token::TOKEN_PREFIX
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hmac_matches_the_published_vector() {
assert_eq!(
hex::encode(hmac_sha256(b"Jefe", b"what do ya want for nothing?")),
"5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
);
}
#[test]
fn a_fingerprint_distinguishes_secrets_without_containing_them() {
let first = fingerprint("secret-a");
assert_eq!(first, fingerprint("secret-a"));
assert_ne!(first, fingerprint("secret-b"));
assert!(first.starts_with("hmac-sha256:"));
assert_eq!(first.len(), "hmac-sha256:".len() + 32);
assert!(!first.contains("secret-a"));
}
#[test]
fn only_a_rejected_signature_or_a_failure_fails_the_probe() {
assert!(probe_accepted(200));
assert!(probe_accepted(403));
assert!(!probe_accepted(401));
assert!(!probe_accepted(500));
assert!(!probe_accepted(503));
}
}