diff --git a/CHANGELOG.md b/CHANGELOG.md index 51bdaa2..19d9227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,4 +18,19 @@ this crate adheres to [Semantic Versioning](https://semver.org/). `auth-bridge.md` (fingerprint → identity server path; vault-backed at-rest client key storage — the vault as secret store, not key derivation), `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 + + feature), `pocs.md` (POC-1..3 plan). +- Phase 0 completed — all three POCs passed against the current + published russh with vanilla tooling (121 checks total); findings + in `poc-1-auth-bridge.md` (31), `poc-2-vault-keys.md` (39), + `poc-3-mapping-bakeoff.md` (51, three mapping-table corrections + recorded), plus the reverse-runner client-direction extraction + (`reverse-runner.md`). POC source is disposable, never merged. +- Phase 1 architecture under `docs/architecture/`: the index + + open-questions tracker (OQ-01…13), the spec docs + (`overview.md`, `mapping.md`, `client.md`, `auth.md`), and ADRs + 001–006 (Accepted) — the `alk/ssh` ALPN pin; the russh 0.65.0 pin + (aws-lc-rs, no compression, `rsa` off); the capability feature + graph (flat, two-direction, `dynamic-forward` retired); auth + dispositions; vault-key custody; and the mapping/dispatch + decisions (route-1 bridge, exec-only git, parse-only session + dispatch). \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 359339e..c8abe04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ # Single-crate protocol/door crate (the alktty template). No workspace: # the composition story is deferred — alkssh's substance is the mappings -# (family ADR-003), and phase 0 research settles the russh pin first. +# (family ADR-003); the russh pin is decided (ADR-002: 0.65.0, aws-lc-rs). [workspace] members = ["."] @@ -10,7 +10,7 @@ version = "0.1.0" edition = "2021" rust-version = "1.88" license = "MIT OR Apache-2.0" -description = "SSH adapter for the alk family: russh-wrapped vanilla-ssh termination bridged to/from alkcall channels — session/pty, tunnels, dynamic forwarding, git exec/subsystem — with every capability mapping a fail-closed cargo feature (no features: no capabilities)" +description = "SSH adapter for the alk family: russh-wrapped vanilla-ssh termination bridged to/from alkcall channels — session/pty, tunnels, git exec — with every capability mapping a fail-closed cargo feature (no features: no capabilities)" repository = "https://git.alk.dev/alkdev/alkssh" keywords = ["ssh", "russh", "protocol", "alkcall", "network"] categories = ["network-programming", "asynchronous"] @@ -24,18 +24,18 @@ exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/plans/", "docs/rese name = "alkssh" [features] -# The capability feature graph (family ADR-003 decision 3) lands with the -# committed architecture: session (→ alktty), tunnel (→ alktunnels), -# dynamic-forward (→ alksocks), git (→ alkgit), sftp (→ alkfs, deferred). -# The default build carries no capabilities by design — every mapping is -# opt-in and every unfeatured channel open declines fail-closed. +# The capability feature graph (ADR-003) lands with implementation start: +# session (→ alktty), tunnel (→ alktunnels), git (→ alkgit), sftp (→ +# alkfs, deferred placeholder). The default build carries no capabilities +# by design — every mapping is opt-in and every unfeatured channel open +# declines fail-closed. default = [] [dependencies] tokio = { version = "1.54.1", features = ["full"] } -# Deliberately empty at initialize time — the russh version pin is phase 0 -# research output (alknet main's `russh = "0.49"` is the reference, not the -# answer). alkcall + sibling-crate deps land with the committed architecture. +# Deliberately empty until implementation start — the russh pin is decided +# (ADR-002: 0.65.0, aws-lc-rs); russh + alkcall + sibling-crate deps land +# with implementation. [dev-dependencies] diff --git a/docs/architecture/README.md b/docs/architecture/README.md index f577246..8cedd72 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-10-26 +last_updated: 2026-10-27 --- # Architecture index @@ -39,8 +39,10 @@ architecture review pass is next, after which specs go to `reviewed`. ## Open Questions Tracked in [open-questions.md](open-questions.md) — OQ-01 … OQ-13, -seeded from the phase 0 POC findings and research docs. Five resolved -(ADR-002/006 review + evidence), four deferred with named blockers. +seeded from the phase 0 POC findings and research docs. Six resolved +(OQ-02/03/05/06/07/12), three open (OQ-08/10/11 — mapping-detail +decisions in the current review pass), four deferred with named +blockers (OQ-01/04/09/13). ## Document Lifecycle @@ -58,4 +60,7 @@ seeded from the phase 0 POC findings and research docs. Five resolved inventory, auth bridge, mapping table, reverse-runner extraction) - Family: `/workspace/@alkdev/alkfamily/docs/architecture/decisions/001..003` (ADR-003 is the alkssh structural decision) -- Sibling: alkcall ADRs 002/004/009/034/042/051, alkhttp ADR-014/016/073-analogs \ No newline at end of file +- Sibling: alkcall ADRs 002/003/004/006/009/010/034/042/051 (ALPN + dispatch, auth surface, channels wire/relay); alkhttp ADR-014/073 + (secret-material flow and door-surface posture — the patterns + ADR-005 and `auth.md` mirror) \ No newline at end of file diff --git a/docs/architecture/auth.md b/docs/architecture/auth.md index 7a1206c..13b006b 100644 --- a/docs/architecture/auth.md +++ b/docs/architecture/auth.md @@ -77,15 +77,20 @@ Two cases, one seam — the crate is **policy-blind**: 2. **Unknown beforehand** (vanilla `sshd` on rental fleets; no anchor until contact): the crate surfaces (host, port, presented fingerprint) to an **assembly-provided - `HostKeyPolicy`** — prompt-and-record, provider-metadata match, or - learn-and-record. The OpenSSH "add to known_hosts?" moment becomes + `HostKeyPolicy`** — prompt-and-record, provider-metadata match, + learn-and-record, or accept-and-continue (the ephemeral-fleet + shape). The OpenSSH "add to known_hosts?" moment becomes assembly policy. Crate rules: default policy is fail-closed (reject unknown); the -crate never decides trust; accept-all is never a crate option -(alkminer's accept-all is the recorded anti-pattern; for ephemeral -fleets the *real* anchor is hub-held key material — per ADR-005's -custody model — not host pins). +crate never decides trust and never hard-codes a trust model — +accept-all is not a built-in crate option, but a permissive case-2 +policy is the assembly's to choose where its fleet model calls for +it (alkminer's accept-all for rental fleets is that shape, made +deliberately: fresh container per rent → fresh host key, so no pin +can exist beforehand; the security anchor there is hub-held +per-instance dial keys — ADR-005's custody model — plus what rides +the tunnels, not host pins). ## What is out of scope @@ -95,15 +100,16 @@ custody model — not host pins). OQ-04 deferred on a CA use case). - ssh-agent integration/forwarding (external signer processes are out of family scope; the vault is the custody story). -- TOFO/TOFU *as crate behavior* — policy lives at the assembly, the +- TOFU *as crate behavior* — policy lives at the assembly, the crate is policy-blind (above). ## Open Questions - **OQ-04** (deferred — certificate auth, on a use case). -- **OQ-07** (open — the vault `decrypt` plain-`String` gap; upstream - ask to alkvault; adapter-side `Zeroizing` convention stands - meanwhile; ADR-005 decision 3 is the contract). +- **OQ-07** (resolved at the adapter level — the vault `decrypt` + plain-`String` gap: adapter-side `Zeroizing` wrap is the contract + (ADR-005 decision 3); the vault-side improvement is an upstream + alkvault ask). - **OQ-05** (resolved — recorded above). ## References @@ -113,6 +119,7 @@ custody model — not host pins). - `docs/research/auth-bridge.md` (the surface mapping + host-key refinement), `poc-1-auth-bridge.md` (§1–4), `poc-2-vault-keys.md` (§2–5), `reverse-runner.md` (lessons 5–6) -- alkcall ADRs 002/003/006 (identity surface), alkhttp ADR-014/073 - analogs (secret flow, one-backend posture), family ADR-002 - decision 5 / family ADR-003 decision 2 \ No newline at end of file +- alkcall ADRs 002/003/006 (identity surface) and 010 (secret-material + flow — alkhttp ADR-014's alkcall analog), alkhttp ADR-073 (one + backend behind every door surface), family ADR-002 decision 5 / + family ADR-003 decision 2 \ No newline at end of file diff --git a/docs/architecture/client.md b/docs/architecture/client.md index 4a1aa04..7287ee1 100644 --- a/docs/architecture/client.md +++ b/docs/architecture/client.md @@ -90,7 +90,9 @@ half-capability, which the family forbids (ADR-003 symmetry + - **Credential storage policy** — assembly (the vault handle is injected; ADR-005 / OQ-06). - **Trust decisions** — the host-key policy is assembly-provided - (OQ-05; the crate default rejects). + (OQ-05; the crate default rejects; a permissive case-2 policy for + ephemeral fleets is the assembly's to choose — the crate carries no + trust model of its own). - **sftp** — until alkfs (OQ-13); the `sftp` feature exists as the tested decline on both directions. @@ -118,7 +120,10 @@ half-capability, which the family forbids (ADR-003 symmetry + pin storage is assembly detail. - **OQ-12** (resolved): lifecycle primitives only — recorded above. - **OQ-13** (deferred, alkfs): the sftp client half. -- **OQ-07** (open): rides the vault decrypt zeroization ask (ADR-005). +- **OQ-07** (resolved at the adapter level): the vault `decrypt` + plain-`String` gap — the adapter-side `Zeroizing` wrap is the + contract (ADR-005 decision 3); the vault-side improvement is an + upstream alkvault ask, not alkssh scope. ## References diff --git a/docs/architecture/decisions/004-auth-dispositions.md b/docs/architecture/decisions/004-auth-dispositions.md index eeef479..700bd68 100644 --- a/docs/architecture/decisions/004-auth-dispositions.md +++ b/docs/architecture/decisions/004-auth-dispositions.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-10-26 +last_updated: 2026-10-27 --- # ADR-004: Auth dispositions — publickey-only, fail-closed, fingerprint identity @@ -65,7 +65,7 @@ twice: which methods exist, and what unauthenticated attempts receive. `none`-probe: POC-1 measured that the probe consumes one attempt, so a default of `10` leaves `IdentitiesOnly`-style clients at least nine real key attempts; a small limit eats one attempt on - every connection (recorded for ADR-008's config defaults). + every connection (recorded for the serve-config defaults). Constant-time rejection is inherited from `Config::auth_rejection_time` (russh-managed). diff --git a/docs/architecture/decisions/005-at-rest-key-custody.md b/docs/architecture/decisions/005-at-rest-key-custody.md index 66c225c..7ac4817 100644 --- a/docs/architecture/decisions/005-at-rest-key-custody.md +++ b/docs/architecture/decisions/005-at-rest-key-custody.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-10-26 +last_updated: 2026-10-27 --- # ADR-005: At-rest key custody — vault-wrapped OpenSSH-PEM, decrypt-at-use @@ -116,6 +116,7 @@ both ends). - `docs/research/poc-2-vault-keys.md` (§1 the 4-way table, §2 the lifecycle + zeroization bounds, §3 the failure matrix, §4 the Signer dismissal, §5 host keys end to end) -- alkhttp ADR-014 + ADR-016-analog (the secret-material-flow family pattern) +- alkcall ADR-010 + alkhttp ADR-014 (the secret-material-flow family + pattern — ADR-010 is alkhttp ADR-014's alkcall analog) - Family ADR-002 decision 5, family ADR-003 decision 2 - OQ-05, OQ-06, OQ-07, OQ-13 \ No newline at end of file diff --git a/docs/architecture/decisions/006-mapping-bridge-and-dispatch.md b/docs/architecture/decisions/006-mapping-bridge-and-dispatch.md index 285fe4f..86a2e27 100644 --- a/docs/architecture/decisions/006-mapping-bridge-and-dispatch.md +++ b/docs/architecture/decisions/006-mapping-bridge-and-dispatch.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-10-26 +last_updated: 2026-10-27 --- # ADR-006: Mapping decisions — route-1 bridge, exec-only git, session dispatch @@ -26,7 +26,7 @@ SOCKS-in-channel is feasible if ever wanted back. pass: which of alktunnels' two routes serves `direct-tcpip`; whether git over SSH has a subsystem path; and the session-channel dispatch shape when one channel type serves both parseable commands and -SOCK5-in-channel traffic. POC-3 resolved all three by construction +SOCKS5-in-channel traffic. POC-3 resolved all three by construction (real git 2.43, real curl 8.5, real OpenSSH through published alkgit/alksocks/alktunnels/alktty). diff --git a/docs/architecture/mapping.md b/docs/architecture/mapping.md index b823890..d4025d4 100644 --- a/docs/architecture/mapping.md +++ b/docs/architecture/mapping.md @@ -34,7 +34,7 @@ bridge primitives every mapping uses (POC-1 §7). | `session` → `exec_request` git | `git clone/push ssh://` | `git` | alkgit: parse command → `Service` + repo → `serve_duplex_resolved(repo_id, identity, service, duplex, …)` | **exec-only** (ADR-006 decision 2): the repo rides inside the exec command string (`git-upload-pack '/repo'`); vanilla git never speaks subsystem for git; process-mode dialect (server-speaks-first advertisement); exit-status proven both directions | | `session` → `subsystem_request` | `sftp` clients; manual `ssh -s` | `sftp` (placeholder) / `git` rows | — | **`sftp` name**: declines until alkfs lands (OQ-13) — the feature exists, the route doesn't. **git service names**: decline with reason — a subsystem request carries only a name, never a repo id (POC-3 §A.3); never half-serves | | `direct-tcpip` | `ssh -L`; all `ssh -D` traffic | `tunnel` | alktunnels **route 1**: adapter-side bridge — `TunnelSession::open_direct` on a held `ChannelClient`, pump `take_halves()` vs the channel | target = `SubstrateAddr::HostPort`/`Path`; `Substrate` = Tcp/Unix; accept only **after** the fabric open succeeds — failure maps to an explicit `ConnectFailed` reject (POC-3 §C.2); vanilla `-D`'s dynamic targets dispatch here (POC-3 correction 2) | -| `forwarded-tcpip` | `ssh -R` data channels | `tunnel` | the `register_tunnel_forwarded_openable`'s `ForwardedHandler` shape — hand the channel duplex to the handler | listen flow = the global-request rows below; *the* data leg untested in POC-3 beyond refusal (OQ-… note below) | +| `forwarded-tcpip` | `ssh -R` data channels | `tunnel` | the `register_tunnel_forwarded_openable`'s `ForwardedHandler` shape — hand the channel duplex to the handler | listen flow = the global-request rows below; the data leg is untested in POC-3 beyond refusal (see the POC-3 note below) | | `direct-streamlocal` | unix-socket forward | `tunnel` | `SubstrateAddr::Path` treatment | unix-only; decline shape per OQ-11 | | `x11` | X forwarding | — (never) | — | always declines; not feature-gated (ADR-003 §5) | | `auth-agent` | agent forwarding | — (never) | — | always declines; the anti-pattern of the alkvault custody posture (ADR-003 §5) | @@ -76,7 +76,7 @@ the served listen flow is implementation-phase contract-test territory.* | `signal` | signal control (alktty names verbatim); PTY mode reaches the process group, pipe mode is pid-only (backend semantics, recorded in OQ-10) | | data / `extended_data(1)` | the translation bridge (rows table) | | `exit_status` | from the alktty exit chunk — sent **before** close, from the spawned task via `Session::handle()` (POC-3 §D: proven both legs) | -| `exit_signal` | only for signal-terminated exits; OQ-10 (chunk carries no signal name — alktt-y ask + assembly fallback pending) | +| `exit_signal` | only for signal-terminated exits; OQ-10 (chunk carries no signal name — alktty ask + assembly fallback pending) | ## The structural constraint: spawn-and-return diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index 5f97cc0..a4d61a1 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-10-26 +last_updated: 2026-10-27 --- # Open questions @@ -25,10 +25,10 @@ alkcall trackers have their own OQ spaces). ## Theme B — session-channel dispatch & mapping details -### OQ-02: ~~What is the session-channel dispatch shape (SOCKS-detect vs parse, and who bounds the window)?~~ — resolved (then simplified) +### OQ-02: ~~What is the session-channel dispatch shape (SOCKS-detect vs parse, and who bounds the window)?~~ — resolved - **Origin**: [poc-3-mapping-bakeoff.md](../research/poc-3-mapping-bakeoff.md) §Stage B.3, `mapping-table.md` §notes -- **Status**: resolved — resolved twice over. First by ADR-006 point 3 (dispatch composition: request-driven parse wins, request-less channels get the detect). Then **simplified by the ADR-003 §1a amendment**: the `dynamic-forward` feature retired (no consumer is ever the family-native SOCKS-in-channel client; vanilla `-D` is client-side SOCKS + plain `direct-tcpip`; the family `-D` door is alksocks composed locally at the assembly), so the detect lost its owner. Final shape: **session dispatch is parse-only**; request-less session channels decline. POC-3 stage B survives as the feasibility record for re-adding a remote SOCKS door via explicit ADR. +- **Status**: resolved. Decided by ADR-006 point 3, then simplified by the ADR-003 §1a amendment: the `dynamic-forward` feature retired (no consumer is ever the family-native SOCKS-in-channel client; vanilla `-D` is client-side SOCKS + plain `direct-tcpip`; the family `-D` door is alksocks composed locally at the assembly), so the detect lost its owner. **Session dispatch is parse-only**; request-less session channels decline. POC-3 stage B survives as the feasibility record for re-adding a remote SOCKS door via explicit ADR. - **Cross-references**: ADR-006, ADR-003 §1a ### OQ-03: ~~Username disposition~~ — resolved @@ -56,9 +56,9 @@ alkcall trackers have their own OQ spaces). - **Resolution**: two basic client cases, one seam: 1. **Known beforehand** — trust anchors exist. Family-built runners mint their host key at assembly time, so the expected fingerprint is known before first contact: assembly-supplied pins, **hard decline on mismatch** (no soft failure). - 2. **Unknown beforehand** — vanilla `sshd` on container fleets the assembly didn't provision; no anchor exists until first contact. The crate surfaces the presented host key (host, port, fingerprint) to an **assembly-provided `HostKeyPolicy`** that decides: prompt-and-record, provider-metadata match, or learn-and-record. The OpenSSH "add to known_hosts?" moment becomes an assembly policy rather than crate behavior. + 2. **Unknown beforehand** — vanilla `sshd` on container fleets the assembly didn't provision; no anchor exists until first contact. The crate surfaces the presented host key (host, port, fingerprint) to an **assembly-provided `HostKeyPolicy`** that decides: prompt-and-record, provider-metadata match, learn-and-record, or accept-and-continue (the ephemeral-fleet shape — see below). The OpenSSH "add to known_hosts?" moment becomes an assembly policy rather than crate behavior. - Crate rules that make this family-shaped: the crate's default policy is **fail-closed** (reject unknown, hard-decline mismatch — russh's own reject-all default, kept); the crate never decides trust itself; accept-all is never a crate option (that collapse is the old-code anti-pattern, and for ephemeral fleets strict pinning is the wrong control anyway — the anchor there is hub-held key material/lifecycle, not host pins). The contract lands in the client-side ADR (written with `client.md`; expected to be ADR-007) and the `auth.md` spec. + Crate rules that make this family-shaped: the crate's default policy is **fail-closed** (reject unknown, hard-decline mismatch — russh's own reject-all default, kept); the crate never decides trust itself and never hard-codes a trust model — accept-all is not a built-in crate option, but a permissive case-2 policy is the assembly's to choose where its fleet model calls for it (alkminer's accept-all for rental fleets is that shape, made deliberately: fresh container per rent → fresh host key, so no pin can exist beforehand and the security anchor is hub-held per-instance dial keys plus what rides the tunnels, not host pins). The contract lands in the `auth.md` spec (host-key policy section) and `client.md`'s dial surface. - **Cross-references**: OQ-07, `reverse-runner.md`, [poc-2-vault-keys.md](../research/poc-2-vault-keys.md) §5 (host keys ride the vault model) ### OQ-06: ~~One vault, many roles?~~ — resolved @@ -135,7 +135,6 @@ alkcall trackers have their own OQ spaces). | OQ | Blocked on | Priority | |---|---|---| | OQ-01 | concrete relay/browser-leg consumer | medium | -| OQ-02 | (deferred(unclear)) investigation during `mapping.md` | high | | OQ-04 | concrete OpenSSH-CA use case | low | | OQ-09 | concrete wire-env use case | low | | OQ-13 | alkfs landing (family ADR-002) | low (for now) | \ No newline at end of file diff --git a/docs/research/auth-bridge.md b/docs/research/auth-bridge.md index 9ca6fad..41395ab 100644 --- a/docs/research/auth-bridge.md +++ b/docs/research/auth-bridge.md @@ -2,7 +2,8 @@ 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 +alkcall ADRs 002/003/006/010 (ADR-010 is alkhttp ADR-014's alkcall +analog), alkvault 0.1.0 source, alkhttp architecture docs. ## The server side: keys in, identity out diff --git a/docs/research/poc-2-vault-keys.md b/docs/research/poc-2-vault-keys.md index c300b8b..42bbea5 100644 --- a/docs/research/poc-2-vault-keys.md +++ b/docs/research/poc-2-vault-keys.md @@ -125,11 +125,10 @@ At-rest hygiene, verified end to end: The wrong-mnemonic/tampered cases land in `VaultServiceError::Encryption` *as strings* — the error taxonomy at the adapter is ours to shape (distinct variants), but the root causes -are distinguishable today via the variant + message. The `InvalidPath` -behavior for version < 2 is deliberate (v1 is the TS_PBKDF2 legacy the +are distinguishable today via the variant + message. The `InvalidPath` behavior for version < 2 is deliberate (v1 is the TS_PBKDF2 legacy the vault cannot decrypt; rotation re-encryption is the migration path, -per alkvault's own docs) — consistent with the ADR-021-style -version-indexed derivation the alkssh ADR will cite for rotation. +per alkvault's own docs) — consistent with alkvault's ADR-021 +version-indexed rotation model. ### 4. Signer-seam dismissal — confirmed, now with runtime and source evidence @@ -265,9 +264,12 @@ with evidence". Evidence assembled: 2. **Key-record schema**: alkssh's vault-backed key record should treat `EncryptedData` as opaque and never interpret `key_version` itself (the vault owns version semantics; `InvalidPath` for unknown - versions is the fail-closed behavior). Rotation (at-rest version - bump, key constant) is a storage-side operation — proven to - preserve key identity. +`InvalidPath` +for unknown + versions is the fail-closed behavior). Rotation (at-rest version + bump, key constant) is a storage-side operation — proven to + preserve key identity (consistent with alkvault's own + version-indexed rotation decision, alkvault ADR-021). 3. **One vault, many roles?** The POC held client dial keys *and* the server host key in one vault (one mnemonic). Whether the assembly layer should run separate vault instances per role (client vs diff --git a/docs/research/poc-3-mapping-bakeoff.md b/docs/research/poc-3-mapping-bakeoff.md index 0cc45bd..df434bd 100644 --- a/docs/research/poc-3-mapping-bakeoff.md +++ b/docs/research/poc-3-mapping-bakeoff.md @@ -243,7 +243,8 @@ construct the mapping table names — then runs the real session. Proven against a **real PTY** (`LocalTtyBackend` over portable-pty): - `pty_request` args → `TerminalParamsWire` (`term`/cols/rows/ - pixels; SSH modes → the reserved `{}` OQ-44 JSON) — live: bash on + pixels; SSH modes → the reserved `{}` JSON — an alktty-side + reservation, alktty OQ-44; modes are deferred there) — live: bash on the PTY answered `stty size` with the requested 24×80. - Keystrokes ride in, PTY echo and shell output ride out (real bash prompt captured in the run output). diff --git a/docs/research/reverse-runner.md b/docs/research/reverse-runner.md index c72966b..08404e6 100644 --- a/docs/research/reverse-runner.md +++ b/docs/research/reverse-runner.md @@ -7,8 +7,8 @@ facts). Source: `/workspace/@alkminer/alkminer` — specifically `crates/alkminer-host/src/remote/{ssh,exec,forward,manager,sftp}.rs` and `src/oai/mod.rs`. Also old (russh 0.62 era) and POC-grade; its -anti-patterns are as instructive as its patterns, and are named below -as anti-patterns, not precedents. +weak spots are as instructive as its strengths, and both are recorded +below as facts to design from, not precedents to copy. ## The direction @@ -75,21 +75,22 @@ One authenticated SSH connection per instance carried: assembly policy (session/forward lifecycle), not wire contract — kept in view so phase 1 doesn't accidentally bake reconnect policy into the crate. -5. **Host-key pinning anti-pattern.** alkminer's `check_server_key` - returned accept-all (TOFU-without-the-F, 0.62 default posture - overridden to the permissive extreme for rental fleets). The family - rule: no trust decisions inside the crate — default fail-closed, - trust via an assembly-provided policy seam. The lesson is sharper - than "pin everything", though: for *ephemeral* fleets strict TOFU - is unusable anyway (fresh container each rent → fresh host key; - the churn is exactly why accept-all won), so the real security - anchor there is hub-held key material (per-instance dial keys make - a captured hub credential worthless) plus what rides the tunnels; - host pinning is the right control only where anchors can exist - (family-built runners, whose host key the assembly mints and - therefore knows beforehand). The fleet churn is the reason the - two-case policy seam (OQ-05) is the correct shape rather than - "pin everything". +5. **Host-key acceptance for ephemeral fleets — the pattern the fleet + model demands.** alkminer's `check_server_key` returned accept-all + for rental fleets. That was the correct pattern for that problem, + not a lapse: a fresh container per rent mints a fresh host key, so + no pin can exist beforehand — strict TOFU/"pin everything" is + unusable there, and the churn is exactly why accept-all won. The + security anchor is elsewhere: hub-held per-instance dial keys make + a captured hub credential worthless, plus what rides the tunnels. + The family rule is therefore not "pin everything" — it is "no + trust decisions inside the crate": default fail-closed at the + crate, with an assembly-provided policy seam so each fleet model + gets the control that fits it — host pinning where anchors can + exist (family-built runners, whose host key the assembly mints and + therefore knows beforehand), permissive accept where they can't + (rental fleets). The fleet churn is the reason the two-case policy + seam (OQ-05) is the correct shape rather than "pin everything". 6. **Per-runner key registration.** Keys were minted per instance and registered hub-side (their provisioning flow, including a real gitea client for dev workspaces) — the deployment precedent for @@ -112,6 +113,6 @@ One authenticated SSH connection per instance carried: server side of the same bridge legs), `poc-1-auth-bridge.md` §7 (the channel↔stream plumbing both leg styles reuse), `poc-3-mapping-bakeoff.md` §D.3 (client-request mechanics) -- `auth-bridge.md` §Host keys and `check_server_key` (the pinning - rule this doc's anti-pattern motivates) +- `auth-bridge.md` §Host keys and `check_server_key` (the trust-policy + seam this doc's ephemeral-fleet acceptance pattern motivates) - alkminer ADR-016 (the OAI proxy pattern — secrets stay at the hub) \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 2b2f272..d72d7e8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,19 +4,20 @@ //! //! No SSH framing is authored in-family (family ADR-003): russh owns the //! wire, and the crate's substance is the channel-type mappings — -//! session/pty → alktty, direct/forwarded-tcpip → alktunnels, dynamic -//! forwarding (`-D`) → alksocks, git exec/subsystem → alkgit, sftp → -//! alkfs later. Every mapping is a cargo feature; a session opening an -//! unfeatured or unimplemented channel type is declined with a clean -//! error — never a half-capability, never a silent fallback. The default -//! build (no features) is structurally incapable of serving anything but -//! declines. +//! session/pty → alktty, direct/forwarded-tcpip → alktunnels, git exec → +//! alkgit, sftp → alkfs later (a declining placeholder until then), each +//! behind its cargo feature. Git over SSH is exec-only (ADR-006): a +//! subsystem request can never carry the repository, so it declines. A +//! client opening an unfeatured or unimplemented channel type gets a +//! clean decline — never a half-capability, never a silent fallback; the +//! default build (no features) is structurally incapable of serving +//! anything but declines. //! -//! Keys reuse the alknet `main` key model (`russh::keys`, -//! authorized-key/KeySource handling) via alkvault-backed sources — never -//! env vars. +//! Identity is the family's, not SSH's: a verified key's fingerprint → +//! alkcall `IdentityProvider`. Keys the crate holds (client dial keys, +//! host keys) come from alkvault-backed sources — vault-encrypted at +//! rest, decrypted at use (ADR-005), never env vars. //! -//! This crate is initialized (docs only, SDD phase 0): the dependency -//! surface, the feature graph, and the mapping table are phase 0 research -//! output and land with the committed architecture. See -//! `docs/research/` and family ADR-003. +//! Architecture: `docs/architecture/` (phase 1 — ADRs 001–006 accepted; +//! the dependency surface and feature graph land with implementation +//! start). See also `docs/research/` and family ADR-003.