Skip to content

Security hardening: plugin name validation, CSPRNG passphrases, parser bounds#1

Open
pscheid92 wants to merge 8 commits into
mainfrom
security-hardening
Open

Security hardening: plugin name validation, CSPRNG passphrases, parser bounds#1
pscheid92 wants to merge 8 commits into
mainfrom
security-hardening

Conversation

@pscheid92

Copy link
Copy Markdown
Owner

Summary

Fixes from a full security/bug audit of the library, CLI, and parsers. Six findings, each in its own commit, plus a benchmark refresh.

Security

  • Plugin names are validated before spawning a process (2ae73aa). A crafted recipient/identity string could smuggle path separators through the bech32 HRP into the age-plugin-{name} executable path, letting a hostile recipients file redirect Process.Start to an attacker-chosen binary. Names are now restricted to [a-z0-9-] at parse time and again at the spawn site. Hyphenated plugins like age-plugin-fido2-hmac still work.
  • Auto-generated passphrases use a cryptographic RNG (cacd850). age -p with an empty passphrase generated the file's sole secret with System.Random; now RandomNumberGenerator.
  • Header and armor parsing are bounded pre-authentication (9591375). A hostile stream with an unterminated multi-gigabyte line was buffered in full before any check could reject it. Header lines are capped at 64 KiB (16 MiB total header); armor lines are bounded by a pass-through stream so StreamReader.ReadLine keeps its fast path.

Bugs

  • age -d -i on a passphrase-encrypted file now says "use -p instead" (2f6c30f) — the sentinel that produced this error was dead code and users got a generic "no identity matched".
  • scrypt work factor is validated at construction (33a3040) — values ≥ 31 overflowed 1 << workFactor, and anything above 20 produced files the library's own decrypt cap refuses to read back.
  • Static StreamEncryption helpers no longer buffer the whole input (8e84123) — they delegated-buffered via a 2 GiB-capped int cast; now they route through the memory-bounded EncryptStream/DecryptStream.

Verification

  • Tests: 340 unit tests + 143 CCTV (Community Cryptography Test Vectors) interop vectors pass; 8 regression tests added across the findings.
  • Performance: verified zero measurable overhead via stashed-baseline A/B runs under matched conditions (BenchmarkDotNet default job, Apple M2). Armored decrypt — the only hot path touched — is within noise of the unbounded baseline (101.9/283.7/3,265 μs vs 101.9/282.4/3,240 μs at 1K/64K/1M); allocations byte-identical. The first bound implementation cost +8.5% on armored decrypt; it was reworked to one vectorized scan per buffered read before landing.
  • Docs: docs/BENCHMARKS.md regenerated (67a2386), including the CLI comparison against age v1.3.1 / rage v0.11.1 with a fresh AOT build. Headline results unchanged. Also corrects stale allocation figures that no longer matched the unmodified baseline on the current .NET 10 runtime.

🤖 Generated with Claude Code

A crafted recipient/identity string could smuggle path separators
through the bech32 HRP into the age-plugin-{name} executable path,
redirecting Process.Start to an attacker-chosen binary. Restrict
plugin names to [a-z0-9-] both at parse time and at the spawn site.
System.Random is predictable; the generated passphrase is the sole
secret protecting the encrypted file.
The RejectScryptIdentity sentinel was only inserted when an identity
file yielded a ScryptRecipient, which never happens — so the friendly
"use -p instead" error was dead code and users got a generic
"no identity matched". Always try the sentinel first; it also runs
before any plugin identity would be spawned.
A hostile stream with an unterminated multi-gigabyte line was buffered
in full before any length or authenticity check could reject it. Cap
header lines at 64 KiB and total header size at 16 MiB, and bound armor
lines via a pass-through stream so StreamReader.ReadLine keeps its fast
path. The armor bound costs one vectorized scan per buffered read;
armored decrypt benchmarks stay within noise of the unbounded code.
The encrypt path accepted any work factor: values >= 31 overflowed
1 << workFactor, and anything above 20 produced files this library's
own decrypt cap refuses to read back. Enforce [1, 20] eagerly,
matching the existing decrypt-side maximum.
The whole-stream Encrypt/Decrypt helpers buffered the entire input in
a MemoryStream and truncated lengths above 2 GiB via an int cast. They
now delegate to EncryptStream/DecryptStream, which are byte-for-byte
identical and memory-bounded. Also drop the unused InspectCommand.Error.
Re-measured microbenchmarks and the CLI comparison on Apple M2.
Armored decrypt stays within noise of the pre-hardening baseline
(verified via stashed A/B runs with matched conditions). Also corrects
allocation figures that no longer matched the unmodified baseline on
the current .NET 10 runtime.
@codecov-commenter

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered ☂️

Both branches of the newline walk threw the same exception; computing
the line length once lets a single check cover them.
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.

2 participants