use std::path::PathBuf;
use boatramp_core::authz::AuthzPolicy;
use boatramp_core::cose::{LocalSigner, Signer, TokenAlg};
use clap::Subcommand;
use crate::client;
use crate::config::ProjectConfig;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("invalid private key: {0}")]
InvalidPrivateKey(String),
#[error("reading {path}: {source}")]
ReadPolicyFile {
path: String,
#[source]
source: std::io::Error,
},
#[error("{path} is not a valid AuthzPolicy: {source}")]
InvalidPolicy {
path: String,
#[source]
source: serde_json::Error,
},
#[error(transparent)]
Client(#[from] crate::client::ClientError),
#[error(transparent)]
Http(#[from] reqwest::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error("bootstrap attestation: {0}")]
Attestation(String),
}
type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, clap::Args)]
pub struct AuthArgs {
#[arg(long, env = "BOATRAMP_SERVER", global = true)]
server: Option<String>,
#[command(subcommand)]
command: AuthCommand,
}
#[derive(Debug, Subcommand)]
enum AuthCommand {
Init,
Pubkey {
#[arg(long, env = "BOATRAMP_AUTH_ROOT_PRIVATE_KEY")]
private_key: String,
},
Pin {
#[arg(long, env = "BOATRAMP_ROOT_PUBKEY")]
root_pubkey: String,
},
#[command(subcommand)]
Policy(PolicyCommand),
RotateRoot {
#[arg(long, value_name = "PUBKEY")]
add: Option<String>,
#[arg(long, value_name = "PUBKEY")]
retire: Option<String>,
},
}
#[derive(Debug, Subcommand)]
enum PolicyCommand {
Get,
Set {
file: PathBuf,
},
}
pub async fn run(args: AuthArgs, config: &ProjectConfig) -> Result<()> {
match args.command {
AuthCommand::Init => {
let signer = LocalSigner::generate(TokenAlg::Es256);
println!("# boatramp control-plane root key (COSE/CWT — ES256)");
println!("# Issuing node — keep secret (mints tokens, runs OIDC exchange):");
println!("BOATRAMP_AUTH_ROOT_PRIVATE_KEY={}", signer.private_hex());
println!("# Verify-only nodes / public trust anchor:");
println!(
"BOATRAMP_AUTH_ROOT_PUBLIC_KEY={}",
signer.public_key().to_hex()
);
eprintln!();
eprintln!(
"Set this private key on the server to enable auth. On a fresh deploy, \
mint the FIRST token by also setting a single-use bootstrap secret \
(`serve --bootstrap-secret <secret>` / BOATRAMP_BOOTSTRAP_SECRET), then:\n \
BOATRAMP_BOOTSTRAP_SECRET=<secret> boatramp token bootstrap --role admin\n\
Use that admin token to mint scoped tokens (`boatramp token create \
publisher:<site>`), then unset the bootstrap secret on the server."
);
}
AuthCommand::Pubkey { private_key } => {
let signer = LocalSigner::from_private_hex(&private_key)
.map_err(|e| Error::InvalidPrivateKey(e.to_string()))?;
println!("{}", signer.public_key().to_hex());
}
AuthCommand::Pin { root_pubkey } => run_pin(args.server, root_pubkey, config).await?,
AuthCommand::Policy(command) => run_policy(command, args.server, config).await?,
AuthCommand::RotateRoot { add, retire } => {
run_rotate_root(add, retire, args.server, config).await?;
}
}
Ok(())
}
async fn run_pin(
server: Option<String>,
root_pubkey: String,
config: &ProjectConfig,
) -> Result<()> {
use std::sync::{Arc, Mutex};
let server = client::resolve_server(server, config)?;
let root = boatramp_core::cose::TokenPublicKey::from_hex(root_pubkey.trim())
.map_err(|e| Error::Attestation(format!("invalid --root-pubkey: {e}")))?;
let captured = Arc::new(Mutex::new(None));
let tls = boatramp_rpktls::client_config_capturing(captured.clone())
.map_err(|e| Error::Attestation(e.to_string()))?;
let http = reqwest::Client::builder()
.use_preconfigured_tls(tls)
.build()?;
let attestation = http
.get(format!("{server}/.well-known/boatramp-bootstrap-identity"))
.send()
.await?
.error_for_status()
.map_err(|_| {
Error::Attestation(format!(
"{server} served no bootstrap attestation (is it `--tls rpk` with a root key?)"
))
})?
.text()
.await?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let attested_hex = boatramp_core::cose::verify_attestation(attestation.trim(), &root, now)
.map_err(|e| Error::Attestation(format!("root signature/validity failed: {e}")))?;
let attested = boatramp_rpktls::parse_public_key(&attested_hex)
.map_err(|e| Error::Attestation(e.to_string()))?;
let presented = captured
.lock()
.expect("capture slot")
.clone()
.ok_or_else(|| Error::Attestation("server presented no key".into()))?;
if presented != attested {
return Err(Error::Attestation(
"the attestation does not match the key the server presented".into(),
));
}
eprintln!("verified {server} against the root key. Export this to pin it:");
println!("BOATRAMP_SERVER_PUBKEY={attested_hex}");
Ok(())
}
async fn run_rotate_root(
add: Option<String>,
retire: Option<String>,
server: Option<String>,
config: &ProjectConfig,
) -> Result<()> {
let server = client::resolve_server(server, config)?;
let http = client::http_client(client::token(config).as_deref());
if let Some(pubkey) = add.as_deref() {
let pubkey = pubkey.trim();
boatramp_core::cose::TokenPublicKey::from_hex(pubkey)
.map_err(|e| Error::Attestation(format!("invalid --add pubkey: {e}")))?;
http.put(format!("{server}/api/auth/root"))
.json(&serde_json::json!({ "pubkey": pubkey }))
.send()
.await?
.error_for_status()?;
eprintln!("trusted new root anchor {pubkey} (make-before-break).");
eprintln!(
"Now re-point [serve.signer] / BOATRAMP_AUTH_ROOT_PRIVATE_KEY to the new key so new \
tokens + attestations use it, wait for every node to converge, then retire the old \
anchor:\n boatramp auth rotate-root --retire <old-pubkey>"
);
}
if let Some(pubkey) = retire.as_deref() {
let pubkey = pubkey.trim();
http.delete(format!("{server}/api/auth/root/{pubkey}"))
.send()
.await?
.error_for_status()?;
eprintln!("retired root anchor {pubkey}.");
}
if add.is_none() && retire.is_none() {
let anchors: Vec<String> = http
.get(format!("{server}/api/auth/root"))
.send()
.await?
.error_for_status()?
.json()
.await?;
if anchors.is_empty() {
println!("(no extra root anchors — running on the primary root key only)");
} else {
for a in anchors {
println!("{a}");
}
}
}
Ok(())
}
async fn run_policy(
command: PolicyCommand,
server: Option<String>,
config: &ProjectConfig,
) -> Result<()> {
let server = client::resolve_server(server, config)?;
let http = client::http_client(client::token(config).as_deref());
match command {
PolicyCommand::Get => {
let policy: AuthzPolicy = http
.get(format!("{server}/api/authz/policy"))
.send()
.await?
.error_for_status()?
.json()
.await?;
println!("{}", serde_json::to_string_pretty(&policy)?);
}
PolicyCommand::Set { file } => {
let raw = std::fs::read_to_string(&file).map_err(|e| Error::ReadPolicyFile {
path: file.display().to_string(),
source: e,
})?;
let policy: AuthzPolicy =
serde_json::from_str(&raw).map_err(|e| Error::InvalidPolicy {
path: file.display().to_string(),
source: e,
})?;
http.put(format!("{server}/api/authz/policy"))
.json(&policy)
.send()
.await?
.error_for_status()?;
println!("policy updated");
}
}
Ok(())
}