mirror of
https://github.com/FiloSottile/age.git
synced 2026-11-08 12:26:58 +00:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b74dce4cdb | ||
|
|
5c1b90c1a6 | ||
|
|
7fcc72e15e | ||
|
|
58ef9fcb8d | ||
|
|
de96c8e8e1 | ||
|
|
3604609d98 | ||
|
|
6ff2f308e6 | ||
|
|
cdb30a188b | ||
|
|
9cbe8d8c2a | ||
|
|
eb706087c6 | ||
|
|
bebcffc2f3 | ||
|
|
572b47c3e7 | ||
|
|
b030f04671 | ||
|
|
352cb3e133 | ||
|
|
e8d1b2f216 | ||
|
|
6424047654 | ||
|
|
88299a50e4 | ||
|
|
ec60cb56b4 | ||
|
|
aaadae2d85 | ||
|
|
019df184df | ||
|
|
e0ffaca260 | ||
|
|
86c6cc24c0 | ||
|
|
fb6bef07c3 | ||
|
|
45445f91cf | ||
|
|
343dda9aa4 | ||
|
|
418c7ac33b | ||
|
|
c45ccfd215 | ||
|
|
366cc58ca8 | ||
|
|
7a8f500567 | ||
|
|
dbe5773864 | ||
|
|
20f9e8574f | ||
|
|
27188e780b | ||
|
|
c083f6045f | ||
|
|
91bc452b9a | ||
|
|
04f65ea9fc | ||
|
|
b155aff565 | ||
|
|
2313f65737 | ||
|
|
d16b021305 | ||
|
|
e65b40fc99 | ||
|
|
ac0ed948b4 | ||
|
|
933c44d4d5 | ||
|
|
6d29a923f3 | ||
|
|
3cfb3adc2a | ||
|
|
72586cd449 | ||
|
|
7c7978c12e |
No files matched your search
@@ -19,6 +19,7 @@ jobs:
|
||||
- {GOOS: darwin, GOARCH: arm64}
|
||||
- {GOOS: darwin, GOARCH: amd64}
|
||||
- {GOOS: windows, GOARCH: amd64}
|
||||
- {GOOS: windows, GOARCH: arm64}
|
||||
- {GOOS: freebsd, GOARCH: amd64}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -36,21 +37,10 @@ jobs:
|
||||
VERSION="$(git describe --tags)"
|
||||
DIR="$(mktemp -d)"
|
||||
mkdir "$DIR/age"
|
||||
go build -o "$DIR/age" -trimpath ./cmd/...
|
||||
go build -o "$DIR/age" -trimpath ./cmd/... ./extra/...
|
||||
cp LICENSE "$DIR/age/LICENSE"
|
||||
cat .github/workflows/LICENSE.suffix.txt >> "$DIR/age/LICENSE"
|
||||
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 )
|
||||
mv "$DIR/age.zip" "age-$VERSION-$GOOS-$GOARCH.zip"
|
||||
else
|
||||
|
||||
@@ -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.
@@ -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-----
|
||||
@@ -15,7 +15,7 @@ age is a simple, modern and secure file encryption tool, format, and Go library.
|
||||
|
||||
It features small explicit keys, post-quantum support, no config options, and UNIX-style composability.
|
||||
|
||||
```
|
||||
```console
|
||||
$ age-keygen -o key.txt
|
||||
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
|
||||
$ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age
|
||||
@@ -150,11 +150,9 @@ $ 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.
|
||||
|
||||
```
|
||||
https://dl.filippo.io/age/latest?for=linux/amd64
|
||||
https://dl.filippo.io/age/v1.3.1?for=darwin/arm64
|
||||
...
|
||||
```
|
||||
- [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)
|
||||
- ...
|
||||
|
||||
If you download the pre-built binaries, you can check their [Sigsum proofs](./SIGSUM.md).
|
||||
|
||||
@@ -185,6 +183,7 @@ Options:
|
||||
-r, --recipient RECIPIENT Encrypt to the specified RECIPIENT. 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.
|
||||
--version Print the version.
|
||||
|
||||
INPUT defaults to standard input, and OUTPUT defaults to standard output.
|
||||
If OUTPUT exists, it will be overwritten.
|
||||
@@ -239,7 +238,7 @@ 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
|
||||
@@ -256,7 +255,7 @@ identities with `age-plugin-pq -identity`.
|
||||
|
||||
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
|
||||
Enter passphrase (leave empty to autogenerate a secure one):
|
||||
Using the autogenerated passphrase "release-response-step-brand-wrap-ankle-pair-unusual-sword-train".
|
||||
@@ -268,7 +267,7 @@ Enter passphrase:
|
||||
|
||||
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
|
||||
Public key: age1yhm4gctwfmrpz87tdslm550wrx6m79y9f2hdzt0lndjnehwj0ukqrjpyx5
|
||||
Enter passphrase (leave empty to autogenerate a secure one):
|
||||
@@ -284,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.)
|
||||
|
||||
```
|
||||
```console
|
||||
$ age -R ~/.ssh/id_ed25519.pub example.jpg > example.jpg.age
|
||||
$ age -d -i ~/.ssh/id_ed25519 example.jpg.age > example.jpg
|
||||
```
|
||||
@@ -295,7 +294,7 @@ 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.
|
||||
|
||||
```
|
||||
```console
|
||||
$ curl https://github.com/benjojo.keys | age -R - example.jpg > example.jpg.age
|
||||
```
|
||||
|
||||
@@ -305,7 +304,7 @@ Keep in mind that people might not protect SSH keys long-term, since they are re
|
||||
|
||||
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".
|
||||
|
||||
@@ -322,6 +321,5 @@ Size breakdown (assuming it decrypts successfully):
|
||||
-------------------
|
||||
Total 1701 bytes
|
||||
|
||||
Tip: for machine-readable output, use --json.
|
||||
```
|
||||
|
||||
For scripting, use `--json` to get machine-readable output.
|
||||
@@ -5,18 +5,18 @@ 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.1?for=darwin/arm64"
|
||||
curl -JLO "https://dl.filippo.io/age/v1.3.1?for=darwin/arm64&proof"
|
||||
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.1-darwin-arm64.tar.gz.proof < age-v1.3.1-darwin-arm64.tar.gz
|
||||
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
|
||||
@@ -26,8 +26,8 @@ docs](https://www.sigsum.org/getting-started/).
|
||||
|
||||
Dear future me, to sign a new release and produce Sigsum proofs, run the following
|
||||
|
||||
```
|
||||
VERSION=v1.3.1
|
||||
```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
|
||||
|
||||
@@ -321,6 +321,8 @@ 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) {
|
||||
|
||||
+229
@@ -15,9 +15,12 @@ import (
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/internal/bech32"
|
||||
"filippo.io/age/plugin"
|
||||
)
|
||||
|
||||
func ExampleEncrypt() {
|
||||
@@ -259,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 {
|
||||
labels []string
|
||||
}
|
||||
@@ -271,6 +380,22 @@ func (t testRecipient) WrapWithLabels(fileKey []byte) (s []*age.Stanza, labels [
|
||||
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) {
|
||||
scrypt, err := age.NewScryptRecipient("xxx")
|
||||
if err != nil {
|
||||
@@ -333,6 +458,83 @@ func (ti *testIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
|
||||
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 {
|
||||
@@ -549,3 +751,30 @@ func TestEncryptReader(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -63,13 +63,16 @@ func NewRSARecipient(pk ssh.PublicKey) (*RSARecipient, error) {
|
||||
} else {
|
||||
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 r, nil
|
||||
}
|
||||
|
||||
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{
|
||||
Type: "ssh-rsa",
|
||||
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,
|
||||
block.Body, []byte(oaepLabel))
|
||||
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
|
||||
}
|
||||
@@ -344,7 +349,9 @@ func (i *Ed25519Identity) unwrap(block *age.Stanza) ([]byte, error) {
|
||||
|
||||
fileKey, err := aeadDecrypt(wrappingKey, block.Body)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
@@ -16,6 +18,33 @@ import (
|
||||
"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) {
|
||||
pk, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
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) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
@@ -101,3 +172,110 @@ func TestSSHEd25519RoundTrip(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,16 +109,17 @@ func (i *EncryptedSSHIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, er
|
||||
var pubKey interface {
|
||||
Equal(x crypto.PublicKey) bool
|
||||
}
|
||||
var decrypted age.Identity
|
||||
switch k := k.(type) {
|
||||
case *ed25519.PrivateKey:
|
||||
i.decrypted, err = NewEd25519Identity(*k)
|
||||
decrypted, err = NewEd25519Identity(*k)
|
||||
pubKey = k.Public().(ed25519.PublicKey)
|
||||
// ParseRawPrivateKey returns inconsistent types. See Issue 429.
|
||||
case ed25519.PrivateKey:
|
||||
i.decrypted, err = NewEd25519Identity(k)
|
||||
decrypted, err = NewEd25519Identity(k)
|
||||
pubKey = k.Public().(ed25519.PublicKey)
|
||||
case *rsa.PrivateKey:
|
||||
i.decrypted, err = NewRSAIdentity(k)
|
||||
decrypted, err = NewRSAIdentity(k)
|
||||
pubKey = &k.PublicKey
|
||||
default:
|
||||
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")
|
||||
}
|
||||
|
||||
i.decrypted = decrypted
|
||||
return i.decrypted.Unwrap(stanzas)
|
||||
}
|
||||
+23
-5
@@ -31,13 +31,24 @@ type armoredWriter struct {
|
||||
dst io.Writer
|
||||
}
|
||||
|
||||
func (a *armoredWriter) Write(p []byte) (int, error) {
|
||||
if !a.started {
|
||||
if _, err := io.WriteString(a.dst, Header+"\n"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
func (a *armoredWriter) writeHeader() error {
|
||||
if a.started {
|
||||
return nil
|
||||
}
|
||||
if _, err := io.WriteString(a.dst, Header+"\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -46,6 +57,9 @@ func (a *armoredWriter) Close() error {
|
||||
return errors.New("ArmoredWriter already closed")
|
||||
}
|
||||
a.closed = true
|
||||
if err := a.writeHeader(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.encoder.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -146,6 +160,10 @@ func (r *armoredReader) Read(p []byte) (int, error) {
|
||||
if len(line) > format.ColumnsPerLine {
|
||||
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[:]
|
||||
n, err := base64.StdEncoding.Strict().Decode(r.unread, line)
|
||||
if err != nil {
|
||||
|
||||
+31
-4
@@ -12,12 +12,12 @@ import (
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agetest "c2sp.org/CCTV/age"
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/armor"
|
||||
"filippo.io/age/internal/format"
|
||||
@@ -94,6 +94,28 @@ func TestArmor(t *testing.T) {
|
||||
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) {
|
||||
buf := &bytes.Buffer{}
|
||||
w := armor.NewWriter(buf)
|
||||
@@ -134,12 +156,13 @@ func testArmor(t *testing.T, size int) {
|
||||
}
|
||||
|
||||
func FuzzMalleability(f *testing.F) {
|
||||
tests, err := filepath.Glob("../testdata/testkit/*")
|
||||
tests, err := fs.ReadDir(agetest.Vectors, ".")
|
||||
if err != nil {
|
||||
f.Fatal(err)
|
||||
}
|
||||
seeds := 0
|
||||
for _, test := range tests {
|
||||
contents, err := os.ReadFile(test)
|
||||
contents, err := fs.ReadFile(agetest.Vectors, test.Name())
|
||||
if err != nil {
|
||||
f.Fatal(err)
|
||||
}
|
||||
@@ -149,8 +172,12 @@ func FuzzMalleability(f *testing.F) {
|
||||
}
|
||||
if bytes.Contains(header, []byte("armored: yes")) {
|
||||
f.Add(contents)
|
||||
seeds++
|
||||
}
|
||||
}
|
||||
if seeds == 0 {
|
||||
f.Fatal("no armored test vectors")
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, data []byte) {
|
||||
r := armor.NewReader(bytes.NewReader(data))
|
||||
content, err := io.ReadAll(r)
|
||||
|
||||
@@ -101,6 +101,9 @@ func main() {
|
||||
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")
|
||||
|
||||
+32
-20
@@ -31,8 +31,8 @@ age-keygen generates a new native X25519 or, with the -pq flag, post-quantum
|
||||
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.
|
||||
If OUTPUT already exists, it is not overwritten.
|
||||
When generating a key, if the output is not going to a terminal, the public key
|
||||
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
|
||||
input and writes the corresponding recipient(s) to OUTPUT or to standard
|
||||
@@ -92,6 +92,20 @@ func main() {
|
||||
errorf("-pq cannot be used with -y")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
converted = convert(in)
|
||||
}
|
||||
|
||||
out := os.Stdout
|
||||
if outFlag != "" {
|
||||
f, err := os.OpenFile(outFlag, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||
@@ -106,18 +120,8 @@ func main() {
|
||||
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 {
|
||||
convert(in, out)
|
||||
writef(out, "%s", converted)
|
||||
} else {
|
||||
if fi, err := out.Stat(); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 {
|
||||
warning("writing secret key to a world-readable file")
|
||||
@@ -149,12 +153,18 @@ func generate(out *os.File, pq bool) {
|
||||
fmt.Fprintf(os.Stderr, "Public key: %s\n", r)
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "# created: %s\n", time.Now().Format(time.RFC3339))
|
||||
fmt.Fprintf(out, "# public key: %s\n", r)
|
||||
fmt.Fprintf(out, "%s\n", i)
|
||||
writef(out, "# created: %s\n", time.Now().Format(time.RFC3339))
|
||||
writef(out, "# public key: %s\n", r)
|
||||
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)
|
||||
if err != nil {
|
||||
errorf("failed to parse input: %v", err)
|
||||
@@ -162,17 +172,19 @@ func convert(in io.Reader, out io.Writer) {
|
||||
if len(ids) == 0 {
|
||||
errorf("no identities found in the input")
|
||||
}
|
||||
var out []byte
|
||||
for _, id := range ids {
|
||||
switch id := id.(type) {
|
||||
case *age.X25519Identity:
|
||||
fmt.Fprintf(out, "%s\n", id.Recipient())
|
||||
out = append(out, id.Recipient().String()...)
|
||||
case *age.HybridIdentity:
|
||||
fmt.Fprintf(out, "%s\n", id.Recipient())
|
||||
out = append(out, id.Recipient().String()...)
|
||||
default:
|
||||
errorf("internal error: unexpected identity type: %T", id)
|
||||
}
|
||||
|
||||
out = append(out, '\n')
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func errorf(format string, v ...any) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -68,11 +68,12 @@ 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.
|
||||
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 limit the
|
||||
maximum scrypt work factor accepted (between 1 and 30, default 30). This can
|
||||
be used to avoid very slow decryptions.`
|
||||
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".
|
||||
|
||||
+17
-4
@@ -19,6 +19,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/agessh"
|
||||
@@ -41,6 +42,7 @@ Options:
|
||||
-r, --recipient RECIPIENT Encrypt to the specified RECIPIENT. 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.
|
||||
--version Print the version.
|
||||
|
||||
INPUT defaults to standard input, and OUTPUT defaults to standard output.
|
||||
If OUTPUT exists, it will be overwritten.
|
||||
@@ -262,8 +264,16 @@ func main() {
|
||||
}
|
||||
}
|
||||
if name := outFlag; name != "" && name != "-" {
|
||||
outFI, outErr := os.Stat(name)
|
||||
for _, f := range inUseFiles {
|
||||
if f == absPath(name) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -286,7 +296,7 @@ func main() {
|
||||
// Buffer the output to check it's printable.
|
||||
out = buf
|
||||
defer func() {
|
||||
if bytes.ContainsFunc(buf.Bytes(), func(r rune) bool {
|
||||
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",
|
||||
@@ -512,10 +522,13 @@ func decrypt(identities []age.Identity, in io.Reader, out io.Writer) {
|
||||
} else if err != nil {
|
||||
errorf("%v", err)
|
||||
}
|
||||
out.Write(nil) // trigger the lazyOpener even if r is empty
|
||||
if _, err := io.Copy(out, r); err != nil {
|
||||
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}
|
||||
@@ -581,7 +594,7 @@ func (l *lazyOpener) Close() error {
|
||||
if l.f != nil {
|
||||
return l.f.Close()
|
||||
}
|
||||
return nil
|
||||
return l.err
|
||||
}
|
||||
|
||||
func absPath(name string) string {
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
@@ -51,6 +53,116 @@ func (testPlugin) Unwrap(ss []*age.Stanza) ([]byte, error) {
|
||||
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.
|
||||
|
||||
+41
-10
@@ -6,6 +6,8 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -71,7 +73,8 @@ func parseRecipientsFile(name string) ([]age.Recipient, error) {
|
||||
const recipientFileSizeLimit = 16 << 20 // 16 MiB
|
||||
const lineLengthLimit = 8 << 10 // 8 KiB, same as sshd(8)
|
||||
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
|
||||
for scanner.Scan() {
|
||||
n++
|
||||
@@ -87,7 +90,7 @@ func parseRecipientsFile(name string) ([]age.Recipient, error) {
|
||||
}
|
||||
r, err := parseRecipient(line)
|
||||
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.
|
||||
warningf("recipients file %q: ignoring unsupported SSH key of type %q at line %d", name, t, n)
|
||||
continue
|
||||
@@ -104,13 +107,16 @@ func parseRecipientsFile(name string) ([]age.Recipient, error) {
|
||||
if err := scanner.Err(); err != nil {
|
||||
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 {
|
||||
return nil, fmt.Errorf("%q: no recipients found", name)
|
||||
}
|
||||
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
|
||||
// field separators like OpenSSH?
|
||||
fields := strings.Split(s, " ")
|
||||
@@ -128,7 +134,26 @@ func sshKeyType(s string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -155,14 +180,16 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) {
|
||||
}
|
||||
|
||||
b := bufio.NewReader(f)
|
||||
p, _ := b.Peek(14) // length of "age-encryption" and "-----BEGIN AGE"
|
||||
peeked := string(p)
|
||||
const maxWhitespace = 1024
|
||||
p, _ := b.Peek(maxWhitespace + len(armor.Header))
|
||||
trimmed := bytes.TrimSpace(p)
|
||||
|
||||
switch {
|
||||
// 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
|
||||
if peeked == "-----BEGIN AGE" {
|
||||
if bytes.HasPrefix(trimmed, []byte(armor.Header)) {
|
||||
r = armor.NewReader(r)
|
||||
}
|
||||
const privateKeySizeLimit = 1 << 24 // 16 MiB
|
||||
@@ -188,7 +215,7 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) {
|
||||
}}, nil
|
||||
|
||||
// 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
|
||||
contents, err := io.ReadAll(io.LimitReader(b, privateKeySizeLimit))
|
||||
if err != nil {
|
||||
@@ -226,7 +253,8 @@ func parseIdentity(s string) (age.Identity, error) {
|
||||
func parseIdentities(f io.Reader) ([]age.Identity, error) {
|
||||
const privateKeySizeLimit = 1 << 24 // 16 MiB
|
||||
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
|
||||
for scanner.Scan() {
|
||||
n++
|
||||
@@ -249,6 +277,9 @@ func parseIdentities(f io.Reader) ([]age.Identity, error) {
|
||||
if err := scanner.Err(); err != nil {
|
||||
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 {
|
||||
return nil, fmt.Errorf("no identities found")
|
||||
}
|
||||
|
||||
Vendored
-1
@@ -42,7 +42,6 @@ stderr 'incompatible recipients'
|
||||
|
||||
# decrypt with native scrypt
|
||||
[!linux] [!darwin] skip # no pty support
|
||||
[darwin] [go1.20] skip # https://go.dev/issue/61779
|
||||
ttyin terminal
|
||||
age -d test.age
|
||||
cmp stdout input
|
||||
|
||||
Vendored
+16
-1
@@ -2,7 +2,6 @@
|
||||
# age file password prompt during encryption
|
||||
|
||||
[!linux] [!darwin] skip # no pty support
|
||||
[darwin] [go1.20] skip # https://go.dev/issue/61779
|
||||
|
||||
# use an encrypted OpenSSH private key without .pub file
|
||||
age -R key_ed25519.pub -o ed25519.age input
|
||||
@@ -60,10 +59,26 @@ ttyout 'Enter passphrase'
|
||||
! stderr .
|
||||
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 --
|
||||
test
|
||||
-- terminal --
|
||||
password
|
||||
-- newpass --
|
||||
password
|
||||
password
|
||||
-- blank --
|
||||
|
||||
-- key_ed25519 --
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABCuvb97i7
|
||||
|
||||
Vendored
+7
@@ -39,9 +39,16 @@ 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
|
||||
Vendored
+4
@@ -23,3 +23,7 @@ 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
|
||||
Vendored
+24
-1
@@ -25,6 +25,22 @@ 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
|
||||
@@ -44,7 +60,6 @@ stderr 'input and output file are the same'
|
||||
cmp key.txt keycopy
|
||||
|
||||
[!linux] [!darwin] skip # no pty support
|
||||
[darwin] [go1.20] skip # https://go.dev/issue/61779
|
||||
|
||||
ttyin terminal
|
||||
! age -p -o inputcopy inputcopy
|
||||
@@ -68,6 +83,14 @@ 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
|
||||
|
||||
+61
@@ -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
|
||||
Vendored
-1
@@ -1,5 +1,4 @@
|
||||
[!linux] [!darwin] skip # no pty support
|
||||
[darwin] [go1.20] skip # https://go.dev/issue/61779
|
||||
|
||||
# encrypt with a provided passphrase
|
||||
stdin input
|
||||
|
||||
Vendored
-1
@@ -1,5 +1,4 @@
|
||||
[!linux] [!darwin] skip # no pty support
|
||||
[darwin] [go1.20] skip # https://go.dev/issue/61779
|
||||
|
||||
# controlling terminal is used instead of stdin/stderr
|
||||
ttyin terminal
|
||||
|
||||
@@ -56,10 +56,10 @@ The passphrase to use for encryption or decryption\. Mutually exclusive with \fB
|
||||
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 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 30\. Can be used to avoid very slow decryptions\.
|
||||
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
|
||||
|
||||
@@ -140,11 +140,13 @@ persisted in the shell history or leaked to other users on multi-user systems.</
|
||||
<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.</dd>
|
||||
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 30.
|
||||
Can be used to avoid very slow decryptions.</dd>
|
||||
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>
|
||||
|
||||
@@ -65,11 +65,13 @@ persisted in the shell history or leaked to other users on multi-user systems.
|
||||
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 30.
|
||||
Can be used to avoid very slow decryptions.
|
||||
Must be between 1 and 30. Default is 22.
|
||||
Can be used to avoid very slow decryptions, or to allow slower ones.
|
||||
|
||||
## EXAMPLES
|
||||
|
||||
|
||||
@@ -66,6 +66,17 @@ func main() {
|
||||
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)
|
||||
@@ -82,18 +93,9 @@ func main() {
|
||||
if fi, err := out.Stat(); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 {
|
||||
warning("writing secret key to a world-readable file")
|
||||
}
|
||||
|
||||
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 _, err := out.Write(converted); err != nil {
|
||||
errorf("failed to write output: %v", err)
|
||||
}
|
||||
|
||||
convert(in, out)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -123,11 +125,12 @@ func main() {
|
||||
os.Exit(p.Main())
|
||||
}
|
||||
|
||||
func convert(in io.Reader, out io.Writer) {
|
||||
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 {
|
||||
@@ -137,8 +140,10 @@ func convert(in io.Reader, out io.Writer) {
|
||||
if err != nil {
|
||||
errorf("failed to decode identity #%d: %v", i+1, err)
|
||||
}
|
||||
fmt.Fprintln(out, plugin.EncodeIdentity("pq", data))
|
||||
out = append(out, plugin.EncodeIdentity("pq", data)...)
|
||||
out = append(out, '\n')
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func errorf(format string, v ...any) {
|
||||
|
||||
@@ -16,7 +16,7 @@ require (
|
||||
|
||||
// Test dependencies.
|
||||
require (
|
||||
c2sp.org/CCTV/age v0.0.0-20260605191406-1e3d2860d46e
|
||||
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d
|
||||
github.com/rogpeppe/go-internal v1.16.0
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
c2sp.org/CCTV/age v0.0.0-20260605191406-1e3d2860d46e h1:N9DpijOhcuCLIlgcX/6qhBQeMQIlBxsVQITjbpJaBso=
|
||||
c2sp.org/CCTV/age v0.0.0-20260605191406-1e3d2860d46e/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
|
||||
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs=
|
||||
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
s = strings.ToLower(s)
|
||||
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)
|
||||
if d == -1 {
|
||||
return "", nil, fmt.Errorf("invalid character data part: s[%d]=%v", p, c)
|
||||
}
|
||||
data = append(data, byte(d))
|
||||
}
|
||||
if len(data) < 6 {
|
||||
return "", nil, fmt.Errorf("data part too short")
|
||||
}
|
||||
if !verifyChecksum(hrp, data) {
|
||||
return "", nil, fmt.Errorf("invalid checksum")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
+112
-15
@@ -109,10 +109,21 @@ var stanzaPrefix = []byte("->")
|
||||
var footerPrefix = []byte("---")
|
||||
|
||||
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 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -132,6 +143,9 @@ func (r *Stanza) Marshal(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 {
|
||||
return err
|
||||
}
|
||||
@@ -154,7 +168,9 @@ func (h *Header) Marshal(w io.Writer) error {
|
||||
}
|
||||
|
||||
type StanzaReader struct {
|
||||
r *bufio.Reader
|
||||
r interface {
|
||||
ReadBytes(delim byte) ([]byte, error)
|
||||
}
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -182,11 +198,6 @@ func (r *StanzaReader) ReadStanza() (s *Stanza, err error) {
|
||||
if prefix != string(stanzaPrefix) || len(args) < 1 {
|
||||
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.Args = args[1:]
|
||||
|
||||
@@ -218,6 +229,44 @@ type ParseError struct {
|
||||
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 {
|
||||
return "parsing age header: " + e.err.Error()
|
||||
}
|
||||
@@ -230,31 +279,62 @@ func errorf(format string, a ...any) error {
|
||||
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
|
||||
// payload.
|
||||
func Parse(input io.Reader) (*Header, io.Reader, error) {
|
||||
h := &Header{}
|
||||
rr := bufio.NewReader(input)
|
||||
hr := &headerReader{r: rr}
|
||||
|
||||
line, err := rr.ReadString('\n')
|
||||
line, err := hr.ReadString('\n')
|
||||
if err == io.EOF {
|
||||
return nil, nil, errorf("file is empty")
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
peek, err := rr.Peek(len(footerPrefix))
|
||||
peek, err := hr.Peek(len(footerPrefix))
|
||||
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)
|
||||
}
|
||||
|
||||
if bytes.Equal(peek, footerPrefix) {
|
||||
line, err := rr.ReadBytes('\n')
|
||||
line, err := hr.ReadBytes('\n')
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
if len(h.Recipients) == maxRecipientStanzas {
|
||||
return nil, nil, errorf("header contains more than %d recipient stanzas", maxRecipientStanzas)
|
||||
}
|
||||
|
||||
s, err := sr.ReadStanza()
|
||||
if err != nil {
|
||||
@@ -276,6 +359,9 @@ func Parse(input io.Reader) (*Header, io.Reader, error) {
|
||||
}
|
||||
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
|
||||
// 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) {
|
||||
l := strings.TrimSuffix(string(line), "\n")
|
||||
parts := strings.Split(l, " ")
|
||||
return parts[0], parts[1:]
|
||||
prefix, rest, ok := strings.Cut(l, " ")
|
||||
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 {
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
package format_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agetest "c2sp.org/CCTV/age"
|
||||
"filippo.io/age/internal/format"
|
||||
)
|
||||
|
||||
@@ -41,15 +44,143 @@ func TestStanzaMarshal(t *testing.T) {
|
||||
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())
|
||||
}
|
||||
|
||||
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) {
|
||||
tests, err := filepath.Glob("../../testdata/testkit/*")
|
||||
tests, err := fs.ReadDir(agetest.Vectors, ".")
|
||||
if err != nil {
|
||||
f.Fatal(err)
|
||||
}
|
||||
for _, test := range tests {
|
||||
contents, err := os.ReadFile(test)
|
||||
contents, err := fs.ReadFile(agetest.Vectors, test.Name())
|
||||
if err != nil {
|
||||
f.Fatal(err)
|
||||
}
|
||||
@@ -59,6 +190,9 @@ func FuzzMalleability(f *testing.F) {
|
||||
}
|
||||
f.Add(contents)
|
||||
}
|
||||
if len(tests) == 0 {
|
||||
f.Fatal("no test vectors")
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, data []byte) {
|
||||
h, payload, err := format.Parse(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
@@ -82,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ type Metadata struct {
|
||||
func Inspect(r io.Reader, fileSize int64) (*Metadata, error) {
|
||||
data := &Metadata{
|
||||
Version: "age-encryption.org/v1",
|
||||
Postquantum: "unknown",
|
||||
Postquantum: "yes",
|
||||
}
|
||||
|
||||
tr := &trackReader{r: r}
|
||||
@@ -64,8 +64,10 @@ func Inspect(r io.Reader, fileSize int64) (*Metadata, error) {
|
||||
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 = "yes"
|
||||
data.Postquantum = "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,12 +105,13 @@ type trackReader struct {
|
||||
}
|
||||
|
||||
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
|
||||
} else if tr.done {
|
||||
panic("non-EOF read after EOF")
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -3,20 +3,21 @@ package inspect
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age/internal/format"
|
||||
"filippo.io/age/internal/stream"
|
||||
)
|
||||
|
||||
// buildFile serializes a header with a single stanza of the given type,
|
||||
// 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, stanzaType string) []byte {
|
||||
func buildFile(t *testing.T, stanzaTypes ...string) []byte {
|
||||
t.Helper()
|
||||
hdr := &format.Header{
|
||||
Recipients: []*format.Stanza{{Type: stanzaType}},
|
||||
MAC: make([]byte, 32),
|
||||
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 {
|
||||
@@ -52,6 +53,73 @@ func TestInspectTagStanzas(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
+22
-11
@@ -12,6 +12,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"sync/atomic"
|
||||
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
@@ -20,6 +21,9 @@ import (
|
||||
const ChunkSize = 64 * 1024
|
||||
|
||||
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
|
||||
@@ -95,7 +99,7 @@ func (r *DecryptReader) Read(p []byte) (int, error) {
|
||||
// words, check for trailing data after a full-length final chunk.
|
||||
// Hopefully, the underlying reader supports returning EOF even if it
|
||||
// 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")
|
||||
} else if err != io.EOF {
|
||||
r.err = fmt.Errorf("non-EOF error reading after end of encrypted file: %w", err)
|
||||
@@ -359,6 +363,20 @@ type cachedChunk struct {
|
||||
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 {
|
||||
@@ -374,7 +392,7 @@ func NewDecryptReaderAt(key []byte, src io.ReaderAt, size int64) (*DecryptReader
|
||||
finalChunkOff := finalChunkIndex * encChunkSize
|
||||
finalChunkSize := size - finalChunkOff
|
||||
finalChunk := make([]byte, finalChunkSize)
|
||||
if _, err := src.ReadAt(finalChunk, finalChunkOff); err != nil {
|
||||
if err := readFullAt(src, finalChunk, finalChunkOff); err != nil {
|
||||
return nil, fmt.Errorf("failed to read final chunk: %w", err)
|
||||
}
|
||||
nonce := nonceForChunk(finalChunkIndex)
|
||||
@@ -412,21 +430,14 @@ func (r *DecryptReaderAt) ReadAt(p []byte, off int64) (n int, err error) {
|
||||
plaintext = cached.data
|
||||
cacheUpdate = nil
|
||||
} else {
|
||||
nn, err := r.src.ReadAt(chunk[:chunkSize], chunkOff)
|
||||
if err == io.EOF {
|
||||
if int64(nn) != chunkSize {
|
||||
err = io.ErrUnexpectedEOF
|
||||
} else {
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
+159
-209
@@ -7,8 +7,10 @@ package stream_test
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"testing"
|
||||
"testing/iotest"
|
||||
|
||||
@@ -18,6 +20,23 @@ import (
|
||||
|
||||
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) {
|
||||
for _, length := range []int{0, 1000, cs - 1, cs, cs + 1, cs + 100, 2 * cs, 2*cs + 500} {
|
||||
for _, stepSize := range []int{512, 600, 1000, cs - 1, cs, cs + 1} {
|
||||
@@ -35,13 +54,9 @@ func TestRoundTrip(t *testing.T) {
|
||||
|
||||
func testRoundTrip(t *testing.T, stepSize, length int) {
|
||||
src := make([]byte, length)
|
||||
if _, err := rand.Read(src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rand.Read(src)
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rand.Read(key)
|
||||
var ciphertext []byte
|
||||
|
||||
t.Run("EncryptWriter", func(t *testing.T) {
|
||||
@@ -201,9 +216,7 @@ func (t *trackingReaderAt) reset() {
|
||||
|
||||
func TestDecryptReaderAt(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rand.Read(key)
|
||||
|
||||
// Create plaintext spanning exactly 3 chunks: 2 full chunks + partial third
|
||||
// Chunk 0: [0, cs)
|
||||
@@ -211,23 +224,8 @@ func TestDecryptReaderAt(t *testing.T) {
|
||||
// Chunk 2: [2*cs, 2*cs+500)
|
||||
plaintextSize := 2*cs + 500
|
||||
plaintext := make([]byte, plaintextSize)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Encrypt
|
||||
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)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
rand.Read(plaintext)
|
||||
ciphertext := encrypt(t, key, plaintext)
|
||||
|
||||
// Create tracking ReaderAt
|
||||
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
|
||||
@@ -266,10 +264,7 @@ func TestDecryptReaderAt(t *testing.T) {
|
||||
|
||||
// Verify data correctness
|
||||
if n > 0 && off >= 0 && off < int64(plaintextSize) {
|
||||
end := int(off) + n
|
||||
if end > plaintextSize {
|
||||
end = plaintextSize
|
||||
}
|
||||
end := min(int(off)+n, plaintextSize)
|
||||
if !bytes.Equal(p[:n], plaintext[off:end]) {
|
||||
t.Errorf("%s: data mismatch", name)
|
||||
}
|
||||
@@ -411,22 +406,100 @@ func TestDecryptReaderAt(t *testing.T) {
|
||||
checkRead("cross boundary 1-2", int64(2*cs-50), 100, 100, false, true)
|
||||
}
|
||||
|
||||
func TestDecryptReaderAtEmpty(t *testing.T) {
|
||||
// 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)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
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)
|
||||
}
|
||||
|
||||
// Create empty encrypted file
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := stream.NewEncryptWriter(key, buf)
|
||||
// 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.Fatal(err)
|
||||
t.Fatalf("ReadAll failed on EOF: %v", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
if !bytes.Equal(got, plaintext) {
|
||||
t.Error("plaintext mismatch")
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
}
|
||||
|
||||
// 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)))
|
||||
@@ -468,28 +541,12 @@ func TestDecryptReaderAtEmpty(t *testing.T) {
|
||||
|
||||
func TestDecryptReaderAtSingleChunk(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rand.Read(key)
|
||||
|
||||
// Single chunk, not full
|
||||
plaintext := make([]byte, 1000)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
rand.Read(plaintext)
|
||||
ciphertext := encrypt(t, key, plaintext)
|
||||
|
||||
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
|
||||
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
|
||||
@@ -532,28 +589,12 @@ func TestDecryptReaderAtSingleChunk(t *testing.T) {
|
||||
|
||||
func TestDecryptReaderAtFullChunks(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rand.Read(key)
|
||||
|
||||
// Exactly 2 full chunks
|
||||
plaintext := make([]byte, 2*cs)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
rand.Read(plaintext)
|
||||
ciphertext := encrypt(t, key, plaintext)
|
||||
|
||||
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
|
||||
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
|
||||
@@ -594,35 +635,15 @@ func TestDecryptReaderAtFullChunks(t *testing.T) {
|
||||
|
||||
func TestDecryptReaderAtWrongKey(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rand.Read(key)
|
||||
plaintext := make([]byte, 1000)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
rand.Read(plaintext)
|
||||
ciphertext := encrypt(t, key, plaintext)
|
||||
|
||||
// Try to decrypt with wrong key
|
||||
wrongKey := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(wrongKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = stream.NewDecryptReaderAt(wrongKey, bytes.NewReader(ciphertext), int64(len(ciphertext)))
|
||||
rand.Read(wrongKey)
|
||||
_, err := stream.NewDecryptReaderAt(wrongKey, bytes.NewReader(ciphertext), int64(len(ciphertext)))
|
||||
if err == nil {
|
||||
t.Error("wrong key: expected error, got nil")
|
||||
}
|
||||
@@ -630,30 +651,13 @@ func TestDecryptReaderAtWrongKey(t *testing.T) {
|
||||
|
||||
func TestDecryptReaderAtInvalidSize(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rand.Read(key)
|
||||
plaintext := make([]byte, 1000)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
rand.Read(plaintext)
|
||||
ciphertext := encrypt(t, key, plaintext)
|
||||
|
||||
// Wrong size (too small)
|
||||
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)-1))
|
||||
_, err := stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)-1))
|
||||
if err == nil {
|
||||
t.Error("wrong size (small): expected error, got nil")
|
||||
}
|
||||
@@ -671,64 +675,44 @@ func TestDecryptReaderAtInvalidSize(t *testing.T) {
|
||||
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)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rand.Read(key)
|
||||
plaintext := make([]byte, 2*cs+500)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rand.Read(plaintext)
|
||||
ciphertext := encrypt(t, key, plaintext)
|
||||
|
||||
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)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
|
||||
// Truncate ciphertext but lie about size
|
||||
// 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 err == nil {
|
||||
t.Error("truncated: expected error, got nil")
|
||||
_, 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)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rand.Read(key)
|
||||
|
||||
// Create 4 chunks: 3 full + 1 partial
|
||||
plaintext := make([]byte, 3*cs+500)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
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,
|
||||
@@ -737,7 +721,7 @@ func TestDecryptReaderAtTruncatedChunk(t *testing.T) {
|
||||
truncatedSize := int64(3 * encChunkSize)
|
||||
truncated := ciphertext[:truncatedSize]
|
||||
|
||||
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(truncated), truncatedSize)
|
||||
_, err := stream.NewDecryptReaderAt(key, bytes.NewReader(truncated), truncatedSize)
|
||||
if err == nil {
|
||||
t.Error("truncated at chunk boundary: expected error, got nil")
|
||||
}
|
||||
@@ -745,30 +729,13 @@ func TestDecryptReaderAtTruncatedChunk(t *testing.T) {
|
||||
|
||||
func TestDecryptReaderAtConcurrent(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rand.Read(key)
|
||||
|
||||
// Create plaintext spanning 3 chunks: 2 full + partial
|
||||
plaintextSize := 2*cs + 500
|
||||
plaintext := make([]byte, plaintextSize)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Encrypt
|
||||
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)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
rand.Read(plaintext)
|
||||
ciphertext := encrypt(t, key, plaintext)
|
||||
|
||||
ra, err := stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)))
|
||||
if err != nil {
|
||||
@@ -907,32 +874,15 @@ func TestDecryptReaderAtConcurrent(t *testing.T) {
|
||||
|
||||
func TestDecryptReaderAtCorrupted(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rand.Read(key)
|
||||
plaintext := make([]byte, 2*cs+500)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
ciphertext := bytes.Clone(buf.Bytes())
|
||||
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)))
|
||||
_, err := stream.NewDecryptReaderAt(key, bytes.NewReader(corruptedFinal), int64(len(corruptedFinal)))
|
||||
if err == nil {
|
||||
t.Error("corrupted final: expected error, got nil")
|
||||
}
|
||||
|
||||
+14
-3
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
@@ -13,6 +15,15 @@ import (
|
||||
// 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) {
|
||||
@@ -64,7 +75,7 @@ func WithTerminal(f func(in, out *os.File) error) error {
|
||||
// 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)
|
||||
printPrompt(out, prompt)
|
||||
defer clearLine(out)
|
||||
s, err = term.ReadPassword(int(in.Fd()))
|
||||
return err
|
||||
@@ -75,7 +86,7 @@ func ReadSecret(prompt string) (s []byte, err error) {
|
||||
// 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 {
|
||||
fmt.Fprintf(out, "%s ", prompt)
|
||||
printPrompt(out, prompt)
|
||||
defer clearLine(out)
|
||||
|
||||
oldState, err := term.MakeRaw(int(in.Fd()))
|
||||
@@ -96,7 +107,7 @@ func ReadPublic(prompt string) (s []byte, err error) {
|
||||
// prompt is ephemeral.
|
||||
func ReadCharacter(prompt string) (c byte, err error) {
|
||||
err = WithTerminal(func(in, out *os.File) error {
|
||||
fmt.Fprintf(out, "%s ", prompt)
|
||||
printPrompt(out, prompt)
|
||||
defer clearLine(out)
|
||||
|
||||
oldState, err := term.MakeRaw(int(in.Fd()))
|
||||
|
||||
@@ -24,7 +24,8 @@ import (
|
||||
func ParseIdentities(f io.Reader) ([]Identity, error) {
|
||||
const privateKeySizeLimit = 1 << 24 // 16 MiB
|
||||
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
|
||||
for scanner.Scan() {
|
||||
n++
|
||||
@@ -44,6 +45,9 @@ func ParseIdentities(f io.Reader) ([]Identity, error) {
|
||||
if err := scanner.Err(); err != nil {
|
||||
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 {
|
||||
return nil, fmt.Errorf("no identities found")
|
||||
}
|
||||
@@ -57,7 +61,9 @@ func parseIdentity(arg string) (Identity, error) {
|
||||
case strings.HasPrefix(arg, "AGE-SECRET-KEY-PQ-1"):
|
||||
return ParseHybridIdentity(arg)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown identity type: %q", arg)
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +80,8 @@ func parseIdentity(arg string) (Identity, error) {
|
||||
func ParseRecipients(f io.Reader) ([]Recipient, error) {
|
||||
const recipientFileSizeLimit = 1 << 24 // 16 MiB
|
||||
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
|
||||
for scanner.Scan() {
|
||||
n++
|
||||
@@ -94,6 +101,9 @@ func ParseRecipients(f io.Reader) ([]Recipient, error) {
|
||||
if err := scanner.Err(); err != nil {
|
||||
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 {
|
||||
return nil, fmt.Errorf("no recipients found")
|
||||
}
|
||||
@@ -107,6 +117,8 @@ func parseRecipient(arg string) (Recipient, error) {
|
||||
case strings.HasPrefix(arg, "age1"):
|
||||
return ParseX25519Recipient(arg)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown recipient type: %q", arg)
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -193,7 +193,7 @@ func NewIdentityWithoutData(name string, ui *ClientUI) (*Identity, error) {
|
||||
return nil, fmt.Errorf("invalid plugin name: %q", name)
|
||||
}
|
||||
return &Identity{
|
||||
name: name, encoding: s, ui: ui,
|
||||
name: strings.ToLower(name), encoding: s, ui: ui,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -457,9 +457,8 @@ func openClientConnection(name, protocol string) (*clientConnection, error) {
|
||||
cmd.Stderr = os.Stderr
|
||||
}
|
||||
|
||||
// We don't want the plugins to rely on the working directory for anything
|
||||
// as different clients might treat it differently, so we set it to an empty
|
||||
// temporary directory.
|
||||
// Avoid running plugins in the client's working directory, as it might
|
||||
// differ between clients.
|
||||
cmd.Dir = os.TempDir()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/internal/bech32"
|
||||
"filippo.io/age/internal/format"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -67,6 +69,59 @@ func (testPQCRecipient) WrapWithLabels(fileKey []byte) ([]*age.Stanza, []string,
|
||||
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) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows support is TODO")
|
||||
|
||||
+19
-5
@@ -16,6 +16,7 @@ import (
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/internal/format"
|
||||
@@ -44,11 +45,14 @@ type Plugin struct {
|
||||
broken bool
|
||||
}
|
||||
|
||||
// New creates a new Plugin with the given name.
|
||||
// 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) {
|
||||
return &Plugin{name: name, stdin: os.Stdin,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -399,6 +403,9 @@ ReadLoop:
|
||||
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):
|
||||
@@ -530,7 +537,7 @@ func (p *Plugin) RequestValue(prompt string, secret bool) (string, error) {
|
||||
if s.Type == "fail" {
|
||||
return "", fmt.Errorf("client failed to request value")
|
||||
}
|
||||
if err := expectStanzaWithBody(s, 0); err != nil {
|
||||
if err := expectStanzaWithAnyBody(s, 0); err != nil {
|
||||
return "", p.fatalInteractf("%v", err)
|
||||
}
|
||||
return string(s.Body), nil
|
||||
@@ -589,8 +596,8 @@ func expectStanzaWithNoBody(s *format.Stanza, wantArgs int) error {
|
||||
}
|
||||
|
||||
func expectStanzaWithBody(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 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)
|
||||
@@ -598,6 +605,13 @@ func expectStanzaWithBody(s *format.Stanza, wantArgs int) error {
|
||||
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)
|
||||
|
||||
@@ -74,14 +74,20 @@ func (i *ClassicIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) {
|
||||
return nil, fmt.Errorf("failed to compute tag: %v", err)
|
||||
}
|
||||
if subtle.ConstantTimeCompare(tagArg, expTag[:4]) != 1 {
|
||||
return nil, age.ErrIncorrectIdentity
|
||||
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)
|
||||
}
|
||||
return r.Open(nil, s.Body)
|
||||
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
|
||||
}
|
||||
@@ -139,14 +145,20 @@ func (i *HybridIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) {
|
||||
return nil, fmt.Errorf("failed to compute tag: %v", err)
|
||||
}
|
||||
if subtle.ConstantTimeCompare(tagArg, expTag[:4]) != 1 {
|
||||
return nil, age.ErrIncorrectIdentity
|
||||
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)
|
||||
}
|
||||
return r.Open(nil, s.Body)
|
||||
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
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/internal/format"
|
||||
"filippo.io/age/tag"
|
||||
"filippo.io/age/tag/internal/tagtest"
|
||||
)
|
||||
@@ -108,6 +109,67 @@ func TestHybridRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in new issue
Block a user