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,44 @@
|
||||
-- Admin authentication & authorization (operator §0.3 / §1.2).
|
||||
--
|
||||
-- The server stores ONLY public keys — an admin proves control of a private key
|
||||
-- (NIP-98 signed action) and is authorized by presence in this allowlist. Each
|
||||
-- row carries an integrity MAC keyed by ADMIN_KEY_INTEGRITY_SECRET (separate from
|
||||
-- DATABASE_URL): it covers id|pubkey|scope|created_at|activated_at|revoked_at, so
|
||||
-- a DB-write attacker who flips revoked_at back to NULL (un-revoke) or revives a
|
||||
-- key produces a MAC mismatch that the guard rejects. Revocation is a soft
|
||||
-- revoke (the audit trail survives); the partial unique index keeps at most one
|
||||
-- ACTIVE row per pubkey while allowing historical revoked rows.
|
||||
CREATE TABLE admin_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
pubkey TEXT NOT NULL, -- x-only secp256k1 hex, 64 lowercase
|
||||
label TEXT,
|
||||
scope TEXT NOT NULL DEFAULT 'admin',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by_kind TEXT NOT NULL, -- 'admin' | 'cli' | 'bootstrap' (coarse; no social graph)
|
||||
activated_at TIMESTAMPTZ, -- NULL = pending (first login not yet completed)
|
||||
activation_deadline TIMESTAMPTZ, -- pending key auto-expires after this
|
||||
revoked_at TIMESTAMPTZ, -- soft revoke
|
||||
last_used_at TIMESTAMPTZ,
|
||||
integrity_mac TEXT NOT NULL -- HMAC(integrity_secret, id|pubkey|scope|created_at|activated_at|revoked_at)
|
||||
);
|
||||
|
||||
-- At most one ACTIVE allowlist entry per pubkey; revoked rows are unconstrained.
|
||||
CREATE UNIQUE INDEX admin_keys_active_pubkey ON admin_keys (pubkey) WHERE revoked_at IS NULL;
|
||||
|
||||
-- Admin sessions are a SEPARATE table from user `sessions` (cryptographically
|
||||
-- distinct tokens, distinct secret). Revoking the key cascades to its sessions.
|
||||
CREATE TABLE admin_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
admin_key_id UUID NOT NULL REFERENCES admin_keys (id) ON DELETE CASCADE,
|
||||
pubkey TEXT NOT NULL,
|
||||
refresh_token_hash TEXT NOT NULL, -- peppered at rest (never the token)
|
||||
access_jti TEXT NOT NULL, -- the live access token's id
|
||||
device_info TEXT,
|
||||
ip_address INET, -- real TCP peer, never XFF
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_admin_sessions_key ON admin_sessions (admin_key_id);
|
||||
CREATE INDEX idx_admin_sessions_jti ON admin_sessions (access_jti) WHERE revoked_at IS NULL;
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Row-integrity MAC for the admin allowlist.
|
||||
//!
|
||||
//! Each `admin_keys` row carries an HMAC over its authorization-bearing fields,
|
||||
//! keyed by `ADMIN_KEY_INTEGRITY_SECRET` (deliberately separate from
|
||||
//! `DATABASE_URL`). Because the MAC covers `activated_at` AND `revoked_at`, a
|
||||
//! DB-write attacker who flips `revoked_at` back to `NULL` (un-revoke) or revives
|
||||
//! a key produces a mismatch the guard rejects — the MAC proves a pubkey is
|
||||
//! *currently* authorized, not merely that it once was. Every write that changes
|
||||
//! a covered field MUST recompute the MAC (the db layer does this centrally).
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use subtle::ConstantTimeEq;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::pepper::peppered_hex;
|
||||
|
||||
/// The covered fields. Timestamps are compared at microsecond precision (the
|
||||
/// resolution Postgres stores), so the MAC round-trips exactly.
|
||||
pub struct AdminKeyMacInput<'a> {
|
||||
pub id: Uuid,
|
||||
pub pubkey: &'a str,
|
||||
pub scope: &'a str,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub activated_at: Option<DateTime<Utc>>,
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// `hex(HMAC-SHA256(secret, "admin_key_mac" ‖ canonical))`. The canonical form is
|
||||
/// a JSON array (unambiguous escaping) of the covered fields, timestamps as
|
||||
/// microsecond epochs (`null` when absent — so present-but-zero ≠ absent).
|
||||
pub fn compute_admin_key_mac(secret: &str, input: &AdminKeyMacInput) -> String {
|
||||
let canonical = serde_json::json!([
|
||||
input.id.to_string(),
|
||||
input.pubkey,
|
||||
input.scope,
|
||||
input.created_at.timestamp_micros(),
|
||||
input.activated_at.map(|t| t.timestamp_micros()),
|
||||
input.revoked_at.map(|t| t.timestamp_micros()),
|
||||
])
|
||||
.to_string();
|
||||
peppered_hex(secret, "admin_key_mac", &canonical)
|
||||
}
|
||||
|
||||
/// Constant-time check of a stored MAC against the recomputed one.
|
||||
pub fn verify_admin_key_mac(secret: &str, input: &AdminKeyMacInput, mac_hex: &str) -> bool {
|
||||
let expected = compute_admin_key_mac(secret, input);
|
||||
expected.as_bytes().ct_eq(mac_hex.as_bytes()).into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PK: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
|
||||
fn input() -> AdminKeyMacInput<'static> {
|
||||
AdminKeyMacInput {
|
||||
id: Uuid::from_u128(1),
|
||||
pubkey: PK,
|
||||
scope: "admin",
|
||||
created_at: DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
|
||||
activated_at: None,
|
||||
revoked_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_and_verifies() {
|
||||
let i = input();
|
||||
let mac = compute_admin_key_mac("secret", &i);
|
||||
assert!(verify_admin_key_mac("secret", &i, &mac));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_secret() {
|
||||
let i = input();
|
||||
let mac = compute_admin_key_mac("secret-A", &i);
|
||||
assert!(!verify_admin_key_mac("secret-B", &i, &mac));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revocation_state_is_bound() {
|
||||
// The MAC for a revoked key must not validate once revoked_at is cleared
|
||||
// (the un-revoke attack), and vice versa.
|
||||
let active = input();
|
||||
let mac_active = compute_admin_key_mac("secret", &active);
|
||||
|
||||
let mut revoked = input();
|
||||
revoked.revoked_at = Some(DateTime::from_timestamp(1_700_000_500, 0).unwrap());
|
||||
assert!(!verify_admin_key_mac("secret", &revoked, &mac_active));
|
||||
|
||||
let mac_revoked = compute_admin_key_mac("secret", &revoked);
|
||||
assert!(!verify_admin_key_mac("secret", &active, &mac_revoked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_state_is_bound() {
|
||||
let pending = input();
|
||||
let mac_pending = compute_admin_key_mac("secret", &pending);
|
||||
let mut activated = input();
|
||||
activated.activated_at = Some(DateTime::from_timestamp(1_700_000_100, 0).unwrap());
|
||||
assert!(!verify_admin_key_mac("secret", &activated, &mac_pending));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Cryptographic primitives: NIP-98 auth and wallet-signature verification.
|
||||
|
||||
pub mod admin_mac;
|
||||
pub mod nip98;
|
||||
pub mod pepper;
|
||||
pub mod signatures;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Admin allowlist queries.
|
||||
//!
|
||||
//! Every read of an active key re-verifies its integrity MAC, and every write
|
||||
//! that changes a covered field recomputes it, so MAC handling cannot drift or
|
||||
//! be forgotten by a caller. A MAC mismatch on read is treated as "not
|
||||
//! authorized" (fail-closed) and logged as tamper evidence. Timestamps bound by
|
||||
//! the MAC are truncated to microseconds (Postgres' resolution) so the value
|
||||
//! signed at write equals the value read back.
|
||||
|
||||
use chrono::{SubsecRound, Utc};
|
||||
use tracing::instrument;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::core::crypto::admin_mac::{
|
||||
compute_admin_key_mac, verify_admin_key_mac, AdminKeyMacInput,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::models::db::{AdminKey, NewAdminKey};
|
||||
|
||||
use super::{unique_violation_as, Database};
|
||||
|
||||
/// Explicit `admin_keys` columns (FromRow maps by name).
|
||||
const COLS: &str = "id, pubkey, label, scope, created_at, created_by_kind, \
|
||||
activated_at, activation_deadline, revoked_at, last_used_at, integrity_mac";
|
||||
|
||||
fn mac_input<'a>(k: &'a AdminKey) -> AdminKeyMacInput<'a> {
|
||||
AdminKeyMacInput {
|
||||
id: k.id,
|
||||
pubkey: &k.pubkey,
|
||||
scope: &k.scope,
|
||||
created_at: k.created_at,
|
||||
activated_at: k.activated_at,
|
||||
revoked_at: k.revoked_at,
|
||||
}
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Add an allowlist entry (pending — `activated_at` NULL until first login).
|
||||
/// Fails 409 if an active key with this pubkey already exists.
|
||||
#[instrument(skip(self, secret))]
|
||||
pub async fn create_admin_key(
|
||||
&self,
|
||||
input: NewAdminKey,
|
||||
secret: &str,
|
||||
) -> Result<AdminKey, AppError> {
|
||||
let id = Uuid::new_v4();
|
||||
let created_at = Utc::now().trunc_subsecs(6);
|
||||
let mac = compute_admin_key_mac(
|
||||
secret,
|
||||
&AdminKeyMacInput {
|
||||
id,
|
||||
pubkey: &input.pubkey,
|
||||
scope: &input.scope,
|
||||
created_at,
|
||||
activated_at: None,
|
||||
revoked_at: None,
|
||||
},
|
||||
);
|
||||
let sql = format!(
|
||||
"INSERT INTO admin_keys \
|
||||
(id, pubkey, label, scope, created_at, created_by_kind, activation_deadline, integrity_mac) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING {COLS}"
|
||||
);
|
||||
let key = sqlx::query_as::<_, AdminKey>(&sql)
|
||||
.bind(id)
|
||||
.bind(&input.pubkey)
|
||||
.bind(&input.label)
|
||||
.bind(&input.scope)
|
||||
.bind(created_at)
|
||||
.bind(&input.created_by_kind)
|
||||
.bind(input.activation_deadline)
|
||||
.bind(&mac)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_err(unique_violation_as(
|
||||
"an active admin key with this pubkey already exists",
|
||||
))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// The active (non-revoked) allowlist entry for `pubkey`, IF its integrity
|
||||
/// MAC verifies. A mismatch returns `None` (fail-closed) and logs tampering.
|
||||
#[instrument(skip(self, secret))]
|
||||
pub async fn get_active_admin_key(
|
||||
&self,
|
||||
pubkey: &str,
|
||||
secret: &str,
|
||||
) -> Result<Option<AdminKey>, AppError> {
|
||||
let sql = format!("SELECT {COLS} FROM admin_keys WHERE pubkey = $1 AND revoked_at IS NULL");
|
||||
let row = sqlx::query_as::<_, AdminKey>(&sql)
|
||||
.bind(pubkey)
|
||||
.fetch_optional(self.pool())
|
||||
.await?;
|
||||
match row {
|
||||
Some(key) if verify_admin_key_mac(secret, &mac_input(&key), &key.integrity_mac) => {
|
||||
Ok(Some(key))
|
||||
}
|
||||
Some(key) => {
|
||||
tracing::error!(
|
||||
admin_key_id = %key.id,
|
||||
"admin_keys integrity MAC mismatch — row tampered; rejecting"
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// All allowlist entries (active, pending, revoked), newest first.
|
||||
#[instrument(skip(self))]
|
||||
pub async fn list_admin_keys(&self) -> Result<Vec<AdminKey>, AppError> {
|
||||
let sql = format!("SELECT {COLS} FROM admin_keys ORDER BY created_at DESC");
|
||||
let rows = sqlx::query_as::<_, AdminKey>(&sql)
|
||||
.fetch_all(self.pool())
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Count live (active + pending) keys — backs the `ADMIN_MAX_KEYS` cap.
|
||||
#[instrument(skip(self))]
|
||||
pub async fn count_live_admin_keys(&self) -> Result<i64, AppError> {
|
||||
let n = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM admin_keys WHERE revoked_at IS NULL",
|
||||
)
|
||||
.fetch_one(self.pool())
|
||||
.await?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Mark a pending key activated (first successful login). Recomputes the MAC.
|
||||
/// Returns the updated row, or `None` if not pending (already active/revoked).
|
||||
#[instrument(skip(self, secret))]
|
||||
pub async fn activate_admin_key(
|
||||
&self,
|
||||
id: Uuid,
|
||||
secret: &str,
|
||||
) -> Result<Option<AdminKey>, AppError> {
|
||||
let mut tx = self.pool().begin().await?;
|
||||
let sel = format!(
|
||||
"SELECT {COLS} FROM admin_keys \
|
||||
WHERE id = $1 AND revoked_at IS NULL AND activated_at IS NULL FOR UPDATE"
|
||||
);
|
||||
let Some(key) = sqlx::query_as::<_, AdminKey>(&sel)
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
else {
|
||||
tx.rollback().await?;
|
||||
return Ok(None);
|
||||
};
|
||||
let activated_at = Utc::now().trunc_subsecs(6);
|
||||
let mac = compute_admin_key_mac(
|
||||
secret,
|
||||
&AdminKeyMacInput {
|
||||
activated_at: Some(activated_at),
|
||||
..mac_input(&key)
|
||||
},
|
||||
);
|
||||
let upd = format!(
|
||||
"UPDATE admin_keys SET activated_at = $2, integrity_mac = $3 WHERE id = $1 RETURNING {COLS}"
|
||||
);
|
||||
let updated = sqlx::query_as::<_, AdminKey>(&upd)
|
||||
.bind(id)
|
||||
.bind(activated_at)
|
||||
.bind(&mac)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Some(updated))
|
||||
}
|
||||
|
||||
/// Soft-revoke a key (recomputing the MAC over the new `revoked_at`). Returns
|
||||
/// the updated row, or `None` if it was already revoked / does not exist.
|
||||
#[instrument(skip(self, secret))]
|
||||
pub async fn revoke_admin_key(
|
||||
&self,
|
||||
id: Uuid,
|
||||
secret: &str,
|
||||
) -> Result<Option<AdminKey>, AppError> {
|
||||
let mut tx = self.pool().begin().await?;
|
||||
let sel = format!(
|
||||
"SELECT {COLS} FROM admin_keys WHERE id = $1 AND revoked_at IS NULL FOR UPDATE"
|
||||
);
|
||||
let Some(key) = sqlx::query_as::<_, AdminKey>(&sel)
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
else {
|
||||
tx.rollback().await?;
|
||||
return Ok(None);
|
||||
};
|
||||
let revoked_at = Utc::now().trunc_subsecs(6);
|
||||
let mac = compute_admin_key_mac(
|
||||
secret,
|
||||
&AdminKeyMacInput {
|
||||
revoked_at: Some(revoked_at),
|
||||
..mac_input(&key)
|
||||
},
|
||||
);
|
||||
let upd = format!(
|
||||
"UPDATE admin_keys SET revoked_at = $2, integrity_mac = $3 WHERE id = $1 RETURNING {COLS}"
|
||||
);
|
||||
let updated = sqlx::query_as::<_, AdminKey>(&upd)
|
||||
.bind(id)
|
||||
.bind(revoked_at)
|
||||
.bind(&mac)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Some(updated))
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//! `seed_fingerprint`) are peppered inside these methods so the plaintext never
|
||||
//! reaches a column and the peppering cannot drift between call sites.
|
||||
|
||||
mod admin_keys;
|
||||
mod audit;
|
||||
mod challenges;
|
||||
mod grin_slatepacks;
|
||||
|
||||
@@ -180,6 +180,41 @@ pub struct AuditLog {
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// An admin allowlist entry. The server holds only the pubkey; `integrity_mac`
|
||||
/// binds the authorization-bearing fields so a DB-write attacker cannot silently
|
||||
/// un-revoke or revive a key. Never `Serialize` to a public surface.
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
pub struct AdminKey {
|
||||
pub id: Uuid,
|
||||
pub pubkey: String,
|
||||
pub label: Option<String>,
|
||||
pub scope: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub created_by_kind: String,
|
||||
/// `NULL` while pending (first login not yet completed).
|
||||
pub activated_at: Option<DateTime<Utc>>,
|
||||
pub activation_deadline: Option<DateTime<Utc>>,
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub integrity_mac: String,
|
||||
}
|
||||
|
||||
/// An admin session backing a short-lived admin access token. Separate table and
|
||||
/// secret from user `sessions` so the two token kinds can never be interchanged.
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
pub struct AdminSession {
|
||||
pub id: Uuid,
|
||||
pub admin_key_id: Uuid,
|
||||
pub pubkey: String,
|
||||
pub refresh_token_hash: String,
|
||||
pub access_jti: String,
|
||||
pub device_info: Option<String>,
|
||||
pub ip_address: Option<IpNetwork>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
// ── Input structs (no auto-generated fields) ────────────────────────────────
|
||||
|
||||
/// Input for creating a new user. Peppered values are supplied by the db layer.
|
||||
@@ -234,6 +269,17 @@ pub struct NewGrinSlatepack {
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Input for adding an admin allowlist entry. The `id`, timestamps, and
|
||||
/// `integrity_mac` are computed by the db layer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewAdminKey {
|
||||
pub pubkey: String,
|
||||
pub label: Option<String>,
|
||||
pub scope: String,
|
||||
pub created_by_kind: String,
|
||||
pub activation_deadline: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewAuditLog {
|
||||
pub user_id: Option<Uuid>,
|
||||
|
||||
@@ -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