Renames the written-usage rule into practice across the contract surface: docs/ (architecture, research, plans, sdd), tasks/ (all 43 task files, taskgraph validate passes), AGENTS.md, README.md, CHANGELOG.md, and the .opencode/agents/ prompts. Every replacement is a sense-preserving rewrite (colons, semicolons, commas, parentheses, sentence splits, varied), never a mechanical swap; no spaced hyphens were substituted. Enum fields, frontmatter, tables, wire data, and cited identifiers survive verbatim; en dashes inside numeric ranges keep their meaning. Verification: taskgraph validate (43 tasks), repo grep clean (single exempt hit: the verification command's own search pattern in AGENTS.md), cargo test, clippy --all-targets -D warnings, fmt --check, doc all green.
187 lines
9.9 KiB
Markdown
187 lines
9.9 KiB
Markdown
---
|
||
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. |