use rand::RngExt as _;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use super::controller_key::{ControllerSigningKey, verify as verify_signature};
pub const JOIN_TOKEN_SCHEME: &str = "zfjoin2";
pub const LEGACY_JOIN_TOKEN_SCHEME: &str = "zfjoin1";
pub const CONTROLLER_CALL_SCHEME: &str = "zfjoin2c";
pub const VOUCH_SCHEME: &str = "zfjoin2v";
pub const VOUCH_TTL_SECS: i64 = 120;
const SECRET_BYTES: usize = 32;
const NONCE_BYTES: usize = 16;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct JoinToken {
pub office_id: String,
pub controller_verify_key: String,
pub secret: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JoinTokenError {
Malformed,
UnknownScheme {
found: String,
},
MissingOfficeId,
MissingVerifyKey,
MissingSecret,
}
impl std::fmt::Display for JoinTokenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let detail = match self {
Self::Malformed => format!(
"value is not shaped \
`{JOIN_TOKEN_SCHEME}:<office_id>:<controller_verify_key>:<secret>`"
),
Self::UnknownScheme { found } if found == LEGACY_JOIN_TOKEN_SCHEME => format!(
"'{LEGACY_JOIN_TOKEN_SCHEME}' tokens are no longer accepted. They carried no \
controller verification key, so an office holding one could not tell a real \
controller from anything that had read the controller's records. Re-mint this \
office's token and delete the stored one"
),
Self::UnknownScheme { found } => format!(
"scheme '{found}' is not '{JOIN_TOKEN_SCHEME}'; this release mints \
`{JOIN_TOKEN_SCHEME}:<office_id>:<controller_verify_key>:<secret>` only"
),
Self::MissingOfficeId => "the office id segment is empty".to_string(),
Self::MissingVerifyKey => {
"the controller verification key segment is empty".to_string()
}
Self::MissingSecret => "the secret segment is empty".to_string(),
};
write!(
f,
"invalid cluster join token: {detail}. Mint one on the controller \
(POST /api/platform/cluster/join-tokens with an office id) and give it to this office as \
ZEBFLOW_CLUSTER_JOIN_TOKEN"
)
}
}
impl std::error::Error for JoinTokenError {}
impl JoinToken {
pub fn mint(office_id: impl Into<String>, controller_verify_key: impl Into<String>) -> Self {
let mut bytes = [0u8; SECRET_BYTES];
rand::rng().fill(&mut bytes);
Self {
office_id: office_id.into(),
controller_verify_key: controller_verify_key.into(),
secret: hex::encode(bytes),
}
}
pub fn parse(raw: &str) -> Result<Self, JoinTokenError> {
let raw = raw.trim();
let mut parts = raw.splitn(4, ':');
let scheme = parts.next().unwrap_or_default();
let (Some(office_id), Some(verify_key), Some(secret)) =
(parts.next(), parts.next(), parts.next())
else {
if scheme == LEGACY_JOIN_TOKEN_SCHEME {
return Err(JoinTokenError::UnknownScheme {
found: scheme.to_string(),
});
}
return Err(JoinTokenError::Malformed);
};
if scheme != JOIN_TOKEN_SCHEME {
return Err(JoinTokenError::UnknownScheme {
found: scheme.to_string(),
});
}
if office_id.trim().is_empty() {
return Err(JoinTokenError::MissingOfficeId);
}
if verify_key.trim().is_empty() {
return Err(JoinTokenError::MissingVerifyKey);
}
if secret.trim().is_empty() {
return Err(JoinTokenError::MissingSecret);
}
Ok(Self {
office_id: office_id.trim().to_string(),
controller_verify_key: verify_key.trim().to_string(),
secret: secret.trim().to_string(),
})
}
pub fn render(&self) -> String {
format!(
"{JOIN_TOKEN_SCHEME}:{}:{}:{}",
self.office_id, self.controller_verify_key, self.secret
)
}
pub fn secret_digest(&self) -> String {
secret_digest(&self.secret)
}
pub fn fingerprint(&self) -> String {
token_fingerprint(&self.secret_digest())
}
}
pub fn secret_digest(secret: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(secret.as_bytes());
hex::encode(hasher.finalize())
}
pub fn token_fingerprint(secret_digest: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(secret_digest.as_bytes());
hex::encode(hasher.finalize())
}
pub fn digests_match(left: &str, right: &str) -> bool {
left.as_bytes().ct_eq(right.as_bytes()).into()
}
pub fn registration_nonce() -> String {
let mut bytes = [0u8; NONCE_BYTES];
rand::rng().fill(&mut bytes);
hex::encode(bytes)
}
fn registration_message(office_id: &str, fingerprint: &str, nonce: &str) -> String {
format!("{JOIN_TOKEN_SCHEME}/registration:{office_id}:{fingerprint}:{nonce}")
}
fn controller_call_message(office_id: &str, fingerprint: &str) -> String {
format!("{JOIN_TOKEN_SCHEME}/controller-call:{office_id}:{fingerprint}")
}
pub fn registration_proof(
key: &ControllerSigningKey,
office_id: &str,
fingerprint: &str,
nonce: &str,
) -> String {
key.sign(®istration_message(office_id, fingerprint, nonce))
}
pub fn verify_registration_proof(
controller_verify_key: &str,
office_id: &str,
fingerprint: &str,
nonce: &str,
proof: &str,
) -> bool {
if nonce.trim().is_empty() || proof.trim().is_empty() {
return false;
}
verify_signature(
controller_verify_key,
®istration_message(office_id, fingerprint, nonce),
proof,
)
}
pub fn controller_call_header(
key: &ControllerSigningKey,
office_id: &str,
fingerprint: &str,
) -> String {
let proof = key.sign(&controller_call_message(office_id, fingerprint));
format!("{CONTROLLER_CALL_SCHEME}:{office_id}:{proof}")
}
pub fn parse_controller_call_header(raw: &str) -> Option<(String, String)> {
let raw = raw.trim();
let mut parts = raw.splitn(3, ':');
let scheme = parts.next()?;
let office_id = parts.next()?;
let proof = parts.next()?;
if scheme != CONTROLLER_CALL_SCHEME || office_id.is_empty() || proof.is_empty() {
return None;
}
Some((office_id.to_string(), proof.to_string()))
}
pub fn verify_controller_call_header(
controller_verify_key: &str,
office_id: &str,
fingerprint: &str,
raw: &str,
) -> bool {
let Some((named, proof)) = parse_controller_call_header(raw) else {
return false;
};
if named != office_id {
return false;
}
verify_signature(
controller_verify_key,
&controller_call_message(office_id, fingerprint),
&proof,
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfficeVouch {
pub office_id: String,
pub identity: String,
pub expires_at: i64,
pub nonce: String,
pub proof: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OfficeVouchError {
Malformed,
UnknownScheme {
found: String,
},
MissingField {
field: &'static str,
},
MalformedExpiry,
OfficeMismatch {
expected: String,
found: String,
},
ProofInvalid,
Expired {
expires_at: i64,
now: i64,
},
}
impl std::fmt::Display for OfficeVouchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Malformed => write!(
f,
"value is not shaped \
`{VOUCH_SCHEME}:<office_id>:<identity>:<expires_at>:<nonce>:<proof>`. \
Ask the controller for a fresh one"
),
Self::UnknownScheme { found } => write!(
f,
"scheme '{found}' is not '{VOUCH_SCHEME}'; this release accepts \
`{VOUCH_SCHEME}:` vouches only"
),
Self::MissingField { field } => {
write!(f, "the {field} segment of the vouch is empty")
}
Self::MalformedExpiry => write!(
f,
"the expiry segment of the vouch is not a Unix timestamp in seconds"
),
Self::OfficeMismatch { expected, found } => write!(
f,
"this vouch was minted for office '{found}' and this office is '{expected}'. \
A vouch opens exactly one office. Ask the controller to mint one for \
'{expected}'"
),
Self::ProofInvalid => write!(
f,
"the vouch does not prove it came from this office's controller. \
If this office's join token was rotated, every vouch minted under the old \
one is now unusable and the controller must mint a fresh vouch"
),
Self::Expired { expires_at, now } => write!(
f,
"this vouch expired at {expires_at} and it is now {now}. A vouch lives \
{VOUCH_TTL_SECS}s because it is a hand-off, not a session. Ask the \
controller for a fresh one"
),
}
}
}
impl std::error::Error for OfficeVouchError {}
impl OfficeVouch {
pub fn mint(
key: &ControllerSigningKey,
office_id: &str,
identity: &str,
expires_at: i64,
nonce: &str,
fingerprint: &str,
) -> Self {
let proof = key.sign(&vouch_message(
office_id,
identity,
expires_at,
nonce,
fingerprint,
));
Self {
office_id: office_id.to_string(),
identity: identity.to_string(),
expires_at,
nonce: nonce.to_string(),
proof,
}
}
pub fn parse(raw: &str) -> Result<Self, OfficeVouchError> {
let raw = raw.trim();
let mut parts = raw.splitn(6, ':');
let scheme = parts.next().unwrap_or_default();
let (Some(office_id), Some(identity), Some(expires_at), Some(nonce), Some(proof)) = (
parts.next(),
parts.next(),
parts.next(),
parts.next(),
parts.next(),
) else {
return Err(OfficeVouchError::Malformed);
};
if scheme != VOUCH_SCHEME {
return Err(OfficeVouchError::UnknownScheme {
found: scheme.to_string(),
});
}
for (field, value) in [
("office id", office_id),
("identity", identity),
("nonce", nonce),
("proof", proof),
] {
if value.trim().is_empty() {
return Err(OfficeVouchError::MissingField { field });
}
}
let expires_at = expires_at
.trim()
.parse::<i64>()
.map_err(|_| OfficeVouchError::MalformedExpiry)?;
Ok(Self {
office_id: office_id.trim().to_string(),
identity: identity.trim().to_string(),
expires_at,
nonce: nonce.trim().to_string(),
proof: proof.trim().to_string(),
})
}
pub fn render(&self) -> String {
format!(
"{VOUCH_SCHEME}:{}:{}:{}:{}:{}",
self.office_id, self.identity, self.expires_at, self.nonce, self.proof
)
}
pub fn verify(
&self,
controller_verify_key: &str,
office_id: &str,
fingerprint: &str,
now: i64,
) -> Result<(), OfficeVouchError> {
if self.office_id != office_id {
return Err(OfficeVouchError::OfficeMismatch {
expected: office_id.to_string(),
found: self.office_id.clone(),
});
}
if !verify_signature(
controller_verify_key,
&vouch_message(
&self.office_id,
&self.identity,
self.expires_at,
&self.nonce,
fingerprint,
),
&self.proof,
) {
return Err(OfficeVouchError::ProofInvalid);
}
if self.expires_at <= now {
return Err(OfficeVouchError::Expired {
expires_at: self.expires_at,
now,
});
}
Ok(())
}
}
pub fn vouch_nonce() -> String {
registration_nonce()
}
fn vouch_message(
office_id: &str,
identity: &str,
expires_at: i64,
nonce: &str,
fingerprint: &str,
) -> String {
format!("{JOIN_TOKEN_SCHEME}/vouch:{office_id}:{identity}:{expires_at}:{nonce}:{fingerprint}")
}
#[cfg(test)]
mod tests {
use super::*;
fn controller() -> ControllerSigningKey {
ControllerSigningKey::generate().expect("generate").1
}
fn token_for(office_id: &str, key: &ControllerSigningKey) -> JoinToken {
JoinToken::mint(office_id, key.verify_key())
}
#[test]
fn a_minted_token_round_trips_through_its_rendered_form() {
let key = controller();
let token = token_for("office-a", &key);
let rendered = token.render();
assert!(rendered.starts_with("zfjoin2:office-a:"));
assert!(rendered.contains(key.verify_key()));
assert_eq!(JoinToken::parse(&rendered).expect("parse"), token);
assert_eq!(token.secret.len(), SECRET_BYTES * 2);
}
#[test]
fn two_mints_never_share_a_secret() {
let key = controller();
let first = token_for("office-a", &key);
let second = token_for("office-a", &key);
assert_ne!(first.secret, second.secret);
assert_ne!(first.secret_digest(), second.secret_digest());
assert_eq!(first.controller_verify_key, second.controller_verify_key);
assert_ne!(first.fingerprint(), second.fingerprint());
}
#[test]
fn the_old_shared_secret_shape_is_refused_with_an_actionable_message() {
let err = JoinToken::parse("join-token").expect_err("bare secret is not a token");
assert_eq!(err, JoinTokenError::Malformed);
let message = err.to_string();
assert!(
message.contains("Mint one on the controller"),
"refusal must name the action: {message}"
);
assert!(message.contains("/api/platform/cluster/join-tokens"), "{message}");
}
#[test]
fn a_zfjoin1_token_is_refused_by_name_because_it_carries_no_controller_key() {
let err = JoinToken::parse("zfjoin1:office-a:deadbeef").expect_err("legacy scheme");
assert_eq!(
err,
JoinTokenError::UnknownScheme {
found: "zfjoin1".to_string()
}
);
let message = err.to_string();
assert!(
message.contains("no controller verification key"),
"{message}"
);
assert!(message.contains("Re-mint this office's token"), "{message}");
}
#[test]
fn a_future_scheme_is_refused_by_name_rather_than_as_gibberish() {
let err = JoinToken::parse("zfjoin3:office-a:key:abc").expect_err("unknown scheme");
assert_eq!(
err,
JoinTokenError::UnknownScheme {
found: "zfjoin3".to_string()
}
);
assert!(err.to_string().contains("zfjoin3"));
}
#[test]
fn empty_segments_are_refused() {
assert_eq!(
JoinToken::parse("zfjoin2::key:secret").expect_err("no office"),
JoinTokenError::MissingOfficeId
);
assert_eq!(
JoinToken::parse("zfjoin2:office-a::secret").expect_err("no verify key"),
JoinTokenError::MissingVerifyKey
);
assert_eq!(
JoinToken::parse("zfjoin2:office-a:key:").expect_err("no secret"),
JoinTokenError::MissingSecret
);
assert_eq!(
JoinToken::parse("zfjoin2:office-a:key").expect_err("three segments"),
JoinTokenError::Malformed
);
}
#[test]
fn the_digest_is_stable_and_the_comparison_is_by_value() {
let key = controller();
let token = token_for("office-a", &key);
assert_eq!(token.secret_digest(), secret_digest(&token.secret));
assert_eq!(
token.fingerprint(),
token_fingerprint(&token.secret_digest())
);
assert_ne!(
token.fingerprint(),
token.secret_digest(),
"the fingerprint must be one hash past the stored digest"
);
assert!(digests_match(
&token.secret_digest(),
&token.secret_digest()
));
assert!(!digests_match(&token.secret_digest(), "0"));
assert!(!digests_match(
&token.secret_digest(),
&token_for("office-a", &key).secret_digest()
));
}
#[test]
fn what_the_controller_stores_no_longer_forges_a_proof() {
let key = controller();
let token = token_for("office-a", &key);
let stolen_digest = token.secret_digest();
let stolen_fingerprint = token_fingerprint(&stolen_digest);
let nonce = registration_nonce();
let honest = registration_proof(&key, "office-a", &stolen_fingerprint, &nonce);
assert!(verify_registration_proof(
key.verify_key(),
"office-a",
&stolen_fingerprint,
&nonce,
&honest
));
for forged in [
stolen_digest.as_str(),
stolen_fingerprint.as_str(),
token.secret.as_str(),
"",
] {
assert!(
!verify_registration_proof(
key.verify_key(),
"office-a",
&stolen_fingerprint,
&nonce,
forged
),
"stored material must not answer a nonce: {forged}"
);
}
let impostor = controller();
assert!(!verify_registration_proof(
key.verify_key(),
"office-a",
&stolen_fingerprint,
&nonce,
®istration_proof(&impostor, "office-a", &stolen_fingerprint, &nonce)
));
}
#[test]
fn a_registration_proof_is_bound_to_its_nonce_office_and_membership() {
let key = controller();
let token = token_for("office-a", &key);
let fingerprint = token.fingerprint();
let nonce = registration_nonce();
let honest = registration_proof(&key, "office-a", &fingerprint, &nonce);
assert!(!verify_registration_proof(
key.verify_key(),
"office-a",
&fingerprint,
®istration_nonce(),
&honest
));
assert!(!verify_registration_proof(
key.verify_key(),
"office-b",
&fingerprint,
&nonce,
&honest
));
assert!(!verify_registration_proof(
key.verify_key(),
"office-a",
&token_for("office-a", &key).fingerprint(),
&nonce,
&honest
));
}
#[test]
fn a_controller_call_header_cannot_be_replayed_as_a_registration() {
let key = controller();
let token = token_for("office-a", &key);
let header = controller_call_header(&key, "office-a", &token.fingerprint());
let (office_id, proof) = parse_controller_call_header(&header).expect("parse");
assert_eq!(office_id, "office-a");
assert!(!proof.is_empty());
assert!(verify_controller_call_header(
key.verify_key(),
"office-a",
&token.fingerprint(),
&header
));
assert!(!verify_controller_call_header(
key.verify_key(),
"office-b",
&token.fingerprint(),
&header
));
assert!(!verify_controller_call_header(
controller().verify_key(),
"office-a",
&token.fingerprint(),
&header
));
assert!(!verify_controller_call_header(
key.verify_key(),
"office-a",
&token_for("office-a", &key).fingerprint(),
&header
));
assert!(JoinToken::parse(&header).is_err());
assert!(parse_controller_call_header(&token.render()).is_none());
}
#[test]
fn a_vouch_round_trips_and_verifies_only_under_the_controllers_key() {
let key = controller();
let token = token_for("office-a", &key);
let now = 1_700_000_000;
let vouch = OfficeVouch::mint(
&key,
"office-a",
"joseph",
now + VOUCH_TTL_SECS,
&vouch_nonce(),
&token.fingerprint(),
);
let rendered = vouch.render();
assert!(
rendered.starts_with("zfjoin2v:office-a:joseph:"),
"{rendered}"
);
assert_eq!(OfficeVouch::parse(&rendered).expect("parse"), vouch);
assert!(
vouch
.verify(key.verify_key(), "office-a", &token.fingerprint(), now)
.is_ok()
);
assert_eq!(
vouch
.verify(
controller().verify_key(),
"office-a",
&token.fingerprint(),
now
)
.expect_err("a stranger's key must not open this office"),
OfficeVouchError::ProofInvalid
);
assert_eq!(
vouch
.verify(
key.verify_key(),
"office-a",
&token_for("office-a", &key).fingerprint(),
now
)
.expect_err("a rotated token invalidates every vouch under the old one"),
OfficeVouchError::ProofInvalid
);
}
#[test]
fn a_vouch_for_one_office_is_refused_by_another() {
let key = controller();
let token_a = token_for("office-a", &key);
let token_b = token_for("office-b", &key);
let now = 1_700_000_000;
let for_a = OfficeVouch::mint(
&key,
"office-a",
"joseph",
now + VOUCH_TTL_SECS,
"n1",
&token_a.fingerprint(),
);
let err = for_a
.verify(key.verify_key(), "office-b", &token_b.fingerprint(), now)
.expect_err("office B must refuse office A's vouch");
assert_eq!(
err,
OfficeVouchError::OfficeMismatch {
expected: "office-b".to_string(),
found: "office-a".to_string(),
}
);
assert!(err.to_string().contains("opens exactly one office"));
let relabelled = OfficeVouch {
office_id: "office-b".to_string(),
..for_a
};
assert_eq!(
relabelled
.verify(key.verify_key(), "office-b", &token_b.fingerprint(), now)
.expect_err("a relabelled vouch is a forgery"),
OfficeVouchError::ProofInvalid
);
}
#[test]
fn every_field_of_a_vouch_is_authenticated() {
let key = controller();
let token = token_for("office-a", &key);
let fingerprint = token.fingerprint();
let now = 1_700_000_000;
let honest = OfficeVouch::mint(&key, "office-a", "joseph", now + 120, "n1", &fingerprint);
for tampered in [
OfficeVouch {
identity: "root".to_string(),
..honest.clone()
},
OfficeVouch {
expires_at: now + 86_400,
..honest.clone()
},
OfficeVouch {
nonce: "n2".to_string(),
..honest.clone()
},
OfficeVouch {
proof: "A".repeat(honest.proof.len()),
..honest.clone()
},
] {
assert_eq!(
tampered
.verify(key.verify_key(), "office-a", &fingerprint, now)
.expect_err("a tampered vouch must not verify"),
OfficeVouchError::ProofInvalid
);
}
}
#[test]
fn an_expired_vouch_is_refused_and_the_proof_is_checked_first() {
let key = controller();
let token = token_for("office-a", &key);
let fingerprint = token.fingerprint();
let now = 1_700_000_000;
let expires_at = now + VOUCH_TTL_SECS;
let vouch = OfficeVouch::mint(&key, "office-a", "joseph", expires_at, "n1", &fingerprint);
assert!(
vouch
.verify(key.verify_key(), "office-a", &fingerprint, expires_at - 1)
.is_ok()
);
assert_eq!(
vouch
.verify(key.verify_key(), "office-a", &fingerprint, expires_at)
.expect_err("expired"),
OfficeVouchError::Expired {
expires_at,
now: expires_at
}
);
let forged = OfficeVouch::mint(
&controller(),
"office-a",
"joseph",
now - 10_000,
"n1",
&fingerprint,
);
assert_eq!(
forged
.verify(key.verify_key(), "office-a", &fingerprint, now)
.expect_err("forged and expired"),
OfficeVouchError::ProofInvalid,
"authenticity is decided before freshness"
);
}
#[test]
fn a_vouch_is_not_a_token_and_a_token_is_not_a_vouch() {
let key = controller();
let token = token_for("office-a", &key);
let vouch =
OfficeVouch::mint(&key, "office-a", "joseph", 1, "n1", &token.fingerprint()).render();
assert!(JoinToken::parse(&vouch).is_err());
assert!(parse_controller_call_header(&vouch).is_none());
assert!(OfficeVouch::parse(&token.render()).is_err());
assert!(
OfficeVouch::parse(&controller_call_header(
&key,
"office-a",
&token.fingerprint()
))
.is_err()
);
}
#[test]
fn a_malformed_vouch_is_refused_by_name() {
assert_eq!(
OfficeVouch::parse("zfjoin2v:office-a:joseph").expect_err("short"),
OfficeVouchError::Malformed
);
assert_eq!(
OfficeVouch::parse("zfjoin1v:office-a:joseph:1:n:p").expect_err("scheme"),
OfficeVouchError::UnknownScheme {
found: "zfjoin1v".to_string()
}
);
assert_eq!(
OfficeVouch::parse("zfjoin2v:office-a::1:n:p").expect_err("identity"),
OfficeVouchError::MissingField { field: "identity" }
);
assert_eq!(
OfficeVouch::parse("zfjoin2v:office-a:joseph:soon:n:p").expect_err("expiry"),
OfficeVouchError::MalformedExpiry
);
}
#[test]
fn nonces_do_not_repeat() {
assert_ne!(registration_nonce(), registration_nonce());
}
}