use std::{collections::BTreeSet, path::Path};
use anyhow::{Context, Result, bail};
use api::heddle::api::v1alpha1::{
CreateDeviceAuthorizationRequest, CreateServiceAccountRequest, DeviceAuthProof,
DeviceAuthorizationEvent, DeviceAuthorizationResponse, DeviceAuthorizationStatus,
ExchangeDeviceAuthorizationRequest, IssueServiceAccountCredentialRequest, MintBiscuitRequest,
WaitForDeviceAuthorizationRequest, mint_biscuit_request::Proof,
};
use cli_shared::{UserConfig, credentials, credentials::ServerCredential};
use crypto::{Ed25519Signer, Signer};
use objects::{HeddleError, RecoveryDetails};
use serde::Serialize;
use sha2::{Digest, Sha256};
use weft_client_shim::CliContext;
use super::{
auth_requests::{AuthCommand, AuthTrustCommand},
credential_file::{self, CredentialKind, CredentialProvenance, VerifiedCredential},
device_flow::{
AgentAttenuation, AgentTemplate, SAFE_AGENT_OPERATIONS, attenuate_for_agent,
effective_pop_public_key_hex,
},
hosted::{
HostedAuthMode, HostedClient, HostedError, HostedSession, ResolvedHostedCredential,
operation_id::ClientOperationId, resolve_hosted_credential,
},
};
#[derive(Serialize)]
struct AuthLogoutOutput {
output_kind: &'static str,
server: String,
removed: bool,
device_identity_removed: bool,
}
#[derive(Serialize)]
struct AuthStatusOutput {
output_kind: &'static str,
server: String,
authenticated: bool,
source: String,
proof_key_available: bool,
subject: Option<String>,
credential_id: Option<String>,
expires_at: Option<String>,
recommended_action: Option<String>,
}
#[derive(Serialize)]
struct AuthTrustOutput {
output_kind: &'static str,
canonical_server: String,
source: crate::hosted_runtime::hosted::DescriptorTrustSource,
key_id: String,
public_key: String,
fingerprint: String,
}
#[derive(Serialize)]
struct ServiceTokenOutput {
output_kind: &'static str,
name: String,
namespace: String,
scope: String,
credential_path: String,
expires_in_days: u32,
}
const DERIVED_TOKEN_SECURITY_NOTE: &str = "Derived credential has its own proof key and is operation/TTL/resource-scope-limited and enforced server-side. The token and proof key travel together inside the .hcred file; the parent device key is not exported.";
const SERVICE_TOKEN_TTL_DAYS: u32 = 30;
const SERVICE_TOKEN_TTL_SECS: i64 = SERVICE_TOKEN_TTL_DAYS as i64 * 24 * 3600;
const ISSUE_SA_PROOF_DOMAIN: &[u8] = b"heddle-sa-credential-issue-v1";
pub async fn cmd_auth(ctx: &dyn CliContext, command: AuthCommand) -> Result<()> {
match command {
AuthCommand::Login {
server,
open_browser,
credential,
} => match credential {
Some(credential) => {
let subject = install_credential_file(&credential)?;
println!("Authenticated as {subject}. Credentials saved.");
Ok(())
}
None => {
let server = resolve_server(server.as_deref())?;
cmd_auth_login(&server, open_browser).await
}
},
AuthCommand::Logout { server } => cmd_auth_logout(ctx, server.as_deref()),
AuthCommand::Status { server } => cmd_auth_status(ctx, server.as_deref()),
AuthCommand::Trust { command } => cmd_auth_trust(ctx, command),
AuthCommand::DeriveAgent {
server,
agent_id,
ttl_secs,
scopes,
allowed_operations,
template,
out,
} => cmd_auth_derive_agent(
&server,
agent_id,
ttl_secs,
scopes,
allowed_operations,
template,
out.as_deref(),
),
AuthCommand::CreateServiceToken {
name,
namespace,
server,
out,
} => {
cmd_create_service_token(ctx, server.as_deref(), name, namespace, out.as_deref()).await
}
}
}
fn cmd_auth_trust(ctx: &dyn CliContext, command: AuthTrustCommand) -> Result<()> {
match command {
AuthTrustCommand::Show { server } => {
let config = UserConfig::load_default()?.hosted_runtime_config(None)?;
let explicit = config
.descriptor_key_id
.as_deref()
.zip(config.descriptor_public_key.as_ref());
let report = crate::hosted_runtime::hosted::trust_report(&server, explicit)?;
emit_auth_trust(
ctx,
AuthTrustOutput {
output_kind: "auth_trust_show",
canonical_server: report.canonical_server,
source: report.source,
key_id: report.key_id,
public_key: report.public_key,
fingerprint: report.fingerprint,
},
)
}
AuthTrustCommand::Replace {
server,
expected_current_public_key,
key_id,
public_key,
} => {
let config = UserConfig::load_default()?.hosted_runtime_config(None)?;
if config.descriptor_key_id.is_some() || config.descriptor_public_key.is_some() {
bail!(
"descriptor trust replacement refused: explicit descriptor trust controls this \
connection; update both explicit values together"
);
}
let canonical_server =
crate::hosted_runtime::hosted::canonical_server_authority(&server)?;
let record = crate::hosted_runtime::hosted::replace_descriptor_trust(
&canonical_server,
&expected_current_public_key,
&key_id,
&public_key,
)?;
let fingerprint = record.fingerprint()?;
emit_auth_trust(
ctx,
AuthTrustOutput {
output_kind: "auth_trust_replace",
canonical_server,
source: crate::hosted_runtime::hosted::DescriptorTrustSource::Automatic,
key_id: record.key_id,
public_key: record.public_key,
fingerprint,
},
)
}
}
}
fn emit_auth_trust(ctx: &dyn CliContext, output: AuthTrustOutput) -> Result<()> {
if ctx.should_output_json(None) {
println!("{}", serde_json::to_string(&output)?);
} else {
println!("Server: {}", output.canonical_server);
println!(
"Source: {}",
match output.source {
crate::hosted_runtime::hosted::DescriptorTrustSource::Explicit => "explicit",
crate::hosted_runtime::hosted::DescriptorTrustSource::Automatic => "automatic",
}
);
println!("Descriptor key id: {}", output.key_id);
println!("Descriptor public key: {}", output.public_key);
println!("Fingerprint: {}", output.fingerprint);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn cmd_auth_derive_agent(
server: &str,
agent_id: Option<String>,
ttl_secs: u64,
scopes: Vec<String>,
requested_operations: Vec<String>,
template: Option<AgentTemplate>,
out: Option<&Path>,
) -> Result<()> {
if ttl_secs == 0 {
bail!("--ttl must be greater than zero seconds");
}
let ttl_secs = i64::try_from(ttl_secs).context("--ttl is too large")?;
let parent = resolve_hosted_credential(Some(server))?;
let parent_token = parent.token.as_ref().ok_or_else(|| {
let primary_command = format!("heddle auth login --server {server}");
anyhow::anyhow!(HeddleError::recovery(
RecoveryDetails::safety_refusal(
"auth_required",
format!("Not authenticated with {server}"),
format!("Run `{primary_command}` to authenticate, then retry the hosted command."),
"no usable hosted credential is available for the selected server",
"continuing without credentials would send an unauthenticated hosted mutation",
"no hosted request was sent and local repository state was left unchanged",
)
.with_recovery_commands(vec![primary_command]),
))
})?;
let private_key_pem = parent.proof_key_pem.as_deref().ok_or_else(|| {
anyhow::anyhow!(
"active credential for {server} has no device proof key; run `heddle auth login --server {server}` first"
)
})?;
let signer = Ed25519Signer::from_pem(private_key_pem)
.map_err(|error| anyhow::anyhow!("active device proof key is invalid: {error}"))?;
let metadata = headless_token_metadata(&parent_token.id)?;
if !metadata
.proof_public_key_hex
.eq_ignore_ascii_case(&hex::encode(signer.public_key()))
{
bail!("active device proof key does not match the parent Biscuit");
}
let now = chrono::Utc::now();
let requested_expiry = now
.checked_add_signed(chrono::Duration::seconds(ttl_secs))
.ok_or_else(|| anyhow::anyhow!("--ttl produces an unsupported expiry"))?;
let expires_at = match parent.expires_at.as_deref() {
Some(value) => {
let parent_expiry = chrono::DateTime::parse_from_rfc3339(value)
.with_context(|| format!("stored parent expiry is invalid: {value}"))?
.with_timezone(&chrono::Utc);
if parent_expiry <= now {
bail!("stored parent credential expired at {parent_expiry}");
}
requested_expiry.min(parent_expiry)
}
None => requested_expiry,
};
let agent_id = agent_id.unwrap_or_else(|| format!("agent-{}", uuid::Uuid::new_v4()));
let allowed_operations = resolve_agent_operations(template, requested_operations)?;
let declared_scopes = parse_agent_scopes(scopes)?;
validate_scope_narrowing(&parent_token.id, &declared_scopes)?;
let child_signer = Ed25519Signer::generate()
.map_err(|error| anyhow::anyhow!("failed to generate child proof key: {error}"))?;
let child_token = attenuate_for_agent(
&parent_token.id,
AgentAttenuation {
agent_id: agent_id.clone(),
expires_at,
allowed_operations: Some(allowed_operations.clone()),
allowed_resources: (!declared_scopes.is_empty()).then(|| declared_scopes.clone()),
declared_scopes: declared_scopes.clone(),
},
&signer,
child_signer.public_key(),
)?;
let child_private_key_pem = child_signer
.to_pem()
.map_err(|error| anyhow::anyhow!("failed to export child proof key: {error}"))?;
if let Some(out) = out {
let provenance = CredentialProvenance {
template: template.map(|template| template.as_str().to_string()),
scopes: (!declared_scopes.is_empty()).then(|| {
declared_scopes
.iter()
.map(|(kind, path)| format!("{kind}:{path}"))
.collect()
}),
allowed_operations: Some(allowed_operations.clone()),
agent_id: Some(agent_id.clone()),
};
let verified = VerifiedCredential {
server: server.to_string(),
kind: CredentialKind::Agent,
subject: metadata.subject.clone(),
token: child_token,
proof_key_pem: child_private_key_pem,
expires_at: Some(expires_at.to_rfc3339()),
credential_id: None,
provenance: Some(provenance),
};
credential_file::write_credential_file(out, &verified)?;
println!("Agent credential {agent_id} written to {}.", out.display());
println!("Parent source: {}", parent.source.label());
if let Some(template) = template {
println!("Template: {} ceiling", template.as_str());
}
println!("Allowed operations: {}", allowed_operations.join(", "));
println!("{DERIVED_TOKEN_SECURITY_NOTE}");
return Ok(());
}
credentials::store_server_credential(
server,
ServerCredential {
token: child_token,
subject: parent.subject.unwrap_or(metadata.subject),
device_id: None,
credential_id: None,
private_key_pem: Some(child_private_key_pem),
expires_at: Some(expires_at.to_rfc3339()),
},
)?;
println!("Derived and installed agent token {agent_id} for {server}.");
println!("Parent source: {}", parent.source.label());
println!("Expires: {expires_at}");
if let Some(template) = template {
println!("Template: {} ceiling", template.as_str());
}
println!("Allowed operations: {}", allowed_operations.join(", "));
if declared_scopes.is_empty() {
println!("Scopes: none (full resource authority inherited from parent)");
} else {
println!(
"Scopes: {} (enforced server-side per request)",
declared_scopes
.iter()
.map(|(kind, path)| format!("{kind}:{path}"))
.collect::<Vec<_>>()
.join(", ")
);
}
println!("{DERIVED_TOKEN_SECURITY_NOTE}");
Ok(())
}
fn resolve_agent_operations(
template: Option<AgentTemplate>,
requested: Vec<String>,
) -> Result<Vec<String>> {
let base: BTreeSet<String> = match template {
Some(template) => template.operations().into_iter().collect(),
None => SAFE_AGENT_OPERATIONS
.iter()
.map(|operation| (*operation).to_string())
.collect(),
};
if requested.is_empty() {
return Ok(base.into_iter().collect());
}
let mut selected = BTreeSet::new();
for operation in requested {
if !base.contains(&operation) {
let ceiling = match template {
Some(template) => format!("the {:?} template's operation set", template.as_str()),
None => "the safe agent operation ceiling".to_string(),
};
bail!(
"operation {operation:?} is outside {ceiling}; --allow can only narrow the {} set",
if template.is_some() {
"template"
} else {
"default"
}
);
}
selected.insert(operation);
}
Ok(selected.into_iter().collect())
}
fn parse_agent_scopes(scopes: Vec<String>) -> Result<Vec<(String, String)>> {
let mut parsed = BTreeSet::new();
for scope in scopes {
let (kind, path) = match scope.split_once(':') {
Some(("repo", path)) => ("repo", path),
Some(("namespace" | "ns", path)) => ("namespace", path),
Some((kind, _)) => bail!(
"unsupported scope kind {kind:?}; use repo:<path>, namespace:<path>, or a bare repo path"
),
None => ("repo", scope.as_str()),
};
let path = path.trim_matches('/');
if path.is_empty() {
bail!("--scope path must not be empty");
}
parsed.insert((kind.to_string(), path.to_string()));
}
Ok(parsed.into_iter().collect())
}
fn validate_scope_narrowing(parent_token: &str, child: &[(String, String)]) -> Result<()> {
if child.is_empty() {
return Ok(());
}
for ancestor in agent_scope_blocks(parent_token)? {
if ancestor.is_empty() {
continue;
}
for child_scope in child {
if !ancestor
.iter()
.any(|parent_scope| scope_is_within(child_scope, parent_scope))
{
bail!(
"scope {}:{} would widen an ancestor agent scope; sub-derivation may only narrow",
child_scope.0,
child_scope.1
);
}
}
}
Ok(())
}
fn agent_scope_blocks(token: &str) -> Result<Vec<Vec<(String, String)>>> {
use biscuit_auth::builder::{BlockBuilder, Term};
let biscuit = biscuit_auth::UnverifiedBiscuit::from_base64(token.as_bytes())
.context("parsing parent Biscuit scopes")?;
let mut blocks = Vec::new();
for index in 1..biscuit.block_count() {
let source = biscuit
.print_block_source(index)
.with_context(|| format!("reading Biscuit attenuation block {index}"))?;
let block = BlockBuilder::new()
.code(&source)
.with_context(|| format!("parsing Biscuit attenuation block {index}"))?;
let scopes = block
.facts
.iter()
.filter_map(|fact| {
if fact.predicate.name != "agent_scope" || fact.predicate.terms.len() != 2 {
return None;
}
match (&fact.predicate.terms[0], &fact.predicate.terms[1]) {
(Term::Str(kind), Term::Str(path)) => Some((kind.clone(), path.clone())),
_ => None,
}
})
.collect();
blocks.push(scopes);
}
Ok(blocks)
}
fn scope_is_within(child: &(String, String), parent: &(String, String)) -> bool {
let path_is_within = child.1 == parent.1
|| child
.1
.strip_prefix(&parent.1)
.is_some_and(|suffix| suffix.starts_with('/'));
match (parent.0.as_str(), child.0.as_str()) {
("repo", "repo") => path_is_within,
("namespace", "namespace") => path_is_within,
("namespace", "repo") => child.1 != parent.1 && path_is_within,
_ => false,
}
}
pub(crate) struct HeadlessTokenMetadata {
pub(crate) subject: String,
pub(crate) is_derived: bool,
pub(crate) credential_id: Option<String>,
pub(crate) expires_at: Option<String>,
pub(crate) proof_public_key_hex: String,
}
pub(crate) fn install_credential_file(path: &Path) -> Result<String> {
let credential = credential_file::load_credential_file(path)?;
let server = credential.server.clone();
let subject = credential.subject.clone();
let register_device_identity = matches!(credential.kind, CredentialKind::Device);
let proof_key_pem = credential.proof_key_pem.clone();
credentials::store_server_credential(&server, credential.into_server_credential())?;
if register_device_identity {
let signer = Ed25519Signer::from_pem(&proof_key_pem)
.map_err(|error| anyhow::anyhow!("credential proof key is invalid: {error}"))?;
repo::identity::link_device_key(signer.public_key(), &proof_key_pem, &server)
.with_context(|| format!("registering device identity for {server}"))?;
}
Ok(subject)
}
pub(crate) fn headless_token_metadata(token: &str) -> Result<HeadlessTokenMetadata> {
use biscuit_auth::builder::{BlockBuilder, Term};
let biscuit = biscuit_auth::UnverifiedBiscuit::from_base64(token.as_bytes())
.context("parsing credential token as a Biscuit")?;
let block_count = biscuit.block_count();
let authority_source = biscuit
.print_block_source(0)
.context("reading Biscuit authority block")?;
let authority = BlockBuilder::new()
.code(&authority_source)
.context("parsing Biscuit authority facts")?;
let string_fact = |name: &str| -> Result<Option<String>> {
let mut values = authority.facts.iter().filter_map(|fact| {
if fact.predicate.name != name || fact.predicate.terms.len() != 1 {
return None;
}
match &fact.predicate.terms[0] {
Term::Str(value) => Some(value.clone()),
_ => None,
}
});
let value = values.next();
if values.next().is_some() {
bail!("Biscuit authority block contains multiple {name} facts");
}
Ok(value)
};
let subject = string_fact("user")?
.filter(|subject| !subject.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!("Biscuit authority block is missing user(subject)"))?;
let credential_id = if block_count > 1 {
None
} else {
string_fact("credential_id")?
};
let proof_public_key_hex = effective_pop_public_key_hex(token)?;
let mut expiries = authority.facts.iter().filter_map(|fact| {
if fact.predicate.name != "expires_at" || fact.predicate.terms.len() != 1 {
return None;
}
match fact.predicate.terms[0] {
Term::Date(seconds) => Some(seconds),
_ => None,
}
});
let authority_expires_at = expiries
.next()
.map(|seconds| {
i64::try_from(seconds)
.ok()
.and_then(|seconds| chrono::DateTime::from_timestamp(seconds, 0))
.map(|expires_at| expires_at.to_rfc3339())
.ok_or_else(|| anyhow::anyhow!("Biscuit expires_at is outside the supported range"))
})
.transpose()?;
if expiries.next().is_some() {
bail!("Biscuit authority block contains multiple expires_at facts");
}
let mut effective_expiry = authority_expires_at
.as_deref()
.map(chrono::DateTime::parse_from_rfc3339)
.transpose()
.context("parsing Biscuit authority expiry")?
.map(|value| value.with_timezone(&chrono::Utc));
for index in 1..block_count {
let source = biscuit
.print_block_source(index)
.with_context(|| format!("reading Biscuit attenuation block {index}"))?;
let block = BlockBuilder::new()
.code(&source)
.with_context(|| format!("parsing Biscuit attenuation block {index}"))?;
for fact in &block.facts {
if fact.predicate.name != "agent_expires_at" || fact.predicate.terms.len() != 1 {
continue;
}
let Term::Date(seconds) = fact.predicate.terms[0] else {
bail!("Biscuit attenuation block {index} has invalid agent_expires_at fact");
};
let seconds = i64::try_from(seconds)
.with_context(|| format!("attenuation block {index} expiry is too large"))?;
let value = chrono::DateTime::from_timestamp(seconds, 0)
.ok_or_else(|| anyhow::anyhow!("attenuation block {index} expiry is invalid"))?;
effective_expiry = Some(effective_expiry.map_or(value, |current| current.min(value)));
}
}
let expires_at = effective_expiry.map(|value| value.to_rfc3339());
Ok(HeadlessTokenMetadata {
subject,
is_derived: block_count > 1,
credential_id,
expires_at,
proof_public_key_hex,
})
}
async fn cmd_auth_login(server: &str, open_browser: bool) -> Result<()> {
let signer = Ed25519Signer::generate()
.map_err(|e| anyhow::anyhow!("failed to generate keypair: {e}"))?;
let public_key_bytes = signer.public_key().to_vec();
let private_key_pem = signer
.to_pem()
.map_err(|e| anyhow::anyhow!("failed to export private key: {e}"))?;
let mut auth_client = connect_auth_client(server).await?;
let hostname = std::env::var("HOSTNAME")
.or_else(|_| std::env::var("HOST"))
.unwrap_or_else(|_| "heddle-cli".to_string());
let response: Result<DeviceAuthorizationResponse> = auth_client
.routes()
.create_device_authorization(&CreateDeviceAuthorizationRequest {
device_name: hostname,
device_public_key: public_key_bytes.clone(),
scope: "repo:*".to_string(),
client_operation_id: String::new(),
})
.await
.map_err(|error| anyhow::anyhow!("create_device_authorization failed: {error}"));
let response = match response {
Ok(response) => response,
Err(error) => {
auth_client.close().await;
return Err(error);
}
};
let verification_uri = &response.verification_uri;
let user_code = &response.user_code;
let device_code = &response.device_code;
println!();
println!("Open this URL to authorize:");
println!(" {verification_uri}");
println!();
println!("Enter code: {user_code}");
println!();
if open_browser {
let encoded_code = percent_encode_query_component(user_code);
let url = format!("{verification_uri}?code={encoded_code}");
match validate_browser_url(&url) {
Ok(()) => {
if let Err(_e) = open_url(&url) {
eprintln!("Could not open browser automatically. Please open the URL above.");
}
}
Err(err) => {
eprintln!("Refusing to open browser URL: {err}");
eprintln!("Please open the URL printed above in your browser.");
}
}
}
println!("Waiting for authorization...");
let access_token = poll_for_approval(
&mut auth_client,
device_code,
&public_key_bytes,
&signer,
response.expires_at,
)
.await;
auth_client.close().await;
let access_token = access_token?;
let credential = ServerCredential {
token: access_token.token,
subject: access_token.subject.clone(),
device_id: None,
credential_id: if access_token.credential_id.is_empty() {
None
} else {
Some(access_token.credential_id)
},
private_key_pem: Some(private_key_pem.clone()),
expires_at: access_token.expires_at.as_ref().and_then(|ts| {
chrono::DateTime::from_timestamp(ts.seconds, ts.nanos.max(0) as u32)
.map(|dt| dt.to_rfc3339())
}),
};
credentials::store_server_credential(server, credential)?;
if let Err(error) = repo::identity::link_device_key(&public_key_bytes, &private_key_pem, server)
{
tracing::warn!(%error, "could not record device signing identity; captures will use the per-repo local key");
}
println!();
println!(
"Authenticated as {}. Credentials saved.",
access_token.subject
);
Ok(())
}
fn cmd_auth_logout(ctx: &dyn CliContext, server: Option<&str>) -> Result<()> {
let server = resolve_server(server)?;
let device_identity_removed = repo::identity::unlink_device_key(&server).map_err(|error| {
anyhow::anyhow!("failed to remove device signing identity for {server}: {error}")
})?;
credentials::remove_server_credential(&server)?;
if ctx.should_output_json(None) {
let output = AuthLogoutOutput {
output_kind: "auth_logout",
server,
removed: true,
device_identity_removed,
};
println!("{}", serde_json::to_string(&output)?);
} else {
println!("Credentials removed for {server}.");
if device_identity_removed {
println!("Device signing identity removed.");
}
}
Ok(())
}
fn cmd_auth_status(ctx: &dyn CliContext, server: Option<&str>) -> Result<()> {
let server = resolve_server(server)?;
let resolved = resolve_hosted_credential(Some(&server))?;
let output = auth_status_output(&server, &resolved);
if ctx.should_output_json(None) {
println!("{}", serde_json::to_string(&output)?);
} else if output.authenticated {
println!("Server: {server}");
println!("Source: {}", output.source);
println!(
"Subject: {}",
output.subject.as_deref().unwrap_or_default()
);
if let Some(ref cred_id) = output.credential_id {
println!("Credential: {cred_id}");
}
if let Some(ref expires) = output.expires_at {
println!("Expires: {expires}");
}
if output.proof_key_available {
println!("Hosted writes: ready (device proof key available)");
} else {
println!(
"Hosted writes: unavailable — credential missing device proof key; re-login / re-install"
);
if let Some(ref action) = output.recommended_action {
println!("Run `{action}` to repair the credential.");
}
}
} else {
println!("Not authenticated with {server}.");
if let Some(ref action) = output.recommended_action {
println!("Run `{action}` to authenticate.");
}
}
Ok(())
}
fn auth_status_output(server: &str, resolved: &ResolvedHostedCredential) -> AuthStatusOutput {
let source = resolved.source.label();
if resolved.token.is_some() {
let proof_key_available = resolved
.proof_key_pem
.as_deref()
.is_some_and(|pem| Ed25519Signer::from_pem(pem).is_ok());
AuthStatusOutput {
output_kind: "auth_status",
server: server.to_string(),
authenticated: true,
source,
proof_key_available,
subject: resolved.subject.clone(),
credential_id: resolved.credential_id.clone(),
expires_at: resolved.expires_at.clone(),
recommended_action: (!proof_key_available)
.then(|| format!("heddle auth login --server {server}")),
}
} else {
AuthStatusOutput {
output_kind: "auth_status",
server: server.to_string(),
authenticated: false,
source,
proof_key_available: false,
subject: None,
credential_id: None,
expires_at: None,
recommended_action: Some(format!("heddle auth login --server {server}")),
}
}
}
async fn cmd_create_service_token(
ctx: &dyn CliContext,
server: Option<&str>,
name: String,
namespace: String,
out: Option<&Path>,
) -> Result<()> {
let server = resolve_server(server)?;
let scope = format!("repo:{namespace}/*");
let credential_path = resolve_service_account_credential_path(&name, out);
if std::fs::symlink_metadata(&credential_path).is_ok() {
bail!(
"credential destination {} already exists; choose a new --out path",
credential_path.display()
);
}
let user_config = UserConfig::load_default()?;
let session = HostedSession::build(
&user_config,
Some(server.clone()),
HostedAuthMode::CredentialFallback,
)?;
let mut auth_client = session.connect(([127, 0, 0, 1], 0).into()).await?;
let result = create_service_token_connected(
ctx,
&mut auth_client,
server,
name,
namespace,
scope,
credential_path,
)
.await;
auth_client.close().await;
result
}
async fn create_service_token_connected(
ctx: &dyn CliContext,
auth_client: &mut HostedClient,
server: String,
name: String,
namespace: String,
scope: String,
credential_path: std::path::PathBuf,
) -> Result<()> {
let create_operation_id = ClientOperationId::caller_or_fresh(
"heddle.api.v1alpha1.IdentityService/CreateServiceAccount",
ctx.operation_id_wire(),
);
let issue_operation_id = ClientOperationId::for_required_method(
"heddle.api.v1alpha1.IdentityService/IssueServiceAccountCredential",
create_operation_id.to_wire(),
)?;
let signer = Ed25519Signer::generate()
.map_err(|e| anyhow::anyhow!("failed to generate keypair: {e}"))?;
let public_key_bytes = signer.public_key().to_vec();
let private_key_pem = signer
.to_pem()
.map_err(|e| anyhow::anyhow!("failed to export service-account private key: {e}"))?;
let sa_response = auth_client
.create_service_account(CreateServiceAccountRequest {
subject: name.clone(),
display_name: name.clone(),
scope: scope.clone(),
client_operation_id: create_operation_id.to_wire(),
})
.await
.map_err(|error| anyhow::anyhow!("create_service_account failed: {error}"))?;
tracing::info!(
service_account_id = %sa_response.service_account_id,
subject = %sa_response.subject,
"service account created"
);
let credential_request = IssueServiceAccountCredentialRequest {
service_account_id: sa_response.service_account_id,
public_key: public_key_bytes,
scope: scope.clone(),
ttl_secs: Some(prost_types::Duration {
seconds: SERVICE_TOKEN_TTL_SECS,
nanos: 0,
}),
client_operation_id: issue_operation_id.to_wire(),
proof_timestamp_seconds: 0,
proof_signature: Vec::new(),
};
let credential_request = issue_service_account_credential_request(credential_request, &signer)?;
let issued = auth_client
.issue_service_account_credential(credential_request)
.await
.map_err(|error| anyhow::anyhow!("issue_service_account_credential failed: {error}"))?;
let subject = crate::hosted_runtime::device_flow::authenticated_subject(&issued.token)
.context("reading the issued service token's authenticated subject")?;
let expires_at =
(chrono::Utc::now() + chrono::Duration::seconds(SERVICE_TOKEN_TTL_SECS)).to_rfc3339();
let verified = VerifiedCredential {
server: server.clone(),
kind: CredentialKind::Service,
subject,
token: issued.token,
proof_key_pem: private_key_pem,
expires_at: Some(expires_at),
credential_id: None,
provenance: Some(CredentialProvenance {
scopes: Some(vec![scope.clone()]),
..CredentialProvenance::default()
}),
};
credential_file::write_credential_file(&credential_path, &verified)?;
let credential_path_display = credential_path.display().to_string();
if ctx.should_output_json(None) {
let output = ServiceTokenOutput {
output_kind: "auth_create_service_token",
name,
namespace,
scope,
credential_path: credential_path_display,
expires_in_days: SERVICE_TOKEN_TTL_DAYS,
};
println!("{}", serde_json::to_string(&output)?);
} else {
println!();
println!("Service token created for \"{name}\" (scope: {scope})");
println!();
println!("Credential written to: {credential_path_display}");
println!("Expires in {SERVICE_TOKEN_TTL_DAYS} days.");
println!(
"The single .hcred file carries the token and its proof key; keep it secret (mode 0600)."
);
println!("Point the runtime at it with HEDDLE_CREDENTIAL={credential_path_display}.");
println!("This token is scoped to the {namespace} namespace.");
}
Ok(())
}
fn resolve_service_account_credential_path(name: &str, out: Option<&Path>) -> std::path::PathBuf {
if let Some(path) = out {
return path.to_path_buf();
}
let mut safe: String = name
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
ch
} else {
'_'
}
})
.collect();
if safe.is_empty() {
safe = "service-account".to_string();
}
repo::identity::heddle_home_dir()
.join("service-accounts")
.join(format!("{safe}.hcred"))
}
fn issue_service_account_credential_request(
request: IssueServiceAccountCredentialRequest,
signer: &Ed25519Signer,
) -> Result<IssueServiceAccountCredentialRequest> {
let timestamp = current_unix_timestamp_i64()?;
issue_service_account_credential_request_at(request, signer, timestamp)
}
fn issue_service_account_credential_request_at(
mut request: IssueServiceAccountCredentialRequest,
signer: &Ed25519Signer,
timestamp: i64,
) -> Result<IssueServiceAccountCredentialRequest> {
let signature = issue_service_account_credential_signature(
signer,
timestamp,
&request.service_account_id,
&request.public_key,
)?;
request.proof_timestamp_seconds = timestamp;
request.proof_signature = signature;
Ok(request)
}
fn issue_service_account_credential_signature(
signer: &Ed25519Signer,
timestamp: i64,
service_account_id: &str,
public_key: &[u8],
) -> Result<Vec<u8>> {
let canonical = derive_issue_service_account_credential_canonical(
timestamp,
service_account_id,
public_key,
);
signer
.sign(&canonical)
.map_err(|e| anyhow::anyhow!("failed to sign service-account proof: {e}"))
}
fn derive_issue_service_account_credential_canonical(
timestamp: i64,
service_account_id: &str,
public_key: &[u8],
) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(ISSUE_SA_PROOF_DOMAIN);
hasher.update([0u8]);
hasher.update(timestamp.to_be_bytes());
hasher.update([0u8]);
hasher.update(service_account_id.as_bytes());
hasher.update([0u8]);
hasher.update(public_key);
hasher.finalize().into()
}
fn current_unix_timestamp_i64() -> Result<i64> {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|err| anyhow::anyhow!("system clock is before unix epoch: {err}"))?
.as_secs();
i64::try_from(secs).map_err(|_| anyhow::anyhow!("system clock exceeds i64 unix timestamp"))
}
pub(crate) fn resolve_server(explicit: Option<&str>) -> Result<String> {
if let Some(s) = explicit {
return Ok(s.to_string());
}
if let Some(default) = credentials::default_server()? {
return Ok(default);
}
Ok("api.heddle.sh".to_string())
}
async fn connect_auth_client(server: &str) -> Result<HostedClient> {
let user_config = UserConfig::load_default()?;
HostedSession::build(
&user_config,
Some(server.to_string()),
HostedAuthMode::Unauthenticated,
)?
.connect(([127, 0, 0, 1], 0).into())
.await
.map_err(Into::into)
}
async fn poll_for_approval(
client: &mut HostedClient,
device_code: &str,
public_key: &[u8],
signer: &Ed25519Signer,
expires_at: Option<prost_types::Timestamp>,
) -> Result<AccessToken> {
let proof_bytes = device_authorization_signature(device_code, signer)?;
let expires_at_secs = expires_at
.as_ref()
.map(|t| t.seconds.max(0) as u64)
.unwrap_or(0);
let wait_budget = std::time::Duration::from_secs(
expires_at_secs
.saturating_sub(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
)
.max(30),
);
let deadline = std::time::Instant::now() + wait_budget;
let mut events = client
.routes()
.wait_for_device_authorization(&WaitForDeviceAuthorizationRequest {
device_code: device_code.to_string(),
})
.await
.map_err(|error| anyhow::anyhow!("wait_for_device_authorization failed: {error}"))?;
loop {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
return Err(device_authorization_wait_timeout(wait_budget));
}
let event =
wait_for_device_authorization_event(events.next(), remaining, wait_budget).await?;
match event.map(|status| status.status()) {
Some(DeviceAuthorizationStatus::Pending) => continue,
Some(DeviceAuthorizationStatus::Approved) => break,
Some(DeviceAuthorizationStatus::Expired) => {
bail!("Authorization expired before approval. Please try again.");
}
Some(status) => bail!("Unexpected device authorization status: {status:?}"),
None => bail!("Device authorization ended before approval. Please try again."),
}
}
match mint_biscuit_with_device_auth(client, device_code, public_key, proof_bytes.clone()).await
{
Ok(token) => Ok(token),
Err(error) if should_fallback_to_exchange_device_authorization(&error) => {
tracing::debug!(
error = %error,
"falling back to ExchangeDeviceAuthorization for lagging auth server"
);
exchange_device_authorization(client, device_code, public_key, proof_bytes).await
}
Err(error) => Err(anyhow::anyhow!("device authorization failed: {error}")),
}
}
async fn wait_for_device_authorization_event(
event: impl std::future::Future<
Output = std::result::Result<Option<DeviceAuthorizationEvent>, HostedError>,
>,
remaining: std::time::Duration,
wait_budget: std::time::Duration,
) -> Result<Option<DeviceAuthorizationEvent>> {
tokio::time::timeout(remaining, event)
.await
.map_err(|_| device_authorization_wait_timeout(wait_budget))?
.map_err(|error| device_authorization_wait_stream_error(error, wait_budget))
}
fn device_authorization_wait_timeout(wait_budget: std::time::Duration) -> anyhow::Error {
anyhow::anyhow!(
"device authorization approval wait exceeded its {}s authorization-expiry budget; please run `heddle auth login` again",
wait_budget.as_secs()
)
}
fn device_authorization_wait_stream_error(
error: HostedError,
wait_budget: std::time::Duration,
) -> anyhow::Error {
if matches!(
error,
HostedError::Call {
code: api::heddle::api::v1alpha1::CallFailureCode::DeadlineExceeded,
..
}
) {
return anyhow::anyhow!(
"device authorization approval stream returned DeadlineExceeded before its {}s authorization-expiry budget elapsed: {error}",
wait_budget.as_secs()
);
}
anyhow::anyhow!("device authorization approval stream failed: {error}")
}
async fn mint_biscuit_with_device_auth(
client: &mut HostedClient,
device_code: &str,
public_key: &[u8],
signature: Vec<u8>,
) -> std::result::Result<AccessToken, HostedError> {
let request = device_auth_mint_biscuit_request(device_code, public_key, signature);
let inner = client.routes().mint_biscuit(&request).await?;
Ok(AccessToken {
token: inner.token,
subject: inner.subject,
expires_at: inner.expires_at,
credential_id: inner.credential_id,
})
}
async fn exchange_device_authorization(
client: &mut HostedClient,
device_code: &str,
public_key: &[u8],
proof: Vec<u8>,
) -> Result<AccessToken> {
let inner = client
.routes()
.exchange_device_authorization(&ExchangeDeviceAuthorizationRequest {
device_code: device_code.to_string(),
device_public_key: public_key.to_vec(),
proof,
})
.await
.map_err(|error| anyhow::anyhow!("device authorization failed: {error}"))?;
Ok(AccessToken {
token: inner.token,
subject: inner.subject,
expires_at: inner.expires_at,
credential_id: inner.credential_id,
})
}
fn device_auth_mint_biscuit_request(
device_code: &str,
public_key: &[u8],
signature: Vec<u8>,
) -> MintBiscuitRequest {
MintBiscuitRequest {
subject: String::new(),
requested_scope: String::new(),
user_agent: String::new(),
ip: String::new(),
proof: Some(Proof::DeviceAuth(DeviceAuthProof {
device_code: device_code.to_string(),
device_public_key: public_key.to_vec(),
signature,
})),
client_operation_id: String::new(),
}
}
fn device_authorization_signature(device_code: &str, signer: &Ed25519Signer) -> Result<Vec<u8>> {
signer
.sign(format!("device:{device_code}").as_bytes())
.map_err(|e| anyhow::anyhow!("failed to sign proof: {e}"))
}
fn should_fallback_to_exchange_device_authorization(error: &HostedError) -> bool {
matches!(
error,
HostedError::Call {
code: api::heddle::api::v1alpha1::CallFailureCode::Unimplemented,
message,
..
} if message.contains("DeviceAuthProof")
&& message.contains("ExchangeDeviceAuthorization")
)
}
struct AccessToken {
token: String,
subject: String,
expires_at: Option<prost_types::Timestamp>,
credential_id: String,
}
pub(crate) fn validate_browser_url(url: &str) -> Result<()> {
if url.is_empty() {
bail!("browser URL is empty");
}
for ch in url.chars() {
if ch.is_control()
|| matches!(
ch,
'"' | '\'' | '|' | '&' | '^' | '`' | '%' | '<' | '>' | ' ' | '\n' | '\r' | '\t'
)
{
bail!("browser URL contains forbidden character {ch:?}");
}
}
let Some((scheme, rest)) = url.split_once("://") else {
bail!("browser URL must include a scheme (https://…)");
};
let scheme = scheme.to_ascii_lowercase();
if scheme != "https" && scheme != "http" {
bail!("browser URL scheme must be https (or http for localhost only)");
}
if rest.is_empty() {
bail!("browser URL is missing a host");
}
let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
if authority.is_empty() {
bail!("browser URL is missing a host");
}
let hostport = authority.rsplit('@').next().unwrap_or(authority);
let host = extract_url_host(hostport);
if host.is_empty() {
bail!("browser URL is missing a host");
}
if host.chars().any(|ch| ch.is_whitespace()) {
bail!("browser URL host must not contain whitespace");
}
if scheme == "http" && !is_loopback_browser_host(host) {
bail!("http browser URLs are only allowed for localhost/127.0.0.1/::1");
}
Ok(())
}
fn extract_url_host(hostport: &str) -> &str {
if let Some(inner) = hostport.strip_prefix('[') {
return inner.split(']').next().unwrap_or(inner);
}
hostport
.rsplit_once(':')
.map(|(host, _port)| host)
.unwrap_or(hostport)
}
fn is_loopback_browser_host(host: &str) -> bool {
let host = host.trim_matches(|c| c == '[' || c == ']');
if host.eq_ignore_ascii_case("localhost") {
return true;
}
match host.parse::<std::net::IpAddr>() {
Ok(ip) => ip.is_loopback(),
Err(_) => false,
}
}
fn percent_encode_query_component(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
use std::fmt::Write as _;
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
fn open_url(url: &str) -> Result<()> {
validate_browser_url(url)?;
#[cfg(target_os = "macos")]
{
std::process::Command::new("open").arg(url).spawn()?;
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open").arg(url).spawn()?;
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("cmd")
.args(["/C", "start", "", url])
.spawn()?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_browser_url_accepts_https() {
validate_browser_url("https://auth.heddle.sh/device").expect("https ok");
validate_browser_url("https://auth.heddle.sh/device?code=ABCD-1234").expect("https+query");
}
#[test]
fn validate_browser_url_accepts_loopback_http() {
validate_browser_url("http://127.0.0.1:8421/path").expect("loopback http");
validate_browser_url("http://localhost:8421/device").expect("localhost http");
validate_browser_url("http://[::1]:8421/path").expect("ipv6 loopback http");
}
#[test]
fn validate_browser_url_rejects_injection_and_dangerous_schemes() {
assert!(
validate_browser_url("https://x.com & calc").is_err(),
"shell metacharacters must be rejected"
);
assert!(validate_browser_url("file:///etc/passwd").is_err());
assert!(validate_browser_url("javascript:alert(1)").is_err());
assert!(validate_browser_url("").is_err());
assert!(validate_browser_url("http://example.com/device").is_err());
assert!(validate_browser_url("https://evil.com\"&calc").is_err());
}
#[test]
fn validate_browser_url_rejects_percent_and_redirection() {
assert!(
validate_browser_url("https://evil.com/%USERPROFILE%").is_err(),
"percent (env-var expansion) must be rejected"
);
assert!(
validate_browser_url("https://evil.com/a<b").is_err(),
"< (redirection) must be rejected"
);
assert!(
validate_browser_url("https://evil.com/a>b").is_err(),
"> (redirection) must be rejected"
);
assert!(validate_browser_url("https://evil.com/?x=%TEMP%>out").is_err());
}
#[test]
fn percent_encode_query_component_encodes_reserved() {
assert_eq!(percent_encode_query_component("ABCD-1234"), "ABCD-1234");
assert_eq!(percent_encode_query_component("a b"), "a%20b");
assert_eq!(percent_encode_query_component("x&y"), "x%26y");
}
#[test]
fn device_auth_mint_request_uses_device_auth_proof_variant() {
let request =
device_auth_mint_biscuit_request("device-123", &[1, 2, 3, 4], vec![5, 6, 7, 8]);
assert!(request.subject.is_empty());
assert!(request.requested_scope.is_empty());
match request.proof.expect("proof variant") {
Proof::DeviceAuth(proof) => {
assert_eq!(proof.device_code, "device-123");
assert_eq!(proof.device_public_key, vec![1, 2, 3, 4]);
assert_eq!(proof.signature, vec![5, 6, 7, 8]);
}
Proof::Keypair(_) => panic!("device login must use DeviceAuthProof"),
}
}
#[test]
fn device_authorization_signature_signs_device_code_challenge() {
let signer = Ed25519Signer::generate().expect("signer");
let signature =
device_authorization_signature("device-123", &signer).expect("device proof");
Ed25519Signer::verify_with_public_key(
b"device:device-123",
signer.public_key(),
&signature,
)
.expect("signature must verify against device challenge");
assert!(
Ed25519Signer::verify_with_public_key(
b"device:other",
signer.public_key(),
&signature,
)
.is_err(),
"signature must commit to the device code",
);
}
#[tokio::test]
async fn device_authorization_timeout_names_stage_and_budget() {
let wait_budget = std::time::Duration::from_secs(42);
let error = wait_for_device_authorization_event(
std::future::pending::<
std::result::Result<Option<DeviceAuthorizationEvent>, HostedError>,
>(),
std::time::Duration::from_millis(1),
wait_budget,
)
.await
.expect_err("the deliberately stalled authorization wait must time out");
assert_eq!(
error.to_string(),
"device authorization approval wait exceeded its 42s authorization-expiry budget; please run `heddle auth login` again"
);
}
#[test]
fn early_remote_deadline_names_stage_and_budget() {
let error = device_authorization_wait_stream_error(
HostedError::Call {
code: api::heddle::api::v1alpha1::CallFailureCode::DeadlineExceeded,
message: "call deadline has elapsed".to_string(),
error: None,
},
std::time::Duration::from_secs(600),
);
assert!(error.to_string().contains(
"device authorization approval stream returned DeadlineExceeded before its 600s authorization-expiry budget elapsed"
));
}
#[test]
fn issue_service_account_request_attaches_pop_fields() {
let signer = Ed25519Signer::generate().expect("signer");
let public_key = signer.public_key().to_vec();
let request = IssueServiceAccountCredentialRequest {
service_account_id: "sa-123".to_string(),
public_key: public_key.clone(),
scope: "repo:heddle/platform/*".to_string(),
ttl_secs: Some(prost_types::Duration {
seconds: SERVICE_TOKEN_TTL_SECS,
nanos: 0,
}),
client_operation_id: "op-1".to_string(),
proof_timestamp_seconds: 0,
proof_signature: Vec::new(),
};
let timestamp = 1_700_000_000;
let request = issue_service_account_credential_request_at(request, &signer, timestamp)
.expect("request with proof");
assert_eq!(request.proof_timestamp_seconds, timestamp);
let signature = &request.proof_signature;
let canonical =
derive_issue_service_account_credential_canonical(timestamp, "sa-123", &public_key);
Ed25519Signer::verify_with_public_key(&canonical, &public_key, signature)
.expect("proof must be signed by the new service-account key");
assert_eq!(request.service_account_id, "sa-123");
assert_eq!(request.public_key, public_key);
assert_eq!(request.scope, "repo:heddle/platform/*");
}
#[test]
fn authenticated_identity_mutations_cannot_use_a_direct_bearer_interceptor() {
let source = include_str!("auth.rs");
assert!(
!source.contains(concat!("IdentityService", "Client")),
"identity mutations must use the transport-neutral hosted client"
);
assert!(
!source.contains(concat!("ton", "ic::")),
"authentication must not depend on the retired product transport"
);
}
#[test]
fn issue_service_account_canonical_commits_to_each_field() {
let public_key = vec![0xAA; 32];
let base =
derive_issue_service_account_credential_canonical(1_700_000_000, "sa-1", &public_key);
assert_ne!(
base,
derive_issue_service_account_credential_canonical(1_700_000_001, "sa-1", &public_key,),
);
assert_ne!(
base,
derive_issue_service_account_credential_canonical(1_700_000_000, "sa-2", &public_key,),
);
assert_ne!(
base,
derive_issue_service_account_credential_canonical(1_700_000_000, "sa-1", &[0xBB; 32],),
);
}
#[test]
fn device_auth_fallback_is_limited_to_lagging_weft_stub() {
let call_error = |code, message: &str| HostedError::Call {
code,
message: message.to_string(),
error: None,
};
let lagging = call_error(
api::heddle::api::v1alpha1::CallFailureCode::Unimplemented,
"MintBiscuit DeviceAuthProof is not implemented yet; use ExchangeDeviceAuthorization for now",
);
assert!(should_fallback_to_exchange_device_authorization(&lagging));
let unrelated = call_error(
api::heddle::api::v1alpha1::CallFailureCode::Unimplemented,
"some other endpoint is missing",
);
assert!(!should_fallback_to_exchange_device_authorization(
&unrelated
));
let denied = call_error(
api::heddle::api::v1alpha1::CallFailureCode::PermissionDenied,
"MintBiscuit DeviceAuthProof signature verification failed",
);
assert!(!should_fallback_to_exchange_device_authorization(&denied));
}
struct TextCtx;
impl CliContext for TextCtx {
fn repo_path(&self) -> Option<&std::path::Path> {
None
}
fn operation_id_wire(&self) -> String {
String::new()
}
fn should_output_json(&self, _repo_config: Option<&repo::Config>) -> bool {
false
}
}
#[test]
fn trust_replace_refuses_active_explicit_config_without_mutating_the_pin() {
with_isolated_home(|| {
let server = "https://api.example";
crate::hosted_runtime::hosted::insert_verified_pin(server, "old-id", &[0x11; 32])
.expect("initial automatic pin");
let before = std::fs::read(crate::hosted_runtime::hosted::descriptor_trust_path())
.expect("read pin store");
let previous_key_id = std::env::var_os("HEDDLE_REMOTE_IROH_DESCRIPTOR_KEY_ID");
let previous_public_key = std::env::var_os("HEDDLE_REMOTE_IROH_DESCRIPTOR_PUBLIC_KEY");
unsafe {
std::env::set_var("HEDDLE_REMOTE_IROH_DESCRIPTOR_KEY_ID", "explicit-id");
std::env::set_var(
"HEDDLE_REMOTE_IROH_DESCRIPTOR_PUBLIC_KEY",
hex::encode([0x33; 32]),
);
}
let result = cmd_auth_trust(
&TextCtx,
AuthTrustCommand::Replace {
server: server.to_string(),
expected_current_public_key: hex::encode([0x11; 32]),
key_id: "new-id".to_string(),
public_key: hex::encode([0x22; 32]),
},
);
unsafe {
match previous_key_id {
Some(value) => std::env::set_var("HEDDLE_REMOTE_IROH_DESCRIPTOR_KEY_ID", value),
None => std::env::remove_var("HEDDLE_REMOTE_IROH_DESCRIPTOR_KEY_ID"),
}
match previous_public_key {
Some(value) => {
std::env::set_var("HEDDLE_REMOTE_IROH_DESCRIPTOR_PUBLIC_KEY", value)
}
None => std::env::remove_var("HEDDLE_REMOTE_IROH_DESCRIPTOR_PUBLIC_KEY"),
}
}
let error = result.expect_err("explicit trust must control replacement");
assert!(
error
.to_string()
.contains("update both explicit values together")
);
assert_eq!(
std::fs::read(crate::hosted_runtime::hosted::descriptor_trust_path())
.expect("read unchanged pin store"),
before
);
});
}
fn with_isolated_home<T>(f: impl FnOnce() -> T) -> T {
let _guard = credentials::lock_test_env();
let temp = tempfile::TempDir::new().expect("temp home");
let prev_home = std::env::var_os("HOME");
let prev_heddle_home = std::env::var_os("HEDDLE_HOME");
unsafe {
std::env::set_var("HOME", temp.path());
std::env::remove_var("HEDDLE_HOME");
}
let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
unsafe {
match prev_home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match prev_heddle_home {
Some(value) => std::env::set_var("HEDDLE_HOME", value),
None => std::env::remove_var("HEDDLE_HOME"),
}
}
drop(temp);
match out {
Ok(value) => value,
Err(payload) => std::panic::resume_unwind(payload),
}
}
fn sample_credential() -> ServerCredential {
ServerCredential {
token: "tkn".to_string(),
subject: "dev".to_string(),
device_id: None,
credential_id: None,
private_key_pem: None,
expires_at: None,
}
}
fn stored_device_parent() -> (ServerCredential, String) {
let signer = Ed25519Signer::generate().expect("device key");
let private_key_pem = signer.to_pem().expect("device PEM");
let expires_at = chrono::Utc::now() + chrono::Duration::hours(2);
let token = biscuit_auth::Biscuit::builder()
.fact(r#"user("alice")"#)
.expect("user fact")
.fact(r#"credential_id("root-credential")"#)
.expect("credential 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(&biscuit_auth::KeyPair::new())
.expect("build parent")
.to_base64()
.expect("encode parent");
(
ServerCredential {
token,
subject: "alice".to_string(),
device_id: Some("device-root".to_string()),
credential_id: Some("root-credential".to_string()),
private_key_pem: Some(private_key_pem.clone()),
expires_at: Some(expires_at.to_rfc3339()),
},
private_key_pem,
)
}
#[test]
fn derive_agent_installs_fresh_pop_child_and_supports_narrower_subderivation() {
with_isolated_home(|| {
let server = "api.S";
let (parent, private_key_pem) = stored_device_parent();
credentials::store_server_credential(server, parent).expect("store parent");
cmd_auth_derive_agent(
server,
Some("agent-parent".to_string()),
3600,
vec!["repo:acme/heddle".to_string()],
vec!["Push".to_string()],
None,
None,
)
.expect("derive and install parent agent");
let installed = credentials::get_server_credential(server)
.expect("load installed child")
.expect("installed child");
let installed_private_key = installed
.private_key_pem
.as_deref()
.expect("derived child stores its own PoP key");
assert_ne!(
installed_private_key, private_key_pem,
"the parent device private key must never be handed to a derived child"
);
let installed_signer =
Ed25519Signer::from_pem(installed_private_key).expect("parse child PoP key");
assert!(
installed.device_id.is_none(),
"a derived PoP key is not the registered root device key"
);
assert!(
installed.credential_id.is_none(),
"derived tokens must not auto-rotate into an unattenuated token"
);
let parsed = biscuit_auth::UnverifiedBiscuit::from_base64(installed.token.as_bytes())
.expect("parse installed child");
assert_eq!(parsed.block_count(), 2);
assert!(
parsed
.print_block_source(1)
.expect("child block")
.contains("agent_scope(\"repo\", \"acme/heddle\")")
);
assert!(
parsed
.print_block_source(1)
.expect("child block")
.contains(&hex::encode(installed_signer.public_key())),
"the child attenuation block must bind the child's PoP public key"
);
cmd_auth_derive_agent(
server,
Some("agent-child".to_string()),
600,
vec!["repo:acme/heddle/subtree".to_string()],
vec!["Push".to_string()],
None,
None,
)
.expect("derive narrower subagent");
let subagent = credentials::get_server_credential(server)
.expect("load subagent")
.expect("installed subagent");
let subagent_private_key = subagent
.private_key_pem
.as_deref()
.expect("subagent stores its own PoP key");
assert_ne!(
subagent_private_key, installed_private_key,
"each delegation hop must generate a fresh private key"
);
let subagent_signer =
Ed25519Signer::from_pem(subagent_private_key).expect("parse subagent PoP key");
let parsed = biscuit_auth::UnverifiedBiscuit::from_base64(subagent.token.as_bytes())
.expect("parse subagent");
assert_eq!(
parsed.block_count(),
3,
"delegation tree adds one block per hop"
);
assert!(
parsed
.print_block_source(2)
.expect("subagent block")
.contains(&hex::encode(subagent_signer.public_key())),
"the subagent attenuation block must bind the subagent's PoP public key"
);
let error = cmd_auth_derive_agent(
server,
Some("agent-widening".to_string()),
300,
vec!["repo:acme".to_string()],
vec!["Push".to_string()],
None,
None,
)
.expect_err("subagent scope widening must be rejected");
assert!(error.to_string().contains("would widen"));
});
}
#[test]
fn derive_agent_out_writes_one_verifiable_hcred_with_a_fresh_child_key() {
with_isolated_home(|| {
let server = "api.S";
let (parent, private_key_pem) = stored_device_parent();
credentials::store_server_credential(server, parent).expect("store parent");
let out = repo::identity::heddle_home_dir().join("agent-export.hcred");
cmd_auth_derive_agent(
server,
Some("agent-export".to_string()),
3600,
vec!["repo:acme/heddle".to_string()],
vec!["Push".to_string()],
None,
Some(&out),
)
.expect("derive portable child credential");
assert!(out.is_file(), "expected a single .hcred file");
let loaded = credential_file::load_credential_file(&out).expect("load written .hcred");
assert_eq!(loaded.server, server);
assert_eq!(loaded.subject, "alice");
assert_eq!(loaded.kind, credential_file::CredentialKind::Agent);
assert_ne!(
loaded.proof_key_pem, private_key_pem,
"the .hcred must carry a fresh child key, never the parent device key"
);
let provenance = loaded.provenance.expect("audit provenance recorded");
assert_eq!(provenance.agent_id.as_deref(), Some("agent-export"));
assert_eq!(
provenance.scopes.as_deref(),
Some(["repo:acme/heddle".to_string()].as_slice())
);
assert_eq!(
provenance.allowed_operations.as_deref(),
Some(["Push".to_string()].as_slice())
);
let child_signer =
Ed25519Signer::from_pem(&loaded.proof_key_pem).expect("parse child key");
let parsed = biscuit_auth::UnverifiedBiscuit::from_base64(loaded.token.as_bytes())
.expect("token is a Biscuit");
assert!(
parsed
.print_block_source(1)
.expect("child block")
.contains(&hex::encode(child_signer.public_key())),
"the token must bind the key packaged alongside it"
);
let error = cmd_auth_derive_agent(
server,
Some("agent-export-again".to_string()),
3600,
vec!["repo:acme/heddle".to_string()],
vec!["Push".to_string()],
None,
Some(&out),
)
.expect_err("an existing credential file must not be overwritten");
assert!(error.to_string().contains("already exists"));
});
}
#[test]
fn derive_agent_allow_flag_cannot_select_unsafe_operations() {
for operation in [
"CreateServiceAccount",
"IssueServiceAccountCredential",
"DeleteRepository",
"DeleteNamespace",
] {
let error = resolve_agent_operations(None, vec![operation.to_string()])
.expect_err("unsafe operation must be outside CLI ceiling");
assert!(
error
.to_string()
.contains("outside the safe agent operation ceiling")
);
}
}
#[test]
fn template_expands_to_a_curated_allow_set() {
let reviewer = resolve_agent_operations(Some(AgentTemplate::Reviewer), Vec::new())
.expect("reviewer template resolves");
assert!(reviewer.contains(&"GetState".to_string()));
assert!(reviewer.contains(&"Pull".to_string()));
assert!(!reviewer.contains(&"Push".to_string()));
assert!(!reviewer.contains(&"UpdateRef".to_string()));
assert!(!reviewer.contains(&"SetContext".to_string()));
let contributor = resolve_agent_operations(Some(AgentTemplate::Contributor), Vec::new())
.expect("contributor template resolves");
assert!(contributor.contains(&"Push".to_string()));
assert!(contributor.contains(&"SetContext".to_string()));
assert!(contributor.contains(&"OpenDiscussion".to_string()));
let ci = resolve_agent_operations(Some(AgentTemplate::CiLanding), Vec::new())
.expect("ci-landing template resolves");
assert!(ci.contains(&"Push".to_string()));
assert!(ci.contains(&"UpdateRef".to_string()));
assert!(ci.contains(&"Pull".to_string()));
assert!(!ci.contains(&"OpenDiscussion".to_string()));
assert!(!ci.contains(&"SetContext".to_string()));
}
#[test]
fn explicit_allow_only_narrows_a_template() {
let narrowed =
resolve_agent_operations(Some(AgentTemplate::Reviewer), vec!["GetState".to_string()])
.expect("narrowing within the template is allowed");
assert_eq!(narrowed, vec!["GetState".to_string()]);
let error =
resolve_agent_operations(Some(AgentTemplate::Reviewer), vec!["Push".to_string()])
.expect_err("a template cannot be widened by --allow");
assert!(error.to_string().contains("outside"));
}
#[test]
fn auth_status_qualifies_a_credential_without_a_proof_key() {
let credential = sample_credential();
let resolved = crate::hosted_runtime::hosted::ResolvedHostedCredential {
token: Some(wire::AuthToken::new(credential.token, "credential-store")),
proof_key_pem: credential.private_key_pem,
renewable: None,
subject: Some(credential.subject),
credential_id: credential.credential_id,
expires_at: credential.expires_at,
source: crate::hosted_runtime::hosted::CredentialSource::Keystore,
};
let output = auth_status_output("api.S", &resolved);
assert!(output.authenticated);
assert_eq!(output.source, "keystore");
assert!(!output.proof_key_available);
assert!(
output
.recommended_action
.as_deref()
.is_some_and(|action| action.contains("auth login --server api.S"))
);
}
#[tokio::test]
async fn login_with_missing_credential_file_fails_closed() {
let error = cmd_auth(
&TextCtx,
AuthCommand::Login {
server: None,
open_browser: false,
credential: Some(std::path::PathBuf::from("/definitely/not/here.hcred")),
},
)
.await
.expect_err("a missing credential file must fail");
assert!(error.to_string().contains("opening credential file"));
}
#[tokio::test]
async fn login_with_device_credential_file_installs_and_links_identity() {
let temp = tempfile::TempDir::new().expect("temp home");
let _guard = credentials::lock_test_env();
let prev_home = std::env::var_os("HOME");
let prev_heddle_home = std::env::var_os("HEDDLE_HOME");
unsafe {
std::env::set_var("HOME", temp.path());
std::env::remove_var("HEDDLE_HOME");
}
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let server = "api.device";
let signer = Ed25519Signer::generate().expect("device key");
let expires_at = chrono::Utc::now() + chrono::Duration::hours(2);
let token = biscuit_auth::Biscuit::builder()
.fact(r#"user("alice")"#)
.expect("user fact")
.fact(r#"credential_id("root-credential")"#)
.expect("credential 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(&biscuit_auth::KeyPair::new())
.expect("build device token")
.to_base64()
.expect("encode device token");
let path = repo::identity::heddle_home_dir().join("device.hcred");
credential_file::write_credential_file(
&path,
&VerifiedCredential {
server: server.to_string(),
kind: CredentialKind::Device,
subject: "alice".to_string(),
token,
proof_key_pem: signer.to_pem().expect("device PEM"),
expires_at: Some(expires_at.to_rfc3339()),
credential_id: Some("root-credential".to_string()),
provenance: None,
},
)
.expect("write device .hcred");
let subject = install_credential_file(&path).expect("install device credential");
assert_eq!(subject, "alice");
assert!(
!crate::hosted_runtime::hosted::descriptor_trust_path().exists(),
".hcred installation must stay offline and must not create descriptor trust",
);
let stored = credentials::get_server_credential(server)
.expect("load stored")
.expect("credential stored under the file's server");
assert_eq!(stored.subject, "alice");
assert_eq!(stored.credential_id.as_deref(), Some("root-credential"));
assert!(
repo::identity::device_identity_path().exists(),
"a device-kind credential registers the host signing identity",
);
}));
unsafe {
match prev_home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match prev_heddle_home {
Some(value) => std::env::set_var("HEDDLE_HOME", value),
None => std::env::remove_var("HEDDLE_HOME"),
}
}
if let Err(payload) = result {
std::panic::resume_unwind(payload);
}
}
#[test]
fn logout_removes_credential_and_device_identity_on_success() {
with_isolated_home(|| {
credentials::store_server_credential("api.S", sample_credential())
.expect("store credential");
let signer = Ed25519Signer::generate().expect("keypair");
repo::identity::link_device_key(
signer.public_key(),
&signer.to_pem().expect("pem"),
"api.S",
)
.expect("link device key");
assert!(repo::identity::device_identity_path().exists());
cmd_auth_logout(&TextCtx, None).expect("logout succeeds");
assert!(
credentials::get_server_credential("api.S")
.expect("load")
.is_none(),
"credential must be removed on a successful logout",
);
assert!(
!repo::identity::device_identity_path().exists(),
"device identity must be removed on a successful logout",
);
});
}
#[test]
fn logout_preserves_credential_when_device_unlink_fails() {
with_isolated_home(|| {
credentials::store_server_credential("api.S", sample_credential())
.expect("store credential");
let device_path = repo::identity::device_identity_path();
std::fs::create_dir_all(device_path.parent().expect("home parent")).expect("home dir");
std::fs::write(
&device_path,
b"!!! definitely not valid device-identity toml !!!",
)
.expect("write corrupt device identity");
let result = cmd_auth_logout(&TextCtx, None);
assert!(
result.is_err(),
"a failed device unlink must fail the logout, not report a clean removal",
);
assert!(
credentials::get_server_credential("api.S")
.expect("load")
.is_some(),
"credential must be preserved when unlink fails, so logout is retryable",
);
assert_eq!(
credentials::default_server().expect("default").as_deref(),
Some("api.S"),
"default server must be preserved so a no-arg retry re-targets the same server",
);
});
}
}