docs(architecture): ADR-004 + ADR-005 (auth dispositions; at-rest key custody)

- 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
This commit is contained in:
glm-5.3-flash committed 2026-10-26 12:05:18 +00:00
1 parent 04f48ab9bc
commit 5baebf1e43
3 files changed
+215

No files matched your search

+2
View File
@@ -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
@@ -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)
@@ -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<String>` 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<String>` 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