use std::{
fmt,
fs::File,
io::Read,
path::Path,
};
use anyhow::{Context, Result, bail};
use chrono::Utc;
use crypto::{Ed25519Signer, Signer};
use serde::{Deserialize, Serialize};
const CREDENTIAL_FORMAT: &str = "heddle-credential";
const CREDENTIAL_VERSION: u32 = 1;
const MAX_CREDENTIAL_FILE_BYTES: u64 = 64 * 1024;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CredentialKind {
Agent,
Service,
Device,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct CredentialProvenance {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scopes: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed_operations: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
}
impl CredentialProvenance {
fn is_empty(&self) -> bool {
self.template.is_none()
&& self.scopes.is_none()
&& self.allowed_operations.is_none()
&& self.agent_id.is_none()
}
}
pub struct VerifiedCredential {
pub server: String,
pub kind: CredentialKind,
pub subject: String,
pub token: String,
pub proof_key_pem: String,
pub expires_at: Option<String>,
pub credential_id: Option<String>,
pub provenance: Option<CredentialProvenance>,
}
impl fmt::Debug for VerifiedCredential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("VerifiedCredential")
.field("server", &self.server)
.field("kind", &self.kind)
.field("subject", &self.subject)
.field("token", &"<redacted>")
.field("proof_key_pem", &"<redacted>")
.field("expires_at", &self.expires_at)
.field("credential_id", &self.credential_id)
.field("provenance", &self.provenance)
.finish()
}
}
impl VerifiedCredential {
pub fn into_server_credential(self) -> crate::credentials::ServerCredential {
crate::credentials::ServerCredential {
token: self.token,
subject: self.subject,
device_id: None,
credential_id: self.credential_id,
private_key_pem: Some(self.proof_key_pem),
expires_at: self.expires_at,
}
}
}
#[derive(Serialize, Deserialize)]
struct OnDiskCredential {
format: String,
version: u32,
server: String,
kind: CredentialKind,
subject: String,
token: String,
proof_key_pem: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
expires_at: Option<String>,
#[serde(default)]
credential_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provenance: Option<CredentialProvenance>,
}
pub fn write_credential_file(path: &Path, credential: &VerifiedCredential) -> Result<()> {
match std::fs::symlink_metadata(path) {
Ok(_) => bail!(
"credential destination {} already exists; choose a new --out path",
path.display()
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(error)
.with_context(|| format!("checking credential destination {}", path.display()));
}
}
if let Some(parent) = path.parent().filter(|parent| !parent.as_os_str().is_empty()) {
objects::fs_atomic::create_private_dir_all(parent)
.with_context(|| format!("creating credential parent {}", parent.display()))?;
}
let provenance = credential
.provenance
.as_ref()
.filter(|provenance| !provenance.is_empty())
.cloned();
let on_disk = OnDiskCredential {
format: CREDENTIAL_FORMAT.to_string(),
version: CREDENTIAL_VERSION,
server: credential.server.clone(),
kind: credential.kind,
subject: credential.subject.clone(),
token: credential.token.clone(),
proof_key_pem: credential.proof_key_pem.clone(),
expires_at: credential.expires_at.clone(),
credential_id: credential.credential_id.clone(),
provenance,
};
let mut bytes = serde_json::to_vec_pretty(&on_disk).context("serializing credential file")?;
bytes.push(b'\n');
objects::fs_atomic::write_file_atomic_secret(path, &bytes)
.with_context(|| format!("writing credential file {}", path.display()))?;
Ok(())
}
pub fn load_credential_file(path: &Path) -> Result<VerifiedCredential> {
let file = open_credential_file_checked(path)?;
let mut contents = String::new();
let mut limited = file.take(MAX_CREDENTIAL_FILE_BYTES + 1);
limited
.read_to_string(&mut contents)
.with_context(|| format!("reading credential file {}", path.display()))?;
if contents.len() as u64 > MAX_CREDENTIAL_FILE_BYTES {
bail!(
"credential file {} exceeds the {} KiB size cap; a credential file is tiny — refusing to load a suspiciously large file",
path.display(),
MAX_CREDENTIAL_FILE_BYTES / 1024,
);
}
let on_disk: OnDiskCredential = serde_json::from_str(&contents)
.with_context(|| format!("parsing credential file {}", path.display()))?;
if on_disk.format != CREDENTIAL_FORMAT {
bail!(
"{} is not a Heddle credential file (format {:?}, expected {CREDENTIAL_FORMAT:?})",
path.display(),
on_disk.format
);
}
if on_disk.version != CREDENTIAL_VERSION {
bail!(
"credential file {} has unsupported version {} (this build reads version {CREDENTIAL_VERSION})",
path.display(),
on_disk.version
);
}
if on_disk.server.chars().any(|c| c.is_control()) {
bail!(
"credential file {} has a server field containing control characters",
path.display()
);
}
let metadata = crate::auth_cmd::headless_token_metadata(&on_disk.token)
.with_context(|| format!("verifying token in credential file {}", path.display()))?;
let signer = Ed25519Signer::from_pem(&on_disk.proof_key_pem)
.map_err(|error| anyhow::anyhow!("credential proof key is not a valid Ed25519 PEM: {error}"))?;
let proof_public_key_hex = hex::encode(signer.public_key());
if !metadata
.proof_public_key_hex
.eq_ignore_ascii_case(&proof_public_key_hex)
{
bail!(
"credential file {} proof key does not match the token's effective proof key",
path.display()
);
}
if on_disk.subject != metadata.subject {
bail!(
"credential file {} claims subject {:?} but the token authenticates {:?}",
path.display(),
on_disk.subject,
metadata.subject
);
}
if let Some(expires_at) = metadata.expires_at.as_deref() {
let parsed = chrono::DateTime::parse_from_rfc3339(expires_at)
.with_context(|| format!("parsing token expiry {expires_at}"))?
.with_timezone(&Utc);
if parsed <= Utc::now() {
bail!(
"credential file {} is expired (token expired at {expires_at})",
path.display()
);
}
}
Ok(VerifiedCredential {
server: on_disk.server,
kind: on_disk.kind,
subject: metadata.subject,
token: on_disk.token,
proof_key_pem: on_disk.proof_key_pem,
expires_at: metadata.expires_at,
credential_id: on_disk.credential_id.or(metadata.credential_id),
provenance: on_disk.provenance,
})
}
fn open_credential_file_checked(path: &Path) -> Result<File> {
let file = File::open(path)
.with_context(|| format!("opening credential file {}", path.display()))?;
let metadata = file
.metadata()
.with_context(|| format!("inspecting credential file {}", path.display()))?;
if !metadata.is_file() {
bail!(
"credential file {} is not a regular file (refusing a symlink to a non-file target)",
path.display()
);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = metadata.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
bail!(
"credential file {} is group/other-accessible (mode {mode:o}); run `chmod 600 {}` to restrict it to your user",
path.display(),
path.display()
);
}
}
Ok(file)
}
#[cfg(test)]
mod tests {
use super::*;
use biscuit_auth::KeyPair;
fn mint_token(subject: &str, signer: &Ed25519Signer, ttl: chrono::Duration) -> String {
let expires_at = Utc::now() + ttl;
biscuit_auth::Biscuit::builder()
.fact(format!("user({})", quote(subject)).as_str())
.expect("user fact")
.fact(format!("device_pop_key(\"{}\")", hex::encode(signer.public_key())).as_str())
.expect("device PoP fact")
.fact(format!("expires_at({})", expires_at.to_rfc3339()).as_str())
.expect("expiry fact")
.check(format!("check if time($now), $now < {}", expires_at.to_rfc3339()).as_str())
.expect("expiry check")
.build(&KeyPair::new())
.expect("build token")
.to_base64()
.expect("encode token")
}
fn quote(s: &str) -> String {
format!("\"{s}\"")
}
fn sample_verified() -> (VerifiedCredential, Ed25519Signer) {
let signer = Ed25519Signer::generate().expect("proof key");
let token = mint_token("alice", &signer, chrono::Duration::hours(2));
let proof_key_pem = signer.to_pem().expect("proof PEM");
(
VerifiedCredential {
server: "grpc.heddle.test".to_string(),
kind: CredentialKind::Agent,
subject: "alice".to_string(),
token,
proof_key_pem,
expires_at: Some((Utc::now() + chrono::Duration::hours(2)).to_rfc3339()),
credential_id: None,
provenance: Some(CredentialProvenance {
template: Some("reviewer".to_string()),
scopes: Some(vec!["repo:acme/heddle".to_string()]),
allowed_operations: Some(vec!["GetState".to_string()]),
agent_id: Some("agent-1".to_string()),
}),
},
signer,
)
}
#[test]
fn round_trips_write_then_load() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("agent.hcred");
let (credential, _signer) = sample_verified();
write_credential_file(&path, &credential).expect("write");
let loaded = load_credential_file(&path).expect("load");
assert_eq!(loaded.server, "grpc.heddle.test");
assert_eq!(loaded.subject, "alice");
assert_eq!(loaded.kind, CredentialKind::Agent);
assert_eq!(loaded.token, credential.token);
assert_eq!(loaded.proof_key_pem, credential.proof_key_pem);
assert!(loaded.expires_at.is_some(), "expiry re-derived from token");
let provenance = loaded.provenance.expect("provenance preserved");
assert_eq!(provenance.template.as_deref(), Some("reviewer"));
assert_eq!(provenance.agent_id.as_deref(), Some("agent-1"));
}
#[test]
fn refuses_to_overwrite_existing_path() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("agent.hcred");
let (credential, _signer) = sample_verified();
write_credential_file(&path, &credential).expect("first write");
let error = write_credential_file(&path, &credential).expect_err("second write refused");
assert!(error.to_string().contains("already exists"));
}
#[test]
fn debug_redacts_secret_material() {
let (credential, _signer) = sample_verified();
let rendered = format!("{credential:?}");
assert!(rendered.contains("<redacted>"));
assert!(
!rendered.contains(&credential.token),
"token must never appear in Debug output"
);
assert!(
!rendered.contains("BEGIN PRIVATE KEY"),
"proof key PEM must never appear in Debug output"
);
}
#[test]
fn rejects_tampered_proof_key() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("agent.hcred");
let (mut credential, _signer) = sample_verified();
let attacker = Ed25519Signer::generate().expect("attacker key");
credential.proof_key_pem = attacker.to_pem().expect("attacker PEM");
write_credential_file(&path, &credential).expect("write");
let error = load_credential_file(&path).expect_err("mismatched key must be rejected");
assert!(error.to_string().contains("proof key does not match"));
}
#[test]
fn rejects_wrong_subject() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("agent.hcred");
let (mut credential, _signer) = sample_verified();
credential.subject = "mallory".to_string();
write_credential_file(&path, &credential).expect("write");
let error = load_credential_file(&path).expect_err("wrong subject must be rejected");
assert!(error.to_string().contains("authenticates"));
}
#[test]
fn rejects_expired_token() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("agent.hcred");
let signer = Ed25519Signer::generate().expect("proof key");
let token = mint_token("alice", &signer, chrono::Duration::hours(-1));
let credential = VerifiedCredential {
server: "grpc.heddle.test".to_string(),
kind: CredentialKind::Agent,
subject: "alice".to_string(),
token,
proof_key_pem: signer.to_pem().expect("proof PEM"),
expires_at: None,
credential_id: None,
provenance: None,
};
write_credential_file(&path, &credential).expect("write");
let error = load_credential_file(&path).expect_err("expired token must be rejected");
assert!(error.to_string().contains("expired"));
}
#[test]
fn rejects_bad_format() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("agent.hcred");
let (credential, _signer) = sample_verified();
write_credential_file(&path, &credential).expect("write");
let contents = std::fs::read_to_string(&path).expect("read");
let tampered = contents.replace("heddle-credential", "not-a-credential");
std::fs::write(&path, tampered).expect("rewrite");
let error = load_credential_file(&path).expect_err("bad format must be rejected");
assert!(error.to_string().contains("not a Heddle credential file"));
}
#[test]
fn rejects_bad_version() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("agent.hcred");
let (credential, _signer) = sample_verified();
write_credential_file(&path, &credential).expect("write");
let contents = std::fs::read_to_string(&path).expect("read");
let tampered = contents.replace("\"version\": 1", "\"version\": 2");
std::fs::write(&path, tampered).expect("rewrite");
let error = load_credential_file(&path).expect_err("bad version must be rejected");
assert!(error.to_string().contains("unsupported version"));
}
#[test]
fn rejects_oversized_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("agent.hcred");
let bloat = "x".repeat((MAX_CREDENTIAL_FILE_BYTES as usize) + 1);
std::fs::write(&path, bloat).expect("write oversized file");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.expect("tighten perms");
}
let error = load_credential_file(&path).expect_err("oversized file must be rejected");
assert!(
error.to_string().contains("size cap"),
"unexpected error: {error}"
);
}
#[test]
fn rejects_control_chars_in_server_field() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("agent.hcred");
let (mut credential, _signer) = sample_verified();
credential.server = "grpc.heddle\u{0007}.test".to_string();
write_credential_file(&path, &credential).expect("write");
let error =
load_credential_file(&path).expect_err("control chars in server must be rejected");
assert!(
error.to_string().contains("control characters"),
"unexpected error: {error}"
);
}
#[cfg(unix)]
#[test]
fn rejects_group_readable_file() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("agent.hcred");
let (credential, _signer) = sample_verified();
write_credential_file(&path, &credential).expect("write");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640))
.expect("loosen perms");
let error = load_credential_file(&path).expect_err("group-readable file must be rejected");
let message = error.to_string();
assert!(message.contains("group/other-accessible"));
assert!(message.contains("chmod 600"));
}
}