--- id: w2-auth-bridge name: "Wave 2: the auth bridge (dispositional handler surface)" status: completed depends_on: [w2-server-mount, w1-fingerprint, w1-test-doubles] scope: moderate risk: low impact: component level: 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 as `VerifiedPublicKey.public_key` with the signature `.algorithm` exposed): 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::Accept` with the resolved identity stored in the per-connection handler state (it rides the session for the wave-3 seam propagation; `auth_succeeded` is the ride-along hook); `None` → reject fail-closed. The resolved-identity-`None` path 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_certificate` each get an explicit `Auth::reject()` implementation (family contract, not inherited defaults, which a pin bump could silently change). - **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, so 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 - [x] `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 state - [x] `auth_publickey_offered` accepts-and-delegates explicitly - [x] `auth_none` / `auth_password` / `auth_keyboard_interactive` / `auth_openssh_certificate` each explicitly reject - [x] The username is never an identity input (fingerprint-only resolution; OQ-03) - [x] Handler-level tests: known-fingerprint accept (fixture key + `MemIdentityProvider`), unknown-fingerprint reject, non-ed25519 explicit reject, every non-publickey method rejects - [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass ## 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 `VerifiedPublicKey` shape) - 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.** `VerifiedPublicKey` and `PublicKeyOffer` are `#[non_exhaustive]` with no external constructor; only russh builds them (and only post-verification), so the *full* `auth_publickey`/`auth_publickey_offered` hooks cannot be invoked directly from a test. The bridge is therefore `SessionHandler::resolve_bridge(&public_key, &signature_algorithm)`, spanning 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 on `Some`, `Auth::reject()` on `None`). The non-constructible hooks are exercised as full hooks over the wire instead (see below). - **Two test vehicles, both recorded.** Wire rows (the real `SshAdapter` over a tokio-duplex pair + russh's own client (the `w2-server-mount` wire-smoke precedent)) drive the full `auth_publickey` hook end to end (accept, fail-closed reject, username independence, `auth_none`/`auth_password`/ `auth_openssh_certificate` declines over the wire). Direct-hook rows against `SessionHandler` (over the wave-1 doubles) drive every hook with constructible arguments plus `resolve_bridge`. The transport choice and harness vocabulary remain `w2-loopback-harness`'s to pin; these rows are its thin precursor, not a competing vehicle. - **`SessionHandler` visibility widened as `w2-server-mount`'s note anticipated**: the struct is `pub` inside 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 exactly `SshAdapter` + the config/error/host/key-source types. - **Non-ed25519 decline testing at this pin**: no wire row can carry a non-ed25519 key: `rsa`/`pkcs1` are 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 `.algorithm` from 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 no `is_real=0` probe; OpenSSH-style clients do, and POC-1 + russh's own `publickey_probes_do_not_burn_auth_attempts` test remain the counting-honesty evidence). The wire rows exercise the signed-request path (`is_real=1`) that passes through it. - **`auth_succeeded` stays unimplemented (the russh default)**: the identity rides in per-connection handler state (`SessionHandler::resolved_identity()`, the fail-closed `None` paths never store); wave 3's seam propagation consumes it there and through `auth_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) and `alpha-cert.pub`, the alpha user certificate signed by it (serial 1, key id `alkssh-fixture-alpha`, principal `git`, validity 2026–2036). Both fingerprints recorded in `fingerprints.txt`. `alpha-cert.pub` drives the `auth_openssh_certificate` decline 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). - **`MemHostKeySource` double added to `tests/common`** (the wave-1 doubles' module, still the only doubles location): an `absent()` shape (load refuses `Absent`, fail-closed if anything ever loads it) and a `from_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, per 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`, with 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`), so the store rides the same resolved value the wire rows exercise.