use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::attestation::{Signer, SignerError};
pub const TYPE_INVITATION: &str = "treeship/invitation/v1";
pub const MAX_INVITATION_LIFETIME_SECS: u64 = 7 * 24 * 60 * 60;
pub const DEFAULT_INVITATION_LIFETIME_SECS: u64 = 60 * 60;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum InviteeRestriction {
Pubkey { fingerprint: String },
Cert {
issuer_pubkey: String,
allowed_subjects: Vec<String>,
},
Open,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GrantedCapabilities {
#[serde(default)]
pub action_types: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvitationStatement {
#[serde(rename = "type")]
pub type_: String,
pub session_ref: String,
pub issuer: String,
pub invitee_restriction: InviteeRestriction,
pub granted_capabilities: GrantedCapabilities,
pub expires_at: String,
pub max_uses: u32,
pub nonce: String,
}
impl InvitationStatement {
pub fn new(
session_ref: impl Into<String>,
issuer: impl Into<String>,
invitee_restriction: InviteeRestriction,
granted_capabilities: GrantedCapabilities,
expires_at: impl Into<String>,
nonce: impl Into<String>,
) -> Self {
Self {
type_: TYPE_INVITATION.into(),
session_ref: session_ref.into(),
issuer: issuer.into(),
invitee_restriction,
granted_capabilities,
expires_at: expires_at.into(),
max_uses: 1,
nonce: nonce.into(),
}
}
pub fn canonical_for_signing(&self) -> String {
let restriction_digest = canonical_json_digest(&self.invitee_restriction);
let capabilities_digest = canonical_json_digest(&self.granted_capabilities);
let nonce_d = nonce_digest_hex(&self.nonce);
format!(
"v1|invitation|{}|{}|{}|{}|{}|{}|{}",
self.session_ref,
self.issuer,
restriction_digest,
capabilities_digest,
self.expires_at,
self.max_uses,
nonce_d,
)
}
pub fn sign_canonical(&self, signer: &dyn Signer) -> Result<String, SignerError> {
let canonical = self.canonical_for_signing();
let sig = signer.sign(canonical.as_bytes())?;
Ok(URL_SAFE_NO_PAD.encode(sig))
}
pub fn verify_canonical(&self, signature_b64url: &str) -> bool {
let pk_bytes = match URL_SAFE_NO_PAD.decode(self.issuer.as_bytes()) {
Ok(b) if b.len() == 32 => b,
_ => return false,
};
let sig_bytes = match URL_SAFE_NO_PAD.decode(signature_b64url.as_bytes()) {
Ok(b) if b.len() == 64 => b,
_ => return false,
};
let mut pk_arr = [0u8; 32];
pk_arr.copy_from_slice(&pk_bytes);
let mut sig_arr = [0u8; 64];
sig_arr.copy_from_slice(&sig_bytes);
let vk = match VerifyingKey::from_bytes(&pk_arr) {
Ok(k) => k,
Err(_) => return false,
};
let sig = Signature::from_bytes(&sig_arr);
vk.verify(self.canonical_for_signing().as_bytes(), &sig).is_ok()
}
pub fn validate_for_mint(&self, now_unix_secs: u64) -> Result<(), InvitationError> {
if self.session_ref.trim().is_empty() {
return Err(InvitationError::EmptyField("session_ref"));
}
if self.nonce.trim().is_empty() {
return Err(InvitationError::EmptyField("nonce"));
}
if self.max_uses != 1 {
return Err(InvitationError::MaxUsesUnsupported { max_uses: self.max_uses });
}
let pk_bytes = URL_SAFE_NO_PAD
.decode(self.issuer.as_bytes())
.map_err(|_| InvitationError::IssuerNotEd25519)?;
if pk_bytes.len() != 32 {
return Err(InvitationError::IssuerNotEd25519);
}
let expires_secs = parse_rfc3339_to_unix(&self.expires_at)
.ok_or(InvitationError::ExpiresAtNotRfc3339)?;
if expires_secs <= now_unix_secs {
return Err(InvitationError::ExpiresInPast);
}
let lifetime = expires_secs - now_unix_secs;
if lifetime > MAX_INVITATION_LIFETIME_SECS {
return Err(InvitationError::LifetimeTooLong {
requested_secs: lifetime,
max_secs: MAX_INVITATION_LIFETIME_SECS,
});
}
Ok(())
}
pub fn is_expired(&self, now_unix_secs: u64) -> bool {
match parse_rfc3339_to_unix(&self.expires_at) {
Some(secs) => now_unix_secs >= secs,
None => true,
}
}
pub fn nonce_digest(&self) -> String {
nonce_digest_hex(&self.nonce)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InvitationError {
EmptyField(&'static str),
IssuerNotEd25519,
ExpiresAtNotRfc3339,
ExpiresInPast,
LifetimeTooLong { requested_secs: u64, max_secs: u64 },
MaxUsesUnsupported { max_uses: u32 },
}
impl std::fmt::Display for InvitationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyField(name) => write!(f, "invitation field {name} must not be empty"),
Self::IssuerNotEd25519 => write!(
f,
"invitation issuer must decode to a 32-byte Ed25519 public key (base64url-no-pad)",
),
Self::ExpiresAtNotRfc3339 => write!(
f,
"invitation expires_at must be RFC 3339 (e.g. 2026-05-18T12:00:00Z)",
),
Self::ExpiresInPast => write!(f, "invitation expires_at must be in the future at mint time"),
Self::LifetimeTooLong { requested_secs, max_secs } => write!(
f,
"invitation lifetime {requested_secs}s exceeds protocol max {max_secs}s ({} days)",
max_secs / (24 * 60 * 60),
),
Self::MaxUsesUnsupported { max_uses } => write!(
f,
"invitation max_uses must be 1 in Phase 1 (got {max_uses}); \
multi-use invitations are a future-version feature",
),
}
}
}
impl std::error::Error for InvitationError {}
pub(crate) fn canonical_json_digest<T: Serialize>(value: &T) -> String {
let json_value = serde_json::to_value(value)
.expect("canonical_json_digest: serialize must not fail for in-crate types");
let canonical = canonical_json_string(&json_value);
let digest = Sha256::digest(canonical.as_bytes());
format!("sha256:{}", hex::encode(digest))
}
fn canonical_json_string(value: &serde_json::Value) -> String {
use std::collections::BTreeMap;
match value {
serde_json::Value::Object(map) => {
let sorted: BTreeMap<&String, String> = map
.iter()
.map(|(k, v)| (k, canonical_json_string(v)))
.collect();
let mut out = String::from("{");
let mut first = true;
for (k, v) in sorted {
if !first { out.push(','); }
first = false;
let key_json = serde_json::to_string(k)
.expect("string serializes to JSON");
out.push_str(&key_json);
out.push(':');
out.push_str(&v);
}
out.push('}');
out
}
serde_json::Value::Array(items) => {
let mut out = String::from("[");
let mut first = true;
for v in items {
if !first { out.push(','); }
first = false;
out.push_str(&canonical_json_string(v));
}
out.push(']');
out
}
other => serde_json::to_string(other)
.expect("scalar JSON value serializes"),
}
}
fn nonce_digest_hex(raw_nonce: &str) -> String {
let digest = Sha256::digest(raw_nonce.as_bytes());
format!("sha256:{}", hex::encode(digest))
}
fn parse_rfc3339_to_unix(s: &str) -> Option<u64> {
let b = s.as_bytes();
if b.len() != 20 || b[10] != b'T' || b[19] != b'Z'
|| b[4] != b'-' || b[7] != b'-'
|| b[13] != b':' || b[16] != b':'
{
return None;
}
let year: i64 = std::str::from_utf8(&b[0..4]).ok()?.parse().ok()?;
let month: u32 = std::str::from_utf8(&b[5..7]).ok()?.parse().ok()?;
let day: u32 = std::str::from_utf8(&b[8..10]).ok()?.parse().ok()?;
let hour: u32 = std::str::from_utf8(&b[11..13]).ok()?.parse().ok()?;
let min: u32 = std::str::from_utf8(&b[14..16]).ok()?.parse().ok()?;
let sec: u32 = std::str::from_utf8(&b[17..19]).ok()?.parse().ok()?;
if !(1970..=9999).contains(&year)
|| !(1..=12).contains(&month)
|| !(1..=31).contains(&day)
|| hour > 23 || min > 59 || sec > 60
{
return None;
}
let mut days: i64 = 0;
for y in 1970..year {
days += if is_leap(y as u64) { 366 } else { 365 };
}
let months = if is_leap(year as u64) {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
for m in 1..month {
days += months[(m - 1) as usize];
}
days += (day - 1) as i64;
let total = days * 86_400 + (hour as i64) * 3600 + (min as i64) * 60 + (sec as i64);
if total < 0 { return None; }
Some(total as u64)
}
fn is_leap(y: u64) -> bool {
(y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
}
pub fn generate_nonce() -> String {
use rand::{rngs::OsRng, RngCore};
let mut buf = [0u8; 16];
OsRng.fill_bytes(&mut buf);
hex::encode(buf)
}
pub fn pubkey_fingerprint_short(canonical_pk: &str) -> String {
let bytes = Sha256::digest(canonical_pk.as_bytes());
hex::encode(bytes)[..16].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::attestation::Ed25519Signer;
fn sample_caps() -> GrantedCapabilities {
GrantedCapabilities {
action_types: vec!["tool.call".into(), "agent.handoff".into()],
}
}
fn host_signer() -> Ed25519Signer {
Ed25519Signer::from_bytes("host_key", &[7u8; 32]).unwrap()
}
fn fixed_now() -> u64 {
1_779_580_800
}
fn one_hour_after(now: u64) -> String {
crate::statements::unix_to_rfc3339(now + 3600)
}
fn sample(restriction: InviteeRestriction) -> InvitationStatement {
let signer = host_signer();
let issuer = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
InvitationStatement::new(
"ssn_room_abc",
issuer,
restriction,
sample_caps(),
one_hour_after(fixed_now()),
"nonce_deadbeef",
)
}
#[test]
fn invitation_round_trips_serde() {
let inv = sample(InviteeRestriction::Open);
let bytes = serde_json::to_vec(&inv).unwrap();
let back: InvitationStatement = serde_json::from_slice(&bytes).unwrap();
assert_eq!(back.session_ref, inv.session_ref);
assert_eq!(back.type_, TYPE_INVITATION);
assert_eq!(back.max_uses, 1);
}
#[test]
fn invitation_canonical_includes_all_fields() {
let base = sample(InviteeRestriction::Cert {
issuer_pubkey: "ed25519:AAA".into(),
allowed_subjects: vec!["org-x".into()],
});
let base_canonical = base.canonical_for_signing();
let mut m1 = base.clone(); m1.session_ref = "ssn_other".into();
assert_ne!(m1.canonical_for_signing(), base_canonical, "session_ref must bind");
let mut m2 = base.clone(); m2.issuer = URL_SAFE_NO_PAD.encode([9u8; 32]);
assert_ne!(m2.canonical_for_signing(), base_canonical, "issuer must bind");
let mut m3 = base.clone();
m3.invitee_restriction = InviteeRestriction::Open;
assert_ne!(m3.canonical_for_signing(), base_canonical, "restriction must bind");
let mut m4 = base.clone();
m4.granted_capabilities.action_types.push("extra.cap".into());
assert_ne!(m4.canonical_for_signing(), base_canonical, "capabilities must bind");
let mut m5 = base.clone(); m5.expires_at = one_hour_after(fixed_now() + 1);
assert_ne!(m5.canonical_for_signing(), base_canonical, "expires_at must bind");
let mut m6 = base.clone(); m6.max_uses = 2;
assert_ne!(m6.canonical_for_signing(), base_canonical, "max_uses must bind");
let mut m7 = base.clone(); m7.nonce = "nonce_other".into();
assert_ne!(m7.canonical_for_signing(), base_canonical, "nonce must bind");
}
#[test]
fn invitation_sign_and_verify_roundtrip() {
let inv = sample(InviteeRestriction::Open);
let signer = host_signer();
let sig = inv.sign_canonical(&signer).unwrap();
assert!(inv.verify_canonical(&sig));
}
#[test]
fn invitation_verify_rejects_wrong_signature() {
let inv = sample(InviteeRestriction::Open);
let attacker = Ed25519Signer::from_bytes("att", &[3u8; 32]).unwrap();
let sig = inv.sign_canonical(&attacker).unwrap();
assert!(!inv.verify_canonical(&sig));
}
#[test]
fn invitation_verify_rejects_tampered_canonical() {
let mut inv = sample(InviteeRestriction::Open);
let signer = host_signer();
let sig = inv.sign_canonical(&signer).unwrap();
inv.session_ref = "ssn_tampered".into();
assert!(!inv.verify_canonical(&sig));
}
#[test]
fn invitation_expiry_max_7d_enforced() {
let now = fixed_now();
let signer = host_signer();
let issuer = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
let too_long = crate::statements::unix_to_rfc3339(now + MAX_INVITATION_LIFETIME_SECS + 1);
let inv = InvitationStatement::new(
"ssn_a", issuer.clone(),
InviteeRestriction::Open, sample_caps(),
too_long, "n1",
);
match inv.validate_for_mint(now) {
Err(InvitationError::LifetimeTooLong { .. }) => {}
other => panic!("expected LifetimeTooLong, got {other:?}"),
}
let exact = crate::statements::unix_to_rfc3339(now + MAX_INVITATION_LIFETIME_SECS);
let inv_ok = InvitationStatement::new(
"ssn_a", issuer,
InviteeRestriction::Open, sample_caps(),
exact, "n2",
);
assert!(inv_ok.validate_for_mint(now).is_ok());
}
#[test]
fn invitation_validate_rejects_past_expiry() {
let now = fixed_now();
let signer = host_signer();
let issuer = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
let past = crate::statements::unix_to_rfc3339(now - 60);
let inv = InvitationStatement::new(
"ssn_a", issuer, InviteeRestriction::Open, sample_caps(), past, "n",
);
assert_eq!(inv.validate_for_mint(now), Err(InvitationError::ExpiresInPast));
}
#[test]
fn invitation_validate_rejects_max_uses_not_one() {
let now = fixed_now();
let mut inv = sample(InviteeRestriction::Open);
inv.max_uses = 2;
match inv.validate_for_mint(now) {
Err(InvitationError::MaxUsesUnsupported { max_uses }) => assert_eq!(max_uses, 2),
other => panic!("expected MaxUsesUnsupported, got {other:?}"),
}
}
#[test]
fn invitation_pubkey_restriction_enforced() {
let signer_a = Ed25519Signer::from_bytes("a", &[1u8; 32]).unwrap();
let signer_b = Ed25519Signer::from_bytes("b", &[2u8; 32]).unwrap();
let fp_a = pubkey_fingerprint_short(&format!(
"ed25519:{}",
URL_SAFE_NO_PAD.encode(signer_a.public_key_bytes()),
));
let fp_b = pubkey_fingerprint_short(&format!(
"ed25519:{}",
URL_SAFE_NO_PAD.encode(signer_b.public_key_bytes()),
));
assert_ne!(fp_a, fp_b);
let restriction = InviteeRestriction::Pubkey { fingerprint: fp_a.clone() };
let accept_for = |fp: &str| matches!(
&restriction,
InviteeRestriction::Pubkey { fingerprint } if fingerprint == fp,
);
assert!(accept_for(&fp_a), "matching pubkey must be accepted");
assert!(!accept_for(&fp_b), "non-matching pubkey must be rejected");
}
#[test]
fn invitation_cert_restriction_enforced() {
let restriction = InviteeRestriction::Cert {
issuer_pubkey: "ed25519:ISSUER_X".into(),
allowed_subjects: vec!["org-x".into(), "org-y".into()],
};
let accept = |iss: &str, subj: &str| matches!(
&restriction,
InviteeRestriction::Cert { issuer_pubkey, allowed_subjects }
if issuer_pubkey == iss && allowed_subjects.iter().any(|s| s == subj),
);
assert!(accept("ed25519:ISSUER_X", "org-x"), "matching issuer+subject accepted");
assert!(!accept("ed25519:ISSUER_OTHER", "org-x"), "wrong issuer rejected");
assert!(!accept("ed25519:ISSUER_X", "org-z"), "wrong subject rejected");
}
#[test]
fn invitation_open_restriction_works() {
let restriction = InviteeRestriction::Open;
let is_open = matches!(restriction, InviteeRestriction::Open);
assert!(is_open);
}
#[test]
fn invitation_is_expired_returns_true_past_expiry() {
let now = fixed_now();
let inv = InvitationStatement::new(
"ssn_a", URL_SAFE_NO_PAD.encode([5u8; 32]),
InviteeRestriction::Open, sample_caps(),
crate::statements::unix_to_rfc3339(now - 1),
"n",
);
assert!(inv.is_expired(now));
}
#[test]
fn invitation_nonce_digest_matches_journal_helper() {
let inv = sample(InviteeRestriction::Open);
assert_eq!(
inv.nonce_digest(),
crate::statements::nonce_digest(&inv.nonce),
);
}
#[test]
fn parse_rfc3339_round_trips() {
let now = fixed_now();
let s = crate::statements::unix_to_rfc3339(now);
assert_eq!(parse_rfc3339_to_unix(&s), Some(now));
assert_eq!(parse_rfc3339_to_unix("not a timestamp"), None);
assert_eq!(parse_rfc3339_to_unix("2026-05-18T00:00:00"), None); assert_eq!(parse_rfc3339_to_unix("2026-13-18T00:00:00Z"), None); }
}