v0.1.0:24 — Keysat licensing service end-to-end
Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages, discount codes, free-license redemption, Apply-discount UX, self-licensing, and v0.1.0 release notes.
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
//! Admin-only issuer-key import endpoint.
|
||||
//!
|
||||
//! 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."
|
||||
})))
|
||||
}
|
||||
Reference in New Issue
Block a user