use std::path::Path;
use chrono::{DateTime, Duration, Utc};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::error::CertmeshError;
pub const DEFAULT_TTL_MINS: i64 = 60;
const TOKEN_BYTES: usize = 24;
const CODE_SEP: char = '.';
pub(crate) fn encode_code(secret: &str, ca_fingerprint: &str) -> String {
format!("{secret}{CODE_SEP}{ca_fingerprint}")
}
pub fn decode_code(code: &str) -> (&str, Option<&str>) {
match code.split_once(CODE_SEP) {
Some((secret, fp)) if !fp.is_empty() => (secret, Some(fp)),
Some((secret, _)) => (secret, None),
None => (code, None),
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct InviteStore {
#[serde(default)]
invites: Vec<Invite>,
}
#[derive(Debug, Serialize, Deserialize)]
struct Invite {
hostname: String,
token_hash: String,
expires_at: DateTime<Utc>,
#[serde(default)]
used: bool,
}
fn token_hash(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
koi_common::encoding::hex_encode(&hasher.finalize()[..])
}
fn load(path: &Path) -> InviteStore {
std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save(path: &Path, store: &InviteStore) -> Result<(), CertmeshError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(store)
.map_err(|e| CertmeshError::Internal(format!("serialize invites: {e}")))?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json.as_bytes())?;
std::fs::rename(&tmp, path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct MintedInvite {
pub token: String,
pub expires_at: DateTime<Utc>,
}
pub fn mint(path: &Path, hostname: &str, ttl_mins: i64) -> Result<MintedInvite, CertmeshError> {
let mut buf = [0u8; TOKEN_BYTES];
rand::rng().fill_bytes(&mut buf);
let token = koi_common::encoding::hex_encode(&buf);
let ttl = if ttl_mins <= 0 {
DEFAULT_TTL_MINS
} else {
ttl_mins
};
let now = Utc::now();
let expires_at = now + Duration::minutes(ttl);
let mut store = load(path);
store.invites.retain(|i| !i.used && i.expires_at > now);
store.invites.push(Invite {
hostname: hostname.to_string(),
token_hash: token_hash(&token),
expires_at,
used: false,
});
save(path, &store)?;
Ok(MintedInvite { token, expires_at })
}
pub fn verify_and_consume(path: &Path, token: &str, hostname: &str) -> bool {
let (secret, _fp) = decode_code(token);
let mut store = load(path);
let h = token_hash(secret);
let now = Utc::now();
let pos = store.invites.iter().position(|i| {
!i.used
&& i.expires_at > now
&& i.hostname == hostname
&& koi_crypto::pinning::fingerprints_match(&i.token_hash, &h)
});
match pos {
Some(idx) => {
store.invites[idx].used = true;
save(path, &store).is_ok()
}
None => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store_path(name: &str) -> std::path::PathBuf {
let dir = koi_common::test::ensure_data_dir("koi-certmesh-invite-tests");
let p = dir.join(format!("{name}.json"));
let _ = std::fs::remove_file(&p);
p
}
#[test]
fn mint_then_verify_consumes_once() {
let p = store_path("roundtrip");
let token = mint(&p, "host-a", 60).unwrap().token;
assert!(verify_and_consume(&p, &token, "host-a"), "first use ok");
assert!(
!verify_and_consume(&p, &token, "host-a"),
"single-use: second use rejected"
);
}
#[test]
fn verify_rejects_wrong_host() {
let p = store_path("wronghost");
let token = mint(&p, "host-a", 60).unwrap().token;
assert!(!verify_and_consume(&p, &token, "host-b"));
assert!(verify_and_consume(&p, &token, "host-a"));
}
#[test]
fn verify_rejects_unknown_token() {
let p = store_path("unknown");
let _ = mint(&p, "host-a", 60).unwrap();
assert!(!verify_and_consume(&p, "deadbeefdeadbeef", "host-a"));
}
#[test]
fn verify_rejects_expired() {
let p = store_path("expired");
let token = mint(&p, "host-a", 60).unwrap().token;
let mut store = load(&p);
store.invites[0].expires_at = Utc::now() - Duration::minutes(5);
save(&p, &store).unwrap();
assert!(!verify_and_consume(&p, &token, "host-a"));
}
#[test]
fn encode_then_decode_round_trips() {
let code = encode_code("deadbeef", "cafing3rprint");
assert_eq!(code, "deadbeef.cafing3rprint");
assert_eq!(decode_code(&code), ("deadbeef", Some("cafing3rprint")));
}
#[test]
fn decode_bare_secret_has_no_fingerprint() {
assert_eq!(decode_code("deadbeef"), ("deadbeef", None));
assert_eq!(decode_code("deadbeef."), ("deadbeef", None));
}
#[test]
fn decode_splits_on_first_separator_only() {
assert_eq!(
decode_code("deadbeef.fp1.fp2"),
("deadbeef", Some("fp1.fp2"))
);
}
#[test]
fn verify_consumes_when_presented_as_full_code() {
let p = store_path("fullcode");
let secret = mint(&p, "host-a", 60).unwrap().token;
let code = encode_code(&secret, "anyfingerprint");
assert!(
verify_and_consume(&p, &code, "host-a"),
"full code consumes"
);
assert!(
!verify_and_consume(&p, &secret, "host-a"),
"single-use: the underlying secret is already burned"
);
}
}