From 2aead32fff59e3daebb6b7f13bfd8c371a9fe762 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sun, 25 Oct 2026 11:23:55 +0000 Subject: [PATCH] =?UTF-8?q?docs(research):=20POC-1=20findings=20=E2=80=94?= =?UTF-8?q?=20auth=20bridge=20+=20fail-closed=20declines=20verified=20end?= =?UTF-8?q?=20to=20end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new: docs/research/poc-1-auth-bridge.md (standalone POC at /workspace/alkssh-poc1; russh 0.64.1 + OpenSSH 9.6p1; 31/31 checks) - confirmed hypotheses: SHA256 fingerprint parity across russh/ ssh-keygen/vanilla known_hosts; auth_publickey -> IdentityProvider resolve -> accept/decline; offered-hook accept-and-let-decide with OpenSSH probe-then-sign; ChannelOpenHandle drop = auto-reject (AdministrativelyProhibited) with clean vanilla diagnostics; max_auth_attempts counts the initial none probe; into_stream echo plumbing works both sides - correction recorded to auth-bridge.md + russh.md: 0.64.1 default auth impls reject (only publickey_offered defaults to accept) - new API fact in russh.md: rejected channel opens surface as Err from the typed open call - OQ seeds: username-ignore direction, max_auth_attempts sizing, pin as one-way door (reject-defaults could change on bump) --- docs/research/auth-bridge.md | 16 ++- docs/research/poc-1-auth-bridge.md | 197 +++++++++++++++++++++++++++++ docs/research/russh.md | 19 ++- 3 files changed, 225 insertions(+), 7 deletions(-) create mode 100644 docs/research/poc-1-auth-bridge.md diff --git a/docs/research/auth-bridge.md b/docs/research/auth-bridge.md index 76581c2..6a91125 100644 --- a/docs/research/auth-bridge.md +++ b/docs/research/auth-bridge.md @@ -89,15 +89,25 @@ limit — the offered hook's disposition is "accept the offer, let |---|---|---| | `auth_publickey` | Implement — the only method | The family identity model is keys; fingerprint → `Identity` | | `auth_publickey_offered` | Implement (accept-and-let-decide) | Keeps `max_auth_attempts` honest | -| `auth_none` | Decline (default impl accepts — override) | Fail-closed is ours to enforce | -| `auth_password` | Decline | No password concept in family identity | -| `auth_keyboard_interactive` | Decline | Same | +| `auth_none` | Decline (implement explicitly) | Fail-closed is ours to enforce | +| `auth_password` | Decline (implement explicitly) | No password concept in family identity | +| `auth_keyboard_interactive` | Decline (implement explicitly) | Same | | `auth_openssh_certificate` | Decline initially (OQ candidate) | OpenSSH CA trust model ≠ family key registry; revisit on a use case | | `authentication_banner` | Assembly-provided (e.g. alkforge notice) or none | — | `Config::methods: MethodSet` must advertise exactly what is implemented (honest advertisement = only `publickey` in every current configuration). +**0.64.1 default-implementation note (verified in source and by POC-1; +corrects an earlier working assumption):** the default impls of +`auth_none`, `auth_password`, `auth_keyboard_interactive`, +`auth_publickey`, and `auth_openssh_certificate` are *reject* +(`Auth::reject()`) in 0.64.1 — only `auth_publickey_offered` defaults +to accept. The fail-closed posture is therefore largely inherited at +this pin, but every hook is still implemented explicitly as a family +contract (a future pin bump could alter defaults; the pin is a one-way +door), and POC-1 proves all of the declines end to end. + ## The client side: keys out (we hold the secret) Vanilla SSH's client model differs from the family's protocols: the diff --git a/docs/research/poc-1-auth-bridge.md b/docs/research/poc-1-auth-bridge.md new file mode 100644 index 0000000..1a047cd --- /dev/null +++ b/docs/research/poc-1-auth-bridge.md @@ -0,0 +1,197 @@ +# POC-1 findings — auth bridge + fail-closed declines end to end + +Status: Complete (phase 0). POC source: `/workspace/alkssh-poc1` +(standalone crate, disposable — never merges). All hypotheses in +`pocs.md` §POC-1 were confirmed; the doc below records the evidence, +one correction to the phase 0 research docs, two API-shape facts, and +the OQ seeds this POC produced. + +## Setup exercised + +- russh **0.64.1** from crates.io (the current published release; + crates.io `newest_version` re-verified during the POC), default + features off, crypto backend `ring`. Clean build under edition 2024, + rustc 1.99.0. The `ring` backend built with no external provisioning + — relevant data point for the pin ADR's crypto-backend question. +- Dependency tree confirms the pin considerations in `russh.md`: + `ssh-key 0.7.0-rc.11` exact-pinned and re-exported via + `russh::keys::ssh_key`; `rand_core 0.10` in-tree. Key generation in + the POC used `rand 0.10` (`PrivateKey::random(&mut rng(), + Algorithm::Ed25519)`) and `PrivateKey::to_openssh(LineEnding::LF)` + produced key files vanilla OpenSSH loaded without complaint. +- Client side: russh client (`client::Handler`, `client::connect`) as + the primary programmatic client, plus a stub `IdentityProvider` + registered via fingerprints taken **from `ssh-keygen -lf` output** + (independent OpenSSH implementation) — making the parity check a + real cross-implementation check, not a same-code self-check. +- Vanilla client: OpenSSH 9.6p1 (`ssh`, `ssh -L`), isolated + `UserKnownHostsFile`, `BatchMode`, `IdentitiesOnly`. + +## Validated: all checks pass (31/31) + +### 1. Fingerprint-format parity — confirmed, format is exactly the registered string + +`public_key.fingerprint(HashAlg::Sha256).to_string()` (via the +`ssh-key` re-export) yields `SHA256:` — +byte-identical to `ssh-keygen -lf` on the same public key. The +`resolve_from_fingerprint` string compare works with values that were +registered from OpenSSH tooling output. Parity was additionally +observed against the vanilla client's own host-key check +(`known_hosts` entry fingerprints, also `SHA256:` format). + +**The auth-bridge checkpoint is closed:** the format is stable and +self-describing; the assembly layer can register `SHA256:` strings +taken from any standard issuer (ssh-keygen, alkcall-side computation +via the same re-export) and the server-side compare holds. + +### 2. Auth bridge end to end — confirmed as designed + +- `auth_publickey` fires **after** russh's own signature verification + (documented behavior observed: the vanilla key was accepted without + any signature handling in alkssh-shaped code). The handler computes + the fingerprint, resolves through the stub `IdentityProvider`, and + returns `Auth::Accept` / `Auth::Reject` accordingly. Unknown keys + decline cleanly. +- `auth_succeeded` fired on every successful auth (both clients). +- `auth_publickey_offered` accept-and-let-decide works with OpenSSH's + probe-then-sign flow: the hook fires (key seen, ownership unverified) + before the signed attempt, and **does not count toward + `max_auth_attempts`** — consistent with russh's own doc warning + about keeping the count honest. +- Username: ignored entirely for identity (keys resolve independently + of the username string sent by the client) — supports the + "ignore username entirely" OQ direction; `ssh anything@host` + compatibility is unaffected. + +### 3. Decline matrix — every method declined and proven + +- `auth_none` / `auth_password` / `auth_keyboard_interactive` all + observed declining (russh client forced each; vanilla client with + `PubkeyAuthentication=no` got `Permission denied (publickey)` with + no fallback attempted). +- `Config::methods` advertising only `publickey` is honored by + OpenSSH: it never attempts non-advertised methods. + +**Correction to the phase 0 research docs (recorded in +`auth-bridge.md` and `russh.md`):** in russh 0.64.1 the *default +implementations* of `auth_none`, `auth_password`, +`auth_keyboard_interactive`, `auth_publickey`, and +`auth_openssh_certificate` are **already reject** (`Auth::reject()`). +Only `auth_publickey_offered` defaults to accept. The research assumed +older-russh accept-defaults. The fail-closed discipline at 0.64.1 is +therefore largely *inherited*; the family contract still requires +implementing every hook explicitly (a future russh bump could change +defaults — the pin is a one-way door) and POC-1 proves the explicit +implementations end to end. + +### 4. Bounded-resource auth behavior — `max_auth_attempts` semantics + +The **initial `none` probe counts toward `max_auth_attempts`**: with a +limit of 3, a BatchMode vanilla client got the `none` probe plus two +signed declined attempts and was disconnected with +`Received disconnect ...:14: Too many authentication attempts` (exit +255). Two practical consequences for phase 1 design: + +- with `IdentitiesOnly` and multiple keys, clients effectively get + `max_auth_attempts - 1` real key attempts; +- the alkforge assembly layer should size the limit with the probe in + mind (e.g. limit 10 default is comfortable; a small limit eats one + attempt on every connection). + +### 5. Fail-closed channel declines observable by vanilla ssh — confirmed + +- `ChannelOpenHandle` **drop = auto-reject** sends + `AdministrativelyProhibited` (verified in russh source + `ChannelOpenHandleInner::drop`, and observed on the wire). No + explicit reject call needed anywhere: unfeatured channel types are + handled by simply not accepting the handle. +- Vanilla `ssh host` after successful auth: `channel 0: open failed: + administratively prohibited: Administratively prohibited`, exit 255 — + clean diagnosis, no hang, and the **connection-level auth remains + intact** (the decline is channel-scoped, not a disconnect). +- Vanilla `ssh -L 127.0.0.1:P:127.0.0.1:P -N`: the local listener + establishes; dialing through it opens a `direct-tcpip` channel that + is refused — the local dial gets TCP-level reset ("Connection reset + by peer"), no data ever crosses. The server-side log shows the drop + with the forward target. +- Session remains usable after declines (further channel opens + correctly declined too — no state poisoning). + +### 6. russh client API shapes on rejects (new facts for the mapping docs) + +- A rejected channel open is surfaced by the *awaiting call*: + `channel_open_session() -> Err(russh::Error::ChannelOpenFailure( + AdministrativelyProhibited))`. The 0.5x-era pattern of awaiting + `wait()` and matching `ChannelMsg::OpenFailure` still exists in the + message enum but the typed open call resolves to `Err` first. The + mapping-table/POC-3 code should expect `Err` from the open call. +- `ServerHandler` has **no `channel_open_x11`-style escape** from + fail-closed: every unhandled open type simply isn't accepted; the + auto-reject covers x11, direct-tcpip, forwarded-tcpip, + direct-streamlocal uniformly. +- `PublicKeyOrCertificate::PublicKey` is a struct variant + (`{ key, hash_alg }`) — small API detail when reading host keys in + `check_server_key`. +- **Client-side plumb caveat (recorded for POC-2/3):** `Channel::wait()` + on a just-opened client channel *before* `into_stream()` breaks the + channel (subsequent `write_all` fails with `BrokenPipe "channel + closed"`). `channel_open_session()` already awaits confirmation + internally; do not consume the channel queue before converting to a + stream. With that caveat respected, the end-to-end echo works. + +### 7. Channel ↔ AsyncRead/AsyncWrite plumbing — confirmed + +`into_stream()` works on both sides: server accepts a session channel, +`channel.into_stream()`, reads/writes in a plain +`AsyncRead`/`AsyncWrite` loop; client `channel_open_session()` → +`into_stream()` → `write_all`/`read_exact` round-trips multiple frames +(`hello-poc1-echo`, `second-frame`). This is the exact plumbing shape +POC-2 (dial lifecycle) and POC-3 (mapping bake-off) will reuse. + +## Hypothesis verdicts (pocs.md §POC-1) + +- "`auth_publickey` → fingerprint string parity → resolve → accept; + unknown key → clean decline" — **confirmed**. +- "`auth_publickey_offered` accept-and-let-decide with OpenSSH + probe-then-sign" — **confirmed** (and confirmed it doesn't inflate + the attempt counter). +- "Default auth impls are accept — every default must be overridden" + — **corrected**: at 0.64.1 only the offered hook defaults to accept. + Explicit implementations still required by the family contract and + proven here. +- "`ChannelOpenHandle` drop = auto-reject; declines observable by + vanilla ssh; clean diagnostics" — **confirmed**. +- "`Channel` ↔ AsyncRead/AsyncWrite (`into_stream`) works" — + **confirmed**, with the wait-before-into_stream caveat. + +## OQ seeds carried forward + +1. Username handling: POC ignored it entirely and everything worked + (`ssh poc@host`, `ssh anything@host` shape). Phase 1 to decide + whether usernames are ever logged/matched (vanilla clients always + send one). +2. `max_auth_attempts` sizing must account for the counting `none` + probe (see §4) — a config default note for the ADR. +3. Vanilla clients probe with `none` first; a future russh bump that + changes reject-defaults would be a silent-auth posture change — + reinforces recording the russh pin as a deliberate one-way door. +4. `auth_openssh_certificate` override-decline exists but was not + exercised with a real certificate (no CA in the POC) — the + "decline initially" row stands unfalsified. + +## Reference run + +Full output of a clean run at `pocs.md` commit time: +31 checks, 6 groups (fingerprint parity 2, russh client auth 9, +channel declines 3, vanilla OpenSSH 11, max_auth_attempts 1, echo +plumbing 5) — all PASS. Vanilla diagnostic strings quoted above are +verbatim from that run. + +## References + +- `pocs.md` §POC-1 (plan), `auth-bridge.md` (design), `russh.md` (API + inventory — updated with the 0.64.1 default-impl correction) +- POC source: `/workspace/alkssh-poc1` (single `src/main.rs` + stub + alkcall-shaped `IdentityProvider` in `authbridge` module) +- russh 0.64.1 (crates.io, ring backend), ssh-key 0.7.0-rc.11 + (re-export), OpenSSH 9.6p1 \ No newline at end of file diff --git a/docs/research/russh.md b/docs/research/russh.md index 25dde9d..937e05a 100644 --- a/docs/research/russh.md +++ b/docs/research/russh.md @@ -54,9 +54,16 @@ time before any pin is recorded. ### `server::Handler` trait (async trait, not dyn-compatible) Required: `type Error: From + Send`. Everything else is -provided methods; **unimplemented methods default to accept (auth -methods) or to a decline/no-op** — the fail-closed discipline in this -crate is *ours to implement*, not inherited. Key inventory: +provided methods; at 0.64.1 the default auth impls are **reject** +(`Auth::reject()`) for `auth_none`, `auth_password`, +`auth_keyboard_interactive`, `auth_publickey`, and +`auth_openssh_certificate` — only `auth_publickey_offered` defaults to +accept (verified in 0.64.1 source; POC-1 corrected this doc's earlier +accept-defaults assumption). Non-auth defaults decline/no-op. The +fail-closed discipline is nonetheless *ours to implement explicitly* +as a family contract (the pin is a one-way door; defaults could change +on a bump), and POC-1 proves the explicit implementations. Key +inventory: - Auth: `auth_none(user)`, `auth_password(user, password)`, `auth_publickey_offered(user, &PublicKey)` (pre-auth probe — cheap @@ -146,7 +153,11 @@ Channel opening: only the four typed opens exist — `channel_open_session`, `channel_open_x11`, `channel_open_direct_tcpip(host, port, originator...)`, `channel_open_direct_streamlocal(socket_path)` — no generic -`channel_open(name)`. Channels expose `wait() -> Option`, +`channel_open(name)`. On a server **reject**, the typed open call +itself resolves to `Err(Error::ChannelOpenFailure(reason))` — no +`wait()`-and-match pattern needed (POC-1 verified with vanilla +declines); the session stays usable after declines. Channels expose +`wait() -> Option`, `split()`, `into_stream() -> ChannelStream` (AsyncRead + AsyncWrite), `make_reader()/make_reader_ext(ext)/make_writer()/make_writer_ext()` (tokio duplex adapters — **the bridge primitives for every mapping**),