feat(fingerprint): land the single fingerprint path + ssh-keygen parity vectors
- src/fingerprint.rs: alkssh::fingerprint wraps the ssh-key re-export (HashAlg::Sha256) — the crate's only fingerprint computation and formatting site (ADR-004 decision 1's contract invariant) - tests/fingerprint_parity.rs: 4 committed ed25519 fixtures with ssh-keygen -lf-recorded fingerprints (OpenSSH 9.6p1, independent issuer); blobs cross-checked against the vectors manifest - verified: cargo test, clippy (default + all-features, -D warnings), fmt; taskgraph validate clean
This commit is contained in:
1 parent
d85299df38
commit
635e82b54b
9 files changed
+225
-7
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
> 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.
|
||||
@@ -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('!'));
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICS7Ub4A073+G450Nt2DUOwWyniGVU/lrlUoQZAUg1sd alkssh-w1-vectors-alpha
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMNPMofKbGRA5CXtRnGsdXK/Jbj4DDOk7OutS6cOawqn alkssh-w1-vectors-bravo
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAGJveAmhmFyBN635c/xhaveGRzafpYobIqg9J6/b8cF alkssh-w1-vectors-charlie
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDg+r/at1NqJqwN5bUEqswoHlYAUMkJxJtKNJyWXH3UX alkssh-w1-vectors-delta
|
||||
@@ -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.
|
||||
#
|
||||
# <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
|
||||
Reference in new issue
Block a user