diff --git a/docs/plans/implementation.md b/docs/plans/implementation.md new file mode 100644 index 0000000..c0f3292 --- /dev/null +++ b/docs/plans/implementation.md @@ -0,0 +1,322 @@ +--- +status: draft +last_updated: 2026-10-28 (initial plan — five waves committed; wave 1 + decomposed same day, see its decomposition record) +--- + +# alkssh — Implementation plan + +Wave-based decomposition of the architecture (`docs/architecture/`) +into units of implementable work. The sibling pattern proven on +alkgit/alkstore/alkblobs, adopted deliberately as a deviation from the +SDD process's decompose-everything-upfront step: early waves change the +shape of later ones (wave 1's config/error/key-source types are what +every later task implements against; wave 2's server stack is what +every mapping composes into), and decomposing only the next wave at a +time keeps each session's task set reviewable and lets later +decompositions absorb earlier waves' course corrections — reviews +sometimes change things, and tasks downstream then have to be edited, +so decomposing too far ahead is paid rework. + +Rhythm: decompose a wave → implement it → review gate → decompose the +next wave. Task files live in `tasks/implementation/` (frontmatter +carries the categorical estimates; the DAG ordering keeps parallel +branches from stepping on each other). Wave boundaries are also review +boundaries. Each wave's section here carries a **decomposition record** +added when that wave actually decomposes (not written upfront). + +Scope authority: **v0.1.0 publishes the crate** — the complete +two-direction door (server stack + all mappings, client stack + op +rows, key sources, the decline matrix contract-tested per build). The +sftp decline placeholder *is* shipped behavior (OQ-13 resolved). **The +assembly stays out of these waves**: alkforge's mounting of +`SshAdapter::handle` at an alkcall `channel/open` boundary, its +feature/ACL selection, its `IdentityProvider` and serve-config values +are downstream composition work — the crate ships the door, not the +deployment. No binary, no workspace split (single crate, the alktty +root pattern; no tracks — one crate, one publish). + +## The waves + +Dependency logic in one line: foundations → server stack → (server +mappings ∥ client direction) → validation & release. + +| Wave | Contents | Depends on | Status | +|---|---|---|---| +| [1](#wave-1--foundations) | Manifest landing (russh pin, feature graph); error model; config surface (serve-config + dial-config + `Limits`); vault-backed key sources; host-key policy seam; fingerprint; test doubles | — | decomposed | +| [2](#wave-2--the-server-stack) | `SshAdapter::handle` mount + `Config` construction; the auth bridge; parse-only dispatch + fail-closed decline core; spawn-and-return plumbing; bounded resources; loopback harness | wave 1 | pending | +| [3](#wave-3--server-mappings) | session → alktty (translation bridge + exit derivation); git → alkgit (exec grammar + dispatch); tunnel → alktunnels route 1; sftp placeholder declines | wave 2 | pending | +| [4](#wave-4--client-direction) | Dial surface; op rows (exec/pty, direct-tcpip, git exec); the `-R` leg (listen + acceptor registry); lifecycle primitives | wave 1 | pending | +| [5](#wave-5--validation--release) | Decline-matrix contract suite; real-tool e2e; fuzzing; docs; **the publish** | waves 3 + 4 | pending | + +## Wave 1 — Foundations + +The contract surface in code, engine-blind: everything later waves +implement *against*, and nothing that needs a live SSH session to +test. The alkssh analog of alkgit's seam wave. + +- **Manifest landing** (ADR-002) — the dependency set lands with + implementation start: `russh = "0.65"` with `aws-lc-rs` (no `flate2`, + no `rsa` — the tree RC-free except russh's own `ssh-key` pin), + `alkcall 0.10` (identity/ACL types at the wire layer), `alkvault 0.1` + (the custody model), plus the standing toolkit (`tokio`, `thiserror`, + `tracing`, `parking_lot`, `zeroize`). `edition = "2024"`, + `rust-version = "1.89"`. The ADR-003 feature graph is declared from + the first commit: `session`, `git`, `tunnel`, `sftp`, `default = []` + — features gate nothing yet (the decline core references them from + wave 2); the per-feature sibling deps (`alktty` under `session`, + `alktunnels` under `tunnel`, `alkgit` under `git`) land with their + first consuming wave, not as dead manifest entries. All family deps + pinned by version, never sibling-checkout paths; pins verified + against crates.io before committing. +- **Error model** — the crate's `thiserror` taxonomy, fail-closed with + no catch-alls: the key-source failure matrix as distinct variants + (ADR-005 decision 4 — locked / absent / decryption failure / unknown + `key_version`, plus decode failure; the vault's taxonomy reshaped, + never collapsed). Auth declines are *dispositions* (`Auth::Reject`), + not errors; wire decline reasons are wave-2 shaped. What the config + surface needs beyond this lands with it. +- **Config surface** — the pure configuration types both directions + implement against: the **serve-config** (feature wiring; OQ-08's two + assembly-supplied fields — shell argv and exec wrapper argv, each + `Option`, unset → its row declines, never a fallback; the + `IdentityProvider` handle; the host-key source handle; the tunnel + `ChannelClient` handle; the `Limits`), the **dial-config** (address, + key source, `HostKeyPolicy` handle, keepalive/inactivity/window + knobs), and **`Limits`** — the bounded-resources budget model + (`overview.md`'s table: in-crate max-concurrent-channels cap, + exec-parse length caps, and the russh knob mapping with the + documented default postures — `max_auth_attempts` 10 sized for the + counting `none`-probe, keepalives 15 s / 3 client-side per the + reverse-runner evidence). +- **Vault-backed key sources** (ADR-005) — the custody model in code: + decrypt-at-use (`vault.decrypt` → `Zeroizing` wrap at the + adapter seam → one-time `decode_secret_key(plain, None)`), the + client dial-key source and the server host-key load path + (decrypt-at-construction; locked vault → no server constructed), + `EncryptedData` opaque to the crate, plaintext never persists, every + failure mode mapped to its distinct error variant. +- **Host-key policy seam** (OQ-05) — the `HostKeyPolicy` trait the + client dial calls with (host, port, presented fingerprint): case-1 + pins (hard-decline on mismatch) and case-2 assembly policies are the + implementor's; the crate ships the fail-closed default (reject + unknown) and is otherwise policy-blind — no accept-all built-in. +- **Fingerprint** (ADR-004) — the `SHA256:` + unpadded url-safe base64 + computation via the `ssh-key` re-export (`HashAlg::Sha256`); the + string format is a contract invariant, proven against independent + issuers (`ssh-keygen -lf` vectors, the POC-1 §1 checkpoint shape). +- **Test doubles** — the in-memory seams every later wave's contract + tests run against: an in-memory `IdentityProvider` (registered + fingerprints → identities), an in-memory vault (encrypt/decrypt + round-trip + the failure postures), an in-memory `HostKeyPolicy`, + and ed25519 test-key fixtures. The convention (adapted from the + sibling crates) is established here even though the harness that + exercises them end to end lands with wave 2's server stack. + +The wave-1 review gate is the cheap-place gate: the config types, the +error taxonomy, and the key-source seam are one-way doors in practice +(every later task implements against them; the published crate freezes +them). A shape error found at this gate costs a task revision; found +after the stack and mappings implement against them it costs churn +across both directions. The gate reads the landed surface against +ADR-002/003/004/005 and the OQ-08/05 resolutions line by line. + +**Decomposition record** (2026-10-28): wave 1 decomposed into eight +task files under `tasks/implementation/` (flat — one track, no +per-track subdir). Shape: `w1-manifest` first (the ADR-002 dependency +landing + the ADR-003 feature graph declaration everything lands +inside); then four independent foundations in parallel — +`w1-error-model` (the `KeySourceError` five-variant taxonomy), +`w1-fingerprint` (the contract-invariant computation + parity +vectors), `w1-key-sources` (the ADR-005 custody chain, over manifest + +error model), and `w1-host-key-policy` (the OQ-05 seam + fail-closed +default); then `w1-config` (serve-config + dial-config + `Limits`) +over the key-source and policy seams it carries handles for; +`w1-test-doubles` (the in-memory seams + ed25519 fixtures) over error +model + key sources + policy; and `review-wave-1` (the cheap-place +gate) depending on all seven. + +Interpretations recorded (flagged for the wave-1 gate): + +- **Per-feature sibling deps land with their first consuming wave**, + not in the wave-1 manifest — `alktty`/`alktunnels`/`alkgit` would be + dead manifest entries in a build where the features gate nothing + yet; the wave-1 manifest lands russh + alkcall + alkvault + the + toolkit only. The feature *names* are declared from the first + commit; the wave-2 decline core is their first `cfg` consumer. +- **No config-error type ships in wave 1** unless the config task + finds one needed: the OQ-08 fields are `Option` and decline at + request time (never construction time), so the error model carries + only the key-source taxonomy; wire decline reasons are wave-2 + shaped. +- **The policy verdict type** (bool vs a richer reason-carrying + verdict) is pinned in `w1-host-key-policy` against the russh + `check_server_key` hook shape at this pin — flagged for the gate. +- **The vault-double shape** (wrap alkvault's in-memory construction + if it ships one, else a local double behind the source trait) is + pinned in `w1-test-doubles` — flagged for the gate. + +## Wave 2 — The server stack + +The russh server substrate every mapping composes into — the door +frame, before any door is hung: + +- **`SshAdapter::handle` + `Config` construction** (ADR-001/002) — the + mount point ADR-001 pins as the `alk/ssh` receiving endpoint: a + generic stream in, the full russh server stack per stream (edge + termination only — no ALPN code, no relay leg; the pin is a ledger + entry, already decided). `Config` built from the serve-config: + honest advertisement (`methods = {publickey}` exactly, ADR-004 + decision 4), host keys from the vault-backed source (locked → no + server constructed), the bounded knobs from `Limits`. +- **The auth bridge** (ADR-004) — the dispositional handler surface: + `auth_publickey` fingerprint → `resolve_from_fingerprint` → accept + with the resolved identity / reject fail-closed; ed25519-only + (non-ed25519 keys declined explicitly); `auth_publickey_offered` + accept-and-delegate (counting stays honest); `auth_none` / + `auth_password` / `auth_keyboard_interactive` / + `auth_openssh_certificate` explicit declines (family contract, not + inherited defaults); the username carries no semantics (OQ-03). +- **Parse-only dispatch + the fail-closed decline core** (ADR-006 as + amended, ADR-003) — session-channel dispatch skeleton (parse path: + git-parseable exec → `git`, else → `session`; request-less session + channels decline), unfeatured channel types decline at open, x11 / + `auth-agent` always decline (never feature-gated), the sftp + subsystem decline-with-reason placeholder (OQ-13 — folded here, + server side), **decline first, alloc never** (the POC-3 stage-A + matrix: unknown repo → collapsed generic denial before any protocol + byte). +- **Spawn-and-return plumbing** (ADR-006 decision 4) — the structural + constraint in code: every serving path `tokio::spawn`ed, handlers + return fast, exit/status reporting from spawned tasks rides the + cloned `Session::handle()`. The reporting *mechanics* land here; the + derivation contract's rules are wave-3 session-mapping material. +- **Bounded resources** — the in-crate concurrent-channel cap enforced + at the handler surface, `inactivity_timeout`/`max_auth_attempts` + wired from `Limits`, per-length exec-parse limits reserved for the + grammar's consumption (wave 3). +- **Loopback harness** — the in-process client/server test pair the + decline matrix and every mapping's contract tests run through + (in-memory doubles from wave 1; real-tool smokes ride the mapping + waves). + +Wave 2 decomposes after the wave-1 gate, absorbing whatever it +corrects. + +## Wave 3 — Server mappings + +Three parallel branches over the stack (plus the folded sftp row), +each carrying a minimal real-tool smoke (one clone, one `-L`, one pty +session — the POC-3 gates re-landed cheaply); the full matrix is +wave 5's: + +- **session → alktty** — the translation bridge, not a byte pump + (POC-3 §D.1): `pty_request` args → `TerminalParamsWire` (SSH modes → + the reserved modes JSON); shell argv / exec wrapper from the + serve-config (`wrapper ++ [cmd]`, never naively split; unset → clean + decline — OQ-08); stdin/stdout/stderr chunk translation + (`extended_data(1)` for stderr); `window_change_request`/`signal` + rows; `env_request` declined unconditionally (OQ-09); the **exit + derivation contract** (`mapping.md` rules 1–6: `code ≥ 0` → + `exit-status(N)`, negative `−N` → `exit-signal(name)` via the + server-side Unix signal-number→name table, `−1` → `exit-status(255)` + never a signal claim, `core_dumped` never asserted, PTY-path rule-5 + fidelity limit) — exit-status sent before close, from the spawned + task. +- **git → alkgit** — exec-only (ADR-006 decision 2): the parse grammar + (`git-{upload,receive}-pack '/repo'`, quoted/unquoted paths, + service-only → decline-with-reason, non-git → parse-then-fail, + per-length limits), unknown-repo → collapsed generic denial before + any protocol byte (no existence oracle), dispatch to + `serve_duplex_resolved(repo_id, identity, service, duplex, limits, + …)` over the channel duplex, process-mode dialect, exit-status both + directions. +- **tunnel → alktunnels route 1** (ADR-006 decision 1) — the + adapter-side bridge: the serve-config's `ChannelClient` into the + family fabric (identity propagated once via `set_identity` so + `AccessControl::check` gates the mapped opens — the visible-surface + invariant), `TunnelSession::open_direct` + `take_halves()` pumped + against the russh channel, accept only after the fabric open + succeeds (failure → explicit `ConnectFailed` reject), + `direct-streamlocal` runtime-Unix-gated (OQ-11: universal compile, + clean open-decline off-Unix), the `forwarded-tcpip` handler shape, + the `-R` listen flow (`tcpip_forward`/`streamlocal_forward` + the + same runtime-Unix gate). +- **sftp placeholder** — the decline-with-reason rows (folded into the + dispatch task server-side; the client-side placeholder folds into + wave 4's acceptor/op-row work; the wave-5 matrix covers both + explicitly). + +Wave 3 decomposes after the wave-2 gate, absorbing whatever it +corrects. + +## Wave 4 — Client direction + +The door outward (independent of wave 3 — shares only wave 1's +foundations), each branch carrying its real-tool smoke where cheap: + +- **Dial surface** (`client.md`) — connect with address, key source + (decrypt-at-dial, fail-closed on locked/absent — never env vars), + host-key policy at `check_server_key` (the wave-1 seam consumed: + case-1 pins hard-decline on mismatch, case-2 policies decide), the + bounded config (keepalives 15 s / 3, inactivity, window knobs). The + authenticated connection handle everything else rides. +- **Op rows** — family op → vanilla translation per `client.md`'s + table: session exec/pty opens (the POC-3 §D.3 mechanics: + `Channel::exec()` not `request_exec`, Success/Failure via + `wait()`-drain not `Err`, `window_change`/`signal` before + `into_stream()`, never consume the queue before conversion); + `direct-tcpip` typed dial (reject → `Err(ChannelOpenFailure)`, the + connection stays usable); git exec consumption (the same grammar the + server parses, pointed upstream); the sftp subsystem placeholder + decline. +- **The `-R` leg** — `tcpip_forward(address, port) -> bound` (the + actually-bound port returned), server-initiated `forwarded-tcpip` + opens routed to the **acceptor registry**: served only under a + registered feature's acceptor, unregistered/unfeatured accepted + opens declined cleanly (never russh's accept-and-drop default), + late-install across a live connection (the alkminer dynamic), + `streamlocal_forward` with the runtime-Unix gate. +- **Lifecycle primitives** (OQ-12) — explicitly cancellable + listener/session handles, clean `Disconnect::ByApplication`; **no + reconnect or restore policy owned by the crate** (assembly policy; + the primitives make it possible). + +Wave 4 decomposes after the wave-3 gate (it may start any time after +wave 1 in principle, but decomposing it after wave 3's gate keeps the +session task sets in rhythm and lets it absorb any seam corrections +the mappings surfaced). + +## Wave 5 — Validation & release + +- **The decline-matrix contract suite** (`mapping.md`'s table) — every + build (none / `git` / `session` / `tunnel` / `--all-features`) × + every row it can appear in × both directions, including the sftp + placeholder rows and the never-features (x11, `auth-agent`). The + fail-closed invariant is contract territory on both sides of every + feature (AGENTS.md conventions 5–6). +- **Real-tool e2e** — the POC-3 gates re-landed against the shipped + crate: OpenSSH (pty/shell/exec, `-L`, `-R`, `-D`'s `direct-tcpip` + leg, decline observability), git 2.43+ (clone/push through the git + mapping), curl through a `-D` proxy; client direction against a real + `sshd` (the reverse-runner shape: exec, direct-tcpip dial, `-R` + listen + acceptor). +- **Fuzzing** — the family standing rule (anything worth fuzzing gets + fuzzed) on the crate's ingress surfaces: the exec parse grammar, the + fingerprint format path, the key-decode path, the TerminalParamsWire + conversion. The `fuzz/` sidecar convention (detached runner, corpus + gate joining the verification list). +- **Release readiness** — crate docs (public-API rustdoc + warning-free; docs honesty), the feature matrix (`default`, each + feature over it, `--all-features`), CHANGELOG/README, publish + dry-run, then **the publish of `alkssh 0.1`** — the gate review that + closes the plan. + +## References + +- `docs/architecture/` — overview, mapping, client, auth, + open-questions (all reviewed 2026-10-28), ADR-001…006 +- `docs/research/` — vision, russh inventory, auth bridge, mapping + table, POC-1/2/3 findings, reverse-runner extraction +- Family ADR-003 (the structural decision); the alkgit implementation + plan (the pattern this follows) \ No newline at end of file diff --git a/tasks/implementation/review-wave-1.md b/tasks/implementation/review-wave-1.md new file mode 100644 index 0000000..a3a4e9d --- /dev/null +++ b/tasks/implementation/review-wave-1.md @@ -0,0 +1,69 @@ +--- +id: review-wave-1 +name: Wave 1 gate — the foundations review +status: pending +depends_on: [w1-manifest, w1-error-model, w1-key-sources, w1-host-key-policy, w1-fingerprint, w1-config, w1-test-doubles] +scope: narrow +risk: low +impact: phase +level: review +--- + +## Description + +The wave-1 review gate — the cheap-place gate. The config types, the +error taxonomy, and the key-source seam are one-way doors in practice: +every later task implements against them, and the published crate +freezes them. A shape error found here costs a task revision; found +after the stack and mappings implement against them it costs churn +across both directions. + +The gate reads the landed surface against the architecture line by +line: + +- ADR-002 — the pin as declared (russh 0.65, `aws-lc-rs` only, no + `flate2`/`rsa` in the tree, edition 2024, MSRV 1.89); the recorded + `ssh-key` RC exception verified in `cargo tree`. +- ADR-003 — the feature graph shape (flat, independent, `default = + []`); the recorded interpretation (per-feature sibling deps land + with their first consuming wave) confirmed or corrected. +- ADR-004 — the fingerprint format invariant (parity vectors green); + the `max_auth_attempts` default posture in `Limits`. +- ADR-005 — the custody chain (decrypt-at-use, `Zeroizing` wrap, + distinct failure variants, `EncryptedData` opaque, host-key load + path, plaintext never persists). +- OQ-05 — the policy seam (fail-closed default, no accept-all + built-in, policy-blind crate). +- OQ-08/09 — the serve-config `Option`-field semantics (unset → + decline, no crate default, no construction-time validation drift). +- The error model — no catch-alls; auth declines are dispositions, not + errors. +- Docs honesty — module docs state what is deliberately not here yet + (the stack, the mappings, the client direction). + +Verdict: green (wave 2 decomposes, absorbing corrections) or findings +booked (remediation before wave 2 decomposes). + +## Acceptance Criteria + +- [ ] Every ADR/OQ surface above read and confirmed (or corrected + with the correction landed) +- [ ] The recorded interpretations confirmed or fixed (sibling-dep + timing; the vault-double shape; the policy verdict type) +- [ ] Full verification set green (`cargo test`, `--all-features`, + clippy, fmt) +- [ ] Verdict recorded; wave 2 decomposition unblocked or findings + booked + +## References + +- docs/plans/implementation.md (wave 1 section + this gate's role) +- docs/architecture/decisions/001…006, open-questions.md + +## Notes + +> To be filled by implementation agent + +## Summary + +> To be filled on completion \ No newline at end of file diff --git a/tasks/implementation/w1-config.md b/tasks/implementation/w1-config.md new file mode 100644 index 0000000..d7b2d6b --- /dev/null +++ b/tasks/implementation/w1-config.md @@ -0,0 +1,83 @@ +--- +id: w1-config +name: Wave 1 — the config surface (serve-config, dial-config, Limits) +status: pending +depends_on: [w1-manifest, w1-key-sources, w1-host-key-policy] +scope: moderate +risk: low +impact: project +level: implementation +--- + +## Description + +Land the pure configuration types both directions implement against — +the shapes every later wave's constructors take: + +- **Serve-config** (server direction, `mapping.md`/`overview.md`) — + the assembly-supplied serving configuration: the OQ-08 fields + (**shell argv** and **exec wrapper argv**, each `Option>` + — unset → its row declines with a clean error, never a fallback, no + crate default; the wrapper form is platform-neutral), the + `IdentityProvider` handle (`Arc` — the + assembly injects it; the crate never resolves identities itself), + the host-key source handle (the wave-1 key-source seam), the tunnel + `ChannelClient` handle (the route-1 bridge's family-fabric entry — + `Option`, absent → tunnel rows decline even under the feature), and + the `Limits`. `cwd`/`env` in `NegotiateRequest` are assembly-supplied + serve-config values, never wire-derived (OQ-09 posture). +- **Dial-config** (client direction, `client.md` §The dial surface) — + address, key source (the wave-1 seam — never static keys, never env + vars), the `HostKeyPolicy` handle, and the bounded-resource knobs: + `keepalive_interval`/`keepalive_max` (15 s / 3 default posture per + the reverse-runner evidence), `inactivity_timeout`, window/packet + knobs. +- **`Limits`** — the bounded-resources budget model (`overview.md`'s + table): the in-crate max-concurrent-channels cap, exec-parse length + caps (consumed by the wave-3 grammar), and the russh knob mapping + with documented default postures — `max_auth_attempts` default 10 + (sized for the counting `none`-probe, ADR-004 decision 6), + `window_size`/`maximum_packet_size`/`channel_buffer_size` set, + `inactivity_timeout` set (assembly-tunable). Every session carries + it; no unbounded loops, no unbounded buffers. + +Types only — no russh `Config` construction (wave 2), no dialing +(wave 4). The OQ-08 unset-declines semantics live in the type shape +(`Option` fields + doc contracts), not in construction-time +validation. + +## Acceptance Criteria + +- [ ] Serve-config carries the OQ-08 fields as `Option` with + unset-declines doc contracts; no crate default shell/wrapper +- [ ] `IdentityProvider`, host-key source, and `ChannelClient` are + injected handles; the crate holds no identity/trust policy of + its own +- [ ] Dial-config carries key source + `HostKeyPolicy` + the + keepalive/inactivity/window knobs with the documented defaults +- [ ] `Limits` carries the in-crate caps (channels, exec-parse) and + the russh knob defaults (`max_auth_attempts` 10, keepalive + 15 s / 3 client posture) +- [ ] Unit tests cover default construction and the `Option`-field + semantics +- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, + `cargo fmt --check` pass + +## References + +- docs/architecture/overview.md §Public surface, §Bounded-resources + policy +- docs/architecture/mapping.md (OQ-08 rows, the data-plane/limits + section) +- docs/architecture/client.md §The dial surface +- docs/architecture/open-questions.md (OQ-08, OQ-09 resolutions) +- docs/architecture/decisions/004-auth-dispositions.md (decision 6 — + the `max_auth_attempts` default) + +## Notes + +> To be filled by implementation agent + +## Summary + +> To be filled on completion \ No newline at end of file diff --git a/tasks/implementation/w1-error-model.md b/tasks/implementation/w1-error-model.md new file mode 100644 index 0000000..fbe9903 --- /dev/null +++ b/tasks/implementation/w1-error-model.md @@ -0,0 +1,63 @@ +--- +id: w1-error-model +name: Wave 1 — the error model (fail-closed taxonomy) +status: pending +depends_on: [w1-manifest] +scope: narrow +risk: low +impact: project +level: implementation +--- + +## Description + +Land the crate's error taxonomy in `src/error.rs` (one module per file, +re-exported from the crate root — the alktty pattern), `thiserror` +throughout, no catch-alls anywhere (AGENTS.md convention 2): + +- **`KeySourceError`** — the ADR-005 decision-4 failure matrix as + distinct variants, the vault's error taxonomy reshaped by the + adapter, **never collapsed**: `Locked` (vault locked → no dial, and + no server: a host key that can't load prevents server + construction), `Absent` (no blob at the requested path), + `Decryption` (wrong mnemonic / tampered ciphertext — the + refuse-at-crypto class), `UnknownKeyVersion` (the vault's + refuse-before-crypto `InvalidPath` class), `Decode` (plaintext + arrived but is not decodable key material — the one-time + `decode_secret_key` failure). Doc comments carry the ADR semantics + per variant, including the fail-closed consequence of each. +- Deliberately **not** error types: auth declines (dispositions — + `Auth::Reject`, ADR-004; never errors), wire decline reasons + (SSH-shaped responses — wave-2 material), config validation (the + OQ-08 fields are `Option` and decline at request time, not + construction time — no config-error type ships unless the config + task finds one needed, flagged for the gate). + +The exact variant payload shapes are the task's to pin (minimal per +the sibling-crate posture; stringly-typed payloads only where a +message is the honest content), flagged for the wave-1 gate. + +## Acceptance Criteria + +- [ ] `KeySourceError` has exactly the five pinned variants; no + catch-all, no `Other`/`Unknown` +- [ ] Doc comments state each variant's ADR-005 semantics and its + fail-closed consequence +- [ ] Unit tests construct every variant and assert `Display`/`Debug` + paths compile clean +- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, + `cargo fmt --check` pass + +## References + +- docs/architecture/decisions/005-at-rest-key-custody.md (decision 4) +- docs/architecture/auth.md §Key custody (the failure-mode list) +- docs/research/poc-2-vault-keys.md §3 (the failure matrix, 39/39) + +## Notes + +> To be filled by implementation agent + +## Summary + +> To be filled on completion \ No newline at end of file diff --git a/tasks/implementation/w1-fingerprint.md b/tasks/implementation/w1-fingerprint.md new file mode 100644 index 0000000..5f4e8b2 --- /dev/null +++ b/tasks/implementation/w1-fingerprint.md @@ -0,0 +1,57 @@ +--- +id: w1-fingerprint +name: Wave 1 — fingerprint computation (the contract invariant) +status: pending +depends_on: [w1-manifest] +scope: narrow +risk: low +impact: project +level: implementation +--- + +## Description + +Land the fingerprint computation the auth bridge (server) and the +host-key policy path (client) both consume — ADR-004 decision 1's +contract invariant in code: + +- Compute a key's fingerprint via the `ssh-key` re-export + (`HashAlg::Sha256`): the `SHA256:` + **unpadded url-safe base64** + OpenSSH format. The string format is a **contract invariant** — + registration strings from any standard issuer (`ssh-keygen -lf`, + alkcall-side computation) must compare equal to what this crate + computes; parity is proven, not assumed (POC-1 §1). +- The function takes the russh/ssh-key public-key type and returns the + string; it is the single fingerprint path in the crate (no + second formatting anywhere — a drift here breaks every identity + resolution silently). +- Parity tests against independent-issuer vectors: static ed25519 + test keys with their `ssh-keygen -lf`-recorded expected fingerprints + (the POC-1 §1 checkpoint shape). The vectors are committed test + fixtures (throwaway test keys — no secret material). + +## Acceptance Criteria + +- [ ] Single fingerprint function via `HashAlg::Sha256`; `SHA256:` + + unpadded url-safe base64 output +- [ ] Parity tests against `ssh-keygen -lf`-recorded vectors pass + (independent-issuer format parity proven) +- [ ] No second fingerprint-formatting path exists in `src/` +- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, + `cargo fmt --check` pass + +## References + +- docs/architecture/decisions/004-auth-dispositions.md (decision 1 — + the format invariant) +- docs/architecture/auth.md §The bridge (step 2) +- docs/research/poc-1-auth-bridge.md §1 (the parity checkpoint) +- docs/research/auth-bridge.md §The mapping (step 2) + +## Notes + +> To be filled by implementation agent + +## Summary + +> To be filled on completion \ No newline at end of file diff --git a/tasks/implementation/w1-host-key-policy.md b/tasks/implementation/w1-host-key-policy.md new file mode 100644 index 0000000..2ee9d46 --- /dev/null +++ b/tasks/implementation/w1-host-key-policy.md @@ -0,0 +1,65 @@ +--- +id: w1-host-key-policy +name: Wave 1 — the HostKeyPolicy seam (OQ-05, policy-blind) +status: pending +depends_on: [w1-manifest] +scope: narrow +risk: low +impact: project +level: implementation +--- + +## Description + +Land the client-side host-key policy seam (`auth.md` §Host-key policy, +OQ-05 resolution) — the trust-decision contract the dial surface calls: + +- **The `HostKeyPolicy` trait** — the crate surfaces the presented host + key (host, port, fingerprint) to an assembly-provided policy that + decides accept/reject. The two OQ-05 cases are the *implementor's*: + case-1 pins (known beforehand — hard-decline on mismatch) and case-2 + policies (unknown beforehand — prompt-and-record, learn-and-record, + provider-metadata match, accept-and-continue for ephemeral fleets). + The crate is **policy-blind**: it never decides trust, never + hard-codes a trust model. +- **The crate default is fail-closed**: reject unknown (russh's own + reject-all default, kept as the built-in default policy). **No + accept-all built-in crate option** — a permissive case-2 policy is + the assembly's to write. +- The trait receives the fingerprint string (the wave-1 fingerprint + computation is the caller's input path, wave-4 dial); the exact + decision type (bool vs a richer verdict carrying a reason) is the + task's to pin against the russh `check_server_key` hook shape at + this pin — flagged for the wave-1 gate. +- The russh client-handler consumption (`check_server_key` wiring) is + wave-4 dial-surface material — this task lands the seam and its + default only. + +## Acceptance Criteria + +- [ ] `HostKeyPolicy` trait in place; the presented-key context + (host, port, fingerprint) is the decision input +- [ ] Built-in default policy rejects unknown keys (fail-closed); + no accept-all option exists in the crate +- [ ] Doc comments carry the two-case model and the policy-blind rule +- [ ] Unit tests: default policy rejects; a custom policy's verdict + is honored +- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, + `cargo fmt --check` pass + +## References + +- docs/architecture/auth.md §Host-key policy (OQ-05 resolution) +- docs/architecture/client.md §The dial surface, §What the crate does + not include (trust decisions) +- docs/architecture/decisions/005-at-rest-key-custody.md (decision 6 — + the client host-*pin* is public config, never vault material) +- docs/research/auth-bridge.md §Host keys and `check_server_key` + +## Notes + +> To be filled by implementation agent + +## Summary + +> To be filled on completion \ No newline at end of file diff --git a/tasks/implementation/w1-key-sources.md b/tasks/implementation/w1-key-sources.md new file mode 100644 index 0000000..faca008 --- /dev/null +++ b/tasks/implementation/w1-key-sources.md @@ -0,0 +1,74 @@ +--- +id: w1-key-sources +name: Wave 1 — vault-backed key sources (ADR-005 custody in code) +status: pending +depends_on: [w1-manifest, w1-error-model] +scope: moderate +risk: medium +impact: project +level: implementation +--- + +## Description + +Land the ADR-005 custody model in code — the key-source seam both +directions consume (client dial keys, server host keys): + +- **The source seam** — the adapter takes *key sources*, not static + keys (the alkhttp ADR-014 pattern): a source trait the assembly + implements over an injected alkvault handle, `EncryptedData` opaque + to the crate (ADR-005 decision 5 — the crate never interprets + `key_version` or record internals). +- **At-use lifecycle** (decision 2) — `vault.decrypt(blob)` → + **`Zeroizing` wrap at the adapter seam** (decision 3 — the + known-gap contract; the vault-side improvement is upstream, not + here) → one-time `decode_secret_key(plain, None)` → the decoded + `PrivateKey` lives for the dial's lifetime. The vault is needed only + at dial time; locking mid-session keeps live sessions streaming. +- **Host-key load path** (decision 6) — decrypt-at-construction + returning the `PrivateKey` set the server `Config::keys` will + consume (the `Config` construction itself is wave 2; locked vault → + the load fails → **no server is constructed**, POC-2 §5). +- **Failure mapping** — every vault failure mode maps to its distinct + `KeySourceError` variant (locked / absent / decryption / unknown + version / decode); never collapsed, never a fallback dial. +- **Plaintext never persists** (decision 7) — no disk spill on this + path, ever; the `Zeroizing` wrap bounds the plaintext from the + adapter seam onward. + +Inner format is OpenSSH private-key PEM, unencrypted (decision 1) — +the source contract assumes it; the decode call is the single +conversion. + +## Acceptance Criteria + +- [ ] Source trait(s) take the vault handle injected; `EncryptedData` + opaque (no `key_version` interpretation anywhere in `src/`) +- [ ] Decrypt → `Zeroizing` → one-time `decode_secret_key` + chain in place; the plaintext never outlives the wrap +- [ ] Host-key load path returns the decoded key set; locked vault → + `KeySourceError::Locked` (no server construction possible) +- [ ] Round-trip test: encrypt(PEM) → source → decoded ed25519 key + usable by russh +- [ ] Failure-mode tests: locked / absent / tampered ciphertext / + unknown version / undecodable plaintext each map to their + distinct variant +- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, + `cargo fmt --check` pass + +## References + +- docs/architecture/decisions/005-at-rest-key-custody.md (decisions + 1–7) +- docs/architecture/auth.md §Key custody +- docs/research/poc-2-vault-keys.md (§1 the 4-way table, §2 lifecycle + + zeroization bounds, §3 failure matrix, §5 host keys end to end) +- docs/research/auth-bridge.md §The gap (the source-shape reasoning) + +## Notes + +> To be filled by implementation agent + +## Summary + +> To be filled on completion \ No newline at end of file diff --git a/tasks/implementation/w1-manifest.md b/tasks/implementation/w1-manifest.md new file mode 100644 index 0000000..ff12568 --- /dev/null +++ b/tasks/implementation/w1-manifest.md @@ -0,0 +1,78 @@ +--- +id: w1-manifest +name: Wave 1 — manifest landing (russh pin + the feature graph) +status: pending +depends_on: [] +scope: moderate +risk: medium +impact: project +level: implementation +--- + +## Description + +Land the implementation dependency set and the ADR-003 feature graph — +the first commit of wave 1; everything else lands inside it. Per +ADR-002 decision 6 ("manifest lands with the dependency") and +`overview.md` §lifecycle note: + +- **`russh = "0.65"`** (caret stops at the minor), `default-features = + false, features = ["aws-lc-rs"]` — the family crypto posture + (decision 2), **no `flate2`** (decision 3), **no `rsa`** (decision 4: + ed25519-only; the tree must drop `rsa`/`pkcs1` RC deps entirely — + `ssh-key =0.7.0-rc.11` riding the russh keys path is the recorded + exception). +- **`alkcall 0.10`** (identity/ACL types at the wire layer — + `IdentityProvider`, `Identity`, `AccessControl`), **`alkvault 0.1`** + (the ADR-005 custody model). Both pinned by version, never + sibling-checkout paths. +- Standing toolkit: `tokio`, `thiserror`, `tracing`, `parking_lot`, + `zeroize` (the ADR-005 `Zeroizing` wrap). +- **`edition = "2024"`, `rust-version = "1.89"`** (supersedes the + current 1.88/2021 manifest). +- **The ADR-003 feature graph declared from the first commit**: + `session`, `git`, `tunnel`, `sftp`, `default = []` — flat, + independent, no feature implies another. Features gate nothing yet + (the wave-2 decline core is their first consumer); the per-feature + sibling deps (`alktty` under `session`, `alktunnels` under `tunnel`, + `alkgit` under `git`) land with their first consuming wave, not as + dead manifest entries — recorded interpretation, flagged for the + wave-1 gate. +- Update the manifest comments (the current "deliberately empty until + implementation start" notes are superseded); keep comments to the + non-obvious-constraint class only. + +Pre-flight: verify every pin exists on crates.io at the declared +version (`russh 0.65`, `alkcall 0.10`, `alkvault 0.1`); if a +crate/version is not published, stop and escalate — a blocking issue, +not something to work around. + +## Acceptance Criteria + +- [ ] Manifest declares russh 0.65 with `aws-lc-rs` only; + `cargo tree` shows no `rsa`/`pkcs1` packages and no `flate2` + (the `ssh-key` RC pin excepted, recorded) +- [ ] `alkcall 0.10` and `alkvault 0.1` pinned by version; no path + deps anywhere +- [ ] Features `session`/`git`/`tunnel`/`sftp` declared, `default = + []`; `cargo test --all-features` passes (features gate nothing + yet) +- [ ] `edition = "2024"`, `rust-version = "1.89"` set +- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, + `cargo fmt --check` pass + +## References + +- docs/architecture/decisions/002-russh-pin.md (decisions 1–6) +- docs/architecture/decisions/003-feature-graph.md (decision 1, §2) +- docs/architecture/overview.md §lifecycle note, §bounded-resources +- docs/research/russh.md (the 2026-10-26 re-verification section — + feature names, tree inventory, MSRV) + +## Notes + +> To be filled by implementation agent + +## Summary + +> To be filled on completion \ No newline at end of file diff --git a/tasks/implementation/w1-test-doubles.md b/tasks/implementation/w1-test-doubles.md new file mode 100644 index 0000000..7688446 --- /dev/null +++ b/tasks/implementation/w1-test-doubles.md @@ -0,0 +1,71 @@ +--- +id: w1-test-doubles +name: Wave 1 — test doubles (the in-memory seams) +status: pending +depends_on: [w1-error-model, w1-key-sources, w1-host-key-policy] +scope: narrow +risk: low +impact: component +level: implementation +--- + +## Description + +Establish the test-double convention (adapted from the sibling crates) +and land the in-memory seams every later wave's contract tests run +against: + +- **In-memory `IdentityProvider`** — registered fingerprints → + `Identity` resolutions over alkcall's trait; the double the auth + bridge and dispatch tests resolve against. +- **In-memory vault seam** — the encrypt/decrypt round-trip plus the + failure postures (locked / absent / tampered) behind the same seam + the key sources consume, so the custody tests run without a live + vault service. If alkvault 0.1 ships an in-memory construction, wrap + it; otherwise a local test double behind the source trait — the + task pins which, flagged for the wave-1 gate. +- **In-memory `HostKeyPolicy`** — a configurable record/verdict double + (accept/reject per host, decision log inspectable) for the dial and + acceptor tests. +- **ed25519 test-key fixtures** — throwaway test keys (generated at + test time or committed static PEMs — the task pins which; committed + keys are throwaway fixtures, not secrets) with recorded + `ssh-keygen -lf` fingerprints, shared with the fingerprint parity + tests and every later auth/mapping test. + +The convention: doubles live in-crate (test module or `#[cfg(test)]` +utility module per the sibling posture), every protocol wave's gate +runs its machines against them, and no double leaks into `src/` +non-test code. + +## Acceptance Criteria + +- [ ] In-memory `IdentityProvider`, vault seam, and `HostKeyPolicy` + doubles in place behind their production seams +- [ ] ed25519 test-key fixtures with recorded fingerprints available + to the test suite +- [ ] The doubles exercise the wave-1 seams end to end (a round-trip + test: register fingerprint → resolve; encrypt → source → decode; + policy verdict honored) +- [ ] No double code outside test-gated compilation +- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, + `cargo fmt --check` pass + +## References + +- docs/architecture/decisions/004-auth-dispositions.md (the + `IdentityProvider` surface) +- docs/architecture/decisions/005-at-rest-key-custody.md (the custody + seam the vault double backs) +- docs/research/poc-2-vault-keys.md (the failure postures the vault + double reproduces) +- The alkgit test-double convention (tasks/implementation/core/ + w1-test-doubles.md — reading reference, never copied code) + +## Notes + +> To be filled by implementation agent + +## Summary + +> To be filled on completion \ No newline at end of file