From 5baebf1e43a4320d0d313ed7fff71a522a965ee8 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Mon, 26 Oct 2026 12:05:18 +0000 Subject: [PATCH] docs(architecture): ADR-004 + ADR-005 (auth dispositions; at-rest key custody) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR-004 (Accepted): publickey-only auth — explicit fails for none/password/kbd-int/cert (inherited-but-explicit per the one-way- door pin rationale), offered-hook accept-and-delegate, username carries no semantics (OQ-03), fingerprint parity invariant, honest advertisement, max_auth_attempts sized for the counting none-probe (default 10) - ADR-005 (Accepted): OpenSSH-PEM inner inside alkvault EncryptedData (4-way verdict cited), decrypt-at-dial one-time decode, adapter-owned Zeroizing with the vault-side gap tracked as an upstream ask (OQ-07), fail-closed failure matrix incl. locked-at-bootstrap, host keys on the same model, Signer seam dismissed with evidence - README ADR table updated --- docs/architecture/README.md | 2 + .../decisions/004-auth-dispositions.md | 93 ++++++++++++++ .../decisions/005-at-rest-key-custody.md | 120 ++++++++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 docs/architecture/decisions/004-auth-dispositions.md create mode 100644 docs/architecture/decisions/005-at-rest-key-custody.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 241357f..c61d4f3 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -36,6 +36,8 @@ below are being written. No implementation until specs reach | ADR | Title | Status | |---|---|---| | [ADR-001](decisions/001-alkssh-alpn-pin.md) | The `alk/ssh` ALPN pin — edge-termination contract for `SshAdapter` | Proposed | +| [ADR-004](decisions/004-auth-dispositions.md) | Auth dispositions — publickey-only, fail-closed, fingerprint identity | Accepted | +| [ADR-005](decisions/005-at-rest-key-custody.md) | At-rest key custody — vault-wrapped OpenSSH-PEM, decrypt-at-use | Accepted | ## Open Questions diff --git a/docs/architecture/decisions/004-auth-dispositions.md b/docs/architecture/decisions/004-auth-dispositions.md new file mode 100644 index 0000000..eeef479 --- /dev/null +++ b/docs/architecture/decisions/004-auth-dispositions.md @@ -0,0 +1,93 @@ +--- +status: draft +last_updated: 2026-10-26 +--- + +# ADR-004: Auth dispositions — publickey-only, fail-closed, fingerprint identity + +## Status + +**Accepted** — 2026-10-26. Evidence-backed (POC-1 end to end, +POC-2's signature-pipeline facts); open to amendment only via a new +ADR. Not a ledger item (no wire format changes), but the decline +matrix is contract territory: every decline is tested on both sides of +every feature. + +## Context + +The family identity model has exactly one SSH-shaped resolution path: +a verified key's fingerprint → alkcall `IdentityProvider:: +resolve_from_fingerprint` → `Identity` (`auth-bridge.md`). russh 0.64.1 +performs the cryptographic verification itself — `auth_publickey` +fires *after* signature and ownership verification (POC-1 observed), +and invalid/malformed signatures produce `USERAUTH_FAILURE` before any +handler hook fires (POC-2). alkssh therefore never touches signature +material; its auth surface is dispositional. + +The fail-closed posture (family ADR-003 decision 3) applies to auth +twice: which methods exist, and what unauthenticated attempts receive. + +## Decision + +1. **`auth_publickey` is implemented — the only method.** Handler: + fingerprint (`ssh-key` re-export, `HashAlg::Sha256`, + `SHA256:` + unpadded url-safe base64) → + `resolve_from_fingerprint` → `Some` → accept with the resolved + identity; `None` → reject. The fingerprint *string format* is a + contract invariant: registration strings from any standard issuer + (ssh-keygen, alkcall-side computation) must compare equal — + POC-1 proved parity against `ssh-keygen -lf` output. +2. **`auth_publickey_offered` is accepted-and-delegated.** The probe + hook (key seen, ownership unverified) accepts the offer and lets + `auth_publickey` decide — which keeps `max_auth_attempts` counting + honest (confirmed: the hook does not consume an attempt). +3. **Every other method is explicitly declined, including when inert + at the pin.** `auth_none`, `auth_password`, + `auth_keyboard_interactive`, `auth_openssh_certificate` each get + explicit reject implementations. At russh 0.64.1 the defaults + already reject (POC-1's correction of the 0.5x-era assumption) — + the explicit implementations are family contract, not redundancy: + the pin is a one-way door and a future bump could silently change + defaults (an auth-posture change would otherwise be invisible). + The certificate decline is the standing disposition (OQ-04 tracks + whether a CA-trust use case ever arrives). +4. **Honest advertisement.** `Config::methods` advertises exactly what + is implemented: `{publickey}` in every current configuration. + OpenSSH honors the subset (POC-1: non-advertised methods are + never attempted), so the advertisement itself is part of the + fail-closed contract. +5. **The username is not identity and carries no semantics.** Keys + resolve independently of the username string (POC-1: `ssh + anything@host` authenticates identically). Whether a username + appears in logs is deployment policy, outside the crate (OQ-03). +6. **Bounded auth resources.** `max_auth_attempts` is + assembly-configurable with a default sized for OpenSSH's counting + `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). + Constant-time rejection is inherited from + `Config::auth_rejection_time` (russh-managed). + +## Consequences + +- **Positive**: the entire auth surface is evidence-backed end to end + (vanilla clients, both directions of failure); the decline matrix is + small and fully testable per build; auth posture survives a russh + bump that changes defaults. +- **Negative**: none material. The explicit-declines cost a few + handler lines per method — deliberately purchased immovability. +- **Neutral**: password/keyboard-interactive exist client-side only as + non-goals (`vision.md`) until a consuming use case arrives; the + server never gains them without amending this ADR (a wire-visible + capability, not a two-way door). + +## References + +- `auth-bridge.md` (the mapping + disposition table + 0.64.1 default note) +- `docs/research/poc-1-auth-bridge.md` §2–4, §OQ seeds 1–4 +- `docs/research/poc-2-vault-keys.md` §"Server-side auth pipeline" (invalid signatures pre-hook) +- Family ADR-003 (decisions 2, 3), alkcall ADR-002/003/006 (the + `IdentityProvider` surface and its single-backend posture; alkhttp + ADR-073's one-backend-per-door analog) +- OQ-03 (resolved), OQ-04 (deferred) \ No newline at end of file diff --git a/docs/architecture/decisions/005-at-rest-key-custody.md b/docs/architecture/decisions/005-at-rest-key-custody.md new file mode 100644 index 0000000..0c05254 --- /dev/null +++ b/docs/architecture/decisions/005-at-rest-key-custody.md @@ -0,0 +1,120 @@ +--- +status: draft +last_updated: 2026-10-26 +--- + +# ADR-005: At-rest key custody — vault-wrapped OpenSSH-PEM, decrypt-at-use + +## Status + +**Accepted** — 2026-10-26. Evidence-backed (POC-2, 39/39, including +the 4-way inner-format bake and the failure matrix); amendable only +via a new ADR. + +## Context + +On the client direction alkssh *holds* secret material (dial keys); on +the server direction it holds the host key. Family rules in force: + +- Secrets come from alkvault-backed sources — never env vars, never + plaintext at rest (family ADR-002 decision 5; family-wide standing + posture). +- The vault is a **secret store on this path, not a key generator or + custodian**: its derivation surface (`derive_ed25519`, SLIP-0010) is + a different feature and is not the SSH-keys path (`auth-bridge.md` + §The gap). Held SSH keys are bearer-token-class secrets: opaque + bytes, vault-encrypted at rest, decrypted at use. +- The vault is **never** involved in resolving *clients'* fingerprints + — that path is the assembly-layer `IdentityProvider` (scope note in + `pocs.md` §POC-2). + +alkvault 0.1.0's `encrypt`/`decrypt` surface is `&str` in, `String` +out (`EncryptedData` as the wrapper) — a decisive constraint on any +binary inner format (POC-2 measured: it would force base64 layers on +both ends). + +## Decision + +1. **Inner format: OpenSSH private-key PEM (unencrypted).** Winner on + POC-2's 4-way evidence: natively ASCII (no encode layer over the + vault's `&str` API), self-describing (algorithm + public key + + comment round-trip), vanilla-OpenSSH-tool-compatible at every + boundary (`ssh -i` accepted the decrypted bytes directly), and + exactly one decode call (`decode_secret_key(plain, None)`) from + russh. Rejected alternatives, recorded for the trail: ssh-key's + own encrypted-key format (double-wrapping antipattern — the + passphrase is itself a stored secret, moving the problem one level + down), raw-seed base64 (80 B vs 540 B blobs — legitimate size win, + rejected because it couples storage to a fixed key shape; "the + bytes of a PEM file" survives every algorithm ssh-key can decode), + PKCS#8 PEM (parity, loses the native round-trip, adds nothing). +2. **At-use lifecycle: decrypt-at-dial, one-time decode.** + `vault.decrypt(blob)` → plaintext wrapped in `Zeroizing` at + the adapter seam → one-time `decode_secret_key` → decoded + `PrivateKey` lives for the dial's lifetime. The vault is needed + only at dial time: locking it mid-session keeps live sessions + streaming while new dials fail (POC-2 verified); unlock is + side-effect-free on key identity. +3. **Zeroization ownership (the known gap).** ssh-key zeroizes + ed25519 key bytes on `Drop` (decoded keys, and their clones, clean + themselves). The *decrypted plaintext* is the gap: alkvault's + `decrypt` returns a plain `String`, so from the vault's return to + the adapter's wrap the plaintext sits in unfenced heap. The + adapter owns the wrap (proven in POC-2) — recorded as the family + convention pending alkvault taking ownership of it + (`Zeroizing` out). This OQ was brought upstream; when + alkvault answers, this ADR is amended accordingly (OQ-07 is the + tracker and the ask). +4. **Failure modes are all fail-closed, with distinct error shapes.** + Locked vault → no dial (and no server: a host key that can't load + prevents server construction — verified at bootstrap). Absent + blob → no dial. Wrong mnemonic / tampered ciphertext → decryption + failure, no dial. Unknown `key_version` → vault's `InvalidPath` + refuse-before-crypto. Rotation (at-rest version bump, key + constant) preserves key identity (v2→v3 proven). The adapter maps + each to a distinct variant; the vault's error taxonomy is the + adapter's to reshape, never to collapse. +5. **`EncryptedData` is opaque to alkssh.** The crate never + interprets `key_version` or any record internals — the vault owns + version semantics. Rotation is a storage-side operation. +6. **Host keys ride the same model.** Server host keys are + decrypt-at-construction into `Config::keys` through the same + load path; the client's host-*pin* is public config, never vault + material (OQ-05's two-case policy handles pinning). +7. **Plaintext never persists.** No disk spill on this path, ever; + the vanilla-tool PEM compatibility is for the assembly layer to + exploit if ever (e.g. handing a key to an external agent process — + itself out of family scope). +8. **The `Signer` seam is dismissed, with evidence.** POC-2's verdict: + no `impl Signer` exists anywhere in the 0.64.1 tree (the doc + comment claiming an SSH-agent implementation is stale); a + hand-rolled signer must replicate the splice convention (`to_sign + || string{sig}` — initial omission was silently misparsed); an + in-process vault gains nothing over one-time decode (decrypted + bytes exist client-side at *either* dial time or sign time; + decrypt-per-sign keeps *more* plaintext copies alive while + multiplying GCM+parse work). Recorded so a future revisit starts + from evidence, not instinct. + +## Consequences + +- **Positive**: one model covers client dial keys, host keys, and any + future held material; every failure mode is proven fail-closed; the + at-rest bytes are plain OpenSSH key files, so alkvault stores and + rotates them with no SSH-specific logic. +- **Negative**: decrypted plaintext has one unfenced window (before + the adapter's wrap) until alkvault changes its API — the gap is + recorded, owned, and tracked (OQ-07), not silently absorbed. +- **Neutral**: the seed-variant size optimization remains noted as a + legitimate future option if blob size ever matters; adopting it + would be an ADR amendment (it changes what a stored record is). + +## References + +- `auth-bridge.md` §The client side + §Host keys +- `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) +- Family ADR-002 decision 5, family ADR-003 decision 2 +- OQ-05, OQ-06, OQ-07, OQ-13 \ No newline at end of file