docs(research): vault's SSH role is at-rest secret storage, not key derivation

A conflation in the research set: alkvault's derive-and-custody surface
(derive_ed25519, SLIP-0010 paths) was being sketched as the SSH key
path. Corrected across the docs — held SSH keys (client outgoing keys
and the server's own host key) are bearer-token-class secrets: opaque
bytes the family holds, vault-encrypted at rest, decrypted at use (the
alkhttp ADR-014 posture). No derivation story, no vault-custody
design, and the Signer-injection idea is withdrawn and recorded with
its dismissal rationale.

- auth-bridge.md: client-side gap re-framed as at-rest storage — the
  at-rest shape (OpenSSH encoding inside alkvault EncryptedData), the
  at-use lifecycle (decrypt-at-dial, one-time decode_secret_key,
  zeroized for the dial), the KeySource-like source shape
- pocs.md: POC-2 rewritten to at-rest key handling (formats comparison,
  decrypt-at-use lifecycle, locked-vault fail-closed, Signer dismissal)
- vision.md: principle 5 scoped (secret store, not key generator/custodian)
- russh.md: Signer seam noted as not presumed to be the vault shape
- AGENTS.md/CHANGELOG: convention 6 + context updated to match

Verification: cargo doc --no-deps, cargo fmt --check, cargo test
(docs-only change)
This commit is contained in:
glm-5.3-flash committed 2026-10-25 10:37:37 +00:00
1 parent ecdf836eeb
commit f4424b7e58
6 files changed
+105 -73

No files matched your search

+51 -31
View File
@@ -104,7 +104,9 @@ Vanilla SSH's client model differs from the family's protocols: the
client *signs* with a private key; the server verifies. alkssh's client
side therefore holds secret material, and the family rule applies: that
material comes from alkvault-backed sources, never env vars, never
plaintext at rest (family ADR-002 decision 5).
plaintext at rest (family ADR-002 decision 5). The guiding framing:
**a held SSH key is just another bearer-token-class secret** — opaque
bytes the family holds, vault-encrypted at rest, decrypted at use.
### The alkhttp pattern being mirrored (alkhttp ADR-014)
@@ -114,41 +116,57 @@ injects the secret as an alkcall `Capabilities` entry at registration,
and the adapter reads it fail-closed at use time (`Capabilities::get`
→ absent → loud internal error, never an unauthenticated send). The
alkssh translation: the client adapter takes *key sources*, not static
keys — the assembly layer supplies an alkvault-backed source; the
adapter converts it into a russh-usable key/signer at construction or
dial time; absent/locked vault → fail-closed decline, no anonymous
dial.
keys — the assembly layer supplies an alkvault-backed source (the
encrypted-at-rest blob); the adapter decodes it into a russh-usable
key at dial time; absent/locked vault → fail-closed decline, no
anonymous dial.
### The gap: alkvault → russh key
### The gap: alkvault-backed key storage
alkvault 0.1.0 is a derive-and-encrypt vault, not a KeySource KV store:
`VaultServiceHandle::derive_ed25519(path) -> Result<DerivedKey>` (SLIP-
0010 ed25519 over the BIP39 seed; `DerivedKey { key_type, private_key
(zeroized), public_key }`), with derivation paths including
`PATHS::SSH_HOST` and a `device_path(index)` family. The bridge that
does not exist yet, and is alkssh phase 0/1 design work:
alkvault's role for SSH keys is **at-rest encryption, nothing more** —
it is to an SSH client key what it is to every outgoing secret: a
secret store. Held key material is treated exactly like a random
bearer token — opaque bytes the family holds, vault-encrypted at rest,
decrypted at use (the alkhttp ADR-014 model: vault is assembly-layer
only, secrets are zeroized `Secret`s at the adapter, never env vars).
alkvault's *derivation* surface (`derive_ed25519`, SLIP-0010 paths) is
a different feature of that crate and is **not** the SSH-keys path —
there is no derivation story here, and no vault-custody design where
key bytes never leave the vault.
- `DerivedKey.private_key` (raw ed25519 bytes) → `russh::keys::PrivateKey`
— the `ssh-key` re-export accepts raw ed25519 seed bytes into
`KeypairData::Ed25519`, but the exact construction (and what
`PrivateKeyWithHashAlg` needs for a non-RSA key) needs POC-2
confirmation.
- **Signing-only alternative**: russh's `Signer` trait +
`authenticate_publickey_with(..., signer)` exists precisely for keys
that shouldn't be extractable. A vault-backed `Signer` would keep
the vault as the only place the bytes exist — candidate preferred
shape, POC-2's design question.
- Scope note: the vault is **client-side only**. The server side never
touches the vault — it receives clients' public keys and resolves
fingerprints through the assembly layer's `IdentityProvider`. Vault
material is for secrets we hold, not for other parties'
fingerprints.
The bridge that does not exist yet, and is alkssh phase 0/1 design
work:
- **At-rest shape**: what byte format sits in the vault store —
OpenSSH private-key encoding (the format `ssh-key` itself supports
with a passphrase) is the natural candidate; alkvault `encrypt`/
`decrypt` (`EncryptedData`) is the outer wrapper.
- **At-use shape**: decrypted bytes → `russh::keys::PrivateKey`
(`decode_secret_key` for OpenSSH encoding) at dial/construction
time, held as a zeroized `Secret`-like value for the dial's
lifetime. russh's `Signer` trait was considered as an
extraction-avoidance shape; withdrawn — the at-rest model implies
the bytes are handled client-side at use time anyway (as every
bearer-token-like secret is), so a one-time decode at the adapter
boundary is the honest shape. Lifecycle questions (zeroization
bounds, locked-vault behavior → fail-closed no-dial) are POC-2's
checkpoints.
- **Source shape**: the adapter takes *key sources*, not static keys —
an alknet-`main`-shaped `KeySource` seam
(`{ File, Memory }`-equivalent) backed by vault-encrypted material
at the assembly layer, mirroring alkhttp's capability injection.
- **Scope note**: the vault is **client-side only**. The server side
never touches the vault for *peer* identity — it receives clients'
public keys and resolves fingerprints through the assembly layer's
`IdentityProvider`. The server's own host key, though, is also a
held secret and uses the same at-rest model. Vault material is for
secrets we hold, not for other parties' fingerprints.
### Host keys and `check_server_key`
The server's host identity (`Config::keys: Vec<PrivateKey>`,
`certificates`) is assembly-configured, ideally from the same vault
path family (`PATHS::SSH_HOST` exists deliberately). The client's
`certificates`) is assembly-configured — a held secret exactly like a
client key, same at-rest model as above. The client's
`Handler::check_server_key` defaults to *reject all* — russh forces a
deliberate host-key policy. Family posture (no TOFU): pin expected
host fingerprints at the assembly layer; `check_known_hosts` exists in
@@ -170,7 +188,9 @@ for the ADR.
(AuthContext shape/set_identity), 010 (secret-material flow)
- alkhttp ADR-014 (secret material flow + capability injection — the
client-side pattern), ADR-073 (one backend behind every door surface)
- alkvault 0.1.0: `src/service.rs` (VaultServiceHandle),
`src/derivation.rs` (PATHS), `src/protocol.rs` (DerivedKey)
- alkvault 0.1.0: `src/service.rs` (`VaultServiceHandle` — for SSH
keys, the `encrypt`/`decrypt`/`EncryptedData` at-rest surface is the
relevant one; the derivation surface is not on this path),
`src/protocol.rs` (`EncryptedData`)
- russh 0.64.1 docs: `server::Handler` auth hooks, `client::Handle`
authenticate methods, `Signer`, `keys` module (see `russh.md`)
+33 -30
View File
@@ -47,42 +47,45 @@ keys; russh client available as a secondary client for
uncertainty is fingerprint-format parity details, which this either
confirms or corrects.
## POC-2 — client-side vault → russh key bridge (separate by nature)
## POC-2 — client-side at-rest key handling (vault-backed storage)
**Question:** how does an alkvault-derived key become a russh client
key without the vault leaking the bytes — `PrivateKey` extraction vs
a vault-backed `Signer`?
**Question:** how are held SSH keys stored and used — the vault as
pure at-rest encryption (the key is just another bearer-token-class
secret: opaque bytes, vault-encrypted at rest, decrypted at use), what
byte format sits inside, and how does the decrypt→dial lifecycle
behave?
**Construction:** standalone. alkvault `VaultServiceHandle` +
`derive_ed25519` (host/device paths) → two candidate shapes:
(a) `ssh-key` `KeypairData::Ed25519` from raw seed bytes →
`russh::keys::PrivateKey` → `authenticate_publickey`; (b) a vault-
backed `russh::Signer` fed to `authenticate_publickey_with` (bytes
never leave the vault struct).
**Construction:** standalone. alkvault `encrypt`/`decrypt`
(`EncryptedData`) wrapping an OpenSSH-encoded private key (generated
in-POC) → `ssh-key` `decode_secret_key` → `russh::keys::PrivateKey` →
`authenticate_publickey` against POC-1's server loopback (which resolves
the key's fingerprint through the same stub `IdentityProvider`).
**Validates:**
- Whether ed25519 seed bytes → `ssh-key` key data round-trips
(`KeypairData` expects the *expanded* keypair — seed-to-`[u8; 64]`
expansion is a real step; whether alkvault's `DerivedKey` carries
the expanded form or just the seed is an open sub-question).
- Whether `Signer` + `authenticate_publickey_with` + `PublicKey`
(public half held outside the vault) is ergonomic, and what
`PrivateKeyWithHashAlg` wants for non-RSA keys.
- Server side of the same loopback re-uses POC-1's fingerprint path —
client key derived in vault, server resolves its fingerprint.
- Lifecycle correctness: derived key zeroization across the boundary;
vault lock/unlock races.
- The at-rest shape: OpenSSH private-key encoding as the inner format
vs raw seed bytes or PKCS#8 (the ssh-key crate's own encrypted-key
format with a passphrase is an alternative wrapper worth comparing
against alkvault's outer encryption).
- The at-use lifecycle: decrypt-at-dial → one-time `decode_secret_key`
→ zeroized handling for the dial's lifetime; what happens locked /
absent (fail-closed no-dial) / mid-session unlock rotation.
- Whether a `Signer`-shaped seam is still worth anything given the
bytes are handled client-side at use anyway (expected answer: no —
recorded so the ADR can dismiss it with evidence).
- Server and client host-key material riding the same model.
**Scope note (per the design split):** the vault is for secrets *we*
hold — client-side outgoing keys and the server's own host key — and
is explicitly not involved in resolving *clients'* fingerprints
(that path is the assembly-layer `IdentityProvider`). Kept as its own
POC because its failure modes (key-material lifecycle) are unrelated
to the auth-flow failure modes of POC-1.
**Scope note:** the vault is for secrets *we* hold — client-side
outgoing keys and the server's own host key — and is explicitly not
involved in resolving *clients'* fingerprints (that path is the
assembly-layer `IdentityProvider`). The vault's derivation surface
(`derive_ed25519`, SLIP-0010 paths) is not on this path either — that
is a different alkvault feature, not SSH key custody. Kept as its own
POC because its failure modes (at-rest/lifecycle, zeroization, lock
behavior) are unrelated to the auth-flow failure modes of POC-1.
**Hypothesized outcome:** shape (b) (vault-backed `Signer`) is the
preferable design; if `Signer` friction proves high, shape (a) with a
zeroizing wrapper is documented with its exposure caveat.
**Hypothesized outcome:** OpenSSH-encoded key inside alkvault
`EncryptedData`, one-time `decode_secret_key` at dial, zeroizing
wrapper — confirming the alkhttp ADR-014 analogy end to end.
## POC-3 — the mapping bake-off: channel bridge through a real sibling seam
+5 -2
View File
@@ -135,8 +135,11 @@ PrivateKeyWithHashAlg)`, `authenticate_password`,
`authenticate_none`, `authenticate_keyboard_interactive_start/
respond`, `authenticate_openssh_cert`, the `*_with` signer-injected
variants (`authenticate_publickey_with(user, PublicKey, hash_alg,
&mut Signer)` — the seam for vault-backed signers that never expose
the private key), `authenticate_gssapi_with_mic`,
&mut Signer)` — a signer-injection seam; under alkssh's at-rest
storage model (held keys are bearer-token-class secrets decrypted at
use — see `auth-bridge.md`) it is not presumed to be the vault
integration shape, but it exists and POC-2 evaluates it against the
simple decrypt-and-decode flow), `authenticate_gssapi_with_mic`,
`best_supported_rsa_hash` (waits ≤1s for EXT_INFO `server-sig-algs`).
Channel opening: only the four typed opens exist —
+7 -5
View File
@@ -50,11 +50,13 @@ not protocol invention.
exactly one point per mapping. No channel-type knowledge flows
upstream into the protocol crates.
5. **Identity is the family's, not SSH's.** SSH user names, `~/.ssh/`
conventions, and password auth are not the alk family's identity
conventions, and password auth are not the alk family's identity
model. A verified key's fingerprint → `Identity` via alkcall's
`IdentityProvider` is the only resolution path; secrets an alkssh
*client* holds come from alkvault-backed sources, never env vars
(family ADR-002 decision 5).
*client* holds come from alkvault-backed sources — vault-encrypted
at rest, treated as bearer-token-class secret material — never env
vars (family ADR-002 decision 5). The vault is a secret store on
this path, not a key generator or key custodian.
## Non-goals
@@ -90,11 +92,11 @@ phase 1's `open-questions.md`:
§topology).
2. **russh pin** — version, crypto-backend feature, and the
RC-pinned transitive dependency situation (see `russh.md`).
3. **Feature names + graph** — the `session`/`tunnel`/`dynamic-`-`forward`/`git`/`sftp` naming and their independence
3. **Feature names + graph** — the `session`/`tunnel`/`dynamic-forward`/`git`/`sftp` naming and their independence
(no feature implies another; flat until architecture says otherwise).
4. **Auth dispositions** — which russh auth methods the server
implements vs declines; the exact `auth_publickey` → fingerprint →
identity contract; the client-side vault→russh key bridge
identity contract; client-side vault-backed at-rest key handling
(see `auth-bridge.md`).
5. **The mapping table details** — per channel type and per request,
the family seam, the parameter mapping, and the decline contract