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:
1 parent
04f48ab9bc
commit
5baebf1e43
3 files changed
+215
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user