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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user