mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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.
135 lines
4.1 KiB
Rust
135 lines
4.1 KiB
Rust
//! 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"
|
|
);
|
|
}
|