feat(manager-core,picloud): api_keys_api + deactivation cascade

* auth: generate_api_key() mints pic_<base32(32 bytes)>, splits the
  indexed 8-char prefix, and Argon2-hashes the body. Adds the
  data-encoding workspace dep for unpadded base32.
* api_keys_api: POST /api/v1/admin/api-keys (mint, returns raw_token
  exactly once), GET (caller's own, no raw), DELETE {id} (caller's
  own; 404 deliberately covers both 'missing' and 'not yours').
  Mint validation rejects bound keys carrying instance:* scopes (422).
* AdminsState gains the api keys repo; PATCH set_active(false) now
  expires every active key for that user alongside session wipe —
  Phase 3.5 deactivation symmetry.
* picloud lib wires PostgresApiKeyRepository through AuthDeps into
  AdminsState + ApiKeysState; api_keys_router merges into the
  guarded_admin layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-26 22:00:33 +02:00
parent 5f7ddd23ab
commit 8659a58eb2
8 changed files with 393 additions and 13 deletions

View File

@@ -13,6 +13,7 @@ use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, Salt
use argon2::Argon2;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use data_encoding::BASE32_NOPAD;
use rand::rngs::OsRng;
use rand::RngCore;
use sha2::{Digest, Sha256};
@@ -93,6 +94,66 @@ fn hex(bytes: &[u8]) -> String {
out
}
// ----------------------------------------------------------------------------
// API key generation (Phase 3.5)
// ----------------------------------------------------------------------------
/// Wire-format prefix that marks a Bearer value as an API key (vs. a
/// session token). Mirrors `auth_middleware::API_KEY_PREFIX` so the
/// generator and the verifier agree.
pub const API_KEY_WIRE_PREFIX: &str = "pic_";
/// Length of the indexed prefix portion (the first 8 chars of the
/// `pic_`-stripped body). Mirrors `auth_middleware::API_KEY_PREFIX_LEN`.
pub const API_KEY_INDEX_PREFIX_LEN: usize = 8;
/// Newly minted API key — returned exactly once by `POST /api/v1/admin/api-keys`.
///
/// * `raw` is the full wire-format token (`pic_<base32>`) shown to the
/// caller in the response body and never persisted.
/// * `prefix` is the indexed 8-char slice persisted to
/// `api_keys.prefix` for lookup.
/// * `hash` is the Argon2id PHC string persisted to `api_keys.hash`;
/// covers the body after `pic_` (i.e., `raw[4..]`).
pub struct GeneratedApiKey {
pub raw: String,
pub prefix: String,
pub hash: String,
}
/// Generate a fresh API key. 32 random bytes → unpadded base32, then
/// `pic_` prefix on the wire. The first 8 base32 chars are the index
/// key; everything after `pic_` is what the verifier hashes.
///
/// # Errors
///
/// Returns `argon2::password_hash::Error` if the Argon2 hash step
/// fails (which it shouldn't under normal conditions).
pub fn generate_api_key() -> Result<GeneratedApiKey, argon2::password_hash::Error> {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
let body = BASE32_NOPAD.encode(&bytes);
debug_assert!(
body.len() >= API_KEY_INDEX_PREFIX_LEN,
"32 bytes base32 must exceed the 8-char prefix length"
);
let prefix = body[..API_KEY_INDEX_PREFIX_LEN].to_string();
let salt = SaltString::generate(&mut ArgonRng);
let hash = Argon2::default()
.hash_password(body.as_bytes(), &salt)?
.to_string();
let raw = format!("{API_KEY_WIRE_PREFIX}{body}");
Ok(GeneratedApiKey { raw, prefix, hash })
}
/// Verify a wire-format token body (the portion *after* `pic_`)
/// against a stored Argon2id hash. Convenience wrapper around
/// `verify_password` named to reflect its caller.
#[must_use]
pub fn verify_api_key(stored_hash: &str, presented_body: &str) -> bool {
verify_password(stored_hash, presented_body)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -129,4 +190,39 @@ mod tests {
assert_eq!(a.hash, hash_token(&a.raw), "hash must be reproducible");
assert_eq!(a.hash.len(), 64, "sha256-hex is 64 chars");
}
#[test]
fn generate_api_key_round_trip() {
let key = generate_api_key().expect("mint");
assert!(
key.raw.starts_with(API_KEY_WIRE_PREFIX),
"raw must carry the pic_ prefix"
);
let body = key
.raw
.strip_prefix(API_KEY_WIRE_PREFIX)
.expect("starts with prefix");
assert_eq!(
&body[..API_KEY_INDEX_PREFIX_LEN],
key.prefix,
"stored prefix matches the first 8 chars of the body"
);
assert!(
verify_api_key(&key.hash, body),
"Argon2 verify must accept the original body"
);
assert!(
!verify_api_key(&key.hash, "wrong-body-entirely"),
"Argon2 verify must reject anything else"
);
}
#[test]
fn generate_api_key_unique() {
let a = generate_api_key().expect("mint a");
let b = generate_api_key().expect("mint b");
assert_ne!(a.raw, b.raw);
assert_ne!(a.hash, b.hash);
assert_ne!(a.prefix, b.prefix, "32 random bytes → prefix collision is negligible");
}
}