The age commands: pub, priv, encrypt and decrypt #7

Merged
clawbot merged 1 commits from issue-3 into next 2026-09-07 18:35:11 +02:00
Collaborator

Closes #3

Adds the age command group: pub, priv, encrypt and decrypt. The
application number is 657169, so the path is m/83696968'/657169'/<n>'.

The branch follows the shape the README lays out for a new key type: one
package under internal/ that turns the 32 derived bytes into a key, and one
command group under internal/cli/. The bytes are clamped the way X25519
requires and go through bech32 into an age identity, which is the only route
age offers from raw bytes to a key, and the same steps sneak/secret takes in
its agehd package. I read that package to confirm the match:
bech32.EncodeFromBase256 is exactly the convert-then-encode pair it uses.

What the diff does not show:

  • Decrypting recognises the text form by the line it begins with, so there is
    no armor flag on decrypt. That is what the age tool itself does.
  • --to accepts age1... recipients only. The README calls a recipient the
    age1... public key, so ssh recipients are outside this issue.
  • A file named with -o is created readable only by its owner. The README does
    not say; a decrypted file is as secret as what went into it, and that is the
    reading I took.

Unverified: this host has no age binary, so nothing was checked against the
reference implementation. The round trips in the tests go through this code
only.

make check is green.

Model: opus-5

Closes https://git.eeqj.de/sneak/keyfunc/issues/3 Adds the `age` command group: `pub`, `priv`, `encrypt` and `decrypt`. The application number is 657169, so the path is `m/83696968'/657169'/<n>'`. The branch follows the shape the README lays out for a new key type: one package under `internal/` that turns the 32 derived bytes into a key, and one command group under `internal/cli/`. The bytes are clamped the way X25519 requires and go through bech32 into an age identity, which is the only route age offers from raw bytes to a key, and the same steps `sneak/secret` takes in its `agehd` package. I read that package to confirm the match: `bech32.EncodeFromBase256` is exactly the convert-then-encode pair it uses. What the diff does not show: - Decrypting recognises the text form by the line it begins with, so there is no armor flag on `decrypt`. That is what the `age` tool itself does. - `--to` accepts `age1...` recipients only. The README calls a recipient the `age1...` public key, so ssh recipients are outside this issue. - A file named with `-o` is created readable only by its owner. The README does not say; a decrypted file is as secret as what went into it, and that is the reading I took. Unverified: this host has no `age` binary, so nothing was checked against the reference implementation. The round trips in the tests go through this code only. `make check` is green. Model: opus-5
clawbot added 1 commit 2026-09-07 17:44:50 +02:00
The age commands: pub, priv, encrypt and decrypt (closes #3)
All checks were successful
check / check (push) Successful in 3m57s
73c80ab173
The application number is 657169, so the path is
m/83696968'/657169'/<n>'. The 32 derived bytes are clamped the way
X25519 requires and go through bech32 into an age identity, which is
the only route age offers from raw bytes to a key; these are the steps
sneak/secret takes in its agehd package.

The derived recipient is always first in the recipient list, so the
mnemonic that encrypted a file can always read it back. Decrypting
recognises the text form by the line it starts with, so it needs no
flag. A file named with -o is created readable only by its owner,
since a decrypted one is as secret as what went into it.

Model: opus-5
clawbot self-assigned this 2026-09-07 17:44:51 +02:00
clawbot added the needs-review label 2026-09-07 17:44:55 +02:00
Author
Collaborator

FAILneeds-rework.

1. internal/agekey/agekey.go:55 with internal/agekey/agekey_test.go:16-21: the clamping step is pinned by nothing.
Deleting clamp(scalar) leaves the whole suite green. X25519 clamps internally, so the recipient does not move — but keyfunc age priv then prints a different AGE-SECRET-KEY-1... line for the same mnemonic and index. Clamping is a step the specification states, and the identity is what makes a key interchangeable with the one sneak/secret derives, so the part of the derivation a later edit is most likely to break is unguarded. Acceptable: a fixed identity constant for the example mnemonic at index 0, beside the two recipient constants and asserted the same way.

2. internal/cli/age/age.go:192-194: a refused decryption destroys the file named by -o.
output() opens the file with O_TRUNC before any work is attempted. Decrypting a file that was encrypted only to another key, with -o naming an existing file, prints the refusal and exits 1 — and leaves that file empty. A wrong index is a foreseeable mistake, and it silently loses whatever was there. Acceptable: write to a temporary file beside the target and rename it once the work succeeds, or remove the file when the work returns an error.

Verified

Rebased head against current next, which it merges cleanly; make check in a fresh clone; and the built binary for each item of the definition of done — the fixed recipient at indexes 0 and 1, byte and text round trips with and without --armor across both files and standard input, refusal with the stated message and status 1, and the derived recipient still able to read a file that --to addressed to somebody else. The identity derivation matches sneak/secret's agehd for the same 32 input bytes.

The branch's open item is closed: output from this branch decrypts under the reference age implementation in both the binary and the text form, that implementation's output decrypts here, and asking it for the public key of the printed identity returns the printed recipient.

Defects were planted in a scratch copy for the recipient list, the armor flag, the text-form detection and the refusal error; each was caught. Finding 1 is the one that was not.

Model: opus-5

**FAIL** — `needs-rework`. **1. `internal/agekey/agekey.go:55` with `internal/agekey/agekey_test.go:16-21`: the clamping step is pinned by nothing.** Deleting `clamp(scalar)` leaves the whole suite green. X25519 clamps internally, so the recipient does not move — but `keyfunc age priv` then prints a different `AGE-SECRET-KEY-1...` line for the same mnemonic and index. Clamping is a step the specification states, and the identity is what makes a key interchangeable with the one `sneak/secret` derives, so the part of the derivation a later edit is most likely to break is unguarded. Acceptable: a fixed identity constant for the example mnemonic at index 0, beside the two recipient constants and asserted the same way. **2. `internal/cli/age/age.go:192-194`: a refused decryption destroys the file named by `-o`.** `output()` opens the file with `O_TRUNC` before any work is attempted. Decrypting a file that was encrypted only to another key, with `-o` naming an existing file, prints the refusal and exits 1 — and leaves that file empty. A wrong index is a foreseeable mistake, and it silently loses whatever was there. Acceptable: write to a temporary file beside the target and rename it once the work succeeds, or remove the file when the work returns an error. **Verified** Rebased head against current `next`, which it merges cleanly; `make check` in a fresh clone; and the built binary for each item of the definition of done — the fixed recipient at indexes 0 and 1, byte and text round trips with and without `--armor` across both files and standard input, refusal with the stated message and status 1, and the derived recipient still able to read a file that `--to` addressed to somebody else. The identity derivation matches `sneak/secret`'s `agehd` for the same 32 input bytes. The branch's open item is closed: output from this branch decrypts under the reference `age` implementation in both the binary and the text form, that implementation's output decrypts here, and asking it for the public key of the printed identity returns the printed recipient. Defects were planted in a scratch copy for the recipient list, the armor flag, the text-form detection and the refusal error; each was caught. Finding 1 is the one that was not. Model: opus-5
clawbot added needs-rework and removed needs-review labels 2026-09-07 17:55:41 +02:00
clawbot force-pushed issue-3 from 73c80ab173 to a44164a6f5 2026-09-07 18:10:37 +02:00 Compare
Author
Collaborator

Rebased onto next (the child-mnemonic unit). The command-tree file conflicted; resolved by registering all three command groups. The rebase required a force-push.

  1. The secret key the example mnemonic gives at index 0 is now a fixed constant beside the two recipients, asserted the same way, so removing the clamping step fails the suite.

  2. age encrypt and age decrypt with -o now write a new file beside the target and rename it into place only once the work succeeds; on failure it is thrown away, so a refused decryption leaves an existing file untouched. The new file keeps the owner-only mode. A test covers the refusal case.

make check is green.

Model: opus-5

Rebased onto `next` (the child-mnemonic unit). The command-tree file conflicted; resolved by registering all three command groups. The rebase required a force-push. 1. The secret key the example mnemonic gives at index 0 is now a fixed constant beside the two recipients, asserted the same way, so removing the clamping step fails the suite. 2. `age encrypt` and `age decrypt` with `-o` now write a new file beside the target and rename it into place only once the work succeeds; on failure it is thrown away, so a refused decryption leaves an existing file untouched. The new file keeps the owner-only mode. A test covers the refusal case. `make check` is green. Model: opus-5
clawbot added needs-review and removed needs-rework labels 2026-09-07 18:11:03 +02:00
Author
Collaborator

PASS.

No findings. Both findings from the first review are fixed, and each fix is
guarded by a test that fails when the fix is taken back out.

Verified

The head already sits on current next and merges cleanly. make check in a
fresh clone exits 0, with the lint stage actually executing rather than replayed
from cache.

Finding 1: internal/agekey/agekey_test.go:24 now fixes the secret key for the
example mnemonic at index 0, beside the two recipients and asserted the same
way. Removing clamp(scalar) in a scratch copy fails that test and only that
test — the recipients still match, which is the gap the first review described.
The constant decodes to a 32-byte scalar clamped as RFC 7748 requires, checked
outside this code.

Finding 2: internal/cli/age/age.go:176-225 writes beside the target and
renames into place only once the work succeeds. From the built binary, a refused
decryption with -o naming an existing file exits 1 with the stated message and
leaves that file byte-identical, with no leftover partial file. Making finish
rename despite the failure fails the new test at internal/cli/age_test.go:75.

Also from the binary: encrypt then decrypt round-trips bytes and text, with and
without --armor, over both files and standard input; -o output is owner-only;
and a missing input, an unparseable --to, an unwritable output directory and a
truncated file each exit 1 without leaving anything behind.

Scope: the diff touches the age package, its command group, the command
registration and the filippo.io/age requirement. Nothing else.

Disclosures: the commit body runs slightly over the 120-word guide, which I read
as within its latitude rather than a finding; and the new test carries one lint
suppression at internal/cli/age_test.go:87, for a path it creates itself.

Model: opus-5

**PASS.** No findings. Both findings from the first review are fixed, and each fix is guarded by a test that fails when the fix is taken back out. **Verified** The head already sits on current `next` and merges cleanly. `make check` in a fresh clone exits 0, with the lint stage actually executing rather than replayed from cache. Finding 1: `internal/agekey/agekey_test.go:24` now fixes the secret key for the example mnemonic at index 0, beside the two recipients and asserted the same way. Removing `clamp(scalar)` in a scratch copy fails that test and only that test — the recipients still match, which is the gap the first review described. The constant decodes to a 32-byte scalar clamped as RFC 7748 requires, checked outside this code. Finding 2: `internal/cli/age/age.go:176-225` writes beside the target and renames into place only once the work succeeds. From the built binary, a refused decryption with `-o` naming an existing file exits 1 with the stated message and leaves that file byte-identical, with no leftover partial file. Making `finish` rename despite the failure fails the new test at `internal/cli/age_test.go:75`. Also from the binary: encrypt then decrypt round-trips bytes and text, with and without `--armor`, over both files and standard input; `-o` output is owner-only; and a missing input, an unparseable `--to`, an unwritable output directory and a truncated file each exit 1 without leaving anything behind. Scope: the diff touches the age package, its command group, the command registration and the `filippo.io/age` requirement. Nothing else. Disclosures: the commit body runs slightly over the 120-word guide, which I read as within its latitude rather than a finding; and the new test carries one lint suppression at `internal/cli/age_test.go:87`, for a path it creates itself. Model: opus-5
clawbot merged commit 5bbeec86d6 into next 2026-09-07 18:35:11 +02:00
clawbot deleted branch issue-3 2026-09-07 18:35:12 +02:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/keyfunc#7