test(fuzz): the four-target fuzz sidecar (w5-fuzz-harness)
The family standing rule (anything worth fuzzing gets fuzzed) on the
crate's ingress surfaces, per the alkgit convention:
- fuzz_targets/{exec_parse,fingerprint,key_decode,terminal_params} —
thin fuzz_target! wrappers over four ingress surfaces:
- exec_parse: the production parse path (from_utf8-fallback ingress
shape), differentially paired against a from-spec grammar oracle;
typed routing classes + determinism.
- fingerprint: blob parse + the single fingerprint path; the
SHA256:/unpadded-base64 contract shape via a from-spec
base64 round-trip oracle; the committed ssh-keygen -lf vectors
replayed inside the gate.
- key_decode: the production decode arm (the #[doc(hidden)]
decode_key_arm exposure, routed through itself); typed-class +
bounded-fixed-message invariants; passphrase-shape robustness
drive on the underlying decode.
- terminal_params: the pty_request conversion via the #[doc(hidden)]
terminal_params exposure; exact clamp, verbatim term, the
reserved-modes {} JSON shape, serialized-shape determinism.
- shared/ — the invariant logic as a stable-clean library; the
corpus gate (cargo test --manifest-path fuzz/shared/Cargo.toml)
replays every committed seed through the batteries and joins
AGENTS.md's verification list.
- corpus/<target>/ committed seeds + the deterministic
gen_fuzz_seeds.py (no-op-diff verified).
- run-detached.sh + nightly pin; the sidecar excluded from the root
workspace (workspace + package excludes; publish dry-run clean).
Exposure shape decided + recorded per target (README + lib.rs doc
comments): fingerprint needs none (fully public ingress); the rest
are #[doc(hidden)] re-exports of the production surfaces, never
forks. Detached campaigns ran per target (600 s default budget, fork
mode): all four clean; the two findings of the first round are
recorded in the task file (the harness oracle's double-/-strip bug;
the PPK echo fixed in the preceding commit).
Verification: corpus gate green (11 tests); scripts/matrix.sh green;
taskgraph validate clean.
This commit is contained in:
1 parent
a5fb03e7c6
commit
d37cbc0c8a
93 files changed
+4833
-14
No files matched your search
@@ -167,6 +167,8 @@ cargo publish --dry-run --allow-dirty # before a release
|
||||
scripts/matrix.sh # the decline-matrix runner: the full suite per
|
||||
# feature build (none / git / session / tunnel /
|
||||
# sftp / --all-features), clippy ×2, fmt, doc
|
||||
cargo test --manifest-path fuzz/shared/Cargo.toml # the fuzz corpus gate: the committed seeds replay
|
||||
# the `fuzz/` sidecar's invariant batteries
|
||||
```
|
||||
|
||||
If feature flags are added, also run `cargo test --all-features` and
|
||||
|
||||
+6
-3
@@ -1,8 +1,10 @@
|
||||
# Single-crate protocol/door crate (the alktty template). No workspace:
|
||||
# the composition story is deferred — alkssh's substance is the mappings
|
||||
# (family ADR-003).
|
||||
# (family ADR-003). The `fuzz/` sidecar is its own workspace (excluded —
|
||||
# it pins its own nightly toolchain and never rides the root's).
|
||||
[workspace]
|
||||
members = ["."]
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[package]
|
||||
name = "alkssh"
|
||||
@@ -17,8 +19,9 @@ categories = ["network-programming", "asynchronous"]
|
||||
# The package policy (the sibling crates' convention): the published crate
|
||||
# ships the architecture docs (the authoritative contract) but not the
|
||||
# process surface — reviews, research, plans, task files, the working
|
||||
# agents' config, AGENTS.md.
|
||||
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/plans/", "docs/research/", "docs/sdd_process.md", "tasks/"]
|
||||
# agents' config, AGENTS.md — and not the `fuzz/` sidecar (its own
|
||||
# workspace; never a root dep, never a shipped surface).
|
||||
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/plans/", "docs/research/", "docs/sdd_process.md", "tasks/", "fuzz/"]
|
||||
|
||||
[lib]
|
||||
name = "alkssh"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
artifacts/
|
||||
coverage/
|
||||
target/
|
||||
/corpus/*/*
|
||||
!/corpus/*/seed-*
|
||||
Generated
+3649
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
||||
[package]
|
||||
name = "alkssh-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
alkssh-fuzz-shared = { path = "shared" }
|
||||
|
||||
[[bin]]
|
||||
name = "exec_parse"
|
||||
path = "fuzz_targets/exec_parse.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fingerprint"
|
||||
path = "fuzz_targets/fingerprint.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "key_decode"
|
||||
path = "fuzz_targets/key_decode.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "terminal_params"
|
||||
path = "fuzz_targets/terminal_params.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,71 @@
|
||||
# alkssh fuzzing
|
||||
|
||||
libFuzzer targets for alkssh's ingress surfaces — the family standing
|
||||
rule (anything worth fuzzing gets fuzzed), landed per the plan's wave-5
|
||||
charter and the alkgit convention: the JSON schema gate itself is
|
||||
alkcall's surface, family-fuzzed there; SSH framing, key exchange, and
|
||||
the channel multiplexer are russh's wire (family ADR-003) — the
|
||||
alkssh-owned post-framing ingress is what fuzzes here.
|
||||
|
||||
## Layout
|
||||
|
||||
- `fuzz_targets/` — one binary per target; thin `fuzz_target!` wrappers.
|
||||
- `shared/` — the invariant logic, as a plain library so normal
|
||||
`cargo test` (stable toolchain) can replay the committed corpora
|
||||
through the same invariants (`#[cfg(test)]` modules; quinn's CI
|
||||
pattern). The fuzz binaries are nightly-only; the shared crate is
|
||||
stable-clean. It consumes alkssh's public API only (plus the three
|
||||
`#[doc(hidden)]` fuzz-visible exposures: the exec-grammar parse path
|
||||
in `src/dispatch.rs`, the key-decode arm `decode_key_arm` in
|
||||
`src/key_source.rs`, and the `terminal_params` conversion in
|
||||
`src/tty_map.rs` — the recorded exposure decisions: the ingresses are
|
||||
production-private, so the exposures are the production surfaces
|
||||
themselves, never forks; the fingerprint ingress is fully public API)
|
||||
and must never become a dependency of the root crate.
|
||||
- `corpus/<target>/` — committed hand-made seeds (regenerate with
|
||||
`python3 fuzz/gen_fuzz_seeds.py` from the repo root — must be a no-op
|
||||
diff on the committed set). Only `seed-*` files are tracked; grown
|
||||
corpora (SHA-named units), artifacts, and coverage output are
|
||||
gitignored and are **not** merged back into the committed seeds.
|
||||
- `run-detached.sh` — mandatory runner for agent sessions: wraps
|
||||
`cargo fuzz run` in `setsid` + `nohup` + log redirection so an OOM
|
||||
in a target can never take down the agent host.
|
||||
- `gen_fuzz_seeds.py` — deterministic seed generator (quiche pattern).
|
||||
|
||||
## Targets
|
||||
|
||||
| Target | Drives | Invariants |
|
||||
|---|---|---|
|
||||
| `exec_parse` | the production exec-grammar parse (`parse_exec`, `src/dispatch.rs` — the handler's ingress shape: byte string → `from_utf8` fallback `""` → parse; the pre-parse `max_exec_command_len` cap and the git row's `invalid-repo-id` gate are the root contract rows' territory, the parse is everything behind them) | no-panic on any byte string (any length behind the cap); the typed routing classes (git-parseable → the git row, non-git → the session pipe path, service-only → decline-with-reason) agree with a from-spec grammar oracle (the differential pair) and are stable per input; for `Git` routes the repo-id gate classification is deterministic |
|
||||
| `fingerprint` | the crate's single fingerprint path ([`alkssh::fingerprint`] over `russh::keys::parse_public_key_base64` — ADR-004 decision 1) | no-panic on arbitrary key blobs (a non-blob is the parse's typed refusal); every accepted key's fingerprint is exactly the `SHA256:` + unpadded-standard-base64 contract shape (43-char body, canonical alphabet, decodes to a 32-byte digest, re-encodes canonically); determinism; the committed `ssh-keygen -lf` parity vectors still agree (the corpus-gate test row) |
|
||||
| `key_decode` | the decrypt-at-use chain's decode arm (`decode_key_arm`, `src/key_source.rs` — the production `decrypt_and_decode` conversion exposed `#[doc(hidden)]`) + the underlying `decode_secret_key` passphrase arm (robustness drive — the production chain always passes `None`) | no-panic on arbitrary plaintext shapes (the vault's `String` shape, lossy from arbitrary bytes) and passphrase shapes; every failure on the production arm is exactly the typed `KeySourceError::Decode` class — never a catch-all, never a panic; the failure text never carries the plaintext; the `Zeroizing` posture rides the drive (the wrap is what the adapter seam applies); determinism |
|
||||
| `terminal_params` | the `pty_request` args → `TerminalParamsWire` conversion (`terminal_params`, `src/tty_map.rs` — OQ-44's SSH modes → reserved-modes `{}` JSON) | no-panic on arbitrary term strings, u32 dimensions, and mode blobs (the modes are never parsed — reserved); the dimension clamp is exactly the recorded cap (`min(value, 65535)`, never a magnitude truncation); `term` preserved verbatim; the reserved-modes JSON is always exactly `{}`; the full serialized wire shape is deterministic per input |
|
||||
|
||||
## Commands
|
||||
|
||||
`cargo fuzz build` and `cargo fuzz run` must be executed with `fuzz/`
|
||||
(or deeper) as the working directory so rustup selects the pinned
|
||||
nightly toolchain — the detached runner handles that itself.
|
||||
|
||||
```sh
|
||||
# build (nightly, pinned by rust-toolchain.toml inside fuzz/; run from fuzz/)
|
||||
cargo fuzz build
|
||||
|
||||
# agent sessions: detached campaign (never foreground; CWD-independent)
|
||||
FUZZ_RUNTIME_SECS=600 fuzz/run-detached.sh <target> [extra libfuzzer args...]
|
||||
|
||||
# corpus replay through the invariants (stable toolchain, no nightly)
|
||||
cargo test --manifest-path fuzz/shared/Cargo.toml
|
||||
|
||||
# coverage
|
||||
cargo fuzz coverage <target>
|
||||
```
|
||||
|
||||
The fuzz workspace is excluded from the main workspace (`exclude` in
|
||||
the root `Cargo.toml`); it pins its own nightly toolchain via
|
||||
`rust-toolchain.toml` and does not affect the root workspace's stable
|
||||
toolchain or MSRV.
|
||||
|
||||
Campaign cadence: the corpus gate (above) runs in every verification
|
||||
pass; the fuzzing sessions themselves run at the wave gate and before
|
||||
publish (`fuzz/run-detached.sh per target`, bounded runtime).
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack '/owner/demo'
|
||||
@@ -0,0 +1 @@
|
||||
git-receive-pack '/owner/demo'
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack /r
|
||||
@@ -0,0 +1 @@
|
||||
git-receive-pack /owner/demo.git
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack /owner/demo.git
|
||||
@@ -0,0 +1 @@
|
||||
git-send-pack '/r'
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-archive '/r'
|
||||
@@ -0,0 +1 @@
|
||||
git-send-pack
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack
|
||||
@@ -0,0 +1 @@
|
||||
git-receive-pack
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack ''
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack '/'
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack '/.git'
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack '/r.git.git'
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack '/a/b/c'
|
||||
@@ -0,0 +1 @@
|
||||
uptime
|
||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
||||
git-upload-packrel
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack rel
|
||||
@@ -0,0 +1 @@
|
||||
GIT-UPLOAD-PACK '/r'
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack$ '/r'
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack '/r'
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack'r'
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack '/r
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack '
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack '/é'
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack '/日本語'
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
git-upload-pack ' '
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack '''
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack '//r'
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack /r.git.git'
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack '/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||
@@ -0,0 +1 @@
|
||||
git-upload-pack /bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.git
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
git-upload-pack '/r'
|
||||
@@ -0,0 +1 @@
|
||||
AAAAC3NzaC1lZDI1NTE5AAAAIM28sP5UAl3npJJ8trZgPt2kE+Af6477/Rx6J2VbM14z alkssh-w1-fixture-alpha
|
||||
@@ -0,0 +1 @@
|
||||
AAAAC3NzaC1lZDI1NTE5AAAAIMvu9S+hH9Dug7fdiHLeW+B9kee+i495jPAdBH8YaOvQ alkssh-w1-fixture-bravo
|
||||
@@ -0,0 +1 @@
|
||||
AAAAC3NzaC1lZDI1NTE5AAAAIKL8zAzLkR0jKFY2l+QrBjC3Cv4Q3OBUUCX7rQV1jXQe alkssh-w1-fixture-charlie
|
||||
@@ -0,0 +1 @@
|
||||
AAAAC3NzaC1lZDI1NTE5AAAAICS7Ub4A073+G450Nt2DUOwWyniGVU/lrlUoQZAUg1sd
|
||||
@@ -0,0 +1 @@
|
||||
not a key
|
||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
||||
AAAAC3NzaC1lZDI1NTE5AAAAIM28
|
||||
@@ -0,0 +1 @@
|
||||
AAAAC3NzaC1lZDI1NTE5AAAAIM28sP5UAl3npJJ8trZgPt2kE+Af6477/Rx6J2VbM14ztrailing-garbage
|
||||
@@ -0,0 +1 @@
|
||||
ssh-rsa AAAAC3NzaC1lZDI1NTE5AAAAIM28sP5UAl3npJJ8trZgPt2kE+Af6477/Rx6J2VbM14z
|
||||
@@ -0,0 +1 @@
|
||||
爠睡戠瑹獥
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
AAAAC3NzaC1lZDI1NTE5AAAAIM28sP5UAl3npJJ8trZgPt2kE+Af6477/Rx6J2VbM14z
|
||||
@@ -0,0 +1 @@
|
||||
BAAAC3NzaC1lZDI1NTE5AAAAIM28sP5UAl3npJJ8trZgPt2kE+Af6477/Rx6J2VbM14z
|
||||
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACDNvLD+VAJd56SSfLa2YD7dpBPgH+uO+/0ceidlWzNeMwAAAKDJQe/myUHv
|
||||
5gAAAAtzc2gtZWQyNTUxOQAAACDNvLD+VAJd56SSfLa2YD7dpBPgH+uO+/0ceidlWzNeMw
|
||||
AAAEATzyiI7Cwpy3CJBDKDepykbHiNzVpZ38kiDqa1S1p5Us28sP5UAl3npJJ8trZgPt2k
|
||||
E+Af6477/Rx6J2VbM14zAAAAF2Fsa3NzaC13MS1maXh0dXJlLWFscGhhAQIDBAUG
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACDL7vUvoR/Q7oO33Yhy3lvgfZHnvouPeYzwHQR/GGjr0AAAAKAUKLAWFCiw
|
||||
FgAAAAtzc2gtZWQyNTUxOQAAACDL7vUvoR/Q7oO33Yhy3lvgfZHnvouPeYzwHQR/GGjr0A
|
||||
AAAEC9aPqnyC37OKCL/VksdonIl6ICy+I8X4So6TDmnSObEMvu9S+hH9Dug7fdiHLeW+B9
|
||||
kee+i495jPAdBH8YaOvQAAAAF2Fsa3NzaC13MS1maXh0dXJlLWJyYXZvAQIDBAUG
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -0,0 +1 @@
|
||||
this is not a key
|
||||
Whitespace-only changes.
@@ -0,0 +1,4 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACDNvLD+VAJd56SSfLa2YD7dpBPgH+uO+/0ceidlWzNeMwAAAKDJQe/myUHv
|
||||
5gAAAAtzc2gtZWQyNTUxOQAAACDNvLD
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
garbage
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -0,0 +1,2 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
AAAA
|
||||
@@ -0,0 +1 @@
|
||||
unicodeé日本語
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
PEM-adjacent-but-not
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
#![no_main]
|
||||
|
||||
//! The exec-grammar target (`mapping.md` §Session-channel dispatch —
|
||||
//! the git-parseable exec grammar + the dispatch parse path) — thin
|
||||
//! wrapper over the shared invariant battery.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
alkssh_fuzz_shared::exec_parse::fuzz_exec_parse(data);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
#![no_main]
|
||||
|
||||
//! The fingerprint-format target (ADR-004 decision 1 — the `SHA256:` +
|
||||
//! unpadded base64 path) — thin wrapper over the shared invariant
|
||||
//! battery.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
alkssh_fuzz_shared::fingerprint::fuzz_fingerprint(data);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
#![no_main]
|
||||
|
||||
//! The key-decode target (ADR-005 decision 2 — the decrypt-at-use
|
||||
//! chain's `decode_secret_key` seam) — thin wrapper over the shared
|
||||
//! invariant battery.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
alkssh_fuzz_shared::key_decode::fuzz_key_decode(data);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
#![no_main]
|
||||
|
||||
//! The terminal-parameters target (`tty_map.rs` — the `pty_request`
|
||||
//! args → `TerminalParamsWire` conversion, the SSH modes → reserved
|
||||
//! modes JSON) — thin wrapper over the shared invariant battery.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
alkssh_fuzz_shared::terminal_params::fuzz_terminal_params(data);
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate the committed seed corpora for alkssh's fuzz targets.
|
||||
|
||||
Writes into fuzz/corpus/<target>/. Deterministic: fixed inputs only, no
|
||||
randomness (the fixture-backed reads are of the committed files).
|
||||
Run from the repo root:
|
||||
|
||||
python3 fuzz/gen_fuzz_seeds.py
|
||||
|
||||
Target seed functions and the main() registry land here with the
|
||||
target tasks; the shared seed_name()/write_seed() plumbing is stable
|
||||
(the alkblobs family pattern). Only `seed-*` files are tracked; grown
|
||||
corpora (SHA-named units), artifacts, and coverage output are
|
||||
gitignored and are never merged back into the committed seeds.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
ALPHA_PUB_BLOB = (
|
||||
"AAAAC3NzaC1lZDI1NTE5AAAAIM28sP5UAl3npJJ8trZgPt2kE+Af6477/Rx6J2VbM14z"
|
||||
)
|
||||
BRAVO_PUB_BLOB = (
|
||||
"AAAAC3NzaC1lZDI1NTE5AAAAIMvu9S+hH9Dug7fdiHLeW+B9kee+i495jPAdBH8YaOvQ"
|
||||
)
|
||||
CHARLIE_PUB_BLOB = (
|
||||
"AAAAC3NzaC1lZDI1NTE5AAAAIKL8zAzLkR0jKFY2l+QrBjC3Cv4Q3OBUUCX7rQV1jXQe"
|
||||
)
|
||||
|
||||
|
||||
def seed_name(target, i):
|
||||
return os.path.join("fuzz", "corpus", target, f"seed-{i:03d}")
|
||||
|
||||
|
||||
def write_seed(target, i, data):
|
||||
path = seed_name(target, i)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
|
||||
def read_bytes(path):
|
||||
with open(path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- exec_parse
|
||||
|
||||
|
||||
def exec_parse_seeds():
|
||||
return [
|
||||
# the vanilla wire shapes (the grammar's serving services)
|
||||
b"git-upload-pack '/owner/demo'",
|
||||
b"git-receive-pack '/owner/demo'",
|
||||
b"git-upload-pack /r",
|
||||
b"git-receive-pack /owner/demo.git",
|
||||
# unquoted absolute with the .git idiom stripped
|
||||
b"git-upload-pack /owner/demo.git",
|
||||
# the decline-with-reason class (known non-serving services)
|
||||
b"git-send-pack '/r'",
|
||||
b"git-upload-archive '/r'",
|
||||
b"git-send-pack",
|
||||
# service-only (SSH exec must carry the repo)
|
||||
b"git-upload-pack",
|
||||
b"git-receive-pack ",
|
||||
# the normalization's edge shapes
|
||||
b"git-upload-pack ''",
|
||||
b"git-upload-pack '/'",
|
||||
b"git-upload-pack '/.git'",
|
||||
b"git-upload-pack '/r.git.git'",
|
||||
b"git-upload-pack '/a/b/c'",
|
||||
# the pipe row (the probe's pinned non-git verdicts)
|
||||
b"uptime",
|
||||
b"",
|
||||
b"git-upload-packrel",
|
||||
b"git-upload-pack rel",
|
||||
b"GIT-UPLOAD-PACK '/r'",
|
||||
b"git-upload-pack$ '/r'",
|
||||
b"git-upload-pack '/r'",
|
||||
b"git-upload-pack'r'",
|
||||
b"git-upload-pack '/r",
|
||||
b"git-upload-pack '",
|
||||
# hostile shapes: unicode, control bytes, interior nulls, quotes
|
||||
"git-upload-pack '/\u00e9'".encode(),
|
||||
"git-upload-pack '/\u65e5\u672c\u8a9e'".encode(),
|
||||
b"git-upload-pack '/\x00\x01'",
|
||||
b"git-upload-pack ' '",
|
||||
b"git-upload-pack '''",
|
||||
b"git-upload-pack '//r'",
|
||||
b"git-upload-pack /r.git.git'",
|
||||
# hostile lengths: the grammar rides any length behind the cap
|
||||
b"git-upload-pack '/" + b"a" * 253 + b"'",
|
||||
b"git-upload-pack /" + b"b" * 99 + b".git",
|
||||
b"\x00git-upload-pack '/r'",
|
||||
b"git-upload-pack\t'/r'",
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------- fingerprint
|
||||
|
||||
|
||||
def fingerprint_seeds():
|
||||
valid = (
|
||||
"AAAAC3NzaC1lZDI1NTE5AAAAICS7Ub4A073+G450Nt2DUOwWyniGVU/lrlUoQZAUg1sd"
|
||||
)
|
||||
return [
|
||||
# the committed fixture blobs (the parity-vector keys)
|
||||
(ALPHA_PUB_BLOB + " alkssh-w1-fixture-alpha").encode(),
|
||||
(BRAVO_PUB_BLOB + " alkssh-w1-fixture-bravo").encode(),
|
||||
(CHARLIE_PUB_BLOB + " alkssh-w1-fixture-charlie").encode(),
|
||||
# the ssh-keygen README example blob
|
||||
valid.encode(),
|
||||
# hostile shapes: non-blobs and near-blobs
|
||||
b"not a key",
|
||||
b"",
|
||||
b"AAAAC3NzaC1lZDI1NTE5AAAAIM28", # truncated valid prefix
|
||||
ALPHA_PUB_BLOB.encode() + b"trailing-garbage",
|
||||
b"ssh-rsa " + ALPHA_PUB_BLOB.encode(), # wrong type prefix
|
||||
b"\xff\xfe raw bytes",
|
||||
# whitespace-padded valid blob (the trim normalization)
|
||||
(" \n" + ALPHA_PUB_BLOB + "\n").encode(),
|
||||
# a valid blob with one flipped base64 char
|
||||
(("B" if ALPHA_PUB_BLOB.startswith("A") else "A").encode() + ALPHA_PUB_BLOB.encode()[1:]),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- key_decode
|
||||
|
||||
|
||||
def key_decode_seeds():
|
||||
alpha = read_bytes(os.path.join("tests", "vectors", "alpha.key"))
|
||||
bravo = read_bytes(os.path.join("tests", "vectors", "bravo.key"))
|
||||
return [
|
||||
# the committed fixture PEMs decode (the production arm's success row)
|
||||
alpha,
|
||||
bravo,
|
||||
# hostile plaintext shapes: every Decode-class refusal
|
||||
b"this is not a key",
|
||||
b"",
|
||||
alpha[: len(alpha) // 2], # truncated PEM
|
||||
alpha + b"\x00trailing", # valid PEM with trailing garbage
|
||||
b"-----BEGIN OPENSSH PRIVATE KEY-----\ngarbage\n-----END OPENSSH PRIVATE KEY-----\n",
|
||||
b"-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n",
|
||||
"unicode\u00e9\u65e5\u672c\u8a9e".encode(),
|
||||
b"\x00\x01\x02\x03\x04",
|
||||
b"PEM-adjacent-but-not", # near-shape garbage
|
||||
]
|
||||
|
||||
|
||||
# ------------------------------------------------------------ terminal_params
|
||||
|
||||
|
||||
def framed(term, cols, rows, pix_w=0, pix_h=0, mode_blob=b""):
|
||||
data = len(term).to_bytes(4, "little") + term
|
||||
data += cols.to_bytes(4, "little")
|
||||
data += rows.to_bytes(4, "little")
|
||||
data += pix_w.to_bytes(4, "little")
|
||||
data += pix_h.to_bytes(4, "little")
|
||||
return data + mode_blob
|
||||
|
||||
|
||||
def terminal_params_seeds():
|
||||
return [
|
||||
# the vanilla shape (an 80x24 xterm, no pixels, no modes)
|
||||
framed(b"xterm-256color", 80, 24, 0, 0),
|
||||
# the clamp's edge: exactly u16::MAX, then a hostile dim beyond it
|
||||
framed(b"w", 65535, 65536, 2**32 - 1, 70000),
|
||||
# overflow shapes: u32 maxima everywhere
|
||||
framed(b"vt100", 2**32 - 1, 2**32 - 1, 2**32 - 1, 2**32 - 1),
|
||||
# hostile term strings: empty, unicode, control bytes
|
||||
framed("\u00e9\u65e5\u672c".encode(), 1, 1, 0, 1),
|
||||
framed(b"\x00\x01\x07\x1b", 5, 5, 5, 5),
|
||||
# zero-defaulted dims (the feed's totality over short inputs)
|
||||
b"\x00",
|
||||
b"\x02\x00\x00\x00vt",
|
||||
# arbitrary mode blobs after the framed fields (never parsed)
|
||||
framed(b"xterm", 80, 24, 128, 48, b"\x53\x01\x00\x00\x00\x40"),
|
||||
framed(b"xterm", 80, 24, 0, 0, b"\xff" * 32),
|
||||
# truncated feeds
|
||||
framed(b"xterm-256color", 80, 24),
|
||||
]
|
||||
|
||||
|
||||
TARGETS = [
|
||||
("exec_parse", exec_parse_seeds),
|
||||
("fingerprint", fingerprint_seeds),
|
||||
("key_decode", key_decode_seeds),
|
||||
("terminal_params", terminal_params_seeds),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
for target, generate in TARGETS:
|
||||
for i, data in enumerate(generate()):
|
||||
write_seed(target, i, data)
|
||||
print("committed corpora regenerated (deterministic; expect no-op diff)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Detached fuzzing runner for agent sessions: the fuzz campaign never
|
||||
# runs as a foreground child of the session (OOM in a target must not
|
||||
# take down the agent host), and survives the session ending.
|
||||
#
|
||||
# Usage: fuzz/run-detached.sh <target> [extra libfuzzer args...]
|
||||
# (works from the repo root or from fuzz/; CWD-independent)
|
||||
# FUZZ_RUNTIME_SECS overrides the per-campaign budget (default 600 s).
|
||||
#
|
||||
# Poll instead of waiting:
|
||||
# tail -n 50 fuzz/artifacts/<target>-*.log
|
||||
# ls fuzz/artifacts/<target>/ (crash-* / oom-* / timeout-* files)
|
||||
# pgrep -f "cargo fuzz run <target>"
|
||||
set -euo pipefail
|
||||
target="${1:?usage: run-detached.sh <target> [extra libfuzzer args...]}"
|
||||
shift
|
||||
|
||||
root="$(git rev-parse --show-toplevel)"
|
||||
fuzz_dir="$root/fuzz"
|
||||
mkdir -p "$fuzz_dir/artifacts"
|
||||
runtime="${FUZZ_RUNTIME_SECS:-600}"
|
||||
log="$fuzz_dir/artifacts/${target}-$(date -u +%Y%m%d-%H%M%S).log"
|
||||
|
||||
cd "$fuzz_dir"
|
||||
setsid nohup cargo fuzz run "$target" -- \
|
||||
-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25 \
|
||||
-max_total_time="$runtime" "$@" \
|
||||
>"$log" 2>&1 < /dev/null &
|
||||
echo "pid=$! log=$log"
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
components = ["llvm-tools-preview"]
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "alkssh-fuzz-shared"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
alkssh = { path = "../..", features = ["session"] }
|
||||
alkgit = "0.1"
|
||||
russh = { version = "0.65", default-features = false, features = ["aws-lc-rs"] }
|
||||
serde_json = "1"
|
||||
zeroize = "1.9"
|
||||
@@ -0,0 +1,54 @@
|
||||
//! The corpus-replay plumbing: the committed seeds replay through the
|
||||
//! invariant batteries on the stable toolchain (`cargo test
|
||||
//! --manifest-path fuzz/shared/Cargo.toml` runs this in
|
||||
//! `#[cfg(test)]` modules across the ingress units — the quinn CI
|
||||
//! pattern), so the gate never needs a fuzzing session.
|
||||
|
||||
pub fn read_corpus(target: &str) -> Vec<Vec<u8>> {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../corpus")
|
||||
.join(target);
|
||||
let mut seeds = Vec::new();
|
||||
let entries = std::fs::read_dir(&dir).unwrap_or_else(|error| {
|
||||
panic!("the committed corpus for {target} is present ({dir:?}): {error}")
|
||||
});
|
||||
for entry in entries {
|
||||
let path = entry.expect("corpus entry readable").path();
|
||||
let data = std::fs::read(&path).expect("corpus entry readable");
|
||||
seeds.push(data);
|
||||
}
|
||||
seeds.sort();
|
||||
seeds
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! corpus_replay {
|
||||
($target:literal, $battery:path, $min:literal) => {
|
||||
#[cfg(test)]
|
||||
mod corpus_replay {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn committed_corpus_replays_through_the_invariants() {
|
||||
let seeds = $crate::corpus::read_corpus($target);
|
||||
assert!(
|
||||
seeds.len() >= $min,
|
||||
"committed seed corpus is present, found {}",
|
||||
seeds.len()
|
||||
);
|
||||
for data in &seeds {
|
||||
$battery(data);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_unit_inputs_replay() {
|
||||
$battery(b"");
|
||||
$battery(b"hello");
|
||||
$battery(b"\x00\x00\x00\x00");
|
||||
$battery(b"\xff\xfe raw bytes");
|
||||
$battery(b"a");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! The exec-grammar ingress (`mapping.md` §Session-channel dispatch,
|
||||
//! `dispatch.rs`): the production `parse_exec` path driven on arbitrary
|
||||
//! command strings — no-panic, typed routing classes, and agreement
|
||||
//! with a from-spec re-derivation of the grammar. The production
|
||||
//! ingress shape is the handler's: the per-length cap
|
||||
//! (`Limits::max_exec_command_len`) is enforced *before* the parse
|
||||
//! (decline first, alloc never — the cap's pre-parse placement is
|
||||
//! pinned by the root's contract rows in `tests/dispatch_core.rs`);
|
||||
//! everything behind the cap is this battery: a byte string →
|
||||
//! `std::str::from_utf8(..).unwrap_or("")` → the parse (invalid UTF-8
|
||||
//! is the pipe row, the wave-2 rule).
|
||||
|
||||
use alkssh::{ExecDecline, ExecRoute, GitServiceRoute, parse_exec};
|
||||
|
||||
/// The battery: no-panic on any byte string; the verdict agrees with
|
||||
/// the from-spec oracle ([`spec_parse`]); the verdict (and the git
|
||||
/// row's repo-id gate classification for `Git` routes) is stable per
|
||||
/// input; every `Git` route carries one of the two serving services
|
||||
/// and a normalized argument.
|
||||
pub fn fuzz_exec_parse(data: &[u8]) {
|
||||
let command = std::str::from_utf8(data).unwrap_or("");
|
||||
let route = parse_exec(command);
|
||||
|
||||
assert_eq!(
|
||||
route,
|
||||
spec_parse(command),
|
||||
"the parse disagrees with the from-spec oracle: {command:?} → {route:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_exec(command),
|
||||
route,
|
||||
"the parse verdict is not deterministic: {command:?}"
|
||||
);
|
||||
|
||||
if let ExecRoute::Git { repo, .. } = &route {
|
||||
assert_eq!(
|
||||
alkgit::validate_repo_id(repo),
|
||||
alkgit::validate_repo_id(repo),
|
||||
"the git row's repo-id gate is not deterministic: {command:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The from-spec oracle: the grammar re-derived from the dispatch
|
||||
/// module's documented rules, sharing no code with the
|
||||
/// implementation — the differential pair (the repo_id target's
|
||||
/// oracle pattern).
|
||||
fn spec_parse(command: &str) -> ExecRoute {
|
||||
let (token, argument) = match command.split_once(' ') {
|
||||
Some((token, argument)) => (token, Some(argument)),
|
||||
None => (command, None),
|
||||
};
|
||||
match token {
|
||||
"git-upload-pack" => spec_serving(GitServiceRoute::UploadPack, argument),
|
||||
"git-receive-pack" => spec_serving(GitServiceRoute::ReceivePack, argument),
|
||||
"git-send-pack" => ExecRoute::Declined(ExecDecline::UnknownService {
|
||||
service: "git-send-pack",
|
||||
}),
|
||||
"git-upload-archive" => ExecRoute::Declined(ExecDecline::UnknownService {
|
||||
service: "git-upload-archive",
|
||||
}),
|
||||
_ => ExecRoute::Pipe,
|
||||
}
|
||||
}
|
||||
|
||||
/// The serving-service arm of the oracle: a serving token must carry
|
||||
/// a non-empty, path-shaped argument (SSH exec must carry the repo —
|
||||
/// service-only is the decline-with-reason class; anything else
|
||||
/// unparseable is the pipe row).
|
||||
fn spec_serving(service: GitServiceRoute, argument: Option<&str>) -> ExecRoute {
|
||||
match argument {
|
||||
None => ExecRoute::Declined(ExecDecline::ServiceOnly),
|
||||
Some(argument) if argument.is_empty() => ExecRoute::Declined(ExecDecline::ServiceOnly),
|
||||
Some(argument) => match spec_normalize(argument) {
|
||||
Some(repo) => ExecRoute::Git { service, repo },
|
||||
None => ExecRoute::Pipe,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The argument grammar re-derived: a path-shaped argument — fully
|
||||
/// single-quote-wrapped (a non-empty inner ending in a quote) or
|
||||
/// unquoted absolute — normalized by stripping the quotes, one
|
||||
/// leading `/`, and one trailing `.git` suffix (an empty-after-strip
|
||||
/// prefix keeps the suffix; the git row's validation declines the
|
||||
/// empties, never a silent emptying).
|
||||
fn spec_normalize(argument: &str) -> Option<String> {
|
||||
let stripped = if let Some(inner) = argument.strip_prefix('\'') {
|
||||
if inner.is_empty() {
|
||||
return None;
|
||||
}
|
||||
inner.strip_suffix('\'')?
|
||||
} else if argument.starts_with('/') {
|
||||
argument
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let without_slash = stripped.strip_prefix('/').unwrap_or(stripped);
|
||||
Some(match without_slash.strip_suffix(".git") {
|
||||
Some(prefix) if !prefix.is_empty() => prefix.to_owned(),
|
||||
_ => without_slash.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
crate::corpus_replay!("exec_parse", fuzz_exec_parse, 12);
|
||||
@@ -0,0 +1,152 @@
|
||||
//! The fingerprint format ingress (ADR-004 decision 1,
|
||||
//! `fingerprint.rs`): arbitrary key blobs through the crate's single
|
||||
//! fingerprint path — the composition of russh's public OpenSSH blob
|
||||
//! parse (`russh::keys::parse_public_key_base64`, the same re-export
|
||||
//! the crate's surface rides) and the crate's public
|
||||
//! [`alkssh::fingerprint`]. The ingress is fully public API — the
|
||||
//! recorded exposure decision needs no `#[doc(hidden)]` surface.
|
||||
//!
|
||||
//! Invariants: no-panic on any byte string (a non-blob is the parse's
|
||||
//! typed refusal, never a panic and never a partial success); every
|
||||
//! accepted key's fingerprint is exactly the `SHA256:` + unpadded
|
||||
//! standard base64 contract shape (one `:` separator, 43-char body,
|
||||
//! canonical alphabet, 32-byte digest decoded exactly); determinism
|
||||
//! (same key, same string — and the same string re-parses to the same
|
||||
//! verdict). The committed `ssh-keygen -lf` parity vectors
|
||||
//! (`tests/vectors/`) are replayed by the corpus-gate tests below.
|
||||
|
||||
use alkssh::fingerprint;
|
||||
use russh::keys::parse_public_key_base64;
|
||||
|
||||
/// The battery.
|
||||
pub fn fuzz_fingerprint(data: &[u8]) {
|
||||
let text = String::from_utf8_lossy(data);
|
||||
let trimmed = text.trim();
|
||||
let first = parse_public_key_base64(trimmed);
|
||||
let second = parse_public_key_base64(trimmed);
|
||||
let verdict = match (first, second) {
|
||||
(Ok(key), Ok(other)) => {
|
||||
assert_eq!(
|
||||
fingerprint(&key),
|
||||
fingerprint(&other),
|
||||
"the blob parse verdict is not deterministic: {trimmed:?}"
|
||||
);
|
||||
key
|
||||
}
|
||||
(Ok(_), Err(_)) | (Err(_), Ok(_)) => {
|
||||
panic!("the blob parse verdict is not deterministic: {trimmed:?}")
|
||||
}
|
||||
(Err(_), Err(_)) => return,
|
||||
};
|
||||
let key = verdict;
|
||||
let fp = fingerprint(&key);
|
||||
assert_eq!(
|
||||
fingerprint(&key),
|
||||
fp,
|
||||
"the fingerprint is not deterministic: {fp}"
|
||||
);
|
||||
|
||||
let Some((prefix, body)) = fp.split_once(':') else {
|
||||
panic!("the fingerprint carries no separator: {fp}");
|
||||
};
|
||||
assert_eq!(prefix, "SHA256", "the fingerprint prefix: {fp}");
|
||||
assert_eq!(
|
||||
body.len(),
|
||||
43,
|
||||
"the unpadded base64 of a 32-byte digest is 43 chars: {fp}"
|
||||
);
|
||||
let decoded = decode_unpadded(body)
|
||||
.unwrap_or_else(|| panic!("the fingerprint body is not standard base64: {fp}"));
|
||||
assert_eq!(
|
||||
decoded.len(),
|
||||
32,
|
||||
"the SHA-256 digest body decodes to 32 bytes: {fp}"
|
||||
);
|
||||
assert_eq!(
|
||||
encode_unpadded(&decoded),
|
||||
body,
|
||||
"the fingerprint body is not the canonical unpadded encoding: {fp}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A from-spec unpadded standard-base64 decoder (RFC 4648, no
|
||||
/// padding): the round-trip half of the format invariant, sharing no
|
||||
/// code with the implementors.
|
||||
fn decode_unpadded(body: &str) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let mut acc: u32 = 0;
|
||||
let mut bits = 0usize;
|
||||
for character in body.chars() {
|
||||
let value = match character {
|
||||
'A'..='Z' => (character as u32) - ('A' as u32),
|
||||
'a'..='z' => (character as u32) - ('a' as u32) + 26,
|
||||
'0'..='9' => (character as u32) - ('0' as u32) + 52,
|
||||
'+' => 62,
|
||||
'/' => 63,
|
||||
_ => return None,
|
||||
};
|
||||
acc = (acc << 6) | value;
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push(((acc >> bits) & 0xff) as u8);
|
||||
}
|
||||
}
|
||||
if acc & ((1 << bits) - 1) != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A from-spec unpadded standard-base64 encoder — the re-encode half.
|
||||
fn encode_unpadded(bytes: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = Vec::new();
|
||||
let mut acc: u32 = 0;
|
||||
let mut bits = 0usize;
|
||||
for byte in bytes {
|
||||
acc = (acc << 8) | u32::from(*byte);
|
||||
bits += 8;
|
||||
while bits >= 6 {
|
||||
bits -= 6;
|
||||
out.push(ALPHABET[((acc >> bits) & 0x3f) as usize]);
|
||||
}
|
||||
}
|
||||
if bits > 0 {
|
||||
out.push(ALPHABET[((acc << (6 - bits)) & 0x3f) as usize]);
|
||||
}
|
||||
String::from_utf8(out).unwrap_or_default()
|
||||
}
|
||||
|
||||
crate::corpus_replay!("fingerprint", fuzz_fingerprint, 8);
|
||||
|
||||
#[cfg(test)]
|
||||
mod parity {
|
||||
use super::*;
|
||||
|
||||
/// The committed `ssh-keygen -lf` vectors still agree: every
|
||||
/// fixture's computed fingerprint is byte-verbatim the recorded
|
||||
/// independent-issuer value.
|
||||
#[test]
|
||||
fn the_committed_parity_vectors_still_agree() {
|
||||
let manifest = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../tests/vectors/fingerprints.txt"))
|
||||
.expect("the parity manifest is readable");
|
||||
for name in ["alpha", "bravo", "charlie", "delta", "ca"] {
|
||||
let line = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join(format!("../../tests/vectors/{name}.pub")))
|
||||
.unwrap_or_else(|error| panic!("the {name}.pub fixture is readable: {error}"));
|
||||
let blob = line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| panic!("{name}.pub carries a base64 blob"));
|
||||
let key = parse_public_key_base64(blob)
|
||||
.unwrap_or_else(|error| panic!("{name}.pub parses: {error}"));
|
||||
let expected = format!("{name}.pub {}", fingerprint(&key));
|
||||
assert!(
|
||||
manifest.contains(&expected),
|
||||
"{name}'s computed fingerprint is not the recorded ssh-keygen value"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! The key-decode ingress (ADR-005 decision 2, `key_source.rs`): the
|
||||
//! decrypt-at-use chain's one-time `decode_secret_key` conversion —
|
||||
//! driven through the production arm itself
|
||||
//! ([`alkssh::decode_key_arm`], the `#[doc(hidden)]` exposure that is
|
||||
//! `decrypt_and_decode`'s exact decode stage, not a fork).
|
||||
//!
|
||||
//! Invariants: no-panic on arbitrary plaintext shapes (the production
|
||||
//! plaintext is the vault's `String` — valid UTF-8 by return type; the
|
||||
//! battery shapes arbitrary bytes into that form via the lossy
|
||||
//! conversion and drives it through the same `Zeroizing` wrap the
|
||||
//! adapter seam applies) and arbitrary passphrase shapes (the
|
||||
//! underlying decode's passphrase arm — the production chain always
|
||||
//! passes `None`; the `Some` arm is robustness drive only); every
|
||||
//! failure is the typed [`KeySourceError::Decode`] class — never a
|
||||
//! catch-all, never another variant on this arm, never a panic; the
|
||||
//! failure text never carries the plaintext (the assertable half of
|
||||
//! the Zeroizing posture — the plaintext exists solely inside the
|
||||
//! call, dropped zeroized; no retention, no static cache — and the
|
||||
//! typed error's only payload is the decode machinery's message).
|
||||
//!
|
||||
//! The no-echo invariant's assertable form is *the bounded-message
|
||||
//! pair* (reshaped in-task after the first campaign's finding): the
|
||||
//! failure text is a bounded fixed message (≤ 256 bytes — the decode
|
||||
//! machinery's vocabulary, never a growable echo), and no 256-byte
|
||||
//! contiguous plaintext run appears in it — which, given the bound,
|
||||
//! cannot false-fire (a string shorter than 256 bytes cannot contain a
|
||||
//! 256-byte run). A plaintext-`contains`-message check in the other
|
||||
//! direction was unsound: an attacker-chosen plaintext can be a
|
||||
//! substring of the fixed text (the campaign's own first crash:
|
||||
//! plaintext `"Could not read key"`).
|
||||
|
||||
use alkssh::{KeySourceError, decode_key_arm};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// The battery.
|
||||
pub fn fuzz_key_decode(data: &[u8]) {
|
||||
let plaintext = Zeroizing::new(String::from_utf8_lossy(data).into_owned());
|
||||
|
||||
let first = decode_key_arm(&plaintext);
|
||||
let second = decode_key_arm(&plaintext);
|
||||
typed_decode_class(&first, &plaintext);
|
||||
match (&first, &second) {
|
||||
(Ok(key), Ok(other)) => {
|
||||
assert_eq!(
|
||||
fingerprint(&key),
|
||||
fingerprint(other),
|
||||
"the decode arm is not deterministic"
|
||||
);
|
||||
}
|
||||
(Err(a), Err(b)) => assert_eq!(
|
||||
a.to_string(),
|
||||
b.to_string(),
|
||||
"the decode arm's failure is not deterministic: {a} vs {b}"
|
||||
),
|
||||
(Ok(_), Err(_)) | (Err(_), Ok(_)) => {
|
||||
panic!("the decode arm is not deterministic: {first:?} vs {second:?}")
|
||||
}
|
||||
}
|
||||
|
||||
let mid = data.len() / 2;
|
||||
let pass = String::from_utf8_lossy(&data[mid..]);
|
||||
let _ = russh::keys::decode_secret_key(&plaintext, Some(&pass));
|
||||
let _ = russh::keys::decode_secret_key(&plaintext, Some(&String::from_utf8_lossy(data)));
|
||||
}
|
||||
|
||||
fn fingerprint(key: &russh::keys::PrivateKey) -> String {
|
||||
key.public_key().fingerprint(russh::keys::HashAlg::Sha256).to_string()
|
||||
}
|
||||
|
||||
/// The typed-class invariant: `Ok` or exactly
|
||||
/// [`KeySourceError::Decode`] — the decode arm's only failure shape —
|
||||
/// and the Decode text is the bounded fixed-message pair (see the
|
||||
/// module docs; the two assertions are jointly airtight).
|
||||
fn typed_decode_class(outcome: &Result<russh::keys::PrivateKey, KeySourceError>, plaintext: &str) {
|
||||
match outcome {
|
||||
Ok(_) => {}
|
||||
Err(KeySourceError::Decode(message)) => {
|
||||
assert!(
|
||||
message.len() <= 256,
|
||||
"the decode failure text is not a bounded fixed message: {message:?}"
|
||||
);
|
||||
let target = message.as_bytes();
|
||||
if plaintext.len() >= 256 && target.len() >= 256 {
|
||||
let bytes = plaintext.as_bytes();
|
||||
let tail_start = bytes.len() - 256;
|
||||
for start in [0, (bytes.len() / 4).min(tail_start), tail_start] {
|
||||
let run = &bytes[start..start + 256];
|
||||
assert!(
|
||||
target.windows(256).all(|window| window != run),
|
||||
"the typed decode failure carried plaintext material: {message:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(other) => panic!("the decode arm produced a non-decode class: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
crate::corpus_replay!("key_decode", fuzz_key_decode, 8);
|
||||
|
||||
#[cfg(test)]
|
||||
mod invariants {
|
||||
use super::*;
|
||||
|
||||
/// The production arm against a real fixture: a committed OpenSSH
|
||||
/// PEM plaintext decodes to the same key the direct decode gives
|
||||
/// (the ADR-005 one-time conversion, exercised through the
|
||||
/// exposure).
|
||||
#[test]
|
||||
fn the_production_arm_decodes_a_fixture_pem() {
|
||||
let pem = std::fs::read_to_string(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../tests/vectors/alpha.key"),
|
||||
)
|
||||
.expect("the alpha fixture is readable");
|
||||
let loaded = decode_key_arm(&Zeroizing::new(pem.clone()))
|
||||
.unwrap_or_else(|error| panic!("the fixture PEM decodes: {error}"));
|
||||
let direct = russh::keys::decode_secret_key(&pem, None)
|
||||
.unwrap_or_else(|error| panic!("the direct decode works: {error}"));
|
||||
assert_eq!(fingerprint(&loaded), fingerprint(&direct));
|
||||
}
|
||||
|
||||
/// A plaintext that is not OpenSSH-PEM key material is a
|
||||
/// [`KeySourceError::Decode`] refusal (the inner-format contract —
|
||||
/// decision 1, never a fallback).
|
||||
#[test]
|
||||
fn non_key_plaintext_is_the_typed_decode_refusal() {
|
||||
let outcome = decode_key_arm(&Zeroizing::new("this is not a key".to_owned()));
|
||||
assert!(matches!(outcome, Err(KeySourceError::Decode(_))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Invariant logic shared by alkssh's fuzz targets. Kept out of the
|
||||
//! fuzz-target binaries so the corpus replay unit tests can exercise the
|
||||
//! same invariant checks against every committed corpus entry (the quinn
|
||||
//! CI pattern) without a nightly toolchain.
|
||||
|
||||
pub mod corpus;
|
||||
pub mod exec_parse;
|
||||
pub mod fingerprint;
|
||||
pub mod key_decode;
|
||||
pub mod terminal_params;
|
||||
@@ -0,0 +1,115 @@
|
||||
//! The terminal-parameters ingress (`tty_map.rs`, OQ-44's reserved
|
||||
//! modes): the `pty_request` args → `TerminalParamsWire` conversion
|
||||
//! driven through the production [terminal_params] surface (the
|
||||
//! `#[doc(hidden)]` exposure — `handler.rs`'s `pty_request` row
|
||||
//! constructs the stored params with exactly this function).
|
||||
//!
|
||||
//! The harness's framed feed (deterministic, seed-friendly):
|
||||
//! `term_len` (u32 LE, bounded to the remaining input) + the term
|
||||
//! bytes + four u32 LE dimensions (`cols`/`rows`/pixel width/height,
|
||||
//! zero-defaulted when the input runs out) + an arbitrary trailing
|
||||
//! mode blob. The mode blob is *never parsed* — the SSH ttymodes ride
|
||||
//! the reserved `{}` JSON (OQ-44, backends ignore modes in v1) — so
|
||||
//! the battery's totality over arbitrary mode blobs is by
|
||||
//! construction; the framing demonstrates it (the conversion's
|
||||
//! outcome never varies with the blob).
|
||||
//!
|
||||
//! Invariants: no-panic on any term string (empty, unicode, control
|
||||
//! characters) and any u32 dimensions; the dimension clamp is the
|
||||
//! recorded cap (`min(value, 65535)` — a dimension beyond 65535 is
|
||||
//! nonsense on both papers) computed exactly, never a magnitude
|
||||
//! truncation; `term` is preserved verbatim; the reserved-modes JSON
|
||||
//! is always exactly the empty object; the full wire shape is
|
||||
//! deterministic per input (the serialized form compared).
|
||||
|
||||
use alkssh::terminal_params;
|
||||
|
||||
/// The battery.
|
||||
pub fn fuzz_terminal_params(data: &[u8]) {
|
||||
let mut cursor = data;
|
||||
let term_len = take_u32(&mut cursor) as usize;
|
||||
let term_len = term_len.min(cursor.len());
|
||||
let (term_bytes, rest) = cursor.split_at(term_len);
|
||||
cursor = rest;
|
||||
let term = String::from_utf8_lossy(term_bytes);
|
||||
let cols = take_u32(&mut cursor);
|
||||
let rows = take_u32(&mut cursor);
|
||||
let pixel_width = take_u32(&mut cursor);
|
||||
let pixel_height = take_u32(&mut cursor);
|
||||
let _mode_blob = cursor;
|
||||
|
||||
let params = terminal_params(&term, cols, rows, pixel_width, pixel_height);
|
||||
assert_eq!(
|
||||
¶ms.term,
|
||||
&Some(term.to_string()),
|
||||
"the conversion did not preserve `term` verbatim: {term:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
params.cols,
|
||||
spec_clamp(cols),
|
||||
"the cols clamp is not the recorded cap: {cols}"
|
||||
);
|
||||
assert_eq!(
|
||||
params.rows,
|
||||
spec_clamp(rows),
|
||||
"the rows clamp is not the recorded cap: {rows}"
|
||||
);
|
||||
assert_eq!(
|
||||
params.pixel_width,
|
||||
spec_clamp(pixel_width),
|
||||
"the pixel-width clamp is not the recorded cap: {pixel_width}"
|
||||
);
|
||||
assert_eq!(
|
||||
params.pixel_height,
|
||||
spec_clamp(pixel_height),
|
||||
"the pixel-height clamp is not the recorded cap: {pixel_height}"
|
||||
);
|
||||
assert!(
|
||||
params.modes.is_object()
|
||||
&& params
|
||||
.modes
|
||||
.as_object()
|
||||
.is_some_and(|map| map.is_empty()),
|
||||
"the reserved modes JSON is not the empty object: {:?}",
|
||||
params.modes
|
||||
);
|
||||
match serde_json::to_string(¶ms.modes) {
|
||||
Ok(text) => assert!(text == "{}", "the reserved modes JSON shape: {text}"),
|
||||
Err(error) => panic!("the reserved modes JSON never fails to serialize: {error}"),
|
||||
}
|
||||
|
||||
let shape = serde_json::to_string(¶ms);
|
||||
let replayed = terminal_params(&term, cols, rows, pixel_width, pixel_height);
|
||||
match (shape, serde_json::to_string(&replayed)) {
|
||||
(Ok(first), Ok(second)) => assert_eq!(
|
||||
first, second,
|
||||
"the conversion's wire shape is not deterministic: {term:?} {cols}×{rows}"
|
||||
),
|
||||
_ => panic!("the params JSON shape never fails to serialize"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor's u32 (zero-defaulted when the framed field is absent —
|
||||
/// the feed is total over any input).
|
||||
fn take_u32(cursor: &mut &[u8]) -> u32 {
|
||||
if cursor.len() >= 4 {
|
||||
let (bytes, rest) = cursor.split_at(4);
|
||||
*cursor = rest;
|
||||
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
|
||||
} else {
|
||||
*cursor = &[];
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// The recorded clamp, from the spec text (`tty_map.rs`: "u32 wire
|
||||
/// dims → the wire's u16 … the cap, not a truncation of useful data").
|
||||
fn spec_clamp(value: u32) -> u16 {
|
||||
if value > (u16::MAX as u32) {
|
||||
u16::MAX
|
||||
} else {
|
||||
value as u16
|
||||
}
|
||||
}
|
||||
|
||||
crate::corpus_replay!("terminal_params", fuzz_terminal_params, 8);
|
||||
+7
-7
@@ -79,11 +79,11 @@ use russh::ChannelOpenFailure;
|
||||
/// skeleton probe's `ExecRoute::Git`/`Pipe` split, extended by the
|
||||
/// `w3-git-mapping` grammar with the declined-with-reason class).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum ExecRoute {
|
||||
pub enum ExecRoute {
|
||||
/// A git-parseable command — routes to the git row. The repo path
|
||||
/// is the normalized argument grammar (quotes, leading `/`, one
|
||||
/// trailing `.git`); validation (`alkgit::validate_repo_id`) is
|
||||
/// the git row's gate ([`crate::git_map`]).
|
||||
/// the git row's gate (the `crate::git_map` module).
|
||||
Git {
|
||||
service: GitServiceRoute,
|
||||
repo: String,
|
||||
@@ -99,10 +99,10 @@ pub(crate) enum ExecRoute {
|
||||
}
|
||||
|
||||
/// The git service the exec grammar recognized — the ADR-016 wire
|
||||
/// strings, as the door-side form of [`alkgit::Service`] (the seam maps
|
||||
/// strings, as the door-side form of `alkgit::Service` (the seam maps
|
||||
/// them verbatim; this type keeps the parse core feature-free).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum GitServiceRoute {
|
||||
pub enum GitServiceRoute {
|
||||
/// `git-upload-pack` — fetch.
|
||||
UploadPack,
|
||||
/// `git-receive-pack` — push.
|
||||
@@ -114,7 +114,7 @@ pub(crate) enum GitServiceRoute {
|
||||
/// the validation gate; that one needs `alkgit`'s grammar, this type
|
||||
/// is the parse core's).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ExecDecline {
|
||||
pub enum ExecDecline {
|
||||
/// A known non-serving git service arrived on the wire:
|
||||
/// `git-send-pack` / `git-upload-archive` (POC-3 §2 — the honest
|
||||
/// surface; ADR-016's refusal).
|
||||
@@ -136,10 +136,10 @@ impl ExecDecline {
|
||||
}
|
||||
}
|
||||
|
||||
/// The exec-grammar parse — the wave-2 [`classify_exec`] probe's
|
||||
/// The exec-grammar parse — the wave-2 `classify_exec` probe's
|
||||
/// replacement, its unit rows migrated (see the grammar section for
|
||||
/// the exact shape rules).
|
||||
pub(crate) fn parse_exec(command: &str) -> ExecRoute {
|
||||
pub fn parse_exec(command: &str) -> ExecRoute {
|
||||
let (token, argument) = match command.split_once(' ') {
|
||||
Some((token, argument)) => (token, Some(argument)),
|
||||
None => (command, None),
|
||||
|
||||
+19
@@ -93,6 +93,13 @@ pub use config::SessionSeam;
|
||||
#[cfg(feature = "tunnel")]
|
||||
pub use config::TunnelListenSeam;
|
||||
pub use config::{DialConfig, Limits, ServeConfig};
|
||||
/// Fuzz-visible surface (`#[doc(hidden)]`, `w5-fuzz-harness`): the exec
|
||||
/// grammar's parse path (`dispatch.rs`) — the `fuzz/` sidecar's
|
||||
/// `exec_parse` target drives the production parse, never a fork. Not
|
||||
/// documented API: the grammar is the dispatch seam's, not a public-API
|
||||
/// promise.
|
||||
#[doc(hidden)]
|
||||
pub use dispatch::{ExecDecline, ExecRoute, GitServiceRoute, parse_exec};
|
||||
pub use error::{AdapterError, DialError, KeySourceError, OpError, OpenDeclineReason};
|
||||
pub use fingerprint::fingerprint;
|
||||
/// Contract-test surface (`#[doc(hidden)]`): the per-connection auth
|
||||
@@ -103,6 +110,11 @@ pub use fingerprint::fingerprint;
|
||||
#[doc(hidden)]
|
||||
pub use handler::SessionHandler;
|
||||
pub use host_key::{HostKeyPolicy, PresentedHostKey, RejectAll};
|
||||
/// Fuzz-visible surface (`#[doc(hidden)]`, `w5-fuzz-harness`): the
|
||||
/// decrypt-at-use chain's decode arm (`key_source.rs`, the production
|
||||
/// `decrypt_and_decode` conversion). Not documented API.
|
||||
#[doc(hidden)]
|
||||
pub use key_source::decode_key_arm;
|
||||
pub use key_source::{HostKeySource, KeySource, VaultHostKeySource, VaultKeySource};
|
||||
/// Contract-test surface (`#[doc(hidden)]`): the spawn-and-return
|
||||
/// plumbing (`ADR-006 decision 4`, see `serve.rs`) — the mechanics the
|
||||
@@ -110,3 +122,10 @@ pub use key_source::{HostKeySource, KeySource, VaultHostKeySource, VaultKeySourc
|
||||
/// real wire to pin the constraint as tests. Not documented API.
|
||||
#[doc(hidden)]
|
||||
pub use serve::{report_exit_status_then_close, spawn_serving_path};
|
||||
/// Fuzz-visible surface (`#[doc(hidden)]`, `w5-fuzz-harness`, under
|
||||
/// `session`): the `pty_request` args → `TerminalParamsWire`
|
||||
/// conversion (`tty_map.rs`, OQ-44's reserved modes). Not documented
|
||||
/// API.
|
||||
#[cfg(feature = "session")]
|
||||
#[doc(hidden)]
|
||||
pub use tty_map::terminal_params;
|
||||
+1
-1
@@ -215,7 +215,7 @@ pub(crate) fn clamp_u16(value: u32) -> u16 {
|
||||
/// negotiation section): SSH ttymodes → the **reserved modes JSON
|
||||
/// `{}`** (alktty OQ-44's reservation — the map is honest: nothing
|
||||
/// is carried, exactly what the reservation asks).
|
||||
pub(crate) fn terminal_params(
|
||||
pub fn terminal_params(
|
||||
term: &str,
|
||||
cols: u32,
|
||||
rows: u32,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: w5-fuzz-harness
|
||||
name: Wave 5 — the fuzz sidecar (the crate's ingress surfaces)
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [review-wave-4]
|
||||
scope: moderate
|
||||
risk: medium
|
||||
@@ -92,8 +92,111 @@ Shape (the alkgit convention, adapted):
|
||||
|
||||
## Notes
|
||||
|
||||
> To be filled by implementation agent
|
||||
**The sidecar as landed**: `fuzz/` (its own workspace — `[workspace]
|
||||
members = []` + nightly pin in `fuzz/rust-toolchain.toml`; the root
|
||||
`Cargo.toml` gains `exclude = ["fuzz"]` at *both* levels — the
|
||||
`[workspace]` exclude and the `[package]` exclude list (the alkgit
|
||||
published-crate policy: the sidecar never ships in a publish; verified
|
||||
with the dry-run)). `fuzz/Cargo.lock` is committed (the alkgit
|
||||
convention); `artifacts/`, `coverage/`, `target/`, and grown corpora
|
||||
are gitignored.
|
||||
|
||||
**The exposure-shape decision (per target, recorded)** — the sidecar
|
||||
consumes alkssh's public API wherever the ingress is already public,
|
||||
and the production surface itself (never a fork) where it is private:
|
||||
|
||||
- `fingerprint` — **no exposure needed**: the ingress is fully public
|
||||
([`alkssh::fingerprint`] + the russh 0.65 blob parse
|
||||
`parse_public_key_base64`, the same re-export surface the crate
|
||||
rides).
|
||||
- `exec_parse` — `#[doc(hidden)]` re-export of the dispatch parse
|
||||
path: `parse_exec`, `ExecRoute`, `ExecDecline`, `GitServiceRoute`
|
||||
(made `pub` in the private `dispatch` module; the lib re-export
|
||||
gate is the only reachability — the grammar stays a dispatch seam,
|
||||
not a public-API promise).
|
||||
- `key_decode` — `#[doc(hidden)]` exposure `decode_key_arm`, the
|
||||
production `decrypt_and_decode`'s decode arm *extracted and routed
|
||||
through itself* (the fuzz target drives the exact production arm,
|
||||
and the arm takes `&Zeroizing<String>` so the drive exercises the
|
||||
adapter seam's wrap posture).
|
||||
- `terminal_params` — `#[doc(hidden)]` re-export (under `session`) of
|
||||
`tty_map::terminal_params` — the exact function `handler.rs`'s
|
||||
`pty_request` row constructs the stored params with.
|
||||
|
||||
**The harness shape**: the deterministic framed feed for
|
||||
`terminal_params` (`term_len` u32-LE + term bytes + four u32-LE dims,
|
||||
zero-defaulted, trailing mode blob ignored — the modes are never
|
||||
parsed, OQ-44); the production ingress shape for `exec_parse`
|
||||
(byte string → `from_utf8(..).unwrap_or("")` → parse — the handler's
|
||||
exact shape; the pre-parse cap and the git row's repo-id gate are the
|
||||
root contract rows' territory, pinned by `tests/dispatch_core.rs`);
|
||||
a from-spec base64 round-trip oracle for `fingerprint`; the
|
||||
`exec_parse` verdict is differentially paired against a from-spec
|
||||
grammar oracle (`fuzz/shared/src/exec_parse.rs`), `fingerprint`
|
||||
parity replays the committed `ssh-keygen -lf` vectors inside the
|
||||
corpus gate.
|
||||
|
||||
**Campaigns run (detached via `fuzz/run-detached.sh`, fork mode, the
|
||||
default 600 s budget each; all four final campaigns exited 0 — zero
|
||||
crashes/OOMs/timeouts)**:
|
||||
|
||||
- `exec_parse` — 2 campaigns: the first crashed on a **harness-side
|
||||
oracle bug** (the oracle stripped two leading `/` where the grammar
|
||||
strips exactly one — `git-upload-pack //.git `); fixed, re-ran
|
||||
clean (40M+ execs).
|
||||
- `fingerprint` — 1 campaign, clean (12M+ execs).
|
||||
- `key_decode` — 2 campaigns: the first found a **real crate
|
||||
finding** (below); fixed, re-ran clean (3.5M+ execs).
|
||||
- `terminal_params` — 1 campaign, clean (8.8M+ execs).
|
||||
|
||||
**The real finding (fixed in-task, recorded)**: russh 0.65's
|
||||
`ssh-key` PPK parse path (`PpkParseError::Header`) *interpolates the
|
||||
raw header line* — the plaintext's own bytes — into its `Display`,
|
||||
and the decode arm previously passed that message through
|
||||
`KeySourceError::Decode`'s payload: a malformed or
|
||||
PPK-header-shaped vault record would echo decrypted-record material
|
||||
into a stored/logged error string (the ADR-005 decision-7 posture's
|
||||
textual leak). Fix: `decode_key_arm` (and so every `Decode` payload
|
||||
the crate ever produces) now carries crate-owned fixed text naming
|
||||
the refusal — the machinery's message never rides the typed payload;
|
||||
`error.rs`'s `Decode` doc records the semantics. **The upstream ask
|
||||
is booked** (not absorbed): `ssh-key`'s PPK parse errors should not
|
||||
echo the untrusted header; filed to russh/ssh-key with the owners at
|
||||
the wave-5 gate. Related harness-side finding (also fixed in-task):
|
||||
the no-echo invariant's first shape (a plaintext-`contains`-message
|
||||
check) was itself unsound — an attacker-chosen plaintext can be a
|
||||
substring of the machinery's fixed text (the campaign's own first
|
||||
crash: plaintext `"Could not read key"`); the assertable form is the
|
||||
bounded-message pair (the text is a bounded fixed message, ≤ 256
|
||||
bytes) + the ≥ 256-byte plaintext-run check that cannot false-fire.
|
||||
|
||||
**Verification list**: the corpus gate
|
||||
(`cargo test --manifest-path fuzz/shared/Cargo.toml`) is green (11
|
||||
tests — every committed seed replays the invariants on the stable
|
||||
toolchain, plus the fixture parity rows) and has joined AGENTS.md's
|
||||
verification commands. `scripts/matrix.sh` green after all changes
|
||||
(the sidecar changes no root behavior: 6 builds × the full suite +
|
||||
clippy ×2 `-D warnings` + fmt + doc, exit 0).
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
Completed 2026-10-30. The `fuzz/` sidecar landed per the alkgit
|
||||
convention with all four targets over the plan's ingress inventory
|
||||
(`exec_parse`, `fingerprint`, `key_decode`, `terminal_params`), each a
|
||||
thin `fuzz_target!` wrapper over the stable-clean
|
||||
`fuzz/shared/` invariant library whose corpus replay
|
||||
(`cargo test --manifest-path fuzz/shared/Cargo.toml`) joined the
|
||||
verification list (AGENTS.md); committed hand-made seeds + the
|
||||
deterministic `gen_fuzz_seeds.py` (no-op-diff verified); the detached
|
||||
runner + nightly pin inside `fuzz/`; the root workspace excludes the
|
||||
sidecar (workspace + package excludes). The exposure-shape decision is
|
||||
recorded per target (one no-exposure — fingerprint's fully-public
|
||||
ingress; three `#[doc(hidden)]` production-surface re-exports).
|
||||
Detached campaigns ran per target at the runner's default budget —
|
||||
all clean after findings: one harness-oracle bug (the exec oracle's
|
||||
double-`/` strip) and one real crate fix — russh's `ssh-key` PPP/PPK
|
||||
parse echo (raw header bytes) riding the typed `Decode` payload;
|
||||
`decode_key_arm` now carries a crate-owned fixed message and the
|
||||
upstream echo ask is booked, not absorbed. Verification: the sidecar
|
||||
gate green, `scripts/matrix.sh` green end to end, `cargo publish
|
||||
--dry-run` clean (the sidecar ships nothing).
|
||||
Reference in new issue
Block a user