test(doubles): land the wave-1 in-memory seams + unified ed25519 fixtures
The `w1-test-doubles` task: the in-memory seams every later wave's contract tests run against, behind their production seams. - tests/common/mod.rs — the doubles, test-only (nothing reachable from lib.rs, no feature): MemIdentityProvider (registered fingerprints -> alkcall Identity resolutions over the IdentityProvider trait; token path resolves none per ADR-004), TestVault (a wrapper over alkvault 0.1's shipped in-memory construction — resolving the flagged vault-double question: wrap, not re-impl), MemHostKeyPolicy (exact (host, fingerprint) rules + explicit default verdict, fail-closed by default, inspectable decision log), and the fixture table. Module docs record the location/vault/fixture decisions and the row/stamp discipline (greppable Contract stamps). - tests/vectors/ — one unified fixture set: four ssh-keygen-minted ed25519 keypairs (committed .key + .pub + fingerprints.txt recorded from ssh-keygen -lf, OpenSSH_9.6p1). The previous inline PEM constants in src/key_source.rs were rejected by OpenSSH itself (russh-only decodable); all keys were re-minted and the overlapping vector set retired — one fixture set now serves the parity tests, the custody round-trip, and every later auth/mapping test (src-unit tests share it via include_str!). - tests/doubles.rs — the end-to-end rows: register -> resolve, seal -> source -> decode (+ locked/absent/tampered postures), host-key set in order, policy verdicts honored-and-logged. - tests/fingerprint_parity.rs and src/key_source.rs tests moved onto the shared fixture files; src changes are #cfg(test) only. Verification: cargo test (33 + 9 + 5 green; --all-features also green), cargo clippy --all-targets -- -D warnings clean, cargo fmt --check clean, taskgraph validate ok (12 tasks).
This commit is contained in:
1 parent
949d0bbbe4
commit
214690b9c2
14 files changed
+933
-113
No files matched your search
+32
-33
@@ -235,39 +235,18 @@ mod tests {
|
||||
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",
|
||||
);
|
||||
/// The committed test-key fixtures (`tests/vectors/`) — the
|
||||
/// wave-1 fixture set, read by this `#[cfg(test)]` module through
|
||||
/// `include_str!` (the `tests/common/mod.rs` double module is the
|
||||
/// tests/-side owner; src-unit tests never own fixture copies).
|
||||
/// Throwaway `ssh-keygen`-minted keys, never registered anywhere
|
||||
/// real (`ALPHA` is the client-dial fixture; `BRAVO`/`CHARLIE`
|
||||
/// the host-key pair). Their `ssh-keygen -lf` fingerprints are
|
||||
/// recorded in `tests/vectors/fingerprints.txt` — the fixture
|
||||
/// parity row below asserts this module sees the same values.
|
||||
const ALPHA_PEM: &str = include_str!("../tests/vectors/alpha.key");
|
||||
const BRAVO_PEM: &str = include_str!("../tests/vectors/bravo.key");
|
||||
const CHARLIE_PEM: &str = include_str!("../tests/vectors/charlie.key");
|
||||
|
||||
fn unlocked_vault() -> alkvault::VaultServiceHandle {
|
||||
let vault = alkvault::VaultServiceHandle::new();
|
||||
@@ -275,6 +254,26 @@ mod tests {
|
||||
vault
|
||||
}
|
||||
|
||||
/// Fixture parity for this module's `include_str!` reads: the
|
||||
/// computed fingerprints match the independent-issuer record
|
||||
/// (`ssh-keygen -lf`, `tests/vectors/fingerprints.txt`).
|
||||
#[test]
|
||||
fn fixture_keys_match_their_ssh_keygen_recorded_fingerprints() {
|
||||
let manifest = include_str!("../tests/vectors/fingerprints.txt");
|
||||
for (name, pem) in [
|
||||
("alpha", ALPHA_PEM),
|
||||
("bravo", BRAVO_PEM),
|
||||
("charlie", CHARLIE_PEM),
|
||||
] {
|
||||
let key = russh::keys::decode_secret_key(pem, None).expect("fixture PEM decodes");
|
||||
let fp = fingerprint(key.public_key());
|
||||
assert!(
|
||||
manifest.contains(&format!("{name}.pub {fp}")),
|
||||
"{name}'s computed fingerprint is not the recorded value"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn encrypt(vault: &alkvault::VaultServiceHandle, pem: &str) -> alkvault::EncryptedData {
|
||||
vault
|
||||
.encrypt(pem, CURRENT_KEY_VERSION)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: w1-test-doubles
|
||||
name: Wave 1 — test doubles (the in-memory seams)
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [w1-error-model, w1-key-sources, w1-host-key-policy]
|
||||
scope: narrow
|
||||
risk: low
|
||||
@@ -64,8 +64,72 @@ non-test code.
|
||||
|
||||
## Notes
|
||||
|
||||
> To be filled by implementation agent
|
||||
- **Vault-double shape (recorded, flagged for the wave-1 gate):** alkvault
|
||||
0.1 ships an in-memory construction — `VaultServiceHandle::new()` is an
|
||||
in-process vault that `unlock_new` arms in-memory. The double is a
|
||||
**wrapper** (`TestVault` in `tests/common/mod.rs`) around that
|
||||
construction, not a local re-impl behind the source trait: the real
|
||||
decrypt taxonomy, GCM integrity behavior, and rotation semantics are
|
||||
exercised verbatim through the production `VaultKeySource` /
|
||||
`VaultHostKeySource` seams, and the POC-2 §3 postures (locked /
|
||||
absent / tampered / wrong seed) ride the real seams.
|
||||
- **Fixture decision (recorded):** committed static OpenSSH-PEM test
|
||||
keys, minted by `ssh-keygen` at vector-generation time — not generated
|
||||
at test time (determinism, no tooling dependency). Committed private
|
||||
halves are throwaway fixtures by the task's own posture, documented as
|
||||
such in `tests/vectors/fingerprints.txt` and in the common module docs.
|
||||
- **Location decision (recorded):** the doubles live only under
|
||||
`tests/common/mod.rs`, reached with `mod common;` — the alkgit
|
||||
convention, adapted. Nothing doubles-related is reachable from
|
||||
`lib.rs`; no feature gates them. `src/`-unit tests that need the
|
||||
fixture material read the committed *files* via `include_str!`
|
||||
(`src/key_source.rs` tests), never a copy — the single fixture set is
|
||||
shared by the src-side and tests/-side suites. dead_code is allowed
|
||||
module-wide in `tests/common/` (each test binary compiles the whole
|
||||
shared module and never exercises all of it).
|
||||
- **The fixture set was unified and re-minted (flagged for the gate):**
|
||||
the previous state had two overlapping throwaway sets — public-only
|
||||
vectors under `tests/vectors/` (fingerprint parity) and inline
|
||||
private-PEM constants in `src/key_source.rs` — and the inline PEM
|
||||
constants, while decodable by russh, were **rejected by OpenSSH
|
||||
itself** (`ssh-keygen` → "error in libcrypto"): unusable as
|
||||
cross-issuer fixtures. All four keys (`alpha`/`bravo`/`charlie`/
|
||||
`delta`) were freshly minted with OpenSSH_9.6p1's ssh-keygen, their
|
||||
fingerprints recorded byte-verbatim in `tests/vectors/fingerprints.txt`,
|
||||
and the two sets unified into one fixture table (`FIXTURE_KEYS`) —
|
||||
committed `.key` + `.pub` files, served to the fingerprint parity
|
||||
tests, the vault round-trip, and every later auth/mapping test. The
|
||||
old vector-only set was retired (its keys had no private halves).
|
||||
- The row/stamp discipline is established in `tests/common/mod.rs`'s
|
||||
module docs (adapted from the sibling crates): rows state properties
|
||||
and carry greppable `Contract stamp:` items citing the pinning
|
||||
ADR § — `grep -rn "Contract stamp:" tests/` is the inventory (11
|
||||
stamps across the 11 landed rows).
|
||||
- No `src/` non-test changes: `src/key_source.rs` changed only inside
|
||||
its `#[cfg(test)]` module (fixture `include_str!`s + one parity row);
|
||||
`tests/common/mod.rs` and `tests/doubles.rs` are the new surfaces.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
The wave-1 test-double convention landed: `tests/common/mod.rs` carries
|
||||
`MemIdentityProvider` (registered fingerprints → alkcall `Identity`
|
||||
resolutions; token path resolves none — ADR-004's fingerprint-only
|
||||
disposition), `TestVault` (a wrapper over alkvault 0.1's shipped
|
||||
in-memory construction — the pinned resolution of the flagged
|
||||
vault-double question), `MemHostKeyPolicy` (exact `(host, fingerprint)`
|
||||
rules + explicit default verdict — fail-closed by default, permissive
|
||||
only by explicit choice — with a fully inspectable decision log), and
|
||||
the unified ed25519 fixture table (`FIXTURE_KEYS`: four committed
|
||||
`ssh-keygen`-minted OpenSSH-PEM key pairs with recorded
|
||||
`ssh-keygen -lf` fingerprints in `tests/vectors/fingerprints.txt`). The
|
||||
end-to-end rows in `tests/doubles.rs` run the seams through the
|
||||
production surfaces: register → resolve, seal → source → decode
|
||||
(round-trip and the locked/absent/tampered postures), verdict →
|
||||
honored-and-logged. `src/key_source.rs` and
|
||||
`tests/fingerprint_parity.rs` were moved onto the shared fixture files
|
||||
(one fixture set, no copies; the previous inline PEMs were discovered
|
||||
to be OpenSSH-rejected and were replaced by freshly minted keys). All
|
||||
doubles live in test-only compilation (`tests/`), with nothing
|
||||
doubles-related reachable from the public API. Verified: `cargo test`
|
||||
(33 + 9 + 5 green; also `--all-features`), `cargo clippy --all-targets
|
||||
-- -D warnings`, `cargo fmt --check` clean.
|
||||
@@ -0,0 +1,369 @@
|
||||
//! alkssh's wave-1 test doubles — the in-memory seams every later
|
||||
//! wave's contract tests run against (the alkgit `tests/common/`
|
||||
//! convention, adapted): an in-memory `IdentityProvider`
|
||||
//! ([`MemIdentityProvider`]), the in-memory vault seam ([`TestVault`]),
|
||||
//! an in-memory `HostKeyPolicy` ([`MemHostKeyPolicy`]), and the
|
||||
//! ed25519 test-key fixtures ([`FIXTURE_KEYS`]).
|
||||
//!
|
||||
//! **Location decision (recorded, the OQ-03 freeze-inventory
|
||||
//! discipline):** the doubles live *only* here — a shared module under
|
||||
//! `tests/` reached with `mod common;` — and never in the public API:
|
||||
//! no re-export from `lib.rs`, no feature gate, nothing doubles-related
|
||||
//! reachable from the crate root. The doubles' shapes are test
|
||||
//! surface, test-versioned freely; the only `src/`-side consumers are
|
||||
//! `#[cfg(test)]` modules reading the fixture *files* through
|
||||
//! `include_str!`, never a public export.
|
||||
//!
|
||||
//! **Vault double decision (recorded, flagged for the wave-1 gate):**
|
||||
//! alkvault 0.1 ships an in-memory construction —
|
||||
//! `VaultServiceHandle::new()` is an in-process vault that
|
||||
//! `unlock_new` arms in-memory. The double is therefore a *wrapper*
|
||||
//! ([`TestVault`]) around that construction, not a local re-impl
|
||||
//! behind the source trait: the same decrypt error taxonomy, GCM
|
||||
//! integrity behavior, and rotation semantics the production source
|
||||
//! seam sees are exercised verbatim, and the POC-2 §3 failure postures
|
||||
//! (locked / absent / tampered / wrong seed) ride the real seams.
|
||||
//!
|
||||
//! **Fixture decision (recorded):** committed static OpenSSH-PEM test
|
||||
//! keys, minted by `ssh-keygen` at vector-generation time (the
|
||||
//! independent issuer), never generated at test time — determinism and
|
||||
//! no tooling dependency. The committed private halves are throwaway
|
||||
//! fixtures, not secrets: minted for this suite only, never registered
|
||||
//! anywhere real, never deployed. Each key's fingerprint is recorded
|
||||
//! byte-verbatim from `ssh-keygen -lf` in
|
||||
//! `tests/vectors/fingerprints.txt`; [`FIXTURE_KEYS`] carries the
|
||||
//! recorded values and every fingerprint comparison in the suite
|
||||
//! asserts parity against them.
|
||||
//!
|
||||
//! Each test binary's `mod common;` compiles the whole shared module
|
||||
//! and no single binary exercises every double — dead_code is allowed
|
||||
//! module-wide so one binary's suite never fails the next's.
|
||||
//!
|
||||
//! The row/stamp discipline (adapted from the sibling crates): test
|
||||
//! functions are *rows* — the name states the property, not the
|
||||
//! caller — and each carries a doc-comment list item beginning
|
||||
//! **`Contract stamp:`** citing the pinning spec §/ADR § (never a bare
|
||||
//! wave or date). The marker is greppable:
|
||||
//! `grep -rn "Contract stamp:" tests/` is the inventory.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use alkcall::core::auth::{AuthToken, Identity, IdentityProvider};
|
||||
use alkvault::{EncryptedData, VaultServiceHandle};
|
||||
use async_trait::async_trait;
|
||||
use russh::keys::{PrivateKey, PublicKey, decode_secret_key, parse_public_key_base64};
|
||||
|
||||
use alkssh::{HostKeyPolicy, PresentedHostKey};
|
||||
|
||||
/// One committed ed25519 test-key fixture: the private OpenSSH-PEM
|
||||
/// (`tests/vectors/<name>.key`), the public one-line blob
|
||||
/// (`tests/vectors/<name>.pub`), and the fingerprint recorded
|
||||
/// byte-verbatim from `ssh-keygen -lf` (`tests/vectors/fingerprints.txt`).
|
||||
///
|
||||
/// The `pem`/`blob`/`fingerprint` values are committed constants — throwaway
|
||||
/// fixture material, never registered or deployed anywhere real.
|
||||
#[derive(Debug)]
|
||||
pub struct FixtureKey {
|
||||
/// The fixture's name (`alpha` … `delta`).
|
||||
pub name: &'static str,
|
||||
/// The OpenSSH private-key PEM, included verbatim from the
|
||||
/// committed fixture file.
|
||||
pub pem: &'static str,
|
||||
/// The public key's base64 blob — the OpenSSH one-line encoding,
|
||||
/// `ssh-ed25519 <blob>` stripped of the key-type prefix and comment.
|
||||
pub blob: &'static str,
|
||||
/// The fingerprint recorded from `ssh-keygen -lf` — the
|
||||
/// independent-issuer value every comparison in the suite asserts
|
||||
/// parity against (ADR-004 decision 1's contract invariant).
|
||||
pub fingerprint: &'static str,
|
||||
}
|
||||
|
||||
impl FixtureKey {
|
||||
/// The decoded public key (parity input; ADR-004 decision 1).
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
parse_public_key_base64(self.blob).expect("committed fixture blob parses")
|
||||
}
|
||||
|
||||
/// The decoded private key — `decode_secret_key(pem, None)`, the
|
||||
/// ADR-005 decision 2 conversion (the fixtures' inner format is
|
||||
/// exactly the at-rest contract: unencrypted OpenSSH PEM).
|
||||
pub fn private_key(&self) -> PrivateKey {
|
||||
decode_secret_key(self.pem, None).expect("committed fixture PEM decodes")
|
||||
}
|
||||
}
|
||||
|
||||
/// The wave-1 fixture set: four minted ed25519 keys. `alpha` is the
|
||||
/// canonical client-dial fixture; `bravo`/`charlie` are the host-key
|
||||
/// pair (rotation/multi-key rows); `delta` is the independent extra
|
||||
/// (unregistered-peer, mismatch, and wrong-key rows).
|
||||
pub const FIXTURE_KEYS: &[FixtureKey] = &[
|
||||
FixtureKey {
|
||||
name: "alpha",
|
||||
pem: include_str!("../vectors/alpha.key"),
|
||||
blob: "AAAAC3NzaC1lZDI1NTE5AAAAIM28sP5UAl3npJJ8trZgPt2kE+Af6477/Rx6J2VbM14z",
|
||||
fingerprint: "SHA256:YyaiL42Xl8tcRRUhNCiqQBsiiRWVHHDR9hpXC45EQ6A",
|
||||
},
|
||||
FixtureKey {
|
||||
name: "bravo",
|
||||
pem: include_str!("../vectors/bravo.key"),
|
||||
blob: "AAAAC3NzaC1lZDI1NTE5AAAAIMvu9S+hH9Dug7fdiHLeW+B9kee+i495jPAdBH8YaOvQ",
|
||||
fingerprint: "SHA256:yQf/w2wYBoZ9adDlRGbcNh7AHE3mbqWRCqoGHra1ib0",
|
||||
},
|
||||
FixtureKey {
|
||||
name: "charlie",
|
||||
pem: include_str!("../vectors/charlie.key"),
|
||||
blob: "AAAAC3NzaC1lZDI1NTE5AAAAIKL8zAzLkR0jKFY2l+QrBjC3Cv4Q3OBUUCX7rQV1jXQe",
|
||||
fingerprint: "SHA256:b5jdwosbZUx5nsY5zfr/ZCt+4sJYehVBq7Xl/wstJ9U",
|
||||
},
|
||||
FixtureKey {
|
||||
name: "delta",
|
||||
pem: include_str!("../vectors/delta.key"),
|
||||
blob: "AAAAC3NzaC1lZDI1NTE5AAAAIIcMK+njeiGqh8THEBPU9MTYrSJ3WHvxv3ZH11JTS/SU",
|
||||
fingerprint: "SHA256:4MiQ0KGoTEaklHBTlxPDuQkeu0FTyqEbne10K6Z8CKs",
|
||||
},
|
||||
];
|
||||
|
||||
/// Looks a fixture up by name — `fixture("alpha")` is the canonical
|
||||
/// client-dial key.
|
||||
pub fn fixture(name: &str) -> &'static FixtureKey {
|
||||
FIXTURE_KEYS
|
||||
.iter()
|
||||
.find(|k| k.name == name)
|
||||
.unwrap_or_else(|| panic!("no fixture named {name}"))
|
||||
}
|
||||
|
||||
/// Builds an [`Identity`] for registration (`id` + scopes; the
|
||||
/// `resources` map is empty — the wire layer only needs the resolved
|
||||
/// identity, richer ACL shapes are the assembly's).
|
||||
pub fn identity(id: &str, scopes: &[&str]) -> Identity {
|
||||
Identity {
|
||||
id: id.into(),
|
||||
scopes: scopes.iter().map(|s| s.to_string()).collect(),
|
||||
resources: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The in-memory [`IdentityProvider`] — registered fingerprints →
|
||||
/// [`Identity`] resolutions over alkcall's trait verbatim.
|
||||
///
|
||||
/// The double the wave-2 auth bridge and dispatch tests resolve
|
||||
/// against, and the analog of serving `Arc<dyn IdentityProvider>` in
|
||||
/// [`alkssh::ServeConfig`]: the fingerprint → identity path is ADR-004
|
||||
/// decision 1's only resolution shape.
|
||||
pub struct MemIdentityProvider {
|
||||
identities: parking_lot::RwLock<HashMap<String, Identity>>,
|
||||
}
|
||||
|
||||
impl MemIdentityProvider {
|
||||
/// An empty registry — nothing resolves until registered
|
||||
/// (fail-closed: unregistered fingerprints decline).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
identities: parking_lot::RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers (or replaces) one fingerprint → identity mapping.
|
||||
pub fn register(&self, fingerprint: &str, identity: Identity) {
|
||||
self.identities
|
||||
.write()
|
||||
.insert(fingerprint.to_string(), identity);
|
||||
}
|
||||
|
||||
/// Removes a registration — the post-resolution-revoke shape.
|
||||
pub fn revoke(&self, fingerprint: &str) {
|
||||
self.identities.write().remove(fingerprint);
|
||||
}
|
||||
|
||||
/// The registered fingerprints — inspection for test assertions.
|
||||
pub fn registered_fingerprints(&self) -> Vec<String> {
|
||||
let mut keys = self
|
||||
.identities
|
||||
.read()
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<Vec<String>>();
|
||||
keys.sort();
|
||||
keys
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MemIdentityProvider {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl IdentityProvider for MemIdentityProvider {
|
||||
/// The fingerprint path — ADR-004 decision 1's single resolution
|
||||
/// shape: exact string equality against registered values.
|
||||
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
|
||||
self.identities.read().get(fingerprint).cloned()
|
||||
}
|
||||
|
||||
/// The token path resolves nothing in the SSH direction —
|
||||
/// ADR-004's dispositions are fingerprint-only on this surface
|
||||
/// (`auth-bridge.md`); the double never invents a token shape.
|
||||
fn resolve_from_token(&self, _: &AuthToken) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The in-memory vault seam — a wrapper over alkvault 0.1's own
|
||||
/// in-memory construction ([`VaultServiceHandle`], unlocked at
|
||||
/// construction via `unlock_new`).
|
||||
///
|
||||
/// The locked/absent/tampered/mismatch failure postures (POC-2 §3) are
|
||||
/// exercised *through the real production seams* the source traits
|
||||
/// call, against this handle — the double exists so tests never touch
|
||||
/// vault lifecycle decisions themselves: [`TestVault::unlocked`] arms
|
||||
/// it, `lock` closes it, and the sources keep refusing, never
|
||||
/// retrying (the crate never unlocks — ADR-005's separation).
|
||||
#[derive(Clone)]
|
||||
pub struct TestVault {
|
||||
handle: VaultServiceHandle,
|
||||
}
|
||||
|
||||
impl TestVault {
|
||||
/// A freshly armed in-memory vault — unlocked, ready to encrypt.
|
||||
pub fn unlocked() -> Self {
|
||||
let handle = VaultServiceHandle::new();
|
||||
handle
|
||||
.unlock_new(24)
|
||||
.expect("in-memory vault arms unlocked");
|
||||
Self { handle }
|
||||
}
|
||||
|
||||
/// The vault handle the production sources consume — clones share
|
||||
/// the same vault state (locking through one is visible to all).
|
||||
pub fn handle(&self) -> VaultServiceHandle {
|
||||
self.handle.clone()
|
||||
}
|
||||
|
||||
/// Seals one fixture key's PEM into an at-rest record — the
|
||||
/// ADR-005 inner-format contract (unencrypted OpenSSH PEM in an
|
||||
/// `EncryptedData` record).
|
||||
pub fn seal(&self, key: &FixtureKey) -> EncryptedData {
|
||||
self.encrypt(key.pem)
|
||||
}
|
||||
|
||||
/// Encrypts an arbitrary plaintext (the non-key and tamper rows
|
||||
/// need it; ADR-005 decision 4's decode-refusal shape).
|
||||
pub fn encrypt(&self, plaintext: &str) -> EncryptedData {
|
||||
self.handle
|
||||
.encrypt(plaintext, alkvault::CURRENT_KEY_VERSION)
|
||||
.expect("in-memory vault encrypts the plaintext")
|
||||
}
|
||||
|
||||
/// Locks the vault — the locked-vault posture (POC-2 §3, the
|
||||
/// `KeySourceError::Locked` refusal class).
|
||||
pub fn lock(&self) {
|
||||
self.handle.lock();
|
||||
}
|
||||
|
||||
/// The vault's lifecycle state, for posture assertions.
|
||||
pub fn is_unlocked(&self) -> bool {
|
||||
self.handle.is_unlocked()
|
||||
}
|
||||
}
|
||||
|
||||
/// The in-memory [`HostKeyPolicy`] — a configurable verdict double with
|
||||
/// an inspectable decision log, for the wave-4 dial tests (and the
|
||||
/// case-1/case-2 rows of every host-key contract test).
|
||||
///
|
||||
/// Verdicts: a rules list of exact `(host, fingerprint)` matches —
|
||||
/// last matching rule wins — falling back to a configurable
|
||||
/// default verdict for unlisted pairs. The default of the
|
||||
/// **default-constructed double is reject** (the crate's fail-closed
|
||||
/// posture, `OQ-05`); permissive verdicts are the test's explicit
|
||||
/// choice, mirroring "permissive policy is the assembly's to write".
|
||||
/// Every decision is recorded with the full [`PresentedHostKey`]
|
||||
/// context; the log is the inspectable record.
|
||||
pub struct MemHostKeyPolicy {
|
||||
default_verdict: bool,
|
||||
rules: parking_lot::RwLock<Vec<Rule>>,
|
||||
log: parking_lot::Mutex<Vec<Decision>>,
|
||||
}
|
||||
|
||||
struct Rule {
|
||||
host: String,
|
||||
fingerprint: String,
|
||||
accept: bool,
|
||||
}
|
||||
|
||||
/// One recorded host-key decision — the presented key and the verdict
|
||||
/// the policy returned.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Decision {
|
||||
/// The presented key that was decided.
|
||||
pub presented: PresentedHostKey,
|
||||
/// The verdict: `true` = accept, `false` = decline.
|
||||
pub accepted: bool,
|
||||
}
|
||||
|
||||
impl MemHostKeyPolicy {
|
||||
/// A decision double with the fail-closed default (reject
|
||||
/// everything without a matching accept rule).
|
||||
pub fn new() -> Self {
|
||||
Self::with_default(false)
|
||||
}
|
||||
|
||||
/// A decision double with an explicit default verdict — the
|
||||
/// case-2 permissive posture (`accepting` doubles) is the test's
|
||||
/// explicit choice, never a default.
|
||||
pub fn with_default(default_verdict: bool) -> Self {
|
||||
Self {
|
||||
default_verdict,
|
||||
rules: parking_lot::RwLock::new(Vec::new()),
|
||||
log: parking_lot::Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a rule: exact `(host, fingerprint)` → verdict. Last match
|
||||
/// wins, so later rules override earlier ones.
|
||||
pub fn rule(&self, host: &str, fingerprint: &str, accept: bool) {
|
||||
self.rules.write().push(Rule {
|
||||
host: host.into(),
|
||||
fingerprint: fingerprint.into(),
|
||||
accept,
|
||||
});
|
||||
}
|
||||
|
||||
/// Pins an accept rule for one `(host, fingerprint)` — the case-1
|
||||
/// trust-anchor shape (hard accept on exact match).
|
||||
pub fn pin(&self, host: &str, fingerprint: &str) {
|
||||
self.rule(host, fingerprint, true);
|
||||
}
|
||||
|
||||
/// The decision log — every [`Decision`] in call order.
|
||||
pub fn decisions(&self) -> Vec<Decision> {
|
||||
self.log.lock().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MemHostKeyPolicy {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HostKeyPolicy for MemHostKeyPolicy {
|
||||
async fn decide(&self, presented: &PresentedHostKey) -> bool {
|
||||
let rules = self.rules.read();
|
||||
let verdict = rules
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|r| r.host == presented.host && r.fingerprint == presented.fingerprint)
|
||||
.map(|r| r.accept)
|
||||
.unwrap_or(self.default_verdict);
|
||||
drop(rules);
|
||||
self.log.lock().push(Decision {
|
||||
presented: presented.clone(),
|
||||
accepted: verdict,
|
||||
});
|
||||
verdict
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! The wave-1 doubles' end-to-end rows — the seams exercised through
|
||||
//! the doubles exactly as the later waves' contract tests will run
|
||||
//! against them: fingerprint registration resolution, the vault
|
||||
//! custody round-trip and its failure postures, and the host-key
|
||||
//! policy verdict path.
|
||||
//!
|
||||
//! The row/stamp discipline (see `tests/common/mod.rs`): assertions
|
||||
//! are named rows stating the property; every row carries its
|
||||
//! `Contract stamp:` citing the pinning §/ADR.
|
||||
|
||||
mod common;
|
||||
|
||||
use alkcall::core::auth::{AuthToken, IdentityProvider};
|
||||
|
||||
use alkssh::{
|
||||
HostKeyPolicy, HostKeySource, KeySource, KeySourceError, PresentedHostKey, VaultHostKeySource,
|
||||
VaultKeySource, fingerprint,
|
||||
};
|
||||
use russh::keys::signature::Signer;
|
||||
|
||||
use common::{FIXTURE_KEYS, MemHostKeyPolicy, MemIdentityProvider, TestVault, fixture, identity};
|
||||
|
||||
/// The registered fingerprint resolves with the registered identity;
|
||||
/// unregistered fingerprints decline (fail-closed empty registry).
|
||||
///
|
||||
/// - *Contract stamp:* ADR-004 decision 1 (fingerprint →
|
||||
/// `resolve_from_fingerprint` → `Some` = accept; `None` = reject).
|
||||
/// - *Contract stamp:* ADR-005 context (client fingerprints never
|
||||
/// involve the vault — the `IdentityProvider` path only).
|
||||
#[test]
|
||||
fn mem_identity_provider_registered_fingerprint_resolves() {
|
||||
let alpha = fixture("alpha");
|
||||
let registered_fp = fingerprint(&alpha.public_key());
|
||||
let provider = MemIdentityProvider::new();
|
||||
provider.register(
|
||||
®istered_fp,
|
||||
identity("fixture-alpha", &["relay:connect"]),
|
||||
);
|
||||
|
||||
let resolved = provider
|
||||
.resolve_from_fingerprint(®istered_fp)
|
||||
.expect("registered fingerprint resolves");
|
||||
assert_eq!(resolved.id, "fixture-alpha");
|
||||
assert_eq!(resolved.scopes, vec!["relay:connect".to_string()]);
|
||||
assert_eq!(provider.registered_fingerprints(), vec![registered_fp]);
|
||||
assert!(
|
||||
provider
|
||||
.resolve_from_fingerprint(&fingerprint(&fixture("delta").public_key()))
|
||||
.is_none(),
|
||||
"unregistered fingerprint declines"
|
||||
);
|
||||
}
|
||||
|
||||
/// A revocation makes the fingerprint resolve `None` again — the
|
||||
/// resolution set is inspectable and mutable.
|
||||
///
|
||||
/// - *Contract stamp:* ADR-004 decision 1 (`None` → reject is the
|
||||
/// only disposition for unresolvable fingerprints).
|
||||
#[test]
|
||||
fn mem_identity_provider_revocation_resolves_none() {
|
||||
let alpha = fixture("alpha");
|
||||
let provider = MemIdentityProvider::new();
|
||||
provider.register(
|
||||
alpha.fingerprint,
|
||||
identity("fixture-alpha", &["relay:connect"]),
|
||||
);
|
||||
provider.revoke(alpha.fingerprint);
|
||||
assert!(
|
||||
provider
|
||||
.resolve_from_fingerprint(alpha.fingerprint)
|
||||
.is_none(),
|
||||
"revoked fingerprint declines (fail-closed)"
|
||||
);
|
||||
assert!(provider.registered_fingerprints().is_empty());
|
||||
}
|
||||
|
||||
/// The token path resolves nothing on the SSH surface — the double
|
||||
/// never invents a token shape.
|
||||
///
|
||||
/// - *Contract stamp:* ADR-004 decision 1 (the fingerprint is the
|
||||
/// SSH-shaped resolution path); `auth-bridge.md` (`IdentityProvider`
|
||||
/// is the one SSH-adjacent bridge, fingerprint-shaped).
|
||||
#[test]
|
||||
fn mem_identity_provider_token_path_resolves_none() {
|
||||
let provider = MemIdentityProvider::new();
|
||||
let token = AuthToken {
|
||||
raw: b"tok".to_vec(),
|
||||
};
|
||||
assert!(provider.resolve_from_token(&token).is_none());
|
||||
}
|
||||
|
||||
/// Every fixture key's fingerprint — whether derived via the private
|
||||
/// PEM or the committed public blob — equals its `ssh-keygen -lf`
|
||||
/// recorded value. This row keeps the fixture set honest for every
|
||||
/// consumer; `tests/fingerprint_parity.rs` is the deep parity check.
|
||||
///
|
||||
/// - *Contract stamp:* ADR-004 decision 1 (the fingerprint string
|
||||
/// format is a contract invariant across issuers).
|
||||
/// - *Contract stamp:* wave-1 test-double convention (fixtures carry
|
||||
/// recorded `ssh-keygen -lf` fingerprints).
|
||||
#[test]
|
||||
fn fixture_keys_carry_ssh_keygen_recorded_fingerprints() {
|
||||
for key in FIXTURE_KEYS {
|
||||
let from_private = fingerprint(key.private_key().public_key());
|
||||
let from_blob = fingerprint(&key.public_key());
|
||||
assert_eq!(from_private, key.fingerprint, "fixture {}", key.name);
|
||||
assert_eq!(from_blob, key.fingerprint, "fixture {}", key.name);
|
||||
assert_eq!(from_private.len(), 50, "SHA256: + 43 unpadded base64 chars");
|
||||
}
|
||||
}
|
||||
|
||||
/// The full custody round-trip: fixture PEM sealed into an at-rest
|
||||
/// record through the vault seam, loaded through the production
|
||||
/// [`VaultKeySource`], decoded by the ADR-005 chain, and verified to
|
||||
/// be the *same* fixture key (fingerprint parity) that is usable by
|
||||
/// russh (sign-capable).
|
||||
///
|
||||
/// - *Contract stamp:* ADR-005 decision 1 (inner format: unencrypted
|
||||
/// OpenSSH-PEM in the record).
|
||||
/// - *Contract stamp:* ADR-005 decision 2 (decrypt-at-use →
|
||||
/// `Zeroizing` wrap → one-time `decode_secret_key`).
|
||||
#[tokio::test]
|
||||
async fn test_vault_seal_then_key_source_decode_round_trips() {
|
||||
let alpha = fixture("alpha");
|
||||
let vault = TestVault::unlocked();
|
||||
let blob = vault.seal(alpha);
|
||||
|
||||
let source = VaultKeySource::new(vault.handle(), Some(blob));
|
||||
let loaded = source.load().await.expect("sealed dial key loads");
|
||||
|
||||
assert_eq!(
|
||||
fingerprint(loaded.public_key()),
|
||||
alpha.fingerprint,
|
||||
"the loaded key is the fixture key"
|
||||
);
|
||||
assert!(
|
||||
loaded
|
||||
.try_sign(b"alkssh w1 doubles usability probe")
|
||||
.is_ok(),
|
||||
"the decoded key is sign-capable (russh-usable)"
|
||||
);
|
||||
}
|
||||
|
||||
/// The POC-2 §3 failure postures ride the real seams: locked vault →
|
||||
/// `Locked`, absent record → `Absent`, tampered ciphertext →
|
||||
/// `Decryption` — each a distinct variant, each a refusal, no
|
||||
/// fallback key shape.
|
||||
///
|
||||
/// - *Contract stamp:* ADR-005 decision 4 (failure modes fail closed
|
||||
/// with distinct error shapes).
|
||||
/// - *Contract stamp:* `docs/research/poc-2-vault-keys.md` §3 (the
|
||||
/// failure matrix: locked / absent / tampered / wrong seed).
|
||||
#[tokio::test]
|
||||
async fn test_vault_postures_refuse_fail_closed_through_the_source() {
|
||||
let alpha = fixture("alpha");
|
||||
|
||||
let locked = TestVault::unlocked();
|
||||
let blob = locked.seal(alpha);
|
||||
locked.lock();
|
||||
let err = VaultKeySource::new(locked.handle(), Some(blob))
|
||||
.load()
|
||||
.await
|
||||
.expect_err("locked vault refuses");
|
||||
assert!(matches!(err, KeySourceError::Locked), "got {err:?}");
|
||||
|
||||
let absent = TestVault::unlocked();
|
||||
let err = VaultKeySource::new(absent.handle(), None)
|
||||
.load()
|
||||
.await
|
||||
.expect_err("absent record refuses");
|
||||
assert!(matches!(err, KeySourceError::Absent), "got {err:?}");
|
||||
|
||||
let tampering = TestVault::unlocked();
|
||||
let mut blob = tampering.seal(alpha);
|
||||
blob.data = tamper_one_byte(&blob.data);
|
||||
let err = VaultKeySource::new(tampering.handle(), Some(blob))
|
||||
.load()
|
||||
.await
|
||||
.expect_err("tampered ciphertext refuses");
|
||||
assert!(matches!(err, KeySourceError::Decryption(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
/// The host-key set loads from the vault seam in registration order —
|
||||
/// one custody model, both directions (the decrypt-at-construction
|
||||
/// load path).
|
||||
///
|
||||
/// - *Contract stamp:* ADR-005 decision 6 (host keys ride the same
|
||||
/// model; the client's host *pin* is public config, never vault
|
||||
/// material).
|
||||
/// - *Contract stamp:* ADR-005 decision 2 (the same decrypt-and-decode
|
||||
/// chain as the dial key).
|
||||
#[tokio::test]
|
||||
async fn host_key_source_set_loads_from_the_vault_seam_in_order() {
|
||||
let (bravo, charlie) = (fixture("bravo"), fixture("charlie"));
|
||||
let vault = TestVault::unlocked();
|
||||
let blobs = vec![vault.seal(bravo), vault.seal(charlie)];
|
||||
|
||||
let source: std::sync::Arc<dyn HostKeySource> =
|
||||
std::sync::Arc::new(VaultHostKeySource::new(vault.handle(), blobs));
|
||||
let loaded = source.load().await.expect("host-key set loads");
|
||||
|
||||
let fps = loaded
|
||||
.iter()
|
||||
.map(|k| fingerprint(k.public_key()))
|
||||
.collect::<Vec<String>>();
|
||||
assert_eq!(
|
||||
fps,
|
||||
vec![
|
||||
bravo.fingerprint.to_string(),
|
||||
charlie.fingerprint.to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The policy double's verdicts are honored and its decision log is
|
||||
/// the inspectable record: pinned exact `(host, fingerprint)` pairs
|
||||
/// accept, unmatched pairs fall to the default (reject — fail-closed),
|
||||
/// and every decision is logged with the full presented context.
|
||||
///
|
||||
/// - *Contract stamp:* OQ-05 resolution (`host_key.rs` module docs —
|
||||
/// the crate is policy-blind; verdicts are the policy's).
|
||||
/// - *Contract stamp:* ADR-005 decision 6 (the host *pin* is public
|
||||
/// config; exact-string comparison by contract).
|
||||
#[tokio::test]
|
||||
async fn mem_host_key_policy_verdicts_are_recorded_and_honored() {
|
||||
let alpha = fixture("alpha");
|
||||
let presented = PresentedHostKey {
|
||||
host: "runner.example".into(),
|
||||
port: 2222,
|
||||
fingerprint: alpha.fingerprint.to_string(),
|
||||
};
|
||||
|
||||
let pinned = MemHostKeyPolicy::new();
|
||||
pinned.pin("runner.example", alpha.fingerprint);
|
||||
assert!(pinned.decide(&presented).await, "pinned pair accepts");
|
||||
|
||||
let delta_fp = PresentedHostKey {
|
||||
host: "runner.example".into(),
|
||||
port: 2222,
|
||||
fingerprint: fixture("delta").fingerprint.to_string(),
|
||||
};
|
||||
pinned.pin("runner.example", fixture("delta").fingerprint);
|
||||
assert!(pinned.decide(&delta_fp).await, "any pinned pair accepts");
|
||||
|
||||
let stranger = PresentedHostKey {
|
||||
host: "stranger.example".into(),
|
||||
port: 2222,
|
||||
fingerprint: alpha.fingerprint.to_string(),
|
||||
};
|
||||
assert!(
|
||||
!pinned.decide(&stranger).await,
|
||||
"default verdict rejects without a matching rule (fail-closed)"
|
||||
);
|
||||
|
||||
let decisions = pinned.decisions();
|
||||
assert_eq!(decisions.len(), 3);
|
||||
assert_eq!(decisions[0].presented, presented);
|
||||
assert!(decisions[0].accepted);
|
||||
assert!(decisions[1].accepted);
|
||||
assert_eq!(decisions[2].presented, stranger);
|
||||
assert!(!decisions[2].accepted);
|
||||
}
|
||||
|
||||
/// A permissive default is the test's explicit choice (never a
|
||||
/// default), and an explicit rule overrides it — the case-2
|
||||
/// ephemeral-fleet shape versus a pin-style correction.
|
||||
///
|
||||
/// - *Contract stamp:* OQ-05 resolution (`host_key.rs` — there is no
|
||||
/// accept-all built-in crate option; case-2 policies are the
|
||||
/// assembly's to write).
|
||||
/// - *Contract stamp:* ADR-004 decision 6 posture analog
|
||||
/// (fail-closed by default, permissive only by explicit choice).
|
||||
#[tokio::test]
|
||||
async fn mem_host_key_policy_accept_default_is_explicit_and_overrideable() {
|
||||
let alpha = fixture("alpha");
|
||||
let presented = PresentedHostKey {
|
||||
host: "fleet.example".into(),
|
||||
port: 2200,
|
||||
fingerprint: alpha.fingerprint.to_string(),
|
||||
};
|
||||
|
||||
let permissive = MemHostKeyPolicy::with_default(true);
|
||||
assert!(
|
||||
permissive.decide(&presented).await,
|
||||
"case-2 permissive posture is the explicit choice"
|
||||
);
|
||||
|
||||
let denying = MemHostKeyPolicy::with_default(true);
|
||||
denying.rule("fleet.example", alpha.fingerprint, false);
|
||||
assert!(
|
||||
!denying.decide(&presented).await,
|
||||
"an explicit rule overrides the default"
|
||||
);
|
||||
}
|
||||
|
||||
fn tamper_one_byte(text: &str) -> String {
|
||||
let mut bytes = text.as_bytes().to_vec();
|
||||
if bytes[4] == b'A' {
|
||||
bytes[4] = b'B';
|
||||
} else {
|
||||
bytes[4] = b'A';
|
||||
}
|
||||
String::from_utf8(bytes).expect("base64 mutation stays text")
|
||||
}
|
||||
+115
-63
@@ -1,93 +1,145 @@
|
||||
//! Fingerprint-format parity — ADR-004 decision 1's contract
|
||||
//! invariant proven against independent-issuer vectors (POC-1 §1).
|
||||
//!
|
||||
//! Each committed fixture carries its fingerprint byte-verbatim from
|
||||
//! `ssh-keygen -lf` (OpenSSH, recorded at vector-generation time; see
|
||||
//! `tests/vectors/fingerprints.txt`). The crate's single fingerprint
|
||||
//! path must reproduce those strings exactly: registration values
|
||||
//! from any standard issuer must compare equal with what this crate
|
||||
//! computes, without cross-issuer drift.
|
||||
//! The vectors are the wave-1 test-key fixture set
|
||||
//! (`tests/common/mod.rs`'s [`FIXTURE_KEYS`]; the committed files
|
||||
//! under `tests/vectors/`), each key's fingerprint recorded
|
||||
//! byte-verbatim from `ssh-keygen -lf` (OpenSSH 9.6p1, at
|
||||
//! vector-generation time; see `tests/vectors/fingerprints.txt`). The
|
||||
//! crate's single fingerprint path must reproduce those strings
|
||||
//! exactly from every issuer shape — the committed public blob, the
|
||||
//! private key's derived public half, and the committed `.pub` file —
|
||||
//! so registration values from any standard issuer compare equal,
|
||||
//! without cross-issuer drift.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::fs;
|
||||
|
||||
use common::FIXTURE_KEYS;
|
||||
|
||||
const VECTOR_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/vectors");
|
||||
|
||||
struct Vector {
|
||||
name: &'static str,
|
||||
blob: &'static str,
|
||||
fingerprint: &'static str,
|
||||
}
|
||||
|
||||
const VECTORS: &[Vector] = &[
|
||||
Vector {
|
||||
name: "alpha.pub",
|
||||
blob: "AAAAC3NzaC1lZDI1NTE5AAAAICS7Ub4A073+G450Nt2DUOwWyniGVU/lrlUoQZAUg1sd",
|
||||
fingerprint: "SHA256:yM/XYR4VM1XX3gfQqOh+wOjOCokfTSu4QqqQIeUTa+c",
|
||||
},
|
||||
Vector {
|
||||
name: "bravo.pub",
|
||||
blob: "AAAAC3NzaC1lZDI1NTE5AAAAIMNPMofKbGRA5CXtRnGsdXK/Jbj4DDOk7OutS6cOawqn",
|
||||
fingerprint: "SHA256:Ruj1hCyzfsM8rjZhP4c4I+lVGEaEG33/srVLOnVpB3s",
|
||||
},
|
||||
Vector {
|
||||
name: "charlie.pub",
|
||||
blob: "AAAAC3NzaC1lZDI1NTE5AAAAIAGJveAmhmFyBN635c/xhaveGRzafpYobIqg9J6/b8cF",
|
||||
fingerprint: "SHA256:fIcjLEaAm4aMpWEtwHm73TiIqGCX1X75HG9hGiJZ6TQ",
|
||||
},
|
||||
Vector {
|
||||
name: "delta.pub",
|
||||
blob: "AAAAC3NzaC1lZDI1NTE5AAAAIDg+r/at1NqJqwN5bUEqswoHlYAUMkJxJtKNJyWXH3UX",
|
||||
fingerprint: "SHA256:D+qcroJ1ly9fVWpDQ4Zn+ig9e0Q7e0xOrQr47ruEZWs",
|
||||
},
|
||||
];
|
||||
|
||||
/// The crate's single fingerprint path reproduces the recorded value
|
||||
/// from the committed public blob alone — the public-key-only issuer
|
||||
/// shape (a registry that only ever sees public material).
|
||||
///
|
||||
/// - *Contract stamp:* ADR-004 decision 1 (fingerprint parity with
|
||||
/// `ssh-keygen -lf`; POC-1 §1's parity checkpoint shape).
|
||||
#[test]
|
||||
fn fingerprint_parity_with_ssh_keygen_recorded_vectors() {
|
||||
for v in VECTORS {
|
||||
let key = russh::keys::parse_public_key_base64(v.blob)
|
||||
.expect("committed fixture parses as OpenSSH key material");
|
||||
fn computed_fingerprints_match_recorded_values_for_public_blobs() {
|
||||
for key in FIXTURE_KEYS {
|
||||
let parsed = russh::keys::parse_public_key_base64(key.blob)
|
||||
.expect("committed fixture blob parses as OpenSSH key material");
|
||||
assert_eq!(
|
||||
alkssh::fingerprint(&key),
|
||||
v.fingerprint,
|
||||
alkssh::fingerprint(&parsed),
|
||||
key.fingerprint,
|
||||
"parity drift on {name}",
|
||||
name = v.name
|
||||
name = key.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The private-key path — the shape every auth/mapping test uses
|
||||
/// (`decode_secret_key` → sign/serve) — yields a public half whose
|
||||
/// fingerprint is the same recorded value.
|
||||
///
|
||||
/// - *Contract stamp:* ADR-004 decision 1 (the fingerprint string
|
||||
/// format is a contract invariant across issuers).
|
||||
#[test]
|
||||
fn committed_fixture_files_match_the_recorded_blobs() {
|
||||
for v in VECTORS {
|
||||
let path = format!("{VECTOR_DIR}/{}", v.name);
|
||||
let file = fs::read_to_string(&path).expect("committed fixture file readable");
|
||||
let expected_line = format!("ssh-ed25519 {blob} ", blob = v.blob);
|
||||
fn private_derived_fingerprints_match_recorded_values() {
|
||||
for key in FIXTURE_KEYS {
|
||||
let decoded = key.private_key();
|
||||
assert_eq!(
|
||||
alkssh::fingerprint(decoded.public_key()),
|
||||
key.fingerprint,
|
||||
"private-derived parity drift on {name}",
|
||||
name = key.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The committed fixture files are the recorded keys: each `.pub`
|
||||
/// starts with the recorded blob line, and each `.key` is an
|
||||
/// unencrypted OpenSSH private-key PEM (the ADR-005 decision 1 inner
|
||||
/// format) whose decoded public half fingerprints to the recorded
|
||||
/// value.
|
||||
///
|
||||
/// - *Contract stamp:* ADR-004 decision 1 (independent-issuer vectors
|
||||
/// are the parity proof — the commitment must be the recorded key).
|
||||
/// - *Contract stamp:* ADR-005 decision 1 (the fixtures' inner format
|
||||
/// is exactly the at-rest contract: unencrypted OpenSSH PEM).
|
||||
#[test]
|
||||
fn committed_fixture_files_match_the_recorded_keys() {
|
||||
for key in FIXTURE_KEYS {
|
||||
let pub_path = format!("{VECTOR_DIR}/{}.pub", key.name);
|
||||
let pub_file = fs::read_to_string(&pub_path).expect("committed .pub readable");
|
||||
let expected_line = format!("ssh-ed25519 {blob} ", blob = key.blob);
|
||||
assert!(
|
||||
file.starts_with(&expected_line),
|
||||
pub_file.starts_with(&expected_line),
|
||||
"fixture file {name} does not start with the recorded blob",
|
||||
name = v.name
|
||||
name = key.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_vector_manifest_matches_the_inlined_vectors() {
|
||||
let manifest = fs::read_to_string(format!("{VECTOR_DIR}/fingerprints.txt"))
|
||||
.expect("fingerprint manifest readable");
|
||||
for v in VECTORS {
|
||||
let key_path = format!("{VECTOR_DIR}/{}.key", key.name);
|
||||
let key_file = fs::read_to_string(&key_path).expect("committed .key readable");
|
||||
assert!(
|
||||
manifest.contains(&format!("{} {fp}", v.name, fp = v.fingerprint)),
|
||||
"manifest does not record {name} at {fp}",
|
||||
name = v.name,
|
||||
fp = v.fingerprint
|
||||
key_file.starts_with("-----BEGIN OPENSSH PRIVATE KEY-----"),
|
||||
"fixture {name} is not an OpenSSH private-key PEM",
|
||||
name = key.name
|
||||
);
|
||||
assert_eq!(key_file, key.pem, "committed .key is the fixture PEM");
|
||||
assert!(
|
||||
!key_file.contains("ENCRYPTED"),
|
||||
"fixture {name} is unencrypted (ADR-005 decision 1)",
|
||||
name = key.name
|
||||
);
|
||||
let decoded = key.private_key();
|
||||
let from_file = decoded.public_key();
|
||||
assert_eq!(
|
||||
alkssh::fingerprint(from_file),
|
||||
key.fingerprint,
|
||||
"committed .key public half drifts on {name}",
|
||||
name = key.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The recorded manifest (`tests/vectors/fingerprints.txt`) records
|
||||
/// every fixture — no fixture ships without its independent issuer's
|
||||
/// fingerprint.
|
||||
///
|
||||
/// - *Contract stamp:* wave-1 test-double convention (recorded
|
||||
/// `ssh-keygen -lf` fingerprints, the independent-issuer record).
|
||||
#[test]
|
||||
fn recorded_manifest_covers_every_fixture() {
|
||||
let manifest =
|
||||
fs::read_to_string(format!("{VECTOR_DIR}/fingerprints.txt")).expect("manifest readable");
|
||||
for key in FIXTURE_KEYS {
|
||||
assert!(
|
||||
manifest.contains(&format!("{}.pub {fp}", key.name, fp = key.fingerprint)),
|
||||
"manifest does not record {name} at {fp}",
|
||||
name = key.name,
|
||||
fp = key.fingerprint
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The registered-string comparison is byte-identical: the computed
|
||||
/// value meets a registered `ssh-keygen` string with no padding or
|
||||
/// url-safe-alphabet drift (`+`/`/`, no `=`).
|
||||
///
|
||||
/// - *Contract stamp:* ADR-004 decision 1 (`SHA256:` + unpadded
|
||||
/// base64, standard alphabet — byte-identical comparison by
|
||||
/// contract).
|
||||
#[test]
|
||||
fn registered_string_compare_is_byte_identical() {
|
||||
let key = russh::keys::parse_public_key_base64(VECTORS[0].blob).expect("fixture parses");
|
||||
let computed = alkssh::fingerprint(&key);
|
||||
let registered: &str = VECTORS[0].fingerprint;
|
||||
let alpha = FIXTURE_KEYS
|
||||
.iter()
|
||||
.find(|k| k.name == "alpha")
|
||||
.expect("alpha fixture present");
|
||||
let computed = alkssh::fingerprint(&alpha.public_key());
|
||||
let registered: &str = alpha.fingerprint;
|
||||
assert_eq!(computed, registered);
|
||||
assert_eq!(registered.len(), 50, "SHA256: + 43 unpadded base64 chars");
|
||||
assert!(!registered.contains('=') && !registered.contains('!'));
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACDNvLD+VAJd56SSfLa2YD7dpBPgH+uO+/0ceidlWzNeMwAAAKDJQe/myUHv
|
||||
5gAAAAtzc2gtZWQyNTUxOQAAACDNvLD+VAJd56SSfLa2YD7dpBPgH+uO+/0ceidlWzNeMw
|
||||
AAAEATzyiI7Cwpy3CJBDKDepykbHiNzVpZ38kiDqa1S1p5Us28sP5UAl3npJJ8trZgPt2k
|
||||
E+Af6477/Rx6J2VbM14zAAAAF2Fsa3NzaC13MS1maXh0dXJlLWFscGhhAQIDBAUG
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -1 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICS7Ub4A073+G450Nt2DUOwWyniGVU/lrlUoQZAUg1sd alkssh-w1-vectors-alpha
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM28sP5UAl3npJJ8trZgPt2kE+Af6477/Rx6J2VbM14z alkssh-w1-fixture-alpha
|
||||
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACDL7vUvoR/Q7oO33Yhy3lvgfZHnvouPeYzwHQR/GGjr0AAAAKAUKLAWFCiw
|
||||
FgAAAAtzc2gtZWQyNTUxOQAAACDL7vUvoR/Q7oO33Yhy3lvgfZHnvouPeYzwHQR/GGjr0A
|
||||
AAAEC9aPqnyC37OKCL/VksdonIl6ICy+I8X4So6TDmnSObEMvu9S+hH9Dug7fdiHLeW+B9
|
||||
kee+i495jPAdBH8YaOvQAAAAF2Fsa3NzaC13MS1maXh0dXJlLWJyYXZvAQIDBAUG
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -1 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMNPMofKbGRA5CXtRnGsdXK/Jbj4DDOk7OutS6cOawqn alkssh-w1-vectors-bravo
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMvu9S+hH9Dug7fdiHLeW+B9kee+i495jPAdBH8YaOvQ alkssh-w1-fixture-bravo
|
||||
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACCi/MwMy5EdIyhWNpfkKwYwtwr+ENzgVFAl+60FdY10HgAAAKBk/WqJZP1q
|
||||
iQAAAAtzc2gtZWQyNTUxOQAAACCi/MwMy5EdIyhWNpfkKwYwtwr+ENzgVFAl+60FdY10Hg
|
||||
AAAEBebYYCpFNzgy2jbKpQ3YPC0mKcrm6iDIYFCyKWl2D5Z6L8zAzLkR0jKFY2l+QrBjC3
|
||||
Cv4Q3OBUUCX7rQV1jXQeAAAAGWFsa3NzaC13MS1maXh0dXJlLWNoYXJsaWUBAgME
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -1 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAGJveAmhmFyBN635c/xhaveGRzafpYobIqg9J6/b8cF alkssh-w1-vectors-charlie
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKL8zAzLkR0jKFY2l+QrBjC3Cv4Q3OBUUCX7rQV1jXQe alkssh-w1-fixture-charlie
|
||||
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACCHDCvp43ohqofExxAT1PTE2K0id1h78b92R9dSU0v0lAAAAKACHXI/Ah1y
|
||||
PwAAAAtzc2gtZWQyNTUxOQAAACCHDCvp43ohqofExxAT1PTE2K0id1h78b92R9dSU0v0lA
|
||||
AAAEDfzKwFxEpqXnfPvMr/GpLI5RTtwyOm5GT2nFrZKr/TG4cMK+njeiGqh8THEBPU9MTY
|
||||
rSJ3WHvxv3ZH11JTS/SUAAAAF2Fsa3NzaC13MS1maXh0dXJlLWRlbHRhAQIDBAUG
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -1 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDg+r/at1NqJqwN5bUEqswoHlYAUMkJxJtKNJyWXH3UX alkssh-w1-vectors-delta
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIcMK+njeiGqh8THEBPU9MTYrSJ3WHvxv3ZH11JTS/SU alkssh-w1-fixture-delta
|
||||
@@ -1,12 +1,16 @@
|
||||
# Independent-issuer fingerprint vectors for the POC-1 §1 parity
|
||||
# checkpoint (ADR-004 decision 1). Each entry pairs a committed OpenSSH
|
||||
# ed25519 public key fixture (the .pub files in this directory) with the
|
||||
# fingerprint byte-verbatim from `ssh-keygen -lf` — recorded from
|
||||
# OpenSSH 9.6p1 (Ubuntu-3ubuntu13.20) at vector-generation time. The
|
||||
# keys are throwaway test fixtures; no secret material is committed.
|
||||
# Independent-issuer fingerprint vectors: the alkssh wave-1 test-key
|
||||
# fixture set. Each entry pairs a committed OpenSSH ed25519 keypair (the
|
||||
# .key/.pub files in this directory) with its fingerprint byte-verbatim
|
||||
# from `ssh-keygen -lf` — recorded from OpenSSH_9.6p1
|
||||
# (Ubuntu-3ubuntu13.20) at vector-generation time.
|
||||
#
|
||||
# The keys are throwaway test fixtures minted for the test suite only;
|
||||
# their private halves are committed on purpose (committed keys are
|
||||
# throwaway fixtures, not secrets — they are never registered anywhere
|
||||
# real and never deployed).
|
||||
#
|
||||
# <fixture> <expected fingerprint>
|
||||
alpha.pub SHA256:yM/XYR4VM1XX3gfQqOh+wOjOCokfTSu4QqqQIeUTa+c
|
||||
bravo.pub SHA256:Ruj1hCyzfsM8rjZhP4c4I+lVGEaEG33/srVLOnVpB3s
|
||||
charlie.pub SHA256:fIcjLEaAm4aMpWEtwHm73TiIqGCX1X75HG9hGiJZ6TQ
|
||||
delta.pub SHA256:D+qcroJ1ly9fVWpDQ4Zn+ig9e0Q7e0xOrQr47ruEZWs
|
||||
alpha.pub SHA256:YyaiL42Xl8tcRRUhNCiqQBsiiRWVHHDR9hpXC45EQ6A
|
||||
bravo.pub SHA256:yQf/w2wYBoZ9adDlRGbcNh7AHE3mbqWRCqoGHra1ib0
|
||||
charlie.pub SHA256:b5jdwosbZUx5nsY5zfr/ZCt+4sJYehVBq7Xl/wstJ9U
|
||||
delta.pub SHA256:4MiQ0KGoTEaklHBTlxPDuQkeu0FTyqEbne10K6Z8CKs
|
||||
Reference in new issue
Block a user