- auth_publickey: ed25519-only gate → single fingerprint path → resolve_from_fingerprint → accept-with-identity (stored per- connection, read by wave 3) / reject fail-closed; the gate is explicit-over-inherited (ADR-002 decision 4) and precedes resolution - auth_publickey_offered: explicit accept-and-delegate (counting stays honest, POC-1) - auth_none / auth_password / auth_keyboard_interactive / auth_openssh_certificate: four explicit family-contract declines - username carried by no hook — resolution is fingerprint-only (OQ-03) - SessionHandler constructible via #[doc(hidden)] re-export (the w2-server-mount note's widening); resolve_bridge structured out of the #[non_exhaustive]-arg hooks so contract tests drive the bridge core directly - tests/auth_bridge.rs: 21 rows — wire rows over the real adapter mount (accept, fail-closed reject, username independence, revoke between attempts, none/password/certificate declines) + direct rows over the wave-1 doubles - cert fixture (throwaway ca + alpha-cert.pub) recorded in fingerprints.txt; MemHostKeySource double in tests/common Verification: cargo test (76 across the suite), --all-features, clippy --all-targets (both feature sets, -D warnings), fmt --check, doc --no-deps, taskgraph validate — all green
9.9 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |||
|---|---|---|---|---|---|---|---|---|---|---|
| w2-auth-bridge | Wave 2 — the auth bridge (dispositional handler surface) | completed |
|
moderate | low | component | implementation |
Description
Fill the Handler skeleton's auth hooks with the ADR-004 dispositions — the fingerprint → identity bridge, fail-closed on every path:
auth_publickey(fires post-verification; the key arrives asVerifiedPublicKey.public_keywith the signature.algorithmexposed): non-ed25519 algorithm → explicit reject (ADR-002 decision 4 — the explicit-over-inherited pattern, so the posture survives a pin bump); ed25519 → fingerprint via the crate's single fingerprint path (src/fingerprint.rs, the ADR-004 contract invariant) →IdentityProvider::resolve_from_fingerprint→Some(identity)→Auth::Acceptwith the resolved identity stored in the per-connection handler state (it rides the session for the wave-3 seam propagation —auth_succeededis the ride-along hook);None→ reject fail-closed. The resolved-identity-Nonepath never creates a weakly authorized session.auth_publickey_offered— accept-and-delegate (the russh default shape at this pin; keep it explicit so the counting stays honest — the hook does not consume an attempt, POC-1 verified).- Explicit declines —
auth_none,auth_password,auth_keyboard_interactive,auth_openssh_certificateeach get an explicitAuth::reject()implementation (family contract, not inherited defaults — a pin bump could silently change them). - The username carries no semantics (OQ-03) — resolution is fingerprint-only; the user string is never an identity input.
Auth declines are dispositions (Auth::Reject), not errors — the
error model stays key-source-only. Tests run against the wave-1
doubles (MemIdentityProvider + the ed25519 fixture keys) at the
handler level; the loopback harness proves the bridge end to end over
the wire in w2-loopback-harness.
Acceptance Criteria
auth_publickey: non-ed25519 → explicit reject; ed25519 → fingerprint →resolve_from_fingerprint→ accept-with-identity / reject fail-closed; the resolved identity lands in per-connection handler stateauth_publickey_offeredaccepts-and-delegates explicitlyauth_none/auth_password/auth_keyboard_interactive/auth_openssh_certificateeach explicitly reject- The username is never an identity input (fingerprint-only resolution; OQ-03)
- Handler-level tests: known-fingerprint accept (fixture key +
MemIdentityProvider), unknown-fingerprint reject, non-ed25519 explicit reject, every non-publickey method rejects cargo test,cargo clippy --all-targets -- -D warnings,cargo fmt --checkpass
References
- docs/architecture/decisions/004-auth-dispositions.md (the dispositions, decisions 1–5)
- docs/architecture/auth.md §Server direction (the bridge steps; the
0.65.0
VerifiedPublicKeyshape) - docs/architecture/decisions/002-russh-pin.md (decision 4 — ed25519-only, the explicit-decline rationale)
- docs/plans/implementation.md §Wave 2 (the auth-bridge bullet)
- russh 0.65.0 in-source:
Auth,VerifiedPublicKey,PublicKeyOffer, the auth hook signatures
Notes
Implementation record (decisions made at this task's discretion):
- The bridge core is structured out of the hook on purpose.
VerifiedPublicKeyandPublicKeyOfferare#[non_exhaustive]with no external constructor — only russh builds them (and only post-verification), so the fullauth_publickey/auth_publickey_offeredhooks cannot be invoked directly from a test. The bridge is thereforeSessionHandler::resolve_bridge(&public_key, &signature_algorithm)— the ed25519-only gate → the single fingerprint path →resolve_from_fingerprint— a plain method the contract tests drive with ordinary key material, and the hook is one line over it (call, store onSome,Auth::reject()onNone). The non-constructible hooks are exercised as full hooks over the wire instead (see below). - Two test vehicles, both recorded. Wire rows (the real
SshAdapterover a tokio-duplex pair + russh's own client — thew2-server-mountwire-smoke precedent) drive the fullauth_publickeyhook end to end (accept, fail-closed reject, username independence,auth_none/auth_password/auth_openssh_certificatedeclines over the wire). Direct-hook rows againstSessionHandler(over the wave-1 doubles) drive every hook with constructible arguments plusresolve_bridge. The transport choice and harness vocabulary remainw2-loopback-harness's to pin — these rows are its thin precursor, not a competing vehicle. SessionHandlervisibility widened asw2-server-mount's note anticipated: the struct ispubinside its still-private module, re-exported#[doc(hidden)]from the crate root — constructible for the contract tests, hidden from the docs; the documented public API stays exactlySshAdapter+ the config/error/host/key-source types.- Non-ed25519 decline testing at this pin: no wire row can carry a
non-ed25519 key —
rsa/pkcs1are dropped from the dependency tree (ADR-002 decision 4's verification) and ssh-key ships without ecdsa/dsa/rsa features, so no other key type even parses in-tree. The gate is therefore tested on the bridge core with constructible stand-in signature algorithms (SkEd25519,SkEcdsaSha2NistP256,Other(name@domain)) — a registered fingerprint under a non-ed25519 algorithm still declines, proving the gate precedes resolution. The gate checks both algorithm facts (the signature.algorithmfrom the hook and the key's.algorithm()); at this pin the two can never disagree in-tree, and the explicit-over-inherited implementation is what survives a pin bump. auth_publickey_offered's accept-and-delegate is explicit but not directly hook-testable at this pin (non-exhaustive argument; the russh client sends nois_real=0probe — OpenSSH-style clients do, and POC-1 + russh's ownpublickey_probes_do_not_burn_auth_attemptstest remain the counting-honesty evidence). The wire rows exercise the signed-request path (is_real=1) that passes through it.auth_succeededstays unimplemented (the russh default) — the identity rides in per-connection handler state (SessionHandler::resolved_identity(), the fail-closedNonepaths never store); wave 3's seam propagation consumes it there and throughauth_succeeded. The store is the hook's one line over the same resolved value the wire rows exercise; its consumption is wave-3's assertion point.- Certificate fixture added (
tests/vectors/):ca.key/ca.pub(throwaway CA, committed on the same fixture rationale as every other private half) andalpha-cert.pub— the alpha user certificate signed by it (serial 1, key idalkssh-fixture-alpha, principalgit, validity 2026–2036). Both fingerprints recorded infingerprints.txt.alpha-cert.pubdrives theauth_openssh_certificatedecline rows (wire and direct-hook); the cert expiry/signature checks russh performs before the hook are exercised by the valid fixture (the decline is the hook's, not russh's pre-checks). MemHostKeySourcedouble added totests/common(the wave-1 doubles' module, still the only doubles location): anabsent()shape (load refusesAbsent— fail-closed if anything ever loads it) and afrom_fixtures()shape for the wire rows' real adapter construction (which wants a non-empty host-key set).
Summary
The ADR-004 dispositions are landed in src/handler.rs:
auth_publickey is the fingerprint → resolve_from_fingerprint →
accept-with-identity / reject fail-closed bridge (non-ed25519 keys
declined explicitly by the bridge-core gate before any resolution —
ADR-002 decision 4's explicit-over-inherited pattern), with the
resolved identity stored in per-connection handler state
(SessionHandler::resolved_identity(), read by wave 3);
auth_publickey_offered is an explicit accept-and-delegate;
auth_none/auth_password/auth_keyboard_interactive/
auth_openssh_certificate are four explicit family-contract declines;
the username is structurally excluded from resolution (no username
parameter exists on the bridge core — OQ-03). Every decline is a
Auth::Reject disposition, never an error.
Testing: tests/auth_bridge.rs — 7 wire rows over the real adapter
mount (registered-accept, unknown-reject with the {publickey}
advertisement surviving, four-username independence, revoke-between-
attempts, auth_none, auth_password, and the OpenSSH-certificate
decline — all via russh's own client over a duplex pair against the
committed cert fixture) + 7 direct rows against SessionHandler and
resolve_bridge over the wave-1 doubles (all four non-publickey hooks
reject explicitly with no identity state left behind; registered /
unknown / revoked fingerprints; the ed25519 gate before resolution
with sk-variant/Other stand-ins; the independent-issuer registration
parity row; the absent-host-key-source construction row) + 1 fixture
fingerprint-format row. 76 tests total across the suite.
Verification: cargo test (47 lib + 14 auth_bridge + 9 doubles +
5 parity, all green), cargo test --all-features green,
cargo clippy --all-targets -- -D warnings green,
cargo clippy --all-features --all-targets -- -D warnings green,
cargo fmt --check green, cargo doc --no-deps green,
taskgraph validate green. The wire rows are noted as the thin
precursor of the w2-loopback-harness vehicle (which pins the
transport choice); flagged for the wave-2 gate: the
resolved-identity landing is code-verifiable only at wave 3 (the
hook's russh-owned argument shape is non-exhaustive and the handler
lives inside run_stream) — the store rides the same resolved value
the wire rows exercise.