From ecdf836eebd7ebe3aaf2d2c298cf2097f2c91aa7 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sun, 25 Oct 2026 10:13:17 +0000 Subject: [PATCH] =?UTF-8?q?docs(research):=20phase=200=20research=20set=20?= =?UTF-8?q?=E2=80=94=20vision,=20russh,=20auth=20bridge,=20mapping=20table?= =?UTF-8?q?,=20POC=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- AGENTS.md | 16 +++ CHANGELOG.md | 7 +- docs/research/auth-bridge.md | 176 +++++++++++++++++++++++++++++ docs/research/mapping-table.md | 178 +++++++++++++++++++++++++++++ docs/research/pocs.md | 146 ++++++++++++++++++++++++ docs/research/russh.md | 198 +++++++++++++++++++++++++++++++++ docs/research/vision.md | 110 ++++++++++++++++++ 7 files changed, 830 insertions(+), 1 deletion(-) create mode 100644 docs/research/auth-bridge.md create mode 100644 docs/research/mapping-table.md create mode 100644 docs/research/pocs.md create mode 100644 docs/research/russh.md create mode 100644 docs/research/vision.md diff --git a/AGENTS.md b/AGENTS.md index 45eff61..d6ce50c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,6 +209,22 @@ production code. ## Architecture Context - `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/` — `architecture/decisions/003-alkssh-russh-adapter.md` is the structural decision this repo builds from (and `002-alkforge-gitea-replacement.md` diff --git a/CHANGELOG.md b/CHANGELOG.md index e82e476..224617b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,4 +12,9 @@ this crate adheres to [Semantic Versioning](https://semver.org/). opencode agent definitions, rust crate manifest (lib only, no dependencies yet), MIT OR Apache-2.0 licensing. The russh pin, the capability feature graph, and the channel-type mapping table are - phase 0 research output (family ADR-003, OQ-08 remaining scope). \ No newline at end of file + 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). \ No newline at end of file diff --git a/docs/research/auth-bridge.md b/docs/research/auth-bridge.md new file mode 100644 index 0000000..201bd46 --- /dev/null +++ b/docs/research/auth-bridge.md @@ -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, + pub resources: HashMap>, +} +pub struct AuthToken { pub raw: Vec } +pub struct AuthContext { + pub identity: Option, + pub alpn: Vec, + pub remote_addr: Option, + pub tls_client_fingerprint: Option, +} + +pub trait IdentityProvider: Send + Sync + 'static { + fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option; + fn resolve_from_token(&self, token: &AuthToken) -> Option; +} +``` + +`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::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` (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`, +`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`) \ No newline at end of file diff --git a/docs/research/mapping-table.md b/docs/research/mapping-table.md new file mode 100644 index 0000000..81f7384 --- /dev/null +++ b/docs/research/mapping-table.md @@ -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` \ No newline at end of file diff --git a/docs/research/pocs.md b/docs/research/pocs.md new file mode 100644 index 0000000..e287a9e --- /dev/null +++ b/docs/research/pocs.md @@ -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/` 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) \ No newline at end of file diff --git a/docs/research/russh.md b/docs/research/russh.md new file mode 100644 index 0000000..9100509 --- /dev/null +++ b/docs/research/russh.md @@ -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 + 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)`. All return `Result`; russh + holds rejection to constant time via `Config::auth_rejection_time`. + `auth_succeeded(&mut Session)` fires on success. +- Channel opens (each receives `channel: Channel` + + `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` (host keys), `certificates: Vec`, +`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 + 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` + +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`, +`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) \ No newline at end of file diff --git a/docs/research/vision.md b/docs/research/vision.md new file mode 100644 index 0000000..82d7e32 --- /dev/null +++ b/docs/research/vision.md @@ -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 \ No newline at end of file