166 lines
6.5 KiB
Rust
166 lines
6.5 KiB
Rust
//! Issuer-key endpoints — public read of the public key, admin-only import.
|
|
//!
|
|
//! Used exactly once, by exactly one operator: when bootstrapping a
|
|
//! "master Keysat" instance (the one that issues licenses for the Keysat
|
|
//! package itself). The master operator pre-generated an Ed25519 keypair
|
|
//! offline; this endpoint takes the PEM-encoded private half and stores
|
|
//! it as the daemon's signing keypair, replacing the auto-generated one
|
|
//! that gets created on first boot.
|
|
//!
|
|
//! ## Why this isn't a StartOS Action
|
|
//!
|
|
//! 95% of Keysat operators install Keysat to sell their own software.
|
|
//! Their auto-generated issuer key is exactly what they want; they never
|
|
//! need this endpoint. Surfacing an "import issuer key" button in every
|
|
//! operator's StartOS Actions tab would create cognitive load (am I
|
|
//! supposed to do this?) for zero benefit. So this lives as an admin
|
|
//! API endpoint only — invisible by default, callable via curl during
|
|
//! the master-bootstrap procedure documented in
|
|
//! `MASTER_KEYPAIR_PROCEDURE.md`.
|
|
//!
|
|
//! ## Safety guards
|
|
//!
|
|
//! Replacing the issuer key after licenses have been issued would
|
|
//! invalidate every previously-signed customer license. To prevent that
|
|
//! footgun, the endpoint refuses if any license rows exist in the
|
|
//! database. The master Keysat instance hasn't issued anything when it
|
|
//! gets bootstrapped, so this guard never trips during legitimate use
|
|
//! and prevents the worst-case mistake.
|
|
//!
|
|
//! ## After successful import
|
|
//!
|
|
//! The new keypair lands in the `server_keys` table immediately, but the
|
|
//! daemon's in-memory `AppState.keypair` still holds the old one until
|
|
//! restart. The endpoint returns a `restart_required: true` so the
|
|
//! operator (or their orchestration) knows to bounce the service before
|
|
//! the new key takes effect.
|
|
|
|
use crate::api::admin::{request_context, require_admin};
|
|
use crate::api::AppState;
|
|
use crate::error::{AppError, AppResult};
|
|
use axum::{body::Bytes, extract::State, http::HeaderMap, Json};
|
|
use ed25519_dalek::pkcs8::{DecodePrivateKey, EncodePrivateKey, EncodePublicKey};
|
|
use ed25519_dalek::SigningKey;
|
|
use serde_json::{json, Value};
|
|
|
|
pub async fn import(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
body: Bytes,
|
|
) -> AppResult<Json<Value>> {
|
|
let actor_hash = require_admin(&state, &headers)?;
|
|
let (ip, ua) = request_context(&headers);
|
|
|
|
let pem = std::str::from_utf8(&body)
|
|
.map_err(|_| AppError::BadRequest("body is not valid UTF-8".into()))?
|
|
.trim();
|
|
if pem.is_empty() {
|
|
return Err(AppError::BadRequest("body is empty".into()));
|
|
}
|
|
if !pem.contains("-----BEGIN") || !pem.contains("PRIVATE KEY-----") {
|
|
return Err(AppError::BadRequest(
|
|
"expected a PEM-encoded private key (must contain BEGIN/END PRIVATE KEY)".into(),
|
|
));
|
|
}
|
|
|
|
// Parse + validate the supplied PEM.
|
|
let signing = SigningKey::from_pkcs8_pem(pem).map_err(|e| {
|
|
AppError::BadRequest(format!("could not parse Ed25519 private key: {e}"))
|
|
})?;
|
|
let verifying = signing.verifying_key();
|
|
|
|
// Re-encode through pkcs8 so we always store a normalized form. This
|
|
// also catches any encoding oddity on the input side that would have
|
|
// tripped a future load.
|
|
use pkcs8::LineEnding;
|
|
let priv_pem = signing
|
|
.to_pkcs8_pem(LineEnding::LF)
|
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("re-encode private key: {e}")))?
|
|
.to_string();
|
|
let pub_pem = verifying
|
|
.to_public_key_pem(LineEnding::LF)
|
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("encode public key: {e}")))?;
|
|
|
|
// Safety guard: refuse if any licenses have already been issued by
|
|
// this Keysat. Replacing the issuer key would invalidate them.
|
|
let licenses_exist: bool =
|
|
sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM licenses LIMIT 1)")
|
|
.fetch_one(&state.db)
|
|
.await?;
|
|
if licenses_exist {
|
|
return Err(AppError::Conflict(
|
|
"this Keysat has already issued at least one license; importing a new \
|
|
issuer key would invalidate every previously-signed license. Refusing. \
|
|
Use this endpoint only on a fresh master-Keysat install before any \
|
|
licenses have been issued."
|
|
.into(),
|
|
));
|
|
}
|
|
|
|
// Upsert the keypair into server_keys row id=1. SQLite's INSERT ON
|
|
// CONFLICT is the idiomatic way to do this in one statement.
|
|
let now = chrono::Utc::now().to_rfc3339();
|
|
sqlx::query(
|
|
"INSERT INTO server_keys (id, algorithm, public_key_pem, private_key_pem, created_at)
|
|
VALUES (1, 'ed25519', ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
algorithm = excluded.algorithm,
|
|
public_key_pem = excluded.public_key_pem,
|
|
private_key_pem = excluded.private_key_pem,
|
|
created_at = excluded.created_at",
|
|
)
|
|
.bind(&pub_pem)
|
|
.bind(&priv_pem)
|
|
.bind(&now)
|
|
.execute(&state.db)
|
|
.await?;
|
|
|
|
// Audit-log this prominently. There is no scenario where a regular
|
|
// operator should be running this; if it shows up in the audit log
|
|
// unexpectedly, that's a red flag worth investigating.
|
|
let _ = crate::db::repo::insert_audit(
|
|
&state.db,
|
|
"admin_api_key",
|
|
Some(&actor_hash),
|
|
"issuer_key.import",
|
|
Some("server_key"),
|
|
None,
|
|
ip.as_deref(),
|
|
ua.as_deref(),
|
|
&json!({
|
|
"public_key_pem": pub_pem,
|
|
"note": "master-bootstrap import",
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
tracing::warn!(
|
|
public_key = %pub_pem.lines().nth(1).unwrap_or(""),
|
|
"issuer key imported via admin endpoint — restart the service for the new key to take effect"
|
|
);
|
|
|
|
Ok(Json(json!({
|
|
"ok": true,
|
|
"public_key_pem": pub_pem,
|
|
"restart_required": true,
|
|
"message": "Issuer key imported. Restart the Keysat service for the new \
|
|
key to take effect — until then, in-memory state still holds \
|
|
the previous keypair."
|
|
})))
|
|
}
|
|
|
|
|
|
/// PUBLIC: GET /v1/issuer/public-key — returns the daemon's signing
|
|
/// public key in PEM and a couple of conveniences. No auth required —
|
|
/// the public key is, by definition, public. Used by SDK consumers and
|
|
/// by the admin Overview's "Embed your public key" tip card.
|
|
pub async fn public(
|
|
axum::extract::State(state): axum::extract::State<crate::api::AppState>,
|
|
) -> Json<serde_json::Value> {
|
|
Json(json!({
|
|
"public_key_pem": state.keypair.public_key_pem,
|
|
"key_algorithm": "ed25519",
|
|
"key_format_version": crate::crypto::KEY_VERSION,
|
|
}))
|
|
}
|