Crypt-Age

 view release on metacpan or  search on metacpan

.claude/skills/crypt-age-core/SKILL.md  view on Meta::CPAN


The last two were found by the test kit on the day it was wired in, not by review —
which is the argument for running it.

Two things worth carrying forward:

- **A partial release is part of the contract.** `decrypt_payload_fh` streams, so
  plaintext already written stays in the output handle when it dies. Each released
  chunk is individually authentic; the *message* is not, and that is exactly what the
  error reports. Do not "fix" this by buffering — the test kit asserts on the partial
  release (`stream_no_final_full` expects 64 KiB out, then the error).
- **CryptX ≥ 0.067 refuses low-order peer keys inside `shared_secret()` itself.** Our
  own check is a backstop, and the reason `t/05-primitives.t` stubs the backend to
  reach it. The cpanfile pins that floor.

Still absent by design, not defects: scrypt / passphrase recipients, the ssh and
`mlkem768x25519` / tagged types, and ASCII armor.

The file and filehandle API **streams**: `encrypt_file` / `decrypt_file` open handles and
go through `_encrypt_fh` / `_decrypt_fh`, which chunk via `encrypt_payload_fh` /
`decrypt_payload_fh`; `encrypt_filehandle` / `decrypt_filehandle` expose the same path to
a caller's own handles. Only the string API `encrypt` / `decrypt` holds the whole message
in memory, which is what a string API means. Do not "add streaming" that is already here.

## Keys

Bech32 per BIP-173, **without** the 90-character length limit (the spec removes it).
HRP `age` for recipients (lowercase output), `age-secret-key-` for identities
(**uppercased** on output — `encode_secret_key` wraps the whole string in `uc`).
The checksum is always computed over the lowercase form, which is why `bech32_decode`
lowercases the HRP before verifying.

`bech32_decode` rejects a string that mixes cases (`Invalid bech32: mixed case`), per
BIP-173's "Decoders MUST NOT accept strings where some characters are uppercase and
some are lowercase" — checked first, before the separator. All-upper and all-lower both
decode, and to the same bytes.

The binaries differ on the case they accept for a *key type*, which is a separate
question from the mixed-case rule and deliberately not mirrored here: `age` 1.2.1
dispatches on the literal prefix, so it rejects an all-upper `AGE1...` recipient
("unknown recipient type") and an all-lower identity ("unknown identity type"); `rage`
0.12.1 accepts both. Both binaries reject mixed case outright.

This distribution follows `rage`, and it does so on **both** paths — say which path when
you make a claim here, because the two used to disagree and that is what hid the bug in
`Header::create`:

| Path | Dispatch | Decode |
|---|---|---|
| encrypt | `Header::create`, `/^age1/i` | `Keys::decode_public_key`, HRP compared with `lc` |
| decrypt | `Header::unwrap_file_key`, `/^AGE-SECRET-KEY-1/i` | `Keys::decode_secret_key`, HRP compared with `lc` |

So an all-upper `AGE1...` recipient encrypts and an all-lower `age-secret-key-1...`
identity decrypts, both the way `rage` takes them; a mixed-case string of either kind
passes the prefix test and then dies in `bech32_decode` with `Invalid bech32: mixed
case`. Until ticket #19 the recipient dispatch alone was case-sensitive, so
`decode_public_key(uc $pub)` returned the right bytes while `encrypt` died with
`Unsupported recipient format` — do not re-narrow either regex to "match the binary",
and note that `age`'s refusal is strictness beyond the spec, not our standard.

None of this reaches the wire. The recipient string is decoded to raw bytes in
`Stanza::X25519::wrap` and the stanza carries the *ephemeral* public key, so a file
encrypted to `AGE1...` is byte-identical to the same file encrypted to `age1...`, and
`age` 1.2.1 reads both — even though it would not have accepted `AGE1...` as its own
`-r` argument. Generated keys stay canonical: `encode_public_key` is lowercase,
`encode_secret_key` uppercase.

## Proof — a green suite is not one

```bash
prove -lr t/                    # everything; note -r, plain -l t/ is not recursive
prove -lv t/07-testkit.t        # the 143 upstream vectors — runs without a binary
prove -lv t/04-interop.t        # the real binary, when there is one
```

**Never assume whether a binary is present — check, with `which age rage`.** Machines
differ and this skill ships inside the tarball, so any claim here about what is installed
would be wrong somewhere. `t/04-interop.t` collects every CLI it finds — `age` and `rage`
— and runs the whole block once per CLI, prefixing each description with `[age]` /
`[rage]`; it `plan skip_all`s only when neither exists. Consequences:

- With no binary the file asserts **nothing**, and a green suite is not evidence for a
  format-touching change on its own.
- With both installed the count is **120** (60 per CLI); with one, 60. A run reporting 60
  covered a single implementation, and the `Using CLI:` diag lines at the top name which.
  No `PATH` surgery is needed to reach the Rust side any more.
- The two differ observably: given a 0-byte plaintext and `-o`, `age` writes a 0-byte
  file, `rage` writes no file at all. The chunk-boundary block handles both.

Say which of the three commands you ran, and against which binaries and versions.

`t/07-testkit.t` is what fills that hole: the C2SP/CCTV vectors, vendored under
`t/testkit/`, 68 of 143 runnable against this implementation and 75 skipped with a
printed per-reason tally (armor, scrypt, hybrid — none implemented). They were produced
by the reference implementation, so they prove the read side against real bytes. They
cannot prove the write side end to end: there is no reproducible way to inject our
randomness, so nothing here shows that `age` accepts what we emit. Only the binary does
that.

Never weaken an assertion to make a test pass, and never edit a vector or the runner's
expectations to get to green — a test kit you adjust proves nothing. Keys, identities
and plaintext never appear in errors, diagnostics, commit messages or ticket bodies.



( run in 1.202 second using v1.01-cache-2.11-cpan-d01c6094234 )