130 Commits
Author SHA1 Message Date
GitHub Actions b74dce4cdb doc: regenerate groff and html man pages 2026-08-29 17:40:19 +00:00
Filippo Valsorda 5c1b90c1a6 all: release v1.3.2 2026-08-29 19:38:23 +02:00
Filippo Valsorda 7fcc72e15e plugin: correct working directory comment
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 58ef9fcb8d cmd/age-keygen: correct public key output documentation
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda de96c8e8e1 cmd/age: reject invalid UTF-8 terminal output
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 3604609d98 cmd/age-plugin-batchpass: correct documented scrypt limit
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 6ff2f308e6 cmd/age: enable PTY tests on Darwin 2026-08-29 19:30:10 +02:00
Filippo Valsorda cdb30a188b cmd/age: recognize armored identity files after whitespace
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 9cbe8d8c2a plugin: allow empty prompt responses
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda eb706087c6 internal/format: validate stanza fields when marshaling
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda bebcffc2f3 plugin: always canonicalize plugin names
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 572b47c3e7 agessh: enforce the RSA key size when wrapping
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda b030f04671 armor: enforce writer lifecycle
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 352cb3e133 plugin: reject negative file indexes
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda e8d1b2f216 armor,internal/format: restore CCTV fuzz seed corpora
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 6424047654 armor: reject embedded carriage returns
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 88299a50e4 cmd/age-inspect: classify unknown recipient stanzas
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda ec60cb56b4 internal/format: reject headers without recipient stanzas
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda aaadae2d85 cmd/age-keygen,extra/age-plugin-pq: buffer identity conversion output
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 019df184df .github/workflows: include compatibility plugins in release archives 2026-08-29 19:30:10 +02:00
Filippo Valsorda e0ffaca260 agessh: don't cache mismatched encrypted identities
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 86c6cc24c0 internal/term: sanitize terminal prompts
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda fb6bef07c3 age: don't reorder caller identities
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 45445f91cf cmd/age-keygen: report output write failures
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 343dda9aa4 cmd/age: reject malformed supported SSH recipients
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 418c7ac33b age,cmd/age: reject oversized key files
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda c45ccfd215 age,internal/format: avoid echoing private keys in errors
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 366cc58ca8 internal/bech32: avoid panic on Unicode input
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 7a8f500567 cmd/age: delay output creation until payload authentication
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda dbe5773864 cmd/age: reject output aliases of input files
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 20f9e8574f agessh,tag/tagtest: handle recipient hint collisions
The four-byte SSH fingerprints (and tagged recipient tags) are hints to
avoid unnecessary password prompts or hardware interactions. Treat an
authenticated unwrap failure after a hint match as a false positive and
continue looking for a matching stanza.

Without this, a colliding recipient stanza that appears before the
valid stanza makes decryption fail even though the file is correctly
encrypted to the identity. Any password prompt or hardware interaction
has already happened by then.

Reported by Claude, Anthropic's AI assistant, and triaged by the
Anthropic security team in collaboration with Anthropic Research,
as ANT-2026-S9C25K8W.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 27188e780b internal/format: limit header size and memory use
The application has no way to limit the header separately from the body,
so we have to enforce some sanity limits to prevent excessive memory use.

To relax the limits somewhat, make the parsing more efficient.

New limits are:

  * 2 MiB total header size (enough for > 1024 PQ recipients)
  * 1024 recipient stanzas
  * 128 arguments per stanza

Fixes #701
2026-08-29 19:30:10 +02:00
Filippo Valsorda c083f6045f internal/stream: detect trailing data returned alongside io.EOF
The trailing data check after a full-length final chunk ignored the
byte count, so a reader returning (1, io.EOF), as io.Reader permits,
would have its trailing data silently accepted, and a transient
(0, nil) return would be misreported as trailing data. io.ReadFull
handles both.
2026-08-29 14:31:26 +02:00
Filippo Valsorda 91bc452b9a internal/stream: reject negative and overflowing encrypted sizes 2026-08-29 14:26:05 +02:00
Filippo Valsorda 04f65ea9fc internal/stream: extract readFullAt and clean up tests 2026-08-29 14:24:43 +02:00
Arpit GuptaandArpit Gupta b155aff565 stream: fix NewDecryptReaderAt EOF handling (#708)
Co-authored-by: Arpit Gupta <arpitb.lgupta1@gmail.com>
2026-08-29 13:45:51 +02:00
Ziad Yahya 2313f65737 README: add --version flag to usage section (#739) 2026-08-29 12:52:26 +02:00
Richard Ulmer d16b021305 README: add syntax highlighting to code blocks (#713) 2026-08-29 00:37:00 +02:00
GitHub Actions e65b40fc99 doc: regenerate groff and html man pages 2026-08-28 22:24:16 +00:00
Filippo Valsorda ac0ed948b4 .github/workflows: remove expired Windows code signing certificate
Closes #715
2026-08-29 00:22:02 +02:00
itsVentieandFilippo Valsorda 933c44d4d5 internal/inspect: handle reads after EOF in trackReader
trackReader.Read panicked if called again after a read returned io.EOF,
which can happen with readers that do not return EOF consistently, such
as a terminal after Ctrl-D. Latch the EOF instead, per the io.Reader
contract, so count stays consistent with the file size.

Fixes #719

Co-authored-by: Filippo Valsorda <hi@filippo.io>
2026-08-29 00:22:02 +02:00
Filippo Valsorda 6d29a923f3 .github/workflows: add windows/arm64 build
Fixes #733
2026-08-29 00:22:02 +02:00
Filippo Valsorda 3cfb3adc2a all: update dependencies and GitHub Actions
Closes #736
Fixes #731
2026-08-29 00:22:02 +02:00
Mohamed Moumni 72586cd449 cmd/age: add --version to usage text (#694) 2026-08-29 00:20:37 +02:00
udf2457 7c7978c12e ‎internal/stream: simplify a min() computation in tests (#727) 2026-08-28 23:28:31 +02:00
Filippo Valsorda a68b8e921b README: add dependencies badge 2026-08-28 23:06:33 +02:00
Steve WeisandClaude 706dfc1e79 all: minor cleanups (#700)
Replace two hand-rolled slicesEqual helpers with slices.Equal from
the standard library, which is already imported in age.go.
(age.go:211-221, plugin/plugin.go:666-676)

Fix godoc link typo [Indentity] -> [Identity].
(plugin/plugin.go:3)

Fix comment typo "not MinPayload" -> "so MinPayload".
(internal/inspect/inspect.go:25)

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-20 22:12:32 +01:00
Steve WeisandClaude 4c86929a84 internal/format: return correct byte count from writeWrapped (#698)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-20 22:02:09 +01:00
Steve WeisandClaude db09cd66d1 internal/inspect: fix tag stanza type names in post-quantum classifier (#695)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-20 21:49:27 +01:00
Filippo Valsorda 4a3a4ef00a SIGSUM.md: extend release playbook 2026-02-02 17:31:01 +01:00
Filippo Valsorda 10561a774f .github/workflows: remove one-off darwin/amd64 backfill workflow
Updates #676
2026-01-21 18:56:39 +01:00
Filippo Valsorda c918a2c113 .github/workflows: add darwin/amd64 backfill workflow
Updates #676
2026-01-21 18:49:52 +01:00
Filippo Valsorda beb5dfa28c .github/workflows: add darwin/amd64 build target
Closes #676
2026-01-21 18:49:46 +01:00
Filippo Valsorda 0293aca1d7 SIGSUM.md: add release playbook
Updates #617
2025-12-31 16:19:37 +01:00
GitHub Actions e7601d8a67 doc: regenerate groff and html man pages 2025-12-31 11:05:09 +00:00
brettwhiteinc acfa73142b doc: fix post-quantum example in age-keygen manpage (#675) 2025-12-31 12:03:49 +01:00
Filippo Valsorda b8564adb6d .github/workflows: inject version into source release artifact
Updates #671
Updates golang/go#77020
2025-12-28 13:00:23 +01:00
Filippo Valsorda e4c611f778 cmd,extra: restore the Version link-time variable
We don't need it in our builds, but it's useful for downstream packagers.

Fixes #671
Updates NixOS/nixpkgs#474666
Updates golang/go#77020
2025-12-28 12:49:37 +01:00
Filippo Valsorda 6a8065f2da SIGSUM.md: update policy for v1.3.0
Updates #617
2025-12-27 15:59:06 +01:00
Filippo Valsorda 50a600eef5 .github/workflows: improve release reproducibility
Remove the go-version-file test target, because it will just use the
toolchain line. It's a bit unfortunate we lose coverage of a go.mod
version that's older than oldstable, but that should not happen and it'd
not be a supported Go version anyway.
2025-12-27 15:59:06 +01:00
Filippo Valsorda 13aab81842 .github/workflows: build and release source tarball
Fixes #401
2025-12-27 15:59:06 +01:00
Filippo ValsordaandHelio Machado 52338c20df .github/workflows: enable GitHub artifact attestation
Closes #621

Co-authored-by: Helio Machado <0x2b3bfa0+git@googlemail.com>
2025-12-27 10:28:25 +01:00
Filippo Valsorda b70af41215 cmd/age: don't output binary plaintext to terminal
Closes #626
2025-12-26 23:16:25 +01:00
Filippo Valsorda 420273952a internal/stream: fix DecryptReaderAt concurrency 2025-12-26 22:18:54 +01:00
Filippo Valsorda da2191789a age: add ExampleDecryptReaderAt with zip.NewReader 2025-12-26 21:42:24 +01:00
Filippo Valsorda 2ff5d341f6 age: add DecryptReaderAt 2025-12-26 20:30:27 +01:00
Filippo Valsorda abe371e157 all: run "go fix" 2025-12-25 20:48:28 +01:00
Filippo Valsorda ec92694aad age: add EncryptReader pull-based encryption API
Fixes #644
Fixes #654
Updates #638
2025-12-25 20:48:28 +01:00
Filippo ValsordaandNicolas Dumazet 92ac13f51c plugin: add NewTerminalUI
Closes #611
Closes #591

Co-authored-by: Nicolas Dumazet <nicdumz.commits@gmail.com>
2025-12-24 17:17:50 +01:00
GitHub Actions a62324430d doc: regenerate groff and html man pages 2025-12-24 11:36:31 +00:00
Filippo Valsorda 41167479ce doc: include warning in age-plugin-batchpass(1) 2025-12-24 12:35:15 +01:00
GitHub Actions 4444afb267 doc: regenerate groff and html man pages 2025-12-24 11:28:31 +00:00
Filippo Valsorda db8ed63595 cmd/age-plugin-batchpass: add detailed warning 2025-12-24 12:10:43 +01:00
Filippo Valsorda f1cc23a3f6 cmd: move compatibility plugins to extra/ directory 2025-12-24 02:40:29 +01:00
Filippo Valsorda 50a81fd5a9 cmd/age-plugin-batchpass: plugin for non-interactive passphrase encryption
Fixes #603
Closes #641
Closes #520
Updates #256
Updates #182
Updates #257
Updates #275
Updates #346
Updates #386
Updates #445
Updates #590
Updates #572
2025-12-24 02:27:54 +01:00
Filippo Valsorda 44a4fcc27b age,cmd/age: improve error messages for mixed up identities/recipients
Not sure why ParseRecipients was hiding errors, when ParseIdentities
doesn't.

For #643
2025-12-23 23:26:16 +01:00
Filippo Valsorda 2e0f1efe4d age,cmd/age: detect invalid UTF-8 in identity and recipient files
For #663
2025-12-23 23:06:00 +01:00
Filippo Valsorda a7586b7557 README: remove pkgx installation instructions
See https://github.com/FiloSottile/age/pull/659#issuecomment-3688047267.
2025-12-23 22:46:29 +01:00
Filippo Valsorda de7813b5f6 README: add age-inspect documentation 2025-12-23 22:46:13 +01:00
RishikesavanRamesh 2f5cf5438c README: add Guix System to installation table (#615) 2025-12-23 22:44:19 +01:00
GitHub Actions 830d84e777 doc: regenerate groff and html man pages 2025-12-23 21:31:56 +00:00
Filippo Valsorda b4cdeef465 cmd/age: accept leading whitespace before armored data
It was already accepted by the API, but the CLI did not handle it while
peeking to detect armored input.
2025-12-23 22:23:41 +01:00
Simone RagusaandFilippo Valsorda 6aae5b48ea cmd/age: fix terminal escape sequences on Windows
If possible, we enable virtual terminal processing, which is necessary
for using terminal escape sequences on instances of the Windows Console.
When enabling virtual terminal processing fails, we completely avoid
using escape sequences to prevent weird characters to be printed to the
console.

Fixes #474
Closes #475

Co-authored-by: Filippo Valsorda <hi@filippo.io>
2025-12-23 22:12:19 +01:00
Filippo Valsorda d36e4ce2c7 cmd/age-inspect: new command
Fixes #56
Closes #501
2025-12-23 21:39:39 +01:00
Filippo Valsorda ca8a69b1b6 age: improve error on empty files
Fixes #416
2025-12-23 15:22:55 +01:00
Filippo Valsorda 38dd222823 age: return better errors from Decrypt for scrypt identities 2025-12-23 14:28:05 +01:00
Filippo Valsorda c17d0b362c age: add NoIdentityMatchError.StanzaTypes field
For https://github.com/FiloSottile/age/issues/56#issuecomment-1962622903.
2025-12-23 13:13:03 +01:00
Filippo Valsorda a36341de15 cmd/age: improve error message when -i is missing 2025-12-23 13:11:49 +01:00
Filippo Valsorda f3b008d1b8 plugin: fix returning in-protocol errors from plugins
Fixes Foxboron/age-plugin-tpm#31
2025-12-23 13:11:49 +01:00
Filippo Valsorda 9795b63263 cmd/age,plugin: add plugin.NotFoundError and CLI hint
Fixes #486
2025-12-23 13:11:49 +01:00
Filippo Valsorda bfae75d93d age,plugin: wrap more errors 2025-12-23 12:34:03 +01:00
Filippo Valsorda 0d5b598cd0 cmd/age: warn about duplicate command-line arguments
Fixes #284
2025-12-22 22:08:21 +01:00
GitHub Actions 50acf91174 doc: regenerate groff and html man pages 2025-12-22 18:42:32 +00:00
Filippo Valsorda ba67de8a4e tag/internal/age-plugin-tagtest: add plugin for testing tag recipients 2025-12-22 19:41:12 +01:00
Filippo Valsorda 7fa810b20a tag: add Recipient.Tag and Bytes methods, and update tag scheme 2025-12-22 19:41:12 +01:00
Filippo Valsorda 1b18d6b279 age: update c2sp.org/CCTV/age testkit to include hybrid identities 2025-12-22 19:41:12 +01:00
Filippo Valsorda ad7bb569eb cmd/age: fix testscript setup races 2025-12-22 19:41:12 +01:00
Filippo Valsorda ed44098807 all: upgrade dependencies
Closes #610
2025-12-22 19:41:12 +01:00
Filippo Valsorda 83bab2ae6a plugin: avoid using deprecated math/rand.Read 2025-12-22 19:41:12 +01:00
Filippo Valsorda 96b6476140 armor: reject empty lines in armored data
Caught by the new CCTV test vectors!
2025-12-22 19:41:12 +01:00
Filippo Valsorda d7409cdc74 .github/workflows: update and harden GitHub Actions workflows 2025-12-22 19:41:12 +01:00
Filippo Valsorda de158f906b cmd/age-plugin-tag,cmd/age-plugin-tagpq: new backward compatibility plugins 2025-12-22 19:41:12 +01:00
Filippo Valsorda 78947d862d age: use native identities first in Decrypt 2025-12-22 19:41:12 +01:00
Filippo Valsorda c6fcb5300c age,cmd/age,cmd/age-keygen: add post-quantum hybrid keys 2025-12-22 19:41:12 +01:00
Filippo Valsorda 6ece9e45ee tag: use filippo.io/hpke 2025-12-22 19:41:12 +01:00
Filippo Valsorda e2d30695f2 cmd/age,tag: implement age1tagpq1.../p256mlkem768tag recipients
Test vectors generated from hpkewg/hpke-pq@19adaeb (hpkewg/hpke-pq#28 +
hpkewg/hpke-pq#32) and cfrg/draft-irtf-cfrg-concrete-hybrid-kems@1bbca40
(cfrg/draft-irtf-cfrg-concrete-hybrid-kems#16), plus the following diff:

diff --git a/reference-implementation/src/bin/generate.rs b/reference-implementation/src/bin/generate.rs
index 25e32e5..bc8f209 100644
--- a/reference-implementation/src/bin/generate.rs
+++ b/reference-implementation/src/bin/generate.rs
@@ -26,6 +26,15 @@ fn generate_test_vectors() -> TestVectors {
     // 5. QSF-P384-MLKEM1024 + SHAKE256 + AES-256-GCM
     vectors.push(TestVector::new::<QsfP384MlKem1024, Shake256, Aes256Gcm>());
 
+    vectors = TestVectors::new();
+
+    // age1pq - xwing
+    vectors.push(TestVector::new::<QsfX25519MlKem768, HkdfSha256, ChaChaPoly>());
+    // age1tag - p256tag
+    vectors.push(TestVector::new::<DhkemP256HkdfSha256, HkdfSha256, ChaChaPoly>());
+    // age1tagpq - p256mlkem768tag
+    vectors.push(TestVector::new::<QsfP256MlKem768, HkdfSha256, ChaChaPoly>());
+
     vectors
 }
 
diff --git a/reference-implementation/src/test_vectors.rs b/reference-implementation/src/test_vectors.rs
index 24335aa..4134fb5 100644
--- a/reference-implementation/src/test_vectors.rs
+++ b/reference-implementation/src/test_vectors.rs
@@ -369,6 +369,10 @@ impl TestVector {
             (0x0051, 0x0011, 0x0002) => self.v::<QsfP384MlKem1024, Shake256, Aes256Gcm>(),
             (0x0051, 0x0011, 0xffff) => self.v::<QsfP384MlKem1024, Shake256, ExportOnly>(),
 
+            // age pq combinations
+            (0x647a, 0x0001, 0x0003) => self.v::<QsfX25519MlKem768, HkdfSha256, ChaChaPoly>(),
+            (0x0050, 0x0001, 0x0003) => self.v::<QsfP256MlKem768, HkdfSha256, ChaChaPoly>(),
+
             _ => Err(format!(
                 "Unsupported algorithm combination: KEM={:#x}, KDF={:#x}, AEAD={:#x}",
                 self.kem_id, self.kdf_id, self.aead_id
2025-12-22 19:41:12 +01:00
Filippo Valsorda e9295dd867 cmd/age,tag: implement age1tag1.../p256tag recipients
See C2SP/C2SP#156
2025-12-22 19:41:12 +01:00
Filippo Valsorda acab3e5c9f plugin: add framework to implement plugins (#580)
Fixes #485
2025-12-07 20:10:01 +01:00
Filippo Valsorda a8de3de174 age: add ExtractHeader, DecryptHeader, and NewInjectedFileKeyIdentity 2025-12-07 20:01:09 +01:00
Filippo Valsorda ae74b61b59 cmd/age,internal/stream: improve error messages 2025-12-07 20:01:09 +01:00
Thibault f882f40aa3 cmd/age: echo terminal input for public plugin prompts 2025-12-07 18:59:13 +01:00
Filippo Valsorda 6d2c4e236c README: move Sigsum instructions to separate file 2025-12-07 16:24:46 +01:00
Filippo Valsorda 75063d25b1 LICENSE: move copyright holders from AUTHORS file 2025-12-07 16:24:46 +01:00
Filippo Valsorda 20eba7e285 cmd/age,cmd/age-keygen: remove unnecessary injected Version
golang/go#50603 started stamping the VCS tag version.
2025-12-07 16:24:46 +01:00
Filippo Valsorda 15153e699f README: update Twitter links to GitHub 2025-07-14 18:29:48 +02:00
Helio Machado fce45118ee .github/workflows: fix actions/setup-go cache (#622) 2025-06-15 20:42:25 +02:00
Richard Burte c3657aca5c README: add winget installation instructions (#627) 2025-06-15 20:40:38 +02:00
Filippo Valsorda 0447d8d089 age: add links to docs 2025-05-10 14:59:20 +02:00
Filippo Valsorda 3d91014ea0 README: link to typage 2025-02-01 18:15:20 +01:00
Filippo Valsorda 482cf6fc9b plugin: restrict characters in plugin names
Thanks to ⬡-49016 for reporting this issue.

Fixes GHSA-32gq-x56h-299c
2024-12-18 16:01:18 +01:00
Alexander YastrebovandFilippo Valsorda cda3988cc7 all: fix staticcheck warnings (#589)
Co-authored-by: Filippo Valsorda <github@bip.filippo.io>
2024-12-18 15:55:57 +01:00
Filippo Valsorda 176e245b3c README: rotate Sigsum keys
Switched to a pair of keys, one kept offline and one on a Tillitis key.

The following script provides key continuity from the previous key.

---

cat << EOF > msg.txt
These are the new age Sigsum keys as of 2024-06-28.
The previous one won't be used anymore
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM1WpnEswJLPzvXJDiswowy48U+G+G1kmgwUE2eaRHZG
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAz2WM5CyPLqiNjk7CLl4roDXwKhQ0QExXLebukZEZFS
EOF

cat << EOF > msg.txt.proof
version=1
log=c9e525b98f412ede185ff2ac5abf70920a2e63a6ae31c88b1138b85de328706b
leaf=e2f0 61d17df1ab563aedf70a662d5344b2d163c7a35aaeaa1ecc6c1631c02d46883e c4564b0b0b70ebb4236e26f971cb467c59200575c01c4d07a5d8e298a6d2319c59bbc162363ffb4f690b581851621bd3de311e9559a1a0432522e2b567647e03

size=24226
root_hash=03b0fc19c812e51e764474d161b944db4ea20dfc13815b738fa4f433f56954c0
signature=b95530661d716886926a49ad4e08435c7980348836eefe4706cc611a6af9dc6ddb160189d4aa177c97f807fa0240cb27b9d5f075084cf3a4a2514eade7d40f00
cosignature=1c997261f16e6e81d13f420900a2542a4b6a049c2d996324ee5d82a90ca3360c 1719587282 02d3f9b34d62f1b735e6c13f2ad7766ed2066a167dba25d526a2acbbf628588c58a888f9abab50ff151f8440076e8bf39564e4f893a32be432e97cd18b936c0e
cosignature=70b861a010f25030de6ff6a5267e0b951e70c04b20ba4a3ce41e7fba7b9b7dfc 1719587283 14b8654586763b6798dc7e7b46565236618f2824135076ba71e18908bf893d73be95c143b8c8cbe48de9ea6267ff5f23f8d870ded7d22ed6e54811393d174402

leaf_index=24157
node_hash=c5782abde765bc2c460d25f0bc1f8f89d787e5b8a141e862eeb550631cb61ca2
node_hash=283f014735692dfda045ca9c32352d5d0f99207862c35214dfaa69810eec6e2f
node_hash=24987bf3ec7c3c8932783faf0fe01ff49c7793c3d25925da180165c3292b786d
node_hash=ba14f9dd00a506474251599083e83a4bf7327491f6ebd5cca1ac8a6863456eb5
node_hash=55aceb2864d26c9c04c85a294e92dfdfa13bb45eeda0286d21f19411763237f5
node_hash=69cc9e7f7ad56da996abeb315dc2ffb3e1e1f02e8097facfae75f06f9e8f9bc9
node_hash=1408251ffc0d485551b058813d7b1227bb91a179b9f0851a6e69e9bb99623eec
node_hash=ca99c77b91c6aa9f55fba8d6d9c80058dcc0444a2e9df3f7a616d7e2d274fb58
node_hash=b95d29a868d7e4413dab001a414cfe4cab65e113bc831cf41ab9003250ede3c9
node_hash=5ced132a7cc05272b797b7ad2e71208366d8023d09bad755514cd86e37a849fd
node_hash=95803b981443b4ff080b5a14927e24e5efc8186b327320b633005213ca3aeff4
node_hash=3aad6b63102dede3851d575b01bb60a5832d9f31eb3405b73aa7d629a1acaaa8
node_hash=c929af9f6731f63a493668627f58810d892dc51f8aa1c9a4de1573cd3e51e62f
EOF

sigsum-verify -k age-sigsum-key.pub -p sigsum-trust-policy.txt msg.txt.proof < msg.txt
2024-08-21 12:36:58 +02:00
Filippo Valsorda faefdc3c81 README: document Sigsum proofs 2024-06-19 10:44:28 +02:00
Filippo ValsordaandRene Leonhardt bbe6ce5eeb .github/workflows: update artifacts Actions
Co-authored-by: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com>
2024-06-16 16:01:06 +02:00
Filippo Valsorda 1e1badabf7 .github/workflows: go-version stable, not latest 2024-06-16 14:59:53 +02:00
Filippo Valsorda 2293a9afef .github/workflows: use latest Go for bootstrap 2024-06-16 14:51:17 +02:00
Filippo Valsorda 01fe9cd84a README: add pkgx installation instructions
Closes #529
2024-06-16 14:49:24 +02:00
Filippo Valsorda bd0511b415 cmd/age: detect output/input file reuse when possible
Fixes #491
2024-06-16 14:40:13 +02:00
Filippo Valsorda febaaded87 cmd/age: create file for empty decryptions
Fixes #555
Updates #159
Updates #57
2024-06-16 13:55:32 +02:00
GitHub Actions 0a40718a93 doc: regenerate groff and html man pages 2024-06-16 10:03:57 +00:00
83 changed files with 8013 additions and 942 deletions

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
---
Copyright 2009 The Go Authors.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google LLC nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+57 -28
View File
@@ -16,41 +16,31 @@ jobs:
- {GOOS: linux, GOARCH: amd64} - {GOOS: linux, GOARCH: amd64}
- {GOOS: linux, GOARCH: arm, GOARM: 6} - {GOOS: linux, GOARCH: arm, GOARM: 6}
- {GOOS: linux, GOARCH: arm64} - {GOOS: linux, GOARCH: arm64}
- {GOOS: darwin, GOARCH: amd64}
- {GOOS: darwin, GOARCH: arm64} - {GOOS: darwin, GOARCH: arm64}
- {GOOS: darwin, GOARCH: amd64}
- {GOOS: windows, GOARCH: amd64} - {GOOS: windows, GOARCH: amd64}
- {GOOS: windows, GOARCH: arm64}
- {GOOS: freebsd, GOARCH: amd64} - {GOOS: freebsd, GOARCH: amd64}
steps: steps:
- name: Install Go
uses: actions/setup-go@v2
with:
go-version: 1.x
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false
- name: Install Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: false
- name: Build binary - name: Build binary
run: | run: |
cp LICENSE "$RUNNER_TEMP/LICENSE"
echo -e "\n---\n" >> "$RUNNER_TEMP/LICENSE"
curl -L "https://go.dev/LICENSE?m=text" >> "$RUNNER_TEMP/LICENSE"
VERSION="$(git describe --tags)" VERSION="$(git describe --tags)"
DIR="$(mktemp -d)" DIR="$(mktemp -d)"
mkdir "$DIR/age" mkdir "$DIR/age"
cp "$RUNNER_TEMP/LICENSE" "$DIR/age" go build -o "$DIR/age" -trimpath ./cmd/... ./extra/...
go build -o "$DIR/age" -ldflags "-X main.Version=$VERSION" -trimpath ./cmd/... cp LICENSE "$DIR/age/LICENSE"
cat .github/workflows/LICENSE.suffix.txt >> "$DIR/age/LICENSE"
if [ "$GOOS" == "windows" ]; then if [ "$GOOS" == "windows" ]; then
sudo apt-get update && sudo apt-get install -y osslsigncode
if [ -n "${{ secrets.SIGN_PASS }}" ]; then
for exe in "$DIR"/age/*.exe; do
/usr/bin/osslsigncode sign -t "http://timestamp.comodoca.com" \
-certs .github/workflows/certs/uitacllc.crt \
-key .github/workflows/certs/uitacllc.key \
-pass "${{ secrets.SIGN_PASS }}" \
-n age -in "$exe" -out "$exe.signed"
mv "$exe.signed" "$exe"
done
fi
( cd "$DIR"; zip age.zip -r age ) ( cd "$DIR"; zip age.zip -r age )
mv "$DIR/age.zip" "age-$VERSION-$GOOS-$GOARCH.zip" mv "$DIR/age.zip" "age-$VERSION-$GOOS-$GOARCH.zip"
else else
@@ -62,22 +52,61 @@ jobs:
GOARCH: ${{ matrix.GOARCH }} GOARCH: ${{ matrix.GOARCH }}
GOARM: ${{ matrix.GOARM }} GOARM: ${{ matrix.GOARM }}
- name: Upload workflow artifacts - name: Upload workflow artifacts
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v7
with: with:
name: age-binaries name: age-artifacts-${{ matrix.GOOS }}-${{ matrix.GOARCH }}
path: age-* path: age-*
source:
name: Package source code
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: Install Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: false
- name: Create source tarball
run: |
VERSION="$(git describe --tags)"
DIR="$(mktemp -d)"
mkdir "$DIR/age"
git archive --format=tar.gz HEAD | tar -xz -C "$DIR/age"
( cd "$DIR/age"; go mod vendor )
for cmd in "$DIR"/age/{cmd,extra}/*; do
echo "package main" >> "$cmd/version.go"
echo "" >> "$cmd/version.go"
echo "func init() { Version = \"$VERSION\" }" >> "$cmd/version.go"
done
tar -cvzf "age-$VERSION-source.tar.gz" -C "$DIR" age
- name: Upload workflow artifacts
uses: actions/upload-artifact@v7
with:
name: age-artifacts-source
path: age-*-source.tar.gz
upload: upload:
name: Upload release binaries name: Upload and attest release artifacts
if: github.event_name == 'release' if: github.event_name == 'release'
needs: build needs: [build, source]
permissions: permissions:
contents: write contents: write
attestations: write
id-token: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Download workflow artifacts - name: Download workflow artifacts
uses: actions/download-artifact@v2 uses: actions/download-artifact@v8
with: with:
name: age-binaries pattern: age-artifacts-*
merge-multiple: true
- name: Generate artifacts attestation
uses: actions/attest-build-provenance@v4
with:
subject-path: age-*
- name: Upload release artifacts - name: Upload release artifacts
run: gh release upload "$GITHUB_REF_NAME" age-* run: gh release upload "$GITHUB_REF_NAME" age-*
env: env:
-14
View File
@@ -1,14 +0,0 @@
In this folder there are
uitacllc.crt
PKCS#7 encoded certificate chain for a code signing certificate issued
to Up in the Air Consulting LLC valid until Sep 26 23:59:59 2024 GMT.
https://crt.sh/?id=5339775059
uitacllc.key
PEM encrypted private key for the leaf certificate above.
Its passphrase is long and randomly generated, so the awful legacy key
derivation doesn't really matter, and it makes osslsigncode happy.
Binary file not shown.
-42
View File
@@ -1,42 +0,0 @@
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-256-CBC,B93C1A166F3677D68FB9CB3E8A184729
UriYsaq3tLyvycDDB2YeQ+9L1P5VCPcfVkYR1ocleF8WxNDUPdz3RqbryAZZdXVO
0bcvAHTXkdI4Oiw5mN0S8fGsNq9zn+pyResx3lXtgN3oCDCe2SQn28uEEKxPzud5
0NRXYoBP+pLDjiuQ/6Lp7DovnAO/uxaPFvYRMiknNVOhwyHGWZyuUe01S9J9im7y
vgc1wkyQzmABIhARynEXHp3KnM9aF8X1/ck839lQRBFrvRFNm5rqiON26spr1Hu5
znrbVGROYk0XNdH5VHDk7V9k+v2WLL/b4nxlMymZpDzr9pXzX8olpLnQrarsMbHe
ysfXNTtQi5Dq6KXURW8VA4DmxAzTRNUxe2aA4JnAEyFU5LDLetTN9F9M7BUkHbXH
RpSbZqDjPwg7U98vuSwxjIkncHSiYYi3FmSoupLvV+eIP6qRSgONdzGlP5NTn4Lh
N1lYMPHPldH6UjLHrldkYN16TQlrqNHZExN91XvsZVjpyAgErY18xwi3CTEco45D
fRqsiWXtoas4LkafhSY0vfl5aFhY9YPUpS6uFdgWBvgcQeYb8meX5Nr4dNXVk5Wa
yRlYlW/X0TWC0T9qaBOPN/z7OWO5aL4jYRcKQQ+aR8gFcHGGCpRAKD369OneXfOQ
MD9UHoPG4WTBg/NU9OSskcywfuSOkwAGfBVNXrnEj6tYFjsjYK2nC2gm+opUCfm0
a1FeDb5nQSOgOJKUCO6Aj+0NvDvVLUOsTk1lfzSugIkmUOdV+rXHnrZC+90q8KfN
S2JlzwSZNg0e+VxZpnD7k7axHkbHrbebtrLvzKVnrh3s0OFAXN0isMw7yhhWtzUe
mPoQTZusLDOAJe/QPuNlDUgr4uoVZtoXrPzoZZkw2VFLwYy2g/EYvlK9BdVVTnRm
9Hq9IBDrZw+SV/7roaeVOXbzrQoxEoXcL7eo6iWvV5Q7Ll5C4ovelHKy3IAzcpYP
6LKfxAO2sIKTALrHbtBNG+O4RTtxOva1hyg27V4v2k53CF/GhoBRPSpbbupwppXc
lJJ9RtMTRfhCv/ObhdsJED+YUqFifTJfcnQ1iGN8dnBuGrjXxVCN0wgmv46Pdhn0
tUfGlkFquOOWamaVaIvp6JCVUDa1ezMzleILoYvrxvOuP+dGVrwTwVCXpx4JuUgp
d72/w+EnqlZnwsAzdrErJFXnHux981ZoojmG94km1B6gPPwMB8JRcD67lfhG/vne
IpTuuzGaSInf24cGNig01hbBuKSg79yNY0llkECPBXbEhfkemEMhg1WHoNP2eG8j
MHS5OCT5KiOfi77pSO3M2mGB1HWYE5R0lcMibukK9ZdyIYcTeMZ0RcGm6YSNv570
ok/Ex4LUCW66AIWFefmbIOtJSIMHlNKWRPJwnJxVoE5qgH0f/2xL3k15vpI55lAS
sabzegnYlElPbUlZGhgwjKknxgqMhFIW/ZS0h2FukFLwipr4qI47nHWz5dguNkYn
48sSKg3YMhVx/sT+X2A/6zqsC+p4PT7Ti5ruWb7S9L9vRuBdIDNE9qAwuz0g8Bs3
WhOx6OW2ZqDQEuRhN0lyGA0mwRC4HPFE9b8dnN8lNm+RsnMfNoFxzPnqtsxhEAwa
2a4ijT97ka94lDy7WQ2bwLRz7trKV/T6MeETKE4s7+z2dMTr1f8IwA2uCovFmO9T
aMQAePFEtDT3qwIPu0zH1ocSCkZ50f7RgVmp4FNn03uT/TnsASrr5CS9m8A9gjEn
QiztQyqt27fTT61YkNdA6lwbpFiByugVbS+mWsNa9kvBkgQkcMQwgrELmU9sYdBT
nRMa60i0nEINT/x3zFvT6R7Dl/O8/QhXLeYv20X2roghPw48IovLb8x7dT3YEQSn
ARIXXVPxwOVvS8xcCa69/+1HjC6vNG9dNNnAsVHxB8mDTBqmmLzAMOVzDoNWEgDd
zoRhQ3ORb1brPlKWg8um/svLiSV63ZYi2J8LPamoGmZ/7J8i5rjOpOeG493UICBR
JymmYGUo6/C1Ze8swdMHApVU/spo0s8BCGkMjYUAaxXD7RufN2DuY30Vny/DMn4y
XasuHS9RstD2Okv25PD06Y2H52HJ6MNdArmPZRe0k2ZbhATs5dXOfmaF5Z0f4IkE
G+hsxE1wlCo900ewntx16sBCbI0v9aE+Napf2+ueqPQ06CdfiTG5yOmeXzgR/8zS
KVmTHpmmFpYtj/N350BLAVb/Hwzmh+ieWnO7TUjvNAHUn2i5LZU65rN3GOlPyIlz
DzB2T6KjOUPFKqSRrIin14HLyf5w0vDuJhe5Zpe0hhYKvoKhwCEVefbmkasWeso3
xsXxOOoL39GA0QpYjR6ztqR8fS9jTeu5IY+zY5LO8yS7+StP3H8CcqRMuxb3ntym
-----END RSA PRIVATE KEY-----
+21 -21
View File
@@ -13,23 +13,23 @@ jobs:
name: Ronn name: Ronn
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - uses: actions/checkout@v7
uses: actions/checkout@v4 with:
- name: Install ronn persist-credentials: false
run: sudo apt-get update && sudo apt-get install -y ronn - uses: geomys/sandboxed-step@v1.2.2
- name: Run ronn with:
run: bash -O globstar -c 'ronn **/*.ronn' persist-workspace-changes: true
- name: Undo email mangling run: |
# rdiscount randomizes the output for no good reason, which causes sudo apt-get update && sudo apt-get install -y ronn
# changes to always get committed. Sigh. bash -O globstar -c 'ronn **/*.ronn'
# https://github.com/davidfstr/rdiscount/blob/6b1471ec3/ext/generate.c#L781-L795 # rdiscount randomizes the output for no good reason, which causes
run: |- # changes to always get committed. Sigh.
for f in doc/*.html; do # https://github.com/davidfstr/rdiscount/blob/6b1471ec3/ext/generate.c#L781-L795
awk '/Filippo Valsorda/ { $0 = "<p>Filippo Valsorda <a href=\"mailto:age@filippo.io\" data-bare-link=\"true\">age@filippo.io</a></p>" } { print }' "$f" > "$f.tmp" for f in doc/*.html; do
mv "$f.tmp" "$f" awk '/Filippo Valsorda/ { $0 = "<p>Filippo Valsorda <a href=\"mailto:age@filippo.io\" data-bare-link=\"true\">age@filippo.io</a></p>" } { print }' "$f" > "$f.tmp"
done mv "$f.tmp" "$f"
- name: Upload generated files done
uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v7
with: with:
name: man-pages name: man-pages
path: | path: |
@@ -42,10 +42,10 @@ jobs:
contents: write contents: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - uses: actions/checkout@v7
uses: actions/checkout@v4 with:
- name: Download generated files persist-credentials: true
uses: actions/download-artifact@v2 - uses: actions/download-artifact@v8
with: with:
name: man-pages name: man-pages
path: doc/ path: doc/
+57 -37
View File
@@ -1,55 +1,75 @@
name: Go tests name: Go tests
on: [push, pull_request] on:
push:
pull_request:
schedule: # daily at 09:42 UTC
- cron: '42 9 * * *'
workflow_dispatch:
permissions: permissions:
contents: read contents: read
jobs: jobs:
test: test:
name: Test
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
go: [1.19.x, 1.x] go:
os: [ubuntu-latest, macos-latest, windows-latest] - { go-version: stable }
- { go-version: oldstable }
os:
- ubuntu-latest
- macos-latest
- windows-latest
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Install Go ${{ matrix.go }} - uses: actions/checkout@v7
uses: actions/setup-go@v5
with: with:
go-version: ${{ matrix.go }} persist-credentials: false
- name: Checkout repository - uses: actions/setup-go@v7
uses: actions/checkout@v4
with: with:
fetch-depth: 0 go-version: ${{ matrix.go.go-version }}
- name: Run tests - run: |
run: go test -race ./... go test -race ./...
gotip: test-latest:
name: Test (Go tip) runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: [ubuntu-latest, macos-latest, windows-latest] go:
runs-on: ${{ matrix.os }} - { go-version: stable }
- { go-version: oldstable }
steps: steps:
- name: Install bootstrap Go - uses: actions/checkout@v7
uses: actions/setup-go@v5
with: with:
go-version: 1.22 persist-credentials: false
- name: Install Go tip (UNIX) - uses: actions/setup-go@v7
if: runner.os != 'Windows'
run: |
git clone --filter=tree:0 https://go.googlesource.com/go $HOME/gotip
cd $HOME/gotip/src && ./make.bash
echo "$HOME/gotip/bin" >> $GITHUB_PATH
- name: Install Go tip (Windows)
if: runner.os == 'Windows'
run: |
git clone --filter=tree:0 https://go.googlesource.com/go $HOME/gotip
cd $HOME/gotip/src && ./make.bat
echo "$HOME/gotip/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Checkout repository
uses: actions/checkout@v4
with: with:
fetch-depth: 0 go-version: ${{ matrix.go.go-version }}
- run: go version - uses: geomys/sandboxed-step@v1.2.2
- name: Run tests with:
run: go test -race ./... run: |
go get -u -t ./...
go test -race ./...
staticcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-go@v7
with:
go-version: stable
- uses: geomys/sandboxed-step@v1.2.2
with:
run: go run honnef.co/go/tools/cmd/staticcheck@latest ./...
govulncheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-go@v7
with:
go-version: stable
- uses: geomys/sandboxed-step@v1.2.2
with:
run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
-6
View File
@@ -1,6 +0,0 @@
# This is the official list of age authors for copyright purposes.
# To be included, send a change adding the individual or company
# who owns a contribution's copyright.
Google LLC
Filippo Valsorda
+2
View File
@@ -1,4 +1,6 @@
Copyright 2019 The age Authors Copyright 2019 The age Authors
Copyright 2019 Google LLC
Copyright 2022 Filippo Valsorda
Redistribution and use in source and binary forms, with or without Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are modification, are permitted provided that the following conditions are
+75 -15
View File
@@ -7,31 +7,32 @@
</p> </p>
[![Go Reference](https://pkg.go.dev/badge/filippo.io/age.svg)](https://pkg.go.dev/filippo.io/age) [![Go Reference](https://pkg.go.dev/badge/filippo.io/age.svg)](https://pkg.go.dev/filippo.io/age)
[![Dependencies](https://badges.api.sourcespotter.com/deps?firstparty=filippo.io&package=filippo.io%2Fage)](https://sourcespotter.com/deps/?firstparty=filippo.io&package=filippo.io%2Fage)
[![man page](<https://img.shields.io/badge/age(1)-man%20page-lightgrey>)](https://filippo.io/age/age.1) [![man page](<https://img.shields.io/badge/age(1)-man%20page-lightgrey>)](https://filippo.io/age/age.1)
[![C2SP specification](https://img.shields.io/badge/%C2%A7%23-specification-blueviolet)](https://age-encryption.org/v1) [![C2SP specification](https://img.shields.io/badge/%C2%A7%23-specification-blueviolet)](https://age-encryption.org/v1)
age is a simple, modern and secure file encryption tool, format, and Go library. age is a simple, modern and secure file encryption tool, format, and Go library.
It features small explicit keys, no config options, and UNIX-style composability. It features small explicit keys, post-quantum support, no config options, and UNIX-style composability.
``` ```console
$ age-keygen -o key.txt $ age-keygen -o key.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
$ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age $ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age
$ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
``` ```
📜 The format specification is at [age-encryption.org/v1](https://age-encryption.org/v1). age was designed by [@Benjojo12](https://twitter.com/Benjojo12) and [@FiloSottile](https://twitter.com/FiloSottile). 📜 The format specification is at [age-encryption.org/v1](https://age-encryption.org/v1). age was designed by [@benjojo](https://github.com/benjojo) and [@FiloSottile](https://github.com/FiloSottile).
📬 Follow the maintenance of this project by subscribing to [Maintainer Dispatches](https://filippo.io/newsletter)!
🦀 An alternative interoperable Rust implementation is available at [github.com/str4d/rage](https://github.com/str4d/rage). 🦀 An alternative interoperable Rust implementation is available at [github.com/str4d/rage](https://github.com/str4d/rage).
🌍 [Typage](https://github.com/FiloSottile/typage) is a TypeScript implementation. It works in the browser, Node.js, Deno, and Bun.
🔑 Hardware PIV tokens such as YubiKeys are supported through the [age-plugin-yubikey](https://github.com/str4d/age-plugin-yubikey) plugin. 🔑 Hardware PIV tokens such as YubiKeys are supported through the [age-plugin-yubikey](https://github.com/str4d/age-plugin-yubikey) plugin.
✨ For more plugins, implementations, tools, and integrations, check out the [awesome age](https://github.com/FiloSottile/awesome-age) list. ✨ For more plugins, implementations, tools, and integrations, check out the [awesome age](https://github.com/FiloSottile/awesome-age) list.
💬 The author pronounces it `[aɡe̞]` [with a hard *g*](https://translate.google.com/?sl=it&text=aghe), like GIF, and is always spelled lowercase. 💬 The author pronounces it `[aɡe̞]` [with a hard *g*](https://translate.google.com/?sl=it&text=aghe), like GIF, and it's always spelled lowercase.
## Installation ## Installation
@@ -48,6 +49,12 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
<code>port install age</code> <code>port install age</code>
</td> </td>
</tr> </tr>
<tr>
<td>Windows</td>
<td>
<code>winget install --id FiloSottile.age</code>
</td>
</tr>
<tr> <tr>
<td>Alpine Linux v3.15+</td> <td>Alpine Linux v3.15+</td>
<td> <td>
@@ -85,6 +92,12 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
<code>emerge app-crypt/age</code> <code>emerge app-crypt/age</code>
</td> </td>
</tr> </tr>
<tr>
<td>Guix System</td>
<td>
<code>guix package -i age</code>
</td>
</tr>
<tr> <tr>
<td>NixOS / Nix</td> <td>NixOS / Nix</td>
<td> <td>
@@ -137,11 +150,11 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
On Windows, Linux, macOS, and FreeBSD you can use the pre-built binaries. On Windows, Linux, macOS, and FreeBSD you can use the pre-built binaries.
``` - [https://dl.filippo.io/age/latest?for=linux/amd64](https://dl.filippo.io/age/latest?for=linux/amd64)
https://dl.filippo.io/age/latest?for=linux/amd64 - [https://dl.filippo.io/age/v1.3.2?for=darwin/arm64](https://dl.filippo.io/age/v1.3.2?for=darwin/arm64)
https://dl.filippo.io/age/v1.1.1?for=darwin/arm64 - ...
...
``` If you download the pre-built binaries, you can check their [Sigsum proofs](./SIGSUM.md).
If your system has [a supported version of Go](https://go.dev/dl/), you can build from source. If your system has [a supported version of Go](https://go.dev/dl/), you can build from source.
@@ -170,6 +183,7 @@ Options:
-r, --recipient RECIPIENT Encrypt to the specified RECIPIENT. Can be repeated. -r, --recipient RECIPIENT Encrypt to the specified RECIPIENT. Can be repeated.
-R, --recipients-file PATH Encrypt to recipients listed at PATH. Can be repeated. -R, --recipients-file PATH Encrypt to recipients listed at PATH. Can be repeated.
-i, --identity PATH Use the identity file at PATH. Can be repeated. -i, --identity PATH Use the identity file at PATH. Can be repeated.
--version Print the version.
INPUT defaults to standard input, and OUTPUT defaults to standard output. INPUT defaults to standard input, and OUTPUT defaults to standard output.
If OUTPUT exists, it will be overwritten. If OUTPUT exists, it will be overwritten.
@@ -215,11 +229,33 @@ $ age -R recipients.txt example.jpg > example.jpg.age
If the argument to `-R` (or `-i`) is `-`, the file is read from standard input. If the argument to `-R` (or `-i`) is `-`, the file is read from standard input.
### Post-quantum keys
To generate hybrid post-quantum keys, which are secure against future quantum
computer attacks, use the `-pq` flag with `age-keygen`. This may become the
default in the future.
Post-quantum identities start with `AGE-SECRET-KEY-PQ-1...` and recipients with
`age1pq1...`. The recipients are unfortunately ~2000 characters long.
```console
$ age-keygen -pq -o key.txt
$ age-keygen -y key.txt > recipient.txt
$ age -R recipient.txt example.jpg > example.jpg.age
$ age -d -i key.txt example.jpg.age > example.jpg
```
Support for post-quantum keys is built into age v1.3.0 and later. Alternatively,
the `age-plugin-pq` binary can be installed and placed in `$PATH` to add support
to any version and implementation of age that supports plugins. Recipients will
work out of the box, while identities will have to be converted to plugin
identities with `age-plugin-pq -identity`.
### Passphrases ### Passphrases
Files can be encrypted with a passphrase by using `-p/--passphrase`. By default age will automatically generate a secure passphrase. Passphrase protected files are automatically detected at decrypt time. Files can be encrypted with a passphrase by using `-p/--passphrase`. By default age will automatically generate a secure passphrase. Passphrase protected files are automatically detected at decrypt time.
``` ```console
$ age -p secrets.txt > secrets.txt.age $ age -p secrets.txt > secrets.txt.age
Enter passphrase (leave empty to autogenerate a secure one): Enter passphrase (leave empty to autogenerate a secure one):
Using the autogenerated passphrase "release-response-step-brand-wrap-ankle-pair-unusual-sword-train". Using the autogenerated passphrase "release-response-step-brand-wrap-ankle-pair-unusual-sword-train".
@@ -231,7 +267,7 @@ Enter passphrase:
If an identity file passed to `-i` is a passphrase encrypted age file, it will be automatically decrypted. If an identity file passed to `-i` is a passphrase encrypted age file, it will be automatically decrypted.
``` ```console
$ age-keygen | age -p > key.age $ age-keygen | age -p > key.age
Public key: age1yhm4gctwfmrpz87tdslm550wrx6m79y9f2hdzt0lndjnehwj0ukqrjpyx5 Public key: age1yhm4gctwfmrpz87tdslm550wrx6m79y9f2hdzt0lndjnehwj0ukqrjpyx5
Enter passphrase (leave empty to autogenerate a secure one): Enter passphrase (leave empty to autogenerate a secure one):
@@ -247,7 +283,7 @@ Passphrase-protected identity files are not necessary for most use cases, where
As a convenience feature, age also supports encrypting to `ssh-rsa` and `ssh-ed25519` SSH public keys, and decrypting with the respective private key file. (`ssh-agent` is not supported.) As a convenience feature, age also supports encrypting to `ssh-rsa` and `ssh-ed25519` SSH public keys, and decrypting with the respective private key file. (`ssh-agent` is not supported.)
``` ```console
$ age -R ~/.ssh/id_ed25519.pub example.jpg > example.jpg.age $ age -R ~/.ssh/id_ed25519.pub example.jpg > example.jpg.age
$ age -d -i ~/.ssh/id_ed25519 example.jpg.age > example.jpg $ age -d -i ~/.ssh/id_ed25519 example.jpg.age > example.jpg
``` ```
@@ -258,8 +294,32 @@ Note that SSH key support employs more complex cryptography, and embeds a public
Combining SSH key support and `-R`, you can easily encrypt a file to the SSH keys listed on a GitHub profile. Combining SSH key support and `-R`, you can easily encrypt a file to the SSH keys listed on a GitHub profile.
``` ```console
$ curl https://github.com/benjojo.keys | age -R - example.jpg > example.jpg.age $ curl https://github.com/benjojo.keys | age -R - example.jpg > example.jpg.age
``` ```
Keep in mind that people might not protect SSH keys long-term, since they are revokable when used only for authentication, and that SSH keys held on YubiKeys can't be used to decrypt files. Keep in mind that people might not protect SSH keys long-term, since they are revokable when used only for authentication, and that SSH keys held on YubiKeys can't be used to decrypt files.
### Inspecting encrypted files
The `age-inspect` command can display metadata about an encrypted file without decrypting it, including the recipient types, whether it uses post-quantum encryption, and the payload size.
```console
$ age-inspect secrets.age
secrets.age is an age file, version "age-encryption.org/v1".
This file is encrypted to the following recipient types:
- "mlkem768x25519"
This file uses post-quantum encryption.
Size breakdown (assuming it decrypts successfully):
Header 1627 bytes
Encryption overhead 32 bytes
Payload 42 bytes
-------------------
Total 1701 bytes
Tip: for machine-readable output, use --json.
```
+43
View File
@@ -0,0 +1,43 @@
If you download the pre-built binaries of version v1.2.0+, you can check their
[Sigsum](https://www.sigsum.org) proofs, which are like signatures with extra
transparency: you can cryptographically verify that every proof is logged in a
public append-only log, so the age project can be held accountable for every
binary release we ever produced. This is similar to what the [Go Checksum
Database](https://go.dev/blog/module-mirror-launch) provides.
```shell
cat << EOF > age-sigsum-key.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM1WpnEswJLPzvXJDiswowy48U+G+G1kmgwUE2eaRHZG
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAz2WM5CyPLqiNjk7CLl4roDXwKhQ0QExXLebukZEZFS
EOF
curl -JLO "https://dl.filippo.io/age/v1.3.2?for=darwin/arm64"
curl -JLO "https://dl.filippo.io/age/v1.3.2?for=darwin/arm64&proof"
go install sigsum.org/sigsum-go/cmd/sigsum-verify@v0.13.1
sigsum-verify -k age-sigsum-key.pub -P sigsum-generic-2025-1 \
age-v1.3.2-darwin-arm64.tar.gz.proof < age-v1.3.2-darwin-arm64.tar.gz
```
You can learn more about what's happening above in the [Sigsum
docs](https://www.sigsum.org/getting-started/).
### Release playbook
Dear future me, to sign a new release and produce Sigsum proofs, run the following
```shell
VERSION=v1.3.2
go install sigsum.org/sigsum-go/cmd/sigsum-verify@latest
go install github.com/tillitis/tkey-ssh-agent/cmd/tkey-ssh-agent@main
tkey-ssh-agent --agent-socket tkey-ssh-agent.sock --uss
passage -c other/tkey-ssh-sigsum-age
SSH_AUTH_SOCK=tkey-ssh-agent.sock ssh-add -L > tkey-ssh-agent.pub
passage other/sigsum-ratelimit > sigsum-ratelimit
gh release download $VERSION --repo FiloSottile/age --dir artifacts/
SSH_AUTH_SOCK=tkey-ssh-agent.sock sigsum-submit -k tkey-ssh-agent.pub -P sigsum-generic-2025-1 -a sigsum-ratelimit -d filippo.io artifacts/*
gh release upload $VERSION --repo FiloSottile/age artifacts/*.proof
```
In the future, we will move to reproducing the artifacts locally, and signing
those instead of the ones built by GitHub Actions.
+256 -79
View File
@@ -5,10 +5,10 @@
// Package age implements file encryption according to the age-encryption.org/v1 // Package age implements file encryption according to the age-encryption.org/v1
// specification. // specification.
// //
// For most use cases, use the Encrypt and Decrypt functions with // For most use cases, use the [Encrypt] and [Decrypt] functions with
// X25519Recipient and X25519Identity. If passphrase encryption is required, use // [HybridRecipient] and [HybridIdentity]. If passphrase encryption is
// ScryptRecipient and ScryptIdentity. For compatibility with existing SSH keys // required, use [ScryptRecipient] and [ScryptIdentity]. For compatibility with
// use the filippo.io/age/agessh package. // existing SSH keys use the filippo.io/age/agessh package.
// //
// age encrypted files are binary and not malleable. For encoding them as text, // age encrypted files are binary and not malleable. For encoding them as text,
// use the filippo.io/age/armor package. // use the filippo.io/age/armor package.
@@ -26,13 +26,13 @@
// There is no default path for age keys. Instead, they should be stored at // There is no default path for age keys. Instead, they should be stored at
// application-specific paths. The CLI supports files where private keys are // application-specific paths. The CLI supports files where private keys are
// listed one per line, ignoring empty lines and lines starting with "#". These // listed one per line, ignoring empty lines and lines starting with "#". These
// files can be parsed with ParseIdentities. // files can be parsed with [ParseIdentities].
// //
// When integrating age into a new system, it's recommended that you only // When integrating age into a new system, it's recommended that you only
// support X25519 keys, and not SSH keys. The latter are supported for manual // support native (X25519 and hybrid) keys, and not SSH keys. The latter are
// encryption operations. If you need to tie into existing key management // supported for manual encryption operations. If you need to tie into existing
// infrastructure, you might want to consider implementing your own Recipient // key management infrastructure, you might want to consider implementing your
// and Identity. // own [Recipient] and [Identity].
// //
// # Backwards compatibility // # Backwards compatibility
// //
@@ -46,47 +46,47 @@
package age package age
import ( import (
"bytes"
"crypto/hmac" "crypto/hmac"
"crypto/rand" "crypto/rand"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"slices"
"sort" "sort"
"filippo.io/age/internal/format" "filippo.io/age/internal/format"
"filippo.io/age/internal/stream" "filippo.io/age/internal/stream"
) )
// An Identity is passed to Decrypt to unwrap an opaque file key from a // An Identity is passed to [Decrypt] to unwrap an opaque file key from a
// recipient stanza. It can be for example a secret key like X25519Identity, a // recipient stanza. It can be for example a secret key like [HybridIdentity], a
// plugin, or a custom implementation. // plugin, or a custom implementation.
//
// Unwrap must return an error wrapping ErrIncorrectIdentity if none of the
// recipient stanzas match the identity, any other error will be considered
// fatal.
//
// Most age API users won't need to interact with this directly, and should
// instead pass Recipient implementations to Encrypt and Identity
// implementations to Decrypt.
type Identity interface { type Identity interface {
// Unwrap must return an error wrapping [ErrIncorrectIdentity] if none of
// the recipient stanzas match the identity, any other error will be
// considered fatal.
//
// Most age API users won't need to interact with this method directly, and
// should instead pass [Identity] implementations to [Decrypt].
Unwrap(stanzas []*Stanza) (fileKey []byte, err error) Unwrap(stanzas []*Stanza) (fileKey []byte, err error)
} }
// ErrIncorrectIdentity is returned by [Identity.Unwrap] if none of the
// recipient stanzas match the identity.
var ErrIncorrectIdentity = errors.New("incorrect identity for recipient block") var ErrIncorrectIdentity = errors.New("incorrect identity for recipient block")
// A Recipient is passed to Encrypt to wrap an opaque file key to one or more // A Recipient is passed to [Encrypt] to wrap an opaque file key to one or more
// recipient stanza(s). It can be for example a public key like X25519Recipient, // recipient stanza(s). It can be for example a public key like [HybridRecipient],
// a plugin, or a custom implementation. // a plugin, or a custom implementation.
//
// Most age API users won't need to interact with this directly, and should
// instead pass Recipient implementations to Encrypt and Identity
// implementations to Decrypt.
type Recipient interface { type Recipient interface {
// Most age API users won't need to interact with this method directly, and
// should instead pass [Recipient] implementations to [Encrypt].
Wrap(fileKey []byte) ([]*Stanza, error) Wrap(fileKey []byte) ([]*Stanza, error)
} }
// RecipientWithLabels can be optionally implemented by a Recipient, in which // RecipientWithLabels can be optionally implemented by a [Recipient], in which
// case Encrypt will use WrapWithLabels instead of Wrap. // case [Encrypt] will use WrapWithLabels instead of [Recipient.Wrap].
// //
// Encrypt will succeed only if the labels returned by all the recipients // Encrypt will succeed only if the labels returned by all the recipients
// (assuming the empty set for those that don't implement RecipientWithLabels) // (assuming the empty set for those that don't implement RecipientWithLabels)
@@ -103,9 +103,9 @@ type RecipientWithLabels interface {
// A Stanza is a section of the age header that encapsulates the file key as // A Stanza is a section of the age header that encapsulates the file key as
// encrypted to a specific recipient. // encrypted to a specific recipient.
// //
// Most age API users won't need to interact with this directly, and should // Most age API users won't need to interact with this type directly, and should
// instead pass Recipient implementations to Encrypt and Identity // instead pass [Recipient] implementations to [Encrypt] and [Identity]
// implementations to Decrypt. // implementations to [Decrypt].
type Stanza struct { type Stanza struct {
Type string Type string
Args []string Args []string
@@ -115,35 +115,23 @@ type Stanza struct {
const fileKeySize = 16 const fileKeySize = 16
const streamNonceSize = 16 const streamNonceSize = 16
// Encrypt encrypts a file to one or more recipients. func encryptHdr(fileKey []byte, recipients ...Recipient) (*format.Header, error) {
//
// Writes to the returned WriteCloser are encrypted and written to dst as an age
// file. Every recipient will be able to decrypt the file.
//
// The caller must call Close on the WriteCloser when done for the last chunk to
// be encrypted and flushed to dst.
func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
if len(recipients) == 0 { if len(recipients) == 0 {
return nil, errors.New("no recipients specified") return nil, errors.New("no recipients specified")
} }
fileKey := make([]byte, fileKeySize)
if _, err := rand.Read(fileKey); err != nil {
return nil, err
}
hdr := &format.Header{} hdr := &format.Header{}
var labels []string var labels []string
for i, r := range recipients { for i, r := range recipients {
stanzas, l, err := wrapWithLabels(r, fileKey) stanzas, l, err := wrapWithLabels(r, fileKey)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to wrap key for recipient #%d: %v", i, err) return nil, fmt.Errorf("failed to wrap key for recipient #%d: %w", i, err)
} }
sort.Strings(l) sort.Strings(l)
if i == 0 { if i == 0 {
labels = l labels = l
} else if !slicesEqual(labels, l) { } else if !slices.Equal(labels, l) {
return nil, fmt.Errorf("incompatible recipients") return nil, incompatibleLabelsError(labels, l)
} }
for _, s := range stanzas { for _, s := range stanzas {
hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s)) hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s))
@@ -154,19 +142,62 @@ func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
} else { } else {
hdr.MAC = mac hdr.MAC = mac
} }
return hdr, nil
}
// Encrypt encrypts a file to one or more recipients. Every recipient will be
// able to decrypt the file.
//
// Writes to the returned WriteCloser are encrypted and written to dst as an age
// file. The caller must call Close on the WriteCloser when done for the last
// chunk to be encrypted and flushed to dst.
func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
fileKey := make([]byte, fileKeySize)
rand.Read(fileKey)
hdr, err := encryptHdr(fileKey, recipients...)
if err != nil {
return nil, err
}
if err := hdr.Marshal(dst); err != nil { if err := hdr.Marshal(dst); err != nil {
return nil, fmt.Errorf("failed to write header: %v", err) return nil, fmt.Errorf("failed to write header: %w", err)
} }
nonce := make([]byte, streamNonceSize) nonce := make([]byte, streamNonceSize)
if _, err := rand.Read(nonce); err != nil { rand.Read(nonce)
return nil, err
}
if _, err := dst.Write(nonce); err != nil { if _, err := dst.Write(nonce); err != nil {
return nil, fmt.Errorf("failed to write nonce: %v", err) return nil, fmt.Errorf("failed to write nonce: %w", err)
} }
return stream.NewWriter(streamKey(fileKey, nonce), dst) return stream.NewEncryptWriter(streamKey(fileKey, nonce), dst)
}
// EncryptReader encrypts a file to one or more recipients. Every recipient will be
// able to decrypt the file.
//
// Reads from the returned Reader produce the encrypted file, where the plaintext
// is read from src.
func EncryptReader(src io.Reader, recipients ...Recipient) (io.Reader, error) {
fileKey := make([]byte, fileKeySize)
rand.Read(fileKey)
hdr, err := encryptHdr(fileKey, recipients...)
if err != nil {
return nil, err
}
buf := &bytes.Buffer{}
if err := hdr.Marshal(buf); err != nil {
return nil, fmt.Errorf("failed to prepare header: %w", err)
}
nonce := make([]byte, streamNonceSize)
rand.Read(nonce)
r, err := stream.NewEncryptReader(streamKey(fileKey, nonce), src)
if err != nil {
return nil, err
}
return io.MultiReader(buf, bytes.NewReader(nonce), r), nil
} }
func wrapWithLabels(r Recipient, fileKey []byte) (s []*Stanza, labels []string, err error) { func wrapWithLabels(r Recipient, fileKey []byte) (s []*Stanza, labels []string, err error) {
@@ -177,51 +208,149 @@ func wrapWithLabels(r Recipient, fileKey []byte) (s []*Stanza, labels []string,
return return
} }
func slicesEqual(s1, s2 []string) bool { func incompatibleLabelsError(l1, l2 []string) error {
if len(s1) != len(s2) { hasPQ1 := slices.Contains(l1, "postquantum")
return false hasPQ2 := slices.Contains(l2, "postquantum")
if hasPQ1 != hasPQ2 {
return fmt.Errorf("incompatible recipients: can't mix post-quantum and classic recipients, or the file would be vulnerable to quantum computers")
} }
for i := range s1 { return fmt.Errorf("incompatible recipients: %q and %q can't be mixed", l1, l2)
if s1[i] != s2[i] {
return false
}
}
return true
} }
// NoIdentityMatchError is returned by Decrypt when none of the supplied // NoIdentityMatchError is returned by [Decrypt] when none of the supplied
// identities match the encrypted file. // identities match the encrypted file.
type NoIdentityMatchError struct { type NoIdentityMatchError struct {
// Errors is a slice of all the errors returned to Decrypt by the Unwrap // Errors is a slice of all the errors returned to Decrypt by the Unwrap
// calls it made. They all wrap ErrIncorrectIdentity. // calls it made. They all wrap [ErrIncorrectIdentity].
Errors []error Errors []error
// StanzaTypes are the first argument of each recipient stanza in the
// encrypted file's header.
StanzaTypes []string
} }
func (*NoIdentityMatchError) Error() string { func (e *NoIdentityMatchError) Error() string {
if len(e.Errors) == 1 {
return "identity did not match any of the recipients: " + e.Errors[0].Error()
}
return "no identity matched any of the recipients" return "no identity matched any of the recipients"
} }
// Decrypt decrypts a file encrypted to one or more identities. func (e *NoIdentityMatchError) Unwrap() []error {
// return e.Errors
// It returns a Reader reading the decrypted plaintext of the age file read }
// from src. All identities will be tried until one successfully decrypts the file.
func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error) {
if len(identities) == 0 {
return nil, errors.New("no identities specified")
}
// Decrypt decrypts a file encrypted to one or more identities.
// All identities will be tried until one successfully decrypts the file.
// Native, non-interactive identities are tried before any other identities.
//
// Decrypt returns a Reader reading the decrypted plaintext of the age file read
// from src. If no identity matches the encrypted file, the returned error will
// be of type [NoIdentityMatchError].
func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error) {
hdr, payload, err := format.Parse(src) hdr, payload, err := format.Parse(src)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read header: %w", err) return nil, fmt.Errorf("failed to read header: %w", err)
} }
fileKey, err := decryptHdr(hdr, identities...)
if err != nil {
return nil, err
}
nonce := make([]byte, streamNonceSize)
if _, err := io.ReadFull(payload, nonce); err != nil {
return nil, fmt.Errorf("failed to read nonce: %w", err)
}
return stream.NewDecryptReader(streamKey(fileKey, nonce), payload)
}
// DecryptReaderAt decrypts a file encrypted to one or more identities.
// All identities will be tried until one successfully decrypts the file.
// Native, non-interactive identities are tried before any other identities.
//
// DecryptReaderAt takes an underlying [io.ReaderAt] and its total encrypted
// size, and returns a ReaderAt of the decrypted plaintext and the plaintext
// size. These can be used for example to instantiate an [io.SectionReader],
// which implements [io.Reader] and [io.Seeker], or for [zip.NewReader].
// Note that ReaderAt by definition disregards the seek position of src.
//
// The ReadAt method of the returned ReaderAt can be called concurrently.
// The ReaderAt will internally cache the most recently decrypted chunk.
// DecryptReaderAt reads and decrypts the final chunk before returning,
// to authenticate the plaintext size.
//
// If no identity matches the encrypted file, the returned error will be of
// type [NoIdentityMatchError].
func DecryptReaderAt(src io.ReaderAt, encryptedSize int64, identities ...Identity) (io.ReaderAt, int64, error) {
srcReader := io.NewSectionReader(src, 0, encryptedSize)
hdr, payload, err := format.Parse(srcReader)
if err != nil {
return nil, 0, fmt.Errorf("failed to read header: %w", err)
}
buf := &bytes.Buffer{}
if err := hdr.Marshal(buf); err != nil {
return nil, 0, fmt.Errorf("failed to serialize header: %w", err)
}
fileKey, err := decryptHdr(hdr, identities...)
if err != nil {
return nil, 0, err
}
nonce := make([]byte, streamNonceSize)
if _, err := io.ReadFull(payload, nonce); err != nil {
return nil, 0, fmt.Errorf("failed to read nonce: %w", err)
}
payloadOffset := int64(buf.Len()) + int64(len(nonce))
payloadSize := encryptedSize - payloadOffset
plaintextSize, err := stream.PlaintextSize(payloadSize)
if err != nil {
return nil, 0, err
}
payloadReaderAt := io.NewSectionReader(src, payloadOffset, payloadSize)
r, err := stream.NewDecryptReaderAt(streamKey(fileKey, nonce), payloadReaderAt, payloadSize)
if err != nil {
return nil, 0, err
}
return r, plaintextSize, nil
}
func decryptHdr(hdr *format.Header, identities ...Identity) ([]byte, error) {
if len(identities) == 0 {
return nil, errors.New("no identities specified")
}
// Don't sort the caller's slice in place.
identities = slices.Clone(identities)
slices.SortStableFunc(identities, func(a, b Identity) int {
var aIsNative, bIsNative bool
switch a.(type) {
case *X25519Identity, *HybridIdentity, *ScryptIdentity:
aIsNative = true
}
switch b.(type) {
case *X25519Identity, *HybridIdentity, *ScryptIdentity:
bIsNative = true
}
if aIsNative && !bIsNative {
return -1
}
if !aIsNative && bIsNative {
return 1
}
return 0
})
stanzas := make([]*Stanza, 0, len(hdr.Recipients)) stanzas := make([]*Stanza, 0, len(hdr.Recipients))
errNoMatch := &NoIdentityMatchError{}
for _, s := range hdr.Recipients { for _, s := range hdr.Recipients {
errNoMatch.StanzaTypes = append(errNoMatch.StanzaTypes, s.Type)
stanzas = append(stanzas, (*Stanza)(s)) stanzas = append(stanzas, (*Stanza)(s))
} }
errNoMatch := &NoIdentityMatchError{}
var fileKey []byte var fileKey []byte
for _, id := range identities { for _, id := range identities {
var err error
fileKey, err = id.Unwrap(stanzas) fileKey, err = id.Unwrap(stanzas)
if errors.Is(err, ErrIncorrectIdentity) { if errors.Is(err, ErrIncorrectIdentity) {
errNoMatch.Errors = append(errNoMatch.Errors, err) errNoMatch.Errors = append(errNoMatch.Errors, err)
@@ -243,12 +372,7 @@ func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error) {
return nil, errors.New("bad header MAC") return nil, errors.New("bad header MAC")
} }
nonce := make([]byte, streamNonceSize) return fileKey, nil
if _, err := io.ReadFull(payload, nonce); err != nil {
return nil, fmt.Errorf("failed to read nonce: %w", err)
}
return stream.NewReader(streamKey(fileKey, nonce), payload)
} }
// multiUnwrap is a helper that implements Identity.Unwrap in terms of a // multiUnwrap is a helper that implements Identity.Unwrap in terms of a
@@ -269,3 +393,56 @@ func multiUnwrap(unwrap func(*Stanza) ([]byte, error), stanzas []*Stanza) ([]byt
} }
return nil, ErrIncorrectIdentity return nil, ErrIncorrectIdentity
} }
// ExtractHeader returns a detached header from the src file.
//
// The detached header can be decrypted with [DecryptHeader] (for example on a
// different system, without sharing the ciphertext) and then the file key can
// be used with [NewInjectedFileKeyIdentity].
//
// This is a low-level function that most users won't need.
func ExtractHeader(src io.Reader) ([]byte, error) {
hdr, _, err := format.Parse(src)
if err != nil {
return nil, fmt.Errorf("failed to read header: %w", err)
}
buf := &bytes.Buffer{}
if err := hdr.Marshal(buf); err != nil {
return nil, fmt.Errorf("failed to serialize header: %w", err)
}
return buf.Bytes(), nil
}
// DecryptHeader decrypts a detached header and returns a file key.
//
// The detached header can be produced by [ExtractHeader], and the
// returned file key can be used with [NewInjectedFileKeyIdentity].
//
// This is a low-level function that most users won't need.
// It is the caller's responsibility to keep track of what file the
// returned file key decrypts, and to ensure the file key is not used
// for any other purpose.
func DecryptHeader(header []byte, identities ...Identity) ([]byte, error) {
hdr, _, err := format.Parse(bytes.NewReader(header))
if err != nil {
return nil, fmt.Errorf("failed to read header: %w", err)
}
return decryptHdr(hdr, identities...)
}
type injectedFileKeyIdentity struct {
fileKey []byte
}
// NewInjectedFileKeyIdentity returns an [Identity] that always produces
// a fixed file key, allowing the use of a file key obtained out-of-band,
// for example via [DecryptHeader].
//
// This is a low-level function that most users won't need.
func NewInjectedFileKeyIdentity(fileKey []byte) Identity {
return injectedFileKeyIdentity{fileKey}
}
func (i injectedFileKeyIdentity) Unwrap(stanzas []*Stanza) (fileKey []byte, err error) {
return i.fileKey, nil
}
+494
View File
@@ -5,15 +5,22 @@
package age_test package age_test
import ( import (
"archive/zip"
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"log" "log"
"os" "os"
"slices"
"strings" "strings"
"sync"
"testing" "testing"
"filippo.io/age" "filippo.io/age"
"filippo.io/age/internal/bech32"
"filippo.io/age/plugin"
) )
func ExampleEncrypt() { func ExampleEncrypt() {
@@ -189,6 +196,40 @@ func TestEncryptDecryptScrypt(t *testing.T) {
} }
} }
func ExampleDecryptReaderAt() {
identity, err := age.ParseX25519Identity(privateKey)
if err != nil {
log.Fatalf("Failed to parse private key: %v", err)
}
f, err := os.Open("testdata/example.zip.age")
if err != nil {
log.Fatalf("Failed to open file: %v", err)
}
stat, err := f.Stat()
if err != nil {
log.Fatalf("Failed to stat file: %v", err)
}
r, size, err := age.DecryptReaderAt(f, stat.Size(), identity)
if err != nil {
log.Fatalf("Failed to open encrypted file: %v", err)
}
z, err := zip.NewReader(r, size)
if err != nil {
log.Fatalf("Failed to open zip: %v", err)
}
contents, err := fs.ReadFile(z, "example.txt")
if err != nil {
log.Fatalf("Failed to read file from zip: %v", err)
}
fmt.Printf("File contents: %q\n", contents)
// Output:
// File contents: "Black lives matter."
}
func TestParseIdentities(t *testing.T) { func TestParseIdentities(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -221,6 +262,112 @@ AGE-SECRET-KEY--1D6K0SGAX3NU66R4GYFZY0UQWCLM3UUSF3CXLW4KXZM342WQSJ82QKU59Q`},
} }
} }
func TestParseErrorsDoNotIncludeLine(t *testing.T) {
x25519, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
hybrid, err := age.GenerateHybridIdentity()
if err != nil {
t.Fatal(err)
}
// Re-encode the hybrid identity as a plugin identity.
_, seed, err := bech32.Decode(hybrid.String())
if err != nil {
t.Fatal(err)
}
pluginIdentity, err := bech32.Encode("AGE-PLUGIN-PQ-", seed)
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
line string
parse func(io.Reader) error
}{
{"identities/plugin", pluginIdentity, func(r io.Reader) error {
_, err := age.ParseIdentities(r)
return err
}},
{"recipients/x25519", x25519.String(), func(r io.Reader) error {
_, err := age.ParseRecipients(r)
return err
}},
{"recipients/hybrid", hybrid.String(), func(r io.Reader) error {
_, err := age.ParseRecipients(r)
return err
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.parse(strings.NewReader("# a comment\n" + tt.line + "\n"))
if err == nil {
t.Fatal("expected an error for an unrecognized line, got nil")
}
if strings.Contains(err.Error(), tt.line) {
t.Errorf("error includes the private key from the file: %v", err)
}
if !strings.Contains(err.Error(), "line 2") {
t.Errorf("error doesn't say which line failed: %v", err)
}
})
}
}
func paddedFile(pad int, lines string) io.Reader {
const chunkSize = 1 << 16
chunk := strings.Repeat("#\n", chunkSize/2)
var readers []io.Reader
if pad%2 != 0 {
readers = append(readers, strings.NewReader("\n"))
pad--
}
for ; pad > chunkSize; pad -= chunkSize {
readers = append(readers, strings.NewReader(chunk))
}
return io.MultiReader(append(readers,
strings.NewReader(chunk[:pad]), strings.NewReader(lines))...)
}
func TestParseFileSizeLimit(t *testing.T) {
const sizeLimit = 1 << 24
const identity = "AGE-SECRET-KEY-1D6K0SGAX3NU66R4GYFZY0UQWCLM3UUSF3CXLW4KXZM342WQSJ82QKU59QJ\n"
const recipient = "age1cy0su9fwf3gf9mw868g5yut09p6nytfmmnktexz2ya5uqg9vl9sss4euqm\n"
tests := []struct {
name string
entry string
parse func(io.Reader) (int, error)
}{
{"identities", identity, func(r io.Reader) (int, error) {
ids, err := age.ParseIdentities(r)
return len(ids), err
}},
{"recipients", recipient, func(r io.Reader) (int, error) {
recipients, err := age.ParseRecipients(r)
return len(recipients), err
}},
}
for _, tt := range tests {
t.Run(tt.name+"/at limit", func(t *testing.T) {
r := paddedFile(sizeLimit-4*len(tt.entry), strings.Repeat(tt.entry, 4))
n, err := tt.parse(r)
if err != nil {
t.Fatal(err)
}
if n != 4 {
t.Errorf("got %d entries, want 4", n)
}
})
t.Run(tt.name+"/over limit", func(t *testing.T) {
r := paddedFile(sizeLimit-4*len(tt.entry), strings.Repeat(tt.entry, 8))
if _, err := tt.parse(r); err == nil {
t.Error("expected an error")
}
})
}
}
type testRecipient struct { type testRecipient struct {
labels []string labels []string
} }
@@ -233,6 +380,22 @@ func (t testRecipient) WrapWithLabels(fileKey []byte) (s []*age.Stanza, labels [
return []*age.Stanza{{Type: "test"}}, t.labels, nil return []*age.Stanza{{Type: "test"}}, t.labels, nil
} }
type emptyRecipient struct{}
func (emptyRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
return nil, nil
}
func TestEmptyRecipient(t *testing.T) {
buf := &bytes.Buffer{}
if _, err := age.Encrypt(buf, emptyRecipient{}); err == nil {
t.Fatal("Encrypt accepted a recipient with no stanzas")
}
if buf.Len() != 0 {
t.Errorf("Encrypt wrote %d bytes", buf.Len())
}
}
func TestLabels(t *testing.T) { func TestLabels(t *testing.T) {
scrypt, err := age.NewScryptRecipient("xxx") scrypt, err := age.NewScryptRecipient("xxx")
if err != nil { if err != nil {
@@ -284,3 +447,334 @@ func TestLabels(t *testing.T) {
t.Errorf("expected pqc+foo mixed with foo+pqc to work, got %v", err) t.Errorf("expected pqc+foo mixed with foo+pqc to work, got %v", err)
} }
} }
// testIdentity is a non-native identity that records if Unwrap is called.
type testIdentity struct {
called bool
}
func (ti *testIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
ti.called = true
return nil, age.ErrIncorrectIdentity
}
type noMatchIdentity int
func (noMatchIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
return nil, age.ErrIncorrectIdentity
}
func TestDecryptDoesNotReorderIdentities(t *testing.T) {
identity, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
buf := &bytes.Buffer{}
w, err := age.Encrypt(buf, identity.Recipient())
if err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
encrypted := buf.Bytes()
header, err := age.ExtractHeader(bytes.NewReader(encrypted))
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
decrypt func([]age.Identity) error
}{
{"Decrypt", func(identities []age.Identity) error {
_, err := age.Decrypt(bytes.NewReader(encrypted), identities...)
return err
}},
{"DecryptReaderAt", func(identities []age.Identity) error {
_, _, err := age.DecryptReaderAt(bytes.NewReader(encrypted), int64(len(encrypted)), identities...)
return err
}},
{"DecryptHeader", func(identities []age.Identity) error {
_, err := age.DecryptHeader(header, identities...)
return err
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
identities := []age.Identity{noMatchIdentity(1), identity, noMatchIdentity(2)}
want := slices.Clone(identities)
if err := tt.decrypt(identities); err != nil {
t.Fatal(err)
}
if !slices.Equal(identities, want) {
t.Error("identities were reordered")
}
})
}
t.Run("concurrent", func(t *testing.T) {
identities := []age.Identity{noMatchIdentity(1), identity, noMatchIdentity(2)}
want := slices.Clone(identities)
var wg sync.WaitGroup
for range 4 {
wg.Add(1)
go func() {
defer wg.Done()
for range 20 {
if _, err := age.Decrypt(bytes.NewReader(encrypted), identities...); err != nil {
t.Error(err)
}
}
}()
}
wg.Wait()
if !slices.Equal(identities, want) {
t.Error("identities were reordered")
}
})
}
func TestDecryptNativeIdentitiesFirst(t *testing.T) {
correct, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
unrelated, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
buf := &bytes.Buffer{}
w, err := age.Encrypt(buf, correct.Recipient())
if err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
nonNative := &testIdentity{}
// Pass identities: unrelated native, non-native, correct native.
// Native identities should be tried first, so correct should match
// before nonNative is ever called.
_, err = age.Decrypt(bytes.NewReader(buf.Bytes()), unrelated, nonNative, correct)
if err != nil {
t.Fatal(err)
}
if nonNative.called {
t.Error("non-native identity was called, but native identities should be tried first")
}
}
type stanzaTypeRecipient string
func (s stanzaTypeRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
return []*age.Stanza{{Type: string(s)}}, nil
}
func TestNoIdentityMatchErrorStanzaTypes(t *testing.T) {
a, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
b, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
wrong, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
buf := &bytes.Buffer{}
w, err := age.Encrypt(buf, a.Recipient(), stanzaTypeRecipient("other"), b.Recipient())
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(w, helloWorld); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
_, err = age.Decrypt(bytes.NewReader(buf.Bytes()), wrong)
if err == nil {
t.Fatal("expected decryption to fail")
}
var noMatch *age.NoIdentityMatchError
if !errors.As(err, &noMatch) {
t.Fatalf("expected NoIdentityMatchError, got %T: %v", err, err)
}
want := []string{"X25519", "other", "X25519"}
if !slices.Equal(noMatch.StanzaTypes, want) {
t.Errorf("StanzaTypes = %v, want %v", noMatch.StanzaTypes, want)
}
}
func TestScryptIdentityErrors(t *testing.T) {
t.Run("not passphrase-encrypted", func(t *testing.T) {
i, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
buf := &bytes.Buffer{}
w, err := age.Encrypt(buf, i.Recipient())
if err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
scryptID, err := age.NewScryptIdentity("password")
if err != nil {
t.Fatal(err)
}
_, err = age.Decrypt(bytes.NewReader(buf.Bytes()), scryptID)
if err == nil {
t.Fatal("expected decryption to fail")
}
if !errors.Is(err, age.ErrIncorrectIdentity) {
t.Errorf("expected ErrIncorrectIdentity, got %v", err)
}
if !strings.Contains(err.Error(), "not passphrase-encrypted") {
t.Errorf("expected error to mention 'not passphrase-encrypted', got %v", err)
}
})
t.Run("incorrect passphrase", func(t *testing.T) {
r, err := age.NewScryptRecipient("correct-password")
if err != nil {
t.Fatal(err)
}
r.SetWorkFactor(10) // Low for fast test
buf := &bytes.Buffer{}
w, err := age.Encrypt(buf, r)
if err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
scryptID, err := age.NewScryptIdentity("wrong-password")
if err != nil {
t.Fatal(err)
}
_, err = age.Decrypt(bytes.NewReader(buf.Bytes()), scryptID)
if err == nil {
t.Fatal("expected decryption to fail")
}
if !errors.Is(err, age.ErrIncorrectIdentity) {
t.Errorf("expected ErrIncorrectIdentity, got %v", err)
}
if !strings.Contains(err.Error(), "incorrect passphrase") {
t.Errorf("expected error to mention 'incorrect passphrase', got %v", err)
}
})
}
func TestDetachedHeader(t *testing.T) {
i, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
buf := &bytes.Buffer{}
w, err := age.Encrypt(buf, i.Recipient())
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(w, helloWorld); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
encrypted := buf.Bytes()
header, err := age.ExtractHeader(bytes.NewReader(encrypted))
if err != nil {
t.Fatal(err)
}
fileKey, err := age.DecryptHeader(header, i)
if err != nil {
t.Fatal(err)
}
identity := age.NewInjectedFileKeyIdentity(fileKey)
out, err := age.Decrypt(bytes.NewReader(encrypted), identity)
if err != nil {
t.Fatal(err)
}
outBytes, err := io.ReadAll(out)
if err != nil {
t.Fatal(err)
}
if string(outBytes) != helloWorld {
t.Errorf("wrong data: %q, expected %q", outBytes, helloWorld)
}
}
func TestEncryptReader(t *testing.T) {
a, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
r, err := age.EncryptReader(strings.NewReader(helloWorld), a.Recipient())
if err != nil {
t.Fatal(err)
}
buf := &bytes.Buffer{}
if _, err := io.Copy(buf, r); err != nil {
t.Fatal(err)
}
out, err := age.Decrypt(buf, a)
if err != nil {
t.Fatal(err)
}
outBytes, err := io.ReadAll(out)
if err != nil {
t.Fatal(err)
}
if string(outBytes) != helloWorld {
t.Errorf("wrong data: %q, excepted %q", outBytes, helloWorld)
}
}
func TestParseUnicode(t *testing.T) {
// U+212A folds to "k", shrinking the data part below the checksum.
w := "AA3100AC" + string(rune(0x212A))
for _, tc := range []struct {
name string
fn func(string) error
}{
{"age.ParseX25519Recipient", func(s string) error { _, err := age.ParseX25519Recipient(s); return err }},
{"age.ParseX25519Identity", func(s string) error { _, err := age.ParseX25519Identity(s); return err }},
{"age.ParseHybridRecipient", func(s string) error { _, err := age.ParseHybridRecipient(s); return err }},
{"age.ParseHybridIdentity", func(s string) error { _, err := age.ParseHybridIdentity(s); return err }},
{"plugin.ParseIdentity", func(s string) error { _, _, err := plugin.ParseIdentity(s); return err }},
{"plugin.ParseRecipient", func(s string) error { _, _, err := plugin.ParseRecipient(s); return err }},
} {
t.Run(tc.name, func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("%s panicked on malformed input: %v", tc.name, r)
}
}()
if err := tc.fn(w); err == nil {
t.Errorf("%s returned nil error, want error", tc.name)
}
})
}
}
+11 -4
View File
@@ -7,7 +7,7 @@
// encryption with age-encryption.org/v1. // encryption with age-encryption.org/v1.
// //
// These recipient types should only be used for compatibility with existing // These recipient types should only be used for compatibility with existing
// keys, and native X25519 keys should be preferred otherwise. // keys, and native keys should be preferred otherwise.
// //
// Note that these recipient types are not anonymous: the encrypted message will // Note that these recipient types are not anonymous: the encrypted message will
// include a short 32-bit ID of the public key. // include a short 32-bit ID of the public key.
@@ -63,13 +63,16 @@ func NewRSARecipient(pk ssh.PublicKey) (*RSARecipient, error) {
} else { } else {
return nil, errors.New("pk does not implement ssh.CryptoPublicKey") return nil, errors.New("pk does not implement ssh.CryptoPublicKey")
} }
if r.pubKey.Size() < 2048/8 { if r.pubKey.N.BitLen() < 2048 {
return nil, errors.New("RSA key size is too small") return nil, errors.New("RSA key size is too small")
} }
return r, nil return r, nil
} }
func (r *RSARecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) { func (r *RSARecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
if r.pubKey.N.BitLen() < 2048 {
return nil, errors.New("RSA key size is too small")
}
l := &age.Stanza{ l := &age.Stanza{
Type: "ssh-rsa", Type: "ssh-rsa",
Args: []string{sshFingerprint(r.sshKey)}, Args: []string{sshFingerprint(r.sshKey)},
@@ -129,7 +132,9 @@ func (i *RSAIdentity) unwrap(block *age.Stanza) ([]byte, error) {
fileKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, i.k, fileKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, i.k,
block.Body, []byte(oaepLabel)) block.Body, []byte(oaepLabel))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decrypt file key: %v", err) // The fingerprint is only a short hint, and might collide with the
// fingerprint of a different recipient.
return nil, age.ErrIncorrectIdentity
} }
return fileKey, nil return fileKey, nil
} }
@@ -344,7 +349,9 @@ func (i *Ed25519Identity) unwrap(block *age.Stanza) ([]byte, error) {
fileKey, err := aeadDecrypt(wrappingKey, block.Body) fileKey, err := aeadDecrypt(wrappingKey, block.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decrypt file key: %v", err) // The fingerprint is only a short hint, and might collide with the
// fingerprint of a different recipient.
return nil, age.ErrIncorrectIdentity
} }
return fileKey, nil return fileKey, nil
} }
+178
View File
@@ -9,6 +9,8 @@ import (
"crypto/ed25519" "crypto/ed25519"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"encoding/pem"
"math/big"
"reflect" "reflect"
"testing" "testing"
@@ -16,6 +18,33 @@ import (
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
func TestSSHRSAKeySize(t *testing.T) {
for _, bits := range []int{2041, 2047, 2048} {
n := new(big.Int).Lsh(big.NewInt(1), uint(bits-1))
n.SetBit(n, 0, 1)
pub, err := ssh.NewPublicKey(&rsa.PublicKey{N: n, E: 65537})
if err != nil {
t.Fatal(err)
}
_, err = agessh.NewRSARecipient(pub)
if rejected, want := err != nil, bits < 2048; rejected != want {
t.Errorf("%d-bit key: rejected = %v, want %v", bits, rejected, want)
}
}
key, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
t.Fatal(err)
}
id, err := agessh.NewRSAIdentity(key)
if err != nil {
t.Fatal(err)
}
if _, err := id.Recipient().Wrap(make([]byte, 16)); err == nil {
t.Error("RSAIdentity.Recipient accepted a 1024-bit key")
}
}
func TestSSHRSARoundTrip(t *testing.T) { func TestSSHRSARoundTrip(t *testing.T) {
pk, err := rsa.GenerateKey(rand.Reader, 2048) pk, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil { if err != nil {
@@ -59,6 +88,48 @@ func TestSSHRSARoundTrip(t *testing.T) {
} }
} }
func TestSSHRSAFingerprintCollision(t *testing.T) {
targetKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
targetIdentity, err := agessh.NewRSAIdentity(targetKey)
if err != nil {
t.Fatal(err)
}
otherKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
otherIdentity, err := agessh.NewRSAIdentity(otherKey)
if err != nil {
t.Fatal(err)
}
fileKey := make([]byte, 16)
if _, err := rand.Read(fileKey); err != nil {
t.Fatal(err)
}
stanzas, err := targetIdentity.Recipient().Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
collision, err := otherIdentity.Recipient().Wrap(make([]byte, 16))
if err != nil {
t.Fatal(err)
}
collision[0].Args[0] = stanzas[0].Args[0]
out, err := targetIdentity.Unwrap(append(collision, stanzas...))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fileKey, out) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
}
func TestSSHEd25519RoundTrip(t *testing.T) { func TestSSHEd25519RoundTrip(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(rand.Reader) pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil { if err != nil {
@@ -101,3 +172,110 @@ func TestSSHEd25519RoundTrip(t *testing.T) {
t.Errorf("invalid output: %x, expected %x", out, fileKey) t.Errorf("invalid output: %x, expected %x", out, fileKey)
} }
} }
func TestSSHEd25519FingerprintCollision(t *testing.T) {
_, targetKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
targetIdentity, err := agessh.NewEd25519Identity(targetKey)
if err != nil {
t.Fatal(err)
}
_, otherKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
otherIdentity, err := agessh.NewEd25519Identity(otherKey)
if err != nil {
t.Fatal(err)
}
fileKey := make([]byte, 16)
if _, err := rand.Read(fileKey); err != nil {
t.Fatal(err)
}
stanzas, err := targetIdentity.Recipient().Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
collision, err := otherIdentity.Recipient().Wrap(make([]byte, 16))
if err != nil {
t.Fatal(err)
}
collision[0].Args[0] = stanzas[0].Args[0]
out, err := targetIdentity.Unwrap(append(collision, stanzas...))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fileKey, out) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
}
func TestEncryptedSSHIdentityMismatch(t *testing.T) {
announcedPub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
embeddedPub, embeddedPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
announcedKey, err := ssh.NewPublicKey(announcedPub)
if err != nil {
t.Fatal(err)
}
embeddedKey, err := ssh.NewPublicKey(embeddedPub)
if err != nil {
t.Fatal(err)
}
passphrase := []byte("passphrase")
block, err := ssh.MarshalPrivateKeyWithPassphrase(embeddedPriv, "", passphrase)
if err != nil {
t.Fatal(err)
}
prompts := 0
i, err := agessh.NewEncryptedSSHIdentity(announcedKey, pem.EncodeToMemory(block), func() ([]byte, error) {
prompts++
return passphrase, nil
})
if err != nil {
t.Fatal(err)
}
announcedRecipient, err := agessh.NewEd25519Recipient(announcedKey)
if err != nil {
t.Fatal(err)
}
embeddedRecipient, err := agessh.NewEd25519Recipient(embeddedKey)
if err != nil {
t.Fatal(err)
}
fileKey := make([]byte, 16)
if _, err := rand.Read(fileKey); err != nil {
t.Fatal(err)
}
stanzas, err := announcedRecipient.Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
embeddedStanzas, err := embeddedRecipient.Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
stanzas = append(stanzas, embeddedStanzas...)
for attempt := 1; attempt <= 2; attempt++ {
_, err := i.Unwrap(stanzas)
if err == nil || err.Error() != "mismatched private and public SSH key" {
t.Fatalf("attempt %d: unexpected error: %v", attempt, err)
}
if prompts != attempt {
t.Fatalf("attempt %d: passphrase callback invoked %d times", attempt, prompts)
}
}
}
+5 -3
View File
@@ -109,16 +109,17 @@ func (i *EncryptedSSHIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, er
var pubKey interface { var pubKey interface {
Equal(x crypto.PublicKey) bool Equal(x crypto.PublicKey) bool
} }
var decrypted age.Identity
switch k := k.(type) { switch k := k.(type) {
case *ed25519.PrivateKey: case *ed25519.PrivateKey:
i.decrypted, err = NewEd25519Identity(*k) decrypted, err = NewEd25519Identity(*k)
pubKey = k.Public().(ed25519.PublicKey) pubKey = k.Public().(ed25519.PublicKey)
// ParseRawPrivateKey returns inconsistent types. See Issue 429. // ParseRawPrivateKey returns inconsistent types. See Issue 429.
case ed25519.PrivateKey: case ed25519.PrivateKey:
i.decrypted, err = NewEd25519Identity(k) decrypted, err = NewEd25519Identity(k)
pubKey = k.Public().(ed25519.PublicKey) pubKey = k.Public().(ed25519.PublicKey)
case *rsa.PrivateKey: case *rsa.PrivateKey:
i.decrypted, err = NewRSAIdentity(k) decrypted, err = NewRSAIdentity(k)
pubKey = &k.PublicKey pubKey = &k.PublicKey
default: default:
return nil, fmt.Errorf("unexpected SSH key type: %T", k) return nil, fmt.Errorf("unexpected SSH key type: %T", k)
@@ -131,5 +132,6 @@ func (i *EncryptedSSHIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, er
return nil, fmt.Errorf("mismatched private and public SSH key") return nil, fmt.Errorf("mismatched private and public SSH key")
} }
i.decrypted = decrypted
return i.decrypted.Unwrap(stanzas) return i.decrypted.Unwrap(stanzas)
} }
+26 -5
View File
@@ -31,13 +31,24 @@ type armoredWriter struct {
dst io.Writer dst io.Writer
} }
func (a *armoredWriter) Write(p []byte) (int, error) { func (a *armoredWriter) writeHeader() error {
if !a.started { if a.started {
if _, err := io.WriteString(a.dst, Header+"\n"); err != nil { return nil
return 0, err }
} if _, err := io.WriteString(a.dst, Header+"\n"); err != nil {
return err
} }
a.started = true a.started = true
return nil
}
func (a *armoredWriter) Write(p []byte) (int, error) {
if a.closed {
return 0, errors.New("ArmoredWriter already closed")
}
if err := a.writeHeader(); err != nil {
return 0, err
}
return a.encoder.Write(p) return a.encoder.Write(p)
} }
@@ -46,6 +57,9 @@ func (a *armoredWriter) Close() error {
return errors.New("ArmoredWriter already closed") return errors.New("ArmoredWriter already closed")
} }
a.closed = true a.closed = true
if err := a.writeHeader(); err != nil {
return err
}
if err := a.encoder.Close(); err != nil { if err := a.encoder.Close(); err != nil {
return err return err
} }
@@ -140,9 +154,16 @@ func (r *armoredReader) Read(p []byte) (int, error) {
if string(line) == Footer { if string(line) == Footer {
return 0, r.setErr(drainTrailing()) return 0, r.setErr(drainTrailing())
} }
if len(line) == 0 {
return 0, r.setErr(errors.New("empty line in armored data"))
}
if len(line) > format.ColumnsPerLine { if len(line) > format.ColumnsPerLine {
return 0, r.setErr(errors.New("column limit exceeded")) return 0, r.setErr(errors.New("column limit exceeded"))
} }
// Reject newline characters ignored by base64.Decode.
if bytes.ContainsAny(line, "\n\r") {
return 0, r.setErr(errors.New("unexpected newline character"))
}
r.unread = r.buf[:] r.unread = r.buf[:]
n, err := base64.StdEncoding.Strict().Decode(r.unread, line) n, err := base64.StdEncoding.Strict().Decode(r.unread, line)
if err != nil { if err != nil {
+31 -5
View File
@@ -3,7 +3,6 @@
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
//go:build go1.18 //go:build go1.18
// +build go1.18
package armor_test package armor_test
@@ -13,12 +12,12 @@ import (
"encoding/pem" "encoding/pem"
"fmt" "fmt"
"io" "io"
"io/fs"
"log" "log"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
agetest "c2sp.org/CCTV/age"
"filippo.io/age" "filippo.io/age"
"filippo.io/age/armor" "filippo.io/age/armor"
"filippo.io/age/internal/format" "filippo.io/age/internal/format"
@@ -95,6 +94,28 @@ func TestArmor(t *testing.T) {
t.Run("FullLine", func(t *testing.T) { testArmor(t, 10*format.BytesPerLine) }) t.Run("FullLine", func(t *testing.T) { testArmor(t, 10*format.BytesPerLine) })
} }
func TestWriterLifecycle(t *testing.T) {
t.Run("CloseWithoutWrite", func(t *testing.T) {
buf := &bytes.Buffer{}
w := armor.NewWriter(buf)
if err := w.Close(); err != nil {
t.Fatal(err)
}
if want := armor.Header + "\n" + armor.Footer + "\n"; buf.String() != want {
t.Errorf("output = %q, want %q", buf.String(), want)
}
})
t.Run("WriteAfterClose", func(t *testing.T) {
w := armor.NewWriter(io.Discard)
if err := w.Close(); err != nil {
t.Fatal(err)
}
if n, err := w.Write([]byte("x")); n != 0 || err == nil {
t.Errorf("Write after Close = %d, %v", n, err)
}
})
}
func testArmor(t *testing.T, size int) { func testArmor(t *testing.T, size int) {
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
w := armor.NewWriter(buf) w := armor.NewWriter(buf)
@@ -135,12 +156,13 @@ func testArmor(t *testing.T, size int) {
} }
func FuzzMalleability(f *testing.F) { func FuzzMalleability(f *testing.F) {
tests, err := filepath.Glob("../testdata/testkit/*") tests, err := fs.ReadDir(agetest.Vectors, ".")
if err != nil { if err != nil {
f.Fatal(err) f.Fatal(err)
} }
seeds := 0
for _, test := range tests { for _, test := range tests {
contents, err := os.ReadFile(test) contents, err := fs.ReadFile(agetest.Vectors, test.Name())
if err != nil { if err != nil {
f.Fatal(err) f.Fatal(err)
} }
@@ -150,8 +172,12 @@ func FuzzMalleability(f *testing.F) {
} }
if bytes.Contains(header, []byte("armored: yes")) { if bytes.Contains(header, []byte("armored: yes")) {
f.Add(contents) f.Add(contents)
seeds++
} }
} }
if seeds == 0 {
f.Fatal("no armored test vectors")
}
f.Fuzz(func(t *testing.T, data []byte) { f.Fuzz(func(t *testing.T, data []byte) {
r := armor.NewReader(bytes.NewReader(data)) r := armor.NewReader(bytes.NewReader(data))
content, err := io.ReadAll(r) content, err := io.ReadAll(r)
+131
View File
@@ -0,0 +1,131 @@
// Copyright 2025 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"os"
"runtime/debug"
"filippo.io/age/internal/inspect"
)
const usage = `Usage:
age-inspect [--json] [INPUT]
Options:
--json Output machine-readable JSON.
INPUT defaults to standard input. "-" may be used as INPUT to explicitly
read from standard input.`
// Version can be set at link time to override debug.BuildInfo.Main.Version when
// building manually without git history. It should look like "v1.2.3".
var Version string
func main() {
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
var (
versionFlag bool
jsonFlag bool
)
flag.BoolVar(&versionFlag, "version", false, "print the version")
flag.BoolVar(&jsonFlag, "json", false, "output machine-readable JSON")
flag.Parse()
if versionFlag {
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
Version = buildInfo.Main.Version
}
fmt.Println(Version)
return
}
if flag.NArg() > 1 {
flag.Usage()
os.Exit(1)
}
in := os.Stdin
var fileSize int64 = -1
if name := flag.Arg(0); name != "" && name != "-" {
f, err := os.Open(name)
if err != nil {
errorf("failed to open input file %q: %v", name, err)
}
defer f.Close()
in = f
if stat, err := f.Stat(); err == nil && stat.Mode().IsRegular() {
fileSize = stat.Size()
}
}
data, err := inspect.Inspect(in, fileSize)
if err != nil {
errorf("inspection failed: %v", err)
}
if jsonFlag {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(data); err != nil {
errorf("failed to encode JSON output: %v", err)
}
} else {
name := flag.Arg(0)
if name == "" {
name = "<stdin>"
}
fmt.Printf("%s is an age file, version %q.\n", name, data.Version)
fmt.Printf("\n")
if data.Armor {
fmt.Printf("This file is ASCII-armored.\n")
fmt.Printf("\n")
}
fmt.Printf("This file is encrypted to the following recipient types:\n")
for _, t := range data.StanzaTypes {
fmt.Printf(" - %q\n", t)
}
fmt.Printf("\n")
switch data.Postquantum {
case "yes":
fmt.Printf("This file uses post-quantum encryption.\n")
fmt.Printf("\n")
case "no":
fmt.Printf("This file does NOT use post-quantum encryption.\n")
fmt.Printf("\n")
case "unknown":
fmt.Printf("It is unknown whether this file uses post-quantum encryption.\n")
fmt.Printf("\n")
}
fmt.Printf("Size breakdown (assuming it decrypts successfully):\n")
fmt.Printf("\n")
fmt.Printf(" Header % 12d bytes\n", data.Sizes.Header)
if data.Armor {
fmt.Printf(" Armor overhead % 12d bytes\n", data.Sizes.Armor)
}
fmt.Printf(" Encryption overhead % 12d bytes\n", data.Sizes.Overhead)
fmt.Printf(" Payload % 12d bytes\n", data.Sizes.MinPayload)
fmt.Printf(" -------------------\n")
total := data.Sizes.Header + data.Sizes.Overhead + data.Sizes.MinPayload + data.Sizes.Armor
fmt.Printf(" Total % 12d bytes\n", total)
fmt.Printf("\n")
fmt.Printf("Tip: for machine-readable output, use --json.\n")
}
}
// l is a logger with no prefixes.
var l = log.New(os.Stderr, "", 0)
func errorf(format string, v ...any) {
l.Printf("age-inspect: error: "+format, v...)
l.Printf("age-inspect: report unexpected or unhelpful errors at https://filippo.io/age/report")
os.Exit(1)
}
+83 -48
View File
@@ -18,18 +18,21 @@ import (
) )
const usage = `Usage: const usage = `Usage:
age-keygen [-o OUTPUT] age-keygen [-pq] [-o OUTPUT]
age-keygen -y [-o OUTPUT] [INPUT] age-keygen -y [-o OUTPUT] [INPUT]
Options: Options:
-pq Generate a post-quantum hybrid ML-KEM-768 + X25519 key pair.
(This might become the default in the future.)
-o, --output OUTPUT Write the result to the file at path OUTPUT. -o, --output OUTPUT Write the result to the file at path OUTPUT.
-y Convert an identity file to a recipients file. -y Convert an identity file to a recipients file.
age-keygen generates a new native X25519 key pair, and outputs it to age-keygen generates a new native X25519 or, with the -pq flag, post-quantum
standard output or to the OUTPUT file. hybrid ML-KEM-768 + X25519 key pair, and outputs it to standard output or to
the OUTPUT file.
If an OUTPUT file is specified, the public key is printed to standard error. When generating a key, if the output is not going to a terminal, the public key
If OUTPUT already exists, it is not overwritten. is printed to standard error. If OUTPUT already exists, it is not overwritten.
In -y mode, age-keygen reads an identity file from INPUT or from standard In -y mode, age-keygen reads an identity file from INPUT or from standard
input and writes the corresponding recipient(s) to OUTPUT or to standard input and writes the corresponding recipient(s) to OUTPUT or to standard
@@ -42,48 +45,65 @@ Examples:
# public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z # public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z
AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9 AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9
$ age-keygen -pq
# created: 2025-11-17T12:15:17+01:00
# public key: age1pq1pd[... 1950 more characters ...]
AGE-SECRET-KEY-PQ-1XXC4XS9DXHZ6TREKQTT3XECY8VNNU7GJ83C3Y49D0GZ3ZUME4JWS6QC3EF
$ age-keygen -o key.txt $ age-keygen -o key.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
$ age-keygen -y key.txt $ age-keygen -y key.txt
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p` age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p`
// Version can be set at link time to override debug.BuildInfo.Main.Version, // Version can be set at link time to override debug.BuildInfo.Main.Version when
// which is "(devel)" when building from within the module. See // building manually without git history. It should look like "v1.2.3".
// golang.org/issue/29814 and golang.org/issue/29228.
var Version string var Version string
func main() { func main() {
log.SetFlags(0) log.SetFlags(0)
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) } flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
var ( var outFlag string
versionFlag, convertFlag bool var pqFlag, versionFlag, convertFlag bool
outFlag string
)
flag.BoolVar(&versionFlag, "version", false, "print the version") flag.BoolVar(&versionFlag, "version", false, "print the version")
flag.BoolVar(&pqFlag, "pq", false, "generate a post-quantum key pair")
flag.BoolVar(&convertFlag, "y", false, "convert identities to recipients") flag.BoolVar(&convertFlag, "y", false, "convert identities to recipients")
flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)") flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)")
flag.StringVar(&outFlag, "output", "", "output to `FILE` (default stdout)") flag.StringVar(&outFlag, "output", "", "output to `FILE` (default stdout)")
flag.Parse() flag.Parse()
if versionFlag {
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
Version = buildInfo.Main.Version
}
fmt.Println(Version)
return
}
if len(flag.Args()) != 0 && !convertFlag { if len(flag.Args()) != 0 && !convertFlag {
errorf("too many arguments") errorf("too many arguments")
} }
if len(flag.Args()) > 1 && convertFlag { if len(flag.Args()) > 1 && convertFlag {
errorf("too many arguments") errorf("too many arguments")
} }
if versionFlag { if pqFlag && convertFlag {
if Version != "" { errorf("-pq cannot be used with -y")
fmt.Println(Version) }
return
var converted []byte
if convertFlag {
in := os.Stdin
if inFile := flag.Arg(0); inFile != "" && inFile != "-" {
f, err := os.Open(inFile)
if err != nil {
errorf("failed to open input file %q: %v", inFile, err)
}
defer f.Close()
in = f
} }
if buildInfo, ok := debug.ReadBuildInfo(); ok { converted = convert(in)
fmt.Println(buildInfo.Main.Version)
return
}
fmt.Println("(unknown)")
return
} }
out := os.Stdout out := os.Stdout
@@ -100,42 +120,51 @@ func main() {
out = f out = f
} }
in := os.Stdin
if inFile := flag.Arg(0); inFile != "" && inFile != "-" {
f, err := os.Open(inFile)
if err != nil {
errorf("failed to open input file %q: %v", inFile, err)
}
defer f.Close()
in = f
}
if convertFlag { if convertFlag {
convert(in, out) writef(out, "%s", converted)
} else { } else {
if fi, err := out.Stat(); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 { if fi, err := out.Stat(); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 {
warning("writing secret key to a world-readable file") warning("writing secret key to a world-readable file")
} }
generate(out) generate(out, pqFlag)
} }
} }
func generate(out *os.File) { func generate(out *os.File, pq bool) {
k, err := age.GenerateX25519Identity() var i age.Identity
if err != nil { var r age.Recipient
errorf("internal error: %v", err) if pq {
k, err := age.GenerateHybridIdentity()
if err != nil {
errorf("internal error: %v", err)
}
i = k
r = k.Recipient()
} else {
k, err := age.GenerateX25519Identity()
if err != nil {
errorf("internal error: %v", err)
}
i = k
r = k.Recipient()
} }
if !term.IsTerminal(int(out.Fd())) { if !term.IsTerminal(int(out.Fd())) {
fmt.Fprintf(os.Stderr, "Public key: %s\n", k.Recipient()) fmt.Fprintf(os.Stderr, "Public key: %s\n", r)
} }
fmt.Fprintf(out, "# created: %s\n", time.Now().Format(time.RFC3339)) writef(out, "# created: %s\n", time.Now().Format(time.RFC3339))
fmt.Fprintf(out, "# public key: %s\n", k.Recipient()) writef(out, "# public key: %s\n", r)
fmt.Fprintf(out, "%s\n", k) writef(out, "%s\n", i)
} }
func convert(in io.Reader, out io.Writer) { func writef(out io.Writer, format string, v ...any) {
if _, err := fmt.Fprintf(out, format, v...); err != nil {
errorf("failed to write output: %v", err)
}
}
func convert(in io.Reader) []byte {
ids, err := age.ParseIdentities(in) ids, err := age.ParseIdentities(in)
if err != nil { if err != nil {
errorf("failed to parse input: %v", err) errorf("failed to parse input: %v", err)
@@ -143,20 +172,26 @@ func convert(in io.Reader, out io.Writer) {
if len(ids) == 0 { if len(ids) == 0 {
errorf("no identities found in the input") errorf("no identities found in the input")
} }
var out []byte
for _, id := range ids { for _, id := range ids {
id, ok := id.(*age.X25519Identity) switch id := id.(type) {
if !ok { case *age.X25519Identity:
out = append(out, id.Recipient().String()...)
case *age.HybridIdentity:
out = append(out, id.Recipient().String()...)
default:
errorf("internal error: unexpected identity type: %T", id) errorf("internal error: unexpected identity type: %T", id)
} }
fmt.Fprintf(out, "%s\n", id.Recipient()) out = append(out, '\n')
} }
return out
} }
func errorf(format string, v ...interface{}) { func errorf(format string, v ...any) {
log.Printf("age-keygen: error: "+format, v...) log.Printf("age-keygen: error: "+format, v...)
log.Fatalf("age-keygen: report unexpected or unhelpful errors at https://filippo.io/age/report") log.Fatalf("age-keygen: report unexpected or unhelpful errors at https://filippo.io/age/report")
} }
func warning(msg string) { func warning(msg string) {
log.Printf("age-keygen: warning: " + msg) log.Printf("age-keygen: warning: %s", msg)
} }
+70
View File
@@ -0,0 +1,70 @@
// Copyright 2019 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"filippo.io/age"
"github.com/rogpeppe/go-internal/testscript"
)
func TestMain(m *testing.M) {
testscript.Main(m, map[string]func(){
"age-keygen": main,
})
}
func unwritable(t *testing.T) *os.File {
t.Helper()
name := filepath.Join(t.TempDir(), "out")
if err := os.WriteFile(name, nil, 0600); err != nil {
t.Fatal(err)
}
f, err := os.Open(name)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { f.Close() })
if _, err := f.Write([]byte("x")); err == nil {
t.Skip("writes to a read-only file descriptor succeed")
}
return f
}
func TestOutputWriteErrors(t *testing.T) {
identity, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
args []string
stdin string
}{
{"generate", nil, ""},
{"generate PQ", []string{"-pq"}, ""},
{"convert", []string{"-y"}, identity.String() + "\n"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stderr := &strings.Builder{}
cmd := exec.Command("age-keygen", tt.args...)
cmd.Stdin = strings.NewReader(tt.stdin)
cmd.Stdout = unwritable(t)
cmd.Stderr = stderr
if err := cmd.Run(); err == nil {
t.Error("age-keygen succeeded")
}
if !strings.Contains(stderr.String(), "failed to write output") {
t.Errorf("stderr = %q", stderr)
}
})
}
}
@@ -0,0 +1,215 @@
package main
import (
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"runtime/debug"
"strconv"
"strings"
"filippo.io/age"
"filippo.io/age/plugin"
)
const usage = `age-plugin-batchpass is an age plugin that enables non-interactive
passphrase-based encryption and decryption using environment variables.
WARNING: IN 90% OF CASES, YOU DON'T NEED THIS PLUGIN.
This functionality is not built into the age CLI because most applications
should use native keys instead of scripting passphrase-based encryption.
Humans are notoriously bad at remembering and generating strong passphrases.
age uses scrypt to partially mitigate this, which is necessarily very slow.
If a computer will be doing the remembering anyway, you can and should use
native keys instead. There is no need to manage separate public and private
keys, you encrypt directly to the private key:
$ age-keygen -o key.txt
$ age -e -i key.txt file.txt > file.txt.age
$ age -d -i key.txt file.txt.age > file.txt
Likewise, you can store a native identity string in an environment variable
or through your CI secrets manager and use it to encrypt and decrypt files
non-interactively:
$ export AGE_SECRET=$(age-keygen)
$ age -e -i <(echo "$AGE_SECRET") file.txt > file.txt.age
$ age -d -i <(echo "$AGE_SECRET") file.txt.age > file.txt
The age CLI also natively supports passphrase-encrypted identity files, so you
can use that functionality to non-interactively encrypt multiple files such that
you will be able to decrypt them later by entering the same passphrase:
$ age-keygen -pq | age -p -o encrypted-identity.txt
Public key: age1pq1cd[... 1950 more characters ...]
Enter passphrase (leave empty to autogenerate a secure one):
age: using autogenerated passphrase "eternal-erase-keen-suffer-fog-exclude-huge-scorpion-escape-scrub"
$ age -r age1pq1cd[... 1950 more characters ...] file.txt > file.txt.age
$ age -d -i encrypted-identity.txt file.txt.age > file.txt
Enter passphrase for identity file "encrypted-identity.txt":
Finally, when using this plugin care should be taken not to let the password be
persisted in the shell history or leaked to other users on multi-user systems.
Usage:
$ AGE_PASSPHRASE=password age -e -j batchpass file.txt > file.txt.age
$ AGE_PASSPHRASE=password age -d -j batchpass file.txt.age > file.txt
Alternatively, you can use AGE_PASSPHRASE_FD to read the passphrase from
a file descriptor. Trailing newlines are stripped from the file contents.
When encrypting, you can set AGE_PASSPHRASE_WORK_FACTOR to adjust the scrypt
work factor (between 1 and 30, default 18). Higher values are more secure
but slower. The age CLI refuses to decrypt files above 22, and so does this
plugin unless AGE_PASSPHRASE_MAX_WORK_FACTOR is set to at least the same value.
When decrypting, you can set AGE_PASSPHRASE_MAX_WORK_FACTOR to change the
maximum scrypt work factor accepted (between 1 and 30, default 22). This can
be used to avoid very slow decryptions, or to allow slower ones.`
// Version can be set at link time to override debug.BuildInfo.Main.Version when
// building manually without git history. It should look like "v1.2.3".
var Version string
func main() {
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
p, err := plugin.New("batchpass")
if err != nil {
log.Fatal(err)
}
p.RegisterFlags(nil)
versionFlag := flag.Bool("version", false, "print the version")
flag.Parse()
if *versionFlag {
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
Version = buildInfo.Main.Version
}
fmt.Println(Version)
return
}
p.HandleIdentityAsRecipient(func(data []byte) (age.Recipient, error) {
if len(data) != 0 {
return nil, fmt.Errorf("batchpass identity does not take any payload")
}
pass, err := passphrase()
if err != nil {
return nil, err
}
r, err := age.NewScryptRecipient(pass)
if err != nil {
return nil, fmt.Errorf("failed to create scrypt recipient: %v", err)
}
if envWorkFactor := os.Getenv("AGE_PASSPHRASE_WORK_FACTOR"); envWorkFactor != "" {
workFactor, err := strconv.Atoi(envWorkFactor)
if err != nil {
return nil, fmt.Errorf("invalid AGE_PASSPHRASE_WORK_FACTOR: %v", err)
}
if workFactor > 30 || workFactor < 1 {
return nil, fmt.Errorf("AGE_PASSPHRASE_WORK_FACTOR must be between 1 and 30")
}
r.SetWorkFactor(workFactor)
}
return r, nil
})
p.HandleIdentity(func(data []byte) (age.Identity, error) {
if len(data) != 0 {
return nil, fmt.Errorf("batchpass identity does not take any payload")
}
pass, err := passphrase()
if err != nil {
return nil, err
}
maxWorkFactor := 0
if envMaxWorkFactor := os.Getenv("AGE_PASSPHRASE_MAX_WORK_FACTOR"); envMaxWorkFactor != "" {
maxWorkFactor, err = strconv.Atoi(envMaxWorkFactor)
if err != nil {
return nil, fmt.Errorf("invalid AGE_PASSPHRASE_MAX_WORK_FACTOR: %v", err)
}
if maxWorkFactor > 30 || maxWorkFactor < 1 {
return nil, fmt.Errorf("AGE_PASSPHRASE_MAX_WORK_FACTOR must be between 1 and 30")
}
}
return &batchpassIdentity{password: pass, maxWorkFactor: maxWorkFactor}, nil
})
os.Exit(p.Main())
}
type batchpassIdentity struct {
password string
maxWorkFactor int
}
func (i *batchpassIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
for _, s := range stanzas {
if s.Type == "scrypt" && len(stanzas) != 1 {
return nil, errors.New("an scrypt recipient must be the only one")
}
}
if len(stanzas) != 1 || stanzas[0].Type != "scrypt" {
// Don't fallback to other identities, this plugin should mostly be used
// in isolation, from the CLI.
return nil, fmt.Errorf("file is not passphrase-encrypted")
}
ii, err := age.NewScryptIdentity(i.password)
if err != nil {
return nil, err
}
if i.maxWorkFactor != 0 {
ii.SetMaxWorkFactor(i.maxWorkFactor)
}
fileKey, err := ii.Unwrap(stanzas)
if errors.Is(err, age.ErrIncorrectIdentity) {
// ScryptIdentity returns ErrIncorrectIdentity to make it possible to
// try multiple passphrases from the API. If a user is invoking this
// plugin, it's safe to say they expect it to be the only mechanism to
// decrypt a passphrase-protected file.
return nil, fmt.Errorf("incorrect passphrase")
}
return fileKey, err
}
func passphrase() (string, error) {
envPASSPHRASE := os.Getenv("AGE_PASSPHRASE")
envFD := os.Getenv("AGE_PASSPHRASE_FD")
if envPASSPHRASE != "" && envFD != "" {
return "", fmt.Errorf("AGE_PASSPHRASE and AGE_PASSPHRASE_FD are mutually exclusive")
}
if envPASSPHRASE == "" && envFD == "" {
return "", fmt.Errorf("either AGE_PASSPHRASE or AGE_PASSPHRASE_FD must be set")
}
if envPASSPHRASE != "" {
return envPASSPHRASE, nil
}
fd, err := strconv.Atoi(envFD)
if err != nil {
return "", fmt.Errorf("invalid AGE_PASSPHRASE_FD: %v", err)
}
f := os.NewFile(uintptr(fd), "AGE_PASSPHRASE_FD")
if f == nil {
return "", fmt.Errorf("failed to open file descriptor %d", fd)
}
defer f.Close()
const maxPassphraseSize = 1024 * 1024 // 1 MiB
b, err := io.ReadAll(io.LimitReader(f, maxPassphraseSize+1))
if err != nil {
return "", fmt.Errorf("failed to read passphrase from fd %d: %v", fd, err)
}
if len(b) > maxPassphraseSize {
return "", fmt.Errorf("passphrase from fd %d is too long", fd)
}
return strings.TrimRight(string(b), "\r\n"), nil
}
+127 -43
View File
@@ -7,19 +7,25 @@ package main
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
"iter"
"os" "os"
"path/filepath"
"regexp" "regexp"
"runtime/debug" "runtime/debug"
"slices"
"strings" "strings"
"unicode"
"unicode/utf8"
"filippo.io/age" "filippo.io/age"
"filippo.io/age/agessh" "filippo.io/age/agessh"
"filippo.io/age/armor" "filippo.io/age/armor"
"filippo.io/age/internal/term"
"filippo.io/age/plugin" "filippo.io/age/plugin"
"golang.org/x/term"
) )
const usage = `Usage: const usage = `Usage:
@@ -36,6 +42,7 @@ Options:
-r, --recipient RECIPIENT Encrypt to the specified RECIPIENT. Can be repeated. -r, --recipient RECIPIENT Encrypt to the specified RECIPIENT. Can be repeated.
-R, --recipients-file PATH Encrypt to recipients listed at PATH. Can be repeated. -R, --recipients-file PATH Encrypt to recipients listed at PATH. Can be repeated.
-i, --identity PATH Use the identity file at PATH. Can be repeated. -i, --identity PATH Use the identity file at PATH. Can be repeated.
--version Print the version.
INPUT defaults to standard input, and OUTPUT defaults to standard output. INPUT defaults to standard input, and OUTPUT defaults to standard output.
If OUTPUT exists, it will be overwritten. If OUTPUT exists, it will be overwritten.
@@ -62,11 +69,6 @@ Example:
$ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age $ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age
$ age --decrypt -i key.txt -o data.tar.gz data.tar.gz.age` $ age --decrypt -i key.txt -o data.tar.gz data.tar.gz.age`
// Version can be set at link time to override debug.BuildInfo.Main.Version,
// which is "(devel)" when building from within the module. See
// golang.org/issue/29814 and golang.org/issue/29228.
var Version string
// stdinInUse is used to ensure only one of input, recipients, or identities // stdinInUse is used to ensure only one of input, recipients, or identities
// file is read from stdin. It's a singleton like os.Stdin. // file is read from stdin. It's a singleton like os.Stdin.
var stdinInUse bool var stdinInUse bool
@@ -98,12 +100,16 @@ func (f *identityFlags) addPluginFlag(value string) error {
return nil return nil
} }
// Version can be set at link time to override debug.BuildInfo.Main.Version when
// building manually without git history. It should look like "v1.2.3".
var Version string
func main() { func main() {
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) } flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
if len(os.Args) == 1 { if len(os.Args) == 1 {
flag.Usage() flag.Usage()
exit(1) os.Exit(1)
} }
var ( var (
@@ -136,17 +142,10 @@ func main() {
flag.Parse() flag.Parse()
if versionFlag { if versionFlag {
if Version != "" { if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
fmt.Println(Version) Version = buildInfo.Main.Version
return
} }
if buildInfo, ok := debug.ReadBuildInfo(); ok { fmt.Println(Version)
// TODO: use buildInfo.Settings to prepare a pseudoversion such as
// v0.0.0-20210817164053-32db794688a5+dirty on Go 1.18+.
fmt.Println(buildInfo.Main.Version)
return
}
fmt.Println("(unknown)")
return return
} }
@@ -161,11 +160,8 @@ func main() {
safe := true safe := true
unsafeShell := regexp.MustCompile(`[^\w@%+=:,./-]`) unsafeShell := regexp.MustCompile(`[^\w@%+=:,./-]`)
for _, arg := range os.Args { if slices.ContainsFunc(os.Args, unsafeShell.MatchString) {
if unsafeShell.MatchString(arg) { safe = false
safe = false
break
}
} }
if safe { if safe {
i := len(os.Args) - flag.NArg() i := len(os.Args) - flag.NArg()
@@ -189,7 +185,7 @@ func main() {
} }
if armorFlag { if armorFlag {
errorWithHint("-a/--armor can't be used with -d/--decrypt", errorWithHint("-a/--armor can't be used with -d/--decrypt",
"note that armored files are detected automatically") "note that armored files are detected automatically, try again without -a/--armor")
} }
if passFlag { if passFlag {
errorWithHint("-p/--passphrase can't be used with -d/--decrypt", errorWithHint("-p/--passphrase can't be used with -d/--decrypt",
@@ -223,9 +219,31 @@ func main() {
} }
} }
warnDuplicates(slices.Values(recipientFlags), "recipient")
warnDuplicates(slices.Values(recipientsFileFlags), "recipients file")
warnDuplicates(func(yield func(string) bool) {
for _, f := range identityFlags {
if f.Type == "i" && !yield(f.Value) {
return
}
}
}, "identity file")
var inUseFiles []string
for _, i := range identityFlags {
if i.Type != "i" {
continue
}
inUseFiles = append(inUseFiles, absPath(i.Value))
}
for _, f := range recipientsFileFlags {
inUseFiles = append(inUseFiles, absPath(f))
}
var in io.Reader = os.Stdin var in io.Reader = os.Stdin
var out io.Writer = os.Stdout var out io.Writer = os.Stdout
if name := flag.Arg(0); name != "" && name != "-" { if name := flag.Arg(0); name != "" && name != "-" {
inUseFiles = append(inUseFiles, absPath(name))
f, err := os.Open(name) f, err := os.Open(name)
if err != nil { if err != nil {
errorf("failed to open input file %q: %v", name, err) errorf("failed to open input file %q: %v", name, err)
@@ -234,7 +252,7 @@ func main() {
in = f in = f
} else { } else {
stdinInUse = true stdinInUse = true
if decryptFlag && term.IsTerminal(int(os.Stdin.Fd())) { if decryptFlag && term.IsTerminal(os.Stdin) {
// If the input comes from a TTY, assume it's armored, and buffer up // If the input comes from a TTY, assume it's armored, and buffer up
// to the END line (or EOF/EOT) so that a password prompt or the // to the END line (or EOF/EOT) so that a password prompt or the
// output don't get in the way of typing the input. See Issue 364. // output don't get in the way of typing the input. See Issue 364.
@@ -246,6 +264,19 @@ func main() {
} }
} }
if name := outFlag; name != "" && name != "-" { if name := outFlag; name != "" && name != "-" {
outFI, outErr := os.Stat(name)
for _, f := range inUseFiles {
same := f == absPath(name)
if !same && outErr == nil {
if fi, err := os.Stat(f); err == nil {
// Catch symlinks and hard links.
same = os.SameFile(fi, outFI)
}
}
if same {
errorf("input and output file are the same: %q", name)
}
}
f := newLazyOpener(name) f := newLazyOpener(name)
defer func() { defer func() {
if err := f.Close(); err != nil { if err := f.Close(); err != nil {
@@ -253,10 +284,25 @@ func main() {
} }
}() }()
out = f out = f
} else if term.IsTerminal(int(os.Stdout.Fd())) { } else if term.IsTerminal(os.Stdout) {
buf := &bytes.Buffer{}
defer func() {
if out == buf {
io.Copy(os.Stdout, buf)
}
}()
if name != "-" { if name != "-" {
if decryptFlag { if decryptFlag {
// TODO: buffer the output and check it's printable. // Buffer the output to check it's printable.
out = buf
defer func() {
if !utf8.Valid(buf.Bytes()) || bytes.ContainsFunc(buf.Bytes(), func(r rune) bool {
return r != '\n' && r != '\r' && r != '\t' && unicode.IsControl(r)
}) {
errorWithHint("refusing to output binary to the terminal",
`force anyway with "-o -"`)
}
}()
} else if !armorFlag { } else if !armorFlag {
// If the output wouldn't be armored, refuse to send binary to // If the output wouldn't be armored, refuse to send binary to
// the terminal unless explicitly requested with "-o -". // the terminal unless explicitly requested with "-o -".
@@ -265,11 +311,9 @@ func main() {
`force anyway with "-o -"`) `force anyway with "-o -"`)
} }
} }
if in == os.Stdin && term.IsTerminal(int(os.Stdin.Fd())) { if in == os.Stdin && term.IsTerminal(os.Stdin) {
// If the input comes from a TTY and output will go to a TTY, // If the input comes from a TTY and output will go to a TTY,
// buffer it up so it doesn't get in the way of typing the input. // buffer it up so it doesn't get in the way of typing the input.
buf := &bytes.Buffer{}
defer func() { io.Copy(os.Stdout, buf) }()
out = buf out = buf
} }
} }
@@ -287,14 +331,14 @@ func main() {
} }
func passphrasePromptForEncryption() (string, error) { func passphrasePromptForEncryption() (string, error) {
pass, err := readSecret("Enter passphrase (leave empty to autogenerate a secure one):") pass, err := term.ReadSecret("Enter passphrase (leave empty to autogenerate a secure one):")
if err != nil { if err != nil {
return "", fmt.Errorf("could not read passphrase: %v", err) return "", fmt.Errorf("could not read passphrase: %v", err)
} }
p := string(pass) p := string(pass)
if p == "" { if p == "" {
var words []string var words []string
for i := 0; i < 10; i++ { for range 10 {
words = append(words, randomWord()) words = append(words, randomWord())
} }
p = strings.Join(words, "-") p = strings.Join(words, "-")
@@ -303,7 +347,7 @@ func passphrasePromptForEncryption() (string, error) {
return "", fmt.Errorf("could not print passphrase: %v", err) return "", fmt.Errorf("could not print passphrase: %v", err)
} }
} else { } else {
confirm, err := readSecret("Confirm passphrase:") confirm, err := term.ReadSecret("Confirm passphrase:")
if err != nil { if err != nil {
return "", fmt.Errorf("could not read passphrase: %v", err) return "", fmt.Errorf("could not read passphrase: %v", err)
} }
@@ -348,7 +392,7 @@ func encryptNotPass(recs, files []string, identities identityFlags, in io.Reader
} }
recipients = append(recipients, r...) recipients = append(recipients, r...)
case "j": case "j":
id, err := plugin.NewIdentityWithoutData(f.Value, pluginTerminalUI) id, err := plugin.NewIdentityWithoutData(f.Value, plugin.NewTerminalUI(printf, warningf))
if err != nil { if err != nil {
errorf("initializing %q: %v", f.Value, err) errorf("initializing %q: %v", f.Value, err)
} }
@@ -385,7 +429,11 @@ func encrypt(recipients []age.Recipient, in io.Reader, out io.Writer, withArmor
out = a out = a
} }
w, err := age.Encrypt(out, recipients...) w, err := age.Encrypt(out, recipients...)
if err != nil { if e := new(plugin.NotFoundError); errors.As(err, &e) {
errorWithHint(err.Error(),
fmt.Sprintf("you might want to install the %q plugin", e.Name),
"visit https://age-encryption.org/awesome#plugins for a list of available plugins")
} else if err != nil {
errorf("%v", err) errorf("%v", err)
} }
if _, err := io.Copy(w, in); err != nil { if _, err := io.Copy(w, in); err != nil {
@@ -414,8 +462,7 @@ func (rejectScryptIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
} }
func decryptNotPass(flags identityFlags, in io.Reader, out io.Writer) { func decryptNotPass(flags identityFlags, in io.Reader, out io.Writer) {
identities := []age.Identity{rejectScryptIdentity{}} var identities []age.Identity
for _, f := range flags { for _, f := range flags {
switch f.Type { switch f.Type {
case "i": case "i":
@@ -425,14 +472,14 @@ func decryptNotPass(flags identityFlags, in io.Reader, out io.Writer) {
} }
identities = append(identities, ids...) identities = append(identities, ids...)
case "j": case "j":
id, err := plugin.NewIdentityWithoutData(f.Value, pluginTerminalUI) id, err := plugin.NewIdentityWithoutData(f.Value, plugin.NewTerminalUI(printf, warningf))
if err != nil { if err != nil {
errorf("initializing %q: %v", f.Value, err) errorf("initializing %q: %v", f.Value, err)
} }
identities = append(identities, id) identities = append(identities, id)
} }
} }
identities = append(identities, rejectScryptIdentity{})
decrypt(identities, in, out) decrypt(identities, in, out)
} }
@@ -440,7 +487,7 @@ func decryptPass(in io.Reader, out io.Writer) {
identities := []age.Identity{ identities := []age.Identity{
// If there is an scrypt recipient (it will have to be the only one and) // If there is an scrypt recipient (it will have to be the only one and)
// this identity will be invoked. // this identity will be invoked.
&LazyScryptIdentity{passphrasePromptForDecryption}, lazyScryptIdentity,
} }
decrypt(identities, in, out) decrypt(identities, in, out)
@@ -455,23 +502,39 @@ func decrypt(identities []age.Identity, in io.Reader, out io.Writer) {
"consider using -o or -a to encrypt files in PowerShell") "consider using -o or -a to encrypt files in PowerShell")
} }
if start, _ := rr.Peek(len(armor.Header)); string(start) == armor.Header { const maxWhitespace = 1024
start, _ := rr.Peek(maxWhitespace + len(armor.Header))
if strings.HasPrefix(string(bytes.TrimSpace(start)), armor.Header) {
in = armor.NewReader(rr) in = armor.NewReader(rr)
} else { } else {
in = rr in = rr
} }
r, err := age.Decrypt(in, identities...) r, err := age.Decrypt(in, identities...)
if err != nil { if e := new(plugin.NotFoundError); errors.As(err, &e) {
errorWithHint(err.Error(),
fmt.Sprintf("you might want to install the %q plugin", e.Name),
"visit https://age-encryption.org/awesome#plugins for a list of available plugins")
} else if errors.As(err, new(*age.NoIdentityMatchError)) &&
len(identities) == 1 && identities[0] == lazyScryptIdentity {
errorWithHint("the file is not passphrase-encrypted, identities are required",
"specify identities with -i/--identity or -j to decrypt this file")
} else if err != nil {
errorf("%v", err) errorf("%v", err)
} }
if _, err := io.Copy(out, r); err != nil { if _, err := io.Copy(out, r); err != nil {
errorf("%v", err) errorf("%v", err)
} }
// Trigger the lazyOpener even if r is empty, if Copy succeeded.
if _, err := out.Write(nil); err != nil {
errorf("%v", err)
}
} }
var lazyScryptIdentity = &LazyScryptIdentity{passphrasePromptForDecryption}
func passphrasePromptForDecryption() (string, error) { func passphrasePromptForDecryption() (string, error) {
pass, err := readSecret("Enter passphrase:") pass, err := term.ReadSecret("Enter passphrase:")
if err != nil { if err != nil {
return "", fmt.Errorf("could not read passphrase: %v", err) return "", fmt.Errorf("could not read passphrase: %v", err)
} }
@@ -484,6 +547,8 @@ func identitiesToRecipients(ids []age.Identity) ([]age.Recipient, error) {
switch id := id.(type) { switch id := id.(type) {
case *age.X25519Identity: case *age.X25519Identity:
recipients = append(recipients, id.Recipient()) recipients = append(recipients, id.Recipient())
case *age.HybridIdentity:
recipients = append(recipients, id.Recipient())
case *plugin.Identity: case *plugin.Identity:
recipients = append(recipients, id.Recipient()) recipients = append(recipients, id.Recipient())
case *agessh.RSAIdentity: case *agessh.RSAIdentity:
@@ -529,5 +594,24 @@ func (l *lazyOpener) Close() error {
if l.f != nil { if l.f != nil {
return l.f.Close() return l.f.Close()
} }
return nil return l.err
}
func absPath(name string) string {
if abs, err := filepath.Abs(name); err == nil {
return abs
}
return name
}
func warnDuplicates(s iter.Seq[string], name string) {
seen := make(map[string]bool)
warned := make(map[string]bool)
for e := range s {
if seen[e] && !warned[e] {
warningf("duplicate %s %q", name, e)
warned[e] = true
}
seen[e] = true
}
} }
+159 -53
View File
@@ -5,79 +5,185 @@
package main package main
import ( import (
"bufio" "io"
"os" "os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing" "testing"
"filippo.io/age" "filippo.io/age"
"filippo.io/age/plugin"
"github.com/rogpeppe/go-internal/testscript" "github.com/rogpeppe/go-internal/testscript"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
os.Exit(testscript.RunMain(m, map[string]func() int{ testscript.Main(m, map[string]func(){
"age": func() (exitCode int) { "age": func() {
testOnlyPanicInsteadOfExit = true
defer func() {
if testOnlyDidExit {
exitCode = recover().(int)
}
}()
testOnlyConfigureScryptIdentity = func(r *age.ScryptRecipient) { testOnlyConfigureScryptIdentity = func(r *age.ScryptRecipient) {
r.SetWorkFactor(10) r.SetWorkFactor(10)
} }
testOnlyFixedRandomWord = "four" testOnlyFixedRandomWord = "four"
main() main()
return 0
}, },
"age-plugin-test": func() (exitCode int) { "age-plugin-test": func() {
// TODO: use plugin server package once it's available. p, _ := plugin.New("test")
switch os.Args[1] { p.HandleRecipient(func(data []byte) (age.Recipient, error) {
case "--age-plugin=recipient-v1": return testPlugin{}, nil
scanner := bufio.NewScanner(os.Stdin) })
scanner.Scan() // add-recipient p.HandleIdentity(func(data []byte) (age.Identity, error) {
scanner.Scan() // body return testPlugin{}, nil
scanner.Scan() // grease })
scanner.Scan() // body os.Exit(p.Main())
scanner.Scan() // wrap-file-key
scanner.Scan() // body
fileKey := scanner.Text()
scanner.Scan() // extension-labels
scanner.Scan() // body
scanner.Scan() // done
scanner.Scan() // body
os.Stdout.WriteString("-> recipient-stanza 0 test\n")
os.Stdout.WriteString(fileKey + "\n")
scanner.Scan() // ok
scanner.Scan() // body
os.Stdout.WriteString("-> done\n\n")
return 0
case "--age-plugin=identity-v1":
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan() // add-identity
scanner.Scan() // body
scanner.Scan() // grease
scanner.Scan() // body
scanner.Scan() // recipient-stanza
scanner.Scan() // body
fileKey := scanner.Text()
scanner.Scan() // done
scanner.Scan() // body
os.Stdout.WriteString("-> file-key 0\n")
os.Stdout.WriteString(fileKey + "\n")
scanner.Scan() // ok
scanner.Scan() // body
os.Stdout.WriteString("-> done\n\n")
return 0
default:
return 1
}
}, },
})) })
} }
type testPlugin struct{}
func (testPlugin) Wrap(fileKey []byte) ([]*age.Stanza, error) {
return []*age.Stanza{{Type: "test", Body: fileKey}}, nil
}
func (testPlugin) Unwrap(ss []*age.Stanza) ([]byte, error) {
if len(ss) == 1 && ss[0].Type == "test" {
return ss[0].Body, nil
}
return nil, age.ErrIncorrectIdentity
}
func paddedFile(pad int, lines string) io.Reader {
const chunkSize = 1 << 16
chunk := strings.Repeat("#\n", chunkSize/2)
var readers []io.Reader
if pad%2 != 0 {
readers = append(readers, strings.NewReader("\n"))
pad--
}
for ; pad > chunkSize; pad -= chunkSize {
readers = append(readers, strings.NewReader(chunk))
}
return io.MultiReader(append(readers,
strings.NewReader(chunk[:pad]), strings.NewReader(lines))...)
}
func TestParseFileSizeLimit(t *testing.T) {
const sizeLimit = 16 << 20
const identity = "AGE-SECRET-KEY-1D6K0SGAX3NU66R4GYFZY0UQWCLM3UUSF3CXLW4KXZM342WQSJ82QKU59QJ\n"
const recipient = "age1cy0su9fwf3gf9mw868g5yut09p6nytfmmnktexz2ya5uqg9vl9sss4euqm\n"
tests := []struct {
name string
entry string
parse func(*testing.T, io.Reader) (int, error)
}{
{"identities", identity, func(_ *testing.T, r io.Reader) (int, error) {
ids, err := parseIdentities(r)
return len(ids), err
}},
{"recipients", recipient, func(t *testing.T, r io.Reader) (int, error) {
name := filepath.Join(t.TempDir(), "recipients.txt")
f, err := os.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := io.Copy(f, r); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
recipients, err := parseRecipientsFile(name)
return len(recipients), err
}},
}
for _, tt := range tests {
t.Run(tt.name+"/at limit", func(t *testing.T) {
r := paddedFile(sizeLimit-4*len(tt.entry), strings.Repeat(tt.entry, 4))
n, err := tt.parse(t, r)
if err != nil {
t.Fatal(err)
}
if n != 4 {
t.Errorf("got %d entries, want 4", n)
}
})
t.Run(tt.name+"/over limit", func(t *testing.T) {
r := paddedFile(sizeLimit-4*len(tt.entry), strings.Repeat(tt.entry, 8))
if _, err := tt.parse(t, r); err == nil {
t.Error("expected an error")
}
})
}
}
func TestUnsupportedSSHKey(t *testing.T) {
tests := []struct {
name, key, want string
ok bool
}{
{
"truncated Ed25519",
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH9pO5pz22JZEasoS2LEWWvJiUYI9M6l1uZc31FG",
"", false,
},
{
"type-only RSA",
"ssh-rsa AAAAB3NzaC1yc2E=",
"", false,
},
{
"small RSA",
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCxlfoYpG04TTnmjocggQZI5l0fjvaUMky1ZD5zwktDKAgBj441OAtFj/3m7Gujpxx/8w3jjJoUXVtU+NFbcC972ROpPI4aPq3OR5SmuuR5bCr6efeZlyAEMY/DmbkeUe1TrvdU3VMDGJAvEEzT1wzokKt9PiwbV2jNkdPjaG5LDQ==",
"ssh-rsa", true,
},
{
"invalid Ed25519 point",
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC",
"", false,
},
{
"unknown type",
"totally-not-a-key AAAAEXRvdGFsbHktbm90LWEta2V5",
"totally-not-a-key", true,
},
{
"ECDSA",
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOSh0Qo5zaBE33EiJnxwIBuZml9Nt9rCfvGi++FniWSk8X/10EO4E7KMgqGLlkCTDNIcbuKcsgfMw1/tifB8aTc=",
"ecdsa-sha2-nistp256", true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := unsupportedSSHKey(tt.key)
if got != tt.want || ok != tt.ok {
t.Errorf("unsupportedSSHKey() = %q, %v; want %q, %v", got, ok, tt.want, tt.ok)
}
})
}
}
var buildExtraCommands = sync.OnceValue(func() error {
bindir := filepath.SplitList(os.Getenv("PATH"))[0]
// Build age-keygen and age-plugin-pq into the test binary directory.
cmd := exec.Command("go", "build", "-o", bindir)
if testing.CoverMode() != "" {
cmd.Args = append(cmd.Args, "-cover")
}
cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-keygen")
cmd.Args = append(cmd.Args, "filippo.io/age/extra/age-plugin-pq")
cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-plugin-batchpass")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
})
func TestScript(t *testing.T) { func TestScript(t *testing.T) {
testscript.Run(t, testscript.Params{ testscript.Run(t, testscript.Params{
Dir: "testdata", Dir: "testdata",
Setup: func(e *testscript.Env) error {
return buildExtraCommands()
},
// TODO: enable AGEDEBUG=plugin without breaking stderr checks. // TODO: enable AGEDEBUG=plugin without breaking stderr checks.
}) })
} }
+72 -21
View File
@@ -6,16 +6,21 @@ package main
import ( import (
"bufio" "bufio"
"bytes"
"crypto/rsa"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io" "io"
"os" "os"
"strings" "strings"
"unicode/utf8"
"filippo.io/age" "filippo.io/age"
"filippo.io/age/agessh" "filippo.io/age/agessh"
"filippo.io/age/armor" "filippo.io/age/armor"
"filippo.io/age/internal/term"
"filippo.io/age/plugin" "filippo.io/age/plugin"
"filippo.io/age/tag"
"golang.org/x/crypto/cryptobyte" "golang.org/x/crypto/cryptobyte"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@@ -30,8 +35,12 @@ func (gitHubRecipientError) Error() string {
func parseRecipient(arg string) (age.Recipient, error) { func parseRecipient(arg string) (age.Recipient, error) {
switch { switch {
case strings.HasPrefix(arg, "age1tag1") || strings.HasPrefix(arg, "age1tagpq1"):
return tag.ParseRecipient(arg)
case strings.HasPrefix(arg, "age1pq1"):
return age.ParseHybridRecipient(arg)
case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1: case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1:
return plugin.NewRecipient(arg, pluginTerminalUI) return plugin.NewRecipient(arg, plugin.NewTerminalUI(printf, warningf))
case strings.HasPrefix(arg, "age1"): case strings.HasPrefix(arg, "age1"):
return age.ParseX25519Recipient(arg) return age.ParseX25519Recipient(arg)
case strings.HasPrefix(arg, "ssh-"): case strings.HasPrefix(arg, "ssh-"):
@@ -64,7 +73,8 @@ func parseRecipientsFile(name string) ([]age.Recipient, error) {
const recipientFileSizeLimit = 16 << 20 // 16 MiB const recipientFileSizeLimit = 16 << 20 // 16 MiB
const lineLengthLimit = 8 << 10 // 8 KiB, same as sshd(8) const lineLengthLimit = 8 << 10 // 8 KiB, same as sshd(8)
var recs []age.Recipient var recs []age.Recipient
scanner := bufio.NewScanner(io.LimitReader(f, recipientFileSizeLimit)) lr := &io.LimitedReader{R: f, N: recipientFileSizeLimit + 1}
scanner := bufio.NewScanner(lr)
var n int var n int
for scanner.Scan() { for scanner.Scan() {
n++ n++
@@ -72,16 +82,22 @@ func parseRecipientsFile(name string) ([]age.Recipient, error) {
if strings.HasPrefix(line, "#") || line == "" { if strings.HasPrefix(line, "#") || line == "" {
continue continue
} }
if !utf8.ValidString(line) {
return nil, fmt.Errorf("%q: recipients file is not valid UTF-8", name)
}
if len(line) > lineLengthLimit { if len(line) > lineLengthLimit {
return nil, fmt.Errorf("%q: line %d is too long", name, n) return nil, fmt.Errorf("%q: line %d is too long", name, n)
} }
r, err := parseRecipient(line) r, err := parseRecipient(line)
if err != nil { if err != nil {
if t, ok := sshKeyType(line); ok { if t, unsupported := unsupportedSSHKey(line); unsupported {
// Skip unsupported but valid SSH public keys with a warning. // Skip unsupported but valid SSH public keys with a warning.
warningf("recipients file %q: ignoring unsupported SSH key of type %q at line %d", name, t, n) warningf("recipients file %q: ignoring unsupported SSH key of type %q at line %d", name, t, n)
continue continue
} }
if strings.HasPrefix(line, "AGE-") {
return nil, fmt.Errorf("%q: error at line %d: apparent identity found in recipients file", name, n)
}
// Hide the error since it might unintentionally leak the contents // Hide the error since it might unintentionally leak the contents
// of confidential files. // of confidential files.
return nil, fmt.Errorf("%q: malformed recipient at line %d", name, n) return nil, fmt.Errorf("%q: malformed recipient at line %d", name, n)
@@ -91,13 +107,16 @@ func parseRecipientsFile(name string) ([]age.Recipient, error) {
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("%q: failed to read recipients file: %v", name, err) return nil, fmt.Errorf("%q: failed to read recipients file: %v", name, err)
} }
if lr.N == 0 {
return nil, fmt.Errorf("%q: recipients file is too long", name)
}
if len(recs) == 0 { if len(recs) == 0 {
return nil, fmt.Errorf("%q: no recipients found", name) return nil, fmt.Errorf("%q: no recipients found", name)
} }
return recs, nil return recs, nil
} }
func sshKeyType(s string) (string, bool) { func unsupportedSSHKey(s string) (string, bool) {
// TODO: also ignore options? And maybe support multiple spaces and tabs as // TODO: also ignore options? And maybe support multiple spaces and tabs as
// field separators like OpenSSH? // field separators like OpenSSH?
fields := strings.Split(s, " ") fields := strings.Split(s, " ")
@@ -115,14 +134,34 @@ func sshKeyType(s string) (string, bool) {
return "", false return "", false
} }
if t := fields[0]; t == string(typeBytes) { if t := fields[0]; t == string(typeBytes) {
return t, true switch t {
case "ssh-ed25519":
return "", false
case "ssh-rsa":
out, _, _, _, err := ssh.ParseAuthorizedKey([]byte(s))
if err != nil {
return "", false
}
cryptoKey, ok := out.(ssh.CryptoPublicKey)
if !ok {
return "", false
}
rsaKey, ok := cryptoKey.CryptoPublicKey().(*rsa.PublicKey)
if !ok {
return "", false
}
return t, rsaKey.N.BitLen() < 2048
default:
return t, true
}
} }
return "", false return "", false
} }
// parseIdentitiesFile parses a file that contains age or SSH keys. It returns // parseIdentitiesFile parses a file that contains age or SSH keys. It returns
// one or more of *age.X25519Identity, *agessh.RSAIdentity, *agessh.Ed25519Identity, // one or more of *[age.X25519Identity], *[age.HybridIdentity],
// *agessh.EncryptedSSHIdentity, or *EncryptedIdentity. // *[agessh.RSAIdentity], *[agessh.Ed25519Identity],
// *[agessh.EncryptedSSHIdentity], or *[EncryptedIdentity].
func parseIdentitiesFile(name string) ([]age.Identity, error) { func parseIdentitiesFile(name string) ([]age.Identity, error) {
var f *os.File var f *os.File
if name == "-" { if name == "-" {
@@ -141,14 +180,16 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) {
} }
b := bufio.NewReader(f) b := bufio.NewReader(f)
p, _ := b.Peek(14) // length of "age-encryption" and "-----BEGIN AGE" const maxWhitespace = 1024
peeked := string(p) p, _ := b.Peek(maxWhitespace + len(armor.Header))
trimmed := bytes.TrimSpace(p)
switch { switch {
// An age encrypted file, plain or armored. // An age encrypted file, plain or armored.
case peeked == "age-encryption" || peeked == "-----BEGIN AGE": case bytes.HasPrefix(p, []byte("age-encryption")) ||
bytes.HasPrefix(trimmed, []byte(armor.Header)):
var r io.Reader = b var r io.Reader = b
if peeked == "-----BEGIN AGE" { if bytes.HasPrefix(trimmed, []byte(armor.Header)) {
r = armor.NewReader(r) r = armor.NewReader(r)
} }
const privateKeySizeLimit = 1 << 24 // 16 MiB const privateKeySizeLimit = 1 << 24 // 16 MiB
@@ -162,7 +203,7 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) {
return []age.Identity{&EncryptedIdentity{ return []age.Identity{&EncryptedIdentity{
Contents: contents, Contents: contents,
Passphrase: func() (string, error) { Passphrase: func() (string, error) {
pass, err := readSecret(fmt.Sprintf("Enter passphrase for identity file %q:", name)) pass, err := term.ReadSecret(fmt.Sprintf("Enter passphrase for identity file %q:", name))
if err != nil { if err != nil {
return "", fmt.Errorf("could not read passphrase: %v", err) return "", fmt.Errorf("could not read passphrase: %v", err)
} }
@@ -174,7 +215,7 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) {
}}, nil }}, nil
// Another PEM file, possibly an SSH private key. // Another PEM file, possibly an SSH private key.
case strings.HasPrefix(peeked, "-----BEGIN"): case bytes.HasPrefix(trimmed, []byte("-----BEGIN")):
const privateKeySizeLimit = 1 << 14 // 16 KiB const privateKeySizeLimit = 1 << 14 // 16 KiB
contents, err := io.ReadAll(io.LimitReader(b, privateKeySizeLimit)) contents, err := io.ReadAll(io.LimitReader(b, privateKeySizeLimit))
if err != nil { if err != nil {
@@ -198,19 +239,22 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) {
func parseIdentity(s string) (age.Identity, error) { func parseIdentity(s string) (age.Identity, error) {
switch { switch {
case strings.HasPrefix(s, "AGE-PLUGIN-"): case strings.HasPrefix(s, "AGE-PLUGIN-"):
return plugin.NewIdentity(s, pluginTerminalUI) return plugin.NewIdentity(s, plugin.NewTerminalUI(printf, warningf))
case strings.HasPrefix(s, "AGE-SECRET-KEY-1"): case strings.HasPrefix(s, "AGE-SECRET-KEY-1"):
return age.ParseX25519Identity(s) return age.ParseX25519Identity(s)
case strings.HasPrefix(s, "AGE-SECRET-KEY-PQ-1"):
return age.ParseHybridIdentity(s)
default: default:
return nil, fmt.Errorf("unknown identity type") return nil, fmt.Errorf("unknown identity type")
} }
} }
// parseIdentities is like age.ParseIdentities, but supports plugin identities. // parseIdentities is like [age.ParseIdentities], but supports plugin identities.
func parseIdentities(f io.Reader) ([]age.Identity, error) { func parseIdentities(f io.Reader) ([]age.Identity, error) {
const privateKeySizeLimit = 1 << 24 // 16 MiB const privateKeySizeLimit = 1 << 24 // 16 MiB
var ids []age.Identity var ids []age.Identity
scanner := bufio.NewScanner(io.LimitReader(f, privateKeySizeLimit)) lr := &io.LimitedReader{R: f, N: privateKeySizeLimit + 1}
scanner := bufio.NewScanner(lr)
var n int var n int
for scanner.Scan() { for scanner.Scan() {
n++ n++
@@ -218,19 +262,26 @@ func parseIdentities(f io.Reader) ([]age.Identity, error) {
if strings.HasPrefix(line, "#") || line == "" { if strings.HasPrefix(line, "#") || line == "" {
continue continue
} }
if !utf8.ValidString(line) {
return nil, fmt.Errorf("identities file is not valid UTF-8")
}
i, err := parseIdentity(line) i, err := parseIdentity(line)
if err != nil { if err != nil {
if strings.HasPrefix(line, "age1") {
return nil, fmt.Errorf("error at line %d: apparent recipient found in identities file", n)
}
return nil, fmt.Errorf("error at line %d: %v", n, err) return nil, fmt.Errorf("error at line %d: %v", n, err)
} }
ids = append(ids, i) ids = append(ids, i)
} }
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read secret keys file: %v", err) return nil, fmt.Errorf("failed to read identities file: %v", err)
}
if lr.N == 0 {
return nil, fmt.Errorf("identities file is too long")
} }
if len(ids) == 0 { if len(ids) == 0 {
return nil, fmt.Errorf("no secret keys found") return nil, fmt.Errorf("no identities found")
} }
return ids, nil return ids, nil
} }
@@ -246,7 +297,7 @@ func parseSSHIdentity(name string, pemBytes []byte) ([]age.Identity, error) {
} }
} }
passphrasePrompt := func() ([]byte, error) { passphrasePrompt := func() ([]byte, error) {
pass, err := readSecret(fmt.Sprintf("Enter passphrase for %q:", name)) pass, err := term.ReadSecret(fmt.Sprintf("Enter passphrase for %q:", name))
if err != nil { if err != nil {
return nil, fmt.Errorf("could not read passphrase for %q: %v", name, err) return nil, fmt.Errorf("could not read passphrase for %q: %v", name, err)
} }
+21
View File
@@ -0,0 +1,21 @@
age -d -i key.txt armored_with_leading_and_trailing_whitespace.txt
stdout test
-- key.txt --
# created: 2025-12-23T22:21:12+01:00
# public key: age15w9kgvgggmfra4sz6vk39kz4mveuq2sfv5vmcu090y0k2sluepaqv7z2fv
AGE-SECRET-KEY-18J6FVYJE2AFSJ0RPH6M29GMUU62UVRSCNWUJZSGETH6R38Q5AZ3S2DHAZ9
-- armored_with_leading_and_trailing_whitespace.txt --
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA5ODhFNHR6RVg0SGVHZFBM
clBEclEzZ3NvOGhqVE9tcFZnbTc2c3R5a0Q4ClZjVzBLNjdxRElZV3E0Z3ZpZ255
T3JWTFBHRFA2cytpWWtkeU45dDRadmcKLS0tIHV3L3hOVmJjL0hMRXBQa05lMlRs
ZW45TndPeE9GcmRNeWFkR3YxeHg0YzQKJBp6KRlFFUE8jbAQUBlcAwaaQcPAflJD
pWGoOjYP33gTxJHNPg==
-----END AGE ENCRYPTED FILE-----
+53
View File
@@ -0,0 +1,53 @@
# encrypt and decrypt with AGE_PASSPHRASE
env AGE_PASSPHRASE_WORK_FACTOR=5
env AGE_PASSPHRASE=password
age -e -j batchpass -o test.age input
age -d -j batchpass test.age
cmp stdout input
# decrypt with AGE_PASSPHRASE_MAX_WORK_FACTOR
env AGE_PASSPHRASE_MAX_WORK_FACTOR=10
age -d -j batchpass test.age
cmp stdout input
# AGE_PASSPHRASE_MAX_WORK_FACTOR lower than work factor
env AGE_PASSPHRASE_MAX_WORK_FACTOR=3
! age -d -j batchpass test.age
stderr 'work factor'
env AGE_PASSPHRASE_MAX_WORK_FACTOR=
# error: both AGE_PASSPHRASE and AGE_PASSPHRASE_FD set
env AGE_PASSPHRASE=password
env AGE_PASSPHRASE_FD=3
! age -e -j batchpass -a input
stderr 'mutually exclusive'
# error: neither AGE_PASSPHRASE nor AGE_PASSPHRASE_FD set
env AGE_PASSPHRASE=
env AGE_PASSPHRASE_FD=
! age -e -j batchpass -a test.age
stderr 'must be set'
# error: incorrect passphrase
env AGE_PASSPHRASE=wrongpassword
! age -d -j batchpass test.age
stderr 'incorrect passphrase'
# error: encrypting to other recipients along with passphrase
env AGE_PASSPHRASE=password
! age -e -j batchpass -r age1gc2zwslg9j25pg9e9ld7623aewc2gjzquzgq90m75r75myqdt5cq3yudvh -a input
stderr 'incompatible recipients'
! age -e -r age1gc2zwslg9j25pg9e9ld7623aewc2gjzquzgq90m75r75myqdt5cq3yudvh -j batchpass -a input
stderr 'incompatible recipients'
# decrypt with native scrypt
[!linux] [!darwin] skip # no pty support
ttyin terminal
age -d test.age
cmp stdout input
-- terminal --
password
password
-- input --
test
+49
View File
@@ -0,0 +1,49 @@
# Test duplicate recipient detection
age -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -r age1yv2sxrjnyymx4qkn3ehdd5zdq4xnrea5ljm4hsmgxn5tpv985anqkrwqa7 -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -o test.age input
stderr 'warning: duplicate recipient "age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85"'
# Test duplicates separated by different argument
age -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -a -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -o test2.age input
stderr 'warning: duplicate recipient "age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85"'
# Test duplicate recipients file detection
age -R recipients1.txt -R recipients2.txt -R recipients1.txt -o test3.age input
stderr 'warning: duplicate recipients file "recipients1.txt"'
# Test duplicates separated by output flag
age -R recipients1.txt -o test4.age -R recipients1.txt input
stderr 'warning: duplicate recipients file "recipients1.txt"'
# First create an encrypted file for decrypt tests
age -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -o encrypted.age input
# Test duplicate identity file detection (decrypt mode)
age -d -i key1.txt -i key2.txt -i key1.txt encrypted.age
stderr 'warning: duplicate identity file "key1.txt"'
# Test duplicates separated by different argument in decrypt mode
age -d -i key1.txt -o test.out -i key1.txt encrypted.age
stderr 'warning: duplicate identity file "key1.txt"'
# Test no warning when no duplicates
age -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -r age1yv2sxrjnyymx4qkn3ehdd5zdq4xnrea5ljm4hsmgxn5tpv985anqkrwqa7 -o test5.age input
! stderr 'warning: duplicate'
# Test multiple duplicates (same value repeated 3+ times)
age -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -o test6.age input
stderr 'warning: duplicate recipient "age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85"'
-- input --
test data
-- recipients1.txt --
age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85
-- recipients2.txt --
age1yv2sxrjnyymx4qkn3ehdd5zdq4xnrea5ljm4hsmgxn5tpv985anqkrwqa7
-- key1.txt --
# created: 2025-12-22T22:06:22+01:00
# public key: age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85
AGE-SECRET-KEY-1WRM2S8SP3XSKLLXAXS489EXZNKCKRZWYQLQ8D2NRNQWCVAPSMA9SC5JWZQ
-- key2.txt --
# created: 2025-12-22T22:06:27+01:00
# public key: age1yv2sxrjnyymx4qkn3ehdd5zdq4xnrea5ljm4hsmgxn5tpv985anqkrwqa7
AGE-SECRET-KEY-1WZ3MRPAWEWR4DG474H460MXX7J2T0TEYNJ0SKQDMKP02JU7UJ9UQFGLZCE
+16 -1
View File
@@ -2,7 +2,6 @@
# age file password prompt during encryption # age file password prompt during encryption
[!linux] [!darwin] skip # no pty support [!linux] [!darwin] skip # no pty support
[darwin] [go1.20] skip # https://go.dev/issue/61779
# use an encrypted OpenSSH private key without .pub file # use an encrypted OpenSSH private key without .pub file
age -R key_ed25519.pub -o ed25519.age input age -R key_ed25519.pub -o ed25519.age input
@@ -60,10 +59,26 @@ ttyout 'Enter passphrase'
! stderr . ! stderr .
cmp stdout input cmp stdout input
# armored identity files accept leading whitespace
exec age-keygen -o key.txt
age -e -i key.txt -o test.age input
ttyin newpass
age -p -a -o key.age key.txt
exec cat blank key.age
cp stdout key-with-blank.age
ttyin terminal
age -d -i key-with-blank.age test.age
cmp stdout input
-- input -- -- input --
test test
-- terminal -- -- terminal --
password password
-- newpass --
password
password
-- blank --
-- key_ed25519 -- -- key_ed25519 --
-----BEGIN OPENSSH PRIVATE KEY----- -----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABCuvb97i7 b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABCuvb97i7
+54
View File
@@ -0,0 +1,54 @@
# encrypt and decrypt a file with -r
age -r age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0 -o test.age input
age -d -i key.txt test.age
cmp stdout input
! stderr .
# encrypt and decrypt a file with -i
age -e -i key.txt -o test.age input
age -d -i key.txt test.age
cmp stdout input
! stderr .
# encrypt and decrypt a file with the wrong key
age -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -o test.age input
! age -d -i key.txt test.age
stderr 'no identity matched any of the recipients'
age -r age1pq13tgqupu990y5qlwwjy4zrzwvxg54cq89x9pxzdjenv4cl7affsqeerksrc6ufw3ndp4h5p3l5hm2jekkd7uay2mlh9eqg3sjcwmv5dcrwguc8djf83qr8gswtdrpav3jpw47kgkh6z0rnfk92xyqeyce48t02n4qz68augry3r3nd4rpdsw998vnnxzd2vepypktx2wxhfvd56jw7p32te6ttcyhc7tkqy2ks77jtjl8cgyal43pcd3g9wkcsgytccqtcnzcucjjdadpr0v7ye8u9fqusq6cq2ah0e4ejrltc8sk6763d7mjkuchmc5tkx6xztygqjx4ldxqh7jzkkyuv6ywt07ys4x09eq7gat4wuznrvgs3pkxjju8t9n0gpfqrsv658m5v09ltqc3c5z30n43ld43dwe5dn8nsk89f43tzsw2zta5kvl95zv0a94tc5y9r4ncj8g63fgljzhdxl96eg39rgd56yuvr2xdvavkk93a85n99jpjeqkfveprqc7eqft0wp7qgxcgsrwcdymgp0cqaj93jp4ckln36ylcwdzew234esa2awty25zc6pmc0jcpkrfuuptr9z5a4kx9ff4ppfff2lw95um853tcg47fa444er9k9j4e8vy5hfjx626j3tz3kc94jl8r66ahfprwtcyxae3swe9xxjhx5dfnt53n5fs2ut83t597wscjg9jc4f6h4uen2ll2ne30w7uvts8vuc5mpcfpp9espa78y0f8588m62kzez5kxxtcxykdkrnjx08w5az3pr6g6dsykwazcxtetpkq85mg7kay84z58slmvxmq56342pm6v8y3evr5wvc9g4j87jfhmryymkz9wk2en2v6tfy83rz657t0w7p3va46jt22f29u9ggzt9nu6qtguw4cp7cpj572ccyenw5rdh43h6s54g73gvxapsq3hw5yfkgttcyhkrjf9ykzd3wm5zsqgp5fca0nr56dnqe3kqrszasagqne0a83cguzwj9aw456jaxg9rurq4n4f6qec4srd0rxqfqc6rw22c2p4xyg25pls4dmxvce2fah8m6shue84preky2h0c4sa9y9tjuzn20gr9aav96e689nqx23s7zz0lvhq925jdkn04cdpzfscvhh93vh9f2tmwkzsq7pq0ncjk8g8cu75u77lyej46a4m2fefy8v2x4nzay05kulhyzk69520r7fdg3fzh0z7ysequcltr94hyf88h46hfujk30x9jp8u0pcjywh06g7tv483ypmc3pm3x3z4h9ph66lx228t3r96hyt764yxe2rn5clnfxkj3k5wf5hffehj58y56qyykwayk6qt9skvctxw20v9xy5ppnld3lushqpsutxht7cqygypdn5d2ppdgaerqzgehyrpdwzkhu0qe8w3u5h6htz8aa5zfpzy4s7sl8zdv3q0gq8ez08p86e4v3m82882yvawrvyrcxewrznwkvvez8m5aj6ktgvynyy0kc2trmtjzvjlxdf06e3rjmf55lwxrucfwu2sxdtnte83fgq0tvr2juv3pfqp8ddsrnckzqcfcvjp02mfg5y4aqlsunxpfcrdm46fphwsslrudwrfh542xg62kphca6h3xxqn538pprkknt35y00ygvrse5mxpvnstvcrnak5qduhf5dqslkn0yeadgpq6tv4wzy98kdjdzp22cpq6dy3kve856y2qlk7elyqyzj7ezpnh3vjwwmcm7ctp23k2sct86jd46ztplsq5vdjg9lyspxt0k8qx5udu9lwgulzapn3kdg7yd2zdz5dqf9933mpzwc32x8uxn8h2v5hlhdd4qk0uvwxhxyul8keaw39pz2avywk3wfly6veet9pjnj7nqecrgz824whs9sf7wl2shxk9kvkteht4z9x3w2gc6hqz5y -o test.age input
! age -d -i key.txt test.age
stderr 'no identity matched any of the recipients'
# cannot mix hybrid and X25519 recipients
! age -r age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0 -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -o test.age input
stderr 'incompatible'
! age -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -r age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0 -o test.age input
stderr 'incompatible'
# convert to plugin identity and use plugin
exec age-plugin-pq -identity -o key-plugin.txt key.txt
age -e -i key.txt -o test.age input
age -d -i key-plugin.txt test.age
cmp stdout input
! stderr .
age -e -i key-plugin.txt -o test.age input
age -d -i key.txt test.age
cmp stdout input
! stderr .
! exec age-plugin-pq -identity -o mixed-plugin.txt mixed.txt
stderr 'identity #2 is not a post-quantum hybrid identity'
! exists mixed-plugin.txt
-- input --
test
-- key.txt --
# created: 2025-11-17T13:27:37+01:00
# public key: age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0
AGE-SECRET-KEY-PQ-16XDSLZ3XCSZ3236YJ5J0T9NAPLZTP96LJKQCVHJYUDTQVJR5J5PQTDPQCX
-- mixed.txt --
AGE-SECRET-KEY-PQ-16XDSLZ3XCSZ3236YJ5J0T9NAPLZTP96LJKQCVHJYUDTQVJR5J5PQTDPQCX
AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0
+29
View File
@@ -0,0 +1,29 @@
exec age-keygen
stdout '# created: 20'
stdout '# public key: age1'
stdout 'AGE-SECRET-KEY-1'
stderr 'Public key: age1'
exec age-keygen -pq
stdout '# created: 20'
stdout '# public key: age1pq1'
stdout 'AGE-SECRET-KEY-PQ-1'
stderr 'Public key: age1pq1'
exec age-keygen -pq -o key.txt
! stdout .
stderr 'Public key: age1pq1'
grep '# created: 20' key.txt
grep '# public key: age1pq1' key.txt
grep 'AGE-SECRET-KEY-PQ-1' key.txt
stdin key.txt
exec age-keygen -y
stdout age1pq1
exec age-keygen -y key.txt
stdout age1pq1
! exec age-keygen -y -o missing.txt missing
stderr 'failed to open input file'
! exists missing.txt
+101
View File
@@ -0,0 +1,101 @@
# https://github.com/FiloSottile/age/issues/57
age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o test.age input
! age -o test.out -d -i wrong.txt test.age
! exists test.out
! age -o test.out -d test.age
! exists test.out
! age -o test.out -d -i notexist test.age
! exists test.out
! age -o test.out -d -i wrong.txt notexist
! exists test.out
! age -o test.out -r BAD
! exists test.out
! age -o test.out -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef notexist
! exists test.out
! age -o test.out -p notexist
! exists test.out
# https://github.com/FiloSottile/age/issues/555
age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o empty.age empty
exists empty.age
age -d -i key.txt empty.age
! stdout .
! stderr .
age -d -i key.txt -o new empty.age
! stderr .
cmp new empty
# Don't create or truncate the output if the payload fails authentication.
! age -o test.out -d -i key.txt corrupt.age
stderr 'failed to decrypt and authenticate payload chunk'
! exists test.out
cp input existing
! age -o existing -d -i key.txt corrupt.age
stderr 'failed to decrypt and authenticate payload chunk'
cmp existing input
# Report output creation errors for an empty plaintext.
! age -o missingdir/test.out -d -i key.txt empty.age
stderr 'missingdir/test.out'
! exists missingdir/test.out
! age -o missingdir/test.out -d -i key.txt test.age
stderr 'missingdir/test.out'
# https://github.com/FiloSottile/age/issues/491
cp input inputcopy
! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o inputcopy inputcopy
stderr 'input and output file are the same'
cmp inputcopy input
! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o ./inputcopy inputcopy
stderr 'input and output file are the same'
cmp inputcopy input
mkdir foo
! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o inputcopy foo/../inputcopy
stderr 'input and output file are the same'
cmp inputcopy input
cp key.txt keycopy
age -e -i keycopy -o test.age input
! age -d -i keycopy -o keycopy test.age
stderr 'input and output file are the same'
cmp key.txt keycopy
[!linux] [!darwin] skip # no pty support
ttyin terminal
! age -p -o inputcopy inputcopy
stderr 'input and output file are the same'
cmp inputcopy input
# https://github.com/FiloSottile/age/issues/159
ttyin terminal
age -p -a -o test.age input
ttyin terminalwrong
! age -o test.out -d test.age
ttyout 'Enter passphrase'
stderr 'incorrect passphrase'
! exists test.out
-- terminal --
password
password
-- terminalwrong --
wrong
-- input --
age
-- empty --
-- corrupt.age --
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBDMXYwVmsyU3dyc1NPdUF1
S1ZKRXhVMXFKWDdyd2ZHajZyUkRDRHBad20wCk93WjZOS0tOZjBNSW5Ka2ErUFRz
K0gzU1pzWEdqS3EvZWlmbUE1ZE95V1EKLS0tIEZBTW81V3RhWk1xVVhoMnA3UDRl
YSs2QW5zeHp6ZFk4M1dSc0pmeld6VWcK2XikoRl4QMDKbFQRZXWCl/A6Z5nvCK3a
RaZyUlKtXBnSTaVCMKRb
-----END AGE ENCRYPTED FILE-----
-- key.txt --
# created: 2021-02-02T13:09:43+01:00
# public key: age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef
AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0
-- wrong.txt --
# created: 2024-06-16T12:14:00+02:00
# public key: age10k7vsqmeg3sp8elfyq5ts55feg4huarpcaf9dmljn9umydg3gymsvx4dp9
AGE-SECRET-KEY-1NPX08S4LELW9K68FKU0U05XXEKG6X7GT004TPNYLF86H3M00D3FQ3VQQNN
+61
View File
@@ -0,0 +1,61 @@
[!symlink] skip # no symlink support
age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o test.age input
# The output is a symlink to the input.
cp input inputcopy
symlink inputlink -> inputcopy
! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o inputlink inputcopy
stderr 'input and output file are the same'
cmp inputcopy input
# The input is a symlink to the output.
! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o inputcopy inputlink
stderr 'input and output file are the same'
cmp inputcopy input
# The output goes through a symlinked directory.
mkdir dir
cp input dir/inputcopy
symlink dirlink -> dir
! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o dirlink/inputcopy dir/inputcopy
stderr 'input and output file are the same'
cmp dir/inputcopy input
# The output is a symlink to the identity file.
cp key.txt keycopy
symlink keylink -> keycopy
! age -d -i keycopy -o keylink test.age
stderr 'input and output file are the same'
cmp keycopy key.txt
# A distinct existing file is still a valid output.
cp input other
age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o other inputcopy
! cmp other input
cmp inputcopy input
# A dangling symlink is still a valid output.
symlink new -> notexist
age -d -i key.txt -o new test.age
cmp notexist input
# testscript has no hard link command, so the rest needs an external ln.
[!exec:ln] stop
exec ln inputcopy inputhard
! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o inputhard inputcopy
stderr 'input and output file are the same'
cmp inputcopy input
exec ln keycopy keyhard
! age -d -i keycopy -o keyhard test.age
stderr 'input and output file are the same'
cmp keycopy key.txt
-- input --
age
-- key.txt --
# created: 2021-02-02T13:09:43+01:00
# public key: age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef
AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0
+22
View File
@@ -10,6 +10,21 @@ age -d -i long-key.txt test.age
cmp stdout input cmp stdout input
! stderr . ! stderr .
# check that path separators are rejected
chmod 755 age-plugin-pwn/pwn
mkdir $TMPDIR/age-plugin-pwn
cp age-plugin-pwn/pwn $TMPDIR/age-plugin-pwn/pwn
! age -r age1pwn/pwn19gt89dfz input
! age -d -i pwn-identity.txt test.age
! age -d -j pwn/pwn test.age
! exists pwn
# check plugin not found hint
! age -r age1nonexistentplugin1pt5d8z -o test1.age
stderr /awesome#plugins
! age -d -i nonexistent-identity.txt test.age
stderr /awesome#plugins
-- input -- -- input --
test test
-- key.txt -- -- key.txt --
@@ -18,3 +33,10 @@ AGE-PLUGIN-TEST-10Q32NLXM
age1test10pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7qj6rl8p age1test10pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7qj6rl8p
-- long-key.txt -- -- long-key.txt --
AGE-PLUGIN-TEST-10PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7Q5U8SUD AGE-PLUGIN-TEST-10PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7Q5U8SUD
-- pwn-identity.txt --
AGE-PLUGIN-PWN/PWN-19GYK4WLY
-- age-plugin-pwn/pwn --
#!/bin/sh
touch "$WORK/pwn"
-- nonexistent-identity.txt --
AGE-PLUGIN-NONEXISTENTPLUGIN-1R4XFW4
+5 -1
View File
@@ -1,5 +1,4 @@
[!linux] [!darwin] skip # no pty support [!linux] [!darwin] skip # no pty support
[darwin] [go1.20] skip # https://go.dev/issue/61779
# encrypt with a provided passphrase # encrypt with a provided passphrase
stdin input stdin input
@@ -42,6 +41,11 @@ ttyin wrong
stderr 'passphrases didn''t match' stderr 'passphrases didn''t match'
! exists fail.age ! exists fail.age
# fail when -i is missing
age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o test.age input
! age -d test.age
stderr 'file is not passphrase-encrypted, identities are required'
-- terminal -- -- terminal --
password password
password password
-1
View File
@@ -1,5 +1,4 @@
[!linux] [!darwin] skip # no pty support [!linux] [!darwin] skip # no pty support
[darwin] [go1.20] skip # https://go.dev/issue/61779
# controlling terminal is used instead of stdin/stderr # controlling terminal is used instead of stdin/stderr
ttyin terminal ttyin terminal
+5
View File
@@ -15,6 +15,11 @@ age -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -o test.ag
! age -d -i key.txt test.age ! age -d -i key.txt test.age
stderr 'no identity matched any of the recipients' stderr 'no identity matched any of the recipients'
# decrypt an empty file
! age -d -i key.txt empty
stderr empty
-- empty --
-- input -- -- input --
test test
-- key.txt -- -- key.txt --
+11 -159
View File
@@ -12,35 +12,35 @@ package main
// //
// - Everything else goes to standard error with an "age:" prefix. // - Everything else goes to standard error with an "age:" prefix.
// No capitalized initials and no periods at the end. // No capitalized initials and no periods at the end.
//
// The one exception is the autogenerated passphrase, which goes to
// the terminal, since we really want it to reach the user only.
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
"os" "os"
"runtime"
"filippo.io/age/armor" "filippo.io/age/armor"
"filippo.io/age/plugin" "filippo.io/age/internal/term"
"golang.org/x/term"
) )
// l is a logger with no prefixes. // l is a logger with no prefixes.
var l = log.New(os.Stderr, "", 0) var l = log.New(os.Stderr, "", 0)
func printf(format string, v ...interface{}) { func printf(format string, v ...any) {
l.Printf("age: "+format, v...) l.Printf("age: "+format, v...)
} }
func errorf(format string, v ...interface{}) { func errorf(format string, v ...any) {
l.Printf("age: error: "+format, v...) l.Printf("age: error: "+format, v...)
l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report") l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report")
exit(1) os.Exit(1)
} }
func warningf(format string, v ...interface{}) { func warningf(format string, v ...any) {
l.Printf("age: warning: "+format, v...) l.Printf("age: warning: "+format, v...)
} }
@@ -50,164 +50,16 @@ func errorWithHint(error string, hints ...string) {
l.Printf("age: hint: %s", hint) l.Printf("age: hint: %s", hint)
} }
l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report") l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report")
exit(1) os.Exit(1)
} }
// If testOnlyPanicInsteadOfExit is true, exit will set testOnlyDidExit and func printfToTerminal(format string, v ...any) error {
// panic instead of calling os.Exit. This way, the wrapper in TestMain can return term.WithTerminal(func(_, out *os.File) error {
// recover the panic and return the exit code only if it was originated in exit.
var testOnlyPanicInsteadOfExit bool
var testOnlyDidExit bool
func exit(code int) {
if testOnlyPanicInsteadOfExit {
testOnlyDidExit = true
panic(code)
}
os.Exit(code)
}
// clearLine clears the current line on the terminal, or opens a new line if
// terminal escape codes don't work.
func clearLine(out io.Writer) {
const (
CUI = "\033[" // Control Sequence Introducer
CPL = CUI + "F" // Cursor Previous Line
EL = CUI + "K" // Erase in Line
)
// First, open a new line, which is guaranteed to work everywhere. Then, try
// to erase the line above with escape codes.
//
// (We use CRLF instead of LF to work around an apparent bug in WSL2's
// handling of CONOUT$. Only when running a Windows binary from WSL2, the
// cursor would not go back to the start of the line with a simple LF.
// Honestly, it's impressive CONIN$ and CONOUT$ work at all inside WSL2.)
fmt.Fprintf(out, "\r\n"+CPL+EL)
}
// withTerminal runs f with the terminal input and output files, if available.
// withTerminal does not open a non-terminal stdin, so the caller does not need
// to check stdinInUse.
func withTerminal(f func(in, out *os.File) error) error {
if runtime.GOOS == "windows" {
in, err := os.OpenFile("CONIN$", os.O_RDWR, 0)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0)
if err != nil {
return err
}
defer out.Close()
return f(in, out)
} else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil {
defer tty.Close()
return f(tty, tty)
} else if term.IsTerminal(int(os.Stdin.Fd())) {
return f(os.Stdin, os.Stdin)
} else {
return fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err)
}
}
func printfToTerminal(format string, v ...interface{}) error {
return withTerminal(func(_, out *os.File) error {
_, err := fmt.Fprintf(out, "age: "+format+"\n", v...) _, err := fmt.Fprintf(out, "age: "+format+"\n", v...)
return err return err
}) })
} }
// readSecret reads a value from the terminal with no echo. The prompt is ephemeral.
func readSecret(prompt string) (s []byte, err error) {
err = withTerminal(func(in, out *os.File) error {
fmt.Fprintf(out, "%s ", prompt)
defer clearLine(out)
s, err = term.ReadPassword(int(in.Fd()))
return err
})
return
}
// readCharacter reads a single character from the terminal with no echo. The
// prompt is ephemeral.
func readCharacter(prompt string) (c byte, err error) {
err = withTerminal(func(in, out *os.File) error {
fmt.Fprintf(out, "%s ", prompt)
defer clearLine(out)
oldState, err := term.MakeRaw(int(in.Fd()))
if err != nil {
return err
}
defer term.Restore(int(in.Fd()), oldState)
b := make([]byte, 1)
if _, err := in.Read(b); err != nil {
return err
}
c = b[0]
return nil
})
return
}
var pluginTerminalUI = &plugin.ClientUI{
DisplayMessage: func(name, message string) error {
printf("%s plugin: %s", name, message)
return nil
},
RequestValue: func(name, message string, _ bool) (s string, err error) {
defer func() {
if err != nil {
warningf("could not read value for age-plugin-%s: %v", name, err)
}
}()
secret, err := readSecret(message)
if err != nil {
return "", err
}
return string(secret), nil
},
Confirm: func(name, message, yes, no string) (choseYes bool, err error) {
defer func() {
if err != nil {
warningf("could not read value for age-plugin-%s: %v", name, err)
}
}()
if no == "" {
message += fmt.Sprintf(" (press enter for %q)", yes)
_, err := readSecret(message)
if err != nil {
return false, err
}
return true, nil
}
message += fmt.Sprintf(" (press [1] for %q or [2] for %q)", yes, no)
for {
selection, err := readCharacter(message)
if err != nil {
return false, err
}
switch selection {
case '1':
return true, nil
case '2':
return false, nil
case '\x03': // CTRL-C
return false, errors.New("user cancelled prompt")
default:
warningf("reading value for age-plugin-%s: invalid selection %q", name, selection)
}
}
},
WaitTimer: func(name string) {
printf("waiting on %s plugin...", name)
},
}
func bufferTerminalInput(in io.Reader) (io.Reader, error) { func bufferTerminalInput(in io.Reader) (io.Reader, error) {
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
if _, err := buf.ReadFrom(ReaderFunc(func(p []byte) (n int, err error) { if _, err := buf.ReadFrom(ReaderFunc(func(p []byte) (n int, err error) {
+95
View File
@@ -0,0 +1,95 @@
.\" generated with Ronn-NG/v0.9.1
.\" http://github.com/apjanke/ronn-ng/tree/0.9.1
.TH "AGE\-INSPECT" "1" "August 2026" ""
.SH "NAME"
\fBage\-inspect\fR \- inspect age(1) encrypted files
.SH "SYNOPSIS"
\fBage\-inspect\fR [\fB\-\-json\fR] [\fIINPUT\fR]
.SH "DESCRIPTION"
\fBage\-inspect\fR reads an age(1) encrypted file from \fIINPUT\fR (or standard input) and displays metadata about it without decrypting\.
.P
This includes the recipient types, whether it uses post\-quantum encryption, and a size breakdown of the file components\.
.SH "OPTIONS"
.TP
\fB\-\-json\fR
Output machine\-readable JSON instead of human\-readable text\.
.TP
\fB\-\-version\fR
Print the version and exit\.
.SH "JSON FORMAT"
When \fB\-\-json\fR is specified, the output is a JSON object with these fields:
.IP "\[ci]" 4
\fBversion\fR: The age format version (e\.g\., \fB"age\-encryption\.org/v1"\fR)\.
.IP "\[ci]" 4
\fBpostquantum\fR: Whether the file uses post\-quantum encryption: \fB"yes"\fR, \fB"no"\fR, or \fB"unknown"\fR\.
.IP "\[ci]" 4
\fBarmor\fR: Boolean indicating whether the file is ASCII\-armored\.
.IP "\[ci]" 4
\fBstanza_types\fR: Array of recipient stanza type strings (e\.g\., \fB["X25519"]\fR or \fB["mlkem768x25519"]\fR)\.
.IP "\[ci]" 4
\fBsizes\fR: Object containing size information in bytes:
.IP "\[ci]" 4
\fBheader\fR: Size of the age header\.
.IP "\[ci]" 4
\fBarmor\fR: Armor encoding overhead (0 if not armored)\.
.IP "\[ci]" 4
\fBoverhead\fR: Stream encryption overhead\.
.IP "\[ci]" 4
\fBmin_payload\fR, \fBmax_payload\fR: Payload size bounds (currently always matching)\.
.IP "\[ci]" 4
\fBmin_padding\fR, \fBmax_padding\fR: Padding size bounds (currently always 0)\.
.IP "" 0
.IP
The fields add up to the total size of the file\.
.IP "" 0
.SH "EXAMPLES"
Inspect an encrypted file:
.IP "" 4
.nf
$ age\-inspect secrets\.age
secrets\.age is an age file, version "age\-encryption\.org/v1"\.
This file is encrypted to the following recipient types:
\- "mlkem768x25519"
This file uses post\-quantum encryption\.
Size breakdown (assuming it decrypts successfully):
Header 1627 bytes
Encryption overhead 32 bytes
Payload 42 bytes
\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-
Total 1701 bytes
Tip: for machine\-readable output, use \-\-json\.
.fi
.IP "" 0
.P
Get JSON output for scripting:
.IP "" 4
.nf
$ age\-inspect \-\-json secrets\.age
{
"version": "age\-encryption\.org/v1",
"postquantum": "yes",
"armor": false,
"stanza_types": [
"mlkem768x25519"
],
"sizes": {
"header": 1627,
"armor": 0,
"overhead": 32,
"min_payload": 42,
"max_payload": 42,
"min_padding": 0,
"max_padding": 0
}
}
.fi
.IP "" 0
.SH "SEE ALSO"
age(1), age\-keygen(1)
.SH "AUTHORS"
Filippo Valsorda \fIage@filippo\.io\fR
+204
View File
@@ -0,0 +1,204 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv='content-type' content='text/html;charset=utf8'>
<meta name='generator' content='Ronn-NG/v0.9.1 (http://github.com/apjanke/ronn-ng/tree/0.9.1)'>
<title>age-inspect(1) - inspect age(1) encrypted files</title>
<style type='text/css' media='all'>
/* style: man */
body#manpage {margin:0}
.mp {max-width:100ex;padding:0 9ex 1ex 4ex}
.mp p,.mp pre,.mp ul,.mp ol,.mp dl {margin:0 0 20px 0}
.mp h2 {margin:10px 0 0 0}
.mp > p,.mp > pre,.mp > ul,.mp > ol,.mp > dl {margin-left:8ex}
.mp h3 {margin:0 0 0 4ex}
.mp dt {margin:0;clear:left}
.mp dt.flush {float:left;width:8ex}
.mp dd {margin:0 0 0 9ex}
.mp h1,.mp h2,.mp h3,.mp h4 {clear:left}
.mp pre {margin-bottom:20px}
.mp pre+h2,.mp pre+h3 {margin-top:22px}
.mp h2+pre,.mp h3+pre {margin-top:5px}
.mp img {display:block;margin:auto}
.mp h1.man-title {display:none}
.mp,.mp code,.mp pre,.mp tt,.mp kbd,.mp samp,.mp h3,.mp h4 {font-family:monospace;font-size:14px;line-height:1.42857142857143}
.mp h2 {font-size:16px;line-height:1.25}
.mp h1 {font-size:20px;line-height:2}
.mp {text-align:justify;background:#fff}
.mp,.mp code,.mp pre,.mp pre code,.mp tt,.mp kbd,.mp samp {color:#131211}
.mp h1,.mp h2,.mp h3,.mp h4 {color:#030201}
.mp u {text-decoration:underline}
.mp code,.mp strong,.mp b {font-weight:bold;color:#131211}
.mp em,.mp var {font-style:italic;color:#232221;text-decoration:none}
.mp a,.mp a:link,.mp a:hover,.mp a code,.mp a pre,.mp a tt,.mp a kbd,.mp a samp {color:#0000ff}
.mp b.man-ref {font-weight:normal;color:#434241}
.mp pre {padding:0 4ex}
.mp pre code {font-weight:normal;color:#434241}
.mp h2+pre,h3+pre {padding-left:0}
ol.man-decor,ol.man-decor li {margin:3px 0 10px 0;padding:0;float:left;width:33%;list-style-type:none;text-transform:uppercase;color:#999;letter-spacing:1px}
ol.man-decor {width:100%}
ol.man-decor li.tl {text-align:left}
ol.man-decor li.tc {text-align:center;letter-spacing:4px}
ol.man-decor li.tr {text-align:right;float:right}
</style>
</head>
<!--
The following styles are deprecated and will be removed at some point:
div#man, div#man ol.man, div#man ol.head, div#man ol.man.
The .man-page, .man-decor, .man-head, .man-foot, .man-title, and
.man-navigation should be used instead.
-->
<body id='manpage'>
<div class='mp' id='man'>
<div class='man-navigation' style='display:none'>
<a href="#NAME">NAME</a>
<a href="#SYNOPSIS">SYNOPSIS</a>
<a href="#DESCRIPTION">DESCRIPTION</a>
<a href="#OPTIONS">OPTIONS</a>
<a href="#JSON-FORMAT">JSON FORMAT</a>
<a href="#EXAMPLES">EXAMPLES</a>
<a href="#SEE-ALSO">SEE ALSO</a>
<a href="#AUTHORS">AUTHORS</a>
</div>
<ol class='man-decor man-head man head'>
<li class='tl'>age-inspect(1)</li>
<li class='tc'></li>
<li class='tr'>age-inspect(1)</li>
</ol>
<h2 id="NAME">NAME</h2>
<p class="man-name">
<code>age-inspect</code> - <span class="man-whatis">inspect <a class="man-ref" href="age.1.html">age<span class="s">(1)</span></a> encrypted files</span>
</p>
<h2 id="SYNOPSIS">SYNOPSIS</h2>
<p><code>age-inspect</code> [<code>--json</code>] [<var>INPUT</var>]</p>
<h2 id="DESCRIPTION">DESCRIPTION</h2>
<p><code>age-inspect</code> reads an <a class="man-ref" href="age.1.html">age<span class="s">(1)</span></a> encrypted file from <var>INPUT</var> (or standard input)
and displays metadata about it without decrypting.</p>
<p>This includes the recipient types, whether it uses post-quantum encryption,
and a size breakdown of the file components.</p>
<h2 id="OPTIONS">OPTIONS</h2>
<dl>
<dt><code>--json</code></dt>
<dd> Output machine-readable JSON instead of human-readable text.</dd>
<dt><code>--version</code></dt>
<dd> Print the version and exit.</dd>
</dl>
<h2 id="JSON-FORMAT">JSON FORMAT</h2>
<p>When <code>--json</code> is specified, the output is a JSON object with these fields:</p>
<ul>
<li>
<p><code>version</code>:
The age format version (e.g., <code>"age-encryption.org/v1"</code>).</p>
</li>
<li>
<p><code>postquantum</code>:
Whether the file uses post-quantum encryption: <code>"yes"</code>, <code>"no"</code>, or
<code>"unknown"</code>.</p>
</li>
<li>
<p><code>armor</code>:
Boolean indicating whether the file is ASCII-armored.</p>
</li>
<li>
<p><code>stanza_types</code>:
Array of recipient stanza type strings (e.g., <code>["X25519"]</code> or
<code>["mlkem768x25519"]</code>).</p>
</li>
<li>
<p><code>sizes</code>:
Object containing size information in bytes:</p>
<ul>
<li>
<code>header</code>: Size of the age header.</li>
<li>
<code>armor</code>: Armor encoding overhead (0 if not armored).</li>
<li>
<code>overhead</code>: Stream encryption overhead.</li>
<li>
<code>min_payload</code>, <code>max_payload</code>: Payload size bounds (currently always matching).</li>
<li>
<code>min_padding</code>, <code>max_padding</code>: Padding size bounds (currently always 0).</li>
</ul>
<p>The fields add up to the total size of the file.</p>
</li>
</ul>
<h2 id="EXAMPLES">EXAMPLES</h2>
<p>Inspect an encrypted file:</p>
<pre><code>$ age-inspect secrets.age
secrets.age is an age file, version "age-encryption.org/v1".
This file is encrypted to the following recipient types:
- "mlkem768x25519"
This file uses post-quantum encryption.
Size breakdown (assuming it decrypts successfully):
Header 1627 bytes
Encryption overhead 32 bytes
Payload 42 bytes
-------------------
Total 1701 bytes
Tip: for machine-readable output, use --json.
</code></pre>
<p>Get JSON output for scripting:</p>
<pre><code>$ age-inspect --json secrets.age
{
"version": "age-encryption.org/v1",
"postquantum": "yes",
"armor": false,
"stanza_types": [
"mlkem768x25519"
],
"sizes": {
"header": 1627,
"armor": 0,
"overhead": 32,
"min_payload": 42,
"max_payload": 42,
"min_padding": 0,
"max_padding": 0
}
}
</code></pre>
<h2 id="SEE-ALSO">SEE ALSO</h2>
<p><a class="man-ref" href="age.1.html">age<span class="s">(1)</span></a>, <a class="man-ref" href="age-keygen.1.html">age-keygen<span class="s">(1)</span></a></p>
<h2 id="AUTHORS">AUTHORS</h2>
<p>Filippo Valsorda <a href="mailto:age@filippo.io" data-bare-link="true">age@filippo.io</a></p>
<ol class='man-decor man-foot man foot'>
<li class='tl'></li>
<li class='tc'>August 2026</li>
<li class='tr'>age-inspect(1)</li>
</ol>
</div>
</body>
</html>
+102
View File
@@ -0,0 +1,102 @@
age-inspect(1) -- inspect age(1) encrypted files
====================================================
## SYNOPSIS
`age-inspect` [`--json`] [<INPUT>]
## DESCRIPTION
`age-inspect` reads an age(1) encrypted file from <INPUT> (or standard input)
and displays metadata about it without decrypting.
This includes the recipient types, whether it uses post-quantum encryption,
and a size breakdown of the file components.
## OPTIONS
* `--json`:
Output machine-readable JSON instead of human-readable text.
* `--version`:
Print the version and exit.
## JSON FORMAT
When `--json` is specified, the output is a JSON object with these fields:
* `version`:
The age format version (e.g., `"age-encryption.org/v1"`).
* `postquantum`:
Whether the file uses post-quantum encryption: `"yes"`, `"no"`, or
`"unknown"`.
* `armor`:
Boolean indicating whether the file is ASCII-armored.
* `stanza_types`:
Array of recipient stanza type strings (e.g., `["X25519"]` or
`["mlkem768x25519"]`).
* `sizes`:
Object containing size information in bytes:
* `header`: Size of the age header.
* `armor`: Armor encoding overhead (0 if not armored).
* `overhead`: Stream encryption overhead.
* `min_payload`, `max_payload`: Payload size bounds (currently always matching).
* `min_padding`, `max_padding`: Padding size bounds (currently always 0).
The fields add up to the total size of the file.
## EXAMPLES
Inspect an encrypted file:
$ age-inspect secrets.age
secrets.age is an age file, version "age-encryption.org/v1".
This file is encrypted to the following recipient types:
- "mlkem768x25519"
This file uses post-quantum encryption.
Size breakdown (assuming it decrypts successfully):
Header 1627 bytes
Encryption overhead 32 bytes
Payload 42 bytes
-------------------
Total 1701 bytes
Tip: for machine-readable output, use --json.
Get JSON output for scripting:
$ age-inspect --json secrets.age
{
"version": "age-encryption.org/v1",
"postquantum": "yes",
"armor": false,
"stanza_types": [
"mlkem768x25519"
],
"sizes": {
"header": 1627,
"armor": 0,
"overhead": 32,
"min_payload": 42,
"max_payload": 42,
"min_padding": 0,
"max_padding": 0
}
}
## SEE ALSO
age(1), age-keygen(1)
## AUTHORS
Filippo Valsorda <age@filippo.io>
+23 -8
View File
@@ -1,10 +1,10 @@
.\" generated with Ronn-NG/v0.9.1 .\" generated with Ronn-NG/v0.9.1
.\" http://github.com/apjanke/ronn-ng/tree/0.9.1 .\" http://github.com/apjanke/ronn-ng/tree/0.9.1
.TH "AGE\-KEYGEN" "1" "April 2023" "" .TH "AGE\-KEYGEN" "1" "August 2026" ""
.SH "NAME" .SH "NAME"
\fBage\-keygen\fR \- generate age(1) key pairs \fBage\-keygen\fR \- generate age(1) key pairs
.SH "SYNOPSIS" .SH "SYNOPSIS"
\fBage\-keygen\fR [\fB\-o\fR \fIOUTPUT\fR] \fBage\-keygen\fR [\fB\-pq\fR] [\fB\-o\fR \fIOUTPUT\fR]
.br .br
\fBage\-keygen\fR \fB\-y\fR [\fB\-o\fR \fIOUTPUT\fR] [\fIINPUT\fR] \fBage\-keygen\fR \fB\-y\fR [\fB\-o\fR \fIOUTPUT\fR] [\fIINPUT\fR]
.br .br
@@ -14,6 +14,11 @@
If the output is not going to a terminal, \fBage\-keygen\fR prints the public key to standard error\. If the output is not going to a terminal, \fBage\-keygen\fR prints the public key to standard error\.
.SH "OPTIONS" .SH "OPTIONS"
.TP .TP
\fB\-pq\fR
Generate a post\-quantum hybrid ML\-KEM\-768 + X25519 key pair\.
.IP
In the future, this might become the default\.
.TP
\fB\-o\fR, \fB\-\-output\fR=\fIOUTPUT\fR \fB\-o\fR, \fB\-\-output\fR=\fIOUTPUT\fR
Write the identity to \fIOUTPUT\fR instead of standard output\. Write the identity to \fIOUTPUT\fR instead of standard output\.
.IP .IP
@@ -25,7 +30,17 @@ Read an identity file from \fIINPUT\fR or from standard input and output the cor
\fB\-\-version\fR \fB\-\-version\fR
Print the version and exit\. Print the version and exit\.
.SH "EXAMPLES" .SH "EXAMPLES"
Generate a new identity: Generate a new post\-quantum identity:
.IP "" 4
.nf
$ age\-keygen \-pq
# created: 2025\-11\-17T13:39:06+01:00
# public key: age1pq167[\|\.\|\.\|\. 1950 more characters \|\.\|\.\|\.]
AGE\-SECRET\-KEY\-PQ\-1K30MYPZAHAXHR22YHH27EGDVLU0QNSUH3DSV7J7NR3X6D9LHXNWSDLTV4T
.fi
.IP "" 0
.P
Generate a new traditional identity:
.IP "" 4 .IP "" 4
.nf .nf
$ age\-keygen $ age\-keygen
@@ -35,11 +50,11 @@ AGE\-SECRET\-KEY\-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9
.fi .fi
.IP "" 0 .IP "" 0
.P .P
Write a new identity to \fBkey\.txt\fR: Write a new post\-quantum identity to \fBkey\.txt\fR:
.IP "" 4 .IP "" 4
.nf .nf
$ age\-keygen \-o key\.txt $ age\-keygen \-pq \-o key\.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p Public key: age1pq1cd[\|\.\|\.\|\. 1950 more characters \|\.\|\.\|\.]
.fi .fi
.IP "" 0 .IP "" 0
.P .P
@@ -47,10 +62,10 @@ Convert an identity to a recipient:
.IP "" 4 .IP "" 4
.nf .nf
$ age\-keygen \-y key\.txt $ age\-keygen \-y key\.txt
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p age1pq1cd[\|\.\|\.\|\. 1950 more characters \|\.\|\.\|\.]
.fi .fi
.IP "" 0 .IP "" 0
.SH "SEE ALSO" .SH "SEE ALSO"
age(1) age(1), age\-inspect(1)
.SH "AUTHORS" .SH "AUTHORS"
Filippo Valsorda \fIage@filippo\.io\fR Filippo Valsorda \fIage@filippo\.io\fR
+21 -8
View File
@@ -76,7 +76,7 @@
</p> </p>
<h2 id="SYNOPSIS">SYNOPSIS</h2> <h2 id="SYNOPSIS">SYNOPSIS</h2>
<p><code>age-keygen</code> [<code>-o</code> <var>OUTPUT</var>]<br> <p><code>age-keygen</code> [<code>-pq</code>] [<code>-o</code> <var>OUTPUT</var>]<br>
<code>age-keygen</code> <code>-y</code> [<code>-o</code> <var>OUTPUT</var>] [<var>INPUT</var>]<br></p> <code>age-keygen</code> <code>-y</code> [<code>-o</code> <var>OUTPUT</var>] [<var>INPUT</var>]<br></p>
<h2 id="DESCRIPTION">DESCRIPTION</h2> <h2 id="DESCRIPTION">DESCRIPTION</h2>
@@ -91,6 +91,11 @@ standard error.</p>
<h2 id="OPTIONS">OPTIONS</h2> <h2 id="OPTIONS">OPTIONS</h2>
<dl> <dl>
<dt><code>-pq</code></dt>
<dd> Generate a post-quantum hybrid ML-KEM-768 + X25519 key pair.
<p>In the future, this might become the default.</p>
</dd>
<dt> <dt>
<code>-o</code>, <code>--output</code>=<var>OUTPUT</var> <code>-o</code>, <code>--output</code>=<var>OUTPUT</var>
</dt> </dt>
@@ -107,7 +112,15 @@ standard error.</p>
<h2 id="EXAMPLES">EXAMPLES</h2> <h2 id="EXAMPLES">EXAMPLES</h2>
<p>Generate a new identity:</p> <p>Generate a new post-quantum identity:</p>
<pre><code>$ age-keygen -pq
# created: 2025-11-17T13:39:06+01:00
# public key: age1pq167[... 1950 more characters ...]
AGE-SECRET-KEY-PQ-1K30MYPZAHAXHR22YHH27EGDVLU0QNSUH3DSV7J7NR3X6D9LHXNWSDLTV4T
</code></pre>
<p>Generate a new traditional identity:</p>
<pre><code>$ age-keygen <pre><code>$ age-keygen
# created: 2021-01-02T15:30:45+01:00 # created: 2021-01-02T15:30:45+01:00
@@ -115,21 +128,21 @@ standard error.</p>
AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9 AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9
</code></pre> </code></pre>
<p>Write a new identity to <code>key.txt</code>:</p> <p>Write a new post-quantum identity to <code>key.txt</code>:</p>
<pre><code>$ age-keygen -o key.txt <pre><code>$ age-keygen -pq -o key.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p Public key: age1pq1cd[... 1950 more characters ...]
</code></pre> </code></pre>
<p>Convert an identity to a recipient:</p> <p>Convert an identity to a recipient:</p>
<pre><code>$ age-keygen -y key.txt <pre><code>$ age-keygen -y key.txt
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p age1pq1cd[... 1950 more characters ...]
</code></pre> </code></pre>
<h2 id="SEE-ALSO">SEE ALSO</h2> <h2 id="SEE-ALSO">SEE ALSO</h2>
<p><a class="man-ref" href="age.1.html">age<span class="s">(1)</span></a></p> <p><a class="man-ref" href="age.1.html">age<span class="s">(1)</span></a>, <a class="man-ref" href="age-inspect.1.html">age-inspect<span class="s">(1)</span></a></p>
<h2 id="AUTHORS">AUTHORS</h2> <h2 id="AUTHORS">AUTHORS</h2>
@@ -137,7 +150,7 @@ age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
<ol class='man-decor man-foot man foot'> <ol class='man-decor man-foot man foot'>
<li class='tl'></li> <li class='tl'></li>
<li class='tc'>April 2023</li> <li class='tc'>August 2026</li>
<li class='tr'>age-keygen(1)</li> <li class='tr'>age-keygen(1)</li>
</ol> </ol>
+19 -7
View File
@@ -3,7 +3,7 @@ age-keygen(1) -- generate age(1) key pairs
## SYNOPSIS ## SYNOPSIS
`age-keygen` [`-o` <OUTPUT>]<br> `age-keygen` [`-pq`] [`-o` <OUTPUT>]<br>
`age-keygen` `-y` [`-o` <OUTPUT>] [<INPUT>]<br> `age-keygen` `-y` [`-o` <OUTPUT>] [<INPUT>]<br>
## DESCRIPTION ## DESCRIPTION
@@ -17,6 +17,11 @@ standard error.
## OPTIONS ## OPTIONS
* `-pq`:
Generate a post-quantum hybrid ML-KEM-768 + X25519 key pair.
In the future, this might become the default.
* `-o`, `--output`=<OUTPUT>: * `-o`, `--output`=<OUTPUT>:
Write the identity to <OUTPUT> instead of standard output. Write the identity to <OUTPUT> instead of standard output.
@@ -31,26 +36,33 @@ standard error.
## EXAMPLES ## EXAMPLES
Generate a new identity: Generate a new post-quantum identity:
$ age-keygen -pq
# created: 2025-11-17T13:39:06+01:00
# public key: age1pq167[... 1950 more characters ...]
AGE-SECRET-KEY-PQ-1K30MYPZAHAXHR22YHH27EGDVLU0QNSUH3DSV7J7NR3X6D9LHXNWSDLTV4T
Generate a new traditional identity:
$ age-keygen $ age-keygen
# created: 2021-01-02T15:30:45+01:00 # created: 2021-01-02T15:30:45+01:00
# public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z # public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z
AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9 AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9
Write a new identity to `key.txt`: Write a new post-quantum identity to `key.txt`:
$ age-keygen -o key.txt $ age-keygen -pq -o key.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p Public key: age1pq1cd[... 1950 more characters ...]
Convert an identity to a recipient: Convert an identity to a recipient:
$ age-keygen -y key.txt $ age-keygen -y key.txt
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p age1pq1cd[... 1950 more characters ...]
## SEE ALSO ## SEE ALSO
age(1) age(1), age-inspect(1)
## AUTHORS ## AUTHORS
+87
View File
@@ -0,0 +1,87 @@
.\" generated with Ronn-NG/v0.9.1
.\" http://github.com/apjanke/ronn-ng/tree/0.9.1
.TH "AGE\-PLUGIN\-BATCHPASS" "1" "August 2026" ""
.SH "NAME"
\fBage\-plugin\-batchpass\fR \- non\-interactive passphrase encryption plugin for age(1)
.SH "SYNOPSIS"
\fBage\fR \fB\-e\fR \fB\-j\fR \fBbatchpass\fR
.br
\fBage\fR \fB\-d\fR \fB\-j\fR \fBbatchpass\fR
.br
.SH "DESCRIPTION"
\fBage\-plugin\-batchpass\fR is an age(1) plugin that enables non\-interactive passphrase\-based encryption and decryption using environment variables\.
.SH "WARNING"
This functionality is not built into the age CLI because most applications should use native keys instead of scripting passphrase\-based encryption\.
.P
Humans are notoriously bad at remembering and generating strong passphrases\. age uses scrypt to partially mitigate this, which is necessarily very slow\.
.P
If a computer will be doing the remembering anyway, you can and should use native keys instead\. There is no need to manage separate public and private keys, you encrypt directly to the private key:
.IP "" 4
.nf
$ age\-keygen \-o key\.txt
$ age \-e \-i key\.txt file\.txt > file\.txt\.age
$ age \-d \-i key\.txt file\.txt\.age > file\.txt
.fi
.IP "" 0
.P
Likewise, you can store a native identity string in an environment variable or through your CI secrets manager and use it to encrypt and decrypt files non\-interactively:
.IP "" 4
.nf
$ export AGE_SECRET=$(age\-keygen)
$ age \-e \-i <(echo "$AGE_SECRET") file\.txt > file\.txt\.age
$ age \-d \-i <(echo "$AGE_SECRET") file\.txt\.age > file\.txt
.fi
.IP "" 0
.P
The age CLI also natively supports passphrase\-encrypted identity files, so you can use that functionality to non\-interactively encrypt multiple files such that you will be able to decrypt them later by entering the same passphrase:
.IP "" 4
.nf
$ age\-keygen \-pq | age \-p \-o encrypted\-identity\.txt
Public key: age1pq1cd[\|\.\|\.\|\. 1950 more characters \|\.\|\.\|\.]
Enter passphrase (leave empty to autogenerate a secure one):
age: using autogenerated passphrase "eternal\-erase\-keen\-suffer\-fog\-exclude\-huge\-scorpion\-escape\-scrub"
$ age \-r age1pq1cd[\|\.\|\.\|\. 1950 more characters \|\.\|\.\|\.] file\.txt > file\.txt\.age
$ age \-d \-i encrypted\-identity\.txt file\.txt\.age > file\.txt
Enter passphrase for identity file "encrypted\-identity\.txt":
.fi
.IP "" 0
.P
Finally, when using this plugin care should be taken not to let the password be persisted in the shell history or leaked to other users on multi\-user systems\.
.SH "ENVIRONMENT"
.TP
\fBAGE_PASSPHRASE\fR
The passphrase to use for encryption or decryption\. Mutually exclusive with \fBAGE_PASSPHRASE_FD\fR\.
.TP
\fBAGE_PASSPHRASE_FD\fR
A file descriptor number to read the passphrase from\. Trailing newlines are stripped from the file contents\. Mutually exclusive with \fBAGE_PASSPHRASE\fR\.
.TP
\fBAGE_PASSPHRASE_WORK_FACTOR\fR
The scrypt work factor to use when encrypting\. Must be between 1 and 30\. Default is 18\. Higher values are more secure but slower\. The age CLI refuses to decrypt files above 22, and so does this plugin unless \fBAGE_PASSPHRASE_MAX_WORK_FACTOR\fR is set to at least the same value\.
.TP
\fBAGE_PASSPHRASE_MAX_WORK_FACTOR\fR
The maximum scrypt work factor to accept when decrypting\. Must be between 1 and 30\. Default is 22\. Can be used to avoid very slow decryptions, or to allow slower ones\.
.SH "EXAMPLES"
Encrypt a file with a passphrase:
.IP "" 4
.nf
$ AGE_PASSPHRASE=secret age \-e \-j batchpass file\.txt > file\.txt\.age
.fi
.IP "" 0
.P
Decrypt a file with a passphrase:
.IP "" 4
.nf
$ AGE_PASSPHRASE=secret age \-d \-j batchpass file\.txt\.age > file\.txt
.fi
.IP "" 0
.P
Read the passphrase from a file descriptor:
.IP "" 4
.nf
$ AGE_PASSPHRASE_FD=3 age \-e \-j batchpass file\.txt 3< passphrase\.txt > file\.txt\.age
.fi
.IP "" 0
.SH "SEE ALSO"
age(1)
.SH "AUTHORS"
Filippo Valsorda \fIage@filippo\.io\fR
+185
View File
@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv='content-type' content='text/html;charset=utf8'>
<meta name='generator' content='Ronn-NG/v0.9.1 (http://github.com/apjanke/ronn-ng/tree/0.9.1)'>
<title>age-plugin-batchpass(1) - non-interactive passphrase encryption plugin for age(1)</title>
<style type='text/css' media='all'>
/* style: man */
body#manpage {margin:0}
.mp {max-width:100ex;padding:0 9ex 1ex 4ex}
.mp p,.mp pre,.mp ul,.mp ol,.mp dl {margin:0 0 20px 0}
.mp h2 {margin:10px 0 0 0}
.mp > p,.mp > pre,.mp > ul,.mp > ol,.mp > dl {margin-left:8ex}
.mp h3 {margin:0 0 0 4ex}
.mp dt {margin:0;clear:left}
.mp dt.flush {float:left;width:8ex}
.mp dd {margin:0 0 0 9ex}
.mp h1,.mp h2,.mp h3,.mp h4 {clear:left}
.mp pre {margin-bottom:20px}
.mp pre+h2,.mp pre+h3 {margin-top:22px}
.mp h2+pre,.mp h3+pre {margin-top:5px}
.mp img {display:block;margin:auto}
.mp h1.man-title {display:none}
.mp,.mp code,.mp pre,.mp tt,.mp kbd,.mp samp,.mp h3,.mp h4 {font-family:monospace;font-size:14px;line-height:1.42857142857143}
.mp h2 {font-size:16px;line-height:1.25}
.mp h1 {font-size:20px;line-height:2}
.mp {text-align:justify;background:#fff}
.mp,.mp code,.mp pre,.mp pre code,.mp tt,.mp kbd,.mp samp {color:#131211}
.mp h1,.mp h2,.mp h3,.mp h4 {color:#030201}
.mp u {text-decoration:underline}
.mp code,.mp strong,.mp b {font-weight:bold;color:#131211}
.mp em,.mp var {font-style:italic;color:#232221;text-decoration:none}
.mp a,.mp a:link,.mp a:hover,.mp a code,.mp a pre,.mp a tt,.mp a kbd,.mp a samp {color:#0000ff}
.mp b.man-ref {font-weight:normal;color:#434241}
.mp pre {padding:0 4ex}
.mp pre code {font-weight:normal;color:#434241}
.mp h2+pre,h3+pre {padding-left:0}
ol.man-decor,ol.man-decor li {margin:3px 0 10px 0;padding:0;float:left;width:33%;list-style-type:none;text-transform:uppercase;color:#999;letter-spacing:1px}
ol.man-decor {width:100%}
ol.man-decor li.tl {text-align:left}
ol.man-decor li.tc {text-align:center;letter-spacing:4px}
ol.man-decor li.tr {text-align:right;float:right}
</style>
</head>
<!--
The following styles are deprecated and will be removed at some point:
div#man, div#man ol.man, div#man ol.head, div#man ol.man.
The .man-page, .man-decor, .man-head, .man-foot, .man-title, and
.man-navigation should be used instead.
-->
<body id='manpage'>
<div class='mp' id='man'>
<div class='man-navigation' style='display:none'>
<a href="#NAME">NAME</a>
<a href="#SYNOPSIS">SYNOPSIS</a>
<a href="#DESCRIPTION">DESCRIPTION</a>
<a href="#WARNING">WARNING</a>
<a href="#ENVIRONMENT">ENVIRONMENT</a>
<a href="#EXAMPLES">EXAMPLES</a>
<a href="#SEE-ALSO">SEE ALSO</a>
<a href="#AUTHORS">AUTHORS</a>
</div>
<ol class='man-decor man-head man head'>
<li class='tl'>age-plugin-batchpass(1)</li>
<li class='tc'></li>
<li class='tr'>age-plugin-batchpass(1)</li>
</ol>
<h2 id="NAME">NAME</h2>
<p class="man-name">
<code>age-plugin-batchpass</code> - <span class="man-whatis">non-interactive passphrase encryption plugin for <a class="man-ref" href="age.1.html">age<span class="s">(1)</span></a></span>
</p>
<h2 id="SYNOPSIS">SYNOPSIS</h2>
<p><code>age</code> <code>-e</code> <code>-j</code> <code>batchpass</code><br>
<code>age</code> <code>-d</code> <code>-j</code> <code>batchpass</code><br></p>
<h2 id="DESCRIPTION">DESCRIPTION</h2>
<p><code>age-plugin-batchpass</code> is an <a class="man-ref" href="age.1.html">age<span class="s">(1)</span></a> plugin that enables non-interactive
passphrase-based encryption and decryption using environment variables.</p>
<h2 id="WARNING">WARNING</h2>
<p>This functionality is not built into the age CLI because most applications
should use native keys instead of scripting passphrase-based encryption.</p>
<p>Humans are notoriously bad at remembering and generating strong passphrases.
age uses scrypt to partially mitigate this, which is necessarily very slow.</p>
<p>If a computer will be doing the remembering anyway, you can and should use
native keys instead. There is no need to manage separate public and private
keys, you encrypt directly to the private key:</p>
<pre><code>$ age-keygen -o key.txt
$ age -e -i key.txt file.txt &gt; file.txt.age
$ age -d -i key.txt file.txt.age &gt; file.txt
</code></pre>
<p>Likewise, you can store a native identity string in an environment variable
or through your CI secrets manager and use it to encrypt and decrypt files
non-interactively:</p>
<pre><code>$ export AGE_SECRET=$(age-keygen)
$ age -e -i &lt;(echo "$AGE_SECRET") file.txt &gt; file.txt.age
$ age -d -i &lt;(echo "$AGE_SECRET") file.txt.age &gt; file.txt
</code></pre>
<p>The age CLI also natively supports passphrase-encrypted identity files, so you
can use that functionality to non-interactively encrypt multiple files such that
you will be able to decrypt them later by entering the same passphrase:</p>
<pre><code>$ age-keygen -pq | age -p -o encrypted-identity.txt
Public key: age1pq1cd[... 1950 more characters ...]
Enter passphrase (leave empty to autogenerate a secure one):
age: using autogenerated passphrase "eternal-erase-keen-suffer-fog-exclude-huge-scorpion-escape-scrub"
$ age -r age1pq1cd[... 1950 more characters ...] file.txt &gt; file.txt.age
$ age -d -i encrypted-identity.txt file.txt.age &gt; file.txt
Enter passphrase for identity file "encrypted-identity.txt":
</code></pre>
<p>Finally, when using this plugin care should be taken not to let the password be
persisted in the shell history or leaked to other users on multi-user systems.</p>
<h2 id="ENVIRONMENT">ENVIRONMENT</h2>
<dl>
<dt><code>AGE_PASSPHRASE</code></dt>
<dd> The passphrase to use for encryption or decryption.
Mutually exclusive with <code>AGE_PASSPHRASE_FD</code>.</dd>
<dt><code>AGE_PASSPHRASE_FD</code></dt>
<dd> A file descriptor number to read the passphrase from.
Trailing newlines are stripped from the file contents.
Mutually exclusive with <code>AGE_PASSPHRASE</code>.</dd>
<dt><code>AGE_PASSPHRASE_WORK_FACTOR</code></dt>
<dd> The scrypt work factor to use when encrypting.
Must be between 1 and 30. Default is 18.
Higher values are more secure but slower.
The age CLI refuses to decrypt files above 22, and so does this plugin
unless <code>AGE_PASSPHRASE_MAX_WORK_FACTOR</code> is set to at least the same value.</dd>
<dt><code>AGE_PASSPHRASE_MAX_WORK_FACTOR</code></dt>
<dd> The maximum scrypt work factor to accept when decrypting.
Must be between 1 and 30. Default is 22.
Can be used to avoid very slow decryptions, or to allow slower ones.</dd>
</dl>
<h2 id="EXAMPLES">EXAMPLES</h2>
<p>Encrypt a file with a passphrase:</p>
<pre><code>$ AGE_PASSPHRASE=secret age -e -j batchpass file.txt &gt; file.txt.age
</code></pre>
<p>Decrypt a file with a passphrase:</p>
<pre><code>$ AGE_PASSPHRASE=secret age -d -j batchpass file.txt.age &gt; file.txt
</code></pre>
<p>Read the passphrase from a file descriptor:</p>
<pre><code>$ AGE_PASSPHRASE_FD=3 age -e -j batchpass file.txt 3&lt; passphrase.txt &gt; file.txt.age
</code></pre>
<h2 id="SEE-ALSO">SEE ALSO</h2>
<p><a class="man-ref" href="age.1.html">age<span class="s">(1)</span></a></p>
<h2 id="AUTHORS">AUTHORS</h2>
<p>Filippo Valsorda <a href="mailto:age@filippo.io" data-bare-link="true">age@filippo.io</a></p>
<ol class='man-decor man-foot man foot'>
<li class='tl'></li>
<li class='tc'>August 2026</li>
<li class='tr'>age-plugin-batchpass(1)</li>
</ol>
</div>
</body>
</html>
+96
View File
@@ -0,0 +1,96 @@
age-plugin-batchpass(1) -- non-interactive passphrase encryption plugin for age(1)
==================================================================================
## SYNOPSIS
`age` `-e` `-j` `batchpass`<br>
`age` `-d` `-j` `batchpass`<br>
## DESCRIPTION
`age-plugin-batchpass` is an age(1) plugin that enables non-interactive
passphrase-based encryption and decryption using environment variables.
## WARNING
This functionality is not built into the age CLI because most applications
should use native keys instead of scripting passphrase-based encryption.
Humans are notoriously bad at remembering and generating strong passphrases.
age uses scrypt to partially mitigate this, which is necessarily very slow.
If a computer will be doing the remembering anyway, you can and should use
native keys instead. There is no need to manage separate public and private
keys, you encrypt directly to the private key:
$ age-keygen -o key.txt
$ age -e -i key.txt file.txt > file.txt.age
$ age -d -i key.txt file.txt.age > file.txt
Likewise, you can store a native identity string in an environment variable
or through your CI secrets manager and use it to encrypt and decrypt files
non-interactively:
$ export AGE_SECRET=$(age-keygen)
$ age -e -i <(echo "$AGE_SECRET") file.txt > file.txt.age
$ age -d -i <(echo "$AGE_SECRET") file.txt.age > file.txt
The age CLI also natively supports passphrase-encrypted identity files, so you
can use that functionality to non-interactively encrypt multiple files such that
you will be able to decrypt them later by entering the same passphrase:
$ age-keygen -pq | age -p -o encrypted-identity.txt
Public key: age1pq1cd[... 1950 more characters ...]
Enter passphrase (leave empty to autogenerate a secure one):
age: using autogenerated passphrase "eternal-erase-keen-suffer-fog-exclude-huge-scorpion-escape-scrub"
$ age -r age1pq1cd[... 1950 more characters ...] file.txt > file.txt.age
$ age -d -i encrypted-identity.txt file.txt.age > file.txt
Enter passphrase for identity file "encrypted-identity.txt":
Finally, when using this plugin care should be taken not to let the password be
persisted in the shell history or leaked to other users on multi-user systems.
## ENVIRONMENT
* `AGE_PASSPHRASE`:
The passphrase to use for encryption or decryption.
Mutually exclusive with `AGE_PASSPHRASE_FD`.
* `AGE_PASSPHRASE_FD`:
A file descriptor number to read the passphrase from.
Trailing newlines are stripped from the file contents.
Mutually exclusive with `AGE_PASSPHRASE`.
* `AGE_PASSPHRASE_WORK_FACTOR`:
The scrypt work factor to use when encrypting.
Must be between 1 and 30. Default is 18.
Higher values are more secure but slower.
The age CLI refuses to decrypt files above 22, and so does this plugin
unless `AGE_PASSPHRASE_MAX_WORK_FACTOR` is set to at least the same value.
* `AGE_PASSPHRASE_MAX_WORK_FACTOR`:
The maximum scrypt work factor to accept when decrypting.
Must be between 1 and 30. Default is 22.
Can be used to avoid very slow decryptions, or to allow slower ones.
## EXAMPLES
Encrypt a file with a passphrase:
$ AGE_PASSPHRASE=secret age -e -j batchpass file.txt > file.txt.age
Decrypt a file with a passphrase:
$ AGE_PASSPHRASE=secret age -d -j batchpass file.txt.age > file.txt
Read the passphrase from a file descriptor:
$ AGE_PASSPHRASE_FD=3 age -e -j batchpass file.txt 3< passphrase.txt > file.txt.age
## SEE ALSO
age(1)
## AUTHORS
Filippo Valsorda <age@filippo.io>
+35 -14
View File
@@ -1,6 +1,6 @@
.\" generated with Ronn-NG/v0.9.1 .\" generated with Ronn-NG/v0.9.1
.\" http://github.com/apjanke/ronn-ng/tree/0.9.1 .\" http://github.com/apjanke/ronn-ng/tree/0.9.1
.TH "AGE" "1" "April 2023" "" .TH "AGE" "1" "August 2026" ""
.SH "NAME" .SH "NAME"
\fBage\fR \- simple, modern, and secure file encryption \fBage\fR \- simple, modern, and secure file encryption
.SH "SYNOPSIS" .SH "SYNOPSIS"
@@ -99,23 +99,39 @@ Decrypt using the data\-less \fIplugin\fR \fIPLUGIN\fR\.
This is equivalent to using \fB\-i\fR/\fB\-\-identity\fR with a file that contains a single plugin \fBIDENTITY\fR that encodes no plugin\-specific data\. This is equivalent to using \fB\-i\fR/\fB\-\-identity\fR with a file that contains a single plugin \fBIDENTITY\fR that encodes no plugin\-specific data\.
.SH "RECIPIENTS AND IDENTITIES" .SH "RECIPIENTS AND IDENTITIES"
\fBRECIPIENTS\fR are public values, like a public key, that a file can be encrypted to\. \fBIDENTITIES\fR are private values, like a private key, that allow decrypting a file encrypted to the corresponding \fBRECIPIENT\fR\. \fBRECIPIENTS\fR are public values, like a public key, that a file can be encrypted to\. \fBIDENTITIES\fR are private values, like a private key, that allow decrypting a file encrypted to the corresponding \fBRECIPIENT\fR\.
.SS "Native X25519 keys" .SS "Native keys"
Native \fBage\fR key pairs are generated with age\-keygen(1), and provide small encodings and strong encryption based on X25519\. They are the recommended recipient type for most applications\. Native \fBage\fR key pairs are generated with age\-keygen(1), and provide small encodings and strong encryption based on X25519 for classic keys, and X25519 + ML\-KEM\-768 for post\-quantum hybrid keys\. The post\-quantum hybrid keys are secure against future quantum computers and are the recommended recipient type for most applications\.
.P .P
A \fBRECIPIENT\fR encoding begins with \fBage1\fR and looks like the following: A hybrid \fBRECIPIENT\fR encoding begins with \fBage1pq1\fR and looks like the following:
.IP "" 4
.nf
age1pq167[\|\.\|\.\|\. 1950 more characters \|\.\|\.\|\.]
.fi
.IP "" 0
.P
A hybrid \fBIDENTITY\fR encoding begins with \fBAGE\-SECRET\-KEY\-PQ\-1\fR and looks like the following:
.IP "" 4
.nf
AGE\-SECRET\-KEY\-PQ\-1K30MYPZAHAXHR22YHH27EGDVLU0QNSUH3DSV7J7NR3X6D9LHXNWSDLTV4T
.fi
.IP "" 0
.P
A classic \fBRECIPIENT\fR encoding begins with \fBage1\fR and looks like the following:
.IP "" 4 .IP "" 4
.nf .nf
age1gde3ncmahlqd9gg50tanl99r960llztrhfapnmx853s4tjum03uqfssgdh age1gde3ncmahlqd9gg50tanl99r960llztrhfapnmx853s4tjum03uqfssgdh
.fi .fi
.IP "" 0 .IP "" 0
.P .P
An \fBIDENTITY\fR encoding begins with \fBAGE\-SECRET\-KEY\-1\fR and looks like the following: A classic \fBIDENTITY\fR encoding begins with \fBAGE\-SECRET\-KEY\-1\fR and looks like the following:
.IP "" 4 .IP "" 4
.nf .nf
AGE\-SECRET\-KEY\-1KTYK6RVLN5TAPE7VF6FQQSKZ9HWWCDSKUGXXNUQDWZ7XXT5YK5LSF3UTKQ AGE\-SECRET\-KEY\-1KTYK6RVLN5TAPE7VF6FQQSKZ9HWWCDSKUGXXNUQDWZ7XXT5YK5LSF3UTKQ
.fi .fi
.IP "" 0 .IP "" 0
.P .P
A file can't be encrypted to both post\-quantum and classic keys, as that would defeat the post\-quantum security of the encryption\.
.P
An encrypted file can't be linked to the native recipient it's encrypted to without access to the corresponding identity\. An encrypted file can't be linked to the native recipient it's encrypted to without access to the corresponding identity\.
.SS "SSH keys" .SS "SSH keys"
As a convenience feature, \fBage\fR also supports encrypting to RSA or Ed25519 ssh(1) keys\. RSA keys must be at least 2048 bits\. This feature employs more complex cryptography, and should only be used when a native key is not available for the recipient\. Note that SSH keys might not be protected long\-term by the recipient, since they are revokable when used only for authentication\. As a convenience feature, \fBage\fR also supports encrypting to RSA or Ed25519 ssh(1) keys\. RSA keys must be at least 2048 bits\. This feature employs more complex cryptography, and should only be used when a native key is not available for the recipient\. Note that SSH keys might not be protected long\-term by the recipient, since they are revokable when used only for authentication\.
@@ -147,6 +163,12 @@ Plugins can be freely mixed with other plugins or natively supported keys\.
A plugin is not bound to only encrypt or decrypt files meant for or generated by the plugin\. For example, a plugin can be used to decrypt files encrypted to a native X25519 \fBRECIPIENT\fR or even with a passphrase\. Similarly, a plugin can encrypt a file such that it can be decrypted without the use of any plugin\. A plugin is not bound to only encrypt or decrypt files meant for or generated by the plugin\. For example, a plugin can be used to decrypt files encrypted to a native X25519 \fBRECIPIENT\fR or even with a passphrase\. Similarly, a plugin can encrypt a file such that it can be decrypted without the use of any plugin\.
.P .P
Plugins for which the \fBIDENTITY\fR/\fBRECIPIENT\fR distinction doesn't make sense (such as a symmetric encryption plugin) may generate only an \fBIDENTITY\fR and instruct the user to perform encryption with the \fB\-e\fR/\fB\-\-encrypt\fR and \fB\-i\fR/\fB\-\-identity\fR flags\. Plugins for which the concept of separate identities doesn't make sense (such as a password\-encryption plugin) may instruct the user to use the \fB\-j\fR flag\. Plugins for which the \fBIDENTITY\fR/\fBRECIPIENT\fR distinction doesn't make sense (such as a symmetric encryption plugin) may generate only an \fBIDENTITY\fR and instruct the user to perform encryption with the \fB\-e\fR/\fB\-\-encrypt\fR and \fB\-i\fR/\fB\-\-identity\fR flags\. Plugins for which the concept of separate identities doesn't make sense (such as a password\-encryption plugin) may instruct the user to use the \fB\-j\fR flag\.
.P
\fBage\fR can natively encrypt to recipients starting with \fBage1tag1\fR (using P\-256 ECDH) or \fBage1tagpq1\fR (using the ML\-KEM\-768 + P\-256 post\-quantum hybrid)\. These are intended to be the public side of private keys held in hardware\.
.P
They are directly supported to avoid the need to install the plugin, which may be platform\-specific, on the encrypting side\.
.P
The tag reduces privacy, by allowing an observer to correlate files with a recipient (but not files amongst them without knowledge of the recipient), but this is also a desirable property for hardware keys that require user interaction for each decryption operation\.
.SH "EXIT STATUS" .SH "EXIT STATUS"
\fBage\fR will exit 0 if and only if encryption or decryption are successful for the full length of the input\. \fBage\fR will exit 0 if and only if encryption or decryption are successful for the full length of the input\.
.P .P
@@ -156,13 +178,13 @@ Files encrypted with a stable version (not alpha, beta, or release candidate) of
.P .P
If decrypting older files poses a security risk, doing so might cause an error by default\. In this case, a flag will be provided to force the operation\. If decrypting older files poses a security risk, doing so might cause an error by default\. In this case, a flag will be provided to force the operation\.
.SH "EXAMPLES" .SH "EXAMPLES"
Generate a new identity, encrypt data, and decrypt: Generate a new post\-quantum identity, encrypt data, and decrypt:
.IP "" 4 .IP "" 4
.nf .nf
$ age\-keygen \-o key\.txt $ age\-keygen \-pq \-o key\.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p Public key: age1pq167[\|\.\|\.\|\. 1950 more characters \|\.\|\.\|\.]
$ tar cvz ~/data | age \-r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data\.tar\.gz\.age $ tar cvz ~/data | age \-r age1pq167[\|\.\|\.\|\.] > data\.tar\.gz\.age
$ age \-d \-o data\.tar\.gz \-i key\.txt data\.tar\.gz\.age $ age \-d \-o data\.tar\.gz \-i key\.txt data\.tar\.gz\.age
.fi .fi
@@ -171,8 +193,7 @@ $ age \-d \-o data\.tar\.gz \-i key\.txt data\.tar\.gz\.age
Encrypt \fBexample\.jpg\fR to multiple recipients and output to \fBexample\.jpg\.age\fR: Encrypt \fBexample\.jpg\fR to multiple recipients and output to \fBexample\.jpg\.age\fR:
.IP "" 4 .IP "" 4
.nf .nf
$ age \-o example\.jpg\.age \-r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p \e $ age \-o example\.jpg\.age \-r age1pq167[\|\.\|\.\|\.] \-r age1pq1e3[\|\.\|\.\|\.] example\.jpg
\-r age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg example\.jpg
.fi .fi
.IP "" 0 .IP "" 0
.P .P
@@ -181,9 +202,9 @@ Encrypt to a list of recipients:
.nf .nf
$ cat > recipients\.txt $ cat > recipients\.txt
# Alice # Alice
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p age1pq167[\|\.\|\.\|\. 1950 more characters \|\.\|\.\|\.]
# Bob # Bob
age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg age1pq1e3[\|\.\|\.\|\. 1950 more characters \|\.\|\.\|\.]
$ age \-R recipients\.txt example\.jpg > example\.jpg\.age $ age \-R recipients\.txt example\.jpg > example\.jpg\.age
.fi .fi
@@ -243,6 +264,6 @@ $ curl https://github\.com/benjojo\.keys | age \-R \- example\.jpg > example\.jp
.fi .fi
.IP "" 0 .IP "" 0
.SH "SEE ALSO" .SH "SEE ALSO"
age\-keygen(1) age\-keygen(1), age\-inspect(1)
.SH "AUTHORS" .SH "AUTHORS"
Filippo Valsorda \fIage@filippo\.io\fR Filippo Valsorda \fIage@filippo\.io\fR
+44 -15
View File
@@ -253,23 +253,39 @@ overhead per recipient, plus 16 bytes every 64KiB of plaintext.</p>
to. <code>IDENTITIES</code> are private values, like a private key, that allow decrypting to. <code>IDENTITIES</code> are private values, like a private key, that allow decrypting
a file encrypted to the corresponding <code>RECIPIENT</code>.</p> a file encrypted to the corresponding <code>RECIPIENT</code>.</p>
<h3 id="Native-X25519-keys">Native X25519 keys</h3> <h3 id="Native-keys">Native keys</h3>
<p>Native <code>age</code> key pairs are generated with <a class="man-ref" href="age-keygen.1.html">age-keygen<span class="s">(1)</span></a>, and provide small <p>Native <code>age</code> key pairs are generated with <a class="man-ref" href="age-keygen.1.html">age-keygen<span class="s">(1)</span></a>, and provide small
encodings and strong encryption based on X25519. They are the recommended encodings and strong encryption based on X25519 for classic keys, and X25519 +
recipient type for most applications.</p> ML-KEM-768 for post-quantum hybrid keys. The post-quantum hybrid keys are secure
against future quantum computers and are the recommended recipient type for most
applications.</p>
<p>A <code>RECIPIENT</code> encoding begins with <code>age1</code> and looks like the following:</p> <p>A hybrid <code>RECIPIENT</code> encoding begins with <code>age1pq1</code> and looks like the following:</p>
<pre><code>age1pq167[... 1950 more characters ...]
</code></pre>
<p>A hybrid <code>IDENTITY</code> encoding begins with <code>AGE-SECRET-KEY-PQ-1</code> and looks like
the following:</p>
<pre><code>AGE-SECRET-KEY-PQ-1K30MYPZAHAXHR22YHH27EGDVLU0QNSUH3DSV7J7NR3X6D9LHXNWSDLTV4T
</code></pre>
<p>A classic <code>RECIPIENT</code> encoding begins with <code>age1</code> and looks like the following:</p>
<pre><code>age1gde3ncmahlqd9gg50tanl99r960llztrhfapnmx853s4tjum03uqfssgdh <pre><code>age1gde3ncmahlqd9gg50tanl99r960llztrhfapnmx853s4tjum03uqfssgdh
</code></pre> </code></pre>
<p>An <code>IDENTITY</code> encoding begins with <code>AGE-SECRET-KEY-1</code> and looks like the <p>A classic <code>IDENTITY</code> encoding begins with <code>AGE-SECRET-KEY-1</code> and looks like the
following:</p> following:</p>
<pre><code>AGE-SECRET-KEY-1KTYK6RVLN5TAPE7VF6FQQSKZ9HWWCDSKUGXXNUQDWZ7XXT5YK5LSF3UTKQ <pre><code>AGE-SECRET-KEY-1KTYK6RVLN5TAPE7VF6FQQSKZ9HWWCDSKUGXXNUQDWZ7XXT5YK5LSF3UTKQ
</code></pre> </code></pre>
<p>A file can't be encrypted to both post-quantum and classic keys, as that would
defeat the post-quantum security of the encryption.</p>
<p>An encrypted file can't be linked to the native recipient it's encrypted to <p>An encrypted file can't be linked to the native recipient it's encrypted to
without access to the corresponding identity.</p> without access to the corresponding identity.</p>
@@ -331,6 +347,20 @@ instruct the user to perform encryption with the <code>-e</code>/<code>--encrypt
doesn't make sense (such as a password-encryption plugin) may instruct the user doesn't make sense (such as a password-encryption plugin) may instruct the user
to use the <code>-j</code> flag.</p> to use the <code>-j</code> flag.</p>
<h4 id="Tagged-recipients">Tagged recipients</h4>
<p><code>age</code> can natively encrypt to recipients starting with <code>age1tag1</code> (using P-256
ECDH) or <code>age1tagpq1</code> (using the ML-KEM-768 + P-256 post-quantum hybrid). These
are intended to be the public side of private keys held in hardware.</p>
<p>They are directly supported to avoid the need to install the plugin, which may
be platform-specific, on the encrypting side.</p>
<p>The tag reduces privacy, by allowing an observer to correlate files with a
recipient (but not files amongst them without knowledge of the recipient),
but this is also a desirable property for hardware keys that require user
interaction for each decryption operation.</p>
<h2 id="EXIT-STATUS">EXIT STATUS</h2> <h2 id="EXIT-STATUS">EXIT STATUS</h2>
<p><code>age</code> will exit 0 if and only if encryption or decryption are successful for the <p><code>age</code> will exit 0 if and only if encryption or decryption are successful for the
@@ -351,29 +381,28 @@ by default. In this case, a flag will be provided to force the operation.</p>
<h2 id="EXAMPLES">EXAMPLES</h2> <h2 id="EXAMPLES">EXAMPLES</h2>
<p>Generate a new identity, encrypt data, and decrypt:</p> <p>Generate a new post-quantum identity, encrypt data, and decrypt:</p>
<pre><code>$ age-keygen -o key.txt <pre><code>$ age-keygen -pq -o key.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p Public key: age1pq167[... 1950 more characters ...]
$ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p &gt; data.tar.gz.age $ tar cvz ~/data | age -r age1pq167[...] &gt; data.tar.gz.age
$ age -d -o data.tar.gz -i key.txt data.tar.gz.age $ age -d -o data.tar.gz -i key.txt data.tar.gz.age
</code></pre> </code></pre>
<p>Encrypt <code>example.jpg</code> to multiple recipients and output to <code>example.jpg.age</code>:</p> <p>Encrypt <code>example.jpg</code> to multiple recipients and output to <code>example.jpg.age</code>:</p>
<pre><code>$ age -o example.jpg.age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p \ <pre><code>$ age -o example.jpg.age -r age1pq167[...] -r age1pq1e3[...] example.jpg
-r age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg example.jpg
</code></pre> </code></pre>
<p>Encrypt to a list of recipients:</p> <p>Encrypt to a list of recipients:</p>
<pre><code>$ cat &gt; recipients.txt <pre><code>$ cat &gt; recipients.txt
# Alice # Alice
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p age1pq167[... 1950 more characters ...]
# Bob # Bob
age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg age1pq1e3[... 1950 more characters ...]
$ age -R recipients.txt example.jpg &gt; example.jpg.age $ age -R recipients.txt example.jpg &gt; example.jpg.age
</code></pre> </code></pre>
@@ -424,7 +453,7 @@ $ age -d -i age-yubikey-identity-388178f3.txt secrets.txt.age
<h2 id="SEE-ALSO">SEE ALSO</h2> <h2 id="SEE-ALSO">SEE ALSO</h2>
<p><a class="man-ref" href="age-keygen.1.html">age-keygen<span class="s">(1)</span></a></p> <p><a class="man-ref" href="age-keygen.1.html">age-keygen<span class="s">(1)</span></a>, <a class="man-ref" href="age-inspect.1.html">age-inspect<span class="s">(1)</span></a></p>
<h2 id="AUTHORS">AUTHORS</h2> <h2 id="AUTHORS">AUTHORS</h2>
@@ -432,7 +461,7 @@ $ age -d -i age-yubikey-identity-388178f3.txt secrets.txt.age
<ol class='man-decor man-foot man foot'> <ol class='man-decor man-foot man foot'>
<li class='tl'></li> <li class='tl'></li>
<li class='tc'>April 2023</li> <li class='tc'>August 2026</li>
<li class='tr'>age(1)</li> <li class='tr'>age(1)</li>
</ol> </ol>
+41 -14
View File
@@ -148,21 +148,35 @@ overhead per recipient, plus 16 bytes every 64KiB of plaintext.
to. `IDENTITIES` are private values, like a private key, that allow decrypting to. `IDENTITIES` are private values, like a private key, that allow decrypting
a file encrypted to the corresponding `RECIPIENT`. a file encrypted to the corresponding `RECIPIENT`.
### Native X25519 keys ### Native keys
Native `age` key pairs are generated with age-keygen(1), and provide small Native `age` key pairs are generated with age-keygen(1), and provide small
encodings and strong encryption based on X25519. They are the recommended encodings and strong encryption based on X25519 for classic keys, and X25519 +
recipient type for most applications. ML-KEM-768 for post-quantum hybrid keys. The post-quantum hybrid keys are secure
against future quantum computers and are the recommended recipient type for most
applications.
A `RECIPIENT` encoding begins with `age1` and looks like the following: A hybrid `RECIPIENT` encoding begins with `age1pq1` and looks like the following:
age1pq167[... 1950 more characters ...]
A hybrid `IDENTITY` encoding begins with `AGE-SECRET-KEY-PQ-1` and looks like
the following:
AGE-SECRET-KEY-PQ-1K30MYPZAHAXHR22YHH27EGDVLU0QNSUH3DSV7J7NR3X6D9LHXNWSDLTV4T
A classic `RECIPIENT` encoding begins with `age1` and looks like the following:
age1gde3ncmahlqd9gg50tanl99r960llztrhfapnmx853s4tjum03uqfssgdh age1gde3ncmahlqd9gg50tanl99r960llztrhfapnmx853s4tjum03uqfssgdh
An `IDENTITY` encoding begins with `AGE-SECRET-KEY-1` and looks like the A classic `IDENTITY` encoding begins with `AGE-SECRET-KEY-1` and looks like the
following: following:
AGE-SECRET-KEY-1KTYK6RVLN5TAPE7VF6FQQSKZ9HWWCDSKUGXXNUQDWZ7XXT5YK5LSF3UTKQ AGE-SECRET-KEY-1KTYK6RVLN5TAPE7VF6FQQSKZ9HWWCDSKUGXXNUQDWZ7XXT5YK5LSF3UTKQ
A file can't be encrypted to both post-quantum and classic keys, as that would
defeat the post-quantum security of the encryption.
An encrypted file can't be linked to the native recipient it's encrypted to An encrypted file can't be linked to the native recipient it's encrypted to
without access to the corresponding identity. without access to the corresponding identity.
@@ -223,6 +237,20 @@ instruct the user to perform encryption with the `-e`/`--encrypt` and
doesn't make sense (such as a password-encryption plugin) may instruct the user doesn't make sense (such as a password-encryption plugin) may instruct the user
to use the `-j` flag. to use the `-j` flag.
#### Tagged recipients
`age` can natively encrypt to recipients starting with `age1tag1` (using P-256
ECDH) or `age1tagpq1` (using the ML-KEM-768 + P-256 post-quantum hybrid). These
are intended to be the public side of private keys held in hardware.
They are directly supported to avoid the need to install the plugin, which may
be platform-specific, on the encrypting side.
The tag reduces privacy, by allowing an observer to correlate files with a
recipient (but not files amongst them without knowledge of the recipient),
but this is also a desirable property for hardware keys that require user
interaction for each decryption operation.
## EXIT STATUS ## EXIT STATUS
`age` will exit 0 if and only if encryption or decryption are successful for the `age` will exit 0 if and only if encryption or decryption are successful for the
@@ -243,27 +271,26 @@ by default. In this case, a flag will be provided to force the operation.
## EXAMPLES ## EXAMPLES
Generate a new identity, encrypt data, and decrypt: Generate a new post-quantum identity, encrypt data, and decrypt:
$ age-keygen -o key.txt $ age-keygen -pq -o key.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p Public key: age1pq167[... 1950 more characters ...]
$ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age $ tar cvz ~/data | age -r age1pq167[...] > data.tar.gz.age
$ age -d -o data.tar.gz -i key.txt data.tar.gz.age $ age -d -o data.tar.gz -i key.txt data.tar.gz.age
Encrypt `example.jpg` to multiple recipients and output to `example.jpg.age`: Encrypt `example.jpg` to multiple recipients and output to `example.jpg.age`:
$ age -o example.jpg.age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p \ $ age -o example.jpg.age -r age1pq167[...] -r age1pq1e3[...] example.jpg
-r age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg example.jpg
Encrypt to a list of recipients: Encrypt to a list of recipients:
$ cat > recipients.txt $ cat > recipients.txt
# Alice # Alice
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p age1pq167[... 1950 more characters ...]
# Bob # Bob
age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg age1pq1e3[... 1950 more characters ...]
$ age -R recipients.txt example.jpg > example.jpg.age $ age -R recipients.txt example.jpg > example.jpg.age
@@ -308,7 +335,7 @@ Encrypt to the SSH keys of a GitHub user:
## SEE ALSO ## SEE ALSO
age-keygen(1) age-keygen(1), age-inspect(1)
## AUTHORS ## AUTHORS
+156
View File
@@ -0,0 +1,156 @@
package main
import (
"flag"
"fmt"
"io"
"log"
"os"
"runtime/debug"
"filippo.io/age"
"filippo.io/age/internal/bech32"
"filippo.io/age/plugin"
)
const usage = `Usage:
age-plugin-pq -identity [-o OUTPUT] [INPUT]
Options:
-identity Convert one or more native post-quantum identities from
INPUT or from standard input to plugin identities.
-o, --output OUTPUT Write the result to the file at path OUTPUT instead of
standard output.
age-plugin-pq is an age plugin for post-quantum hybrid ML-KEM-768 + X25519
recipients and identities. These are supported natively by age v1.3.0 and later,
but this plugin can be placed in $PATH to add support to any version and
implementation of age that supports plugins.
Recipients work out of the box, while identities need to be converted to plugin
identities with -identity. If OUTPUT already exists, it is not overwritten.`
// Version can be set at link time to override debug.BuildInfo.Main.Version when
// building manually without git history. It should look like "v1.2.3".
var Version string
func main() {
log.SetFlags(0)
p, err := plugin.New("pq")
if err != nil {
errorf("failed to create plugin: %v", err)
}
p.RegisterFlags(nil)
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
var outFlag string
var versionFlag, identityFlag bool
flag.BoolVar(&versionFlag, "version", false, "print the version")
flag.BoolVar(&identityFlag, "identity", false, "convert identities to plugin identities")
flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)")
flag.StringVar(&outFlag, "output", "", "output to `FILE` (default stdout)")
flag.Parse()
if versionFlag {
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
Version = buildInfo.Main.Version
}
fmt.Println(Version)
return
}
if identityFlag {
if len(flag.Args()) > 1 {
errorf("too many arguments")
}
in := os.Stdin
if inFile := flag.Arg(0); inFile != "" && inFile != "-" {
f, err := os.Open(inFile)
if err != nil {
errorf("failed to open input file %q: %v", inFile, err)
}
defer f.Close()
in = f
}
converted := convert(in)
out := os.Stdout
if outFlag != "" {
f, err := os.OpenFile(outFlag, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
errorf("failed to open output file %q: %v", outFlag, err)
}
defer func() {
if err := f.Close(); err != nil {
errorf("failed to close output file %q: %v", outFlag, err)
}
}()
out = f
}
if fi, err := out.Stat(); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 {
warning("writing secret key to a world-readable file")
}
if _, err := out.Write(converted); err != nil {
errorf("failed to write output: %v", err)
}
return
}
p.HandleRecipientEncoding(func(s string) (age.Recipient, error) {
return age.ParseHybridRecipient(s)
})
p.HandleIdentity(func(data []byte) (age.Identity, error) {
// Convert from a AGE-PLUGIN-PQ-1... payload to a
// AGE-SECRET-KEY-PQ-1... identity encoding.
s, err := bech32.Encode("AGE-SECRET-KEY-PQ-", data)
if err != nil {
return nil, err
}
return age.ParseHybridIdentity(s)
})
p.HandleIdentityAsRecipient(func(data []byte) (age.Recipient, error) {
s, err := bech32.Encode("AGE-SECRET-KEY-PQ-", data)
if err != nil {
return nil, err
}
i, err := age.ParseHybridIdentity(s)
if err != nil {
return nil, err
}
return i.Recipient(), nil
})
os.Exit(p.Main())
}
func convert(in io.Reader) []byte {
ids, err := age.ParseIdentities(in)
if err != nil {
errorf("failed to parse identities: %v", err)
}
var out []byte
for i, id := range ids {
hybridID, ok := id.(*age.HybridIdentity)
if !ok {
errorf("identity #%d is not a post-quantum hybrid identity", i+1)
}
_, data, err := bech32.Decode(hybridID.String())
if err != nil {
errorf("failed to decode identity #%d: %v", i+1, err)
}
out = append(out, plugin.EncodeIdentity("pq", data)...)
out = append(out, '\n')
}
return out
}
func errorf(format string, v ...any) {
log.Printf("age-plugin-pq: error: "+format, v...)
log.Fatalf("age-plugin-pq: report unexpected or unhelpful errors at https://filippo.io/age/report")
}
func warning(msg string) {
log.Printf("age-plugin-pq: warning: %s", msg)
}
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"runtime/debug"
"filippo.io/age"
"filippo.io/age/plugin"
"filippo.io/age/tag"
)
const usage = `age-plugin-tag is an age plugin for P-256 tagged recipients. These are supported
natively by age v1.3.0 and later, but this plugin can be placed in $PATH to add
support to any version and implementation of age that supports plugins.
Usually, tagged recipients are the public side of private keys held in hardware,
where the identity side is handled by a different plugin.`
// Version can be set at link time to override debug.BuildInfo.Main.Version when
// building manually without git history. It should look like "v1.2.3".
var Version string
func main() {
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
p, err := plugin.New("tag")
if err != nil {
log.Fatal(err)
}
p.RegisterFlags(nil)
versionFlag := flag.Bool("version", false, "print the version")
flag.Parse()
if *versionFlag {
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
Version = buildInfo.Main.Version
}
fmt.Println(Version)
return
}
p.HandleRecipient(func(b []byte) (age.Recipient, error) {
return tag.NewClassicRecipient(b)
})
os.Exit(p.Main())
}
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"runtime/debug"
"filippo.io/age"
"filippo.io/age/plugin"
"filippo.io/age/tag"
)
const usage = `age-plugin-tagpq is an age plugin for ML-KEM-768 + P-256 post-quantum hybrid
tagged recipients. These are supported natively by age v1.3.0 and later, but
this plugin can be placed in $PATH to add support to any version and
implementation of age that supports plugins.
Usually, tagged recipients are the public side of private keys held in hardware,
where the identity side is handled by a different plugin.`
// Version can be set at link time to override debug.BuildInfo.Main.Version when
// building manually without git history. It should look like "v1.2.3".
var Version string
func main() {
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
p, err := plugin.New("tagpq")
if err != nil {
log.Fatal(err)
}
p.RegisterFlags(nil)
versionFlag := flag.Bool("version", false, "print the version")
flag.Parse()
if *versionFlag {
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
Version = buildInfo.Main.Version
}
fmt.Println(Version)
return
}
p.HandleRecipient(func(b []byte) (age.Recipient, error) {
return tag.NewHybridRecipient(b)
})
os.Exit(p.Main())
}
+13 -8
View File
@@ -1,17 +1,22 @@
module filippo.io/age module filippo.io/age
go 1.19 go 1.25.0
// Release build version.
toolchain go1.27.0
require ( require (
filippo.io/edwards25519 v1.1.0 filippo.io/edwards25519 v1.2.0
golang.org/x/crypto v0.24.0 filippo.io/hpke v0.4.0
golang.org/x/sys v0.21.0 filippo.io/nistec v0.0.4
golang.org/x/term v0.21.0 golang.org/x/crypto v0.55.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
) )
// Test dependencies. // Test dependencies.
require ( require (
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d
github.com/rogpeppe/go-internal v1.12.0 github.com/rogpeppe/go-internal v1.16.0
golang.org/x/tools v0.22.0 // indirect golang.org/x/tools v0.49.0 // indirect
) )
+18 -14
View File
@@ -1,14 +1,18 @@
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs=
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= filippo.io/nistec v0.0.4 h1:F14ZHT5htWlMnQVPndX9ro9arf56cBhQxq4LnDI491s=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= filippo.io/nistec v0.0.4/go.mod h1:PK/lw8I1gQT4hUML4QGaqljwdDaFcMyFKSXN7kjrtKI=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
+9 -2
View File
@@ -37,7 +37,7 @@ func polymod(values []byte) uint32 {
top := chk >> 25 top := chk >> 25
chk = (chk & 0x1ffffff) << 5 chk = (chk & 0x1ffffff) << 5
chk = chk ^ uint32(v) chk = chk ^ uint32(v)
for i := 0; i < 5; i++ { for i := range 5 {
bit := top >> i & 1 bit := top >> i & 1
if bit == 1 { if bit == 1 {
chk ^= generator[i] chk ^= generator[i]
@@ -154,14 +154,21 @@ func Decode(s string) (hrp string, data []byte, err error) {
return "", nil, fmt.Errorf("invalid character human-readable part: s[%d]=%d", p, c) return "", nil, fmt.Errorf("invalid character human-readable part: s[%d]=%d", p, c)
} }
} }
s = strings.ToLower(s)
for p, c := range s[pos+1:] { for p, c := range s[pos+1:] {
// Fold ASCII explicitly. Unicode case folding can turn a non-ASCII
// rune into a shorter valid charset member.
if c >= 'A' && c <= 'Z' {
c += 'a' - 'A'
}
d := strings.IndexRune(charset, c) d := strings.IndexRune(charset, c)
if d == -1 { if d == -1 {
return "", nil, fmt.Errorf("invalid character data part: s[%d]=%v", p, c) return "", nil, fmt.Errorf("invalid character data part: s[%d]=%v", p, c)
} }
data = append(data, byte(d)) data = append(data, byte(d))
} }
if len(data) < 6 {
return "", nil, fmt.Errorf("data part too short")
}
if !verifyChecksum(hrp, data) { if !verifyChecksum(hrp, data) {
return "", nil, fmt.Errorf("invalid checksum") return "", nil, fmt.Errorf("invalid checksum")
} }
+20
View File
@@ -93,3 +93,23 @@ func TestBech32(t *testing.T) {
} }
} }
} }
func TestDecodeShortDataPart(t *testing.T) {
kelvin := string(rune(0x212A))
for _, s := range []string{
"AA3100AC" + kelvin,
"BK1" + kelvin + "0JFM",
"AQM1KZCML",
} {
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("Decode(%+q) panicked: %v", s, r)
}
}()
if _, _, err := bech32.Decode(s); err == nil {
t.Errorf("Decode(%+q) = nil error, want error", s)
}
}()
}
}
+119 -22
View File
@@ -76,11 +76,9 @@ func (w *WrappedBase64Encoder) writeWrapped(p []byte) (int, error) {
if w.buf.Len() != 0 { if w.buf.Len() != 0 {
panic("age: internal error: non-empty WrappedBase64Encoder.buf") panic("age: internal error: non-empty WrappedBase64Encoder.buf")
} }
total := len(p)
for len(p) > 0 { for len(p) > 0 {
toWrite := ColumnsPerLine - (w.written % ColumnsPerLine) toWrite := min(ColumnsPerLine-(w.written%ColumnsPerLine), len(p))
if toWrite > len(p) {
toWrite = len(p)
}
n, _ := w.buf.Write(p[:toWrite]) n, _ := w.buf.Write(p[:toWrite])
w.written += n w.written += n
p = p[n:] p = p[n:]
@@ -94,7 +92,7 @@ func (w *WrappedBase64Encoder) writeWrapped(p []byte) (int, error) {
// are not recoverable anyway. // are not recoverable anyway.
return 0, err return 0, err
} }
return len(p), nil return total, nil
} }
// LastLineIsEmpty returns whether the last output line was empty, either // LastLineIsEmpty returns whether the last output line was empty, either
@@ -111,10 +109,21 @@ var stanzaPrefix = []byte("->")
var footerPrefix = []byte("---") var footerPrefix = []byte("---")
func (r *Stanza) Marshal(w io.Writer) error { func (r *Stanza) Marshal(w io.Writer) error {
if !isValidString(r.Type) {
return fmt.Errorf("invalid stanza type: %q", r.Type)
}
for _, a := range r.Args {
if !isValidString(a) {
return fmt.Errorf("invalid stanza argument: %q", a)
}
}
if _, err := w.Write(stanzaPrefix); err != nil { if _, err := w.Write(stanzaPrefix); err != nil {
return err return err
} }
for _, a := range append([]string{r.Type}, r.Args...) { if _, err := io.WriteString(w, " "+r.Type); err != nil {
return err
}
for _, a := range r.Args {
if _, err := io.WriteString(w, " "+a); err != nil { if _, err := io.WriteString(w, " "+a); err != nil {
return err return err
} }
@@ -134,6 +143,9 @@ func (r *Stanza) Marshal(w io.Writer) error {
} }
func (h *Header) MarshalWithoutMAC(w io.Writer) error { func (h *Header) MarshalWithoutMAC(w io.Writer) error {
if len(h.Recipients) == 0 {
return errors.New("no recipient stanzas")
}
if _, err := io.WriteString(w, intro); err != nil { if _, err := io.WriteString(w, intro); err != nil {
return err return err
} }
@@ -156,7 +168,9 @@ func (h *Header) Marshal(w io.Writer) error {
} }
type StanzaReader struct { type StanzaReader struct {
r *bufio.Reader r interface {
ReadBytes(delim byte) ([]byte, error)
}
err error err error
} }
@@ -184,11 +198,6 @@ func (r *StanzaReader) ReadStanza() (s *Stanza, err error) {
if prefix != string(stanzaPrefix) || len(args) < 1 { if prefix != string(stanzaPrefix) || len(args) < 1 {
return nil, fmt.Errorf("malformed stanza: %q", line) return nil, fmt.Errorf("malformed stanza: %q", line)
} }
for _, a := range args {
if !isValidString(a) {
return nil, fmt.Errorf("malformed stanza: %q", line)
}
}
s.Type = args[0] s.Type = args[0]
s.Args = args[1:] s.Args = args[1:]
@@ -201,7 +210,7 @@ func (r *StanzaReader) ReadStanza() (s *Stanza, err error) {
b, err := DecodeString(strings.TrimSuffix(string(line), "\n")) b, err := DecodeString(strings.TrimSuffix(string(line), "\n"))
if err != nil { if err != nil {
if bytes.HasPrefix(line, footerPrefix) || bytes.HasPrefix(line, stanzaPrefix) { if bytes.HasPrefix(line, footerPrefix) || bytes.HasPrefix(line, stanzaPrefix) {
return nil, fmt.Errorf("malformed body line %q: stanza ended without a short line\nNote: this might be a file encrypted with an old beta version of age or rage. Use age v1.0.0-beta6 or rage to decrypt it.", line) return nil, fmt.Errorf("malformed body line %q: stanza ended without a short line\nnote: this might be a file encrypted with an old beta version of age or rage; use age v1.0.0-beta6 or rage to decrypt it", line)
} }
return nil, errorf("malformed body line %q: %v", line, err) return nil, errorf("malformed body line %q: %v", line, err)
} }
@@ -220,6 +229,44 @@ type ParseError struct {
err error err error
} }
const (
maxHeaderBytes = 2 << 20
maxRecipientStanzas = 1024
maxStanzaArgs = 128
)
type headerReader struct {
r *bufio.Reader
n int
}
func (r *headerReader) ReadBytes(delim byte) ([]byte, error) {
var line []byte
for {
frag, err := r.r.ReadSlice(delim)
if len(frag) > maxHeaderBytes-r.n {
return nil, errorf("header exceeds 2 MiB")
}
r.n += len(frag)
line = append(line, frag...)
if err != bufio.ErrBufferFull {
return line, err
}
}
}
func (r *headerReader) ReadString(delim byte) (string, error) {
line, err := r.ReadBytes(delim)
return string(line), err
}
func (r *headerReader) Peek(n int) ([]byte, error) {
if r.n+n > maxHeaderBytes {
return nil, errorf("header exceeds 2 MiB")
}
return r.r.Peek(n)
}
func (e *ParseError) Error() string { func (e *ParseError) Error() string {
return "parsing age header: " + e.err.Error() return "parsing age header: " + e.err.Error()
} }
@@ -228,33 +275,66 @@ func (e *ParseError) Unwrap() error {
return e.err return e.err
} }
func errorf(format string, a ...interface{}) error { func errorf(format string, a ...any) error {
return &ParseError{fmt.Errorf(format, a...)} return &ParseError{fmt.Errorf(format, a...)}
} }
// describeIntro returns a quoted description of a bad intro line, or an empty
// string if the line contains recognizable private key material.
func describeIntro(line string) string {
for _, prefix := range []string{"AGE-SECRET-KEY-", "AGE-PLUGIN-"} {
if strings.HasPrefix(line, prefix) {
return ""
}
}
// Preserve enough context to diagnose a mangled intro without echoing an
// arbitrarily long first line.
return fmt.Sprintf("%q", line[:min(len(line), len(intro))])
}
// Parse returns the header and a Reader that begins at the start of the // Parse returns the header and a Reader that begins at the start of the
// payload. // payload.
func Parse(input io.Reader) (*Header, io.Reader, error) { func Parse(input io.Reader) (*Header, io.Reader, error) {
h := &Header{} h := &Header{}
rr := bufio.NewReader(input) rr := bufio.NewReader(input)
hr := &headerReader{r: rr}
line, err := rr.ReadString('\n') line, err := hr.ReadString('\n')
if err != nil { if err == io.EOF {
if len(line) == 0 {
return nil, nil, errorf("file is empty")
}
if description := describeIntro(line); description != "" {
return nil, nil, errorf("unexpected EOF reading intro: %s", description)
}
return nil, nil, errorf("unexpected EOF reading intro, expected %q", intro)
} else if err != nil {
// headerReader errors are already ParseErrors; don't nest the prefix.
if _, ok := err.(*ParseError); ok {
return nil, nil, err
}
return nil, nil, errorf("failed to read intro: %w", err) return nil, nil, errorf("failed to read intro: %w", err)
} }
if line != intro { if line != intro {
return nil, nil, errorf("unexpected intro: %q", line) if description := describeIntro(line); description != "" {
return nil, nil, errorf("unexpected intro: %s", description)
}
return nil, nil, errorf("unexpected intro, expected %q", intro)
} }
sr := NewStanzaReader(rr) sr := &StanzaReader{r: hr}
for { for {
peek, err := rr.Peek(len(footerPrefix)) peek, err := hr.Peek(len(footerPrefix))
if err != nil { if err != nil {
// headerReader errors are already ParseErrors; don't nest the prefix.
if _, ok := err.(*ParseError); ok {
return nil, nil, err
}
return nil, nil, errorf("failed to read header: %w", err) return nil, nil, errorf("failed to read header: %w", err)
} }
if bytes.Equal(peek, footerPrefix) { if bytes.Equal(peek, footerPrefix) {
line, err := rr.ReadBytes('\n') line, err := hr.ReadBytes('\n')
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to read header: %w", err) return nil, nil, fmt.Errorf("failed to read header: %w", err)
} }
@@ -269,6 +349,9 @@ func Parse(input io.Reader) (*Header, io.Reader, error) {
} }
break break
} }
if len(h.Recipients) == maxRecipientStanzas {
return nil, nil, errorf("header contains more than %d recipient stanzas", maxRecipientStanzas)
}
s, err := sr.ReadStanza() s, err := sr.ReadStanza()
if err != nil { if err != nil {
@@ -276,6 +359,9 @@ func Parse(input io.Reader) (*Header, io.Reader, error) {
} }
h.Recipients = append(h.Recipients, s) h.Recipients = append(h.Recipients, s)
} }
if len(h.Recipients) == 0 {
return nil, nil, errorf("no recipient stanzas")
}
// If input is a bufio.Reader, rr might be equal to input because // If input is a bufio.Reader, rr might be equal to input because
// bufio.NewReader short-circuits. In this case we can just return it (and // bufio.NewReader short-circuits. In this case we can just return it (and
@@ -294,8 +380,19 @@ func Parse(input io.Reader) (*Header, io.Reader, error) {
func splitArgs(line []byte) (string, []string) { func splitArgs(line []byte) (string, []string) {
l := strings.TrimSuffix(string(line), "\n") l := strings.TrimSuffix(string(line), "\n")
parts := strings.Split(l, " ") prefix, rest, ok := strings.Cut(l, " ")
return parts[0], parts[1:] if !ok {
return l, nil
}
var args []string
for arg := range strings.SplitSeq(rest, " ") {
if !isValidString(arg) || len(args) > maxStanzaArgs {
return l, nil
}
args = append(args, arg)
}
return prefix, args
} }
func isValidString(s string) bool { func isValidString(s string) bool {
+216 -5
View File
@@ -3,17 +3,19 @@
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
//go:build go1.18 //go:build go1.18
// +build go1.18
package format_test package format_test
import ( import (
"bufio"
"bytes" "bytes"
"errors"
"io" "io"
"os" "io/fs"
"path/filepath" "strings"
"testing" "testing"
agetest "c2sp.org/CCTV/age"
"filippo.io/age/internal/format" "filippo.io/age/internal/format"
) )
@@ -42,15 +44,143 @@ func TestStanzaMarshal(t *testing.T) {
if exp := "-> test 1 2 3\nQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB\n\n"; buf.String() != exp { if exp := "-> test 1 2 3\nQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB\n\n"; buf.String() != exp {
t.Errorf("wrong 64 columns stanza encoding: expected %q, got %q", exp, buf.String()) t.Errorf("wrong 64 columns stanza encoding: expected %q, got %q", exp, buf.String())
} }
for _, s := range []*format.Stanza{
{Type: ""},
{Type: "test", Args: []string{""}},
{Type: "test", Args: []string{"a b"}},
{Type: "test", Args: []string{"a\nb"}},
{Type: "test", Args: []string{"café"}},
} {
buf.Reset()
if err := s.Marshal(buf); err == nil {
t.Errorf("Marshal accepted type %q, args %q", s.Type, s.Args)
}
if buf.Len() != 0 {
t.Errorf("Marshal wrote %d bytes", buf.Len())
}
}
}
func TestHeaderMarshalNoStanzas(t *testing.T) {
buf := &bytes.Buffer{}
if err := (&format.Header{}).Marshal(buf); err == nil {
t.Fatal("Marshal accepted a header with no stanzas")
}
if buf.Len() != 0 {
t.Errorf("Marshal wrote %d bytes", buf.Len())
}
}
func TestParseLimits(t *testing.T) {
const (
intro = "age-encryption.org/v1\n"
maxHeaderBytes = 2 << 20
)
footer := "--- " + format.EncodeToString(make([]byte, 32)) + "\n"
t.Run("header size", func(t *testing.T) {
makeHeader := func(size int) []byte {
const opening = "-> test "
fixed := len(intro) + len(opening) + len("\n\n") + len(footer)
return []byte(intro + opening + strings.Repeat("a", size-fixed) + "\n\n" + footer)
}
if _, _, err := format.Parse(bytes.NewReader(makeHeader(maxHeaderBytes))); err != nil {
t.Fatalf("maximum-size header was rejected: %v", err)
}
_, _, err := format.Parse(bytes.NewReader(makeHeader(maxHeaderBytes + 1)))
if err == nil || !strings.Contains(err.Error(), "header exceeds 2 MiB") {
t.Fatalf("unexpected oversized-header error: %v", err)
}
var parseError *format.ParseError
if !errors.As(err, &parseError) {
t.Errorf("oversized-header error is not a ParseError: %T", err)
}
if strings.Count(err.Error(), "parsing age header") != 1 {
t.Errorf("oversized-header error nests ParseError prefixes: %v", err)
}
// An intro line that exceeds the limit before any newline.
_, _, err = format.Parse(bytes.NewReader(bytes.Repeat([]byte{'a'}, maxHeaderBytes+1)))
if err == nil || !strings.Contains(err.Error(), "header exceeds 2 MiB") {
t.Fatalf("unexpected oversized-intro error: %v", err)
}
if !errors.As(err, &parseError) {
t.Errorf("oversized-intro error is not a ParseError: %T", err)
}
if strings.Count(err.Error(), "parsing age header") != 1 {
t.Errorf("oversized-intro error nests ParseError prefixes: %v", err)
}
})
t.Run("recipient stanzas", func(t *testing.T) {
makeHeader := func(stanzas int) []byte {
return []byte(intro + strings.Repeat("-> test\n\n", stanzas) + footer)
}
if _, _, err := format.Parse(bytes.NewReader(makeHeader(0))); err == nil || !strings.Contains(err.Error(), "no recipient stanzas") {
t.Fatalf("unexpected zero-stanza error: %v", err)
}
if _, _, err := format.Parse(bytes.NewReader(makeHeader(1024))); err != nil {
t.Fatalf("header with 1024 stanzas was rejected: %v", err)
}
_, _, err := format.Parse(bytes.NewReader(makeHeader(1025)))
if err == nil || !strings.Contains(err.Error(), "more than 1024 recipient stanzas") {
t.Fatalf("unexpected stanza-limit error: %v", err)
}
})
t.Run("recipient stanza arguments", func(t *testing.T) {
makeHeader := func(args int) []byte {
return []byte(intro + "-> test" + strings.Repeat(" a", args) + "\n\n" + footer)
}
if _, _, err := format.Parse(bytes.NewReader(makeHeader(128))); err != nil {
t.Fatalf("stanza with 128 arguments was rejected: %v", err)
}
_, _, err := format.Parse(bytes.NewReader(makeHeader(129)))
if err == nil {
t.Fatal("stanza with 129 arguments was accepted")
}
})
t.Run("payload is not limited", func(t *testing.T) {
header := []byte(intro + "-> test\n\n" + footer)
want := bytes.Repeat([]byte("p"), maxHeaderBytes+1)
_, payload, err := format.Parse(io.MultiReader(bytes.NewReader(header), bytes.NewReader(want)))
if err != nil {
t.Fatal(err)
}
got, err := io.ReadAll(payload)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, want) {
t.Error("payload was truncated or modified")
}
})
t.Run("bufio reader is preserved", func(t *testing.T) {
header := []byte(intro + "-> test\n\n" + footer)
input := bufio.NewReader(bytes.NewReader(append(header, "payload"...)))
_, payload, err := format.Parse(input)
if err != nil {
t.Fatal(err)
}
if payload != input {
t.Error("Parse did not return the input bufio.Reader")
}
})
} }
func FuzzMalleability(f *testing.F) { func FuzzMalleability(f *testing.F) {
tests, err := filepath.Glob("../../testdata/testkit/*") tests, err := fs.ReadDir(agetest.Vectors, ".")
if err != nil { if err != nil {
f.Fatal(err) f.Fatal(err)
} }
for _, test := range tests { for _, test := range tests {
contents, err := os.ReadFile(test) contents, err := fs.ReadFile(agetest.Vectors, test.Name())
if err != nil { if err != nil {
f.Fatal(err) f.Fatal(err)
} }
@@ -60,6 +190,9 @@ func FuzzMalleability(f *testing.F) {
} }
f.Add(contents) f.Add(contents)
} }
if len(tests) == 0 {
f.Fatal("no test vectors")
}
f.Fuzz(func(t *testing.T, data []byte) { f.Fuzz(func(t *testing.T, data []byte) {
h, payload, err := format.Parse(bytes.NewReader(data)) h, payload, err := format.Parse(bytes.NewReader(data))
if err != nil { if err != nil {
@@ -83,3 +216,81 @@ func FuzzMalleability(f *testing.F) {
} }
}) })
} }
const secret = "AGE-SECRET-KEY-1NOTAREALKEYNOTAREALKEYNOTAREALKEYNOTAREALKEYNOTAREALKEYNOTA"
const pluginSecret = "AGE-PLUGIN-PQ-1NOTAREALKEYNOTAREALKEYNOTAREALKEYNOTAREALKEYNOTAREALKEYNOTA"
func TestParseIntroErrorIsNotSecret(t *testing.T) {
for _, test := range []struct {
name string
input string
secret bool
}{
{"identity", secret + "\n", true},
{"identity, no newline", secret, true},
{"plugin identity", pluginSecret + "\n", true},
{"plugin identity, no newline", pluginSecret, true},
{"truncated intro", "age-encryption.org/v1", false},
{"binary", "\x00\x01\x02", false},
} {
t.Run(test.name, func(t *testing.T) {
_, _, err := format.Parse(strings.NewReader(test.input))
if err == nil {
t.Fatal("expected an error")
}
if strings.Contains(err.Error(), "file is empty") {
t.Errorf("error says the file is empty, but it is %d bytes: %v",
len(test.input), err)
}
// Check substrings, not just the key prefix.
if test.secret {
key := strings.TrimSuffix(test.input, "\n")
for i := 0; i+17 <= len(key); i++ {
if strings.Contains(err.Error(), key[i:i+17]) {
t.Errorf("error includes %q, a run of the first line, "+
"which is a private key: %v", key[i:i+17], err)
break
}
}
}
})
}
// Preserve the error for empty files (#416).
t.Run("empty", func(t *testing.T) {
_, _, err := format.Parse(strings.NewReader(""))
if err == nil {
t.Fatal("expected an error")
}
if !strings.Contains(err.Error(), "file is empty") {
t.Errorf("expected an empty file error, got: %v", err)
}
})
}
func TestParseIntroErrorShowsMangling(t *testing.T) {
for _, test := range []struct {
name string
input string
want string
}{
{"crlf", "age-encryption.org/v1\r\n", `\r`},
{"utf8 bom", "\xef\xbb\xbfage-encryption.org/v1\n", `\ufeff`},
{"utf16be", "\x00a\x00g\x00e\x00-\x00e\x00n\x00c\x00r\x00y\x00p\x00", `\x00a\x00g\x00e`},
{"trailing space", "age-encryption.org/v1 \n", `v1 `},
{"wrong version", "age-encryption.org/v2\n", "v2"},
{"leading blank line", "\nage-encryption.org/v1\n", `"\n"`},
} {
t.Run(test.name, func(t *testing.T) {
_, _, err := format.Parse(strings.NewReader(test.input))
if err == nil {
t.Fatal("expected an error")
}
if !strings.Contains(err.Error(), test.want) {
t.Errorf("error does not show the mangling: got %v, want it to contain %q",
err, test.want)
}
})
}
}
+130
View File
@@ -0,0 +1,130 @@
package inspect
import (
"bufio"
"bytes"
"fmt"
"io"
"strings"
"filippo.io/age/armor"
"filippo.io/age/internal/format"
"filippo.io/age/internal/stream"
)
type Metadata struct {
Version string `json:"version"`
Postquantum string `json:"postquantum"` // "yes" or "no" or "unknown"
Armor bool `json:"armor"`
StanzaTypes []string `json:"stanza_types"`
Sizes struct {
Header int64 `json:"header"`
Armor int64 `json:"armor"`
Overhead int64 `json:"overhead"`
// Currently, we don't do any padding, so MinPayload == MaxPayload and
// MinPadding == MaxPadding == 0, but that might change in the future.
MinPayload int64 `json:"min_payload"`
MaxPayload int64 `json:"max_payload"`
MinPadding int64 `json:"min_padding"`
MaxPadding int64 `json:"max_padding"`
} `json:"sizes"`
}
func Inspect(r io.Reader, fileSize int64) (*Metadata, error) {
data := &Metadata{
Version: "age-encryption.org/v1",
Postquantum: "yes",
}
tr := &trackReader{r: r}
br := bufio.NewReader(tr)
const maxWhitespace = 1024
start, _ := br.Peek(maxWhitespace + len(armor.Header))
if strings.HasPrefix(string(bytes.TrimSpace(start)), armor.Header) {
r = armor.NewReader(br)
data.Armor = true
} else {
r = br
}
hdr, rest, err := format.Parse(r)
if err != nil {
return nil, fmt.Errorf("failed to read header: %w", err)
}
buf := &bytes.Buffer{}
if err := hdr.Marshal(buf); err != nil {
return nil, fmt.Errorf("failed to re-serialize header: %w", err)
}
data.Sizes.Header = int64(buf.Len())
for _, s := range hdr.Recipients {
data.StanzaTypes = append(data.StanzaTypes, s.Type)
switch s.Type {
case "X25519", "ssh-rsa", "ssh-ed25519", "p256tag", "piv-p256":
data.Postquantum = "no"
case "mlkem768x25519", "scrypt", "mlkem768p256tag":
// Keep "yes".
default:
if data.Postquantum != "no" {
data.Postquantum = "unknown"
}
}
}
// If fileSize is not provided, or if it's the size of the armored file
// (which can have LF or CRLF line endings, varying its size), read to
// the end to determine it.
if fileSize == -1 || data.Armor {
n, err := io.Copy(io.Discard, rest)
if err != nil {
return nil, fmt.Errorf("failed to read rest of file: %w", err)
}
fileSize = data.Sizes.Header + n
if !tr.done {
panic("trackReader not done after io.Copy")
}
if tr.count != fileSize && !data.Armor {
panic("trackReader count mismatch")
}
data.Sizes.Armor = tr.count - fileSize
}
data.Sizes.Overhead, err = streamOverhead(fileSize - data.Sizes.Header)
if err != nil {
return nil, fmt.Errorf("failed to compute stream overhead: %w", err)
}
data.Sizes.MinPayload = fileSize - data.Sizes.Header - data.Sizes.Overhead
data.Sizes.MaxPayload = data.Sizes.MinPayload
return data, nil
}
type trackReader struct {
r io.Reader
count int64
done bool
}
func (tr *trackReader) Read(p []byte) (int, error) {
if tr.done {
return 0, io.EOF
}
n, err := tr.r.Read(p)
tr.count += int64(n)
if err == io.EOF {
tr.done = true
}
return n, err
}
func streamOverhead(payloadSize int64) (int64, error) {
const streamNonceSize = 16
if payloadSize < streamNonceSize {
return 0, fmt.Errorf("encrypted size too small: %d", payloadSize)
}
encryptedSize := payloadSize - streamNonceSize
plaintextSize, err := stream.PlaintextSize(encryptedSize)
if err != nil {
return 0, err
}
return payloadSize - plaintextSize, nil
}
+159
View File
@@ -0,0 +1,159 @@
package inspect
import (
"bytes"
"fmt"
"io"
"testing"
"filippo.io/age/internal/format"
"filippo.io/age/internal/stream"
)
// buildFile serializes a header with the given stanza types,
// followed by the minimal valid encrypted payload (a 16-byte stream nonce
// and a single empty ChaCha20-Poly1305 chunk).
func buildFile(t *testing.T, stanzaTypes ...string) []byte {
t.Helper()
hdr := &format.Header{MAC: make([]byte, 32)}
for _, stanzaType := range stanzaTypes {
hdr.Recipients = append(hdr.Recipients, &format.Stanza{Type: stanzaType})
}
buf := &bytes.Buffer{}
if err := hdr.Marshal(buf); err != nil {
t.Fatalf("Header.Marshal: %v", err)
}
// Append nonce (16 bytes) + poly1305 tag for empty chunk (16 bytes).
buf.Write(make([]byte, 16+16))
return buf.Bytes()
}
func TestInspectTagStanzas(t *testing.T) {
tests := []struct {
stanzaType string
want string
}{
{stanzaType: "p256tag", want: "no"},
{stanzaType: "mlkem768p256tag", want: "yes"},
}
for _, tt := range tests {
t.Run(tt.stanzaType, func(t *testing.T) {
f := buildFile(t, tt.stanzaType)
md, err := Inspect(bytes.NewReader(f), int64(len(f)))
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if got := md.Postquantum; got != tt.want {
t.Errorf("Postquantum = %q, want %q", got, tt.want)
}
if len(md.StanzaTypes) != 1 || md.StanzaTypes[0] != tt.stanzaType {
t.Errorf("StanzaTypes = %v, want [%q]", md.StanzaTypes, tt.stanzaType)
}
})
}
}
func TestInspectPostquantum(t *testing.T) {
tests := []struct {
name string
stanzaTypes []string
want string
}{
{"postquantum", []string{"mlkem768x25519"}, "yes"},
{"classical", []string{"X25519"}, "no"},
{"both", []string{"mlkem768x25519", "X25519"}, "no"},
{"unrecognized", []string{"tpm-ecc"}, "unknown"},
{"postquantum and unrecognized", []string{"mlkem768x25519", "tpm-ecc"}, "unknown"},
{"unrecognized and postquantum", []string{"tpm-ecc", "mlkem768x25519"}, "unknown"},
{"classical and unrecognized", []string{"X25519", "tpm-ecc"}, "no"},
{"unrecognized and classical", []string{"tpm-ecc", "X25519"}, "no"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := buildFile(t, tt.stanzaTypes...)
md, err := Inspect(bytes.NewReader(f), int64(len(f)))
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if md.Postquantum != tt.want {
t.Errorf("Postquantum = %q, want %q", md.Postquantum, tt.want)
}
})
}
}
// readAfterEOFReader returns io.EOF along with the last of its data, and then
// more data from subsequent Reads, like a terminal that received Ctrl-D
// followed by more input.
type readAfterEOFReader struct {
data []byte
eof bool
}
func (r *readAfterEOFReader) Read(p []byte) (int, error) {
if r.eof {
return copy(p, "\n"), nil
}
n := copy(p, r.data)
r.data = r.data[n:]
if len(r.data) == 0 {
r.eof = true
return n, io.EOF
}
return n, nil
}
func TestInspectReadAfterEOF(t *testing.T) {
f := buildFile(t, "X25519")
r := &readAfterEOFReader{data: f}
md, err := Inspect(r, -1)
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if md.Version != "age-encryption.org/v1" {
t.Errorf("Version = %q, want age-encryption.org/v1", md.Version)
}
// If the reads after EOF were counted towards the file size, the extra
// bytes would show up in the payload size.
if md.Sizes.MinPayload != 0 {
t.Errorf("MinPayload = %d, want 0", md.Sizes.MinPayload)
}
}
func TestStreamOverhead(t *testing.T) {
tests := []struct {
payloadSize int64
want int64
wantErr bool
}{
{payloadSize: 0, wantErr: true},
{payloadSize: 15, wantErr: true},
{payloadSize: 16, wantErr: true},
{payloadSize: 16 + 15, wantErr: true},
{payloadSize: 16 + 16, want: 16 + 16}, // empty plaintext
{payloadSize: 16 + 1 + 16, want: 16 + 16},
{payloadSize: 16 + stream.ChunkSize + 16, want: 16 + 16},
{payloadSize: 16 + stream.ChunkSize + 16 + 1, wantErr: true},
{payloadSize: 16 + stream.ChunkSize + 16 + 15, wantErr: true},
{payloadSize: 16 + stream.ChunkSize + 16 + 16, wantErr: true}, // empty final chunk
{payloadSize: 16 + stream.ChunkSize + 16 + 1 + 16, want: 16 + 16 + 16},
}
for _, tt := range tests {
name := "payloadSize=" + fmt.Sprint(tt.payloadSize)
t.Run(name, func(t *testing.T) {
got, gotErr := streamOverhead(tt.payloadSize)
if gotErr != nil {
if !tt.wantErr {
t.Errorf("streamOverhead() failed: %v", gotErr)
}
return
}
if tt.wantErr {
t.Fatal("streamOverhead() succeeded unexpectedly")
}
if got != tt.want {
t.Errorf("streamOverhead() = %v, want %v", got, tt.want)
}
})
}
}
+273 -42
View File
@@ -6,18 +6,49 @@
package stream package stream
import ( import (
"bytes"
"crypto/cipher" "crypto/cipher"
"encoding/binary"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math"
"sync/atomic"
"golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/poly1305"
) )
const ChunkSize = 64 * 1024 const ChunkSize = 64 * 1024
type Reader struct { func EncryptedChunkCount(encryptedSize int64) (int64, error) {
if encryptedSize < 0 || encryptedSize > math.MaxInt64-encChunkSize+1 {
return 0, fmt.Errorf("invalid encrypted payload size: %d", encryptedSize)
}
chunks := (encryptedSize + encChunkSize - 1) / encChunkSize
plaintextSize := encryptedSize - chunks*chacha20poly1305.Overhead
expChunks := (plaintextSize + ChunkSize - 1) / ChunkSize
// Empty plaintext, the only case that allows (and requires) an empty chunk.
if plaintextSize == 0 {
expChunks = 1
}
if expChunks != chunks {
return 0, fmt.Errorf("invalid encrypted payload size: %d", encryptedSize)
}
return chunks, nil
}
func PlaintextSize(encryptedSize int64) (int64, error) {
chunks, err := EncryptedChunkCount(encryptedSize)
if err != nil {
return 0, err
}
plaintextSize := encryptedSize - chunks*chacha20poly1305.Overhead
return plaintextSize, nil
}
type DecryptReader struct {
a cipher.AEAD a cipher.AEAD
src io.Reader src io.Reader
@@ -29,22 +60,19 @@ type Reader struct {
} }
const ( const (
encChunkSize = ChunkSize + poly1305.TagSize encChunkSize = ChunkSize + chacha20poly1305.Overhead
lastChunkFlag = 0x01 lastChunkFlag = 0x01
) )
func NewReader(key []byte, src io.Reader) (*Reader, error) { func NewDecryptReader(key []byte, src io.Reader) (*DecryptReader, error) {
aead, err := chacha20poly1305.New(key) aead, err := chacha20poly1305.New(key)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &Reader{ return &DecryptReader{a: aead, src: src}, nil
a: aead,
src: src,
}, nil
} }
func (r *Reader) Read(p []byte) (int, error) { func (r *DecryptReader) Read(p []byte) (int, error) {
if len(r.unread) > 0 { if len(r.unread) > 0 {
n := copy(p, r.unread) n := copy(p, r.unread)
r.unread = r.unread[n:] r.unread = r.unread[n:]
@@ -71,7 +99,7 @@ func (r *Reader) Read(p []byte) (int, error) {
// words, check for trailing data after a full-length final chunk. // words, check for trailing data after a full-length final chunk.
// Hopefully, the underlying reader supports returning EOF even if it // Hopefully, the underlying reader supports returning EOF even if it
// had previously returned an EOF to ReadFull. // had previously returned an EOF to ReadFull.
if _, err := r.src.Read(make([]byte, 1)); err == nil { if _, err := io.ReadFull(r.src, make([]byte, 1)); err == nil {
r.err = errors.New("trailing data after end of encrypted file") r.err = errors.New("trailing data after end of encrypted file")
} else if err != io.EOF { } else if err != io.EOF {
r.err = fmt.Errorf("non-EOF error reading after end of encrypted file: %w", err) r.err = fmt.Errorf("non-EOF error reading after end of encrypted file: %w", err)
@@ -86,7 +114,7 @@ func (r *Reader) Read(p []byte) (int, error) {
// readChunk reads the next chunk of ciphertext from r.src and makes it available // readChunk reads the next chunk of ciphertext from r.src and makes it available
// in r.unread. last is true if the chunk was marked as the end of the message. // in r.unread. last is true if the chunk was marked as the end of the message.
// readChunk must not be called again after returning a last chunk or an error. // readChunk must not be called again after returning a last chunk or an error.
func (r *Reader) readChunk() (last bool, err error) { func (r *DecryptReader) readChunk() (last bool, err error) {
if len(r.unread) != 0 { if len(r.unread) != 0 {
panic("stream: internal error: readChunk called with dirty buffer") panic("stream: internal error: readChunk called with dirty buffer")
} }
@@ -119,7 +147,7 @@ func (r *Reader) readChunk() (last bool, err error) {
out, err = r.a.Open(outBuf, r.nonce[:], in, nil) out, err = r.a.Open(outBuf, r.nonce[:], in, nil)
} }
if err != nil { if err != nil {
return false, errors.New("failed to decrypt and authenticate payload chunk") return false, errors.New("failed to decrypt and authenticate payload chunk, file may be corrupted or tampered with")
} }
incNonce(&r.nonce) incNonce(&r.nonce)
@@ -131,12 +159,17 @@ func incNonce(nonce *[chacha20poly1305.NonceSize]byte) {
for i := len(nonce) - 2; i >= 0; i-- { for i := len(nonce) - 2; i >= 0; i-- {
nonce[i]++ nonce[i]++
if nonce[i] != 0 { if nonce[i] != 0 {
break return
} else if i == 0 {
// The counter is 88 bits, this is unreachable.
panic("stream: chunk counter wrapped around")
} }
} }
// The counter is 88 bits, this is unreachable.
panic("stream: chunk counter wrapped around")
}
func nonceForChunk(chunkIndex int64) *[chacha20poly1305.NonceSize]byte {
var nonce [chacha20poly1305.NonceSize]byte
binary.BigEndian.PutUint64(nonce[3:11], uint64(chunkIndex))
return &nonce
} }
func setLastChunkFlag(nonce *[chacha20poly1305.NonceSize]byte) { func setLastChunkFlag(nonce *[chacha20poly1305.NonceSize]byte) {
@@ -147,30 +180,23 @@ func nonceIsZero(nonce *[chacha20poly1305.NonceSize]byte) bool {
return *nonce == [chacha20poly1305.NonceSize]byte{} return *nonce == [chacha20poly1305.NonceSize]byte{}
} }
type Writer struct { type EncryptWriter struct {
a cipher.AEAD a cipher.AEAD
dst io.Writer dst io.Writer
unwritten []byte // backed by buf buf bytes.Buffer
buf [encChunkSize]byte nonce [chacha20poly1305.NonceSize]byte
nonce [chacha20poly1305.NonceSize]byte err error
err error
} }
func NewWriter(key []byte, dst io.Writer) (*Writer, error) { func NewEncryptWriter(key []byte, dst io.Writer) (*EncryptWriter, error) {
aead, err := chacha20poly1305.New(key) aead, err := chacha20poly1305.New(key)
if err != nil { if err != nil {
return nil, err return nil, err
} }
w := &Writer{ return &EncryptWriter{a: aead, dst: dst}, nil
a: aead,
dst: dst,
}
w.unwritten = w.buf[:0]
return w, nil
} }
func (w *Writer) Write(p []byte) (n int, err error) { func (w *EncryptWriter) Write(p []byte) (n int, err error) {
// TODO: consider refactoring with a bytes.Buffer.
if w.err != nil { if w.err != nil {
return 0, w.err return 0, w.err
} }
@@ -180,12 +206,13 @@ func (w *Writer) Write(p []byte) (n int, err error) {
total := len(p) total := len(p)
for len(p) > 0 { for len(p) > 0 {
freeBuf := w.buf[len(w.unwritten):ChunkSize] n := min(len(p), ChunkSize-w.buf.Len())
n := copy(freeBuf, p) w.buf.Write(p[:n])
p = p[n:] p = p[n:]
w.unwritten = w.unwritten[:len(w.unwritten)+n]
if len(w.unwritten) == ChunkSize && len(p) > 0 { // Only flush if there's a full chunk with bytes still to write, or we
// can't know if this is the last chunk yet.
if w.buf.Len() == ChunkSize && len(p) > 0 {
if err := w.flushChunk(notLastChunk); err != nil { if err := w.flushChunk(notLastChunk); err != nil {
w.err = err w.err = err
return 0, err return 0, err
@@ -196,7 +223,7 @@ func (w *Writer) Write(p []byte) (n int, err error) {
} }
// Close flushes the last chunk. It does not close the underlying Writer. // Close flushes the last chunk. It does not close the underlying Writer.
func (w *Writer) Close() error { func (w *EncryptWriter) Close() error {
if w.err != nil { if w.err != nil {
return w.err return w.err
} }
@@ -215,17 +242,221 @@ const (
notLastChunk = false notLastChunk = false
) )
func (w *Writer) flushChunk(last bool) error { func (w *EncryptWriter) flushChunk(last bool) error {
if !last && len(w.unwritten) != ChunkSize { if !last && w.buf.Len() != ChunkSize {
panic("stream: internal error: flush called with partial chunk") panic("stream: internal error: flush called with partial chunk")
} }
if last { if last {
setLastChunkFlag(&w.nonce) setLastChunkFlag(&w.nonce)
} }
buf := w.a.Seal(w.buf[:0], w.nonce[:], w.unwritten, nil) w.buf.Grow(chacha20poly1305.Overhead)
_, err := w.dst.Write(buf) ciphertext := w.a.Seal(w.buf.Bytes()[:0], w.nonce[:], w.buf.Bytes(), nil)
w.unwritten = w.buf[:0] _, err := w.dst.Write(ciphertext)
incNonce(&w.nonce) incNonce(&w.nonce)
w.buf.Reset()
return err return err
} }
type EncryptReader struct {
a cipher.AEAD
src io.Reader
// The first ready bytes of buf are already encrypted. This may be less than
// buf.Len(), because we need to over-read to know if a chunk is the last.
ready int
buf bytes.Buffer
nonce [chacha20poly1305.NonceSize]byte
err error
}
func NewEncryptReader(key []byte, src io.Reader) (*EncryptReader, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
return &EncryptReader{a: aead, src: src}, nil
}
func (r *EncryptReader) Read(p []byte) (int, error) {
if r.ready > 0 {
n, err := r.buf.Read(p[:min(len(p), r.ready)])
r.ready -= n
return n, err
}
if r.err != nil {
return 0, r.err
}
if len(p) == 0 {
return 0, nil
}
if err := r.feedBuffer(); err != nil {
r.err = err
return 0, err
}
n, err := r.buf.Read(p[:min(len(p), r.ready)])
r.ready -= n
return n, err
}
// feedBuffer reads and encrypts the next chunk from r.src and appends it to
// r.buf. It sets r.ready to the number of newly available bytes in r.buf.
func (r *EncryptReader) feedBuffer() error {
if r.ready > 0 {
panic("stream: internal error: feedBuffer called with dirty buffer")
}
// CopyN will use r.buf.ReadFrom/WriteTo to fill the buffer directly.
// We need ChunkSize + 1 bytes to determine if this is the last chunk.
_, err := io.CopyN(&r.buf, r.src, int64(ChunkSize-r.buf.Len()+1))
if err != nil && err != io.EOF {
return err
}
if last := r.buf.Len() <= ChunkSize; last {
setLastChunkFlag(&r.nonce)
// After Grow, we know r.buf.Bytes() has enough capacity for the
// overhead. We encrypt in place and then do a Write to include the
// overhead in the buffer.
r.buf.Grow(chacha20poly1305.Overhead)
plaintext := r.buf.Bytes()
r.a.Seal(plaintext[:0], r.nonce[:], plaintext, nil)
incNonce(&r.nonce)
r.buf.Write(plaintext[len(plaintext) : len(plaintext)+chacha20poly1305.Overhead])
r.ready = r.buf.Len()
r.err = io.EOF
return nil
}
// Same, but accounting for the tail byte which will remain unencrypted and
// needs to be shifted past the overhead.
if r.buf.Len() != ChunkSize+1 {
panic("stream: internal error: unexpected buffer length")
}
tailByte := r.buf.Bytes()[ChunkSize]
r.buf.Grow(chacha20poly1305.Overhead)
plaintext := r.buf.Bytes()[:ChunkSize]
r.a.Seal(plaintext[:0], r.nonce[:], plaintext, nil)
incNonce(&r.nonce)
r.buf.Write(plaintext[len(plaintext)+1 : len(plaintext)+chacha20poly1305.Overhead])
r.buf.WriteByte(tailByte)
r.ready = ChunkSize + chacha20poly1305.Overhead
return nil
}
type DecryptReaderAt struct {
a cipher.AEAD
src io.ReaderAt
size int64
chunks int64
cache atomic.Pointer[cachedChunk]
}
type cachedChunk struct {
off int64
data []byte
}
func readFullAt(r io.ReaderAt, p []byte, off int64) error {
n, err := r.ReadAt(p, off)
switch {
case n == len(p) && errors.Is(err, io.EOF):
// io.ReaderAt implementations are allowed to return io.EOF when the
// read reaches the end of the source.
err = nil
case n < len(p) && (err == nil || errors.Is(err, io.EOF)):
// Incorrect io.ReaderAt implementation, or the source is short.
err = io.ErrUnexpectedEOF
}
return err
}
func NewDecryptReaderAt(key []byte, src io.ReaderAt, size int64) (*DecryptReaderAt, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
// Check that size is valid by decrypting the final chunk.
chunks, err := EncryptedChunkCount(size)
if err != nil {
return nil, err
}
finalChunkIndex := chunks - 1
finalChunkOff := finalChunkIndex * encChunkSize
finalChunkSize := size - finalChunkOff
finalChunk := make([]byte, finalChunkSize)
if err := readFullAt(src, finalChunk, finalChunkOff); err != nil {
return nil, fmt.Errorf("failed to read final chunk: %w", err)
}
nonce := nonceForChunk(finalChunkIndex)
setLastChunkFlag(nonce)
plaintext, err := aead.Open(finalChunk[:0], nonce[:], finalChunk, nil)
if err != nil {
return nil, fmt.Errorf("failed to decrypt and authenticate final chunk: %w", err)
}
cache := &cachedChunk{off: finalChunkOff, data: plaintext}
plaintextSize := size - chunks*chacha20poly1305.Overhead
r := &DecryptReaderAt{a: aead, src: src, size: plaintextSize, chunks: chunks}
r.cache.Store(cache)
return r, nil
}
func (r *DecryptReaderAt) ReadAt(p []byte, off int64) (n int, err error) {
if off < 0 || off > r.size {
return 0, fmt.Errorf("offset out of range [0:%d]: %d", r.size, off)
}
if len(p) == 0 {
return 0, nil
}
var cacheUpdate *cachedChunk
chunk := make([]byte, encChunkSize)
for len(p) > 0 && off < r.size {
chunkIndex := off / ChunkSize
chunkOff := chunkIndex * encChunkSize
encSize := r.size + r.chunks*chacha20poly1305.Overhead
chunkSize := min(encSize-chunkOff, encChunkSize)
cached := r.cache.Load()
var plaintext []byte
if cached != nil && cached.off == chunkOff {
plaintext = cached.data
cacheUpdate = nil
} else {
if err := readFullAt(r.src, chunk[:chunkSize], chunkOff); err != nil {
return n, fmt.Errorf("failed to read chunk at offset %d: %w", chunkOff, err)
}
nonce := nonceForChunk(chunkIndex)
if chunkIndex == r.chunks-1 {
setLastChunkFlag(nonce)
}
var err error
plaintext, err = r.a.Open(chunk[:0], nonce[:], chunk[:chunkSize], nil)
if err != nil {
return n, fmt.Errorf("failed to decrypt and authenticate chunk at offset %d: %w", chunkOff, err)
}
cacheUpdate = &cachedChunk{off: chunkOff, data: plaintext}
}
plainChunkOff := int(off - chunkIndex*ChunkSize)
copySize := min(len(plaintext)-plainChunkOff, len(p))
copy(p, plaintext[plainChunkOff:plainChunkOff+copySize])
p = p[copySize:]
off += int64(copySize)
n += copySize
}
if cacheUpdate != nil {
r.cache.Store(cacheUpdate)
}
if off == r.size {
return n, io.EOF
}
return n, nil
}
+854 -45
View File
@@ -7,8 +7,12 @@ package stream_test
import ( import (
"bytes" "bytes"
"crypto/rand" "crypto/rand"
"errors"
"fmt" "fmt"
"io"
"math"
"testing" "testing"
"testing/iotest"
"filippo.io/age/internal/stream" "filippo.io/age/internal/stream"
"golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/chacha20poly1305"
@@ -16,78 +20,883 @@ import (
const cs = stream.ChunkSize const cs = stream.ChunkSize
// encrypt encrypts plaintext with key and returns the ciphertext.
func encrypt(t *testing.T, key, plaintext []byte) []byte {
t.Helper()
buf := &bytes.Buffer{}
w, err := stream.NewEncryptWriter(key, buf)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(plaintext); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func TestRoundTrip(t *testing.T) { func TestRoundTrip(t *testing.T) {
for _, stepSize := range []int{512, 600, 1000, cs} { for _, length := range []int{0, 1000, cs - 1, cs, cs + 1, cs + 100, 2 * cs, 2*cs + 500} {
for _, length := range []int{0, 1000, cs, cs + 100} { for _, stepSize := range []int{512, 600, 1000, cs - 1, cs, cs + 1} {
t.Run(fmt.Sprintf("len=%d,step=%d", length, stepSize), t.Run(fmt.Sprintf("len=%d,step=%d", length, stepSize), func(t *testing.T) {
func(t *testing.T) { testRoundTrip(t, stepSize, length) }) testRoundTrip(t, stepSize, length)
})
} }
} }
length, stepSize := 2*cs+500, 1
t.Run(fmt.Sprintf("len=%d,step=%d", length, stepSize), func(t *testing.T) {
testRoundTrip(t, stepSize, length)
})
} }
func testRoundTrip(t *testing.T, stepSize, length int) { func testRoundTrip(t *testing.T, stepSize, length int) {
src := make([]byte, length) src := make([]byte, length)
if _, err := rand.Read(src); err != nil { rand.Read(src)
t.Fatal(err)
}
buf := &bytes.Buffer{}
key := make([]byte, chacha20poly1305.KeySize) key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil { rand.Read(key)
t.Fatal(err) var ciphertext []byte
}
w, err := stream.NewWriter(key, buf) t.Run("EncryptWriter", func(t *testing.T) {
if err != nil { buf := &bytes.Buffer{}
t.Fatal(err) w, err := stream.NewEncryptWriter(key, buf)
}
var n int
for n < length {
b := length - n
if b > stepSize {
b = stepSize
}
nn, err := w.Write(src[n : n+b])
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if nn != b {
t.Errorf("Write returned %d, expected %d", nn, b)
}
n += nn
nn, err = w.Write(src[n:n]) var n int
for n < length {
b := min(length-n, stepSize)
nn, err := w.Write(src[n : n+b])
if err != nil {
t.Fatal(err)
}
if nn != b {
t.Errorf("Write returned %d, expected %d", nn, b)
}
n += nn
nn, err = w.Write(src[n:n])
if err != nil {
t.Fatal(err)
}
if nn != 0 {
t.Errorf("Write returned %d, expected 0", nn)
}
}
if err := w.Close(); err != nil {
t.Error("Close returned an error:", err)
}
ciphertext = buf.Bytes()
})
t.Run("DecryptReader", func(t *testing.T) {
r, err := stream.NewDecryptReader(key, bytes.NewReader(ciphertext))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if nn != 0 {
t.Errorf("Write returned %d, expected 0", nn) var n int
readBuf := make([]byte, stepSize)
for n < length {
nn, err := r.Read(readBuf)
if err != nil {
t.Fatalf("Read error at index %d: %v", n, err)
}
if !bytes.Equal(readBuf[:nn], src[n:n+nn]) {
t.Errorf("wrong data at indexes %d - %d", n, n+nn)
}
n += nn
} }
}
if err := w.Close(); err != nil { t.Run("TestReader", func(t *testing.T) {
t.Error("Close returned an error:", err) if length > 1000 && testing.Short() {
} t.Skip("skipping slow iotest.TestReader on long input")
}
r, _ := stream.NewDecryptReader(key, bytes.NewReader(ciphertext))
if err := iotest.TestReader(r, src); err != nil {
t.Error("iotest.TestReader error on DecryptReader:", err)
}
})
})
t.Logf("buffer size: %d", buf.Len()) t.Run("DecryptReaderAt", func(t *testing.T) {
rAt, err := stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)))
if err != nil {
t.Fatal(err)
}
rr := io.NewSectionReader(rAt, 0, int64(len(ciphertext)))
r, err := stream.NewReader(key, buf) var n int
readBuf := make([]byte, stepSize)
for n < length {
nn, err := rr.Read(readBuf)
if n+nn == length && err == io.EOF {
err = nil
}
if err != nil {
t.Fatalf("ReadAt error at index %d: %v", n, err)
}
if !bytes.Equal(readBuf[:nn], src[n:n+nn]) {
t.Errorf("wrong data at indexes %d - %d", n, n+nn)
}
n += nn
}
t.Run("TestReader", func(t *testing.T) {
if length > 1000 && testing.Short() {
t.Skip("skipping slow iotest.TestReader on long input")
}
rr := io.NewSectionReader(rAt, 0, int64(len(src)))
if err := iotest.TestReader(rr, src); err != nil {
t.Error("iotest.TestReader error on DecryptReaderAt:", err)
}
})
})
t.Run("EncryptReader", func(t *testing.T) {
er, err := stream.NewEncryptReader(key, bytes.NewReader(src))
if err != nil {
t.Fatal(err)
}
var n int
readBuf := make([]byte, stepSize)
for {
nn, err := er.Read(readBuf)
if nn == 0 && err == io.EOF {
break
} else if err != nil {
t.Fatalf("EncryptReader Read error at index %d: %v", n, err)
}
if !bytes.Equal(readBuf[:nn], ciphertext[n:n+nn]) {
t.Errorf("EncryptReader wrong data at indexes %d - %d", n, n+nn)
}
n += nn
}
if n != len(ciphertext) {
t.Errorf("EncryptReader read %d bytes, expected %d", n, len(ciphertext))
}
t.Run("TestReader", func(t *testing.T) {
if length > 1000 && testing.Short() {
t.Skip("skipping slow iotest.TestReader on long input")
}
er, _ := stream.NewEncryptReader(key, bytes.NewReader(src))
if err := iotest.TestReader(er, ciphertext); err != nil {
t.Error("iotest.TestReader error on EncryptReader:", err)
}
})
})
}
// trackingReaderAt wraps an io.ReaderAt and tracks whether ReadAt was called.
type trackingReaderAt struct {
r io.ReaderAt
called bool
}
func (t *trackingReaderAt) ReadAt(p []byte, off int64) (int, error) {
t.called = true
return t.r.ReadAt(p, off)
}
func (t *trackingReaderAt) reset() {
t.called = false
}
func TestDecryptReaderAt(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
rand.Read(key)
// Create plaintext spanning exactly 3 chunks: 2 full chunks + partial third
// Chunk 0: [0, cs)
// Chunk 1: [cs, 2*cs)
// Chunk 2: [2*cs, 2*cs+500)
plaintextSize := 2*cs + 500
plaintext := make([]byte, plaintextSize)
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
// Create tracking ReaderAt
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
// Create DecryptReaderAt (this reads and caches the final chunk)
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
tracker.reset()
n = 0 // Helper to check reads
readBuf := make([]byte, stepSize) checkRead := func(name string, off int64, size int, wantN int, wantEOF bool, wantSrcRead bool) {
for n < length { t.Helper()
nn, err := r.Read(readBuf) tracker.reset()
if err != nil { p := make([]byte, size)
t.Fatalf("Read error at index %d: %v", n, err) n, err := ra.ReadAt(p, off)
if wantEOF {
if err != io.EOF {
t.Errorf("%s: got err=%v, want EOF", name, err)
}
} else {
if err != nil {
t.Errorf("%s: got err=%v, want nil", name, err)
}
} }
if !bytes.Equal(readBuf[:nn], src[n:n+nn]) { if n != wantN {
t.Errorf("wrong data at indexes %d - %d", n, n+nn) t.Errorf("%s: got n=%d, want %d", name, n, wantN)
} }
n += nn if tracker.called != wantSrcRead {
t.Errorf("%s: src.ReadAt called=%v, want %v", name, tracker.called, wantSrcRead)
}
// Verify data correctness
if n > 0 && off >= 0 && off < int64(plaintextSize) {
end := min(int(off)+n, plaintextSize)
if !bytes.Equal(p[:n], plaintext[off:end]) {
t.Errorf("%s: data mismatch", name)
}
}
}
// Test 1: Read from final chunk (cached by constructor)
checkRead("final chunk (cached)", int64(2*cs+100), 100, 100, false, false)
// Test 2: Read spanning second and third chunk
checkRead("span chunks 1-2", int64(cs+cs-50), 100, 100, false, true)
// Test 3: Read from final chunk again (cached from test 2)
// When reading across chunks 1-2 in test 2, the loop processes chunk 1 then chunk 2,
// so chunk 2 ends up in the cache.
checkRead("final chunk after span", int64(2*cs+200), 100, 100, false, false)
// Test 4: Read from final chunk again (now cached)
checkRead("final chunk (cached again)", int64(2*cs+50), 50, 50, false, false)
// Test 5: Read from first chunk (not cached)
checkRead("first chunk", 0, 100, 100, false, true)
// Test 6: Read from first chunk again (now cached)
checkRead("first chunk (cached)", 50, 100, 100, false, false)
// Test 7: Read spanning all chunks
tracker.reset()
p := make([]byte, plaintextSize)
n, err := ra.ReadAt(p, 0)
if err != io.EOF {
t.Errorf("span all: got err=%v, want EOF", err)
}
if n != plaintextSize {
t.Errorf("span all: got n=%d, want %d", n, plaintextSize)
}
if !bytes.Equal(p, plaintext) {
t.Errorf("span all: data mismatch")
}
// Test 8: Read beyond the end (offset > size)
tracker.reset()
p = make([]byte, 100)
n, err = ra.ReadAt(p, int64(plaintextSize+100))
if err == nil {
t.Error("beyond end: expected error, got nil")
}
if n != 0 {
t.Errorf("beyond end: got n=%d, want 0", n)
}
// Test 9: Read with off = size (should return 0, EOF)
tracker.reset()
p = make([]byte, 100)
n, err = ra.ReadAt(p, int64(plaintextSize))
if err != io.EOF {
t.Errorf("off=size: got err=%v, want EOF", err)
}
if n != 0 {
t.Errorf("off=size: got n=%d, want 0", n)
}
// Test 10: Read spanning last chunk and beyond
tracker.reset()
p = make([]byte, 1000) // request more than available
n, err = ra.ReadAt(p, int64(2*cs+400))
if err != io.EOF {
t.Errorf("span last+beyond: got err=%v, want EOF", err)
}
wantN := 500 - 400 // only 100 bytes available from offset 2*cs+400
if n != wantN {
t.Errorf("span last+beyond: got n=%d, want %d", n, wantN)
}
if !bytes.Equal(p[:n], plaintext[2*cs+400:]) {
t.Error("span last+beyond: data mismatch")
}
// Test 11: Read spanning second+last chunk and beyond
tracker.reset()
p = make([]byte, cs+1000) // request more than available
n, err = ra.ReadAt(p, int64(cs+100))
if err != io.EOF {
t.Errorf("span 1-2+beyond: got err=%v, want EOF", err)
}
wantN = plaintextSize - (cs + 100)
if n != wantN {
t.Errorf("span 1-2+beyond: got n=%d, want %d", n, wantN)
}
if !bytes.Equal(p[:n], plaintext[cs+100:]) {
t.Error("span 1-2+beyond: data mismatch")
}
// Test 12: Negative offset
tracker.reset()
p = make([]byte, 100)
n, err = ra.ReadAt(p, -1)
if err == nil {
t.Error("negative offset: expected error, got nil")
}
if n != 0 {
t.Errorf("negative offset: got n=%d, want 0", n)
}
// Test 13: Zero-length read in the middle
tracker.reset()
p = make([]byte, 0)
n, err = ra.ReadAt(p, 100)
if err != nil {
t.Errorf("zero-length middle: got err=%v, want nil", err)
}
if n != 0 {
t.Errorf("zero-length middle: got n=%d, want 0", n)
}
// Test 14: Zero-length read at end
tracker.reset()
p = make([]byte, 0)
n, err = ra.ReadAt(p, int64(plaintextSize))
if err != nil {
t.Errorf("zero-length end: got err=%v, want nil", err)
}
if n != 0 {
t.Errorf("zero-length end: got n=%d, want 0", n)
}
// Test 15: Read exactly one chunk at chunk boundary
checkRead("exact chunk at boundary", int64(cs), cs, cs, false, true)
// Test 16: Read one byte at each chunk boundary
checkRead("one byte at start", 0, 1, 1, false, true)
checkRead("one byte at cs-1", int64(cs-1), 1, 1, false, false) // cached from test 15
checkRead("one byte at cs", int64(cs), 1, 1, false, true)
checkRead("one byte at 2*cs-1", int64(2*cs-1), 1, 1, false, false) // same chunk
checkRead("one byte at 2*cs", int64(2*cs), 1, 1, false, true)
checkRead("last byte", int64(plaintextSize-1), 1, 1, true, false) // same chunk, EOF because we reach end
// Test 17: Read crossing exactly one chunk boundary
checkRead("cross boundary 0-1", int64(cs-50), 100, 100, false, true)
checkRead("cross boundary 1-2", int64(2*cs-50), 100, 100, false, true)
}
// eofReaderAt wraps a bytes.Reader and forces it to return io.EOF alongside a
// read that successfully reaches the exact end of the data.
type eofReaderAt struct {
r *bytes.Reader
}
func (e *eofReaderAt) ReadAt(p []byte, off int64) (int, error) {
n, err := e.r.ReadAt(p, off)
if err == nil && off+int64(n) == e.r.Size() {
err = io.EOF
}
return n, err
}
func TestDecryptReaderAtEOF(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
rand.Read(key)
// Multiple chunks, so that reads reach the final chunk both in the
// constructor and through ReadAt.
plaintext := make([]byte, 2*cs+100)
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
// An io.ReaderAt is allowed to return io.EOF alongside a successful read
// that reaches the end of the source; both the constructor (which reads
// the final chunk eagerly) and ReadAt must tolerate it.
er := &eofReaderAt{r: bytes.NewReader(ciphertext)}
ra, err := stream.NewDecryptReaderAt(key, er, int64(len(ciphertext)))
if err != nil {
t.Fatalf("NewDecryptReaderAt failed on EOF: %v", err)
}
// Sequential small reads evict the final chunk from the cache, so it is
// read again through ReadAt, exercising its own EOF handling.
got, err := io.ReadAll(io.NewSectionReader(ra, 0, int64(len(plaintext))))
if err != nil {
t.Fatalf("ReadAll failed on EOF: %v", err)
}
if !bytes.Equal(got, plaintext) {
t.Error("plaintext mismatch")
}
}
// TestDecryptReaderTrailingData checks that data appended after a full-length
// final chunk is rejected, including when the source returns the trailing byte
// alongside io.EOF as permitted by io.Reader.
func TestDecryptReaderTrailingData(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
rand.Read(key)
// A plaintext of exactly one full chunk, so the trailing data can only be
// detected by reading past the final chunk.
plaintext := make([]byte, cs)
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
trailing := append(bytes.Clone(ciphertext), 0x42)
wrappers := map[string]func(io.Reader) io.Reader{
"plain": func(r io.Reader) io.Reader { return r },
"dataErr": iotest.DataErrReader,
}
for name, wrap := range wrappers {
t.Run(name, func(t *testing.T) {
r, err := stream.NewDecryptReader(key, wrap(bytes.NewReader(trailing)))
if err != nil {
t.Fatal(err)
}
if _, err := io.ReadAll(r); err == nil {
t.Error("trailing data: expected error, got nil")
}
r, err = stream.NewDecryptReader(key, wrap(bytes.NewReader(ciphertext)))
if err != nil {
t.Fatal(err)
}
got, err := io.ReadAll(r)
if err != nil {
t.Errorf("valid file: got err=%v, want nil", err)
}
if !bytes.Equal(got, plaintext) {
t.Error("valid file: plaintext mismatch")
}
})
}
}
func TestDecryptReaderAtEmpty(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
rand.Read(key)
// Create empty encrypted file
ciphertext := encrypt(t, key, nil)
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
if err != nil {
t.Fatal(err)
}
tracker.reset()
// Test 1: Read from empty file at offset 0
p := make([]byte, 100)
n, err := ra.ReadAt(p, 0)
if err != io.EOF {
t.Errorf("empty read: got err=%v, want EOF", err)
}
if n != 0 {
t.Errorf("empty read: got n=%d, want 0", n)
}
// Test 2: Zero-length read from empty file
p = make([]byte, 0)
n, err = ra.ReadAt(p, 0)
if err != nil {
t.Errorf("empty zero-length: got err=%v, want nil", err)
}
if n != 0 {
t.Errorf("empty zero-length: got n=%d, want 0", n)
}
// Test 3: Read beyond empty file
p = make([]byte, 100)
n, err = ra.ReadAt(p, 1)
if err == nil {
t.Error("empty beyond: expected error, got nil")
}
if n != 0 {
t.Errorf("empty beyond: got n=%d, want 0", n)
}
}
func TestDecryptReaderAtSingleChunk(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
rand.Read(key)
// Single chunk, not full
plaintext := make([]byte, 1000)
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
if err != nil {
t.Fatal(err)
}
tracker.reset()
// All reads should use cache (final chunk = only chunk)
p := make([]byte, 100)
n, err := ra.ReadAt(p, 0)
if err != nil {
t.Errorf("single chunk start: got err=%v, want nil", err)
}
if n != 100 {
t.Errorf("single chunk start: got n=%d, want 100", n)
}
if tracker.called {
t.Error("single chunk start: unexpected src.ReadAt call")
}
if !bytes.Equal(p[:n], plaintext[:100]) {
t.Error("single chunk start: data mismatch")
}
// Read at end
n, err = ra.ReadAt(p, 900)
if err != io.EOF {
t.Errorf("single chunk end: got err=%v, want EOF", err)
}
if n != 100 {
t.Errorf("single chunk end: got n=%d, want 100", n)
}
if tracker.called {
t.Error("single chunk end: unexpected src.ReadAt call")
}
if !bytes.Equal(p[:n], plaintext[900:]) {
t.Error("single chunk end: data mismatch")
}
}
func TestDecryptReaderAtFullChunks(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
rand.Read(key)
// Exactly 2 full chunks
plaintext := make([]byte, 2*cs)
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
if err != nil {
t.Fatal(err)
}
tracker.reset()
// Read last byte of second chunk (cached)
p := make([]byte, 1)
n, err := ra.ReadAt(p, int64(2*cs-1))
if err != io.EOF {
t.Errorf("last byte: got err=%v, want EOF", err)
}
if n != 1 {
t.Errorf("last byte: got n=%d, want 1", n)
}
if tracker.called {
t.Error("last byte: unexpected src.ReadAt call (should be cached)")
}
if p[0] != plaintext[2*cs-1] {
t.Error("last byte: data mismatch")
}
// Read at exactly the boundary between chunks
p = make([]byte, 100)
n, err = ra.ReadAt(p, int64(cs-50))
if err != nil {
t.Errorf("boundary: got err=%v, want nil", err)
}
if n != 100 {
t.Errorf("boundary: got n=%d, want 100", n)
}
if !bytes.Equal(p, plaintext[cs-50:cs+50]) {
t.Error("boundary: data mismatch")
}
}
func TestDecryptReaderAtWrongKey(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
rand.Read(key)
plaintext := make([]byte, 1000)
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
// Try to decrypt with wrong key
wrongKey := make([]byte, chacha20poly1305.KeySize)
rand.Read(wrongKey)
_, err := stream.NewDecryptReaderAt(wrongKey, bytes.NewReader(ciphertext), int64(len(ciphertext)))
if err == nil {
t.Error("wrong key: expected error, got nil")
}
}
func TestDecryptReaderAtInvalidSize(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
rand.Read(key)
plaintext := make([]byte, 1000)
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
// Wrong size (too small)
_, err := stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)-1))
if err == nil {
t.Error("wrong size (small): expected error, got nil")
}
// Wrong size (too large)
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)+1))
if err == nil {
t.Error("wrong size (large): expected error, got nil")
}
// Size that would imply empty final chunk (invalid)
// This would be: one full encrypted chunk + just overhead
invalidSize := int64(cs + chacha20poly1305.Overhead + chacha20poly1305.Overhead)
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(make([]byte, invalidSize)), invalidSize)
if err == nil {
t.Error("invalid size (empty final chunk): expected error, got nil")
}
// Negative sizes and sizes that would overflow the chunk count computation
// must be rejected with an error rather than a panic.
for _, size := range []int64{-1, -70000, math.MaxInt64, math.MaxInt64 - 200, math.MaxInt64 - 65550} {
if _, err := stream.EncryptedChunkCount(size); err == nil {
t.Errorf("EncryptedChunkCount(%d): expected error, got nil", size)
}
if _, err := stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), size); err == nil {
t.Errorf("NewDecryptReaderAt(%d): expected error, got nil", size)
}
}
}
func TestDecryptReaderAtTruncated(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
rand.Read(key)
plaintext := make([]byte, 2*cs+500)
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
// Truncate ciphertext but lie about size. The source ending before the
// expected end of the final chunk must be reported as an unexpected EOF,
// not as a decryption failure.
truncated := ciphertext[:len(ciphertext)-100]
_, err := stream.NewDecryptReaderAt(key, bytes.NewReader(truncated), int64(len(ciphertext)))
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Errorf("truncated: got err=%v, want io.ErrUnexpectedEOF", err)
}
}
func TestDecryptReaderAtTruncatedChunk(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
rand.Read(key)
// Create 4 chunks: 3 full + 1 partial
plaintext := make([]byte, 3*cs+500)
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
// Truncate to 3 chunks (remove the actual final chunk)
// The third chunk was NOT encrypted with the last chunk flag,
// so decryption should fail when we try to use it as the final chunk.
encChunkSize := cs + 16 // ChunkSize + Overhead
truncatedSize := int64(3 * encChunkSize)
truncated := ciphertext[:truncatedSize]
_, err := stream.NewDecryptReaderAt(key, bytes.NewReader(truncated), truncatedSize)
if err == nil {
t.Error("truncated at chunk boundary: expected error, got nil")
}
}
func TestDecryptReaderAtConcurrent(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
rand.Read(key)
// Create plaintext spanning 3 chunks: 2 full + partial
plaintextSize := 2*cs + 500
plaintext := make([]byte, plaintextSize)
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
ra, err := stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)))
if err != nil {
t.Fatal(err)
}
t.Run("same chunk", func(t *testing.T) {
t.Parallel()
const goroutines = 10
const iterations = 100
errc := make(chan error, goroutines)
for g := range goroutines {
go func(id int) {
for i := range iterations {
off := int64((id*iterations + i) % 500)
p := make([]byte, 100)
n, err := ra.ReadAt(p, off)
if err != nil {
errc <- fmt.Errorf("goroutine %d iter %d: %v", id, i, err)
return
}
if n != 100 {
errc <- fmt.Errorf("goroutine %d iter %d: n=%d, want 100", id, i, n)
return
}
if !bytes.Equal(p, plaintext[off:off+100]) {
errc <- fmt.Errorf("goroutine %d iter %d: data mismatch", id, i)
return
}
}
errc <- nil
}(g)
}
for range goroutines {
if err := <-errc; err != nil {
t.Error(err)
}
}
})
t.Run("different chunks", func(t *testing.T) {
t.Parallel()
const goroutines = 10
const iterations = 100
errc := make(chan error, goroutines)
for g := range goroutines {
go func(id int) {
for i := range iterations {
// Each goroutine reads from a different chunk based on id
chunkIdx := id % 3
off := int64(chunkIdx*cs + (i % 400))
size := 100
if off+int64(size) > int64(plaintextSize) {
size = plaintextSize - int(off)
}
p := make([]byte, size)
n, err := ra.ReadAt(p, off)
if n == size && err == io.EOF {
err = nil // EOF at end is acceptable
}
if err != nil {
errc <- fmt.Errorf("goroutine %d iter %d: off=%d: %v", id, i, off, err)
return
}
if n != size {
errc <- fmt.Errorf("goroutine %d iter %d: n=%d, want %d", id, i, n, size)
return
}
if !bytes.Equal(p[:n], plaintext[off:off+int64(n)]) {
errc <- fmt.Errorf("goroutine %d iter %d: data mismatch", id, i)
return
}
}
errc <- nil
}(g)
}
for range goroutines {
if err := <-errc; err != nil {
t.Error(err)
}
}
})
t.Run("across chunks", func(t *testing.T) {
t.Parallel()
const goroutines = 10
const iterations = 100
errc := make(chan error, goroutines)
for g := range goroutines {
go func(id int) {
for i := range iterations {
// Read across chunk boundaries
boundary := (id%2 + 1) * cs // either cs or 2*cs
off := int64(boundary - 50 + (i % 30))
size := 100
if off+int64(size) > int64(plaintextSize) {
size = plaintextSize - int(off)
}
if size <= 0 {
continue
}
p := make([]byte, size)
n, err := ra.ReadAt(p, off)
if n == size && err == io.EOF {
err = nil
}
if err != nil {
errc <- fmt.Errorf("goroutine %d iter %d: off=%d size=%d: %v", id, i, off, size, err)
return
}
if n != size {
errc <- fmt.Errorf("goroutine %d iter %d: n=%d, want %d", id, i, n, size)
return
}
if !bytes.Equal(p[:n], plaintext[off:off+int64(n)]) {
errc <- fmt.Errorf("goroutine %d iter %d: data mismatch", id, i)
return
}
}
errc <- nil
}(g)
}
for range goroutines {
if err := <-errc; err != nil {
t.Error(err)
}
}
})
}
func TestDecryptReaderAtCorrupted(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
rand.Read(key)
plaintext := make([]byte, 2*cs+500)
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
// Corrupt final chunk - should fail in constructor
corruptedFinal := bytes.Clone(ciphertext)
corruptedFinal[len(corruptedFinal)-10] ^= 0xFF
_, err := stream.NewDecryptReaderAt(key, bytes.NewReader(corruptedFinal), int64(len(corruptedFinal)))
if err == nil {
t.Error("corrupted final: expected error, got nil")
}
// Corrupt first chunk - should fail on read
corruptedFirst := bytes.Clone(ciphertext)
corruptedFirst[10] ^= 0xFF
ra, err := stream.NewDecryptReaderAt(key, bytes.NewReader(corruptedFirst), int64(len(corruptedFirst)))
if err != nil {
t.Fatalf("corrupted first constructor: unexpected error: %v", err)
}
p := make([]byte, 100)
_, err = ra.ReadAt(p, 0)
if err == nil {
t.Error("corrupted first read: expected error, got nil")
} }
} }
+133
View File
@@ -0,0 +1,133 @@
package term
import (
"fmt"
"os"
"runtime"
"strings"
"unicode"
"golang.org/x/term"
)
// enableVirtualTerminalProcessing tries to enable virtual terminal processing
// on Windows. If it fails, avoid using escape sequences to prevent weird
// characters being printed to the console.
var enableVirtualTerminalProcessing func(out *os.File) error
func printPrompt(out *os.File, prompt string) {
fmt.Fprintf(out, "%s ", strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return '\uFFFD'
}
return r
}, prompt))
}
// clearLine clears the current line on the terminal, or opens a new line if
// terminal escape codes don't work.
func clearLine(out *os.File) {
const (
CUI = "\033[" // Control Sequence Introducer
CPL = CUI + "F" // Cursor Previous Line
EL = CUI + "K" // Erase in Line
)
// First, open a new line, which is guaranteed to work everywhere. Then, try
// to erase the line above with escape codes, if possible.
//
// (We use CRLF instead of LF to work around an apparent bug in WSL2's
// handling of CONOUT$. Only when running a Windows binary from WSL2, the
// cursor would not go back to the start of the line with a simple LF.
// Honestly, it's impressive CONIN$ and CONOUT$ work at all inside WSL2.)
fmt.Fprintf(out, "\r\n")
if enableVirtualTerminalProcessing == nil || enableVirtualTerminalProcessing(out) == nil {
fmt.Fprintf(out, CPL+EL)
}
}
// WithTerminal runs f with the terminal input and output files, if available.
// WithTerminal does not open a non-terminal stdin, so the caller does not need
// to check if stdin is in use.
func WithTerminal(f func(in, out *os.File) error) error {
if runtime.GOOS == "windows" {
in, err := os.OpenFile("CONIN$", os.O_RDWR, 0)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0)
if err != nil {
return err
}
defer out.Close()
return f(in, out)
} else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil {
defer tty.Close()
return f(tty, tty)
} else if term.IsTerminal(int(os.Stdin.Fd())) {
return f(os.Stdin, os.Stdin)
} else {
return fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err)
}
}
// ReadSecret reads a value from the terminal with no echo. The prompt is ephemeral.
func ReadSecret(prompt string) (s []byte, err error) {
err = WithTerminal(func(in, out *os.File) error {
printPrompt(out, prompt)
defer clearLine(out)
s, err = term.ReadPassword(int(in.Fd()))
return err
})
return
}
// ReadPublic reads a value from the terminal. The prompt is ephemeral.
func ReadPublic(prompt string) (s []byte, err error) {
err = WithTerminal(func(in, out *os.File) error {
printPrompt(out, prompt)
defer clearLine(out)
oldState, err := term.MakeRaw(int(in.Fd()))
if err != nil {
return err
}
defer term.Restore(int(in.Fd()), oldState)
t := term.NewTerminal(in, "")
line, err := t.ReadLine()
s = []byte(line)
return err
})
return
}
// ReadCharacter reads a single character from the terminal with no echo. The
// prompt is ephemeral.
func ReadCharacter(prompt string) (c byte, err error) {
err = WithTerminal(func(in, out *os.File) error {
printPrompt(out, prompt)
defer clearLine(out)
oldState, err := term.MakeRaw(int(in.Fd()))
if err != nil {
return err
}
defer term.Restore(int(in.Fd()), oldState)
b := make([]byte, 1)
if _, err := in.Read(b); err != nil {
return err
}
c = b[0]
return nil
})
return
}
// IsTerminal returns whether the given file is a terminal.
func IsTerminal(f *os.File) bool {
return term.IsTerminal(int(f.Fd()))
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright 2022 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package term
import (
"errors"
"os"
"syscall"
"golang.org/x/sys/windows"
)
func init() {
enableVirtualTerminalProcessing = func(out *os.File) error {
// Some instances of the Windows Console (e.g., cmd.exe and Windows PowerShell)
// do not have the virtual terminal processing enabled, which is necessary to
// make terminal escape sequences work. For this reason the clearLine function
// may not properly work. Here we enable the virtual terminal processing, if
// possible.
//
// See https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences.
const (
ENABLE_PROCESSED_OUTPUT uint32 = 0x1
ENABLE_VIRTUAL_TERMINAL_PROCESSING uint32 = 0x4
)
kernel32DLL := windows.NewLazySystemDLL("Kernel32.dll")
setConsoleMode := kernel32DLL.NewProc("SetConsoleMode")
var mode uint32
if err := syscall.GetConsoleMode(syscall.Handle(out.Fd()), &mode); err != nil {
return err
}
mode |= ENABLE_PROCESSED_OUTPUT
mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING
// If the SetConsoleMode function fails, the return value is zero.
// See https://learn.microsoft.com/en-us/windows/console/setconsolemode#return-value.
if ret, _, _ := setConsoleMode.Call(out.Fd(), uintptr(mode)); ret == 0 {
return errors.New("SetConsoleMode failed")
}
return nil
}
}
+55 -15
View File
@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"io" "io"
"strings" "strings"
"unicode/utf8"
) )
// ParseIdentities parses a file with one or more private key encodings, one per // ParseIdentities parses a file with one or more private key encodings, one per
@@ -16,14 +17,15 @@ import (
// //
// This is the same syntax as the private key files accepted by the CLI, except // This is the same syntax as the private key files accepted by the CLI, except
// the CLI also accepts SSH private keys, which are not recommended for the // the CLI also accepts SSH private keys, which are not recommended for the
// average application. // average application, and plugins, which involve invoking external programs.
// //
// Currently, all returned values are of type *X25519Identity, but different // Currently, all returned values are of type *[X25519Identity] or
// types might be returned in the future. // *[HybridIdentity], but different types might be returned in the future.
func ParseIdentities(f io.Reader) ([]Identity, error) { func ParseIdentities(f io.Reader) ([]Identity, error) {
const privateKeySizeLimit = 1 << 24 // 16 MiB const privateKeySizeLimit = 1 << 24 // 16 MiB
var ids []Identity var ids []Identity
scanner := bufio.NewScanner(io.LimitReader(f, privateKeySizeLimit)) lr := &io.LimitedReader{R: f, N: privateKeySizeLimit + 1}
scanner := bufio.NewScanner(lr)
var n int var n int
for scanner.Scan() { for scanner.Scan() {
n++ n++
@@ -31,34 +33,55 @@ func ParseIdentities(f io.Reader) ([]Identity, error) {
if strings.HasPrefix(line, "#") || line == "" { if strings.HasPrefix(line, "#") || line == "" {
continue continue
} }
i, err := ParseX25519Identity(line) if !utf8.ValidString(line) {
return nil, fmt.Errorf("identities file is not valid UTF-8")
}
i, err := parseIdentity(line)
if err != nil { if err != nil {
return nil, fmt.Errorf("error at line %d: %v", n, err) return nil, fmt.Errorf("error at line %d: %v", n, err)
} }
ids = append(ids, i) ids = append(ids, i)
} }
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read secret keys file: %v", err) return nil, fmt.Errorf("failed to read identities file: %v", err)
}
if lr.N == 0 {
return nil, fmt.Errorf("identities file is too long")
} }
if len(ids) == 0 { if len(ids) == 0 {
return nil, fmt.Errorf("no secret keys found") return nil, fmt.Errorf("no identities found")
} }
return ids, nil return ids, nil
} }
func parseIdentity(arg string) (Identity, error) {
switch {
case strings.HasPrefix(arg, "AGE-SECRET-KEY-1"):
return ParseX25519Identity(arg)
case strings.HasPrefix(arg, "AGE-SECRET-KEY-PQ-1"):
return ParseHybridIdentity(arg)
default:
// Don't include arg in the error: it may contain private key material,
// and callers print these errors.
return nil, fmt.Errorf("unknown identity type")
}
}
// ParseRecipients parses a file with one or more public key encodings, one per // ParseRecipients parses a file with one or more public key encodings, one per
// line. Empty lines and lines starting with "#" are ignored. // line. Empty lines and lines starting with "#" are ignored.
// //
// This is the same syntax as the recipients files accepted by the CLI, except // This is the same syntax as the recipients files accepted by the CLI, except
// the CLI also accepts SSH recipients, which are not recommended for the // the CLI also accepts SSH recipients, which are not recommended for the
// average application. // average application, tagged recipients, which have different privacy
// properties, and plugins, which involve invoking external programs.
// //
// Currently, all returned values are of type *X25519Recipient, but different // Currently, all returned values are of type *[X25519Recipient] or
// types might be returned in the future. // *[HybridRecipient] but different types might be returned in the future.
func ParseRecipients(f io.Reader) ([]Recipient, error) { func ParseRecipients(f io.Reader) ([]Recipient, error) {
const recipientFileSizeLimit = 1 << 24 // 16 MiB const recipientFileSizeLimit = 1 << 24 // 16 MiB
var recs []Recipient var recs []Recipient
scanner := bufio.NewScanner(io.LimitReader(f, recipientFileSizeLimit)) lr := &io.LimitedReader{R: f, N: recipientFileSizeLimit + 1}
scanner := bufio.NewScanner(lr)
var n int var n int
for scanner.Scan() { for scanner.Scan() {
n++ n++
@@ -66,19 +89,36 @@ func ParseRecipients(f io.Reader) ([]Recipient, error) {
if strings.HasPrefix(line, "#") || line == "" { if strings.HasPrefix(line, "#") || line == "" {
continue continue
} }
r, err := ParseX25519Recipient(line) if !utf8.ValidString(line) {
return nil, fmt.Errorf("recipients file is not valid UTF-8")
}
r, err := parseRecipient(line)
if err != nil { if err != nil {
// Hide the error since it might unintentionally leak the contents return nil, fmt.Errorf("error at line %d: %v", n, err)
// of confidential files.
return nil, fmt.Errorf("malformed recipient at line %d", n)
} }
recs = append(recs, r) recs = append(recs, r)
} }
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read recipients file: %v", err) return nil, fmt.Errorf("failed to read recipients file: %v", err)
} }
if lr.N == 0 {
return nil, fmt.Errorf("recipients file is too long")
}
if len(recs) == 0 { if len(recs) == 0 {
return nil, fmt.Errorf("no recipients found") return nil, fmt.Errorf("no recipients found")
} }
return recs, nil return recs, nil
} }
func parseRecipient(arg string) (Recipient, error) {
switch {
case strings.HasPrefix(arg, "age1pq1"):
return ParseHybridRecipient(arg)
case strings.HasPrefix(arg, "age1"):
return ParseX25519Recipient(arg)
default:
// It might be a private key from an identities file accidentally used
// as a recipients file, so don't include arg in the error.
return nil, fmt.Errorf("unknown recipient type")
}
}
+66 -12
View File
@@ -4,18 +4,19 @@
// license that can be found in the LICENSE file or at // license that can be found in the LICENSE file or at
// https://developers.google.com/open-source/licenses/bsd // https://developers.google.com/open-source/licenses/bsd
// Package plugin implements the age plugin protocol.
package plugin package plugin
import ( import (
"bufio" "bufio"
"bytes" "crypto/rand"
"errors"
"fmt" "fmt"
"io" "io"
"math/rand" mathrand "math/rand/v2"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
"time" "time"
exec "golang.org/x/sys/execabs" exec "golang.org/x/sys/execabs"
@@ -53,6 +54,15 @@ func (r *Recipient) Name() string {
return r.name return r.name
} }
// String returns the recipient encoding string ("age1name1...") or
// "<identity-based recipient>" if r was created by [Identity.Recipient].
func (r *Recipient) String() string {
if r.identity {
return "<identity-based recipient>"
}
return r.encoding
}
func (r *Recipient) Wrap(fileKey []byte) (stanzas []*age.Stanza, err error) { func (r *Recipient) Wrap(fileKey []byte) (stanzas []*age.Stanza, err error) {
stanzas, _, err = r.WrapWithLabels(fileKey) stanzas, _, err = r.WrapWithLabels(fileKey)
return return
@@ -67,7 +77,7 @@ func (r *Recipient) WrapWithLabels(fileKey []byte) (stanzas []*age.Stanza, label
conn, err := openClientConnection(r.name, "recipient-v1") conn, err := openClientConnection(r.name, "recipient-v1")
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("couldn't start plugin: %v", err) return nil, nil, fmt.Errorf("couldn't start plugin: %w", err)
} }
defer conn.Close() defer conn.Close()
@@ -79,7 +89,7 @@ func (r *Recipient) WrapWithLabels(fileKey []byte) (stanzas []*age.Stanza, label
if err := writeStanza(conn, addType, r.encoding); err != nil { if err := writeStanza(conn, addType, r.encoding); err != nil {
return nil, nil, err return nil, nil, err
} }
if err := writeStanza(conn, fmt.Sprintf("grease-%x", rand.Int())); err != nil { if _, err := writeGrease(conn); err != nil {
return nil, nil, err return nil, nil, err
} }
if err := writeStanzaWithBody(conn, "wrap-file-key", fileKey); err != nil { if err := writeStanzaWithBody(conn, "wrap-file-key", fileKey); err != nil {
@@ -179,8 +189,11 @@ func NewIdentity(s string, ui *ClientUI) (*Identity, error) {
func NewIdentityWithoutData(name string, ui *ClientUI) (*Identity, error) { func NewIdentityWithoutData(name string, ui *ClientUI) (*Identity, error) {
s := EncodeIdentity(name, nil) s := EncodeIdentity(name, nil)
if s == "" {
return nil, fmt.Errorf("invalid plugin name: %q", name)
}
return &Identity{ return &Identity{
name: name, encoding: s, ui: ui, name: strings.ToLower(name), encoding: s, ui: ui,
}, nil }, nil
} }
@@ -191,6 +204,11 @@ func (i *Identity) Name() string {
return i.name return i.name
} }
// String returns the identity encoding string ("AGE-PLUGIN-NAME-1...").
func (i *Identity) String() string {
return i.encoding
}
// Recipient returns a Recipient wrapping this identity. When that Recipient is // Recipient returns a Recipient wrapping this identity. When that Recipient is
// used to encrypt a file key, the identity encoding is provided as-is to the // used to encrypt a file key, the identity encoding is provided as-is to the
// plugin, which is expected to support encrypting to identities. // plugin, which is expected to support encrypting to identities.
@@ -212,7 +230,7 @@ func (i *Identity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
conn, err := openClientConnection(i.name, "identity-v1") conn, err := openClientConnection(i.name, "identity-v1")
if err != nil { if err != nil {
return nil, fmt.Errorf("couldn't start plugin: %v", err) return nil, fmt.Errorf("couldn't start plugin: %w", err)
} }
defer conn.Close() defer conn.Close()
@@ -220,7 +238,7 @@ func (i *Identity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
if err := writeStanza(conn, "add-identity", i.encoding); err != nil { if err := writeStanza(conn, "add-identity", i.encoding); err != nil {
return nil, err return nil, err
} }
if err := writeStanza(conn, fmt.Sprintf("grease-%x", rand.Int())); err != nil { if _, err := writeGrease(conn); err != nil {
return nil, err return nil, err
} }
for _, rs := range stanzas { for _, rs := range stanzas {
@@ -382,16 +400,35 @@ type clientConnection struct {
cmd *exec.Cmd cmd *exec.Cmd
io.Reader // stdout io.Reader // stdout
io.Writer // stdin io.Writer // stdin
stderr bytes.Buffer
close func() close func()
} }
// NotFoundError is returned by [Recipient.Wrap] and [Identity.Unwrap] when the
// plugin binary cannot be found.
type NotFoundError struct {
// Name is the plugin (not binary) name.
Name string
// Err is the underlying error, usually an [exec.Error] wrapping
// [exec.ErrNotFound].
Err error
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%q plugin not found: %v", e.Name, e.Err)
}
func (e *NotFoundError) Unwrap() error {
return e.Err
}
var testOnlyPluginPath string var testOnlyPluginPath string
func openClientConnection(name, protocol string) (*clientConnection, error) { func openClientConnection(name, protocol string) (*clientConnection, error) {
path := "age-plugin-" + name path := "age-plugin-" + name
if testOnlyPluginPath != "" { if testOnlyPluginPath != "" {
path = filepath.Join(testOnlyPluginPath, path) path = filepath.Join(testOnlyPluginPath, path)
} else if strings.ContainsRune(name, os.PathSeparator) {
return nil, fmt.Errorf("invalid plugin name: %q", name)
} }
cmd := exec.Command(path, "--age-plugin="+protocol) cmd := exec.Command(path, "--age-plugin="+protocol)
@@ -420,12 +457,14 @@ func openClientConnection(name, protocol string) (*clientConnection, error) {
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
} }
// We don't want the plugins to rely on the working directory for anything // Avoid running plugins in the client's working directory, as it might
// as different clients might treat it differently, so we set it to an empty // differ between clients.
// temporary directory.
cmd.Dir = os.TempDir() cmd.Dir = os.TempDir()
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
if errors.Is(err, exec.ErrNotFound) {
return nil, &NotFoundError{Name: name, Err: err}
}
return nil, err return nil, err
} }
@@ -449,3 +488,18 @@ func writeStanzaWithBody(conn io.Writer, t string, body []byte) error {
s := &format.Stanza{Type: t, Body: body} s := &format.Stanza{Type: t, Body: body}
return s.Marshal(conn) return s.Marshal(conn)
} }
func writeGrease(conn io.Writer) (sent bool, err error) {
if mathrand.IntN(3) == 0 {
return false, nil
}
s := &format.Stanza{Type: fmt.Sprintf("grease-%x", mathrand.Int())}
for i := 0; i < mathrand.IntN(3); i++ {
s.Args = append(s.Args, fmt.Sprintf("%d", mathrand.IntN(100)))
}
if mathrand.IntN(2) == 0 {
s.Body = make([]byte, mathrand.IntN(100))
rand.Read(s.Body)
}
return true, s.Marshal(conn)
}
+199 -50
View File
@@ -8,75 +8,120 @@ package plugin
import ( import (
"bufio" "bufio"
"bytes"
"errors"
"io" "io"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"testing" "testing"
"filippo.io/age" "filippo.io/age"
"filippo.io/age/internal/bech32" "filippo.io/age/internal/bech32"
"filippo.io/age/internal/format"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
switch filepath.Base(os.Args[0]) { switch filepath.Base(os.Args[0]) {
// TODO: deduplicate from cmd/age TestMain.
case "age-plugin-test": case "age-plugin-test":
switch os.Args[1] { p, _ := New("test")
case "--age-plugin=recipient-v1": p.HandleRecipient(func(data []byte) (age.Recipient, error) {
scanner := bufio.NewScanner(os.Stdin) return testRecipient{}, nil
scanner.Scan() // add-recipient })
scanner.Scan() // body os.Exit(p.Main())
scanner.Scan() // grease
scanner.Scan() // body
scanner.Scan() // wrap-file-key
scanner.Scan() // body
fileKey := scanner.Text()
scanner.Scan() // extension-labels
scanner.Scan() // body
scanner.Scan() // done
scanner.Scan() // body
os.Stdout.WriteString("-> recipient-stanza 0 test\n")
os.Stdout.WriteString(fileKey + "\n")
scanner.Scan() // ok
scanner.Scan() // body
os.Stdout.WriteString("-> done\n\n")
os.Exit(0)
default:
panic(os.Args[1])
}
case "age-plugin-testpqc": case "age-plugin-testpqc":
switch os.Args[1] { p, _ := New("testpqc")
case "--age-plugin=recipient-v1": p.HandleRecipient(func(data []byte) (age.Recipient, error) {
scanner := bufio.NewScanner(os.Stdin) return testPQCRecipient{}, nil
scanner.Scan() // add-recipient })
scanner.Scan() // body os.Exit(p.Main())
scanner.Scan() // grease case "age-plugin-error":
scanner.Scan() // body p, _ := New("error")
scanner.Scan() // wrap-file-key p.HandleRecipient(func(data []byte) (age.Recipient, error) {
scanner.Scan() // body return nil, errors.New("oh my, an error occurred")
fileKey := scanner.Text() })
scanner.Scan() // extension-labels p.HandleIdentity(func(data []byte) (age.Identity, error) {
scanner.Scan() // body return nil, errors.New("oh my, an error occurred")
scanner.Scan() // done })
scanner.Scan() // body os.Exit(p.Main())
os.Stdout.WriteString("-> recipient-stanza 0 test\n")
os.Stdout.WriteString(fileKey + "\n")
scanner.Scan() // ok
scanner.Scan() // body
os.Stdout.WriteString("-> labels postquantum\n\n")
scanner.Scan() // ok
scanner.Scan() // body
os.Stdout.WriteString("-> done\n\n")
os.Exit(0)
default:
panic(os.Args[1])
}
default: default:
os.Exit(m.Run()) os.Exit(m.Run())
} }
} }
type testRecipient struct{}
func (testRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
return []*age.Stanza{{Type: "test", Body: fileKey}}, nil
}
type testPQCRecipient struct{}
var _ age.RecipientWithLabels = testPQCRecipient{}
func (testPQCRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
return []*age.Stanza{{Type: "test", Body: fileKey}}, nil
}
func (testPQCRecipient) WrapWithLabels(fileKey []byte) ([]*age.Stanza, []string, error) {
return []*age.Stanza{{Type: "test", Body: fileKey}}, []string{"postquantum"}, nil
}
func TestIdentityV1NegativeIndex(t *testing.T) {
p, err := New("test")
if err != nil {
t.Fatal(err)
}
p.HandleIdentity(func([]byte) (age.Identity, error) { return nil, nil })
stderr := &bytes.Buffer{}
p.SetIO(strings.NewReader("-> recipient-stanza -1 X25519\n\n"), io.Discard, stderr)
if code := p.IdentityV1(); code != 1 {
t.Errorf("exit code = %d, want 1", code)
}
want := "unexpected file index -1, previous was -1"
if got := stderr.String(); got != want {
t.Errorf("stderr = %q, want %q", got, want)
}
}
func TestPluginNameCase(t *testing.T) {
p, err := New("MixedCase")
if err != nil {
t.Fatal(err)
}
if p.Name() != "mixedcase" {
t.Errorf("Plugin.Name = %q", p.Name())
}
id, err := NewIdentityWithoutData("MixedCase", &ClientUI{})
if err != nil {
t.Fatal(err)
}
if id.Name() != "mixedcase" {
t.Errorf("Identity.Name = %q", id.Name())
}
if _, err := New("\u212a"); err == nil {
t.Error("New accepted a non-ASCII name")
}
}
func TestRequestValueEmpty(t *testing.T) {
p, err := New("test")
if err != nil {
t.Fatal(err)
}
p.SetIO(nil, io.Discard, io.Discard)
p.sr = format.NewStanzaReader(bufio.NewReader(strings.NewReader("-> ok\n\n")))
value, err := p.RequestValue("prompt", true)
if err != nil {
t.Fatal(err)
}
if value != "" {
t.Errorf("value = %q", value)
}
}
func TestLabels(t *testing.T) { func TestLabels(t *testing.T) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
t.Skip("Windows support is TODO") t.Skip("Windows support is TODO")
@@ -131,3 +176,107 @@ func TestLabels(t *testing.T) {
t.Errorf("expected one pqc and one normal to fail") t.Errorf("expected one pqc and one normal to fail")
} }
} }
func TestNotFound(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows support is TODO")
}
r := EncodeRecipient("nonexistentplugin", nil)
t.Log(r)
testPluginRecipient, err := NewRecipient(r, &ClientUI{})
if err != nil {
t.Fatal(err)
}
var e *NotFoundError
if _, err := age.Encrypt(io.Discard, testPluginRecipient); err == nil {
t.Errorf("expected error for nonexistent plugin")
} else if !errors.As(err, &e) {
t.Errorf("expected NotFoundError, got %T: %v", err, err)
} else if e.Name != "nonexistentplugin" {
t.Errorf("expected NotFoundError.Name to be nonexistentplugin, got %q", e.Name)
} else if !errors.Is(err, exec.ErrNotFound) {
t.Errorf("expected error to wrap exec.ErrNotFound, got: %v", err)
}
buf := &bytes.Buffer{}
id, err := age.GenerateHybridIdentity()
if err != nil {
t.Fatal(err)
}
w, err := age.Encrypt(buf, id.Recipient())
if err != nil {
t.Fatal(err)
}
w.Close()
i := EncodeIdentity("nonexistentplugin", nil)
t.Log(i)
testPluginIdentity, err := NewIdentity(i, &ClientUI{})
if err != nil {
t.Fatal(err)
}
if _, err := age.Decrypt(buf, testPluginIdentity); err == nil {
t.Errorf("expected error for nonexistent plugin")
} else if errors.As(err, new(*age.NoIdentityMatchError)) {
t.Errorf("expected NotFoundError, got NoIdentityMatchError: %v", err)
} else if !errors.As(err, &e) {
t.Errorf("expected NotFoundError, got %T: %v", err, err)
} else if e.Name != "nonexistentplugin" {
t.Errorf("expected NotFoundError.Name to be nonexistentplugin, got %q", e.Name)
} else if !errors.Is(err, exec.ErrNotFound) {
t.Errorf("expected error to wrap exec.ErrNotFound, got: %v", err)
}
}
func TestPluginError(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows support is TODO")
}
temp := t.TempDir()
testOnlyPluginPath = temp
t.Cleanup(func() { testOnlyPluginPath = "" })
ex, err := os.Executable()
if err != nil {
t.Fatal(err)
}
if err := os.Link(ex, filepath.Join(temp, "age-plugin-error")); err != nil {
t.Fatal(err)
}
if err := os.Chmod(filepath.Join(temp, "age-plugin-error"), 0755); err != nil {
t.Fatal(err)
}
r := EncodeRecipient("error", nil)
testPluginRecipient, err := NewRecipient(r, &ClientUI{})
if err != nil {
t.Fatal(err)
}
if _, err := age.Encrypt(io.Discard, testPluginRecipient); err == nil {
t.Errorf("expected error from plugin")
} else if !strings.Contains(err.Error(), "oh my, an error occurred") {
t.Errorf("expected plugin error, got: %v", err)
}
buf := &bytes.Buffer{}
id, err := age.GenerateHybridIdentity()
if err != nil {
t.Fatal(err)
}
w, err := age.Encrypt(buf, id.Recipient())
if err != nil {
t.Fatal(err)
}
w.Close()
i := EncodeIdentity("error", nil)
testPluginIdentity, err := NewIdentity(i, &ClientUI{})
if err != nil {
t.Fatal(err)
}
if _, err := age.Decrypt(buf, testPluginIdentity); err == nil {
t.Errorf("expected error from plugin")
} else if !strings.Contains(err.Error(), "oh my, an error occurred") {
t.Errorf("expected plugin error, got: %v", err)
}
}
+53
View File
@@ -5,15 +5,21 @@
package plugin package plugin
import ( import (
"crypto/ecdh"
"crypto/mlkem"
"fmt" "fmt"
"strings" "strings"
"filippo.io/age/internal/bech32" "filippo.io/age/internal/bech32"
"filippo.io/hpke"
) )
// EncodeIdentity encodes a plugin identity string for a plugin with the given // EncodeIdentity encodes a plugin identity string for a plugin with the given
// name. If the name is invalid, it returns an empty string. // name. If the name is invalid, it returns an empty string.
func EncodeIdentity(name string, data []byte) string { func EncodeIdentity(name string, data []byte) string {
if !validPluginName(name) {
return ""
}
s, _ := bech32.Encode("AGE-PLUGIN-"+strings.ToUpper(name)+"-", data) s, _ := bech32.Encode("AGE-PLUGIN-"+strings.ToUpper(name)+"-", data)
return s return s
} }
@@ -30,12 +36,18 @@ func ParseIdentity(s string) (name string, data []byte, err error) {
} }
name = strings.TrimSuffix(strings.TrimPrefix(hrp, "AGE-PLUGIN-"), "-") name = strings.TrimSuffix(strings.TrimPrefix(hrp, "AGE-PLUGIN-"), "-")
name = strings.ToLower(name) name = strings.ToLower(name)
if !validPluginName(name) {
return "", nil, fmt.Errorf("invalid plugin name: %q", name)
}
return name, data, nil return name, data, nil
} }
// EncodeRecipient encodes a plugin recipient string for a plugin with the given // EncodeRecipient encodes a plugin recipient string for a plugin with the given
// name. If the name is invalid, it returns an empty string. // name. If the name is invalid, it returns an empty string.
func EncodeRecipient(name string, data []byte) string { func EncodeRecipient(name string, data []byte) string {
if !validPluginName(name) {
return ""
}
s, _ := bech32.Encode("age1"+strings.ToLower(name), data) s, _ := bech32.Encode("age1"+strings.ToLower(name), data)
return s return s
} }
@@ -51,5 +63,46 @@ func ParseRecipient(s string) (name string, data []byte, err error) {
return "", nil, fmt.Errorf("not a plugin recipient: %v", err) return "", nil, fmt.Errorf("not a plugin recipient: %v", err)
} }
name = strings.TrimPrefix(hrp, "age1") name = strings.TrimPrefix(hrp, "age1")
if !validPluginName(name) {
return "", nil, fmt.Errorf("invalid plugin name: %q", name)
}
return name, data, nil return name, data, nil
} }
func validPluginName(name string) bool {
if name == "" {
return false
}
allowed := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+-._"
for _, r := range name {
if !strings.ContainsRune(allowed, r) {
return false
}
}
return true
}
// EncodeX25519Recipient encodes a native X25519 recipient from a
// [crypto/ecdh.X25519] public key. It's meant for plugins that implement
// identities that are compatible with native recipients.
func EncodeX25519Recipient(pk *ecdh.PublicKey) (string, error) {
if pk.Curve() != ecdh.X25519() {
return "", fmt.Errorf("wrong ecdh Curve")
}
return bech32.Encode("age", pk.Bytes())
}
// EncodeHybridRecipient encodes a native MLKEM768-X25519 recipient from a
// [crypto/mlkem.EncapsulationKey768] and a [crypto/ecdh.X25519] public key.
// It's meant for plugins that implement identities that are compatible with
// native recipients.
func EncodeHybridRecipient(pq *mlkem.EncapsulationKey768, t *ecdh.PublicKey) (string, error) {
if t.Curve() != ecdh.X25519() {
return "", fmt.Errorf("wrong ecdh Curve")
}
pk, err := hpke.NewHybridPublicKey(pq, t)
if err != nil {
return "", fmt.Errorf("failed to create hybrid public key: %v", err)
}
return bech32.Encode("age1pq", pk.Bytes())
}
-24
View File
@@ -1,24 +0,0 @@
// Copyright 2023 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.20
package plugin
import (
"crypto/ecdh"
"fmt"
"filippo.io/age/internal/bech32"
)
// EncodeX25519Recipient encodes a native X25519 recipient from a
// [crypto/ecdh.X25519] public key. It's meant for plugins that implement
// identities that are compatible with native recipients.
func EncodeX25519Recipient(pk *ecdh.PublicKey) (string, error) {
if pk.Curve() != ecdh.X25519() {
return "", fmt.Errorf("wrong ecdh Curve")
}
return bech32.Encode("age", pk.Bytes())
}
+43
View File
@@ -0,0 +1,43 @@
package plugin_test
import (
"log"
"os"
"filippo.io/age"
"filippo.io/age/plugin"
)
type Recipient struct{}
func (r *Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
panic("unimplemented")
}
func NewRecipient(data []byte) (*Recipient, error) {
return &Recipient{}, nil
}
type Identity struct{}
func (i *Identity) Unwrap(s []*age.Stanza) ([]byte, error) {
panic("unimplemented")
}
func NewIdentity(data []byte) (*Identity, error) {
return &Identity{}, nil
}
func ExamplePlugin_main() {
p, err := plugin.New("example")
if err != nil {
log.Fatal(err)
}
p.HandleRecipient(func(data []byte) (age.Recipient, error) {
return NewRecipient(data)
})
p.HandleIdentity(func(data []byte) (age.Identity, error) {
return NewIdentity(data)
})
os.Exit(p.Main())
}
+679
View File
@@ -0,0 +1,679 @@
// Package plugin implements the age plugin protocol.
//
// [Recipient] and [Identity] are plugin clients, that execute plugin binaries to
// perform encryption and decryption operations.
//
// [Plugin] is a framework for writing age plugins, that exposes an [age.Recipient]
// and/or [age.Identity] implementation as a plugin binary.
package plugin
import (
"bufio"
"errors"
"flag"
"fmt"
"io"
"os"
"slices"
"strconv"
"strings"
"filippo.io/age"
"filippo.io/age/internal/format"
)
// TODO: add plugin test framework.
// Plugin is a framework for writing age plugins. It allows exposing regular
// [age.Recipient] and [age.Identity] implementations as plugins, and handles
// all the protocol details.
type Plugin struct {
name string
fs *flag.FlagSet
sm *string
recipient func([]byte) (age.Recipient, error)
idAsRecipient func([]byte) (age.Recipient, error)
identity func([]byte) (age.Identity, error)
stdin io.Reader
stdout, stderr io.Writer
sr *format.StanzaReader
// broken is set if the protocol broke down during an interaction function
// called by a Recipient or Identity.
broken bool
}
// New creates a new Plugin with the given case-insensitive name.
//
// For example, a plugin named "frood" would be invoked as "age-plugin-frood".
func New(name string) (*Plugin, error) {
if !validPluginName(name) {
return nil, fmt.Errorf("invalid plugin name: %q", name)
}
return &Plugin{name: strings.ToLower(name), stdin: os.Stdin,
stdout: os.Stdout, stderr: os.Stderr}, nil
}
// Name returns the name of the plugin.
func (p *Plugin) Name() string {
return p.name
}
// RegisterFlags registers the plugin's flags with the given [flag.FlagSet], or
// with the default [flag.CommandLine] if fs is nil. It must be called before
// [flag.Parse] and [Plugin.Main].
//
// This allows the plugin to expose additional flags when invoked manually, for
// example to implement a keygen mode.
func (p *Plugin) RegisterFlags(fs *flag.FlagSet) {
if fs == nil {
fs = flag.CommandLine
}
p.fs = fs
p.sm = fs.String("age-plugin", "", "age-plugin state machine")
}
// HandleRecipient registers a function to parse recipients of the form
// age1name1... into [age.Recipient] values. data is the decoded Bech32 payload.
//
// If the returned Recipient implements [age.RecipientWithLabels], Plugin will
// use it and enforce consistency across every returned stanza in an execution.
// If the client supports labels, they will be passed through the protocol.
//
// It must be called before [Plugin.Main], and can be called at most once.
// Otherwise, it panics.
func (p *Plugin) HandleRecipient(f func(data []byte) (age.Recipient, error)) {
if p.recipient != nil {
panic("HandleRecipient called twice")
}
p.recipient = f
}
// HandleIdentityAsRecipient registers a function to parse identities of the
// form AGE-PLUGIN-NAME-1... into [age.Recipient] values, for when identities
// are used as recipients. data is the decoded Bech32 payload.
//
// If the returned Recipient implements [age.RecipientWithLabels], Plugin will
// use it and enforce consistency across every returned stanza in an execution.
// If the client supports labels, they will be passed through the protocol.
//
// It must be called before [Plugin.Main], and can be called at most once.
// Otherwise, it panics.
func (p *Plugin) HandleIdentityAsRecipient(f func(data []byte) (age.Recipient, error)) {
if p.idAsRecipient != nil {
panic("HandleIdentityAsRecipient called twice")
}
p.idAsRecipient = f
}
// HandleIdentity registers a function to parse identities of the form
// AGE-PLUGIN-NAME-1... into [age.Identity] values. data is the decoded Bech32
// payload.
//
// It must be called before [Plugin.Main], and can be called at most once.
// Otherwise, it panics.
func (p *Plugin) HandleIdentity(f func(data []byte) (age.Identity, error)) {
if p.identity != nil {
panic("HandleIdentity called twice")
}
p.identity = f
}
// HandleRecipientEncoding is like [Plugin.HandleRecipient] but provides the
// full recipient encoding string to the callback.
//
// It allows using functions like ParseRecipient directly.
func (p *Plugin) HandleRecipientEncoding(f func(recipient string) (age.Recipient, error)) {
p.HandleRecipient(func(data []byte) (age.Recipient, error) {
return f(EncodeRecipient(p.name, data))
})
}
// HandleIdentityEncodingAsRecipient is like [Plugin.HandleIdentityAsRecipient] but
// provides the full identity encoding string to the callback.
func (p *Plugin) HandleIdentityEncodingAsRecipient(f func(identity string) (age.Recipient, error)) {
p.HandleIdentityAsRecipient(func(data []byte) (age.Recipient, error) {
return f(EncodeIdentity(p.name, data))
})
}
// HandleIdentityEncoding is like [Plugin.HandleIdentity] but provides the
// full identity encoding string to the callback.
//
// It allows using functions like ParseIdentity directly.
func (p *Plugin) HandleIdentityEncoding(f func(identity string) (age.Identity, error)) {
p.HandleIdentity(func(data []byte) (age.Identity, error) {
return f(EncodeIdentity(p.name, data))
})
}
// Main runs the plugin protocol. It returns an exit code to pass to os.Exit.
//
// It automatically calls [Plugin.RegisterFlags] and [flag.Parse] if they were
// not called before.
func (p *Plugin) Main() int {
if p.fs == nil {
p.RegisterFlags(nil)
}
if !p.fs.Parsed() {
p.fs.Parse(os.Args[1:])
}
if *p.sm == "recipient-v1" {
return p.RecipientV1()
}
if *p.sm == "identity-v1" {
return p.IdentityV1()
}
fmt.Fprintf(p.stderr, "unknown state machine %q", *p.sm)
return 4
}
// SetIO sets the plugin's input and output streams, which default to
// stdin/stdout/stderr.
//
// It must be called before [Plugin.Main].
func (p *Plugin) SetIO(stdin io.Reader, stdout, stderr io.Writer) {
p.stdin = stdin
p.stdout = stdout
p.stderr = stderr
}
// RecipientV1 implements the recipient-v1 state machine. It returns an exit
// code to pass to os.Exit.
//
// Most plugins should call [Plugin.Main] instead of this method.
func (p *Plugin) RecipientV1() int {
if p.recipient == nil && p.idAsRecipient == nil {
return p.fatalf("recipient-v1 not supported")
}
var recipientStrings, identityStrings []string
var fileKeys [][]byte
var supportsLabels bool
p.sr = format.NewStanzaReader(bufio.NewReader(p.stdin))
ReadLoop:
for {
s, err := p.sr.ReadStanza()
if err != nil {
return p.fatalf("failed to read stanza: %v", err)
}
switch s.Type {
case "add-recipient":
if err := expectStanzaWithNoBody(s, 1); err != nil {
return p.fatalf("%v", err)
}
recipientStrings = append(recipientStrings, s.Args[0])
case "add-identity":
if err := expectStanzaWithNoBody(s, 1); err != nil {
return p.fatalf("%v", err)
}
identityStrings = append(identityStrings, s.Args[0])
case "extension-labels":
if err := expectStanzaWithNoBody(s, 0); err != nil {
return p.fatalf("%v", err)
}
supportsLabels = true
case "wrap-file-key":
if err := expectStanzaWithBody(s, 0); err != nil {
return p.fatalf("%v", err)
}
fileKeys = append(fileKeys, s.Body)
case "done":
if err := expectStanzaWithNoBody(s, 0); err != nil {
return p.fatalf("%v", err)
}
break ReadLoop
default:
// Unsupported stanzas in uni-directional phases are ignored.
}
}
if len(recipientStrings)+len(identityStrings) == 0 {
return p.fatalf("no recipients or identities provided")
}
if len(fileKeys) == 0 {
return p.fatalf("no file keys provided")
}
var recipients, identities []age.Recipient
for i, s := range recipientStrings {
name, data, err := ParseRecipient(s)
if err != nil {
return p.recipientError(i, err)
}
if name != p.name {
return p.recipientError(i, fmt.Errorf("unsupported plugin name: %q", name))
}
if p.recipient == nil {
return p.recipientError(i, fmt.Errorf("recipient encodings not supported"))
}
r, err := p.recipient(data)
if err != nil {
return p.recipientError(i, err)
}
recipients = append(recipients, r)
}
for i, s := range identityStrings {
name, data, err := ParseIdentity(s)
if err != nil {
return p.identityError(i, err)
}
if name != p.name {
return p.identityError(i, fmt.Errorf("unsupported plugin name: %q", name))
}
if p.idAsRecipient == nil {
return p.identityError(i, fmt.Errorf("identity encodings not supported"))
}
r, err := p.idAsRecipient(data)
if err != nil {
return p.identityError(i, err)
}
identities = append(identities, r)
}
// Technically labels should be per-file key, but the client-side protocol
// extension shipped like this, and it doesn't feel worth making a v2.
var labels []string
stanzas := make([][]*age.Stanza, len(fileKeys))
for i, fk := range fileKeys {
for j, r := range recipients {
ss, ll, err := wrapWithLabels(r, fk)
if p.broken {
return 2
} else if err != nil {
return p.recipientError(j, err)
}
if i == 0 && j == 0 {
labels = ll
} else if err := checkLabels(ll, labels); err != nil {
return p.recipientError(j, err)
}
stanzas[i] = append(stanzas[i], ss...)
}
for j, r := range identities {
ss, ll, err := wrapWithLabels(r, fk)
if p.broken {
return 2
} else if err != nil {
return p.identityError(j, err)
}
if i == 0 && j == 0 && len(recipients) == 0 {
labels = ll
} else if err := checkLabels(ll, labels); err != nil {
return p.identityError(j, err)
}
stanzas[i] = append(stanzas[i], ss...)
}
}
if sent, err := writeGrease(p.stdout); err != nil {
return p.fatalf("failed to write grease: %v", err)
} else if sent {
if err := expectUnsupported(p.sr); err != nil {
return p.fatalf("%v", err)
}
}
if supportsLabels {
if err := writeStanza(p.stdout, "labels", labels...); err != nil {
return p.fatalf("failed to write labels stanza: %v", err)
}
if err := expectOk(p.sr); err != nil {
return p.fatalf("%v", err)
}
}
for i, ss := range stanzas {
for _, s := range ss {
if err := (&format.Stanza{Type: "recipient-stanza",
Args: append([]string{fmt.Sprint(i), s.Type}, s.Args...),
Body: s.Body}).Marshal(p.stdout); err != nil {
return p.fatalf("failed to write recipient-stanza: %v", err)
}
if err := expectOk(p.sr); err != nil {
return p.fatalf("%v", err)
}
}
if sent, err := writeGrease(p.stdout); err != nil {
return p.fatalf("failed to write grease: %v", err)
} else if sent {
if err := expectUnsupported(p.sr); err != nil {
return p.fatalf("%v", err)
}
}
}
if err := writeStanza(p.stdout, "done"); err != nil {
return p.fatalf("failed to write done stanza: %v", err)
}
return 0
}
func wrapWithLabels(r age.Recipient, fileKey []byte) ([]*age.Stanza, []string, error) {
if r, ok := r.(age.RecipientWithLabels); ok {
return r.WrapWithLabels(fileKey)
}
s, err := r.Wrap(fileKey)
return s, nil, err
}
func checkLabels(ll, labels []string) error {
if !slices.Equal(ll, labels) {
return fmt.Errorf("labels %q do not match previous recipients %q", ll, labels)
}
return nil
}
// IdentityV1 implements the identity-v1 state machine. It returns an exit code
// to pass to os.Exit.
//
// Most plugins should call [Plugin.Main] instead of this method.
func (p *Plugin) IdentityV1() int {
if p.identity == nil {
return p.fatalf("identity-v1 not supported")
}
var files [][]*age.Stanza
var identityStrings []string
p.sr = format.NewStanzaReader(bufio.NewReader(p.stdin))
ReadLoop:
for {
s, err := p.sr.ReadStanza()
if err != nil {
return p.fatalf("failed to read stanza: %v", err)
}
switch s.Type {
case "add-identity":
if err := expectStanzaWithNoBody(s, 1); err != nil {
return p.fatalf("%v", err)
}
identityStrings = append(identityStrings, s.Args[0])
case "recipient-stanza":
if len(s.Args) < 2 {
return p.fatalf("recipient-stanza stanza has %d arguments, want >=2", len(s.Args))
}
i, err := strconv.Atoi(s.Args[0])
if err != nil {
return p.fatalf("failed to parse recipient-stanza stanza argument: %v", err)
}
if i < 0 {
return p.fatalf("unexpected file index %d, previous was %d", i, len(files)-1)
}
ss := &age.Stanza{Type: s.Args[1], Args: s.Args[2:], Body: s.Body}
switch i {
case len(files):
files = append(files, []*age.Stanza{ss})
case len(files) - 1:
files[len(files)-1] = append(files[len(files)-1], ss)
default:
return p.fatalf("unexpected file index %d, previous was %d", i, len(files)-1)
}
case "done":
if err := expectStanzaWithNoBody(s, 0); err != nil {
return p.fatalf("%v", err)
}
break ReadLoop
default:
// Unsupported stanzas in uni-directional phases are ignored.
}
}
if len(identityStrings) == 0 {
return p.fatalf("no identities provided")
}
if len(files) == 0 {
return p.fatalf("no stanzas provided")
}
var identities []age.Identity
for i, s := range identityStrings {
name, data, err := ParseIdentity(s)
if err != nil {
return p.identityError(i, err)
}
if name != p.name {
return p.identityError(i, fmt.Errorf("unsupported plugin name: %q", name))
}
if p.identity == nil {
return p.identityError(i, fmt.Errorf("identity encodings not supported"))
}
r, err := p.identity(data)
if err != nil {
return p.identityError(i, err)
}
identities = append(identities, r)
}
for i, ss := range files {
if sent, err := writeGrease(p.stdout); err != nil {
return p.fatalf("failed to write grease: %v", err)
} else if sent {
if err := expectUnsupported(p.sr); err != nil {
return p.fatalf("%v", err)
}
}
// TODO: there should be a mechanism to let the plugin decide the order
// in which identities are tried.
for _, id := range identities {
fk, err := id.Unwrap(ss)
if p.broken {
return 2
} else if errors.Is(err, age.ErrIncorrectIdentity) {
continue
} else if err != nil {
if err := p.writeError([]string{"stanza", fmt.Sprint(i), "0"}, err); err != nil {
return p.fatalf("%v", err)
}
// Note that we don't exit here, as the protocol allows
// continuing with other files.
break
}
s := &format.Stanza{Type: "file-key", Args: []string{fmt.Sprint(i)}, Body: fk}
if err := s.Marshal(p.stdout); err != nil {
return p.fatalf("failed to write file-key: %v", err)
}
if err := expectOk(p.sr); err != nil {
return p.fatalf("%v", err)
}
break
}
}
if err := writeStanza(p.stdout, "done"); err != nil {
return p.fatalf("failed to write done stanza: %v", err)
}
return 0
}
// DisplayMessage requests that the client display a message to the user. The
// message should start with a lowercase letter and have no final period.
// DisplayMessage returns an error if the client can't display the message, and
// may return before the message has been displayed to the user.
//
// It must only be called by a Wrap or Unwrap method invoked by [Plugin.Main].
func (p *Plugin) DisplayMessage(message string) error {
if err := writeStanzaWithBody(p.stdout, "msg", []byte(message)); err != nil {
return p.fatalInteractf("failed to write msg stanza: %v", err)
}
s, err := readOkOrFail(p.sr)
if err != nil {
return p.fatalInteractf("%v", err)
}
if s.Type == "fail" {
return fmt.Errorf("client failed to display message")
}
if err := expectStanzaWithNoBody(s, 0); err != nil {
return p.fatalInteractf("%v", err)
}
return nil
}
// RequestValue requests a secret or public input from the user through the
// client, with the provided prompt. It returns an error if the client can't
// request the input or if the user dismisses the prompt.
//
// It must only be called by a Wrap or Unwrap method invoked by [Plugin.Main].
func (p *Plugin) RequestValue(prompt string, secret bool) (string, error) {
t := "request-public"
if secret {
t = "request-secret"
}
if err := writeStanzaWithBody(p.stdout, t, []byte(prompt)); err != nil {
return "", p.fatalInteractf("failed to write stanza: %v", err)
}
s, err := readOkOrFail(p.sr)
if err != nil {
return "", p.fatalInteractf("%v", err)
}
if s.Type == "fail" {
return "", fmt.Errorf("client failed to request value")
}
if err := expectStanzaWithAnyBody(s, 0); err != nil {
return "", p.fatalInteractf("%v", err)
}
return string(s.Body), nil
}
// Confirm requests a confirmation from the user through the client, with the
// provided prompt. The yes and no value are the choices provided to the user.
// no may be empty. The return value choseYes indicates whether the user
// selected the yes or no option. Confirm returns an error if the client can't
// request the confirmation.
//
// It must only be called by a Wrap or Unwrap method invoked by [Plugin.Main].
func (p *Plugin) Confirm(prompt, yes, no string) (choseYes bool, err error) {
args := []string{format.EncodeToString([]byte(yes))}
if no != "" {
args = append(args, format.EncodeToString([]byte(no)))
}
s := &format.Stanza{Type: "confirm", Args: args, Body: []byte(prompt)}
if err := s.Marshal(p.stdout); err != nil {
return false, p.fatalInteractf("failed to write confirm stanza: %v", err)
}
s, err = readOkOrFail(p.sr)
if err != nil {
return false, p.fatalInteractf("%v", err)
}
if s.Type == "fail" {
return false, fmt.Errorf("client failed to request confirmation")
}
if err := expectStanzaWithNoBody(s, 1); err != nil {
return false, p.fatalInteractf("%v", err)
}
return s.Args[0] == "yes", nil
}
// fatalInteractf prints the error to stderr and sets the broken flag, so the
// Wrap/Unwrap caller can exit with an error.
func (p *Plugin) fatalInteractf(format string, args ...any) error {
p.broken = true
fmt.Fprintf(p.stderr, format, args...)
return fmt.Errorf(format, args...)
}
func (p *Plugin) fatalf(format string, args ...any) int {
fmt.Fprintf(p.stderr, format, args...)
return 1
}
func expectStanzaWithNoBody(s *format.Stanza, wantArgs int) error {
if len(s.Args) != wantArgs {
return fmt.Errorf("%s stanza has %d arguments, want %d", s.Type, len(s.Args), wantArgs)
}
if len(s.Body) != 0 {
return fmt.Errorf("%s stanza has %d bytes of body, want 0", s.Type, len(s.Body))
}
return nil
}
func expectStanzaWithBody(s *format.Stanza, wantArgs int) error {
if err := expectStanzaWithAnyBody(s, wantArgs); err != nil {
return err
}
if len(s.Body) == 0 {
return fmt.Errorf("%s stanza has 0 bytes of body, want >0", s.Type)
}
return nil
}
func expectStanzaWithAnyBody(s *format.Stanza, wantArgs int) error {
if len(s.Args) != wantArgs {
return fmt.Errorf("%s stanza has %d arguments, want %d", s.Type, len(s.Args), wantArgs)
}
return nil
}
func (p *Plugin) recipientError(idx int, err error) int {
if err := p.writeError([]string{"recipient", fmt.Sprint(idx)}, err); err != nil {
return p.fatalf("%v", err)
}
return 3
}
func (p *Plugin) identityError(idx int, err error) int {
if err := p.writeError([]string{"identity", fmt.Sprint(idx)}, err); err != nil {
return p.fatalf("%v", err)
}
return 3
}
func expectOk(sr *format.StanzaReader) error {
ok, err := sr.ReadStanza()
if err != nil {
return fmt.Errorf("failed to read OK stanza: %v", err)
}
if ok.Type != "ok" {
return fmt.Errorf("expected OK stanza, got %q", ok.Type)
}
return expectStanzaWithNoBody(ok, 0)
}
func readOkOrFail(sr *format.StanzaReader) (*format.Stanza, error) {
s, err := sr.ReadStanza()
if err != nil {
return nil, fmt.Errorf("failed to read response stanza: %v", err)
}
switch s.Type {
case "fail":
if err := expectStanzaWithNoBody(s, 0); err != nil {
return nil, fmt.Errorf("%v", err)
}
return s, nil
case "ok":
return s, nil
default:
return nil, fmt.Errorf("expected ok or fail stanza, got %q", s.Type)
}
}
func expectUnsupported(sr *format.StanzaReader) error {
unsupported, err := sr.ReadStanza()
if err != nil {
return fmt.Errorf("failed to read unsupported stanza: %v", err)
}
if unsupported.Type != "unsupported" {
return fmt.Errorf("expected unsupported stanza, got %q", unsupported.Type)
}
return expectStanzaWithNoBody(unsupported, 0)
}
func (p *Plugin) writeError(args []string, err error) error {
s := &format.Stanza{Type: "error", Args: args}
s.Body = []byte(err.Error())
if err := s.Marshal(p.stdout); err != nil {
return fmt.Errorf("failed to write error stanza: %v", err)
}
if err := expectOk(p.sr); err != nil {
return fmt.Errorf("%v", err)
}
return nil
}
+78
View File
@@ -0,0 +1,78 @@
package plugin
import (
"errors"
"fmt"
"filippo.io/age/internal/term"
)
// NewTerminalUI returns a [ClientUI] that uses the terminal to request inputs,
// and the provided functions to display messages and errors.
//
// The terminal is reached directly through /dev/tty or CONIN$/CONOUT$,
// bypassing standard input and output, so this UI can be used even when
// standard input or output are redirected.
func NewTerminalUI(printf, warningf func(format string, v ...any)) *ClientUI {
return &ClientUI{
DisplayMessage: func(name, message string) error {
printf("%s plugin: %s", name, message)
return nil
},
RequestValue: func(name, message string, isSecret bool) (s string, err error) {
defer func() {
if err != nil {
warningf("could not read value for age-plugin-%s: %v", name, err)
}
}()
if isSecret {
secret, err := term.ReadSecret(message)
if err != nil {
return "", err
}
return string(secret), nil
} else {
public, err := term.ReadPublic(message)
if err != nil {
return "", err
}
return string(public), nil
}
},
Confirm: func(name, message, yes, no string) (choseYes bool, err error) {
defer func() {
if err != nil {
warningf("could not read value for age-plugin-%s: %v", name, err)
}
}()
if no == "" {
message += fmt.Sprintf(" (press enter for %q)", yes)
_, err := term.ReadSecret(message)
if err != nil {
return false, err
}
return true, nil
}
message += fmt.Sprintf(" (press [1] for %q or [2] for %q)", yes, no)
for {
selection, err := term.ReadCharacter(message)
if err != nil {
return false, err
}
switch selection {
case '1':
return true, nil
case '2':
return false, nil
case '\x03': // CTRL-C
return false, errors.New("user cancelled prompt")
default:
warningf("reading value for age-plugin-%s: invalid selection %q", name, selection)
}
}
},
WaitTimer: func(name string) {
printf("waiting on %s plugin...", name)
},
}
}
+181
View File
@@ -0,0 +1,181 @@
// Copyright 2025 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package age
import (
"errors"
"fmt"
"strings"
"filippo.io/age/internal/bech32"
"filippo.io/age/internal/format"
"filippo.io/hpke"
"golang.org/x/crypto/chacha20poly1305"
)
const pqLabel = "age-encryption.org/mlkem768x25519"
// HybridRecipient is the standard age public key. Messages encrypted to
// this recipient can be decrypted with the corresponding [HybridIdentity].
//
// This recipient is safe against future cryptographically-relevant quantum
// computers, and can only be used along with other post-quantum recipients.
//
// This recipient is anonymous, in the sense that an attacker can't tell from
// the message alone if it is encrypted to a certain recipient.
type HybridRecipient struct {
pk hpke.PublicKey
}
var _ Recipient = &HybridRecipient{}
// newHybridRecipient returns a new [HybridRecipient] from a raw HPKE public key.
func newHybridRecipient(publicKey []byte) (*HybridRecipient, error) {
pk, err := hpke.MLKEM768X25519().NewPublicKey(publicKey)
if err != nil {
return nil, errors.New("invalid MLKEM768-X25519 public key")
}
return &HybridRecipient{pk: pk}, nil
}
// ParseHybridRecipient returns a new [HybridRecipient] from a Bech32 public key
// encoding with the "age1pq1" prefix.
func ParseHybridRecipient(s string) (*HybridRecipient, error) {
t, k, err := bech32.Decode(s)
if err != nil {
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
}
if t != "age1pq" {
return nil, fmt.Errorf("malformed recipient %q: invalid type %q", s, t)
}
r, err := newHybridRecipient(k)
if err != nil {
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
}
return r, nil
}
func (r *HybridRecipient) Wrap(fileKey []byte) ([]*Stanza, error) {
s, _, err := r.WrapWithLabels(fileKey)
return s, err
}
// WrapWithLabels implements [RecipientWithLabels], returning a single
// "postquantum" label. This ensures a HybridRecipient can't be mixed with other
// recipients that would defeat its post-quantum security.
//
// To unsafely bypass this restriction, wrap HybridRecipient in a [Recipient]
// type that doesn't expose WrapWithLabels.
func (r *HybridRecipient) WrapWithLabels(fileKey []byte) ([]*Stanza, []string, error) {
enc, s, err := hpke.NewSender(r.pk, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte(pqLabel))
if err != nil {
return nil, nil, fmt.Errorf("failed to set up HPKE sender: %v", err)
}
ct, err := s.Seal(nil, fileKey)
if err != nil {
return nil, nil, fmt.Errorf("failed to encrypt file key: %v", err)
}
l := &Stanza{
Type: "mlkem768x25519",
Args: []string{format.EncodeToString(enc)},
Body: ct,
}
return []*Stanza{l}, []string{"postquantum"}, nil
}
// String returns the Bech32 public key encoding of r.
func (r *HybridRecipient) String() string {
s, _ := bech32.Encode("age1pq", r.pk.Bytes())
return s
}
// HybridIdentity is the standard age private key, which can decrypt messages
// encrypted to the corresponding [HybridRecipient].
type HybridIdentity struct {
k hpke.PrivateKey
}
var _ Identity = &HybridIdentity{}
// newHybridIdentity returns a new [HybridIdentity] from a raw HPKE private key.
func newHybridIdentity(secretKey []byte) (*HybridIdentity, error) {
k, err := hpke.MLKEM768X25519().NewPrivateKey(secretKey)
if err != nil {
return nil, errors.New("invalid MLKEM768-X25519 secret key")
}
return &HybridIdentity{k: k}, nil
}
// GenerateHybridIdentity randomly generates a new [HybridIdentity].
func GenerateHybridIdentity() (*HybridIdentity, error) {
k, err := hpke.MLKEM768X25519().GenerateKey()
if err != nil {
return nil, fmt.Errorf("failed to generate post-quantum identity: %v", err)
}
return &HybridIdentity{k: k}, nil
}
// ParseHybridIdentity returns a new [HybridIdentity] from a Bech32 private key
// encoding with the "AGE-SECRET-KEY-PQ-1" prefix.
func ParseHybridIdentity(s string) (*HybridIdentity, error) {
t, k, err := bech32.Decode(s)
if err != nil {
return nil, fmt.Errorf("malformed secret key: %v", err)
}
if t != "AGE-SECRET-KEY-PQ-" {
return nil, fmt.Errorf("malformed secret key: unknown type %q", t)
}
r, err := newHybridIdentity(k)
if err != nil {
return nil, fmt.Errorf("malformed secret key: %v", err)
}
return r, nil
}
func (i *HybridIdentity) Unwrap(stanzas []*Stanza) ([]byte, error) {
return multiUnwrap(i.unwrap, stanzas)
}
func (i *HybridIdentity) unwrap(block *Stanza) ([]byte, error) {
if block.Type != "mlkem768x25519" {
return nil, ErrIncorrectIdentity
}
if len(block.Args) != 1 {
return nil, errors.New("invalid mlkem768x25519 recipient block")
}
enc, err := format.DecodeString(block.Args[0])
if err != nil {
return nil, fmt.Errorf("failed to parse mlkem768x25519 recipient: %v", err)
}
if len(block.Body) != fileKeySize+chacha20poly1305.Overhead {
return nil, errIncorrectCiphertextSize
}
r, err := hpke.NewRecipient(enc, i.k, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte(pqLabel))
if err != nil {
// MLKEM768-X25519 does implicit rejection, so a mismatched key does not
// hit this error path, but is only detected later when trying to open.
return nil, fmt.Errorf("invalid mlkem768x25519 recipient: %v", err)
}
fileKey, err := r.Open(nil, block.Body)
if err != nil {
return nil, ErrIncorrectIdentity
}
return fileKey, nil
}
// Recipient returns the public [HybridRecipient] value corresponding to i.
func (i *HybridIdentity) Recipient() *HybridRecipient {
return &HybridRecipient{pk: i.k.PublicKey()}
}
// String returns the Bech32 private key encoding of i.
func (i *HybridIdentity) String() string {
b, _ := i.k.Bytes()
s, _ := bech32.Encode("AGE-SECRET-KEY-PQ-", b)
return strings.ToUpper(s)
}
+62
View File
@@ -7,6 +7,7 @@ package age_test
import ( import (
"bytes" "bytes"
"crypto/rand" "crypto/rand"
"io"
"testing" "testing"
"filippo.io/age" "filippo.io/age"
@@ -49,6 +50,67 @@ func TestX25519RoundTrip(t *testing.T) {
} }
} }
func TestHybridRoundTrip(t *testing.T) {
i, err := age.GenerateHybridIdentity()
if err != nil {
t.Fatal(err)
}
r := i.Recipient()
if r1, err := age.ParseHybridRecipient(r.String()); err != nil {
t.Fatal(err)
} else if r1.String() != r.String() {
t.Errorf("recipient did not round-trip through parsing: got %q, want %q", r1, r)
}
if i1, err := age.ParseHybridIdentity(i.String()); err != nil {
t.Fatal(err)
} else if i1.String() != i.String() {
t.Errorf("identity did not round-trip through parsing: got %q, want %q", i1, i)
}
fileKey := make([]byte, 16)
if _, err := rand.Read(fileKey); err != nil {
t.Fatal(err)
}
stanzas, err := r.Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
out, err := i.Unwrap(stanzas)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fileKey, out) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
}
func TestHybridMixingRestrictions(t *testing.T) {
x25519, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
hybrid, err := age.GenerateHybridIdentity()
if err != nil {
t.Fatal(err)
}
// Hybrid recipients can be used together.
if _, err := age.Encrypt(io.Discard, hybrid.Recipient(), hybrid.Recipient()); err != nil {
t.Errorf("expected two hybrid recipients to work, got %v", err)
}
// Hybrid and X25519 recipients cannot be mixed.
if _, err := age.Encrypt(io.Discard, hybrid.Recipient(), x25519.Recipient()); err == nil {
t.Error("expected hybrid mixed with X25519 to fail")
}
if _, err := age.Encrypt(io.Discard, x25519.Recipient(), hybrid.Recipient()); err == nil {
t.Error("expected X25519 mixed with hybrid to fail")
}
}
func TestScryptRoundTrip(t *testing.T) { func TestScryptRoundTrip(t *testing.T) {
password := "twitch.tv/filosottile" password := "twitch.tv/filosottile"
+12 -4
View File
@@ -27,7 +27,7 @@ const scryptLabel = "age-encryption.org/v1/scrypt"
// for the same file. // for the same file.
// //
// Its use is not recommended for automated systems, which should prefer // Its use is not recommended for automated systems, which should prefer
// X25519Recipient. // [HybridRecipient] or [X25519Recipient].
type ScryptRecipient struct { type ScryptRecipient struct {
password []byte password []byte
workFactor int workFactor int
@@ -150,14 +150,20 @@ func (i *ScryptIdentity) Unwrap(stanzas []*Stanza) ([]byte, error) {
return nil, errors.New("an scrypt recipient must be the only one") return nil, errors.New("an scrypt recipient must be the only one")
} }
} }
return multiUnwrap(i.unwrap, stanzas) for _, s := range stanzas {
if s.Type != "scrypt" {
continue
}
return i.unwrap(s)
}
return nil, fmt.Errorf("%w: file is not passphrase-encrypted", ErrIncorrectIdentity)
} }
var digitsRe = regexp.MustCompile(`^[1-9][0-9]*$`) var digitsRe = regexp.MustCompile(`^[1-9][0-9]*$`)
func (i *ScryptIdentity) unwrap(block *Stanza) ([]byte, error) { func (i *ScryptIdentity) unwrap(block *Stanza) ([]byte, error) {
if block.Type != "scrypt" { if block.Type != "scrypt" {
return nil, ErrIncorrectIdentity return nil, errors.New("internal error: unwrap called on non-scrypt stanza")
} }
if len(block.Args) != 2 { if len(block.Args) != 2 {
return nil, errors.New("invalid scrypt recipient block") return nil, errors.New("invalid scrypt recipient block")
@@ -200,7 +206,9 @@ func (i *ScryptIdentity) unwrap(block *Stanza) ([]byte, error) {
if err == errIncorrectCiphertextSize { if err == errIncorrectCiphertextSize {
return nil, errors.New("invalid scrypt recipient block: incorrect file key size") return nil, errors.New("invalid scrypt recipient block: incorrect file key size")
} else if err != nil { } else if err != nil {
return nil, ErrIncorrectIdentity // Wrap [ErrIncorrectIdentity] so that multiple passphrases can be tried
// in sequence by passing multiple [ScryptIdentity] values to [Decrypt].
return nil, fmt.Errorf("%w: incorrect passphrase", ErrIncorrectIdentity)
} }
return fileKey, nil return fileKey, nil
} }
@@ -0,0 +1,61 @@
// Command age-plugin-tagtest is a that decrypts files encrypted to fixed
// age1tag1... or age1tagpq1... recipients for testing purposes.
//
// It can be used with the "-j" flag:
//
// go install ./tag/internal/age-plugin-tagtest
// age -d -j tagtest file.age
package main
import (
"errors"
"fmt"
"log"
"os"
"filippo.io/age"
"filippo.io/age/plugin"
"filippo.io/age/tag/internal/tagtest"
)
const classicRecipient = "age1tag1qwe0kafsjrar4txm6heqnhpfuggzr0gvznz7fvygxrlq90u5mq2pysxtw6h"
const hybridRecipient = "age1tagpq14h4z7cks9sxftfc8tq4xektt4854ur9rv76tvujdvtzk2fmyywkvh9z2emz3x4epvhz7qdt2v7uksyyq2cdzf3k04ny0g5sc3u4heqh3r9v4cnwhfjw0a2azpgmnk9xk02wvywt5szcq6q3jvwsjxvkn3tsk52vqjczdcvc398ym4j6cvqas4w99gkgt7ur3fmt4g873phr23tgxw3f7wgsz9zxz7m8cp27vpq3h5vc8nssjemtr2etmtmqkg4fzn2u9x9zvtysuya5yrytgx482ftx9864h8a6pprarxd3d0qe8nw2at5ekg3tsahtef7kawasxjamyckw2ans6v933vuypcfrra32f89r2v72mka9hhc55s49xe2khfsq7w9r2zynuzfx4fg6v7jjncsc87rw2yy8qp8hr27edus6zw5xd6m3hax2nxhl2dys9792z3wp5c034sfkrxe86guj7pfdh7sytzrufl9euhuhyf9w6c7z2nwf7v5v8f2s4gplgvfx4jj4le22k2qn242qkqkcwx7llfyrct7jm2wcv0ytypeh6h93ezgtd7q6zr428qze3dec5jxlc5xxjetyephp42fljft3s02p0570kjwyfeyjcnks2vglkvyus5g9l4z6m0gf8wu22ygfm40028txwjlxvvgnn7c36z783c6tmc9k5nef8nucj6u3ustff5vhtnzhnscsvsz79wzrkv3sujtntx4wezucy6lp49flmnyydn3khk2xsesw0ekn4u44nzqw2g2rjyrrl7crshlzttgpe0jvqycjzp9kmtz23t3yu0w9j4n344nnrf88k2jqqfpjxte38pcn0epr879pqsuvajxrkmsas89pvfrzwcewneujn08guj5pvvrtn5hzzg2y6u4wwjqqxx4x8w65yc4dchf750dft8kgcttt2f6j0j5v8s7tkaua78tte7artdfar544vl0rau79h95mc4ghp887z82s6rq93txpkvan86n963kagqkldngnkjcn28zdrh38vdxj002zqs9mx7zjvg3ynzdfhfakkynt9fyqpaxpsdrsqrycuhw5ykwgjz6wldef7xtu6p689234hstxe7v8e5422f2dy8ystn57z3fvy9yfrm4t3lye6ejk5n6x8zqexmql7lx965xcxuuy38xzyt8j9qprnwgfqgx54l4tnjdpzdde6xgmwtnpkfwvyr7rkgnavvjn6a3e56wtvjx3evmhjjxvukpq5zqrj0s4sntkz3yeszs5dty8q0q6m7dgp6mjpvaer0c4343g72eycfqzkjupeaemh0n8e935hqs8fh3jgk7fzyxctzuqlx6d2q9jaf8r9wu4sjxj5w6ppw7m9c3hxrzpcv2uek3kxnndgf2hd99q9v2ux8pjkv29ntslvnvhy09dvcy9578rt89gf4cj4cu79zjxtlj3dpct6rjme02zj3qspsade96njkkufu9zuq2lk3qwvddpxjkqm2hnpqwck54zug7ctvkgvk325lwkg4q5rf73zkgys5e9y8jqc96ntdyl4r78lgtw4k5uljk5ttf46s3gc0rq0jwmddnxt875twwq92505zh3zkse5ag2dhjjxyfzkn7xv3j0kv9r3jzpvgep8fq6z8mar509u4fvnhvthp2ah0r45lsyq0mm6fwkcs30v8k9wzvgt6uvcty6qsjvarjs3htym69zu43m4jd3k4tllrr8c05v6p6spuhup4hkk2p9fp9lxafe3pntcn4nk83gzhjjpcjwyg7jcyz5uancu0fakgz27up7ymzp2xv3sqyqewkkqynskw9qkvysrncxj0cy7dt6q8dsseuwmc2urfmcvkykf82wfa54t85hqx8gywhmhzunm2x0d66a4pwl0xl78fhkces5dpq8pfnp35m5a3u8vdam64zx5s5x9cmnrx3zr066f4f8hlecqnq2fd5quw79ljg3q5nvs6ggmm4gkc"
func init() {
c := tagtest.NewClassicIdentity("age-plugin-tagtest").Recipient().String()
if c != classicRecipient {
log.Fatalf("unexpected classic recipient: %s", c)
}
h := tagtest.NewHybridIdentity("age-plugin-tagtest").Recipient().String()
if h != hybridRecipient {
log.Fatalf("unexpected hybrid recipient: %s", h)
}
}
func main() {
p, err := plugin.New("tagtest")
if err != nil {
log.Fatal(err)
}
p.HandleIdentity(func(b []byte) (age.Identity, error) {
if len(b) != 0 {
return nil, fmt.Errorf("unexpected identity data")
}
return &tagtestIdentity{}, nil
})
os.Exit(p.Main())
}
type tagtestIdentity struct{}
func (i *tagtestIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) {
classic := tagtest.NewClassicIdentity("age-plugin-tagtest")
if key, err := classic.Unwrap(ss); err == nil {
return key, nil
} else if !errors.Is(err, age.ErrIncorrectIdentity) {
return nil, err
}
hybrid := tagtest.NewHybridIdentity("age-plugin-tagtest")
return hybrid.Unwrap(ss)
}
+164
View File
@@ -0,0 +1,164 @@
// Copyright 2025 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package tagtest
import (
"crypto/ecdh"
"crypto/subtle"
"fmt"
"filippo.io/age"
"filippo.io/age/internal/format"
"filippo.io/age/tag"
"filippo.io/hpke"
"filippo.io/nistec"
)
type ClassicIdentity struct {
k hpke.PrivateKey
}
var _ age.Identity = &ClassicIdentity{}
func NewClassicIdentity(seed string) *ClassicIdentity {
k, err := hpke.DHKEM(ecdh.P256()).DeriveKeyPair([]byte(seed))
if err != nil {
panic(fmt.Sprintf("failed to generate key: %v", err))
}
return &ClassicIdentity{k: k}
}
func (i *ClassicIdentity) Recipient() *tag.Recipient {
uncompressed := i.k.PublicKey().Bytes()
p, err := nistec.NewP256Point().SetBytes(uncompressed)
if err != nil {
panic(fmt.Sprintf("failed to parse public key: %v", err))
}
r, err := tag.NewClassicRecipient(p.BytesCompressed())
if err != nil {
panic(fmt.Sprintf("failed to create recipient: %v", err))
}
return r
}
func (i *ClassicIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) {
for _, s := range ss {
if s.Type != "p256tag" {
continue
}
if len(s.Args) != 2 {
return nil, fmt.Errorf("malformed stanza")
}
tagArg, err := format.DecodeString(s.Args[0])
if err != nil {
return nil, fmt.Errorf("malformed tag: %v", err)
}
if len(tagArg) != 4 {
return nil, fmt.Errorf("invalid tag length: %d", len(tagArg))
}
enc, err := format.DecodeString(s.Args[1])
if err != nil {
return nil, fmt.Errorf("malformed encapsulated key: %v", err)
}
if len(enc) != 65 {
return nil, fmt.Errorf("invalid encapsulated key length: %d", len(enc))
}
if len(s.Body) != 32 {
return nil, fmt.Errorf("invalid encrypted file key length: %d", len(s.Body))
}
expTag, err := i.Recipient().Tag(enc)
if err != nil {
return nil, fmt.Errorf("failed to compute tag: %v", err)
}
if subtle.ConstantTimeCompare(tagArg, expTag[:4]) != 1 {
continue
}
r, err := hpke.NewRecipient(enc, i.k, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte("age-encryption.org/p256tag"))
if err != nil {
return nil, fmt.Errorf("failed to unwrap file key: %v", err)
}
fileKey, err := r.Open(nil, s.Body)
if err != nil {
// The tag is only a short hint, and might collide with the tag of a
// different recipient.
continue
}
return fileKey, nil
}
return nil, age.ErrIncorrectIdentity
}
type HybridIdentity struct {
k hpke.PrivateKey
}
var _ age.Identity = &HybridIdentity{}
func NewHybridIdentity(seed string) *HybridIdentity {
k, err := hpke.MLKEM768P256().DeriveKeyPair([]byte(seed))
if err != nil {
panic(fmt.Sprintf("failed to generate key: %v", err))
}
return &HybridIdentity{k: k}
}
func (i *HybridIdentity) Recipient() *tag.Recipient {
r, err := tag.NewHybridRecipient(i.k.PublicKey().Bytes())
if err != nil {
panic(fmt.Sprintf("failed to create recipient: %v", err))
}
return r
}
func (i *HybridIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) {
for _, s := range ss {
if s.Type != "mlkem768p256tag" {
continue
}
if len(s.Args) != 2 {
return nil, fmt.Errorf("malformed stanza")
}
tagArg, err := format.DecodeString(s.Args[0])
if err != nil {
return nil, fmt.Errorf("malformed tag: %v", err)
}
if len(tagArg) != 4 {
return nil, fmt.Errorf("invalid tag length: %d", len(tagArg))
}
enc, err := format.DecodeString(s.Args[1])
if err != nil {
return nil, fmt.Errorf("malformed encapsulated key: %v", err)
}
if len(enc) != 1153 {
return nil, fmt.Errorf("invalid encapsulated key length: %d", len(enc))
}
if len(s.Body) != 32 {
return nil, fmt.Errorf("invalid encrypted file key length: %d", len(s.Body))
}
expTag, err := i.Recipient().Tag(enc)
if err != nil {
return nil, fmt.Errorf("failed to compute tag: %v", err)
}
if subtle.ConstantTimeCompare(tagArg, expTag[:4]) != 1 {
continue
}
r, err := hpke.NewRecipient(enc, i.k, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte("age-encryption.org/mlkem768p256tag"))
if err != nil {
return nil, fmt.Errorf("failed to unwrap file key: %v", err)
}
fileKey, err := r.Open(nil, s.Body)
if err != nil {
// The tag is only a short hint, and might collide with the tag of a
// different recipient.
continue
}
return fileKey, nil
}
return nil, age.ErrIncorrectIdentity
}
+189
View File
@@ -0,0 +1,189 @@
// Copyright 2025 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package tag implements tagged P-256 or hybrid P-256 + ML-KEM-768 recipients,
// which can be used with identities stored on hardware keys, usually supported
// by dedicated plugins.
//
// The tag reduces privacy, by allowing an observer to correlate files with a
// recipient (but not files amongst them without knowledge of the recipient),
// but this is also a desirable property for hardware keys that require user
// interaction for each decryption operation.
package tag
import (
"crypto/ecdh"
"crypto/hkdf"
"crypto/mlkem"
"crypto/sha256"
"fmt"
"slices"
"filippo.io/age"
"filippo.io/age/internal/format"
"filippo.io/age/plugin"
"filippo.io/hpke"
"filippo.io/nistec"
)
// Recipient is a tagged P-256 or hybrid P-256 + ML-KEM-768 recipient.
//
// The latter recipient is safe against future cryptographically-relevant
// quantum computers, and can only be used along with other post-quantum
// recipients.
type Recipient struct {
pk hpke.PublicKey
}
var _ age.Recipient = &Recipient{}
// ParseRecipient returns a new [Recipient] from a Bech32 public key
// encoding with the "age1tag1" or "age1tagpq1" prefix.
func ParseRecipient(s string) (*Recipient, error) {
t, k, err := plugin.ParseRecipient(s)
if err != nil {
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
}
switch t {
case "tag":
r, err := NewClassicRecipient(k)
if err != nil {
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
}
return r, nil
case "tagpq":
r, err := NewHybridRecipient(k)
if err != nil {
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
}
return r, nil
default:
return nil, fmt.Errorf("malformed recipient %q: invalid type %q", s, t)
}
}
const compressedPointSize = 1 + 32
const uncompressedPointSize = 1 + 32 + 32
// NewClassicRecipient returns a new P-256 [Recipient] from a raw public key.
func NewClassicRecipient(publicKey []byte) (*Recipient, error) {
if len(publicKey) != compressedPointSize {
return nil, fmt.Errorf("invalid tag recipient public key size %d", len(publicKey))
}
p, err := nistec.NewP256Point().SetBytes(publicKey)
if err != nil {
return nil, fmt.Errorf("invalid tag recipient public key: %v", err)
}
k, err := hpke.DHKEM(ecdh.P256()).NewPublicKey(p.Bytes())
if err != nil {
return nil, fmt.Errorf("invalid tag recipient public key: %v", err)
}
return &Recipient{k}, nil
}
// NewHybridRecipient returns a new hybrid P-256 + ML-KEM-768 [Recipient] from
// raw concatenated public keys.
func NewHybridRecipient(publicKey []byte) (*Recipient, error) {
k, err := hpke.MLKEM768P256().NewPublicKey(publicKey)
if err != nil {
return nil, fmt.Errorf("invalid tagpq recipient public key: %v", err)
}
return &Recipient{k}, nil
}
// Hybrid reports whether r is a hybrid P-256 + ML-KEM-768 recipient.
func (r *Recipient) Hybrid() bool {
return r.pk.KEM().ID() == hpke.MLKEM768P256().ID()
}
func (r *Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
s, _, err := r.WrapWithLabels(fileKey)
return s, err
}
// Tag computes the 4-byte tag for the given ciphertext enc.
//
// This is a low-level method exposed for use by plugins that implement
// identities compatible with tagged recipients.
func (r *Recipient) Tag(enc []byte) ([]byte, error) {
label, tagRecipient := "age-encryption.org/p256tag", r.Bytes()
if r.Hybrid() {
label = "age-encryption.org/mlkem768p256tag"
// In hybrid mode, the tag is computed over just the P-256 part.
tagRecipient = tagRecipient[mlkem.EncapsulationKeySize768:]
if len(enc) != mlkem.CiphertextSize768+uncompressedPointSize {
return nil, fmt.Errorf("invalid ciphertext size")
}
} else if len(enc) != uncompressedPointSize {
return nil, fmt.Errorf("invalid ciphertext size")
}
rh := sha256.Sum256(tagRecipient)
tag, err := hkdf.Extract(sha256.New, append(slices.Clip(enc), rh[:4]...), []byte(label))
if err != nil {
return nil, fmt.Errorf("failed to compute tag: %v", err)
}
return tag[:4], nil
}
// WrapWithLabels implements [age.RecipientWithLabels], returning a single
// "postquantum" label if r is a hybrid P-256 + ML-KEM-768 recipient. This
// ensures a hybrid Recipient can't be mixed with other recipients that would
// defeat its post-quantum security.
//
// To unsafely bypass this restriction, wrap Recipient in an [age.Recipient]
// type that doesn't expose WrapWithLabels.
func (r *Recipient) WrapWithLabels(fileKey []byte) ([]*age.Stanza, []string, error) {
label, arg := "age-encryption.org/p256tag", "p256tag"
if r.Hybrid() {
label, arg = "age-encryption.org/mlkem768p256tag", "mlkem768p256tag"
}
enc, s, err := hpke.NewSender(r.pk, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte(label))
if err != nil {
return nil, nil, fmt.Errorf("failed to set up HPKE sender: %v", err)
}
ct, err := s.Seal(nil, fileKey)
if err != nil {
return nil, nil, fmt.Errorf("failed to encrypt file key: %v", err)
}
tag, err := r.Tag(enc)
if err != nil {
return nil, nil, fmt.Errorf("failed to compute tag: %v", err)
}
l := &age.Stanza{
Type: arg,
Args: []string{
format.EncodeToString(tag[:4]),
format.EncodeToString(enc),
},
Body: ct,
}
if r.Hybrid() {
return []*age.Stanza{l}, []string{"postquantum"}, nil
}
return []*age.Stanza{l}, nil, nil
}
// Bytes returns the raw recipient encoding.
func (r *Recipient) Bytes() []byte {
if r.Hybrid() {
return r.pk.Bytes()
}
p, err := nistec.NewP256Point().SetBytes(r.pk.Bytes())
if err != nil {
panic("internal error: invalid P-256 public key")
}
return p.BytesCompressed()
}
// String returns the Bech32 public key encoding of r.
func (r *Recipient) String() string {
if r.Hybrid() {
return plugin.EncodeRecipient("tagpq", r.Bytes())
}
return plugin.EncodeRecipient("tag", r.Bytes())
}
+202
View File
@@ -0,0 +1,202 @@
// Copyright 2025 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package tag_test
import (
"bytes"
"io"
"testing"
"filippo.io/age"
"filippo.io/age/internal/format"
"filippo.io/age/tag"
"filippo.io/age/tag/internal/tagtest"
)
func TestClassicRoundTrip(t *testing.T) {
i := tagtest.NewClassicIdentity("test")
r := i.Recipient()
if r.Hybrid() {
t.Error("classic recipient incorrectly reports as hybrid")
}
r1, err := tag.ParseRecipient(r.String())
if err != nil {
t.Fatal(err)
}
if r1.String() != r.String() {
t.Errorf("recipient did not round-trip through parsing: got %q, want %q", r1.String(), r.String())
}
if r1.Hybrid() {
t.Error("parsed classic recipient incorrectly reports as hybrid")
}
plaintext := []byte("hello world")
encrypted := &bytes.Buffer{}
w, err := age.Encrypt(encrypted, r)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(plaintext); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
decrypted, err := age.Decrypt(encrypted, i)
if err != nil {
t.Fatal(err)
}
out, err := io.ReadAll(decrypted)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(plaintext, out) {
t.Errorf("invalid output: %q, expected %q", out, plaintext)
}
}
func TestHybridRoundTrip(t *testing.T) {
i := tagtest.NewHybridIdentity("test")
r := i.Recipient()
if !r.Hybrid() {
t.Error("hybrid recipient incorrectly reports as classic")
}
r1, err := tag.ParseRecipient(r.String())
if err != nil {
t.Fatal(err)
}
if r1.String() != r.String() {
t.Errorf("recipient did not round-trip through parsing: got %q, want %q", r1.String(), r.String())
}
if !r1.Hybrid() {
t.Error("parsed hybrid recipient incorrectly reports as classic")
}
plaintext := []byte("hello world")
encrypted := &bytes.Buffer{}
w, err := age.Encrypt(encrypted, r)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(plaintext); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
decrypted, err := age.Decrypt(encrypted, i)
if err != nil {
t.Fatal(err)
}
out, err := io.ReadAll(decrypted)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(plaintext, out) {
t.Errorf("invalid output: %q, expected %q", out, plaintext)
}
}
func TestTagCollision(t *testing.T) {
tests := []struct {
name string
identity age.Identity
recipient *tag.Recipient
other *tag.Recipient
}{
{
name: "classic",
identity: tagtest.NewClassicIdentity("target"),
recipient: tagtest.NewClassicIdentity("target").Recipient(),
other: tagtest.NewClassicIdentity("other").Recipient(),
},
{
name: "hybrid",
identity: tagtest.NewHybridIdentity("target"),
recipient: tagtest.NewHybridIdentity("target").Recipient(),
other: tagtest.NewHybridIdentity("other").Recipient(),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fileKey := bytes.Repeat([]byte{1}, 16)
stanzas, err := test.recipient.Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
unrelated, err := test.other.Wrap(make([]byte, 16))
if err != nil {
t.Fatal(err)
}
collision := &age.Stanza{
Type: unrelated[0].Type,
Args: append([]string(nil), unrelated[0].Args...),
Body: append([]byte(nil), unrelated[0].Body...),
}
enc, err := format.DecodeString(collision.Args[1])
if err != nil {
t.Fatal(err)
}
collisionTag, err := test.recipient.Tag(enc)
if err != nil {
t.Fatal(err)
}
collision.Args[0] = format.EncodeToString(collisionTag)
allStanzas := append(unrelated, collision)
allStanzas = append(allStanzas, stanzas...)
out, err := test.identity.Unwrap(allStanzas)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fileKey, out) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
})
}
}
func TestTagHybridMixingRestrictions(t *testing.T) {
x25519, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
tagHybrid := tagtest.NewHybridIdentity("test").Recipient()
// Hybrid tag recipients can be used together with hybrid recipients.
hybrid, err := age.GenerateHybridIdentity()
if err != nil {
t.Fatal(err)
}
if _, err := age.Encrypt(io.Discard, tagHybrid, hybrid.Recipient()); err != nil {
t.Errorf("expected hybrid tag + hybrid to work, got %v", err)
}
// Hybrid tag and X25519 recipients cannot be mixed.
if _, err := age.Encrypt(io.Discard, tagHybrid, x25519.Recipient()); err == nil {
t.Error("expected hybrid tag mixed with X25519 to fail")
}
if _, err := age.Encrypt(io.Discard, x25519.Recipient(), tagHybrid); err == nil {
t.Error("expected X25519 mixed with hybrid tag to fail")
}
// Classic tag and X25519 recipients can be mixed (both are non-PQ).
tagClassic := tagtest.NewClassicIdentity("test").Recipient()
if _, err := age.Encrypt(io.Discard, tagClassic, x25519.Recipient()); err != nil {
t.Errorf("expected classic tag + X25519 to work, got %v", err)
}
}
+6
View File
@@ -0,0 +1,6 @@
age-encryption.org/v1
-> X25519 5CD81lZA72aQi0v6EnniOGkwaswpZ0AxCZNdiUVzP04
ol9DvdkiZWeRI4vMKRBVNxowDKwir4UPqYinSM5zqUI
--- 2tyNGCaPoT6UnuOy7sQJf1eXn4pb7z2ukSgTDIxrJxU
W
ï¡‚dtUb¸T ³¹0°ãÇ(àyKAP‚dr1‡M~ï ÄóÝkX>—ªËÌÑcÜ�[ƒ$à9», Gß{턚Fý‰ÂÁtkåÌ*}â9˜T®ÅÄÞ±®hLÐ�çÙWƒ-Rdª˜üË£‡÷ÒH¿SdUà‹ �¬˜’…ÄšFsÈìÀ2y+)æô]/ðÞ,k=Æ8(XŒ”íRÇAÖ×01RÃëY®Š»ƒ·ŽÌ •k4N§†6ÓÚt«v¬çÀb­�êc „ Þ_F0dêvÚx‹î ×ÛÏ$Xø~ /
+184 -43
View File
@@ -3,12 +3,12 @@
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
//go:build go1.18 //go:build go1.18
// +build go1.18
package age_test package age_test
import ( import (
"bytes" "bytes"
"compress/zlib"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors" "errors"
@@ -20,6 +20,7 @@ import (
"filippo.io/age" "filippo.io/age"
"filippo.io/age/armor" "filippo.io/age/armor"
"filippo.io/age/internal/format" "filippo.io/age/internal/format"
"filippo.io/age/internal/inspect"
"filippo.io/age/internal/stream" "filippo.io/age/internal/stream"
"golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/hkdf" "golang.org/x/crypto/hkdf"
@@ -55,6 +56,7 @@ type vector struct {
} }
func parseVector(t *testing.T, test []byte) *vector { func parseVector(t *testing.T, test []byte) *vector {
var z bool
v := &vector{file: test} v := &vector{file: test}
for { for {
line, rest, ok := bytes.Cut(v.file, []byte("\n")) line, rest, ok := bytes.Cut(v.file, []byte("\n"))
@@ -92,7 +94,11 @@ func parseVector(t *testing.T, test []byte) *vector {
} }
v.fileKey = (*[16]byte)(h) v.fileKey = (*[16]byte)(h)
case "identity": case "identity":
var i age.Identity
i, err := age.ParseX25519Identity(value) i, err := age.ParseX25519Identity(value)
if err != nil {
i, err = age.ParseHybridIdentity(value)
}
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -105,20 +111,45 @@ func parseVector(t *testing.T, test []byte) *vector {
v.identities = append(v.identities, i) v.identities = append(v.identities, i)
case "armored": case "armored":
v.armored = true v.armored = true
case "compressed":
if value != "zlib" {
t.Fatal("invalid test file: unknown compression:", value)
}
z = true
case "comment": case "comment":
t.Log(value) t.Log(value)
default: default:
t.Fatal("invalid test file: unknown header key:", key) t.Fatal("invalid test file: unknown header key:", key)
} }
} }
if z {
r, err := zlib.NewReader(bytes.NewReader(v.file))
if err != nil {
t.Fatal(err)
}
b, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
v.file = b
}
return v return v
} }
func TestVectors(t *testing.T) { func TestVectors(t *testing.T) {
forEachVector(t, testVector) forEachVector(t, func(t *testing.T, v *vector) {
var plaintext []byte
t.Run("Decrypt", func(t *testing.T) { plaintext = testDecrypt(t, v) })
t.Run("DecryptReaderAt", func(t *testing.T) { testDecryptReaderAt(t, v, plaintext) })
t.Run("Inspect", func(t *testing.T) { testInspect(t, v, plaintext) })
t.Run("RoundTrip", func(t *testing.T) { testVectorRoundTrip(t, v) })
})
} }
func testVector(t *testing.T, v *vector) { func testDecrypt(t *testing.T, v *vector) []byte {
var in io.Reader = bytes.NewReader(v.file) var in io.Reader = bytes.NewReader(v.file)
if v.armored { if v.armored {
in = armor.NewReader(in) in = armor.NewReader(in)
@@ -127,25 +158,25 @@ func testVector(t *testing.T, v *vector) {
if err != nil && strings.HasSuffix(err.Error(), "bad header MAC") { if err != nil && strings.HasSuffix(err.Error(), "bad header MAC") {
if v.expect == "HMAC failure" { if v.expect == "HMAC failure" {
t.Log(err) t.Log(err)
return return nil
} }
t.Fatalf("expected %s, got HMAC error", v.expect) t.Fatalf("expected %s, got HMAC error", v.expect)
} else if e := new(armor.Error); errors.As(err, &e) { } else if e := new(armor.Error); errors.As(err, &e) {
if v.expect == "armor failure" { if v.expect == "armor failure" {
t.Log(err) t.Log(err)
return return nil
} }
t.Fatalf("expected %s, got: %v", v.expect, err) t.Fatalf("expected %s, got: %v", v.expect, err)
} else if _, ok := err.(*age.NoIdentityMatchError); ok { } else if _, ok := err.(*age.NoIdentityMatchError); ok {
if v.expect == "no match" { if v.expect == "no match" {
t.Log(err) t.Log(err)
return return nil
} }
t.Fatalf("expected %s, got: %v", v.expect, err) t.Fatalf("expected %s, got: %v", v.expect, err)
} else if err != nil { } else if err != nil {
if v.expect == "header failure" { if v.expect == "header failure" {
t.Log(err) t.Log(err)
return return nil
} }
t.Fatalf("expected %s, got: %v", v.expect, err) t.Fatalf("expected %s, got: %v", v.expect, err)
} else if v.expect != "success" && v.expect != "payload failure" && } else if v.expect != "success" && v.expect != "payload failure" &&
@@ -163,27 +194,109 @@ func testVector(t *testing.T, v *vector) {
} }
} }
if v.payloadHash != nil && sha256.Sum256(out) != *v.payloadHash { if v.payloadHash != nil && sha256.Sum256(out) != *v.payloadHash {
t.Error("partial payload hash mismatch") t.Errorf("partial payload hash mismatch, read %d bytes", len(out))
} }
return return out
} else if v.expect != "success" { } else if v.expect != "success" {
t.Fatalf("expected %s, got success", v.expect) t.Fatalf("expected %s, got success", v.expect)
} }
if sha256.Sum256(out) != *v.payloadHash { if sha256.Sum256(out) != *v.payloadHash {
t.Error("payload hash mismatch") t.Error("payload hash mismatch")
} }
return out
} }
// TestVectorsRoundTrip checks that any (valid) armor, header, and/or STREAM func testDecryptReaderAt(t *testing.T, v *vector, plaintext []byte) {
if v.armored {
t.Skip("armor.NewReader does not implement ReaderAt")
}
rAt, s, err := age.DecryptReaderAt(bytes.NewReader(v.file), int64(len(v.file)), v.identities...)
switch v.expect {
case "success":
if err != nil {
t.Fatalf("expected success, got: %v", err)
}
if int64(len(plaintext)) != s {
t.Errorf("unexpected size: got %d, want %d", s, len(plaintext))
}
case "payload failure":
// DecryptReaderAt detects some (but not all) payload failures upfront,
// either from the size of the payload, or by decrypting the last chunk
// to authenticate its size.
if err != nil {
t.Log(err)
return
}
default:
if err != nil {
t.Log(err)
return
}
t.Fatalf("expected %s, got success", v.expect)
}
out, err := io.ReadAll(io.NewSectionReader(rAt, 0, s))
if v.expect == "success" {
if err != nil {
t.Fatalf("expected success, got: %v", err)
}
} else {
if err == nil {
t.Fatalf("expected %s, got success", v.expect)
}
t.Log(err)
// We can't check the partial payload hash, because the ReaderAt will
// notice errors that a linearly scanning Reader could not. For example,
// if there are two final chunks, the linear Reader will decrypt the
// first one and then error out on the second, while the ReaderAt will
// decrypt the second one to check the size, and then know that the
// first chunk could not be the last one. Instead, check that the
// prefix, if any, matches.
if !bytes.HasPrefix(plaintext, out) {
t.Errorf("partial payload prefix mismatch, read %d bytes", len(out))
}
return
}
if sha256.Sum256(out) != *v.payloadHash {
t.Error("payload hash mismatch")
}
}
func testInspect(t *testing.T, v *vector, plaintext []byte) {
if v.expect != "success" {
t.Skip("invalid file, can't inspect")
}
for _, fileSize := range []int64{int64(len(v.file)), -1} {
metadata, err := inspect.Inspect(bytes.NewReader(v.file), fileSize)
if err != nil {
t.Fatalf("inspect failed: %v", err)
}
if metadata.Armor != v.armored {
t.Errorf("unexpected armor: %v", metadata.Armor)
}
if metadata.Armor && metadata.Sizes.Armor == 0 {
t.Errorf("expected non-zero armor size")
}
if metadata.Sizes.Armor+metadata.Sizes.Header+metadata.Sizes.Overhead+metadata.Sizes.MinPayload != int64(len(v.file)) {
t.Errorf("size breakdown does not add up to file size")
}
if metadata.Sizes.MinPayload != int64(len(plaintext)) {
t.Errorf("unexpected payload size: got %d, want %d", metadata.Sizes.MinPayload, len(plaintext))
}
if metadata.Sizes.MaxPayload != metadata.Sizes.MinPayload {
t.Errorf("unexpected max payload size: got %d, want %d", metadata.Sizes.MaxPayload, metadata.Sizes.MinPayload)
}
if metadata.Sizes.MinPadding != 0 || metadata.Sizes.MaxPadding != 0 {
t.Errorf("unexpected padding sizes: got min %d max %d, want 0", metadata.Sizes.MinPadding, metadata.Sizes.MaxPadding)
}
}
}
// testVectorsRoundTrip checks that any (valid) armor, header, and/or STREAM
// payload in the test vectors re-encodes identically. // payload in the test vectors re-encodes identically.
func TestVectorsRoundTrip(t *testing.T) {
forEachVector(t, testVectorRoundTrip)
}
func testVectorRoundTrip(t *testing.T, v *vector) { func testVectorRoundTrip(t *testing.T, v *vector) {
if v.armored { if v.armored {
if v.expect == "armor failure" { if v.expect == "armor failure" {
t.SkipNow() t.Skip("invalid armor, nothing to round-trip")
} }
t.Run("armor", func(t *testing.T) { t.Run("armor", func(t *testing.T) {
payload, err := io.ReadAll(armor.NewReader(bytes.NewReader(v.file))) payload, err := io.ReadAll(armor.NewReader(bytes.NewReader(v.file)))
@@ -212,7 +325,7 @@ func testVectorRoundTrip(t *testing.T, v *vector) {
} }
if v.expect == "header failure" { if v.expect == "header failure" {
t.SkipNow() t.Skip("invalid header, nothing to round-trip")
} }
hdr, p, err := format.Parse(bytes.NewReader(v.file)) hdr, p, err := format.Parse(bytes.NewReader(v.file))
if err != nil { if err != nil {
@@ -234,34 +347,62 @@ func testVectorRoundTrip(t *testing.T, v *vector) {
} }
}) })
if v.expect == "success" { if v.expect != "success" {
t.Run("STREAM", func(t *testing.T) { return
nonce, payload := payload[:16], payload[16:]
key := streamKey(v.fileKey[:], nonce)
r, err := stream.NewReader(key, bytes.NewReader(payload))
if err != nil {
t.Fatal(err)
}
plaintext, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
buf := &bytes.Buffer{}
w, err := stream.NewWriter(key, buf)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(plaintext); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
if !bytes.Equal(buf.Bytes(), payload) {
t.Error("got a different STREAM ciphertext")
}
})
} }
t.Run("STREAM", func(t *testing.T) {
nonce, payload := payload[:16], payload[16:]
key := streamKey(v.fileKey[:], nonce)
r, err := stream.NewDecryptReader(key, bytes.NewReader(payload))
if err != nil {
t.Fatal(err)
}
plaintext, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
rAt, err := stream.NewDecryptReaderAt(key, bytes.NewReader(payload), int64(len(payload)))
if err != nil {
t.Fatal(err)
}
plaintextAt, err := io.ReadAll(io.NewSectionReader(rAt, 0, int64(len(plaintext))))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(plaintextAt, plaintext) {
t.Errorf("got a different plaintext from DecryptReaderAt")
}
buf := &bytes.Buffer{}
w, err := stream.NewEncryptWriter(key, buf)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(plaintext); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
if !bytes.Equal(buf.Bytes(), payload) {
t.Error("got a different STREAM ciphertext")
}
er, err := stream.NewEncryptReader(key, bytes.NewReader(plaintext))
if err != nil {
t.Fatal(err)
}
ciphertext, err := io.ReadAll(er)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(ciphertext, payload) {
t.Error("got a different STREAM ciphertext from EncryptReader")
}
})
} }
func streamKey(fileKey, nonce []byte) []byte { func streamKey(fileKey, nonce []byte) []byte {
+6 -4
View File
@@ -21,8 +21,9 @@ import (
const x25519Label = "age-encryption.org/v1/X25519" const x25519Label = "age-encryption.org/v1/X25519"
// X25519Recipient is the standard age public key. Messages encrypted to this // X25519Recipient is the standard age pre-quantum public key. Messages
// recipient can be decrypted with the corresponding X25519Identity. // encrypted to this recipient can be decrypted with the corresponding
// [X25519Identity]. For post-quantum resistance, use [HybridRecipient].
// //
// This recipient is anonymous, in the sense that an attacker can't tell from // This recipient is anonymous, in the sense that an attacker can't tell from
// the message alone if it is encrypted to a certain recipient. // the message alone if it is encrypted to a certain recipient.
@@ -105,8 +106,9 @@ func (r *X25519Recipient) String() string {
return s return s
} }
// X25519Identity is the standard age private key, which can decrypt messages // X25519Identity is the standard pre-quantum age private key, which can decrypt
// encrypted to the corresponding X25519Recipient. // messages encrypted to the corresponding [X25519Recipient]. For post-quantum
// resistance, use [HybridIdentity].
type X25519Identity struct { type X25519Identity struct {
secretKey, ourPublicKey []byte secretKey, ourPublicKey []byte
} }