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:
1 parent
ecdf836eeb
commit
f4424b7e58
6 files changed
+105
-73
No files matched your search
@@ -102,7 +102,9 @@ session, not just spawned implementation agents.
|
|||||||
6. **Key reuse, no env vars** — keys reuse the alknet `main` key model
|
6. **Key reuse, no env vars** — keys reuse the alknet `main` key model
|
||||||
(`russh::keys`, authorized-key/KeySource handling — see
|
(`russh::keys`, authorized-key/KeySource handling — see
|
||||||
`/workspace/@alkdev/alknet-main/crates/alknet-core/src/auth/`) via
|
`/workspace/@alkdev/alknet-main/crates/alknet-core/src/auth/`) via
|
||||||
alkvault-backed sources (family ADR-003 decision 2). No secret
|
alkvault-backed sources — keys held encrypted at rest, decrypted at
|
||||||
|
use (the vault is a secret store on this path, not a key generator)
|
||||||
|
(family ADR-003 decision 2). No secret
|
||||||
material on the wire, in plaintext at rest, or in env vars — the
|
material on the wire, in plaintext at rest, or in env vars — the
|
||||||
standing family security posture (family ADR-002 decision 5).
|
standing family security posture (family ADR-002 decision 5).
|
||||||
|
|
||||||
@@ -219,12 +221,13 @@ production code.
|
|||||||
against current russh at architecture time
|
against current russh at architecture time
|
||||||
- `auth-bridge.md` — russh auth ↔ alkcall `IdentityProvider` mapping
|
- `auth-bridge.md` — russh auth ↔ alkcall `IdentityProvider` mapping
|
||||||
(server: verified-key fingerprint → `resolve_from_fingerprint`;
|
(server: verified-key fingerprint → `resolve_from_fingerprint`;
|
||||||
client: alkvault-backed key sources, `Signer`-shaped)
|
client: alkvault-backed key sources — held keys encrypted at rest,
|
||||||
|
at-rest/bearer-token-class storage, not key derivation)
|
||||||
- `mapping-table.md` — the crate's spine: SSH channel type / request
|
- `mapping-table.md` — the crate's spine: SSH channel type / request
|
||||||
→ family protocol seam + cargo feature; the alktunnels
|
→ family protocol seam + cargo feature; the alktunnels
|
||||||
absence note; the `alk/ssh` ALPN topology question
|
absence note; the `alk/ssh` ALPN topology question
|
||||||
- `pocs.md` — POC-1..3 plan (auth bridge, vault key bridge, mapping
|
- `pocs.md` — POC-1..3 plan (auth bridge, client-side at-rest key
|
||||||
bake-off) gating phase 0 completion
|
handling, mapping bake-off) gating phase 0 completion
|
||||||
- family docs: `/workspace/@alkdev/alkfamily/docs/` —
|
- family docs: `/workspace/@alkdev/alkfamily/docs/` —
|
||||||
`architecture/decisions/003-alkssh-russh-adapter.md` is the structural
|
`architecture/decisions/003-alkssh-russh-adapter.md` is the structural
|
||||||
decision this repo builds from (and `002-alkforge-gitea-replacement.md`
|
decision this repo builds from (and `002-alkforge-gitea-replacement.md`
|
||||||
|
|||||||
+2
-1
@@ -16,5 +16,6 @@ this crate adheres to [Semantic Versioning](https://semver.org/).
|
|||||||
- Phase 0 research set under `docs/research/`: `vision.md` (scope
|
- Phase 0 research set under `docs/research/`: `vision.md` (scope
|
||||||
questions), `russh.md` (0.64.1-era inventory + pin considerations),
|
questions), `russh.md` (0.64.1-era inventory + pin considerations),
|
||||||
`auth-bridge.md` (fingerprint → identity server path; vault-backed
|
`auth-bridge.md` (fingerprint → identity server path; vault-backed
|
||||||
client keys), `mapping-table.md` (channel type/request → family seam
|
at-rest client key storage — the vault as secret store, not key
|
||||||
|
derivation), `mapping-table.md` (channel type/request → family seam
|
||||||
+ feature), `pocs.md` (POC-1..3 plan — documented, not yet run).
|
+ feature), `pocs.md` (POC-1..3 plan — documented, not yet run).
|
||||||
@@ -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
|
client *signs* with a private key; the server verifies. alkssh's client
|
||||||
side therefore holds secret material, and the family rule applies: that
|
side therefore holds secret material, and the family rule applies: that
|
||||||
material comes from alkvault-backed sources, never env vars, never
|
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)
|
### 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`
|
and the adapter reads it fail-closed at use time (`Capabilities::get`
|
||||||
→ absent → loud internal error, never an unauthenticated send). The
|
→ absent → loud internal error, never an unauthenticated send). The
|
||||||
alkssh translation: the client adapter takes *key sources*, not static
|
alkssh translation: the client adapter takes *key sources*, not static
|
||||||
keys — the assembly layer supplies an alkvault-backed source; the
|
keys — the assembly layer supplies an alkvault-backed source (the
|
||||||
adapter converts it into a russh-usable key/signer at construction or
|
encrypted-at-rest blob); the adapter decodes it into a russh-usable
|
||||||
dial time; absent/locked vault → fail-closed decline, no anonymous
|
key at dial time; absent/locked vault → fail-closed decline, no
|
||||||
dial.
|
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:
|
alkvault's role for SSH keys is **at-rest encryption, nothing more** —
|
||||||
`VaultServiceHandle::derive_ed25519(path) -> Result<DerivedKey>` (SLIP-
|
it is to an SSH client key what it is to every outgoing secret: a
|
||||||
0010 ed25519 over the BIP39 seed; `DerivedKey { key_type, private_key
|
secret store. Held key material is treated exactly like a random
|
||||||
(zeroized), public_key }`), with derivation paths including
|
bearer token — opaque bytes the family holds, vault-encrypted at rest,
|
||||||
`PATHS::SSH_HOST` and a `device_path(index)` family. The bridge that
|
decrypted at use (the alkhttp ADR-014 model: vault is assembly-layer
|
||||||
does not exist yet, and is alkssh phase 0/1 design work:
|
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 bridge that does not exist yet, and is alkssh phase 0/1 design
|
||||||
— the `ssh-key` re-export accepts raw ed25519 seed bytes into
|
work:
|
||||||
`KeypairData::Ed25519`, but the exact construction (and what
|
|
||||||
`PrivateKeyWithHashAlg` needs for a non-RSA key) needs POC-2
|
- **At-rest shape**: what byte format sits in the vault store —
|
||||||
confirmation.
|
OpenSSH private-key encoding (the format `ssh-key` itself supports
|
||||||
- **Signing-only alternative**: russh's `Signer` trait +
|
with a passphrase) is the natural candidate; alkvault `encrypt`/
|
||||||
`authenticate_publickey_with(..., signer)` exists precisely for keys
|
`decrypt` (`EncryptedData`) is the outer wrapper.
|
||||||
that shouldn't be extractable. A vault-backed `Signer` would keep
|
- **At-use shape**: decrypted bytes → `russh::keys::PrivateKey`
|
||||||
the vault as the only place the bytes exist — candidate preferred
|
(`decode_secret_key` for OpenSSH encoding) at dial/construction
|
||||||
shape, POC-2's design question.
|
time, held as a zeroized `Secret`-like value for the dial's
|
||||||
- Scope note: the vault is **client-side only**. The server side never
|
lifetime. russh's `Signer` trait was considered as an
|
||||||
touches the vault — it receives clients' public keys and resolves
|
extraction-avoidance shape; withdrawn — the at-rest model implies
|
||||||
fingerprints through the assembly layer's `IdentityProvider`. Vault
|
the bytes are handled client-side at use time anyway (as every
|
||||||
material is for secrets we hold, not for other parties'
|
bearer-token-like secret is), so a one-time decode at the adapter
|
||||||
fingerprints.
|
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`
|
### Host keys and `check_server_key`
|
||||||
|
|
||||||
The server's host identity (`Config::keys: Vec<PrivateKey>`,
|
The server's host identity (`Config::keys: Vec<PrivateKey>`,
|
||||||
`certificates`) is assembly-configured, ideally from the same vault
|
`certificates`) is assembly-configured — a held secret exactly like a
|
||||||
path family (`PATHS::SSH_HOST` exists deliberately). The client's
|
client key, same at-rest model as above. The client's
|
||||||
`Handler::check_server_key` defaults to *reject all* — russh forces a
|
`Handler::check_server_key` defaults to *reject all* — russh forces a
|
||||||
deliberate host-key policy. Family posture (no TOFU): pin expected
|
deliberate host-key policy. Family posture (no TOFU): pin expected
|
||||||
host fingerprints at the assembly layer; `check_known_hosts` exists in
|
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)
|
(AuthContext shape/set_identity), 010 (secret-material flow)
|
||||||
- alkhttp ADR-014 (secret material flow + capability injection — the
|
- alkhttp ADR-014 (secret material flow + capability injection — the
|
||||||
client-side pattern), ADR-073 (one backend behind every door surface)
|
client-side pattern), ADR-073 (one backend behind every door surface)
|
||||||
- alkvault 0.1.0: `src/service.rs` (VaultServiceHandle),
|
- alkvault 0.1.0: `src/service.rs` (`VaultServiceHandle` — for SSH
|
||||||
`src/derivation.rs` (PATHS), `src/protocol.rs` (DerivedKey)
|
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`
|
- russh 0.64.1 docs: `server::Handler` auth hooks, `client::Handle`
|
||||||
authenticate methods, `Signer`, `keys` module (see `russh.md`)
|
authenticate methods, `Signer`, `keys` module (see `russh.md`)
|
||||||
+33
-30
@@ -47,42 +47,45 @@ keys; russh client available as a secondary client for
|
|||||||
uncertainty is fingerprint-format parity details, which this either
|
uncertainty is fingerprint-format parity details, which this either
|
||||||
confirms or corrects.
|
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
|
**Question:** how are held SSH keys stored and used — the vault as
|
||||||
key without the vault leaking the bytes — `PrivateKey` extraction vs
|
pure at-rest encryption (the key is just another bearer-token-class
|
||||||
a vault-backed `Signer`?
|
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` +
|
**Construction:** standalone. alkvault `encrypt`/`decrypt`
|
||||||
`derive_ed25519` (host/device paths) → two candidate shapes:
|
(`EncryptedData`) wrapping an OpenSSH-encoded private key (generated
|
||||||
(a) `ssh-key` `KeypairData::Ed25519` from raw seed bytes →
|
in-POC) → `ssh-key` `decode_secret_key` → `russh::keys::PrivateKey` →
|
||||||
`russh::keys::PrivateKey` → `authenticate_publickey`; (b) a vault-
|
`authenticate_publickey` against POC-1's server loopback (which resolves
|
||||||
backed `russh::Signer` fed to `authenticate_publickey_with` (bytes
|
the key's fingerprint through the same stub `IdentityProvider`).
|
||||||
never leave the vault struct).
|
|
||||||
|
|
||||||
**Validates:**
|
**Validates:**
|
||||||
- Whether ed25519 seed bytes → `ssh-key` key data round-trips
|
- The at-rest shape: OpenSSH private-key encoding as the inner format
|
||||||
(`KeypairData` expects the *expanded* keypair — seed-to-`[u8; 64]`
|
vs raw seed bytes or PKCS#8 (the ssh-key crate's own encrypted-key
|
||||||
expansion is a real step; whether alkvault's `DerivedKey` carries
|
format with a passphrase is an alternative wrapper worth comparing
|
||||||
the expanded form or just the seed is an open sub-question).
|
against alkvault's outer encryption).
|
||||||
- Whether `Signer` + `authenticate_publickey_with` + `PublicKey`
|
- The at-use lifecycle: decrypt-at-dial → one-time `decode_secret_key`
|
||||||
(public half held outside the vault) is ergonomic, and what
|
→ zeroized handling for the dial's lifetime; what happens locked /
|
||||||
`PrivateKeyWithHashAlg` wants for non-RSA keys.
|
absent (fail-closed no-dial) / mid-session unlock rotation.
|
||||||
- Server side of the same loopback re-uses POC-1's fingerprint path —
|
- Whether a `Signer`-shaped seam is still worth anything given the
|
||||||
client key derived in vault, server resolves its fingerprint.
|
bytes are handled client-side at use anyway (expected answer: no —
|
||||||
- Lifecycle correctness: derived key zeroization across the boundary;
|
recorded so the ADR can dismiss it with evidence).
|
||||||
vault lock/unlock races.
|
- Server and client host-key material riding the same model.
|
||||||
|
|
||||||
**Scope note (per the design split):** the vault is for secrets *we*
|
**Scope note:** the vault is for secrets *we* hold — client-side
|
||||||
hold — client-side outgoing keys and the server's own host key — and
|
outgoing keys and the server's own host key — and is explicitly not
|
||||||
is explicitly not involved in resolving *clients'* fingerprints
|
involved in resolving *clients'* fingerprints (that path is the
|
||||||
(that path is the assembly-layer `IdentityProvider`). Kept as its own
|
assembly-layer `IdentityProvider`). The vault's derivation surface
|
||||||
POC because its failure modes (key-material lifecycle) are unrelated
|
(`derive_ed25519`, SLIP-0010 paths) is not on this path either — that
|
||||||
to the auth-flow failure modes of POC-1.
|
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
|
**Hypothesized outcome:** OpenSSH-encoded key inside alkvault
|
||||||
preferable design; if `Signer` friction proves high, shape (a) with a
|
`EncryptedData`, one-time `decode_secret_key` at dial, zeroizing
|
||||||
zeroizing wrapper is documented with its exposure caveat.
|
wrapper — confirming the alkhttp ADR-014 analogy end to end.
|
||||||
|
|
||||||
## POC-3 — the mapping bake-off: channel bridge through a real sibling seam
|
## POC-3 — the mapping bake-off: channel bridge through a real sibling seam
|
||||||
|
|
||||||
|
|||||||
@@ -135,8 +135,11 @@ PrivateKeyWithHashAlg)`, `authenticate_password`,
|
|||||||
`authenticate_none`, `authenticate_keyboard_interactive_start/
|
`authenticate_none`, `authenticate_keyboard_interactive_start/
|
||||||
respond`, `authenticate_openssh_cert`, the `*_with` signer-injected
|
respond`, `authenticate_openssh_cert`, the `*_with` signer-injected
|
||||||
variants (`authenticate_publickey_with(user, PublicKey, hash_alg,
|
variants (`authenticate_publickey_with(user, PublicKey, hash_alg,
|
||||||
&mut Signer)` — the seam for vault-backed signers that never expose
|
&mut Signer)` — a signer-injection seam; under alkssh's at-rest
|
||||||
the private key), `authenticate_gssapi_with_mic`,
|
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`).
|
`best_supported_rsa_hash` (waits ≤1s for EXT_INFO `server-sig-algs`).
|
||||||
|
|
||||||
Channel opening: only the four typed opens exist —
|
Channel opening: only the four typed opens exist —
|
||||||
|
|||||||
@@ -50,11 +50,13 @@ not protocol invention.
|
|||||||
exactly one point per mapping. No channel-type knowledge flows
|
exactly one point per mapping. No channel-type knowledge flows
|
||||||
upstream into the protocol crates.
|
upstream into the protocol crates.
|
||||||
5. **Identity is the family's, not SSH's.** SSH user names, `~/.ssh/`
|
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
|
model. A verified key's fingerprint → `Identity` via alkcall's
|
||||||
`IdentityProvider` is the only resolution path; secrets an alkssh
|
`IdentityProvider` is the only resolution path; secrets an alkssh
|
||||||
*client* holds come from alkvault-backed sources, never env vars
|
*client* holds come from alkvault-backed sources — vault-encrypted
|
||||||
(family ADR-002 decision 5).
|
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
|
## Non-goals
|
||||||
|
|
||||||
@@ -90,11 +92,11 @@ phase 1's `open-questions.md`:
|
|||||||
§topology).
|
§topology).
|
||||||
2. **russh pin** — version, crypto-backend feature, and the
|
2. **russh pin** — version, crypto-backend feature, and the
|
||||||
RC-pinned transitive dependency situation (see `russh.md`).
|
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).
|
(no feature implies another; flat until architecture says otherwise).
|
||||||
4. **Auth dispositions** — which russh auth methods the server
|
4. **Auth dispositions** — which russh auth methods the server
|
||||||
implements vs declines; the exact `auth_publickey` → fingerprint →
|
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`).
|
(see `auth-bridge.md`).
|
||||||
5. **The mapping table details** — per channel type and per request,
|
5. **The mapping table details** — per channel type and per request,
|
||||||
the family seam, the parameter mapping, and the decline contract
|
the family seam, the parameter mapping, and the decline contract
|
||||||
|
|||||||
Reference in new issue
Block a user