Files
glm-5.3-flash f4b5f896b4 docs: sweep em dashes from all markdown prose
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.
2026-11-04 20:28:00 +00:00

187 lines
9.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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.