docs(research): phase 0 research set — vision, russh, auth bridge, mapping table, POC plan

The phase 0 document set (documented only; no POCs run yet):

- docs/research/vision.md — guiding principles (russh owns the wire,
  door-not-backend, fail-closed everything, one seam per mapping,
  family identity not SSH identity); non-goals (no sshd, no sftp until
  alkfs, no password auth); the five scope questions phase 0/1 must
  answer
- docs/research/russh.md — 0.64.1-era inventory: server/client
  Handler method tables, Config knobs, keys module (ssh-key re-export,
  fingerprint API), the RC-pinned dependency tree (ssh-key, rsa,
  pkcs1), MSRV/edition/license posture; alknet-main referenced as a
  rough example only, explicitly not decision input
- docs/research/auth-bridge.md — the alkcall mapping: no
  resolve_from_signature exists; the actual surface is
  IdentityProvider::resolve_from_fingerprint fed by russh-verified key
  fingerprints in auth_publickey; auth-method disposition matrix
  (only publickey implemented, all defaults overridden); client side
  mirrors alkhttp ADR-014 (key sources, not static keys; vault
  client-side only); the vault-to-russh-key bridge named as the
  missing design piece
- docs/research/mapping-table.md — the spine: channel-type rows with
  feature candidates and the verified sibling serve/consume entries
  (alktty drive_session, alkgit serve_duplex_resolved, alksocks
  serve_socks5_over, alktunnels consumer wrappers + the no-serve-over-
  stream absence note); request-level rows; parse-then-route dispatch
  inside the session channel; the alk/ssh ALPN topology question
  (ADR-004 reserved / ADR-034 stream_types [0,1] / ADR-042 hub never
  runs it); alktunnels route choice flagged for ADR
- docs/research/pocs.md — POC-1 (auth bridge + fail-closed declines
  against vanilla OpenSSH), POC-2 (vault-to-russh key bridge:
  PrivateKey extraction vs vault-backed Signer), POC-3 (mapping
  bake-off stages A-D: git exec, socks -D, tunnel -L, pty); all
  standalone mode; findings docs are the deliverables

Verification: cargo doc --no-deps, cargo fmt --check, cargo test
(docs-only change; lib untouched)
This commit is contained in:
glm-5.3-flash committed 2026-10-25 10:13:17 +00:00
1 parent e0805cd438
commit ecdf836eeb
7 files changed
+829

No files matched your search

+16
View File
@@ -209,6 +209,22 @@ production code.
## Architecture Context ## Architecture Context
- `docs/sdd_process.md` — the SDD process (phases, roles, task format) - `docs/sdd_process.md` — the SDD process (phases, roles, task format)
- `docs/research/` — phase 0 research (current source of truth for
design direction):
- `vision.md` — vision, guiding principles, non-goals, the phase 0
scope questions
- `russh.md` — russh crate inventory (server/client Handler, Config
knobs, keys module) and pin considerations (RC-pinned dep tree,
MSRV, license) — no alknet-derived bias; the pin is verified
against current russh at architecture time
- `auth-bridge.md` — russh auth ↔ alkcall `IdentityProvider` mapping
(server: verified-key fingerprint → `resolve_from_fingerprint`;
client: alkvault-backed key sources, `Signer`-shaped)
- `mapping-table.md` — the crate's spine: SSH channel type / request
→ family protocol seam + cargo feature; the alktunnels
absence note; the `alk/ssh` ALPN topology question
- `pocs.md` — POC-1..3 plan (auth bridge, vault key bridge, mapping
bake-off) gating phase 0 completion
- family docs: `/workspace/@alkdev/alkfamily/docs/` — - family docs: `/workspace/@alkdev/alkfamily/docs/` —
`architecture/decisions/003-alkssh-russh-adapter.md` is the structural `architecture/decisions/003-alkssh-russh-adapter.md` is the structural
decision this repo builds from (and `002-alkforge-gitea-replacement.md` decision this repo builds from (and `002-alkforge-gitea-replacement.md`
+5
View File
@@ -13,3 +13,8 @@ this crate adheres to [Semantic Versioning](https://semver.org/).
dependencies yet), MIT OR Apache-2.0 licensing. The russh pin, the dependencies yet), MIT OR Apache-2.0 licensing. The russh pin, the
capability feature graph, and the channel-type mapping table are capability feature graph, and the channel-type mapping table are
phase 0 research output (family ADR-003, OQ-08 remaining scope). phase 0 research output (family ADR-003, OQ-08 remaining scope).
- Phase 0 research set under `docs/research/`: `vision.md` (scope
questions), `russh.md` (0.64.1-era inventory + pin considerations),
`auth-bridge.md` (fingerprint → identity server path; vault-backed
client keys), `mapping-table.md` (channel type/request → family seam
+ feature), `pocs.md` (POC-1..3 plan — documented, not yet run).
+176
View File
@@ -0,0 +1,176 @@
# Auth bridge — russh auth ↔ alkcall identity
Status: Draft (phase 0 research). Sources: alkcall 0.10.1 source
(`src/core/auth.rs`, `src/core/types.rs`, `src/protocol/dispatch.rs`),
alkcall ADRs 002/003/006/010/014-analog (alkhttp ADR-014), alkvault
0.1.0 source, alkhttp architecture docs.
## The server side: keys in, identity out
### What alkcall provides (exact surface)
```rust
// /workspace/@alkdev/alkcall/src/core/auth.rs
pub struct Identity {
pub id: String, // for SSH keys: the SHA-256 fingerprint
pub scopes: Vec<String>,
pub resources: HashMap<String, Vec<String>>,
}
pub struct AuthToken { pub raw: Vec<u8> }
pub struct AuthContext {
pub identity: Option<Identity>,
pub alpn: Vec<u8>,
pub remote_addr: Option<SocketAddr>,
pub tls_client_fingerprint: Option<String>,
}
pub trait IdentityProvider: Send + Sync + 'static {
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity>;
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity>;
}
```
`IdentityProvider` is a trait only — implementations are supplied by
the assembly layer (alkcall ships `NoopIdentityProvider`, which
resolves nothing). alkhttp's server resolves tokens through exactly
this one backend for every inbound surface (bearer + door-Basic,
alkhttp ADR-073); alkssh is the same posture: one backend, an SSH-
shaped front.
**Correction to a working assumption:** there is no
`resolve_from_signature` anywhere in alkcall (nor in the ADR). The
credential table in alkcall ADR-003 maps SshAdapter to
`resolve_from_fingerprint`. The division of labor works because russh
does the cryptographic verification: by the time
`Handler::auth_publickey(user, &PublicKey)` fires, russh has verified
the signature and key ownership (doc: "called after the signature has
been verified and key ownership has been confirmed"). alkssh never
sees or verifies a raw signature; it consumes the fingerprint of an
already-verified key.
### The mapping
1. russh `auth_publickey(user, public_key)` fires.
2. alkssh computes `public_key.fingerprint()` via the `ssh-key` re-
export (`HashAlg::Sha256`, `SHA256:base64` OpenSSH format) —
**fingerprint-format parity with the assembly layer's registered
identities is a contract invariant and a POC-1 checkpoint** (the
format is stable and self-describing, but the registration path is
assembly-owned, so the exact string compare must be verified, not
assumed).
3. `IdentityProvider::resolve_from_fingerprint(fp)` — `Some(identity)`
→ `Result<Auth, _>::Authenticate { .. }`; `None` → decline
(`Reject`). The user name string is **ignored for identity** (it is
not the family identity model); whether the username is checked at
all vs. ignored is an OQ candidate (vanilla `ssh` and `git` always
send one; ignoring it entirely keeps OpenSSH-tool compatibility:
`ssh anything@host`).
4. On success, the resolved identity rides the session:
`auth_succeeded(session)` attaches it to alkssh's per-session
state; when the session is bridged over alkcall, the identity is
pushed with the once-only `Connection::set_identity(identity)` —
after which `AccessControl::check(peer_identity)` gates every
mapped channel open exactly as in alkhttp (visible-surface =
authorized-surface). Unresolvable keys decline fail-closed; the
resolved-identity-None path must not create a "weakly authorized"
session.
`auth_publickey_offered` exists as the cheap pre-auth hook (key
ownership unverified there); the constant-rejection-time contract
(`Config::auth_rejection_time`) plus `Config::max_auth_attempts`
covers the timing/DoS posture. Note russh's own doc warning: seeing an
unknown key at the `offered` stage should not count toward the attempt
limit — the offered hook's disposition is "accept the offer, let
`auth_publickey` decide", which keeps counting honest.
### Auth-method disposition (fail-closed matrix)
| russh hook | Disposition | Rationale |
|---|---|---|
| `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_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).
## The client side: keys out (we hold the secret)
Vanilla SSH's client model differs from the family's protocols: the
client *signs* with a private key; the server verifies. alkssh's client
side therefore holds secret material, and the family rule applies: that
material comes from alkvault-backed sources, never env vars, never
plaintext at rest (family ADR-002 decision 5).
### The alkhttp pattern being mirrored (alkhttp ADR-014)
alkhttp's client adapters (`from_openapi`/`from_mcp`) never hold static
tokens in config: the config declares the scheme, the assembly layer
injects the secret as an alkcall `Capabilities` entry at registration,
and the adapter reads it fail-closed at use time (`Capabilities::get`
→ absent → loud internal error, never an unauthenticated send). The
alkssh translation: the client adapter takes *key sources*, not static
keys — the assembly layer supplies an alkvault-backed source; the
adapter converts it into a russh-usable key/signer at construction or
dial time; absent/locked vault → fail-closed decline, no anonymous
dial.
### The gap: alkvault → russh key
alkvault 0.1.0 is a derive-and-encrypt vault, not a KeySource KV store:
`VaultServiceHandle::derive_ed25519(path) -> Result<DerivedKey>` (SLIP-
0010 ed25519 over the BIP39 seed; `DerivedKey { key_type, private_key
(zeroized), public_key }`), with derivation paths including
`PATHS::SSH_HOST` and a `device_path(index)` family. The bridge that
does not exist yet, and is alkssh phase 0/1 design work:
- `DerivedKey.private_key` (raw ed25519 bytes) → `russh::keys::PrivateKey`
— the `ssh-key` re-export accepts raw ed25519 seed bytes into
`KeypairData::Ed25519`, but the exact construction (and what
`PrivateKeyWithHashAlg` needs for a non-RSA key) needs POC-2
confirmation.
- **Signing-only alternative**: russh's `Signer` trait +
`authenticate_publickey_with(..., signer)` exists precisely for keys
that shouldn't be extractable. A vault-backed `Signer` would keep
the vault as the only place the bytes exist — candidate preferred
shape, POC-2's design question.
- Scope note: the vault is **client-side only**. The server side never
touches the vault — it receives clients' public keys and resolves
fingerprints through the assembly layer's `IdentityProvider`. Vault
material is for secrets we hold, not for other parties'
fingerprints.
### Host keys and `check_server_key`
The server's host identity (`Config::keys: Vec<PrivateKey>`,
`certificates`) is assembly-configured, ideally from the same vault
path family (`PATHS::SSH_HOST` exists deliberately). The client's
`Handler::check_server_key` defaults to *reject all* — russh forces a
deliberate host-key policy. Family posture (no TOFU): pin expected
host fingerprints at the assembly layer; `check_known_hosts` exists in
russh, but the family identity registry is likely the better pin store
(OQ candidate). `hostkeys_prove` / `openssh_ext_host_keys_announced`
(RSA-AKCE-style proving, new in 0.64.0) is an optional hardening note
for the ADR.
## References
- alkcall: `src/core/auth.rs` (Identity/AuthToken/AuthContext/
IdentityProvider verbatim above), `src/core/types.rs` (`set_identity`
once-only), `src/protocol/dispatch.rs` (`resolve_identity` — token
wins per-request, else connection identity), `src/registry/spec.rs`
(`AccessControl::check`)
- alkcall ADRs: 002 (ProtocolHandler, hybrid auth completion —
"handlers must not assume AuthContext is fully resolved"), 003
(auth-as-shared-core; the SshAdapter credential-table row), 006
(AuthContext shape/set_identity), 010 (secret-material flow)
- alkhttp ADR-014 (secret material flow + capability injection — the
client-side pattern), ADR-073 (one backend behind every door surface)
- alkvault 0.1.0: `src/service.rs` (VaultServiceHandle),
`src/derivation.rs` (PATHS), `src/protocol.rs` (DerivedKey)
- russh 0.64.1 docs: `server::Handler` auth hooks, `client::Handle`
authenticate methods, `Signer`, `keys` module (see `russh.md`)
+178
View File
@@ -0,0 +1,178 @@
# Mapping table — SSH channel types → family protocol seams
Status: Draft (phase 0 research). The crate's spine. Every row maps a
russh server-side entry point onto exactly one family protocol seam
plus exactly one cargo feature. Names below are *feature-name
candidates*, not decisions — the feature graph is phase 1 ADR
territory. All downstream surfaces verified against published sibling
source (alktty 0.8.1, alktunnels, alksocks, alkgit 0.1.0, alkcall 0.10).
## The shape that makes this work
Every family protocol crate already exposes a generic duplex-stream or
`Connection`-serving entry — no crate needs to know SSH exists:
| Crate | Serve-over-a-stream entry | Signature shape |
|---|---|---|
| alktty | `drive_session[_pre_negotiated]` | `(impl AsyncWrite, impl AsyncRead, backends, identity)` |
| alkgit | `serve_duplex_resolved` | `(repo_id, identity, service, T: AsyncRead+AsyncWrite, limits, backend traits)` |
| alksocks | `serve_socks5_over` | `(T: AsyncRead+AsyncWrite+Unpin, dial, egress, accept, can_bind)` |
| alktunnels | — **no serve-over-stream entry; see absence note** | consumer wrappers / open-op registrations instead |
russh channels convert to duplex via `Channel::{into_stream, split,
make_reader/make_writer}` (`ChannelStream`/com halves). So each
mapping is: accept-or-decline the channel open
(`ChannelOpenHandle`; dropping it auto-rejects — russh's default is
already fail-closed), pump/serve the channel through the seam, map the
request-level extras.
## Channel-type rows
| SSH channel type | Vanilla use | Feature (candidate) | Family seam | Notes |
|---|---|---|---|---|
| `session` → `pty_request`+`shell_request` | `ssh host` interactive shell | `session` | alktty producer: `NegotiateRequest` constructed locally → `drive_session` (or pre-allocated variant) | `pty_request` args → `TerminalParamsWire` (term/cols/rows/pixels; SSH modes → `modes` JSON reserved); shell case needs a cmd convention (OQ); PTY mode merges stderr into stdout (kernel PTY) |
| `session` → `exec_request` (non-git command) | `ssh host "cmd"` | `session` | alktty pipe mode: `terminal: None`, separate stderr | stderr flows back as SSH `extended_data` code 1 — only valid without PTY |
| `session` → `exec_request`/`subsystem_request` git | `git push/clone ssh://` | `git` | alkgit: parse command → `Service` + repo (same grammar as `preamble.rs`, without `\0host=`) → `serve_duplex_resolved(repo_id, identity, service, duplex, ...)` | Process-mode dialect is correct (SSH supplies service+repo out of band; server speaks first = advertisement). `subsystem` names also map here (`git-upload-pack` etc. arrive as either); unknown subsystems decline |
| `session` → SOCS5-in-channel | `ssh -D` | `dynamic-forward` | alksocks: `serve_socks5_over(channel_duplex, dial, egress, accept, can_bind)` | alkssh embeds `Socks5Producer`-shaped config from the assembly; `DialFn`/`EgressFactory` injected at assembly |
| `direct-tcpip` | `ssh -L` | `tunnel` | alktunnels (two routes, below) | Target = `SubstrateAddr::HostPort`/`Path`; `Substrate` = Tcp/Unix |
| `forwarded-tcpip` | `ssh -R` (data channel) | `tunnel` | alktunnels: `register_tunnel_forwarded_openable`'s `ForwardedHandler(params, BiStream)` shape — in the adapter, hand the russh channel duplex to the handler | listen flow via global-request rows below |
| `direct-streamlocal` | unix-socket forward | `tunnel` | same `SubstrateAddr::Path` treatment | unix-only; decline on other platforms (compile-time feature + runtime decline) |
| `x11` | X forwarding | **never** (no feature) | — | decline, always; not even feature-gated initially (OQ: revisit years later if a use case arrives) |
| `sftp` (`subsystem_request("sftp")`) | sftp clients | `sftp` (future) | alkfs (deferred, family ADR-002) | The declinable placeholder — the feature name exists in the feature graph, decline until alkfs lands |
| `auth-agent` (`agent_request`) | ssh-agent forwarding | **never** | — | decline; family holds keys in alkvault — forwarding agents to remote sessions is the anti-pattern this posture rejects |
Notes: the rows deliberately split one channel type (`session`) across
three features, because vanilla SSH multiplexes everything through a
session channel: a git command (`exec_request`), a SOCKS proxy client,
a shell, a pipe-mode command. The failure matrix this produces:
- git-only build: declines pty/shell, declines plain exec, declines
SOCKS-in-channel; serves only the git-parseable exec/subsystem path.
- session-only build: serves pty/shell/pipe-exec; the git-parseable
exec path declines (parsing to a git service is gated on `git`).
- No features: every session channel declines at open; every
unfeatured channel type declines at open.
Dispatch is parse-then-route by command shape: parsing to a git
service and repo requires `git`; SOCKS-in-channel requires
`dynamic-forward`; otherwise the alktty pipe/pty path requires
`session`. Parsing happens before dispatch and before any resource
allocation (a leaked channel alloc is a capability leak — decline
first). Exact grammar/precedence is a phase 1 spec question (OQ
candidate).
## Global-request rows (`tcpip_forward` / `streamlocal_forward`) — the `-R` listen flow
`Handler::tcpip_forward(address, &mut port) -> bool` + per-open
`channel_open_forwarded_tcpip`. Consumer-side alktunnels:
`TunnelListener`, `open_reverse_channel`; the forwarded data channel
maps to the `ForwardedHandler` route above. Dispositions follow the
`tunnel` feature (decline both listen and data when `tunnel` absent).
`port = 0` (server picks) interacts with the `&mut u32` out-param —
spec detail deferred to phase 1.
## Request-level mapping on a `session` channel
| SSH request | Family mapping |
|---|---|
| `pty_request` | `NegotiateRequest.tty = TerminalParamsWire{term, cols, rows, pixel_w, pixel_h, modes}` |
| `shell_request` | `NegotiateRequest.cmd` per the backend's shell convention (OQ — probably `cmd` + `cwd`/`env` from assembly policy, not from the wire) |
| `exec_request` | Parse command → git/service dispatch or alktty pipe `cmd`; unknown commands fail with `session.channel_failure` (parse-then-decline, no resource alloc) |
| `subsystem_request` | `sftp` → decline until `sftp` feature serves alkfs; git subsystem names → same parse as exec; anything else → decline |
| `env_request` | **Decline by default.** Wire-supplied env is not family identity config; `TtyParams.env` comes from assembly. (OQ: allowlist shape, e.g. `LANG`, later.) |
| `window_change_request` | `TtyControlHandle::resize(cols, rows, pixel_w, pixel_h)` |
| `signal` | `TtyControlHandle::signal(name)` (alktty's `signal_from_name` accepts the HUP/INT/TERM/... names verbatim) |
| data / `extended_data(1)` | channel duplex in/out; stderr only meaningful in pipe mode (PTY merges) |
| `exit_status` | from `TtyHandle.exit_code` (`TtySession.wait()` semantics: i32; negative = signal-terminated) — alkssh sends the SSH `exit-status` request before channel close |
| `exit_signal` | Only reachable in signal-termination cases; mapped from the same exit-code path (OQ: exact encoding) |
## Data-plane / limits contract
- russh windows: `Config::window_size`, `maximum_packet_size`,
`channel_buffer_size` cap per-channel memory; `Limits` caps
rekey volume; `inactivity_timeout` the session wall clock — the
family bounded-resources convention maps onto these knobs plus the
crate's own max-concurrent-channels (per session) and the per-length
parse limits on exec commands.
- alkcall bridge leg (when the family half is remote): channels-layer
reassembly cap 1 MiB/channel, chunk max 16 MiB, per-identity
channel-count policy — an SSH channel bridged over `alk/channels`
rides one reassembled byte stream per SSH channel; SSH's own
multiplexing is why ADR-034 grants `alk/ssh` only stream_types
`[0, 1]` ("SSH multiplexes internally, including its own control").
- Honest advertisement: `Config::methods` mirrors implemented auth; an
unfeatured channel type is *declined at open*, never half-opened.
Vanilla clients see a clean failure — which is the contract.
## alktunnels absence note
alktunnels has **no standalone `serve_*_over(stream)` producer entry**
(unlike alktty/alkgit/alksocks). The `direct-tcpip` mapping therefore
has two legitimate routes, and choosing between them is a phase 1
decision:
1. **Adapter-side bridge**: alkssh is itself an alk/channels *client*
(it holds a `ChannelClient` into the family fabric) and calls
`TunnelSession::open_direct(client, target, substrate)` /
`TunnelSession::adopt(...)` directly, pumping `take_halves()`/
`stream_halves()` against the russh channel. (alksocks'
`composed::direct_egress` is the family precedent for exactly this
bridging.)
2. **Registry-side bridge**: within an alkcall serving endpoint,
`register_tunnel_direct_openable` with a `DialFn` — but that puts
alkssh-shaped work in the serving endpoint's registry, which
inverts the door posture. Likely rejected on composability grounds;
recorded here for the ADR to decide explicitly.
## Topology: where the `alk/ssh` ALPN itself lives
Three alkcall ADR constraints collide productively:
- ADR-004 reserves `alk/ssh` → `SshAdapter` (custom protocol).
- ADR-034's per-ALPN table: `alk/ssh` gets stream_types `[0, 1]` —
SSH multiplexes internally, the channels layer is byte-blind to it.
- ADR-042/051: "The hub never runs a handler for `alk/tty`, `alk/ssh`,
or `alk/tunnel`" — `alk/ssh` channels *relay payload-blind* between
spokes; ACL fires at `channel/open`.
ADR-009 additionally sketches a browser route: a WebTransport session
to `/alk/ssh` where each bidi stream is handed to `SshAdapter::handle`
as a `Connection`, with a WASM SSH parser speaking SSH over it.
Reading: `alk/ssh` is plausibly *SSH-over-channels* — the raw vanilla-
SSH byte stream carried between alkssh instances (edge termination at
one end, mapped serving at the other), reused by the browser route.
Whether the edge-terminated topology (russh at the edge, family seams
in-process or via `alk/channels` hops) ever needs to speak `alk/ssh`
itself, and what `SshAdapter::handle(connection)` does for the browser
route (it would be alkssh's russh wrapper serving an inbound alkcall
connection), is the phase 0 topology question. POC-3's design will
attempt the simplest composition; the decision records itself in the
phase 1 ADR-001 (ALPN pin, ledger rule 1).
## Open questions seeded for phase 1
- Session-channel dispatch precedence + the exec-command grammar
(git parse vs pipe cmd) — parse-then-fail-closed contract.
- Shell command convention for `shell_request` (assembly policy vs
wire-supplied; `wd`/`env` provenance).
- `env_request` policy (decline-only vs allowlist).
- OQ: OpenSSH certificate auth (`auth_openssh_certificate`) — decline
until a use case.
- OQ: username disposition (ignored entirely vs a routing hint).
- Feature-graph names + the `session`-vs-`git`-vs-`dynamic-forward`
split inside one channel type.
- The `alk/ssh` topology question above.
- Windows: `direct-streamlocal` is unix-only; `pty` on Windows targets
depends on alktty backend capability (decline rather than degrade).
## References
- `russh.md` §server surface (handler inventory, Config)
- `auth-bridge.md` (identity arrives before any channel opens)
- alkcall ADRs 002, 004, 009, 034 (+035 chunk header), 037, 042, 051
- Sibling sources: alktty `src/adapter.rs`/`src/negotiation.rs`/
`src/backend.rs`/`src/channels.rs`/`src/session.rs`, alktunnels
`src/consumer.rs`/`src/producer.rs`/`src/params.rs`, alksocks
`src/producer.rs`/`src/composed/mod.rs`, alkgit
`src/duplex.rs`/`src/preamble.rs`/`src/substrate.rs`/`src/session.rs`
+146
View File
@@ -0,0 +1,146 @@
# POCs — phase 0 validation plan
Status: Draft (phase 0 research). Deliverables of each POC are
findings docs (`docs/research/poc-N-*.md` when run); POC source is
disposable and never merges to main. Mode for all: **standalone**
(repos have no code to build on yet) — scratch cargo projects at
`/workspace/<poc-name>` unless a POC explicitly builds on repo code
(then branch mode per `docs/sdd_process.md`).
The hypotheses below are what phase 1's ADRs will cite. If a POC
refutes a hypothesis, the finding doc records the refutation — that is
the point of running it.
## POC-1 — auth bridge + fail-closed declines end to end
**Question:** does the russh 0.64-era server auth surface compose with
alkcall's fingerprint-based identity model as cleanly as
`auth-bridge.md` assumes, and does the fail-closed decline contract
hold under a real vanilla client?
**Construction:** standalone crate. russh server loopback bound to a
local listener + vanilla `ssh` (OpenSSH) as the client (the point is
to validate against the *vanilla* wire, not just russh's own client).
A config-backed `IdentityProvider` stub registered with two ed25519
keys; russh client available as a secondary client for
`Signer`-shaped checks.
**Validates:**
- `auth_publickey` after russh's signature verification →
`ssh_key` fingerprint (`HashAlg::Sha256`) string parity with a
stub-registered identity; `resolve_from_fingerprint` → `Some` →
authenticated; unknown key → clean decline under
`auth_rejection_time`/`max_auth_attempts`.
- `auth_publickey_offered` disposition (accept-and-let-decide) behaves
with OpenSSH's probe-then-sign flow.
- Decline matrix: `auth_none`/`password`/`keyboard-interactive`
declined (default impls are *accept* — every default must be
overridden and proven); `Config::methods` advertises `publickey`
only.
- `ChannelOpenHandle` drop = auto-reject semantics; unfeatured
channel-type declines observable by vanilla `ssh`
(`ssh host` session-open refused, `-L` refused, clean diagnostics).
- `Channel` ↔ AsyncRead/AsyncWrite conversion (`into_stream`/`split`)
behaves under a trivial echo (the plumbing POC-2 leans on).
**Hypothesized outcome:** the auth-bridge design holds; the only
uncertainty is fingerprint-format parity details, which this either
confirms or corrects.
## POC-2 — client-side vault → russh key bridge (separate by nature)
**Question:** how does an alkvault-derived key become a russh client
key without the vault leaking the bytes — `PrivateKey` extraction vs
a vault-backed `Signer`?
**Construction:** standalone. alkvault `VaultServiceHandle` +
`derive_ed25519` (host/device paths) → two candidate shapes:
(a) `ssh-key` `KeypairData::Ed25519` from raw seed bytes →
`russh::keys::PrivateKey` → `authenticate_publickey`; (b) a vault-
backed `russh::Signer` fed to `authenticate_publickey_with` (bytes
never leave the vault struct).
**Validates:**
- Whether ed25519 seed bytes → `ssh-key` key data round-trips
(`KeypairData` expects the *expanded* keypair — seed-to-`[u8; 64]`
expansion is a real step; whether alkvault's `DerivedKey` carries
the expanded form or just the seed is an open sub-question).
- Whether `Signer` + `authenticate_publickey_with` + `PublicKey`
(public half held outside the vault) is ergonomic, and what
`PrivateKeyWithHashAlg` wants for non-RSA keys.
- Server side of the same loopback re-uses POC-1's fingerprint path —
client key derived in vault, server resolves its fingerprint.
- Lifecycle correctness: derived key zeroization across the boundary;
vault lock/unlock races.
**Scope note (per the design split):** the vault is for secrets *we*
hold — client-side outgoing keys and the server's own host key — and
is explicitly not involved in resolving *clients'* fingerprints
(that path is the assembly-layer `IdentityProvider`). Kept as its own
POC because its failure modes (key-material lifecycle) are unrelated
to the auth-flow failure modes of POC-1.
**Hypothesized outcome:** shape (b) (vault-backed `Signer`) is the
preferable design; if `Signer` friction proves high, shape (a) with a
zeroizing wrapper is documented with its exposure caveat.
## POC-3 — the mapping bake-off: channel bridge through a real sibling seam
**Question:** does "russh channel duplex → family serve-over-a-stream
entry" actually work against the real published seams — and which of
the mapping-table's route choices survives contact with reality?
**Construction:** standalone, but *depending on the real sibling
crates* (alktty/alksocks/alkgit + alkcall types via their published
forms). russh server loopback + vanilla clients; per stage:
- **Stage A — git exec mapping** (the alkforge-critical path): vanilla
`git clone/push` over `ssh://` → russh server → parse exec command →
`serve_duplex_resolved(...)` over the split channel halves with stub
`GitRefs`/`GitPackGen`/`GitPackIngest` (or the substrate composition
if a real repo is wanted late in the POC). Confirms: the Parse-mode
(server-speaks-first advertisement) dialect satisfies vanilla git's
expectations over exec; channel EOF/close maps to session
termination; exit-status flows.
- **Stage B — `-D` mapping**: russh server, feature `dynamic-forward`-
shaped build → `serve_socks5_over` over a session channel → `curl`
through the SOCKS5 proxy to a local HTTP endpoint. Confirms: the
dispatch-split inside `session` (parse-then-route vs SOCKS-detect),
duplex backpressure under throughput.
- **Stage C — `-L` mapping**: `direct-tcpip` → the mapping-table's two
alktunnels routes (adapter-side `TunnelSession::open_direct` via a
real `ChannelClient` vs local-dial stand-in). Whichever route the
POC exercises informs the phase 1 ADR on the presence/absence of a
serve-over-stream entry in alktunnels.
- **Stage D — session/pty mapping**, last and optional (a real PTY
backend needs alktty's `local` feature; the wire-level questions are
already largely answered by A/B/C): shell over
`drive_session` with the locally-constructed `NegotiateRequest`,
window-change and signal mapping.
**Validates:** the one-seam-per-mapping hypothesis; the parse-then-
route dispatch; stderr semantics in pipe vs PTY mode; EOF/close/signal
fidelity end to end; throughput sanity (window sizes vs the 1 MiB
channels reassembly cap on the bridged route).
**Hypothesized outcome:** stages A–C confirm the design with local
fixups recorded; stage D may surface PTY-merge stderr surprises that
reshape the request-level mapping rows.
## Sequencing and gates
1. POC-1 first — everything downstream presumes its auth plumbing.
2. POC-2 after POC-1 (reuses POC-1's server loopback).
3. POC-3 last; stage A gates phase 1's mapping ADR work — it is the
path alkforge phase 0 consumes first.
4. Phase 0 completes when the findings docs exist and the OQ list in
`vision.md` §scope questions is either answered by findings or
carried into phase 1's `open-questions.md` as seeded OQs.
## References
- `vision.md` (principles the POCs must honor — including that POC
code carries relaxed constraints but never merges)
- `russh.md` (API shapes the POCs exercise), `auth-bridge.md`,
`mapping-table.md`
- `docs/sdd_process.md` §Phase 0 (POC modes, standalone placement)
+198
View File
@@ -0,0 +1,198 @@
# russh — crate inventory and pin considerations
Status: Draft (phase 0 research). Facts from docs.rs and the GitHub
release notes as of 2026-10-25; versions re-verified at architecture
time before any pin is recorded.
## Version landscape
- Current published: **russh 0.64.1** (2026-10-05, docs.rs). 0.64.1 is
a security-fix-only patch of 0.64.0 (GHSA-4wc5-f2rc-q74m — unbounded
memory allocation in the agent protocol server from crafted
constraint records; no API change).
- 0.64.0 (2026-10-04) broke API: dropped the
`legacy-ed25519-pkcs8-parser` feature (pre-0.43 non-compliant
ed25519 keys now hard-error); fixed GHSA-j7h2-66j4-ghjf (server
pubkey auth accepted mismatched signature algorithms — e.g.
rsa-sha2-512 silently downgraded to rsa-sha1); added
`Handle::hostkeys_prove` and `Handle::send_global_request`; fixed
`Limits` not being fully enforced.
- There are older russh families (0.4x–0.5x) with materially different
handler shapes in circulation; one rough family-internal integration
example exists (alknet `main`, pinned 0.49) but it is a reading aid
only — a rough working example, months stale — and is **not decision
input** for anything here. The pin is chosen against the current
published russh, verified at architecture time.
## Requirement posture
- **License: Apache-2.0 only.** The family crates are MIT OR
Apache-2.0. Depending on an Apache-2.0-only dependency from a dual-
licensed crate is clean (distribution under the Apache-2.0 branch of
our license satisfies it); record this in the pin ADR so the audit
trail is explicit.
- **MSRV 1.89, edition 2024.** Our crate's `rust-version` must be ≥
russh's once the dependency lands (currently `1.88` in the manifest
— a pending decision, deferred to the pin ADR).
- **Crypto backend: exactly one of `aws-lc-rs` (default) or `ring`.**
The build errors if zero or both are selected. Feature choice is a
phase 1 decision (alktls family precedent may pull one way).
- Other features: `flate2` (compression, default), `rsa` (default;
pulls **RC-pinned** `rsa =0.10.0-rc.18` + `pkcs1 =0.8.0-rc.4`),
`async-trait`, `des`/`dsa` ("Danger: insecure" — never enable),
`serde`. The `rsa` question interacts with the RC-pin question below.
- **RC dependencies in the current pin set:** `ssh-key =0.7.0-rc.11`
(exact-pinned by russh itself via `russh::keys::ssh_key` re-export),
`rsa =0.10.0-rc.18`, `pkcs1 =0.8.0-rc.4`, `getrandom 0.4`. Exact pins
mean consumers get the same RCs; but an RC in the tree of a crate we
intend to publish is a supply-chain/stability posture point the pin
ADR must weigh (wait for ssh-key 0.7.0 stable? accept the RC? which
russh family avoids it?).
## Server surface (`russh::server`)
### `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:
- Auth: `auth_none(user)`, `auth_password(user, password)`,
`auth_publickey_offered(user, &PublicKey)` (pre-auth probe — cheap
rejection hook; key ownership not yet verified),
`auth_publickey(user, &PublicKey)` (called **after** russh verified
the signature and key ownership), `auth_openssh_certificate(user,
&Certificate)`, `auth_keyboard_interactive(user, submethods,
Option<Response>)`. All return `Result<Auth, Self::Error>`; russh
holds rejection to constant time via `Config::auth_rejection_time`.
`auth_succeeded(&mut Session)` fires on success.
- Channel opens (each receives `channel: Channel<Msg>` +
`reply: ChannelOpenHandle`; **dropping the handle without calling
`reply.accept()`/`reply.reject()` auto-rejects the open** — a
natural fail-closed default): `channel_open_session`,
`channel_open_direct_tcpip(host_to_connect, port_to_connect,
originator_address, originator_port)`,
`channel_open_forwarded_tcpip(same params)`,
`channel_open_direct_streamlocal(socket_path)`,
`channel_open_x11(...).`
- Session-channel requests: `pty_request(term, col_width, row_height,
pix_width, pix_height, &[(Pty, u32)])`, `shell_request`,
`exec_request(data: &[u8])`, `subsystem_request(name: &str)`,
`env_request(name, value)`, `window_change_request(...)`,
`signal(Sig)`, `x11_request`, `agent_request` (returns whether
accepted). Success/failure must be signaled explicitly via
`session.channel_success(channel)` / `channel_failure(channel)`.
- Data plane: `data(channel, &[u8])`, `extended_data(channel, code,
&[u8])` (code 1 = stderr), `channel_eof`, `channel_close`,
`window_adjusted`, `adjust_window` (returns next target window).
- Global: `tcpip_forward(address, &mut port) -> bool`,
`cancel_tcpip_forward`, `streamlocal_forward`,
`cancel_streamlocal_forward` (the `-R` listen flow), and
`lookup_dh_gex_group`.
### `server::Config` knobs (bounded-resources posture)
`server_id`, `methods: MethodSet` (advertised auth methods — honest
advertisement means this must mirror the feature graph), `keys: Vec<PrivateKey>` (host keys), `certificates: Vec<Certificate>`,
`auth_rejection_time` + `auth_rejection_time_initial`,
`max_auth_attempts`, `limits: Limits` (bytes/time before rekey),
`window_size`, `maximum_packet_size`, `channel_buffer_size`,
`event_buffer_size`, `inactivity_timeout`, `keepalive_interval`,
`keepalive_max`, `nodelay`, `preferred: Preferred` (algorithm
preferences — the place any algorithm restriction would be enforced).
Most family bounded-resource conventions map directly: max concurrent
channels is additionally ours (alkcall-side per-identity policy when
bridged; in-crate when serving locally), session timeout =
`inactivity_timeout`, auth attempt limit = `max_auth_attempts`.
## Client surface (`russh::client`)
### `client::Handler` trait
Same shape as server: `type Error: From<russh::Error> + Send + Debug`;
provided methods default sensibly, but **`check_server_key` defaults
to reject-all and must be overridden** — host-key policy is a phase 1
design point (for the family client: pin the expected server
fingerprint at the assembly layer, per the no-trust-on-first-use
direction this implies). Inventory: `check_server_key`
(&PublicKeyOrCertificate) → bool; `kex_done`; the full
server-initiated channel-open set (`server_channel_open_session /
_direct_tcpip / _forwarded_tcpip / _forwarded_streamlocal / _x11 /
_agent_forward / _unknown` — the client is an acceptor for forwarded
channels, i.e. the `-R` data path) with the same
`ChannelOpenHandle` accept/reject semantics; `should_accept_unknown_server_channel`; data plane (`data`,
`extended_data`, `exit_status`, `exit_signal`, `xon_xoff`,
`window_adjusted`, `channel_eof/close/...`); `disconnected`;
`openssh_ext_host_keys_announced` (do-not-await-inside warning;
proofs go through `Handle::hostkeys_prove`).
### `client::Handle<H>`
Authentication methods: `authenticate_publickey(user,
PrivateKeyWithHashAlg)`, `authenticate_password`,
`authenticate_none`, `authenticate_keyboard_interactive_start/
respond`, `authenticate_openssh_cert`, the `*_with` signer-injected
variants (`authenticate_publickey_with(user, PublicKey, hash_alg,
&mut Signer)` — the seam for vault-backed signers that never expose
the private key), `authenticate_gssapi_with_mic`,
`best_supported_rsa_hash` (waits ≤1s for EXT_INFO `server-sig-algs`).
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>`,
`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**),
`request_pty/request_shell/exec/request_subsystem/set_env/
window_change/signal/eof/close/exit_status`,
`writable_packet_size()`.
Global: `tcpip_forward(address, port) -> u32` (0 → server picks),
`cancel_tcpip_forward`, `streamlocal_forward`, `disconnect`,
`send_keepalive`, `rekey_soon`, `no_more_sessions`,
`send_global_request(name, data, want_reply)`, `hostkeys_prove`.
`client::Config`: `window_size`, `maximum_packet_size`,
`limits`, `channel_buffer_size`, `preferred`, `inactivity_timeout`,
`keepalive_interval`/`keepalive_max`, `anonymous`, `gex`, `nodelay`,
`client_id`. (No client-side auth-attempt limit knob.)
## Keys module (`russh::keys`)
Re-exports: the whole `ssh-key` crate (`ssh_key`, pinned `=0.7.0-rc.11`)
plus `PrivateKey`, `PublicKey`, `Certificate`, `HashAlg`,
`PublicKeyOrCertificate`, `PrivateKeyWithHashAlg`, and loaders
(`load_secret_key`, `decode_secret_key`, `load_public_key`,
`parse_public_key_base64`, `load_openssh_certificate`,
`decode_openssh`). Fingerprint computation lives in `ssh-key`
(`PublicKey::fingerprint`, SHA-256 OpenSSH format `SHA256:base64`) —
relevant to `auth-bridge.md`. `known_hosts::check_known_hosts[_path]`
exists for host-key verification. OpenSSH agent interop (`keys::agent`)
exists but is out of family scope (agent forwarding declines; see
`mapping-table.md`).
## Open pin questions for the ADR
1. Which family/russh line (current stable 0.64.1 is the default
candidate) and whether the RC-pinned `ssh-key`/`rsa`/`pkcs1` tree
is acceptable or gates the pin.
2. Crypto backend (`aws-lc-rs` vs `ring`) — align with alktls.
3. `rust-version` bump in our manifest (≥ 1.89) — lands with the
dependency.
4. Whether to enable the `rsa` feature at all (family keys are
ed25519-first; disabling `rsa` drops two RC deps from the tree).
5. Confirm compatibility with the vanilla clients alkforge needs (ssh
+ git + scp-era behavior) — POC-1's deliverable.
## References
- docs.rs russh 0.64.1: server `Handler`, client `Handler`, `Config`s,
`Handle`, `keys` module (sections above quote their inventories)
- russh GitHub releases 0.64.0 / 0.64.1 (GHSA ids quoted above)
- alknet `main` `crates/alknet-core/src/auth/` — rough reference only,
explicitly not decision input (0.49-era API; verify nothing against
it)
+110
View File
@@ -0,0 +1,110 @@
# Vision
Status: Draft (phase 0 research).
## What this is
alkssh is the alk family's SSH adapter: a thin wrapper around russh
terminating the vanilla-ssh wire and bridging to/from alkcall channels
(family ADR-003). It is the alkhttp-style adapter for the SSH protocol
— the vanilla-ssh front door for vanilla clients, and the family's
vanilla-ssh client where a family app dials an upstream SSH server.
The substance almost entirely exists in the family. What alkssh adds is
the glue + the mapping:
- vanilla ssh (ssh(1), git(1), sftp clients) connect to an alkssh
endpoint as they would to any sshd;
- each SSH channel type is mapped onto a family protocol half
(see `mapping-table.md`);
- identity arrives the same way it does everywhere else in the family:
a verified SSH public key's fingerprint resolved through alkcall's
`IdentityProvider` (see `auth-bridge.md`).
The alkforge-relevant property is **exclusion**: an alkforge ssh
endpoint built with only the `git` feature is structurally incapable of
serving a shell, tunnels, or socks. Every mapping is a cargo feature;
unfeatured channel opens decline fail-closed (family ADR-003 decision
3). This is why alkssh leads alkforge's phase 0 — the adapter is glue,
not protocol invention.
## Guiding principles
1. **russh owns the SSH wire.** No SSH framing is authored in-family
(family ADR-003 decision 1). KEX, cipher negotiation, channel
multiplexing, and userauth state machines come from russh; third-
party-upstream posture applies (file asks upstream; never silently
absorb a workaround).
2. **A door, not a backend.** alkssh speaks family-protocol types only
at the mapped seams. It carries no deployment policy: a downstream
app (alkforge) embeds it, enables features, and brings identity/ACL
configuration.
3. **Fail-closed everything.** The feature graph, the auth-method
disposition, and the channel-request disposition all default to a
clean decline. Nothing is advertised that isn't served; nothing is
half-served. Every decline path is contract territory and is
tested.
4. **One seam per mapping.** Each family protocol already exposes a
generic duplex-stream or `Connection` serving entry
(`mapping-table.md`); a russh channel's read/write halves plug in at
exactly one point per mapping. No channel-type knowledge flows
upstream into the protocol crates.
5. **Identity is the family's, not SSH's.** SSH user names, `~/.ssh/`
conventions, and password auth are not the alk family's identity
model. A verified key's fingerprint → `Identity` via alkcall's
`IdentityProvider` is the only resolution path; secrets an alkssh
*client* holds come from alkvault-backed sources, never env vars
(family ADR-002 decision 5).
## Non-goals
- **Authoring SSH protocol machinery** — no in-family framing, cipher,
or KEX code, ever (russh upstream decides what SSH is).
- **Being an sshd.** No local user accounts, no `authorized_keys` file
semantics of the OpenSSH variety, no PAM-shaped anything. Deployment
posture belongs to the embedding app.
- **Serving sftp** — deferred to alkfs (family ADR-002); the subsystem
decline is the placeholder contract.
- **A binary / CLI.** Downstream apps embed the crate; there is no
`alkssh` executable in this crate.
- **Password / keyboard-interactive auth for clients.** The family
identity model has no password concept; these methods decline
server-side (see `auth-bridge.md`) and exist client-side only as
non-goals until a consuming use case arrives.
## Scope questions phase 0 must answer
These frame the research docs; each is a candidate open question for
phase 1's `open-questions.md`:
1. **The `alk/ssh` ALPN's topology role** — alkcall ADR-004 reserves
`alk/ssh` → `SshAdapter`, ADR-034's channels table gives it
stream_types `[0, 1]` ("SSH multiplexes internally, including its
own control"), and ADR-009 sketches a browser-WASM route where the
ALPN transports an SSH stream to an `SshAdapter`. But family ADR-003
describes alkssh terminating vanilla SSH at an edge. Whether
`alk/ssh` carries raw vanilla-SSH bytes between alkssh instances
(a relay leg), serves the browser route, or both — and how that
interacts with ADR-042's "the hub never runs a handler for
`alk/ssh`" relay ruling — is unresolved (see `mapping-table.md`
§topology).
2. **russh pin** — version, crypto-backend feature, and the
RC-pinned transitive dependency situation (see `russh.md`).
3. **Feature names + graph** — the `session`/`tunnel`/`dynamic-`-`forward`/`git`/`sftp` naming and their independence
(no feature implies another; flat until architecture says otherwise).
4. **Auth dispositions** — which russh auth methods the server
implements vs declines; the exact `auth_publickey` → fingerprint →
identity contract; the client-side vault→russh key bridge
(see `auth-bridge.md`).
5. **The mapping table details** — per channel type and per request,
the family seam, the parameter mapping, and the decline contract
(see `mapping-table.md`); POC validation per `pocs.md`.
## References
- family ADR-003 (`/workspace/@alkdev/alkfamily/docs/architecture/decisions/003-alkssh-russh-adapter.md`)
- family ADR-002 (alkforge; why the exclusion property matters)
- family open-questions OQ-08 (partially resolved; remaining scope is
this repo's phase 0)
- `russh.md`, `auth-bridge.md`, `mapping-table.md`, `pocs.md` in this
directory