Skip to content

M14.1: cert_authority parser (FR-60, FR-64)#15

Merged
UnbreakableMJ merged 1 commit into
mainfrom
feature/m14-1-cert-authority-parser
May 4, 2026
Merged

M14.1: cert_authority parser (FR-60, FR-64)#15
UnbreakableMJ merged 1 commit into
mainfrom
feature/m14-1-cert-authority-parser

Conversation

@UnbreakableMJ

Copy link
Copy Markdown
Contributor

Summary

Adds the parsing surface for OpenSSH @cert-authority lines and the Anvil @revoked shorthand in known_hosts-style files. Crate-public, consumed by M14.2's revocation enforcement and (eventually) the deferred FR-61..63 cert-validation path.

Files added

  • src/cert_authority.rs — public types + parser:
    • struct CertAuthority { host_pattern, algorithm, fingerprint, openssh }
    • struct RevokedEntry { host_pattern, fingerprint }
    • struct DirectHostKey { host_pattern, fingerprint }
    • struct KnownHostsFile { direct, cert_authorities, revoked }
    • pub fn parse_known_hosts(content) -> Result<KnownHostsFile, AnvilError>

Parser behavior

  • Recognizes @cert-authority / @revoked markers case-insensitively (require trailing whitespace per OpenSSH).
  • Splits comma-separated host patterns into multiple entries.
  • Direct host SHA256:fp form: existing Anvil convention, preserved.
  • @cert-authority host algorithm AAAA... comment — full OpenSSH-format pubkey, parsed via ssh_key::PublicKey::from_openssh for algorithm + SHA-256 fingerprint extraction.
  • @revoked host SHA256:fp — Anvil shorthand fingerprint form.
  • Hashed entries (|1|...|...) skipped with debug log (full support: follow-up).
  • Malformed @revoked soft-skipped with warn (operator typo does not brick the connection).

Tests (15 new)

Empty input, comments + blanks, direct line, comma-separated hosts, @cert-authority parse + case-insensitive marker + invalid-pubkey error, @revoked parse + case-insensitive + comma hosts + missing fingerprint soft-skip, hashed-entry skip, marker-without-space negative case, mixed three-class file, whitespace tolerance. Total: 200 lib tests, 0 failures, 5 ignored (pre-existing).

Out of scope for this PR

  • FR-61, FR-62, FR-63 (live cert validation during handshake) — deferred to a follow-up that lands once russh exposes the server's certificate to check_server_key. Russh 0.59's KEX negotiation does not include *-cert-v01@openssh.com in Preferred::DEFAULT.key.
  • OpenSSH pubkey-blob form for @revoked — Anvil takes the SHA256-fingerprint shorthand; full pubkey form is a follow-up if requested.
  • Hashed host entries (|1|...|...) — skipped with debug log.
  • Negated host patterns (!example.com) in @cert-authority — rare; documented as follow-up.

Test plan

  • CI passes (Linux/macOS/Windows + MSRV 1.88).
  • cargo fmt / cargo clippy --all-targets -- -D warnings clean.
  • cargo test --lib --tests --locked green.

Plan: M14.1 of let-us-plan-on-bright-cosmos.md.

🤖 Generated with Claude Code

…0, FR-64)

Adds the parsing surface for OpenSSH @cert-authority lines and the
Anvil @Revoked shorthand in known_hosts-style files.  The next slice
(M14.2) consumes these in check_server_key for revocation
enforcement; the live cert-during-handshake verification path (FR-61,
FR-62, FR-63) is deferred to a follow-up that lands once russh
exposes the server's certificate to the check_server_key callback
(see M14 plan for the upstream blocker).

src/cert_authority.rs (new, public module):
- struct CertAuthority { host_pattern, algorithm, fingerprint, openssh }
- struct RevokedEntry { host_pattern, fingerprint }
- struct DirectHostKey { host_pattern, fingerprint }
- struct KnownHostsFile { direct, cert_authorities, revoked }
- pub fn parse_known_hosts(content) -> Result<KnownHostsFile, AnvilError>

The parser:
- recognizes @cert-authority and @Revoked markers case-insensitively
  per OpenSSH (markers must be followed by whitespace);
- splits comma-separated host patterns into multiple entries;
- accepts the Anvil-format `host SHA256:fp` direct line as before;
- accepts the OpenSSH-format `algorithm AAAA... comment` pubkey on
  @cert-authority lines (parsed via ssh_key::PublicKey::from_openssh
  for fingerprint computation + algorithm extraction);
- skips hashed entries (|1|...|...) with a debug log; full support
  documented as a follow-up;
- soft-skips malformed @Revoked lines with a warn so an operator
  typo doesn't brick the connection.

src/lib.rs: add `pub mod cert_authority;`.

Tests: 15 new unit tests in cert_authority::tests covering empty
input, comments + blanks, direct lines, comma-separated hosts,
@cert-authority parse + case-insensitive marker + invalid pubkey
error, @Revoked parse + case-insensitive + comma hosts + missing
fingerprint, hashed-entry skip, marker-without-space negative case,
mixed three-class file, whitespace tolerance.  Total now 200 lib
tests, 0 failures, 5 ignored.

Plan: M14.1 of let-us-plan-on-bright-cosmos.md.
@UnbreakableMJ UnbreakableMJ reopened this May 4, 2026
@UnbreakableMJ
UnbreakableMJ merged commit 17b59a2 into main May 4, 2026
5 checks passed
@UnbreakableMJ
UnbreakableMJ deleted the feature/m14-1-cert-authority-parser branch May 4, 2026 13:05
UnbreakableMJ added a commit that referenced this pull request May 4, 2026
Wires the M14.1 cert_authority parser into the connect-time host-key
check.  @Revoked entries become a hard, policy-overriding blocklist:
even StrictHostKeyChecking::No cannot accept a key whose fingerprint
appears in a matching @Revoked line.

src/hostkey.rs:

- New public type HostKeyTrust { fingerprints, cert_authorities, revoked }
  combining the embedded set, parsed direct pins, matching cert
  authorities, and matching revoked entries from one known_hosts
  pass.

- New public fn host_key_trust(host, custom_path) -> HostKeyTrust:
    1. Seeds with embedded fingerprints (GitHub / GitLab / Codeberg).
    2. Reads the user-supplied or default known_hosts file.
    3. Filters direct, cert-authority, and revoked entries by the
       host pattern using ssh_config::lexer::wildcard_match.
    4. Returns an empty trust set without erroring (unlike
       fingerprints_for_host) — the caller's policy decides.

- Existing fingerprints_for_host preserved verbatim for back-compat.

- New embedded_fingerprints helper extracted from fingerprints_for_host
  so host_key_trust can share the well-known-host seeding logic.

src/session.rs:

- GitwayHandler gains revoked: Vec<String> field.
- check_server_key consults `revoked` FIRST, before the
  StrictHostKeyChecking::No bypass and the fingerprint match path:
  any presented key whose SHA-256 fingerprint hits a revoked entry
  is rejected with a host_key_mismatch error and a hint mentioning
  the @Revoked entry.
- build_handler_pieces switched from fingerprints_for_host to
  host_key_trust so direct pins, revocations, and cert authorities
  are pulled in one pass.  The empty-fingerprint branch reproduces
  the long-form actionable hint that fingerprints_for_host emitted.

Tests: +7 new hostkey unit tests:
- host_key_trust embeds well-known fingerprints.
- host_key_trust pattern matches @cert-authority by host glob.
- host_key_trust excludes non-matching @cert-authority.
- host_key_trust pattern matches @Revoked.
- host_key_trust combines direct pins with embedded set.
- host_key_trust tolerates a missing custom_path file.
- host_key_trust returns empty for an unknown host with no file
  (caller decides whether the absence is fatal — the AcceptNew path
  relies on this).

Total: 207 lib tests, 0 failures, 5 ignored (pre-existing).

Plan: M14.2 of let-us-plan-on-bright-cosmos.md. Stacked on M14.1
(PR #15).
UnbreakableMJ added a commit that referenced this pull request May 4, 2026
…64) (#16)

Wires the M14.1 cert_authority parser into the connect-time host-key
check.  @Revoked entries become a hard, policy-overriding blocklist:
even StrictHostKeyChecking::No cannot accept a key whose fingerprint
appears in a matching @Revoked line.

src/hostkey.rs:

- New public type HostKeyTrust { fingerprints, cert_authorities, revoked }
  combining the embedded set, parsed direct pins, matching cert
  authorities, and matching revoked entries from one known_hosts
  pass.

- New public fn host_key_trust(host, custom_path) -> HostKeyTrust:
    1. Seeds with embedded fingerprints (GitHub / GitLab / Codeberg).
    2. Reads the user-supplied or default known_hosts file.
    3. Filters direct, cert-authority, and revoked entries by the
       host pattern using ssh_config::lexer::wildcard_match.
    4. Returns an empty trust set without erroring (unlike
       fingerprints_for_host) — the caller's policy decides.

- Existing fingerprints_for_host preserved verbatim for back-compat.

- New embedded_fingerprints helper extracted from fingerprints_for_host
  so host_key_trust can share the well-known-host seeding logic.

src/session.rs:

- GitwayHandler gains revoked: Vec<String> field.
- check_server_key consults `revoked` FIRST, before the
  StrictHostKeyChecking::No bypass and the fingerprint match path:
  any presented key whose SHA-256 fingerprint hits a revoked entry
  is rejected with a host_key_mismatch error and a hint mentioning
  the @Revoked entry.
- build_handler_pieces switched from fingerprints_for_host to
  host_key_trust so direct pins, revocations, and cert authorities
  are pulled in one pass.  The empty-fingerprint branch reproduces
  the long-form actionable hint that fingerprints_for_host emitted.

Tests: +7 new hostkey unit tests:
- host_key_trust embeds well-known fingerprints.
- host_key_trust pattern matches @cert-authority by host glob.
- host_key_trust excludes non-matching @cert-authority.
- host_key_trust pattern matches @Revoked.
- host_key_trust combines direct pins with embedded set.
- host_key_trust tolerates a missing custom_path file.
- host_key_trust returns empty for an unknown host with no file
  (caller decides whether the absence is fatal — the AcceptNew path
  relies on this).

Total: 207 lib tests, 0 failures, 5 ignored (pre-existing).

Plan: M14.2 of let-us-plan-on-bright-cosmos.md. Stacked on M14.1
(PR #15).
UnbreakableMJ added a commit that referenced this pull request May 4, 2026
…ity + host_key_trust

Adds tests/test_known_hosts_cert.rs — four hermetic integration tests
that exercise the M14.1 parser and the M14.2 trust-aggregation API
through the published crate boundary (no internal helpers, no
network, no russh server).

Coverage:

- parses_three_classes_in_one_file
  Single known_hosts blob containing a direct pin, an
  @cert-authority *.example.com line, and an @Revoked entry.
  Asserts each class lands in the right vector with the right
  shape (host pattern, fingerprint prefix, algorithm).

- host_key_trust_filters_by_host_pattern
  Two cert-authorities + two revoked entries with mixed host
  globs. Calls host_key_trust(foo.example.com, path) and asserts
  only the *.example.com matches come back; calls again with
  third-party.io and asserts both vectors are empty. Locks in the
  wildcard_match plumbing from M12.

- host_key_trust_reports_well_known_embedded_set
  Calls host_key_trust(github.com, &None) — no custom file —
  and asserts the three embedded SHA256 fingerprints from the
  M11.5 pin set are still surfaced. Confirms the M14.2 refactor
  preserved the embedded path.

- malformed_cert_authority_pubkey_errors_with_clear_message
  Feeds parse_known_hosts a syntactically @cert-authority line
  with a non-base64 key blob and asserts the error message
  references @cert-authority so an operator can find the
  failing line.

The tempfile dependency was already a dev-dep from M12.
No production code changes.

Plan: M14.5 of let-us-plan-on-bright-cosmos.md. Stacked on M14.2
(PR #16), which is stacked on M14.1 (PR #15).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UnbreakableMJ added a commit that referenced this pull request May 4, 2026
…ity + host_key_trust (#17)

Adds tests/test_known_hosts_cert.rs — four hermetic integration tests
that exercise the M14.1 parser and the M14.2 trust-aggregation API
through the published crate boundary (no internal helpers, no
network, no russh server).

Coverage:

- parses_three_classes_in_one_file
  Single known_hosts blob containing a direct pin, an
  @cert-authority *.example.com line, and an @Revoked entry.
  Asserts each class lands in the right vector with the right
  shape (host pattern, fingerprint prefix, algorithm).

- host_key_trust_filters_by_host_pattern
  Two cert-authorities + two revoked entries with mixed host
  globs. Calls host_key_trust(foo.example.com, path) and asserts
  only the *.example.com matches come back; calls again with
  third-party.io and asserts both vectors are empty. Locks in the
  wildcard_match plumbing from M12.

- host_key_trust_reports_well_known_embedded_set
  Calls host_key_trust(github.com, &None) — no custom file —
  and asserts the three embedded SHA256 fingerprints from the
  M11.5 pin set are still surfaced. Confirms the M14.2 refactor
  preserved the embedded path.

- malformed_cert_authority_pubkey_errors_with_clear_message
  Feeds parse_known_hosts a syntactically @cert-authority line
  with a non-base64 key blob and asserts the error message
  references @cert-authority so an operator can find the
  failing line.

The tempfile dependency was already a dev-dep from M12.
No production code changes.

Plan: M14.5 of let-us-plan-on-bright-cosmos.md. Stacked on M14.2
(PR #16), which is stacked on M14.1 (PR #15).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UnbreakableMJ added a commit that referenced this pull request May 4, 2026
) (#18)

Bumps anvil-ssh from 0.4.0 to 0.5.0 to publish the M14.1 + M14.2 +
M14.5 work as a single crates.io release.

Highlights (full text in CHANGELOG.md):

- new public module `anvil_ssh::cert_authority` with
  `parse_known_hosts(&str) -> Result<KnownHostsFile, AnvilError>`
  and the `CertAuthority` / `RevokedEntry` / `DirectHostKey` /
  `KnownHostsFile` types (FR-60, FR-64);
- new public fn `anvil_ssh::hostkey::host_key_trust(host, custom_path)`
  returning `HostKeyTrust { fingerprints, cert_authorities, revoked }`,
  combining embedded fingerprints + direct pins + matching CAs +
  matching revoked entries from one known_hosts pass;
- @Revoked enforcement in `check_server_key`: revoked fingerprints
  are checked first, before the StrictHostKeyChecking::No bypass —
  no policy can override.

FR-61, FR-62, FR-63 (live `@cert-authority` validation during the
SSH handshake) are deferred to a follow-up that lands once russh
upstream exposes the server's certificate to `check_server_key`.
Russh 0.59's `Preferred::DEFAULT.key` does not list the
*-cert-v01@openssh.com host-key algorithms, so KEX never asks for
a cert-bearing host key.  The CHANGELOG records the exact upstream
patch sketch.

Public-API additions only.  Existing `fingerprints_for_host` is
preserved verbatim; the empty-fingerprint branch in
`build_handler_pieces` reproduces the long-form actionable hint
inline.

Lib tests: 207 passed, 5 ignored (pre-existing).
Integration tests: 4 in tests/test_known_hosts_cert.rs.

Plan: M14.3 of let-us-plan-on-bright-cosmos.md.  Stacked after
M14.1 (#15), M14.2 (#16), M14.5 (#17), all merged to main.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant