use std::{path::PathBuf, sync::Arc, time::Duration};
use algae_cli::{
passphrases::{Passphrase, PassphraseArgs},
streams::decrypt_stream,
};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use bestool_canopy::{
TAILSCALE_URL, device_identity,
registration::{self, Registration},
tailscale_client,
};
use bestool_tamanu::server_info::generate_device_key_pem;
use clap::Parser;
use miette::{IntoDiagnostic as _, Result, WrapErr as _, bail};
use p256::{
SecretKey,
ecdsa::{Signature, SigningKey, signature::Signer as _},
elliptic_curve::pkcs8::{DecodePrivateKey as _, EncodePublicKey as _},
};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use tracing::debug;
use uuid::Uuid;
use crate::actions::Context;
#[derive(Debug, Clone, Parser)]
pub struct RegisterArgs {
pub ticket: Option<String>,
#[arg(long, value_name = "DIR")]
pub config: Option<PathBuf>,
#[command(flatten)]
#[allow(missing_docs, reason = "don't interfere with clap")]
pub passphrase: PassphraseArgs,
}
#[derive(Deserialize)]
struct EnrollTicket {
v: String,
api_url: String,
server_id: String,
token: String,
}
#[derive(Serialize)]
pub(crate) struct BeginRequest<'a> {
pub(crate) server_id: &'a str,
pub(crate) token: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) spki: Option<&'a str>,
}
#[derive(Deserialize)]
pub(crate) struct BeginResponse {
pub(crate) nonce: String,
#[serde(default)]
pub(crate) channel_binding_required: bool,
}
#[derive(Serialize)]
pub(crate) struct CompleteRequest<'a> {
pub(crate) server_id: &'a str,
pub(crate) nonce: &'a str,
pub(crate) signature: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) spki: Option<&'a str>,
}
enum Transport {
Tailscale(reqwest::Client),
Mtls(reqwest::Client),
}
impl Transport {
fn client(&self) -> &reqwest::Client {
match self {
Transport::Tailscale(c) | Transport::Mtls(c) => c,
}
}
fn carries_spki_in_body(&self) -> bool {
matches!(self, Transport::Tailscale(_))
}
fn url(&self, api_url: &Url, step: &str) -> Result<Url> {
match self {
Transport::Tailscale(_) => format!("{TAILSCALE_URL}/public/servers/register/{step}")
.parse()
.into_diagnostic()
.wrap_err_with(|| format!("building tailscale register/{step} URL")),
Transport::Mtls(_) => api_url
.join(&format!("/servers/register/{step}"))
.into_diagnostic()
.wrap_err_with(|| format!("building register/{step} URL")),
}
}
}
#[derive(Deserialize)]
pub(crate) struct CompleteResponse {
pub(crate) server_id: String,
pub(crate) device_id: String,
}
#[derive(Deserialize)]
struct Problem {
title: Option<String>,
detail: Option<String>,
}
pub async fn run(args: RegisterArgs, _ctx: Context) -> Result<()> {
let RegisterArgs {
ticket,
config,
passphrase,
} = args;
let dir = config.clone().unwrap_or_else(registration::default_dir);
super::ensure_writable_or_reexec(&dir)?;
let ticket_b64 = match ticket {
Some(t) => t,
None => super::read_stdin("ticket")?,
};
let encrypted = super::decode_base64(ticket_b64.trim())?;
if !is_age_ciphertext(&encrypted) {
bail!("ticket is not a valid Canopy enrollment ticket");
}
let pass = passphrase.require().await?;
let ticket = decrypt_ticket(&encrypted, pass).await?;
if ticket.v != "enroll-1" {
bail!(
"unsupported enrollment ticket version {:?} (expected \"enroll-1\")",
ticket.v
);
}
let api_url: Url = ticket
.api_url
.parse()
.into_diagnostic()
.wrap_err_with(|| format!("ticket api_url is not a valid URL: {}", ticket.api_url))?;
if api_url.scheme() != "https" {
bail!("ticket api_url must be https, got {:?}", api_url.scheme());
}
let server_id = Uuid::parse_str(&ticket.server_id)
.into_diagnostic()
.wrap_err("ticket server_id is not a valid UUID")?;
debug!(%api_url, %server_id, "decrypted enrollment ticket");
let existing = super::load_registration(config.as_deref())
.await
.wrap_err("reading existing canopy registration")?;
if let Some(reg) = &existing
&& reg.server_id.as_deref() == Some(ticket.server_id.as_str())
&& let Some(device_id) = ®.device_id
{
println!("Already enrolled with Canopy.");
println!(" server id: {}", ticket.server_id);
println!(" device id: {device_id}");
return Ok(());
}
let device_key_pem = match existing.as_ref().and_then(|r| r.device_key.clone()) {
Some(pem) => pem,
None => generate_device_key_pem()?,
};
let spki_der = spki_der(&device_key_pem)?;
let signing_key = SigningKey::from_pkcs8_pem(&device_key_pem)
.into_diagnostic()
.wrap_err("loading device key for signing")?;
let factory: bestool_canopy::ClientBuilderFactory = Arc::new(crate::http::client_builder);
let transport = match tailscale_client(&factory).await {
Some(client) => {
debug!("enrolling over the canopy tailnet");
Transport::Tailscale(client)
}
None => {
debug!("tailnet unreachable; enrolling over public mTLS");
let identity = device_identity(&device_key_pem)?;
let client = crate::http::client_builder()
.identity(identity)
.use_rustls_tls()
.timeout(Duration::from_secs(30))
.build()
.into_diagnostic()
.wrap_err("building mTLS HTTP client")?;
Transport::Mtls(client)
}
};
let spki_b64 = STANDARD.encode(&spki_der);
let begin = begin(
&transport,
&api_url,
&ticket.server_id,
&ticket.token,
&spki_b64,
)
.await?;
if begin.channel_binding_required {
bail!(
"this Canopy server requires TLS channel binding, which this version of bestool does not support yet"
);
}
let nonce_bytes = STANDARD
.decode(begin.nonce.trim())
.into_diagnostic()
.wrap_err("decoding challenge nonce")?;
let transcript = build_transcript(&nonce_bytes, &server_id, &spki_der);
let signature: Signature = signing_key.sign(&transcript);
let signature_b64 = STANDARD.encode(signature.to_der().as_bytes());
let complete = complete(
&transport,
&api_url,
&ticket.server_id,
&begin.nonce,
&signature_b64,
&spki_b64,
)
.await?;
let registration = Registration {
server_id: Some(complete.server_id.clone()),
device_key: Some(device_key_pem),
device_id: Some(complete.device_id.clone()),
api_url: Some(api_url.to_string()),
..Registration::default()
};
registration::store_in(&dir, ®istration)
.await
.wrap_err("storing canopy registration")?;
println!("Enrolled with Canopy.");
println!(" server id: {}", complete.server_id);
println!(" device id: {}", complete.device_id);
Ok(())
}
fn is_age_ciphertext(bytes: &[u8]) -> bool {
bytes.starts_with(b"age-encryption.org/v1")
}
async fn decrypt_ticket(encrypted: &[u8], pass: Passphrase) -> Result<EnrollTicket> {
let reader = futures::io::Cursor::new(encrypted.to_vec());
let mut plaintext: Vec<u8> = Vec::new();
decrypt_stream(reader, &mut plaintext, Box::new(pass))
.await
.wrap_err("decrypting enrollment ticket (wrong passphrase?)")?;
serde_json::from_slice(&plaintext)
.into_diagnostic()
.wrap_err("parsing decrypted enrollment ticket")
}
fn spki_der(device_key_pem: &str) -> Result<Vec<u8>> {
let secret = SecretKey::from_pkcs8_pem(device_key_pem)
.into_diagnostic()
.wrap_err("parsing device key")?;
let der = secret
.public_key()
.to_public_key_der()
.into_diagnostic()
.wrap_err("encoding device public key (SPKI)")?;
Ok(der.as_bytes().to_vec())
}
fn build_transcript(nonce: &[u8], server_id: &Uuid, spki_der: &[u8]) -> Vec<u8> {
let mut transcript = Vec::with_capacity(nonce.len() + 16 + spki_der.len());
transcript.extend_from_slice(nonce);
transcript.extend_from_slice(server_id.as_bytes());
transcript.extend_from_slice(spki_der);
transcript
}
async fn begin(
transport: &Transport,
api_url: &Url,
server_id: &str,
token: &str,
spki: &str,
) -> Result<BeginResponse> {
let url = transport.url(api_url, "begin")?;
let resp = transport
.client()
.post(url)
.json(&BeginRequest {
server_id,
token,
spki: transport.carries_spki_in_body().then_some(spki),
})
.send()
.await
.into_diagnostic()
.wrap_err("calling register/begin")?;
parse_json_or_problem(resp, "register/begin").await
}
async fn complete(
transport: &Transport,
api_url: &Url,
server_id: &str,
nonce: &str,
signature: &str,
spki: &str,
) -> Result<CompleteResponse> {
let url = transport.url(api_url, "complete")?;
let resp = transport
.client()
.post(url)
.json(&CompleteRequest {
server_id,
nonce,
signature,
spki: transport.carries_spki_in_body().then_some(spki),
})
.send()
.await
.into_diagnostic()
.wrap_err("calling register/complete")?;
parse_json_or_problem(resp, "register/complete").await
}
async fn parse_json_or_problem<T: serde::de::DeserializeOwned>(
resp: reqwest::Response,
what: &str,
) -> Result<T> {
let status = resp.status();
let body = resp
.bytes()
.await
.into_diagnostic()
.wrap_err_with(|| format!("reading {what} response"))?;
if status.is_success() {
return serde_json::from_slice(&body)
.into_diagnostic()
.wrap_err_with(|| format!("parsing {what} response"));
}
if let Ok(problem) = serde_json::from_slice::<Problem>(&body) {
let title = problem.title.unwrap_or_else(|| "enrollment failed".into());
match problem.detail {
Some(detail) => bail!("canopy {what} failed ({status}): {title}: {detail}"),
None => bail!("canopy {what} failed ({status}): {title}"),
}
}
let text = String::from_utf8_lossy(&body);
bail!("canopy {what} failed ({status}): {text}")
}
#[cfg(test)]
mod tests {
use age::secrecy::SecretString;
use algae_cli::streams::encrypt_stream;
use p256::{
ecdsa::{VerifyingKey, signature::Verifier as _},
elliptic_curve::{
pkcs8::{DecodePublicKey as _, EncodePrivateKey as _, LineEnding},
rand_core::OsRng,
},
};
use super::*;
const SAMPLE_TICKET: &str = r#"{"v":"enroll-1","api_url":"https://canopy.example","server_id":"7deb2793-0425-427e-8a19-7213946fa9be","token":"c2VjcmV0"}"#;
fn test_key_pem() -> String {
SecretKey::random(&mut OsRng)
.to_pkcs8_pem(LineEnding::LF)
.unwrap()
.to_string()
}
#[test]
fn transport_routes_and_spki_placement() {
let api: Url = "https://canopy.example".parse().unwrap();
let ts = Transport::Tailscale(reqwest::Client::new());
assert_eq!(
ts.url(&api, "begin").unwrap().as_str(),
"https://canopy.tail53aef.ts.net/public/servers/register/begin"
);
assert!(ts.carries_spki_in_body());
let mtls = Transport::Mtls(reqwest::Client::new());
assert_eq!(
mtls.url(&api, "complete").unwrap().as_str(),
"https://canopy.example/servers/register/complete"
);
assert!(!mtls.carries_spki_in_body());
}
#[test]
fn build_transcript_layout() {
let nonce = [0xAAu8; 32];
let server_id = Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef);
let spki = [0xBBu8; 91];
let transcript = build_transcript(&nonce, &server_id, &spki);
assert_eq!(transcript.len(), 32 + 16 + 91);
assert_eq!(&transcript[..32], &nonce);
assert_eq!(&transcript[32..48], server_id.as_bytes());
assert_eq!(&transcript[48..], &spki);
}
#[tokio::test]
async fn rejects_non_age_ticket_before_decrypting() {
assert!(!is_age_ciphertext(b"foo"));
let pass_phrase = SecretString::from("correct-horse-battery-staple");
let recipient = Passphrase::new(pass_phrase);
let mut cursor = futures::io::Cursor::new(Vec::new());
encrypt_stream(SAMPLE_TICKET.as_bytes(), &mut cursor, Box::new(recipient))
.await
.unwrap();
assert!(is_age_ciphertext(&cursor.into_inner()));
}
#[tokio::test]
async fn ticket_roundtrip_decrypts_and_parses() {
let pass_phrase = SecretString::from("correct-horse-battery-staple");
let recipient = Passphrase::new(pass_phrase.clone());
let mut cursor = futures::io::Cursor::new(Vec::new());
encrypt_stream(SAMPLE_TICKET.as_bytes(), &mut cursor, Box::new(recipient))
.await
.unwrap();
let encrypted = cursor.into_inner();
let ticket = decrypt_ticket(&encrypted, Passphrase::new(pass_phrase))
.await
.unwrap();
assert_eq!(ticket.v, "enroll-1");
assert_eq!(ticket.api_url, "https://canopy.example");
assert_eq!(ticket.server_id, "7deb2793-0425-427e-8a19-7213946fa9be");
assert_eq!(ticket.token, "c2VjcmV0");
}
#[tokio::test]
async fn ticket_decrypt_fails_with_wrong_passphrase() {
let recipient = Passphrase::new(SecretString::from("right-passphrase-here-please"));
let mut cursor = futures::io::Cursor::new(Vec::new());
encrypt_stream(SAMPLE_TICKET.as_bytes(), &mut cursor, Box::new(recipient))
.await
.unwrap();
let encrypted = cursor.into_inner();
let wrong = Passphrase::new(SecretString::from("wrong-passphrase-entirely-no"));
assert!(decrypt_ticket(&encrypted, wrong).await.is_err());
}
#[test]
fn signature_over_transcript_verifies_against_spki() {
let pem = test_key_pem();
let spki = spki_der(&pem).unwrap();
let signing_key = SigningKey::from_pkcs8_pem(&pem).unwrap();
let nonce = [0x11u8; 32];
let server_id = Uuid::from_u128(0xDEAD_BEEF);
let transcript = build_transcript(&nonce, &server_id, &spki);
let signature: Signature = signing_key.sign(&transcript);
let der = signature.to_der();
let parsed = Signature::from_der(der.as_bytes()).unwrap();
let verifying = VerifyingKey::from_public_key_der(&spki).unwrap();
verifying.verify(&transcript, &parsed).unwrap();
}
}