use std::sync::Arc;
use astrid_core::PrincipalId;
use astrid_core::groups::GroupConfig;
use astrid_core::kernel_api::{AdminResponseBody, InviteIssued, InviteRedeemed, InviteSummary};
use astrid_core::profile::{AuthConfig, AuthMethod, PrincipalProfile};
use sha2::{Digest, Sha256};
use tracing::{info, warn};
use crate::invite::{self, Invite, InviteStore, MAX_EXPIRY_SECS};
#[must_use]
pub(crate) fn fingerprint_public_key(hex_key: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(hex_key.as_bytes());
hex::encode(hasher.finalize())
}
pub(crate) async fn invite_issue(
kernel: &Arc<crate::Kernel>,
group: String,
expires_secs: Option<u64>,
max_uses: u32,
metadata: Option<String>,
) -> AdminResponseBody {
if max_uses == 0 {
return err_bad_input("max_uses must be greater than 0".into());
}
if let Some(exp) = expires_secs
&& exp > MAX_EXPIRY_SECS
{
return err_bad_input(format!(
"expires_secs {exp} exceeds the 30-day cap ({MAX_EXPIRY_SECS}s)"
));
}
if !group_exists(kernel, &group) {
return err_bad_input(format!(
"group {group:?} is not defined — create it via `astrid group create` first"
));
}
let _guard = kernel.admin_write_lock.lock().await;
let store = InviteStore::new(InviteStore::path_for(&kernel.astrid_home));
let mut invites = match store.load() {
Ok(v) => v,
Err(e) => return err_internal(format!("invites.toml load failed: {e}")),
};
let _ = invite::prune_expired(&mut invites);
let now = invite::now_epoch();
let expires_at_epoch = expires_secs.map(|s| now.saturating_add(s));
let token = invite::generate_token();
let token_hash = invite::hash_token(&token);
invites.push(Invite {
token_hash: token_hash.clone(),
group: group.clone(),
remaining_uses: max_uses,
expires_at_epoch,
issued_at_epoch: now,
metadata: metadata.clone(),
});
if let Err(e) = store.save(&invites) {
return err_internal(format!("invites.toml save failed: {e}"));
}
info!(
token_fingerprint = %token_hash,
group = %group,
max_uses,
expires_at_epoch = ?expires_at_epoch,
"Layer 6 invite.issue"
);
AdminResponseBody::Invite(InviteIssued {
token,
group,
remaining_uses: max_uses,
expires_at_epoch,
metadata,
})
}
pub(crate) async fn invite_redeem(
kernel: &Arc<crate::Kernel>,
token: String,
public_key: String,
display_name: Option<String>,
) -> AdminResponseBody {
let normalised_key = match normalise_public_key(&public_key) {
Ok(k) => k,
Err(e) => return err_bad_input(e),
};
let _guard = kernel.admin_write_lock.lock().await;
let store = InviteStore::new(InviteStore::path_for(&kernel.astrid_home));
let mut invites = match store.load() {
Ok(v) => v,
Err(e) => return err_internal(format!("invites.toml load failed: {e}")),
};
let _ = invite::prune_expired(&mut invites);
let token_hash = invite::hash_token(&token);
let now = invite::now_epoch();
let mut matched_index: Option<usize> = None;
for (i, inv) in invites.iter().enumerate() {
let live = inv.remaining_uses > 0 && inv.expires_at_epoch.is_none_or(|e| e > now);
let hit = invite::ct_hash_eq(&inv.token_hash, &token_hash) && live;
if hit && matched_index.is_none() {
matched_index = Some(i);
}
}
let Some(idx) = matched_index else {
return err_unauthorized("invite token invalid, expired, or already consumed".into());
};
let chosen = invites[idx].clone();
let principal = match allocate_principal(kernel, display_name.as_deref()) {
Ok(p) => p,
Err(e) => return err_internal(e),
};
let mut auth = AuthConfig::default();
auth.methods.push(AuthMethod::Keypair);
auth.public_keys.push(format!("ed25519:{normalised_key}"));
let profile = PrincipalProfile {
groups: vec![chosen.group.clone()],
auth,
..PrincipalProfile::default()
};
if let Err(e) = profile.validate() {
return err_internal(format!("profile rejected: {e}"));
}
let user = match kernel
.identity_store
.create_user(Some(principal.as_str()))
.await
{
Ok(u) => u,
Err(e) => return err_internal(format!("identity store create_user failed: {e}")),
};
if let Err(e) = kernel
.identity_store
.link("cli", principal.as_str(), user.id, "system")
.await
{
let _ = kernel.identity_store.delete_user(user.id).await;
return err_internal(format!("identity store link failed: {e}"));
}
let profile_path = kernel.astrid_home.profile_path(&principal);
if let Err(e) = profile.save_to_path(&profile_path) {
let _ = kernel
.identity_store
.unlink("cli", principal.as_str())
.await;
let _ = kernel.identity_store.delete_user(user.id).await;
return err_internal(format!("profile save failed: {e}"));
}
if let Err(e) = kernel.astrid_home.principal_home(&principal).ensure() {
let _ = kernel
.identity_store
.unlink("cli", principal.as_str())
.await;
let _ = kernel.identity_store.delete_user(user.id).await;
let _ = std::fs::remove_file(&profile_path);
return err_internal(format!("principal home tree provisioning failed: {e}"));
}
invites[idx].remaining_uses = invites[idx].remaining_uses.saturating_sub(1);
if invites[idx].remaining_uses == 0 {
invites.remove(idx);
}
if let Err(e) = store.save(&invites) {
warn!(
error = %e,
security_event = true,
principal = %principal,
"invite.redeem: invites.toml save failed AFTER principal mint; manual reconciliation may be required"
);
}
let fingerprint = fingerprint_public_key(&format!("ed25519:{normalised_key}"));
info!(
%principal,
group = %chosen.group,
public_key_fingerprint = %fingerprint,
"Layer 6 invite.redeem"
);
AdminResponseBody::InviteRedeemed(InviteRedeemed {
principal,
group: chosen.group,
public_key_fingerprint: fingerprint,
})
}
pub(crate) async fn invite_list(kernel: &Arc<crate::Kernel>) -> AdminResponseBody {
let _guard = kernel.admin_write_lock.lock().await;
let store = InviteStore::new(InviteStore::path_for(&kernel.astrid_home));
let mut invites = match store.load() {
Ok(v) => v,
Err(e) => return err_internal(format!("invites.toml load failed: {e}")),
};
if invite::prune_expired(&mut invites) > 0 {
if let Err(e) = store.save(&invites) {
warn!(error = %e, "invite.list: lazy prune save failed");
}
}
let summaries: Vec<InviteSummary> = invites
.into_iter()
.map(|i| InviteSummary {
token_fingerprint: i.token_hash,
group: i.group,
remaining_uses: i.remaining_uses,
expires_at_epoch: i.expires_at_epoch,
issued_at_epoch: i.issued_at_epoch,
metadata: i.metadata,
})
.collect();
AdminResponseBody::InviteList(summaries)
}
pub(crate) async fn invite_revoke(kernel: &Arc<crate::Kernel>, token: String) -> AdminResponseBody {
let _guard = kernel.admin_write_lock.lock().await;
let store = InviteStore::new(InviteStore::path_for(&kernel.astrid_home));
let mut invites = match store.load() {
Ok(v) => v,
Err(e) => return err_internal(format!("invites.toml load failed: {e}")),
};
let from_raw = invite::hash_token(&token);
let pre_len = invites.len();
invites.retain(|i| {
!invite::ct_hash_eq(&i.token_hash, &from_raw) && !invite::ct_hash_eq(&i.token_hash, &token)
});
if invites.len() == pre_len {
return err_bad_input("no invite matches the supplied token or fingerprint".into());
}
if let Err(e) = store.save(&invites) {
return err_internal(format!("invites.toml save failed: {e}"));
}
let removed = pre_len.saturating_sub(invites.len());
info!(removed, "Layer 6 invite.revoke");
AdminResponseBody::Success(serde_json::json!({ "removed": removed }))
}
fn group_exists(kernel: &Arc<crate::Kernel>, name: &str) -> bool {
let cfg = kernel.groups.load_full();
GroupConfig::is_builtin_name(name) || cfg.iter().any(|(n, _)| n == name)
}
fn normalise_public_key(raw: &str) -> Result<String, String> {
let candidate = raw
.strip_prefix("ed25519:")
.unwrap_or(raw)
.trim()
.to_ascii_lowercase();
if candidate.len() != 64 {
return Err(format!(
"public_key must be 32 bytes hex-encoded (64 hex chars); got {} chars",
candidate.len()
));
}
if !candidate.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("public_key contains non-hex characters".into());
}
Ok(candidate)
}
fn allocate_principal(
kernel: &Arc<crate::Kernel>,
display_name: Option<&str>,
) -> Result<PrincipalId, String> {
if let Some(name) = display_name {
let slug = slugify_principal(name);
if !slug.is_empty() {
let pid = PrincipalId::new(&slug)
.map_err(|e| format!("display_name {name:?} produces invalid principal: {e}"))?;
if pid == PrincipalId::default() {
return Err("`default` is the bootstrap principal and cannot be re-created".into());
}
let path = kernel.astrid_home.profile_path(&pid);
if !path.exists() {
return Ok(pid);
}
}
}
for _ in 0..16 {
let candidate = format!("agent-{}", random_suffix());
if let Ok(pid) = PrincipalId::new(&candidate) {
let path = kernel.astrid_home.profile_path(&pid);
if !path.exists() {
return Ok(pid);
}
}
}
Err("failed to allocate a unique principal id after 16 attempts".into())
}
const MAX_PRINCIPAL_SLUG_LEN: usize = 64;
fn slugify_principal(input: &str) -> String {
let mut out = String::with_capacity(input.len().min(MAX_PRINCIPAL_SLUG_LEN));
let mut last_was_dash = false;
for ch in input.chars().take(MAX_PRINCIPAL_SLUG_LEN) {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
last_was_dash = false;
} else if !last_was_dash && !out.is_empty() {
out.push('-');
last_was_dash = true;
}
}
while out.ends_with('-') {
out.pop();
}
out
}
fn random_suffix() -> String {
use rand::RngCore;
let mut bytes = [0u8; 4];
rand::rngs::OsRng.fill_bytes(&mut bytes);
hex::encode(bytes)
}
fn err_bad_input(msg: String) -> AdminResponseBody {
warn!(error = %msg, "invite request rejected: bad input");
AdminResponseBody::Error(msg)
}
fn err_internal(msg: String) -> AdminResponseBody {
warn!(error = %msg, "invite request failed: internal error");
AdminResponseBody::Error(msg)
}
fn err_unauthorized(msg: String) -> AdminResponseBody {
warn!(security_event = true, error = %msg, "invite request denied");
AdminResponseBody::Error(msg)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalise_public_key_accepts_bare_hex() {
let key = "a".repeat(64);
assert_eq!(normalise_public_key(&key).unwrap(), key);
}
#[test]
fn normalise_public_key_accepts_prefixed_hex() {
let key = "B".repeat(64);
let normalised = normalise_public_key(&format!("ed25519:{key}")).unwrap();
assert_eq!(normalised, "b".repeat(64));
}
#[test]
fn normalise_public_key_rejects_wrong_length() {
assert!(normalise_public_key("deadbeef").is_err());
assert!(normalise_public_key(&"a".repeat(63)).is_err());
assert!(normalise_public_key(&"a".repeat(65)).is_err());
}
#[test]
fn normalise_public_key_rejects_non_hex() {
let bad = "g".repeat(64);
assert!(normalise_public_key(&bad).is_err());
}
#[test]
fn slugify_principal_lowercases_and_dashes() {
assert_eq!(slugify_principal("Alice Smith"), "alice-smith");
assert_eq!(slugify_principal("alice@example.com"), "alice-example-com");
assert_eq!(slugify_principal("--Alice--"), "alice");
assert_eq!(slugify_principal(""), "");
}
#[test]
fn slugify_principal_caps_oversize_input() {
let monster = "a".repeat(10_000);
let out = slugify_principal(&monster);
assert!(
out.len() <= MAX_PRINCIPAL_SLUG_LEN,
"expected len <= {MAX_PRINCIPAL_SLUG_LEN}, got {}",
out.len()
);
assert_eq!(out, "a".repeat(MAX_PRINCIPAL_SLUG_LEN));
}
#[test]
fn fingerprint_is_deterministic() {
let a = fingerprint_public_key("ed25519:abcd");
let b = fingerprint_public_key("ed25519:abcd");
assert_eq!(a, b);
assert_ne!(a, fingerprint_public_key("ed25519:abce"));
assert_eq!(a.len(), 64);
}
}