docs(research): POC-1 findings — auth bridge + fail-closed declines verified end to end

- 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)
This commit is contained in:
glm-5.3-flash committed 2026-10-25 11:23:55 +00:00
1 parent f4424b7e58
commit 2aead32fff
3 files changed
+225 -7

No files matched your search

+13 -3
View File
@@ -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
+197
View File
@@ -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:<url-safe base64, unpadded>` —
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
+15 -4
View File
@@ -54,9 +54,16 @@ time before any pin is recorded.
### `server::Handler` trait (async trait, not dyn-compatible)
Required: `type Error: From<russh::Error> + 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<ChannelMsg>`,
`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<ChannelMsg>`,
`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**),