diff --git a/src/fingerprint.rs b/src/fingerprint.rs new file mode 100644 index 0000000..ebc4b4f --- /dev/null +++ b/src/fingerprint.rs @@ -0,0 +1,62 @@ +//! The crate's single fingerprint path (ADR-004 decision 1). +//! +//! A public key's fingerprint is the SSH-shaped identity handle: the +//! auth bridge (server) resolves it through alkcall's +//! `IdentityProvider::resolve_from_fingerprint`, the host-key policy +//! path (client) surfaces it at `check_server_key`, and assembly +//! registrations store it as a string. The string format +//! (`SSHFP`-style `SHA256:` prefix over the OpenSSH wire-key digest, +//! unpadded base64) is a **contract invariant** — registration strings +//! from any standard issuer (`ssh-keygen -lf`, alkcall-side +//! computation) must compare equal to what this crate computes. +//! +//! Parity is proven against independent-issuer vectors +//! (`ssh-keygen -lf`-recorded, committed under `tests/vectors/`), not +//! assumed. There is deliberately no second formatting path in the +//! crate: a drift here would break every identity resolution silently. + +use russh::keys::{HashAlg, PublicKey}; + +/// Computes a public key's fingerprint — the single fingerprint path +/// in the crate (ADR-004 decision 1's contract invariant). +/// +/// The returned string is the OpenSSH fingerprint format: `SHA256:` +/// followed by the unpadded base64 of the SHA-256 digest over the +/// key's OpenSSH wire encoding, byte-identical to independent issuers +/// (`ssh-keygen -lf`) — parity is proven, not assumed. Every +/// fingerprint comparison in the crate and every registration value an +/// assembly stores is this string; no other formatting path exists. +pub fn fingerprint(public_key: &PublicKey) -> String { + public_key.fingerprint(HashAlg::Sha256).to_string() +} + +#[cfg(test)] +mod tests { + use super::fingerprint; + + #[test] + fn format_shape_is_prefixed_and_unpadded() { + let key = russh::keys::parse_public_key_base64( + "AAAAC3NzaC1lZDI1NTE5AAAAICS7Ub4A073+G450Nt2DUOwWyniGVU/lrlUoQZAUg1sd", + ) + .expect("fixture blob parses"); + let fp = fingerprint(&key); + assert_eq!(fp.split(':').count(), 1 + 1); + let (prefix, body) = fp.split_once(':').expect("single separator"); + assert_eq!(prefix, "SHA256"); + assert!(!body.contains('='), "unpadded: {fp}"); + assert!( + !body.contains('-') && !body.contains('_'), + "not url-safe: {fp}" + ); + } + + #[test] + fn determinism_same_key_same_string() { + let key = russh::keys::parse_public_key_base64( + "AAAAC3NzaC1lZDI1NTE5AAAAICS7Ub4A073+G450Nt2DUOwWyniGVU/lrlUoQZAUg1sd", + ) + .expect("fixture blob parses"); + assert_eq!(fingerprint(&key), fingerprint(&key)); + } +} diff --git a/src/lib.rs b/src/lib.rs index d538b8c..8304788 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,5 +23,7 @@ //! start). See also `docs/research/` and family ADR-003. mod error; +mod fingerprint; pub use error::KeySourceError; +pub use fingerprint::fingerprint; diff --git a/tasks/implementation/w1-fingerprint.md b/tasks/implementation/w1-fingerprint.md index 5f4e8b2..7b55b8c 100644 --- a/tasks/implementation/w1-fingerprint.md +++ b/tasks/implementation/w1-fingerprint.md @@ -1,7 +1,7 @@ --- id: w1-fingerprint name: Wave 1 — fingerprint computation (the contract invariant) -status: pending +status: completed depends_on: [w1-manifest] scope: narrow risk: low @@ -32,12 +32,12 @@ contract invariant in code: ## Acceptance Criteria -- [ ] Single fingerprint function via `HashAlg::Sha256`; `SHA256:` + +- [x] Single fingerprint function via `HashAlg::Sha256`; `SHA256:` + unpadded url-safe base64 output -- [ ] Parity tests against `ssh-keygen -lf`-recorded vectors pass +- [x] Parity tests against `ssh-keygen -lf`-recorded vectors pass (independent-issuer format parity proven) -- [ ] No second fingerprint-formatting path exists in `src/` -- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, +- [x] No second fingerprint-formatting path exists in `src/` +- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass ## References @@ -50,8 +50,52 @@ contract invariant in code: ## Notes -> To be filled by implementation agent +> Design: `src/fingerprint.rs` exposes `fingerprint(&public_key) -> +> String` — the thin wrapper over the `ssh-key` re-export +> (`key.fingerprint(HashAlg::Sha256).to_string()`; russh 0.65 +> re-exports `ssh_key::{PublicKey, HashAlg}` via `russh::keys`), so +> the *computation and formatting stay ssh-key's own code* and parity +> is structural, not re-implemented. `lib.rs` re-exports +> `alkssh::fingerprint`; a grep over `src/` confirms no second +> formatting path (doc mentions only). The russh 0.65 handler shape +> feeds exactly this type: `VerifiedPublicKey.public_key: & +> ssh_key::PublicKey` — the auth bridge (wave 2, w1-host-key-policy) +> passes that field straight through. +> +> **Fact-check finding (doc correction landed):** the ADR-004 / +> auth.md / task wording "unpadded **url-safe** base64" is a misnomer. +> The ssh-key crate's `Fingerprint` Display emits `SHA256:` + +> *unpadded standard* base64 (base64ct `Base64Unpadded` +> = the standard `+/` alphabet), and OpenSSH does the same — the +> committed `ssh-keygen -lf` vectors contain `+` and `/`. The binding +> invariant was never "url-safe"; it is byte-parity with standard +> issuers, which POC-1 proved and the committed vectors now pin. +> Corrected the wording in ADR-004 and `auth.md` (the two places that +> spell the format); the parity contract itself is unchanged on both +> sides. +> +> Vectors: 4 throwaway ed25519 keys generated with local ssh-keygen +> (OpenSSH 9.6p1), blobs committed under `tests/vectors/*.pub`, +> expected fingerprints byte-verbatim from `ssh-keygen -lf` recorded +> in `tests/vectors/fingerprints.txt` (provenance header included) and +> inlined in `tests/fingerprint_parity.rs`. Tests cross-check the +> inlined vectors against the committed files and manifest so a stale +> fixture edit cannot silently pass. No secret material committed +> (private halves never left the tmpdir and were discarded). ## Summary -> To be filled on completion \ No newline at end of file +> Landed `src/fingerprint.rs` — the crate's single fingerprint path +> (`alkssh::fingerprint`), delegating computation/formatting to the +> ssh-key re-export behind russh 0.65's keys module with +> `HashAlg::Sha256`. Parity proven, not assumed: 4 committed +> ed25519 fixtures with `ssh-keygen -lf` (OpenSSH 9.6p1) recorded +> fingerprints reproduce byte-identically (`tests/ +> fingerprint_parity.rs`, incl. shape/format-of-registered-string +> assertions). Found and corrected a misnomer in the docs: the format +> is unpadded *standard* base64, not url-safe (OpenSSH fingerprints +> contain `+/`; byte-parity with `ssh-keygen` is and always was the +> contract). Verified: `cargo test` (10/10 incl. all-features), +> `cargo clippy --all-targets -- -D warnings` (default + +> all-features), `cargo fmt --check`, `cargo doc --no-deps` clean; +> `taskgraph validate` clean. \ No newline at end of file diff --git a/tests/fingerprint_parity.rs b/tests/fingerprint_parity.rs new file mode 100644 index 0000000..ae967e5 --- /dev/null +++ b/tests/fingerprint_parity.rs @@ -0,0 +1,94 @@ +//! 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. + +use std::fs; + +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", + }, +]; + +#[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"); + assert_eq!( + alkssh::fingerprint(&key), + v.fingerprint, + "parity drift on {name}", + name = v.name + ); + } +} + +#[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); + assert!( + file.starts_with(&expected_line), + "fixture file {name} does not start with the recorded blob", + name = v.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 { + assert!( + manifest.contains(&format!("{} {fp}", v.name, fp = v.fingerprint)), + "manifest does not record {name} at {fp}", + name = v.name, + fp = v.fingerprint + ); + } +} + +#[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; + assert_eq!(computed, registered); + assert_eq!(registered.len(), 50, "SHA256: + 43 unpadded base64 chars"); + assert!(!registered.contains('=') && !registered.contains('!')); +} diff --git a/tests/vectors/alpha.pub b/tests/vectors/alpha.pub new file mode 100644 index 0000000..5975df7 --- /dev/null +++ b/tests/vectors/alpha.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICS7Ub4A073+G450Nt2DUOwWyniGVU/lrlUoQZAUg1sd alkssh-w1-vectors-alpha diff --git a/tests/vectors/bravo.pub b/tests/vectors/bravo.pub new file mode 100644 index 0000000..4aeb416 --- /dev/null +++ b/tests/vectors/bravo.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMNPMofKbGRA5CXtRnGsdXK/Jbj4DDOk7OutS6cOawqn alkssh-w1-vectors-bravo diff --git a/tests/vectors/charlie.pub b/tests/vectors/charlie.pub new file mode 100644 index 0000000..113f5cb --- /dev/null +++ b/tests/vectors/charlie.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAGJveAmhmFyBN635c/xhaveGRzafpYobIqg9J6/b8cF alkssh-w1-vectors-charlie diff --git a/tests/vectors/delta.pub b/tests/vectors/delta.pub new file mode 100644 index 0000000..c444741 --- /dev/null +++ b/tests/vectors/delta.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDg+r/at1NqJqwN5bUEqswoHlYAUMkJxJtKNJyWXH3UX alkssh-w1-vectors-delta diff --git a/tests/vectors/fingerprints.txt b/tests/vectors/fingerprints.txt new file mode 100644 index 0000000..f9ba69c --- /dev/null +++ b/tests/vectors/fingerprints.txt @@ -0,0 +1,12 @@ +# 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. +# +# +alpha.pub SHA256:yM/XYR4VM1XX3gfQqOh+wOjOCokfTSu4QqqQIeUTa+c +bravo.pub SHA256:Ruj1hCyzfsM8rjZhP4c4I+lVGEaEG33/srVLOnVpB3s +charlie.pub SHA256:fIcjLEaAm4aMpWEtwHm73TiIqGCX1X75HG9hGiJZ6TQ +delta.pub SHA256:D+qcroJ1ly9fVWpDQ4Zn+ig9e0Q7e0xOrQr47ruEZWs \ No newline at end of file