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
+104
View File
@@ -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
View File
@@ -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;
+212
View File
@@ -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))
}
}
+1
View File
@@ -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;
+46
View File
@@ -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>,