use anyhow::{Context, Result, bail};
use api::heddle::api::v1alpha1::CreateAgentAccountRequest;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::Utc;
use cli_shared::{UserConfig, credentials::ServerCredential};
use crypto::{Ed25519Signer, Signer as _};
use serde::Serialize;
use weft_client_shim::CliContext;
use super::{
HostedAuthMode, HostedSession, agent_node_identity,
auth::{HeadlessTokenMetadata, cmd_auth_derive_agent, headless_token_metadata, resolve_server},
device_flow::restrict_agent_account_root,
hosted::{canonical_server_authority, resolve_hosted_credential},
identity_server,
identity_state::{self, ClaimState},
root_mint::{is_local_agent_root, local_agent_credential_needs_refresh, mint_agent_root},
};
use crate::cli::IdentityCommands;
const DEFAULT_AGENT_TTL_SECS: u64 = 60 * 60;
const MAX_CLAIM_TTL_SECS: u64 = 60 * 60;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum EnsureAction {
Reuse,
Derive,
Provision,
RequireInvite,
}
#[derive(Serialize)]
struct IdentityOutput<'a> {
output_kind: &'a str,
outcome: &'a str,
server: &'a str,
subject: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
node_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
claim_url: Option<&'a str>,
}
pub(crate) async fn cmd_identity(
ctx: &(dyn CliContext + 'static),
command: IdentityCommands,
) -> Result<()> {
match command {
IdentityCommands::Ensure { server, invite } => ensure(ctx, server, invite).await,
IdentityCommands::ClaimLink { server, ttl_secs } => claim_link(ctx, server, ttl_secs).await,
IdentityCommands::Serve { server } => identity_server::serve(server).await,
}
}
async fn ensure(
ctx: &(dyn CliContext + 'static),
server: Option<String>,
invite: Option<String>,
) -> Result<()> {
let server = resolve_server(server.as_deref())?;
let resolved = resolve_hosted_credential(Some(&server))?;
let metadata = resolved
.token
.as_ref()
.map(|token| headless_token_metadata(&token.id))
.transpose()?;
match ensure_action(
metadata.as_ref().map(|value| value.is_derived),
invite.is_some(),
metadata
.as_ref()
.is_some_and(|value| is_local_agent_root(&value.subject, &value.proof_public_key_hex)),
) {
EnsureAction::Reuse => {
let metadata = metadata
.ok_or_else(|| anyhow::anyhow!("identity decision lost credential metadata"))?;
let metadata = refresh_expired_local_agent(&server, &metadata)?;
print_identity(
ctx,
"identity_ensure",
"reused",
&server,
&metadata.subject,
None,
None,
)
}
EnsureAction::Derive => {
cmd_auth_derive_agent(
&server,
None,
DEFAULT_AGENT_TTL_SECS,
Vec::new(),
Vec::new(),
None,
None,
true,
)?;
let store = cli_shared::credentials::load_credentials()?;
let derived = store.servers.get(&server).ok_or_else(|| {
anyhow::anyhow!("derived credential was not installed for {server}")
})?;
let metadata = headless_token_metadata(&derived.token)?;
print_identity(
ctx,
"identity_ensure",
"derived",
&server,
&metadata.subject,
None,
None,
)
}
EnsureAction::Provision => {
let invite = invite.ok_or_else(|| anyhow::anyhow!("identity decision lost invite"))?;
create_on_behalf(ctx, server, invite).await
}
EnsureAction::RequireInvite => bail!(
"no existing account credential for {server}; supply --invite to create a placeholder human account"
),
}
}
pub(crate) fn ensure_action(
derived: Option<bool>,
has_invite: bool,
local_agent_root: bool,
) -> EnsureAction {
match derived {
Some(true) => EnsureAction::Reuse,
Some(false) if local_agent_root => EnsureAction::Reuse,
Some(false) => EnsureAction::Derive,
None if has_invite => EnsureAction::Provision,
None => EnsureAction::RequireInvite,
}
}
fn refresh_expired_local_agent(
server: &str,
metadata: &HeadlessTokenMetadata,
) -> Result<HeadlessTokenMetadata> {
if !is_local_agent_root(&metadata.subject, &metadata.proof_public_key_hex) {
return Ok(metadata.clone());
}
if !local_agent_credential_needs_refresh(metadata.expires_at.as_deref(), Utc::now()) {
return Ok(metadata.clone());
}
let identity = agent_node_identity::load_or_create()?;
let seed = identity.secret_key().to_bytes();
let signer = Ed25519Signer::from_seed(&seed)
.context("deriving the agent credential key from the persisted node identity")?;
let node_id = identity.node_id().to_string();
if hex::encode(signer.public_key()) != node_id
|| !metadata.proof_public_key_hex.eq_ignore_ascii_case(&node_id)
{
bail!("expired local agent root is not bound to this node's Iroh seed");
}
let root = mint_agent_root(&seed).context("reminting the expired local agent root")?;
if root.public_key_hex() != node_id {
bail!("reminted agent independent root is not bound to this node key");
}
let restricted = restrict_agent_account_root(&root.token, &signer, root.expires_at)
.context("reapplying the local agent deny floor after remint")?;
let refreshed =
headless_token_metadata(&restricted).context("validating the reminted agent capability")?;
if !refreshed
.proof_public_key_hex
.eq_ignore_ascii_case(&node_id)
|| refreshed.subject != root.subject
{
bail!("reminted agent capability is not bound to this node key");
}
cli_shared::credentials::store_server_credential(
server,
ServerCredential {
token: restricted,
subject: refreshed.subject.clone(),
device_id: None,
credential_id: None,
private_key_pem: Some(root.private_key_pem),
expires_at: Some(root.expires_at.to_rfc3339()),
},
)?;
Ok(refreshed)
}
async fn create_on_behalf(
ctx: &(dyn CliContext + 'static),
server: String,
invite: String,
) -> Result<()> {
let identity = agent_node_identity::load_or_create()?;
let seed = identity.secret_key().to_bytes();
let signer = Ed25519Signer::from_seed(&seed)
.context("deriving the agent credential key from the persisted node identity")?;
let node_id = identity.node_id().to_string();
if hex::encode(signer.public_key()) != node_id {
bail!("persisted Iroh node key does not map to the agent credential key");
}
let root = mint_agent_root(&seed).context("minting the agent independent root locally")?;
if root.public_key_hex() != node_id {
bail!("agent independent root is not bound to this node key");
}
let user_config = UserConfig::load_default()?;
let session = HostedSession::build(
&user_config,
Some(server.clone()),
HostedAuthMode::PresentedRoot {
token: root.token.clone(),
proof_key_pem: root.private_key_pem.clone(),
subject: root.subject.clone(),
},
)?;
let mut client = session.connect(([127, 0, 0, 1], 0).into()).await?;
let operation_id = match ctx.operation_id_wire() {
value if value.is_empty() => uuid::Uuid::new_v4().to_string(),
value => value,
};
let response = client
.create_agent_account(CreateAgentAccountRequest {
invite_code: invite,
agent_public_key: root.public_key.to_vec(),
client_operation_id: operation_id,
})
.await
.map_err(|error| {
anyhow::anyhow!("creating placeholder account on behalf of human: {error}")
});
client.close().await;
let response = response?;
let restricted = restrict_agent_account_root(&root.token, &signer, root.expires_at)
.context("applying the local agent deny floor and safe operation ceiling")?;
let metadata = headless_token_metadata(&restricted)
.context("validating the restricted client-minted agent capability")?;
if !metadata.proof_public_key_hex.eq_ignore_ascii_case(&node_id)
|| metadata.subject != root.subject
{
bail!("client-minted agent capability is not bound to this node key");
}
cli_shared::credentials::store_server_credential(
&server,
ServerCredential {
token: restricted,
subject: metadata.subject.clone(),
device_id: None,
credential_id: None,
private_key_pem: Some(root.private_key_pem),
expires_at: Some(root.expires_at.to_rfc3339()),
},
)?;
let owner_id = uuid::Uuid::parse_str(&response.account_id)
.context("server returned a non-UUID account identity")?;
let state = ClaimState::new(
server.clone(),
owner_id,
metadata.subject.clone(),
response.pet_name,
node_id,
);
identity_state::store(&state)?;
let (node_id, url) = reissue(&server, 900)?;
identity_server::ensure_running(&server).await?;
print_identity(
ctx,
"identity_ensure",
"created_on_behalf",
&server,
&metadata.subject,
Some(&node_id),
Some(&url),
)
}
async fn claim_link(
ctx: &(dyn CliContext + 'static),
server: Option<String>,
ttl_secs: u64,
) -> Result<()> {
let state = identity_state::load()?.ok_or_else(|| {
anyhow::anyhow!("no agent-created placeholder account exists on this machine")
})?;
let server = server.unwrap_or_else(|| state.server.clone());
if server != state.server {
bail!("claim state belongs to {}, not {server}", state.server);
}
let subject = state.subject.clone();
let (node_id, url) = reissue(&server, ttl_secs)?;
identity_server::ensure_running(&server).await?;
print_identity(
ctx,
"identity_claim_link",
"claim_link_reissued",
&server,
&subject,
Some(&node_id),
Some(&url),
)
}
fn reissue(server: &str, ttl_secs: u64) -> Result<(String, String)> {
if ttl_secs == 0 || ttl_secs > MAX_CLAIM_TTL_SECS {
bail!("--ttl must be between 1 and {MAX_CLAIM_TTL_SECS} seconds");
}
let mut state = identity_state::load()?
.ok_or_else(|| anyhow::anyhow!("no claimable agent identity is stored"))?;
if state.server != server {
bail!("claim state belongs to {}, not {server}", state.server);
}
let origin = claim_web_origin(server)?;
let mut secret = [0_u8; 32];
getrandom::fill(&mut secret).context("generating claim secret")?;
let ttl_millis = i64::try_from(ttl_secs)?
.checked_mul(1_000)
.context("claim TTL overflow")?;
let expires = chrono::Utc::now()
.timestamp_millis()
.checked_add(ttl_millis)
.context("claim expiry overflow")?;
if !state.reissue(&secret, expires) {
bail!("account claim is already complete");
}
identity_state::store(&state)?;
let encoded_secret = URL_SAFE_NO_PAD.encode(secret);
let node_id = state.node_id;
let url = claim_url(&origin, &node_id, &encoded_secret);
Ok((node_id, url))
}
#[cfg(test)]
pub(crate) fn claim_link_url(server: &str, node_id: &str, encoded_secret: &str) -> Result<String> {
Ok(claim_url(
&claim_web_origin(server)?,
node_id,
encoded_secret,
))
}
pub(crate) fn claim_web_origin(server: &str) -> Result<String> {
let authority = canonical_server_authority(server).with_context(|| {
format!("refusing to mint a claim URL for {server}: need an HTTPS server origin")
})?;
let url = reqwest::Url::parse(&authority).context("claim server origin is not a valid URL")?;
let Some(host) = url.host_str() else {
bail!("refusing to mint a claim URL for {server}: origin has no host");
};
if hosted_heddle_web_host(host) {
return Ok("https://heddle.sh".to_string());
}
Ok(authority)
}
fn claim_url(origin: &str, node_id: &str, encoded_secret: &str) -> String {
format!("{origin}/claim/hcl1.{node_id}.{encoded_secret}")
}
fn hosted_heddle_web_host(host: &str) -> bool {
let host = host.to_ascii_lowercase();
host == "heddle.sh" || host.ends_with(".heddle.sh")
}
fn print_identity(
ctx: &(dyn CliContext + 'static),
output_kind: &str,
outcome: &str,
server: &str,
subject: &str,
node_id: Option<&str>,
claim_url: Option<&str>,
) -> Result<()> {
if ctx.should_output_json(None) {
println!(
"{}",
serde_json::to_string(&IdentityOutput {
output_kind,
outcome,
server,
subject,
node_id,
claim_url,
})?
);
} else {
println!("Identity: {outcome}");
println!("Server: {server}");
println!("Subject: {subject}");
if let Some(node_id) = node_id {
println!("NodeId: {node_id}");
}
if let Some(claim_url) = claim_url {
println!("\nOpen this short-lived claim link:\n\n{claim_url}\n");
}
}
Ok(())
}