feat(admin): allowlist + integrity-MAC data model [operator §0.3/§1.2]

The admin authorization data model. The server stores only public keys; an admin
is authorized by presence in admin_keys, and each row carries an HMAC
(ADMIN_KEY_INTEGRITY_SECRET, separate from DATABASE_URL) over
id|pubkey|scope|created_at|activated_at|revoked_at. Because the MAC covers the
revocation/activation state, a DB-write attacker who clears revoked_at (un-revoke)
or revives a key produces a mismatch the guard rejects — the MAC proves a pubkey
is *currently* authorized, not merely once.

- migration 20260625000002: admin_keys (partial unique index = one active row per
  pubkey; soft revoke keeps history) + admin_sessions (separate table/secret from
  user sessions; cascade-on-key-delete).
- core::crypto::admin_mac: compute/verify over a JSON-canonical field set,
  timestamps as microsecond epochs; constant-time compare; KATs bind
  revoked/activated state.
- db::admin_keys: create / get_active (re-verifies MAC, fail-closed + logs tamper)
  / list / count_live / activate / revoke. Every covered-field write recomputes
  the MAC centrally; timestamps truncated to microseconds so the signed value
  round-trips; revoke/activate are FOR UPDATE transactions.
- 5 L1 tests incl. the load-bearing raw-SQL un-revoke -> MAC mismatch -> rejected.
This commit is contained in:
jwinterm
2026-06-25 18:38:57 -04:00
parent 4f9d00f1d2
commit a03dfc6253
7 changed files with 542 additions and 0 deletions
+134
View File
@@ -0,0 +1,134 @@
//! L1 integration: the admin allowlist + integrity MAC against real Postgres.
//! The load-bearing test is `tampered_revoke_flip_is_rejected`: a DB-write
//! attacker who clears `revoked_at` via raw SQL must not resurrect the key.
mod common;
use smirk_backend_core::models::db::NewAdminKey;
use uuid::Uuid;
const SECRET: &str = "admin-key-integrity-secret-0123456789ab";
fn new_key(pubkey: &str) -> NewAdminKey {
NewAdminKey {
pubkey: pubkey.to_string(),
label: Some("test".into()),
scope: "admin".into(),
created_by_kind: "cli".into(),
activation_deadline: None,
}
}
#[tokio::test]
async fn create_then_active_lookup_verifies_mac() {
let app = require_app!();
let db = &app.state.db;
let pk = format!("{:0>64}", Uuid::new_v4().simple());
let created = db.create_admin_key(new_key(&pk), SECRET).await.unwrap();
assert!(created.activated_at.is_none(), "starts pending");
let active = db
.get_active_admin_key(&pk, SECRET)
.await
.unwrap()
.expect("active key with valid MAC");
assert_eq!(active.id, created.id);
// A different integrity secret must NOT validate the row.
assert!(
db.get_active_admin_key(&pk, "WRONG-SECRET")
.await
.unwrap()
.is_none(),
"wrong secret rejected"
);
}
#[tokio::test]
async fn duplicate_active_pubkey_conflicts() {
let app = require_app!();
let db = &app.state.db;
let pk = format!("{:0>64}", Uuid::new_v4().simple());
db.create_admin_key(new_key(&pk), SECRET).await.unwrap();
let err = db.create_admin_key(new_key(&pk), SECRET).await.unwrap_err();
assert!(
matches!(err, smirk_backend_core::error::AppError::Conflict(_)),
"second active key for the same pubkey is a conflict"
);
}
#[tokio::test]
async fn revoke_removes_from_active_set() {
let app = require_app!();
let db = &app.state.db;
let pk = format!("{:0>64}", Uuid::new_v4().simple());
let key = db.create_admin_key(new_key(&pk), SECRET).await.unwrap();
let revoked = db.revoke_admin_key(key.id, SECRET).await.unwrap().unwrap();
assert!(revoked.revoked_at.is_some());
assert!(db
.get_active_admin_key(&pk, SECRET)
.await
.unwrap()
.is_none());
// Re-revoking is a no-op (already revoked).
assert!(db.revoke_admin_key(key.id, SECRET).await.unwrap().is_none());
// After revoke the pubkey is free for a fresh active key (partial index).
assert!(db.create_admin_key(new_key(&pk), SECRET).await.is_ok());
}
#[tokio::test]
async fn activate_flips_pending_once() {
let app = require_app!();
let db = &app.state.db;
let pk = format!("{:0>64}", Uuid::new_v4().simple());
let key = db.create_admin_key(new_key(&pk), SECRET).await.unwrap();
let activated = db
.activate_admin_key(key.id, SECRET)
.await
.unwrap()
.unwrap();
assert!(activated.activated_at.is_some());
// Still MAC-valid after the activation rewrite.
assert!(db
.get_active_admin_key(&pk, SECRET)
.await
.unwrap()
.is_some());
// Activating again is a no-op (no longer pending).
assert!(db
.activate_admin_key(key.id, SECRET)
.await
.unwrap()
.is_none());
}
#[tokio::test]
async fn tampered_revoke_flip_is_rejected() {
let app = require_app!();
let db = &app.state.db;
let pk = format!("{:0>64}", Uuid::new_v4().simple());
let key = db.create_admin_key(new_key(&pk), SECRET).await.unwrap();
db.revoke_admin_key(key.id, SECRET).await.unwrap();
// Attacker clears revoked_at via raw SQL WITHOUT recomputing the MAC.
sqlx::query("UPDATE admin_keys SET revoked_at = NULL WHERE id = $1")
.bind(key.id)
.execute(db.pool())
.await
.unwrap();
// The row is now "active" by column, but its MAC was computed for the
// revoked state, so the guard's lookup must reject it.
assert!(
db.get_active_admin_key(&pk, SECRET)
.await
.unwrap()
.is_none(),
"un-revoke tamper rejected by integrity MAC"
);
}