use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use ed25519_dalek::Verifier;
use serde::{Deserialize, Serialize};
use crate::{ClusterSigner, Result, SecretsError};
const DOMAIN_TAG: &str = "zlayer-worker-bootstrap-v1";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerBootstrapClaims {
pub domain_tag: String,
pub cluster_id: String,
pub jti: String,
pub issued_at_unix: i64,
pub expires_at_unix: i64,
pub max_uses: u32,
#[serde(default)]
pub permitted_labels: Vec<(String, String)>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerBootstrapToken {
pub claims: WorkerBootstrapClaims,
pub signer_kid: String,
pub signature_b64: String,
}
impl WorkerBootstrapToken {
pub fn to_cli_string(&self) -> Result<String> {
let bytes = postcard2::to_vec(self)
.map_err(|e| SecretsError::Encryption(format!("encode worker token: {e}")))?;
Ok(URL_SAFE_NO_PAD.encode(bytes))
}
pub fn from_cli_string(s: &str) -> Result<Self> {
let bytes = URL_SAFE_NO_PAD
.decode(s)
.map_err(|e| SecretsError::Encryption(format!("decode worker token base64: {e}")))?;
postcard2::from_bytes(&bytes)
.map_err(|e| SecretsError::Encryption(format!("decode worker token postcard2: {e}")))
}
}
pub fn issue_worker_bootstrap_token(
signer: &ClusterSigner,
cluster_id: impl Into<String>,
valid_for_secs: i64,
max_uses: u32,
permitted_labels: Vec<(String, String)>,
) -> Result<WorkerBootstrapToken> {
let now = time::OffsetDateTime::now_utc().unix_timestamp();
let jti = uuid::Uuid::new_v4().to_string();
let claims = WorkerBootstrapClaims {
domain_tag: DOMAIN_TAG.into(),
cluster_id: cluster_id.into(),
jti,
issued_at_unix: now,
expires_at_unix: now + valid_for_secs,
max_uses,
permitted_labels,
};
let payload = postcard2::to_vec(&claims)
.map_err(|e| SecretsError::Encryption(format!("encode bootstrap claims: {e}")))?;
let sig_bytes = signer.sign(&payload);
Ok(WorkerBootstrapToken {
claims,
signer_kid: signer.key_id(),
signature_b64: URL_SAFE_NO_PAD.encode(sig_bytes),
})
}
pub fn verify_worker_bootstrap_token(
signer: &ClusterSigner,
token: &WorkerBootstrapToken,
) -> Result<WorkerBootstrapClaims> {
if token.claims.domain_tag != DOMAIN_TAG {
return Err(SecretsError::Encryption(format!(
"wrong token domain: expected {DOMAIN_TAG}, got {}",
token.claims.domain_tag
)));
}
if signer.key_id() != token.signer_kid {
return Err(SecretsError::Encryption(format!(
"signer kid mismatch: signer has {}, token claims {}",
signer.key_id(),
token.signer_kid
)));
}
let now = time::OffsetDateTime::now_utc().unix_timestamp();
if now >= token.claims.expires_at_unix {
return Err(SecretsError::Encryption("token expired".into()));
}
if token.claims.issued_at_unix > now + 60 {
return Err(SecretsError::Encryption(
"token issued more than 60s in the future".into(),
));
}
let sig_bytes = URL_SAFE_NO_PAD
.decode(&token.signature_b64)
.map_err(|e| SecretsError::Encryption(format!("decode token signature: {e}")))?;
let sig_array: [u8; 64] = sig_bytes
.as_slice()
.try_into()
.map_err(|_| SecretsError::Encryption("token signature wrong length".into()))?;
let signature = ed25519_dalek::Signature::from_bytes(&sig_array);
let payload = postcard2::to_vec(&token.claims)
.map_err(|e| SecretsError::Encryption(format!("re-encode claims: {e}")))?;
signer
.verifying_key()
.verify(&payload, &signature)
.map_err(|e| SecretsError::Encryption(format!("token signature invalid: {e}")))?;
Ok(token.claims.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
async fn make_signer() -> (ClusterSigner, TempDir) {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("cluster_signer.json");
let signer = ClusterSigner::load_or_generate(&path)
.await
.expect("load_or_generate");
(signer, dir)
}
#[tokio::test]
async fn issue_and_verify_round_trip() {
let (signer, _dir) = make_signer().await;
let token = issue_worker_bootstrap_token(
&signer,
"cluster-abc",
3600,
1,
vec![("region".into(), "us-east".into())],
)
.expect("issue");
let s = token.to_cli_string().expect("encode");
let parsed = WorkerBootstrapToken::from_cli_string(&s).expect("decode");
assert_eq!(token, parsed);
let claims = verify_worker_bootstrap_token(&signer, &parsed).expect("verify");
assert_eq!(claims.cluster_id, "cluster-abc");
assert_eq!(claims.max_uses, 1);
assert_eq!(
claims.permitted_labels,
vec![("region".into(), "us-east".into())]
);
}
#[tokio::test]
async fn expired_token_rejected() {
let (signer, _dir) = make_signer().await;
let mut token = issue_worker_bootstrap_token(&signer, "c", 3600, 1, vec![]).expect("issue");
token.claims.expires_at_unix = 0;
let payload = postcard2::to_vec(&token.claims).unwrap();
let sig = signer.sign(&payload);
token.signer_kid = signer.key_id();
token.signature_b64 = URL_SAFE_NO_PAD.encode(sig);
let err = verify_worker_bootstrap_token(&signer, &token).unwrap_err();
assert!(format!("{err}").contains("expired"));
}
#[tokio::test]
async fn tampered_signature_rejected() {
let (signer, _dir) = make_signer().await;
let token = issue_worker_bootstrap_token(&signer, "c", 3600, 1, vec![]).expect("issue");
let mut bad = token.clone();
bad.claims.cluster_id = "different-cluster".into();
let err = verify_worker_bootstrap_token(&signer, &bad).unwrap_err();
assert!(format!("{err}").contains("signature invalid"));
}
#[tokio::test]
async fn wrong_domain_tag_rejected() {
let (signer, _dir) = make_signer().await;
let mut token = issue_worker_bootstrap_token(&signer, "c", 3600, 1, vec![]).expect("issue");
token.claims.domain_tag = "other-domain".into();
let err = verify_worker_bootstrap_token(&signer, &token).unwrap_err();
assert!(format!("{err}").contains("domain"));
}
}