mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user