mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
fix(admin): harden allowlist per adversarial review (Subsystems 0-1)
From a 5-lens adversarial review of the admin surface (6 confirmed findings,
collapsing to 4 fixes; 2 dismissed after verification):
- HIGH: last-key-revoke TOCTOU (lockout). The count(live)<=1 floor was checked in
the handler, then revoke ran separately — two concurrent revokes could both pass
and empty the allowlist. Moved the floor INTO revoke_admin_key_full under a
per-tx advisory lock (admin_keys_mutate_lock); returns RevokeKeyOutcome
{Revoked,NotFound,WouldEmptyAllowlist}.
- LOW: ADMIN_MAX_KEYS cap TOCTOU. Same check-then-mutate race on add. Cap is now
counted INSIDE create_admin_key_audited under the same lock; returns
AddKeyOutcome {Created,CapReached}. rotate takes the lock too, so all
add/revoke/rotate serialize.
- LOW: integrity MAC didn't cover activation_deadline — a DB-write attacker could
null/extend the deadline to revive an expired pending key without breaking the
MAC. Added activation_deadline to AdminKeyMacInput + the canonical input +
every compute/verify site; new KAT binds it.
- LOW: get_active_admin_key (and create_*) logged the full pubkey via #[instrument]
(params weren't skipped), defeating the prefix-only / no-social-graph design.
Skipped pubkey/input from those spans.
Dismissed (both skeptics not-a-bug): audit-chain JSONB `details` round-trip — the
handlers always pass details:None and serde's sorted-key default makes the
string case round-trip (the L1 chain test proves it). Documented v1 deferrals
(login timing-oracle hardening, per-pubkey backoff, hard non-loopback bind guard)
remain tracked, not regressions.
All gates green: 138 lib + 138 integration, no openapi drift, secret-scan, leak.
This commit is contained in:
+31
-19
@@ -35,6 +35,7 @@ use crate::core::admin_session::AdminSessionManager;
|
||||
use crate::core::crypto::nip98::{descriptor_sha256, request_descriptor, verify_signed_action};
|
||||
use crate::core::session::hash_refresh_token;
|
||||
use crate::error::AppError;
|
||||
use crate::infra::db::{AddKeyOutcome, RevokeKeyOutcome};
|
||||
use crate::models::db::{AdminKey, NewAdminAudit, NewAdminKey, NewAdminSession};
|
||||
use crate::AppState;
|
||||
|
||||
@@ -478,13 +479,6 @@ pub async fn admin_keys_add(
|
||||
let pubkey = req.pubkey.to_lowercase();
|
||||
validate_admin_pubkey(&pubkey)?;
|
||||
|
||||
// Cap total live (active + pending) keys to prevent allowlist-stuffing.
|
||||
if state.db.count_live_admin_keys().await? >= state.config.admin.max_keys as i64 {
|
||||
return Err(AppError::Forbidden(
|
||||
"admin key limit reached; revoke an existing key first".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let secret = &state.config.admin.key_integrity_secret;
|
||||
let audit = NewAdminAudit {
|
||||
action: "admin_key_added".into(),
|
||||
@@ -494,10 +488,24 @@ pub async fn admin_keys_add(
|
||||
details: None,
|
||||
ip_address: None,
|
||||
};
|
||||
let key = state
|
||||
// The live-key cap is enforced INSIDE the insert transaction (race-free).
|
||||
let key = match state
|
||||
.db
|
||||
.create_admin_key_audited(pending_key(&state, pubkey, req.label), &audit, secret)
|
||||
.await?;
|
||||
.create_admin_key_audited(
|
||||
pending_key(&state, pubkey, req.label),
|
||||
&audit,
|
||||
secret,
|
||||
state.config.admin.max_keys as i64,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
AddKeyOutcome::Created(key) => key,
|
||||
AddKeyOutcome::CapReached => {
|
||||
return Err(AppError::Forbidden(
|
||||
"admin key limit reached; revoke an existing key first".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
let status = key_status(&key).to_string();
|
||||
Ok(Json(KeyResponse {
|
||||
id: key.id.to_string(),
|
||||
@@ -528,11 +536,6 @@ pub async fn admin_keys_revoke(
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<OkResponse>, AppError> {
|
||||
let ctx = admin_guard(&state, &headers).await?;
|
||||
if state.db.count_live_admin_keys().await? <= 1 {
|
||||
return Err(AppError::Forbidden(
|
||||
"refusing to revoke the last admin key over the network; use the CLI".into(),
|
||||
));
|
||||
}
|
||||
let secret = &state.config.admin.key_integrity_secret;
|
||||
let audit = NewAdminAudit {
|
||||
action: "admin_key_revoked".into(),
|
||||
@@ -542,12 +545,21 @@ pub async fn admin_keys_revoke(
|
||||
details: None,
|
||||
ip_address: None,
|
||||
};
|
||||
state
|
||||
// The last-key floor is enforced INSIDE the revoke transaction (race-free):
|
||||
// concurrent revokes cannot both pass the check and empty the allowlist.
|
||||
match state
|
||||
.db
|
||||
.revoke_admin_key_full(id, &audit, secret)
|
||||
.revoke_admin_key_full(id, &audit, secret, true)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("admin key not found or already revoked".into()))?;
|
||||
Ok(Json(OkResponse { ok: true }))
|
||||
{
|
||||
RevokeKeyOutcome::Revoked(_) => Ok(Json(OkResponse { ok: true })),
|
||||
RevokeKeyOutcome::NotFound => Err(AppError::NotFound(
|
||||
"admin key not found or already revoked".into(),
|
||||
)),
|
||||
RevokeKeyOutcome::WouldEmptyAllowlist => Err(AppError::Forbidden(
|
||||
"refusing to revoke the last admin key over the network; use the CLI".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically rotate a key: revoke the old one (+ its sessions) and add a fresh
|
||||
|
||||
Reference in New Issue
Block a user