feat(key-source): land the vault-backed key sources (ADR-005 custody in code)
- Two injected-handle seams: KeySource (dial-config, decrypt-at-use -> one PrivateKey) and HostKeySource (serve-config, decrypt-at-construction -> the Config::keys set); async_trait object-safe, the HostKeyPolicy handle shape - Crate-provided ADR-005 implementations: VaultKeySource (injected vault handle + Option<EncryptedData>, None -> Absent) and VaultHostKeySource (record set, empty -> Absent) over the shared decrypt_and_decode chain: vault.decrypt -> Zeroizing<String> seam wrap -> one-time decode_secret_key(plain, None) - EncryptedData opaque in src/ (no key_version interpretation); vault error taxonomy reshaped into the five-variant KeySourceError matrix, never collapsed; plaintext never persists - 12 in-module tests on the real in-memory alkvault handle: round-trip (fingerprint-identical ed25519 key that signs), full failure matrix, locked host-bootstrap refusal, lock-mid-session lifecycle, v2->v3 rotation identity; three committed throwaway PEM fixtures Verified: cargo test (23 + 4 parity), cargo test --all-features, clippy -D warnings (default + all-features), fmt --check, taskgraph validate
This commit is contained in:
1 parent
149f11d4b2
commit
4d529dbacf
3 files changed
+580
-9
No files matched your search
@@ -0,0 +1,513 @@
|
||||
//! The vault-backed key sources — ADR-005's custody model in code,
|
||||
//! both directions (client dial keys, server host keys).
|
||||
//!
|
||||
//! The adapter takes *key sources*, not static keys (the alkhttp
|
||||
//! ADR-014 pattern): the consumer surfaces (wave-4 dial-config, wave-2
|
||||
//! serve-config) carry injected handle traits, and the assembly
|
||||
//! implements them over an injected alkvault handle. The crate ships
|
||||
//! the vault-backed implementations for the ADR-005 shape
|
||||
//! ([`VaultKeySource`] for one dial key, [`VaultHostKeySource`] for
|
||||
//! the host-key set) along with the decrypt-at-use chain they run —
|
||||
//! the custody machinery is the crate's, the at-rest storage is the
|
||||
//! assembly's.
|
||||
//!
|
||||
//! Crate rules, per ADR-005:
|
||||
//!
|
||||
//! - **`EncryptedData` is opaque** (decision 5): blobs flow through
|
||||
//! this module to `VaultServiceHandle::decrypt` untouched — no
|
||||
//! `key_version` interpretation, no record-internals access
|
||||
//! anywhere in the crate; version semantics belong to the vault.
|
||||
//! - **At-use lifecycle** (decision 2): `vault.decrypt(blob)` →
|
||||
//! `Zeroizing<String>` wrap at the adapter seam (decision 3 — the
|
||||
//! known-gap contract; the vault-side improvement is upstream, not
|
||||
//! here) → one-time
|
||||
//! [`decode_secret_key`](russh::keys::decode_secret_key) → the
|
||||
//! decoded `PrivateKey` lives for the dial's lifetime. The vault is
|
||||
//! needed only at use time: locking the vault mid-session keeps
|
||||
//! live sessions streaming while new loads fail (POC-2 §2).
|
||||
//! - **Inner format is OpenSSH private-key PEM, unencrypted**
|
||||
//! (decision 1) — the source contract assumes it; the decode call
|
||||
//! is the single conversion. A plaintext that is not that shape is
|
||||
//! a [`KeySourceError::Decode`] refusal, not a fallback.
|
||||
//! - **Failure modes are fail-closed with distinct variants**
|
||||
//! (decision 4): locked / absent / decryption / unknown version /
|
||||
//! decode map to [`KeySourceError`]'s five variants; never
|
||||
//! collapsed, never a fallback dial. The vault's error taxonomy is
|
||||
//! reshaped, never re-exported.
|
||||
//! - **Plaintext never persists** (decision 7): no disk spill on this
|
||||
//! path, ever.
|
||||
//! - Vault lifecycle is the assembly's: this module never unlocks
|
||||
//! the vault and never retries — a locked vault stays a refusal.
|
||||
|
||||
use alkvault::{EncryptedData, VaultServiceError, VaultServiceHandle};
|
||||
use async_trait::async_trait;
|
||||
use russh::keys::{PrivateKey, decode_secret_key};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::error::KeySourceError;
|
||||
|
||||
/// The client dial-key source seam — the handle the wave-4 dial-config
|
||||
/// carries (never a static key, never env material).
|
||||
///
|
||||
/// Produces the decoded [`PrivateKey`] for one dial, decrypt-at-use:
|
||||
/// the vault participates only while the load runs, and the decoded
|
||||
/// key outlives any vault state change (locking mid-session keeps
|
||||
/// established sessions streaming; POC-2 §2). Implementations must be
|
||||
/// fail-closed: a load that cannot produce key material returns the
|
||||
/// matching [`KeySourceError`] variant — every variant is a
|
||||
/// no-dial outcome, and there is no fallback key shape in the crate.
|
||||
///
|
||||
/// The handle shape is the family's injected-handle pattern
|
||||
/// (`Arc<dyn KeySource>`, the alkcall `IdentityProvider` precedent):
|
||||
/// object-safe via [`async_trait`], shared by reference — the vault
|
||||
/// handle rides the implementation (the assembly clones its
|
||||
/// [`VaultServiceHandle`] into its source). The crate's ADR-005
|
||||
/// implementation is [`VaultKeySource`]; an assembly may implement the
|
||||
/// trait itself for other at-rest shapes, under the same inner-format
|
||||
/// contract (OpenSSH-PEM, unencrypted).
|
||||
#[async_trait]
|
||||
pub trait KeySource: Send + Sync + 'static {
|
||||
/// Loads the dial key — decryption plus the one-time
|
||||
/// `decode_secret_key` (ADR-005 decision 2).
|
||||
///
|
||||
/// This is the only surface where the dial direction touches
|
||||
/// secret material: the plaintext exists solely inside this
|
||||
/// method and is dropped (zeroized from the adapter seam onward)
|
||||
/// before the decoded key is returned.
|
||||
async fn load(&self) -> Result<PrivateKey, KeySourceError>;
|
||||
}
|
||||
|
||||
/// The server host-key source seam — the handle the wave-2
|
||||
/// serve-config carries (ADR-005 decision 6).
|
||||
///
|
||||
/// Produces the decoded host-key set for server construction —
|
||||
/// decrypt-**at-construction**: the wave-2 `Config` builder calls
|
||||
/// [`HostKeySource::load`] when it assembles the server, and a failure
|
||||
/// means no server is constructed (POC-2 §5; locked vault at
|
||||
/// bootstrap → [`KeySourceError::Locked`] → fail-closed, never a
|
||||
/// half-configured server). The crate is policy-blind on host *pin*)
|
||||
/// trust — that is the [`crate::HostKeyPolicy`] seam's business — this
|
||||
/// seam is only the *held* material (the client's host pin is public
|
||||
/// config, never vault material).
|
||||
///
|
||||
/// The set, not a single key: `Config::keys` accepts multiple
|
||||
/// host keys and fleets rotate them. Implementations must be
|
||||
/// fail-closed in the same shape as [`KeySource`] — see the trait's
|
||||
/// docs. The crate's ADR-005 implementation is [`VaultHostKeySource`].
|
||||
#[async_trait]
|
||||
pub trait HostKeySource: Send + Sync + 'static {
|
||||
/// Loads the host-key set — each blob through the same
|
||||
/// decrypt-at-use chain as [`KeySource::load`]. Order follows the
|
||||
/// source's registration order.
|
||||
async fn load(&self) -> Result<Vec<PrivateKey>, KeySourceError>;
|
||||
}
|
||||
|
||||
/// The ADR-005 dial-key source — decrypt-at-use over an injected
|
||||
/// alkvault handle, backed by one at-rest `EncryptedData` record.
|
||||
///
|
||||
/// The record is injected at construction, from the assembly's
|
||||
/// registry (`None` = the assembly holds no record at this key's
|
||||
/// location → [`KeySourceError::Absent`], POC-2 §3). The vault handle
|
||||
/// is a clone of the assembly's — this source never unlocks, locks, or
|
||||
/// retries the vault's lifecycle state; every [`VaultKeySource::load`]
|
||||
/// is decrypt-at-usage and a locked vault is a clean
|
||||
/// [`KeySourceError::Locked`] refusal.
|
||||
#[derive(Clone)]
|
||||
pub struct VaultKeySource {
|
||||
vault: VaultServiceHandle,
|
||||
blob: Option<EncryptedData>,
|
||||
}
|
||||
|
||||
impl VaultKeySource {
|
||||
/// Builds a dial-key source over an injected vault handle and the
|
||||
/// injected at-rest record (`None` → absent).
|
||||
pub fn new(vault: VaultServiceHandle, blob: Option<EncryptedData>) -> Self {
|
||||
Self { vault, blob }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl KeySource for VaultKeySource {
|
||||
async fn load(&self) -> Result<PrivateKey, KeySourceError> {
|
||||
match &self.blob {
|
||||
Some(blob) => decrypt_and_decode(&self.vault, blob),
|
||||
None => Err(KeySourceError::Absent),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The ADR-005 host-key source — decrypt-at-construction over an
|
||||
/// injected alkvault handle, backed by the injected at-rest record set.
|
||||
///
|
||||
/// Each element of the set flows through the same decrypt-and-decode
|
||||
/// chain as [`VaultKeySource`] (one model, both directions —
|
||||
/// POC-2 §5). An empty set is [`KeySourceError::Absent`] (a host key
|
||||
/// registration gap), which the wave-2 config builder treats exactly
|
||||
/// like every other load failure: no server is constructed.
|
||||
#[derive(Clone)]
|
||||
pub struct VaultHostKeySource {
|
||||
vault: VaultServiceHandle,
|
||||
blobs: Vec<EncryptedData>,
|
||||
}
|
||||
|
||||
impl VaultHostKeySource {
|
||||
/// Builds a host-key source over an injected vault handle and the
|
||||
/// injected at-rest record set.
|
||||
pub fn new(vault: VaultServiceHandle, blobs: Vec<EncryptedData>) -> Self {
|
||||
Self { vault, blobs }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HostKeySource for VaultHostKeySource {
|
||||
async fn load(&self) -> Result<Vec<PrivateKey>, KeySourceError> {
|
||||
if self.blobs.is_empty() {
|
||||
return Err(KeySourceError::Absent);
|
||||
}
|
||||
self.blobs
|
||||
.iter()
|
||||
.map(|blob| decrypt_and_decode(&self.vault, blob))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The ADR-005 at-use chain (decision 2): one vault decrypt, the
|
||||
/// `Zeroizing<String>` seam wrap, one `decode_secret_key`.
|
||||
///
|
||||
/// The plaintext `String` leaves the vault as
|
||||
/// [`KeySourceError`]-mapped failure or enters the wrap immediately —
|
||||
/// its only unfenced window is the vault's own `String` return, the
|
||||
/// known-gap contract of ADR-005 decision 3 (the upstream ask is
|
||||
/// alkvault's; OQ-07). The decode is the single inner-format
|
||||
/// conversion (decision 1); a plaintext that is not OpenSSH-PEM key
|
||||
/// material is a [`KeySourceError::Decode`] refusal.
|
||||
fn decrypt_and_decode(
|
||||
vault: &VaultServiceHandle,
|
||||
blob: &EncryptedData,
|
||||
) -> Result<PrivateKey, KeySourceError> {
|
||||
let plaintext = Zeroizing::new(vault.decrypt(blob).map_err(map_vault_error)?);
|
||||
decode_secret_key(&plaintext, None).map_err(|e| KeySourceError::Decode(e.to_string()))
|
||||
}
|
||||
|
||||
/// The vault's error taxonomy reshaped into ADR-005 decision 4's
|
||||
/// five-variant matrix — never re-exported, never collapsed into one.
|
||||
///
|
||||
/// `VaultLocked` → [`KeySourceError::Locked`]; `InvalidPath` (the
|
||||
/// vault's refuse-before-crypto refusal of an unresolvable derivation
|
||||
/// path — reserved/legacy v1, v0) →
|
||||
/// [`KeySourceError::UnknownKeyVersion`]; `Encryption` (the GCM-open
|
||||
/// class: wrong mnemonic, tampered ciphertext) is honest refuted
|
||||
/// decryption → [`KeySourceError::Decryption`]. The remaining vault
|
||||
/// variants cannot arise on the decrypt path (decryption never
|
||||
/// touches mnemonic parsing, key derivation outside version-path
|
||||
/// resolution, unlock state transitions, or key-type registration;
|
||||
/// POC-2 §3) — if one were ever observed it would be a vault-side
|
||||
/// refuse-to-decrypt condition, so it lands in the `Decryption` class
|
||||
/// with the vault's message intact rather than silently succeeding.
|
||||
fn map_vault_error(error: VaultServiceError) -> KeySourceError {
|
||||
match error {
|
||||
VaultServiceError::VaultLocked => KeySourceError::Locked,
|
||||
VaultServiceError::InvalidPath(msg) => KeySourceError::UnknownKeyVersion(msg),
|
||||
VaultServiceError::Encryption(msg) => KeySourceError::Decryption(msg),
|
||||
VaultServiceError::Derivation(msg) => {
|
||||
KeySourceError::Decryption(format!("derivation error: {msg}"))
|
||||
}
|
||||
VaultServiceError::Mnemonic(msg) => {
|
||||
KeySourceError::Decryption(format!("mnemonic error: {msg}"))
|
||||
}
|
||||
VaultServiceError::AlreadyUnlocked => KeySourceError::Decryption(
|
||||
"vault reported already-unlocked on a decrypt attempt".into(),
|
||||
),
|
||||
VaultServiceError::UnsupportedKeyType => {
|
||||
KeySourceError::Decryption("vault reported unsupported key type".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use alkvault::CURRENT_KEY_VERSION;
|
||||
use russh::keys::Algorithm;
|
||||
use russh::keys::signature::Signer;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{HostKeySource, KeySource, VaultHostKeySource, VaultKeySource};
|
||||
use crate::error::KeySourceError;
|
||||
use crate::fingerprint::fingerprint;
|
||||
|
||||
/// Throwaway test fixtures generated at vector time with
|
||||
/// `ssh-keygen -t ed25519` (OpenSSH 9.6p1); recorded here as
|
||||
/// committed test constants — throwaway secrets, never deployed,
|
||||
/// never registered anywhere real (the w1-test-doubles posture).
|
||||
/// `ALPHA` is the client-dial fixture; `BRAVO`/`CHARLIE` are two
|
||||
/// independent host-key fixtures.
|
||||
const ALPHA_PEM: &str = concat!(
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\n",
|
||||
"b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n",
|
||||
"QyNTUxOQAAACA7vQRGU0CqQMrqRUJef/2aZK/bR2mxnzBWWZHMUkq8/wAAAJgexTRBHsU0\n",
|
||||
"QQAAAAtzc2gtZWQyNTUxOQAAACA7vQRGU0CqQMrqRUJef/2aZK/bR2mxnzBWWZHMUkq8/w\n",
|
||||
"AAAEAjImC/Ay2J2FejKJYNoRllIF9+ed5Qmdd7erLCisK03Du9BEZTQKpAyupFQl5//Zpk\n",
|
||||
"r9tHabGfMFZZkcxSSrz/AAAAEmFsa3NzaC13MS1rcy1hbHBoYQECAw==\n",
|
||||
"-----END OPENSSH PRIVATE KEY-----\n",
|
||||
);
|
||||
const BRAVO_PEM: &str = concat!(
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\n",
|
||||
"b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n",
|
||||
"QyNTUxOQAAACDqobhDdf1GvNXSd8tK6HHp9tIlhWooOWSp+MS8jRTlTAAAAJjsjCqp7Iwq\n",
|
||||
"qQAAAAtzc2gtZWQyNTUxOQAAACDqobhDdf1GvNXSd8tK6HHp9tIlhWooOWSp+MS8jRTlTA\n",
|
||||
"AAAEDe5J9K933yhWNUQmQ6VdT1zBN9eu3/39IoR0Q+4hXpheqhuEN1/Ua81dJ3y0rocen2\n",
|
||||
"0iWFaig5ZKn4xLyNFOVMAAAAEmFsa3NzaC13MS1rcy1icmF2bwECAw==\n",
|
||||
"-----END OPENSSH PRIVATE KEY-----\n",
|
||||
);
|
||||
const CHARLIE_PEM: &str = concat!(
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\n",
|
||||
"b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n",
|
||||
"QyNTUxOQAAACAnkAMvpNU6zs25fONkvgqt5E0V8eR8ySnFzv5OOdU2OQAAAJi/2ytvv9sr\n",
|
||||
"bwAAAAtzc2gtZWQyNTUxOQAAACAnkAMvpNU6zs25fONkvgqt5E0V8eR8ySnFzv5OOdU2OQ\n",
|
||||
"AAAEBqCsZ6AAe7S+AedrAYPjMBIcW10vHlaEWdIGH7jJQefCeQAy+k1TrOzbl842S+Cq3k\n",
|
||||
"TRXx5HzJKcXO/k451TY5AAAAFGFsa3NzaC13MS1rcy1jaGFybGllAQ==\n",
|
||||
"-----END OPENSSH PRIVATE KEY-----\n",
|
||||
);
|
||||
|
||||
fn unlocked_vault() -> alkvault::VaultServiceHandle {
|
||||
let vault = alkvault::VaultServiceHandle::new();
|
||||
vault.unlock_new(24).expect("in-memory vault unlocks");
|
||||
vault
|
||||
}
|
||||
|
||||
fn encrypt(vault: &alkvault::VaultServiceHandle, pem: &str) -> alkvault::EncryptedData {
|
||||
vault
|
||||
.encrypt(pem, CURRENT_KEY_VERSION)
|
||||
.expect("vault encrypts the test PEM")
|
||||
}
|
||||
|
||||
fn direct_decode(pem: &str) -> russh::keys::PrivateKey {
|
||||
russh::keys::decode_secret_key(pem, None).expect("test PEM decodes")
|
||||
}
|
||||
|
||||
fn assert_same_key(decoded: &russh::keys::PrivateKey, loaded: &russh::keys::PrivateKey) {
|
||||
assert_eq!(decoded.algorithm(), Algorithm::Ed25519);
|
||||
assert_eq!(
|
||||
fingerprint(decoded.public_key()),
|
||||
fingerprint(loaded.public_key())
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_usable_by_russh(key: &russh::keys::PrivateKey) {
|
||||
let _ = key.try_sign(b"alkssh w1 key-source usability probe");
|
||||
assert!(
|
||||
key.try_sign(b"alkssh w1 key-source usability probe")
|
||||
.is_ok(),
|
||||
"decoded key signs (usable by russh)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn round_trip_encrypt_then_source_yields_decoded_ed25519() {
|
||||
let vault = unlocked_vault();
|
||||
let blob = encrypt(&vault, ALPHA_PEM);
|
||||
|
||||
let source = VaultKeySource::new(vault.clone(), Some(blob));
|
||||
let loaded = source.load().await.expect("loads through the vault");
|
||||
|
||||
assert_same_key(&direct_decode(ALPHA_PEM), &loaded);
|
||||
assert_usable_by_russh(&loaded);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dyn_key_source_handle_honors_the_load() {
|
||||
let vault = unlocked_vault();
|
||||
let blob = encrypt(&vault, ALPHA_PEM);
|
||||
|
||||
let source: Arc<dyn KeySource> = Arc::new(VaultKeySource::new(vault, Some(blob)));
|
||||
let loaded = source.load().await.expect("loads through the dyn handle");
|
||||
|
||||
assert_same_key(&direct_decode(ALPHA_PEM), &loaded);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_key_set_loads_in_registration_order() {
|
||||
let vault = unlocked_vault();
|
||||
let blobs = vec![encrypt(&vault, BRAVO_PEM), encrypt(&vault, CHARLIE_PEM)];
|
||||
|
||||
let source: Arc<dyn HostKeySource> = Arc::new(VaultHostKeySource::new(vault, blobs));
|
||||
let loaded = source.load().await.expect("host keys load");
|
||||
|
||||
assert_eq!(loaded.len(), 2);
|
||||
assert_same_key(&direct_decode(BRAVO_PEM), &loaded[0]);
|
||||
assert_same_key(&direct_decode(CHARLIE_PEM), &loaded[1]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn source_load_keeps_working_after_vault_lock() {
|
||||
let vault = unlocked_vault();
|
||||
let blob = encrypt(&vault, ALPHA_PEM);
|
||||
let source = VaultKeySource::new(vault.clone(), Some(blob));
|
||||
let loaded = source.load().await.expect("loads unlocked");
|
||||
|
||||
vault.lock();
|
||||
assert_same_key(&direct_decode(ALPHA_PEM), &loaded);
|
||||
|
||||
source
|
||||
.load()
|
||||
.await
|
||||
.expect_err("re-loads refuse once locked");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn locked_vault_maps_to_locked() {
|
||||
let blob = encrypt(&unlocked_vault(), ALPHA_PEM);
|
||||
let vault = alkvault::VaultServiceHandle::new();
|
||||
|
||||
let err = VaultKeySource::new(vault, Some(blob))
|
||||
.load()
|
||||
.await
|
||||
.expect_err("locked vault is a refusal");
|
||||
assert!(
|
||||
matches!(err, KeySourceError::Locked),
|
||||
"expected Locked, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn absent_blob_maps_to_absent() {
|
||||
let vault = unlocked_vault();
|
||||
|
||||
let err = VaultKeySource::new(vault, None)
|
||||
.load()
|
||||
.await
|
||||
.expect_err("no record is a refusal");
|
||||
assert!(
|
||||
matches!(err, KeySourceError::Absent),
|
||||
"expected Absent, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tampered_ciphertext_maps_to_decryption() {
|
||||
let vault = unlocked_vault();
|
||||
let mut blob = encrypt(&vault, ALPHA_PEM);
|
||||
let tampered = flip_byte_at(&blob.data, 4);
|
||||
blob.data = tampered;
|
||||
|
||||
let err = VaultKeySource::new(vault, Some(blob))
|
||||
.load()
|
||||
.await
|
||||
.expect_err("tampered ciphertext is a refusal");
|
||||
assert!(
|
||||
matches!(err, KeySourceError::Decryption(_)),
|
||||
"expected Decryption, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wrong_mnemonic_vault_maps_to_decryption() {
|
||||
let holder = unlocked_vault();
|
||||
let blob = encrypt(&holder, ALPHA_PEM);
|
||||
let stranger = unlocked_vault();
|
||||
|
||||
let err = VaultKeySource::new(stranger, Some(blob))
|
||||
.load()
|
||||
.await
|
||||
.expect_err("an unrelated vault cannot open the record");
|
||||
assert!(
|
||||
matches!(err, KeySourceError::Decryption(_)),
|
||||
"expected Decryption, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_version_maps_to_unknown_key_version() {
|
||||
let vault = unlocked_vault();
|
||||
let mut blob = encrypt(&vault, ALPHA_PEM);
|
||||
|
||||
blob.key_version = 0;
|
||||
let err = VaultKeySource::new(vault.clone(), Some(blob.clone()))
|
||||
.load()
|
||||
.await
|
||||
.expect_err("version 0 is refused before crypto");
|
||||
assert!(
|
||||
matches!(err, KeySourceError::UnknownKeyVersion(_)),
|
||||
"expected UnknownKeyVersion, got {err:?}"
|
||||
);
|
||||
|
||||
blob.key_version = 1;
|
||||
let err = VaultKeySource::new(vault, Some(blob))
|
||||
.load()
|
||||
.await
|
||||
.expect_err("version 1 is refused before crypto");
|
||||
assert!(
|
||||
matches!(err, KeySourceError::UnknownKeyVersion(_)),
|
||||
"expected UnknownKeyVersion, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn undecodable_plaintext_maps_to_decode() {
|
||||
let vault = unlocked_vault();
|
||||
let blob = encrypt(&vault, "this is not a key");
|
||||
|
||||
let err = VaultKeySource::new(vault, Some(blob))
|
||||
.load()
|
||||
.await
|
||||
.expect_err("decrypted non-key plaintext is a refusal");
|
||||
assert!(
|
||||
matches!(err, KeySourceError::Decode(_)),
|
||||
"expected Decode, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn locked_vault_at_host_key_bootstrap_is_a_refusal() {
|
||||
let blobs = vec![encrypt(&unlocked_vault(), BRAVO_PEM)];
|
||||
let vault = alkvault::VaultServiceHandle::new();
|
||||
|
||||
let err = VaultHostKeySource::new(vault, blobs)
|
||||
.load()
|
||||
.await
|
||||
.expect_err("locked vault at bootstrap is a refusal");
|
||||
assert!(
|
||||
matches!(err, KeySourceError::Locked),
|
||||
"expected Locked, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_host_key_set_is_absent() {
|
||||
let vault = unlocked_vault();
|
||||
|
||||
let err = VaultHostKeySource::new(vault, Vec::new())
|
||||
.load()
|
||||
.await
|
||||
.expect_err("no host-key registration is a refusal");
|
||||
assert!(
|
||||
matches!(err, KeySourceError::Absent),
|
||||
"expected Absent, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rotation_preserves_key_identity_through_the_source() {
|
||||
let vault = unlocked_vault();
|
||||
let blob = encrypt(&vault, ALPHA_PEM);
|
||||
|
||||
let rotated = vault.rotate(&blob, 3).expect("rotates to v3");
|
||||
assert_ne!(blob.key_version, rotated.key_version);
|
||||
|
||||
let loaded = VaultKeySource::new(vault, Some(rotated))
|
||||
.load()
|
||||
.await
|
||||
.expect("the rotated record still loads");
|
||||
assert_same_key(&direct_decode(ALPHA_PEM), &loaded);
|
||||
}
|
||||
|
||||
fn flip_byte_at(text: &str, index: usize) -> String {
|
||||
let mut bytes = text.as_bytes().to_vec();
|
||||
if bytes[index] == b'A' {
|
||||
bytes[index] = b'B';
|
||||
} else {
|
||||
bytes[index] = b'A';
|
||||
}
|
||||
String::from_utf8(bytes).expect("base64 mutation stays text")
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,9 @@
|
||||
mod error;
|
||||
mod fingerprint;
|
||||
mod host_key;
|
||||
mod key_source;
|
||||
|
||||
pub use error::KeySourceError;
|
||||
pub use fingerprint::fingerprint;
|
||||
pub use host_key::{HostKeyPolicy, PresentedHostKey, RejectAll};
|
||||
pub use key_source::{HostKeySource, KeySource, VaultHostKeySource, VaultKeySource};
|
||||
Reference in new issue
Block a user