use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use objects::{
fs_atomic::write_file_atomic_secret,
lock::{RepoLock, WriteLockGuard},
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
const STATE_FORMAT: &str = "heddle-agent-claim";
const STATE_VERSION: u32 = 2;
const STATE_FILE: &str = "agent-claim.toml";
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum ClaimStatus {
Dormant,
Active,
Prepared,
Claimed,
}
pub(crate) struct ClaimSecret(String);
impl ClaimSecret {
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ClaimIssuanceStatus {
Active,
Claimed,
Expired,
Replaced,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct ClaimState {
format: String,
version: u32,
pub(crate) server: String,
pub(crate) owner_id: uuid::Uuid,
pub(crate) subject: String,
pub(crate) pet_name: String,
pub(crate) node_id: String,
#[serde(default)]
pub(crate) web_origin: Option<String>,
pub(crate) created_at: String,
secret_hash: String,
pub(crate) expires_at_millis: i64,
status: ClaimStatus,
prepared_handle: Option<String>,
prepared_nonce_hash: Option<String>,
}
impl ClaimState {
pub(crate) fn new(
server: String,
owner_id: uuid::Uuid,
subject: String,
pet_name: String,
node_id: String,
web_origin: Option<String>,
) -> Self {
Self {
format: STATE_FORMAT.to_string(),
version: STATE_VERSION,
server,
owner_id,
subject,
pet_name,
node_id,
web_origin,
created_at: chrono::Utc::now().to_rfc3339(),
secret_hash: String::new(),
expires_at_millis: 0,
status: ClaimStatus::Dormant,
prepared_handle: None,
prepared_nonce_hash: None,
}
}
pub(crate) fn activate(&mut self, expires_at_millis: i64) -> Result<Option<ClaimSecret>> {
if matches!(self.status, ClaimStatus::Claimed) {
return Ok(None);
}
if expires_at_millis <= chrono::Utc::now().timestamp_millis() {
bail!("claim expiry must be in the future");
}
let mut random = [0u8; 32];
getrandom::fill(&mut random).context("minting claim secret")?;
let secret = ClaimSecret(URL_SAFE_NO_PAD.encode(random));
self.activate_with_secret(secret.as_str().as_bytes(), expires_at_millis);
Ok(Some(secret))
}
fn activate_with_secret(&mut self, secret: &[u8], expires_at_millis: i64) {
self.secret_hash = hex::encode(Sha256::digest(secret));
self.expires_at_millis = expires_at_millis;
self.status = ClaimStatus::Active;
self.prepared_handle = None;
self.prepared_nonce_hash = None;
}
#[cfg(test)]
pub(crate) fn reissue(&mut self, secret: &[u8], expires_at_millis: i64) -> bool {
if matches!(self.status, ClaimStatus::Claimed) {
return false;
}
self.activate_with_secret(secret, expires_at_millis);
true
}
pub(crate) fn is_active(&self, now: i64) -> bool {
matches!(self.status, ClaimStatus::Active | ClaimStatus::Prepared)
&& self.consent_unexpired(now)
}
pub(crate) fn consent_unexpired(&self, now: i64) -> bool {
self.expires_at_millis > 0 && now < self.expires_at_millis
}
pub(crate) fn accepts(&self, secret: &[u8], now: i64) -> bool {
self.is_active(now) && self.secret_matches(secret)
}
pub(crate) fn accepts_claimed_resolve(&self, secret: &[u8], now: i64) -> bool {
matches!(self.status, ClaimStatus::Claimed)
&& self.consent_unexpired(now)
&& self.secret_matches(secret)
}
fn secret_matches(&self, secret: &[u8]) -> bool {
if self.secret_hash.is_empty() {
return false;
}
let Ok(expected) = hex::decode(&self.secret_hash) else {
return false;
};
constant_time_eq(&expected, Sha256::digest(secret).as_slice())
}
pub(crate) fn authorization_hash(&self) -> &str {
&self.secret_hash
}
pub(crate) fn is_claimed(&self) -> bool {
matches!(self.status, ClaimStatus::Claimed)
}
pub(crate) fn issuance_status(
&self,
authorization_hash: &str,
now: i64,
) -> ClaimIssuanceStatus {
if self.secret_hash != authorization_hash {
return ClaimIssuanceStatus::Replaced;
}
if self.is_claimed() {
return ClaimIssuanceStatus::Claimed;
}
if self.is_active(now) {
ClaimIssuanceStatus::Active
} else {
ClaimIssuanceStatus::Expired
}
}
pub(crate) fn deactivate_issuance(&mut self, authorization_hash: &str) -> bool {
if self.secret_hash != authorization_hash || self.is_claimed() {
return false;
}
self.secret_hash.clear();
self.expires_at_millis = 0;
self.status = ClaimStatus::Dormant;
self.prepared_handle = None;
self.prepared_nonce_hash = None;
true
}
pub(crate) fn prepare(&mut self, handle: &str, nonce: &[u8]) -> bool {
let nonce_hash = hex::encode(Sha256::digest(nonce));
match self.status {
ClaimStatus::Active => {
self.status = ClaimStatus::Prepared;
self.prepared_handle = Some(handle.to_string());
self.prepared_nonce_hash = Some(nonce_hash);
true
}
ClaimStatus::Prepared => {
self.prepared_handle.as_deref() == Some(handle)
&& self.prepared_nonce_hash.as_deref() == Some(nonce_hash.as_str())
}
ClaimStatus::Dormant | ClaimStatus::Claimed => false,
}
}
pub(crate) fn claim(&mut self, handle: &str) -> bool {
if !matches!(self.status, ClaimStatus::Prepared)
|| self.prepared_handle.as_deref() != Some(handle)
{
return false;
}
self.status = ClaimStatus::Claimed;
self.prepared_nonce_hash = None;
true
}
}
pub(crate) fn state_path() -> PathBuf {
repo::identity::heddle_home_dir().join(STATE_FILE)
}
pub(crate) fn load() -> Result<Option<ClaimState>> {
load_at(&state_path())
}
pub(crate) fn store(state: &ClaimState) -> Result<()> {
store_at(&state_path(), state)
}
pub(crate) fn write_lock() -> Result<WriteLockGuard> {
claim_lock(&state_path())
.write()
.map_err(|error| anyhow::anyhow!(error))
}
pub(crate) fn load_while_locked() -> Result<Option<ClaimState>> {
load_at(&state_path())
}
pub(crate) fn store_while_locked(state: &ClaimState) -> Result<()> {
store_at_unlocked(&state_path(), state)
}
fn load_at(path: &Path) -> Result<Option<ClaimState>> {
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
};
crypto::reject_group_or_world_readable_key(path)
.with_context(|| format!("refusing exposed claim state {}", path.display()))?;
let state: ClaimState =
toml::from_str(&contents).with_context(|| format!("parsing {}", path.display()))?;
if state.format != STATE_FORMAT || state.version != STATE_VERSION {
bail!("{} is not supported Heddle claim state", path.display());
}
Ok(Some(state))
}
fn store_at(path: &Path, state: &ClaimState) -> Result<()> {
if let Some(parent) = path.parent() {
objects::fs_atomic::create_private_dir_all(parent)?;
}
let _guard = claim_lock(path)
.write()
.map_err(|error| anyhow::anyhow!(error))?;
store_at_unlocked(path, state)
}
fn store_at_unlocked(path: &Path, state: &ClaimState) -> Result<()> {
let encoded = toml::to_string_pretty(state).context("serializing claim state")?;
write_file_atomic_secret(path, encoded.as_bytes())
.with_context(|| format!("writing {}", path.display()))
}
fn claim_lock(path: &Path) -> RepoLock {
RepoLock::at(
path.parent()
.unwrap_or_else(|| Path::new("."))
.join("locks/agent-claim.lock"),
)
}
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
let mut difference = left.len() ^ right.len();
for index in 0..left.len().max(right.len()) {
difference |= usize::from(*left.get(index).unwrap_or(&0) ^ *right.get(index).unwrap_or(&0));
}
difference == 0
}
#[cfg(test)]
mod tests {
use super::*;
fn state() -> ClaimState {
ClaimState::new(
"api.heddle.test".into(),
uuid::Uuid::parse_str("7ed1b633-64dd-4b78-b3a8-7f8e08fc4a28").unwrap(),
"subject-1".into(),
"quiet-otter".into(),
"11".repeat(32),
None,
)
}
#[test]
fn reissue_invalidates_the_previous_secret() {
let mut state = state();
assert!(state.reissue(b"first-secret", 2_000));
assert!(state.accepts(b"first-secret", 1_000));
assert!(state.reissue(b"second-secret", 2_000));
assert!(!state.accepts(b"first-secret", 1_000));
assert!(state.accepts(b"second-secret", 1_000));
}
#[test]
fn production_activation_mints_a_persistable_bearer_without_storing_it() {
let directory = tempfile::tempdir().expect("temporary directory");
let path = directory.path().join("claim.toml");
let mut state = state();
let expires_at = chrono::Utc::now().timestamp_millis() + 60_000;
let secret = state
.activate(expires_at)
.expect("activate claim")
.expect("unclaimed account");
assert!(state.accepts(secret.as_str().as_bytes(), expires_at - 1));
assert_eq!(secret.as_str().len(), 43, "32 random bytes use base64url");
store_at(&path, &state).expect("store active claim state");
let contents = std::fs::read_to_string(path).expect("read claim state");
assert!(!contents.contains(secret.as_str()));
assert!(contents.contains("status = \"active\""));
}
#[test]
fn prepared_claim_is_bound_to_one_handle_and_nonce() {
let mut state = state();
assert!(state.reissue(b"claim-secret", 2_000));
assert!(state.prepare("human-handle", b"nonce-one"));
assert!(state.prepare("human-handle", b"nonce-one"));
assert!(!state.prepare("other-handle", b"nonce-one"));
assert!(!state.prepare("human-handle", b"nonce-two"));
assert!(!state.claim("other-handle"));
assert!(state.claim("human-handle"));
assert!(!state.accepts(b"claim-secret", 1_000));
assert!(!state.reissue(b"third-secret", 3_000));
}
#[test]
fn shutdown_deactivates_only_its_own_unclaimed_issuance() {
let mut state = state();
assert!(state.reissue(b"first-secret", 2_000));
let first_hash = state.authorization_hash().to_string();
assert!(state.reissue(b"replacement-secret", 3_000));
assert!(
!state.deactivate_issuance(&first_hash),
"an older resident process must not revoke a replacement offer"
);
let replacement_hash = state.authorization_hash().to_string();
assert!(state.deactivate_issuance(&replacement_hash));
assert!(!state.accepts(b"replacement-secret", 2_000));
}
#[test]
fn expired_secret_is_rejected_and_not_persisted_in_plaintext() {
let directory = tempfile::tempdir().expect("temporary directory");
let path = directory.path().join("claim.toml");
let mut state = state();
assert!(state.reissue(b"super-secret-value", 2_000));
assert!(state.consent_unexpired(1_999));
assert!(!state.consent_unexpired(2_000));
assert!(!state.accepts(b"super-secret-value", 2_000));
store_at(&path, &state).expect("store claim state");
let contents = std::fs::read_to_string(path).expect("read claim state");
assert!(!contents.contains("super-secret-value"));
}
}