├── .gitattributes ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── config.yml └── workflows │ ├── build.yml │ ├── certs │ ├── README │ ├── uitacllc.crt │ └── uitacllc.key │ ├── interop.yml │ ├── ronn.yml │ └── test.yml ├── AUTHORS ├── LICENSE ├── README.md ├── age.go ├── age_test.go ├── agessh ├── agessh.go ├── agessh_test.go └── encrypted_keys.go ├── armor ├── armor.go └── armor_test.go ├── cmd ├── age-keygen │ └── keygen.go └── age │ ├── age.go │ ├── age_test.go │ ├── encrypted_keys.go │ ├── parse.go │ ├── testdata │ ├── ed25519.txt │ ├── encrypted_keys.txt │ ├── output_file.txt │ ├── pkcs8.txt │ ├── plugin.txt │ ├── rsa.txt │ ├── scrypt.txt │ ├── terminal.txt │ ├── usage.txt │ └── x25519.txt │ ├── tui.go │ └── wordlist.go ├── doc ├── age-keygen.1 ├── age-keygen.1.html ├── age-keygen.1.ronn ├── age.1 ├── age.1.html └── age.1.ronn ├── go.mod ├── go.sum ├── internal ├── bech32 │ ├── bech32.go │ └── bech32_test.go ├── format │ ├── format.go │ └── format_test.go └── stream │ ├── stream.go │ └── stream_test.go ├── logo ├── README.md ├── logo.png ├── logo.svg ├── logo_white.png └── logo_white.svg ├── parse.go ├── plugin ├── client.go ├── client_test.go ├── encode.go └── encode_go1.20.go ├── primitives.go ├── recipients_test.go ├── scrypt.go ├── testdata ├── example.age └── example_keys.txt ├── testkit_test.go └── x25519.go /.gitattributes: -------------------------------------------------------------------------------- 1 | *.age binary 2 | testdata/testkit/* binary 3 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Issues 2 | 3 | I want to hear about any issues you encounter while using age. 4 | 5 | Particularly appreciated are well researched, complete [issues](https://github.com/FiloSottile/age/issues/new/choose) with lots of context, **focusing on the intended outcome and/or use case**. Issues don't have to be just about bugs: if something was hard to figure out or unexpected, please file a **[UX report](https://github.com/FiloSottile/age/discussions/new?category=UX-reports)**! ✨ 6 | 7 | Not all issue reports might lead to a change, so please don't be offended if yours doesn't, but they are precious datapoints to understand how age could work better in aggregate. 8 | 9 | ## Pull requests 10 | 11 | age is a little unusual in how it is maintained. I like to keep the code style consistent and complexity to a minimum, and going through many iterations of code review is a significant toil on both contributors and maintainers. age is also small enough that such a time investment is unlikely to pay off over ongoing contributions. 12 | 13 | Therefore, **be prepared for your change to get reimplemented rather than merged**, and please don't be offended if that happens. PRs are still appreciated as a way to clarify the intended behavior, but are not at all required: prefer focusing on providing detailed context in an issue report instead. 14 | 15 | To learn more, please see my [maintenance policy](https://github.com/FiloSottile/FiloSottile/blob/main/maintenance.md). 16 | 17 | 22 | 23 | ## Other ways to contribute 24 | 25 | age itself is not community maintained, but its ecosystem very much is, and that's where a lot of the strength of age is! Here are some ideas for ways to contribute to age and its ecosystem, besides contributing to this repository. 26 | 27 | * **Write an article about how to use age for a certain community or use case.** The number one reason people don't use age is because they haven't heard about it and existing tutorials present more complex alternatives. 28 | * Integrate age into existing projects that might use it, for example replacing legacy alternatives. 29 | * Build and maintain an [age plugin](https://c2sp.org/age-plugin) for a KMS or platform. 30 | * Watch the [discussions](https://github.com/FiloSottile/age/discussions) and help other users. 31 | * Provide bindings in a language or framework that doesn't support age well. 32 | * Package age for an ecosystem that doesn't have packages yet. 33 | 34 | If you build or write something related to age, [let me know](https://github.com/FiloSottile/age/discussions/new?category=general)! 💖 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 🐞 3 | about: Did you encounter a bug in this implementation? 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Environment 11 | 12 | * OS: 13 | * age version: 14 | 15 | ## What were you trying to do 16 | 17 | ## What happened 18 | 19 | ``` 20 | 21 | ``` 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: UX report ✨ 3 | url: https://github.com/FiloSottile/age/discussions/new?category=UX-reports 4 | about: Was age hard to use? It's not you, it's us. We want to hear about it. 5 | - name: Spec feedback 📃 6 | url: https://github.com/FiloSottile/age/discussions/new?category=Spec-feedback 7 | about: Have a comment about the age spec as it's implemented by this and other tools? 8 | - name: Questions, feature requests, and more 💬 9 | url: https://github.com/FiloSottile/age/discussions 10 | about: Do you need support? Did you make something with age? Do you have an idea? Tell us about it! 11 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and upload binaries 2 | on: 3 | release: 4 | types: [published] 5 | push: 6 | pull_request: 7 | permissions: 8 | contents: read 9 | jobs: 10 | build: 11 | name: Build binaries 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | include: 16 | - {GOOS: linux, GOARCH: amd64} 17 | - {GOOS: linux, GOARCH: arm, GOARM: 6} 18 | - {GOOS: linux, GOARCH: arm64} 19 | - {GOOS: darwin, GOARCH: amd64} 20 | - {GOOS: darwin, GOARCH: arm64} 21 | - {GOOS: windows, GOARCH: amd64} 22 | - {GOOS: freebsd, GOARCH: amd64} 23 | steps: 24 | - name: Install Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: 1.x 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | with: 31 | fetch-depth: 0 32 | - name: Build binary 33 | run: | 34 | cp LICENSE "$RUNNER_TEMP/LICENSE" 35 | echo -e "\n---\n" >> "$RUNNER_TEMP/LICENSE" 36 | curl -L "https://go.dev/LICENSE?m=text" >> "$RUNNER_TEMP/LICENSE" 37 | VERSION="$(git describe --tags)" 38 | DIR="$(mktemp -d)" 39 | mkdir "$DIR/age" 40 | cp "$RUNNER_TEMP/LICENSE" "$DIR/age" 41 | go build -o "$DIR/age" -ldflags "-X main.Version=$VERSION" -trimpath ./cmd/... 42 | if [ "$GOOS" == "windows" ]; then 43 | sudo apt-get update && sudo apt-get install -y osslsigncode 44 | if [ -n "${{ secrets.SIGN_PASS }}" ]; then 45 | for exe in "$DIR"/age/*.exe; do 46 | /usr/bin/osslsigncode sign -t "http://timestamp.comodoca.com" \ 47 | -certs .github/workflows/certs/uitacllc.crt \ 48 | -key .github/workflows/certs/uitacllc.key \ 49 | -pass "${{ secrets.SIGN_PASS }}" \ 50 | -n age -in "$exe" -out "$exe.signed" 51 | mv "$exe.signed" "$exe" 52 | done 53 | fi 54 | ( cd "$DIR"; zip age.zip -r age ) 55 | mv "$DIR/age.zip" "age-$VERSION-$GOOS-$GOARCH.zip" 56 | else 57 | tar -cvzf "age-$VERSION-$GOOS-$GOARCH.tar.gz" -C "$DIR" age 58 | fi 59 | env: 60 | CGO_ENABLED: 0 61 | GOOS: ${{ matrix.GOOS }} 62 | GOARCH: ${{ matrix.GOARCH }} 63 | GOARM: ${{ matrix.GOARM }} 64 | - name: Upload workflow artifacts 65 | uses: actions/upload-artifact@v4 66 | with: 67 | name: age-binaries-${{ matrix.GOOS }}-${{ matrix.GOARCH }} 68 | path: age-* 69 | upload: 70 | name: Upload release binaries 71 | if: github.event_name == 'release' 72 | needs: build 73 | permissions: 74 | contents: write 75 | runs-on: ubuntu-latest 76 | steps: 77 | - name: Download workflow artifacts 78 | uses: actions/download-artifact@v4 79 | with: 80 | pattern: age-binaries-* 81 | merge-multiple: true 82 | - name: Upload release artifacts 83 | run: gh release upload "$GITHUB_REF_NAME" age-* 84 | env: 85 | GH_REPO: ${{ github.repository }} 86 | GH_TOKEN: ${{ github.token }} 87 | -------------------------------------------------------------------------------- /.github/workflows/certs/README: -------------------------------------------------------------------------------- 1 | In this folder there are 2 | 3 | uitacllc.crt 4 | 5 | PKCS#7 encoded certificate chain for a code signing certificate issued 6 | to Up in the Air Consulting LLC valid until Sep 26 23:59:59 2024 GMT. 7 | 8 | https://crt.sh/?id=5339775059 9 | 10 | uitacllc.key 11 | 12 | PEM encrypted private key for the leaf certificate above. 13 | Its passphrase is long and randomly generated, so the awful legacy key 14 | derivation doesn't really matter, and it makes osslsigncode happy. 15 | -------------------------------------------------------------------------------- /.github/workflows/certs/uitacllc.crt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FiloSottile/age/0447d8d089ca7e1ebe85a93b7ef0151a83e5a7d7/.github/workflows/certs/uitacllc.crt -------------------------------------------------------------------------------- /.github/workflows/certs/uitacllc.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | Proc-Type: 4,ENCRYPTED 3 | DEK-Info: AES-256-CBC,B93C1A166F3677D68FB9CB3E8A184729 4 | 5 | UriYsaq3tLyvycDDB2YeQ+9L1P5VCPcfVkYR1ocleF8WxNDUPdz3RqbryAZZdXVO 6 | 0bcvAHTXkdI4Oiw5mN0S8fGsNq9zn+pyResx3lXtgN3oCDCe2SQn28uEEKxPzud5 7 | 0NRXYoBP+pLDjiuQ/6Lp7DovnAO/uxaPFvYRMiknNVOhwyHGWZyuUe01S9J9im7y 8 | vgc1wkyQzmABIhARynEXHp3KnM9aF8X1/ck839lQRBFrvRFNm5rqiON26spr1Hu5 9 | znrbVGROYk0XNdH5VHDk7V9k+v2WLL/b4nxlMymZpDzr9pXzX8olpLnQrarsMbHe 10 | ysfXNTtQi5Dq6KXURW8VA4DmxAzTRNUxe2aA4JnAEyFU5LDLetTN9F9M7BUkHbXH 11 | RpSbZqDjPwg7U98vuSwxjIkncHSiYYi3FmSoupLvV+eIP6qRSgONdzGlP5NTn4Lh 12 | N1lYMPHPldH6UjLHrldkYN16TQlrqNHZExN91XvsZVjpyAgErY18xwi3CTEco45D 13 | fRqsiWXtoas4LkafhSY0vfl5aFhY9YPUpS6uFdgWBvgcQeYb8meX5Nr4dNXVk5Wa 14 | yRlYlW/X0TWC0T9qaBOPN/z7OWO5aL4jYRcKQQ+aR8gFcHGGCpRAKD369OneXfOQ 15 | MD9UHoPG4WTBg/NU9OSskcywfuSOkwAGfBVNXrnEj6tYFjsjYK2nC2gm+opUCfm0 16 | a1FeDb5nQSOgOJKUCO6Aj+0NvDvVLUOsTk1lfzSugIkmUOdV+rXHnrZC+90q8KfN 17 | S2JlzwSZNg0e+VxZpnD7k7axHkbHrbebtrLvzKVnrh3s0OFAXN0isMw7yhhWtzUe 18 | mPoQTZusLDOAJe/QPuNlDUgr4uoVZtoXrPzoZZkw2VFLwYy2g/EYvlK9BdVVTnRm 19 | 9Hq9IBDrZw+SV/7roaeVOXbzrQoxEoXcL7eo6iWvV5Q7Ll5C4ovelHKy3IAzcpYP 20 | 6LKfxAO2sIKTALrHbtBNG+O4RTtxOva1hyg27V4v2k53CF/GhoBRPSpbbupwppXc 21 | lJJ9RtMTRfhCv/ObhdsJED+YUqFifTJfcnQ1iGN8dnBuGrjXxVCN0wgmv46Pdhn0 22 | tUfGlkFquOOWamaVaIvp6JCVUDa1ezMzleILoYvrxvOuP+dGVrwTwVCXpx4JuUgp 23 | d72/w+EnqlZnwsAzdrErJFXnHux981ZoojmG94km1B6gPPwMB8JRcD67lfhG/vne 24 | IpTuuzGaSInf24cGNig01hbBuKSg79yNY0llkECPBXbEhfkemEMhg1WHoNP2eG8j 25 | MHS5OCT5KiOfi77pSO3M2mGB1HWYE5R0lcMibukK9ZdyIYcTeMZ0RcGm6YSNv570 26 | ok/Ex4LUCW66AIWFefmbIOtJSIMHlNKWRPJwnJxVoE5qgH0f/2xL3k15vpI55lAS 27 | sabzegnYlElPbUlZGhgwjKknxgqMhFIW/ZS0h2FukFLwipr4qI47nHWz5dguNkYn 28 | 48sSKg3YMhVx/sT+X2A/6zqsC+p4PT7Ti5ruWb7S9L9vRuBdIDNE9qAwuz0g8Bs3 29 | WhOx6OW2ZqDQEuRhN0lyGA0mwRC4HPFE9b8dnN8lNm+RsnMfNoFxzPnqtsxhEAwa 30 | 2a4ijT97ka94lDy7WQ2bwLRz7trKV/T6MeETKE4s7+z2dMTr1f8IwA2uCovFmO9T 31 | aMQAePFEtDT3qwIPu0zH1ocSCkZ50f7RgVmp4FNn03uT/TnsASrr5CS9m8A9gjEn 32 | QiztQyqt27fTT61YkNdA6lwbpFiByugVbS+mWsNa9kvBkgQkcMQwgrELmU9sYdBT 33 | nRMa60i0nEINT/x3zFvT6R7Dl/O8/QhXLeYv20X2roghPw48IovLb8x7dT3YEQSn 34 | ARIXXVPxwOVvS8xcCa69/+1HjC6vNG9dNNnAsVHxB8mDTBqmmLzAMOVzDoNWEgDd 35 | zoRhQ3ORb1brPlKWg8um/svLiSV63ZYi2J8LPamoGmZ/7J8i5rjOpOeG493UICBR 36 | JymmYGUo6/C1Ze8swdMHApVU/spo0s8BCGkMjYUAaxXD7RufN2DuY30Vny/DMn4y 37 | XasuHS9RstD2Okv25PD06Y2H52HJ6MNdArmPZRe0k2ZbhATs5dXOfmaF5Z0f4IkE 38 | G+hsxE1wlCo900ewntx16sBCbI0v9aE+Napf2+ueqPQ06CdfiTG5yOmeXzgR/8zS 39 | KVmTHpmmFpYtj/N350BLAVb/Hwzmh+ieWnO7TUjvNAHUn2i5LZU65rN3GOlPyIlz 40 | DzB2T6KjOUPFKqSRrIin14HLyf5w0vDuJhe5Zpe0hhYKvoKhwCEVefbmkasWeso3 41 | xsXxOOoL39GA0QpYjR6ztqR8fS9jTeu5IY+zY5LO8yS7+StP3H8CcqRMuxb3ntym 42 | -----END RSA PRIVATE KEY----- 43 | -------------------------------------------------------------------------------- /.github/workflows/interop.yml: -------------------------------------------------------------------------------- 1 | name: Interoperability tests 2 | on: push 3 | permissions: 4 | contents: read 5 | jobs: 6 | trigger: 7 | name: Trigger 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Trigger interoperability tests in str4d/rage 11 | run: > 12 | gh api repos/str4d/rage/dispatches 13 | --field event_type="age-interop-request" 14 | --field client_payload[sha]="$GITHUB_SHA" 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.RAGE_INTEROP_ACCESS_TOKEN }} 17 | -------------------------------------------------------------------------------- /.github/workflows/ronn.yml: -------------------------------------------------------------------------------- 1 | name: Generate man pages 2 | on: 3 | push: 4 | branches: 5 | - '**' 6 | paths: 7 | - '**.ronn' 8 | - '**/ronn.yml' 9 | permissions: 10 | contents: read 11 | jobs: 12 | ronn: 13 | name: Ronn 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - name: Install ronn 19 | run: sudo apt-get update && sudo apt-get install -y ronn 20 | - name: Run ronn 21 | run: bash -O globstar -c 'ronn **/*.ronn' 22 | - name: Undo email mangling 23 | # rdiscount randomizes the output for no good reason, which causes 24 | # changes to always get committed. Sigh. 25 | # https://github.com/davidfstr/rdiscount/blob/6b1471ec3/ext/generate.c#L781-L795 26 | run: |- 27 | for f in doc/*.html; do 28 | awk '/Filippo Valsorda/ { $0 = "

Filippo Valsorda age@filippo.io

" } { print }' "$f" > "$f.tmp" 29 | mv "$f.tmp" "$f" 30 | done 31 | - name: Upload generated files 32 | uses: actions/upload-artifact@v4 33 | with: 34 | name: man-pages 35 | path: | 36 | doc/*.1 37 | doc/*.html 38 | commit: 39 | name: Commit changes 40 | needs: ronn 41 | permissions: 42 | contents: write 43 | runs-on: ubuntu-latest 44 | steps: 45 | - name: Checkout 46 | uses: actions/checkout@v4 47 | - name: Download generated files 48 | uses: actions/download-artifact@v4 49 | with: 50 | name: man-pages 51 | path: doc/ 52 | - name: Commit and push if changed 53 | run: |- 54 | git config user.name "GitHub Actions" 55 | git config user.email "actions@users.noreply.github.com" 56 | git add doc/ 57 | git commit -m "doc: regenerate groff and html man pages" || exit 0 58 | git push 59 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Go tests 2 | on: [push, pull_request] 3 | permissions: 4 | contents: read 5 | jobs: 6 | test: 7 | name: Test 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | go: [1.19.x, 1.x] 12 | os: [ubuntu-latest, macos-latest, windows-latest] 13 | runs-on: ${{ matrix.os }} 14 | steps: 15 | - name: Install Go ${{ matrix.go }} 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version: ${{ matrix.go }} 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | - name: Run tests 24 | run: go test -race ./... 25 | gotip: 26 | name: Test (Go tip) 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | os: [ubuntu-latest, macos-latest, windows-latest] 31 | runs-on: ${{ matrix.os }} 32 | steps: 33 | - name: Install bootstrap Go 34 | uses: actions/setup-go@v5 35 | with: 36 | go-version: stable 37 | - name: Install Go tip (UNIX) 38 | if: runner.os != 'Windows' 39 | run: | 40 | git clone --filter=tree:0 https://go.googlesource.com/go $HOME/gotip 41 | cd $HOME/gotip/src && ./make.bash 42 | echo "$HOME/gotip/bin" >> $GITHUB_PATH 43 | - name: Install Go tip (Windows) 44 | if: runner.os == 'Windows' 45 | run: | 46 | git clone --filter=tree:0 https://go.googlesource.com/go $HOME/gotip 47 | cd $HOME/gotip/src && ./make.bat 48 | echo "$HOME/gotip/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append 49 | - name: Checkout repository 50 | uses: actions/checkout@v4 51 | with: 52 | fetch-depth: 0 53 | - run: go version 54 | - name: Run tests 55 | run: go test -race ./... 56 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | # This is the official list of age authors for copyright purposes. 2 | # To be included, send a change adding the individual or company 3 | # who owns a contribution's copyright. 4 | 5 | Google LLC 6 | Filippo Valsorda 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 The age Authors 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of the age project nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | The age logo, a wireframe of St. Peters dome in Rome, with the text: age, file encryption 6 | 7 |

8 | 9 | [![Go Reference](https://pkg.go.dev/badge/filippo.io/age.svg)](https://pkg.go.dev/filippo.io/age) 10 | [![man page]()](https://filippo.io/age/age.1) 11 | [![C2SP specification](https://img.shields.io/badge/%C2%A7%23-specification-blueviolet)](https://age-encryption.org/v1) 12 | 13 | age is a simple, modern and secure file encryption tool, format, and Go library. 14 | 15 | It features small explicit keys, no config options, and UNIX-style composability. 16 | 17 | ``` 18 | $ age-keygen -o key.txt 19 | Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p 20 | $ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age 21 | $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz 22 | ``` 23 | 24 | 📜 The format specification is at [age-encryption.org/v1](https://age-encryption.org/v1). age was designed by [@Benjojo12](https://twitter.com/Benjojo12) and [@FiloSottile](https://twitter.com/FiloSottile). 25 | 26 | 🦀 An alternative interoperable Rust implementation is available at [github.com/str4d/rage](https://github.com/str4d/rage). 27 | 28 | 🌍 [Typage](https://github.com/FiloSottile/typage) is a TypeScript implementation. It works in the browser, in Node.js, and in Bun. 29 | 30 | 🔑 Hardware PIV tokens such as YubiKeys are supported through the [age-plugin-yubikey](https://github.com/str4d/age-plugin-yubikey) plugin. 31 | 32 | ✨ For more plugins, implementations, tools, and integrations, check out the [awesome age](https://github.com/FiloSottile/awesome-age) list. 33 | 34 | 💬 The author pronounces it `[aɡe̞]` [with a hard *g*](https://translate.google.com/?sl=it&text=aghe), like GIF, and is always spelled lowercase. 35 | 36 | ## Installation 37 | 38 | 39 | 40 | 41 | 44 | 45 | 46 | 47 | 50 | 51 | 52 | 53 | 56 | 57 | 58 | 59 | 62 | 63 | 64 | 65 | 68 | 69 | 70 | 71 | 75 | 76 | 77 | 78 | 81 | 82 | 83 | 84 | 87 | 88 | 89 | 90 | 93 | 94 | 95 | 96 | 99 | 100 | 101 | 102 | 105 | 106 | 107 | 108 | 111 | 112 | 113 | 114 | 117 | 118 | 119 | 120 | 123 | 124 | 125 | 126 | 129 | 130 | 131 | 132 | 135 | 136 | 137 | 138 | 141 | 142 |
Homebrew (macOS or Linux) 42 | brew install age 43 |
MacPorts 48 | port install age 49 |
Alpine Linux v3.15+ 54 | apk add age 55 |
Arch Linux 60 | pacman -S age 61 |
Debian 12+ (Bookworm) 66 | apt install age 67 |
Debian 11 (Bullseye) 72 | apt install age/bullseye-backports 73 | (enable backports for age v1.0.0+) 74 |
Fedora 33+ 79 | dnf install age 80 |
Gentoo Linux 85 | emerge app-crypt/age 86 |
NixOS / Nix 91 | nix-env -i age 92 |
openSUSE Tumbleweed 97 | zypper install age 98 |
Ubuntu 22.04+ 103 | apt install age 104 |
Void Linux 109 | xbps-install age 110 |
FreeBSD 115 | pkg install age (security/age) 116 |
OpenBSD 6.7+ 121 | pkg_add age (security/age) 122 |
Chocolatey (Windows) 127 | choco install age.portable 128 |
Scoop (Windows) 133 | scoop bucket add extras && scoop install age 134 |
pkgx 139 | pkgx install age 140 |
143 | 144 | On Windows, Linux, macOS, and FreeBSD you can use the pre-built binaries. 145 | 146 | ``` 147 | https://dl.filippo.io/age/latest?for=linux/amd64 148 | https://dl.filippo.io/age/v1.1.1?for=darwin/arm64 149 | ... 150 | ``` 151 | 152 | If your system has [a supported version of Go](https://go.dev/dl/), you can build from source. 153 | 154 | ``` 155 | go install filippo.io/age/cmd/...@latest 156 | ``` 157 | 158 | Help from new packagers is very welcome. 159 | 160 | ### Verifying the release signatures 161 | 162 | If you download the pre-built binaries, you can check their 163 | [Sigsum](https://www.sigsum.org) proofs, which are like signatures with extra 164 | transparency: you can cryptographically verify that every proof is logged in a 165 | public append-only log, so you can hold the age project accountable for every 166 | binary release we ever produced. This is similar to what the [Go Checksum 167 | Database](https://go.dev/blog/module-mirror-launch) provides. 168 | 169 | ``` 170 | cat << EOF > age-sigsum-key.pub 171 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM1WpnEswJLPzvXJDiswowy48U+G+G1kmgwUE2eaRHZG 172 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAz2WM5CyPLqiNjk7CLl4roDXwKhQ0QExXLebukZEZFS 173 | EOF 174 | cat << EOF > sigsum-trust-policy.txt 175 | log 154f49976b59ff09a123675f58cb3e346e0455753c3c3b15d465dcb4f6512b0b https://poc.sigsum.org/jellyfish 176 | witness poc.sigsum.org/nisse 1c25f8a44c635457e2e391d1efbca7d4c2951a0aef06225a881e46b98962ac6c 177 | witness rgdd.se/poc-witness 28c92a5a3a054d317c86fc2eeb6a7ab2054d6217100d0be67ded5b74323c5806 178 | group demo-quorum-rule all poc.sigsum.org/nisse rgdd.se/poc-witness 179 | quorum demo-quorum-rule 180 | EOF 181 | 182 | curl -JLO "https://dl.filippo.io/age/v1.2.0?for=darwin/arm64" 183 | curl -JLO "https://dl.filippo.io/age/v1.2.0?for=darwin/arm64&proof" 184 | 185 | go install sigsum.org/sigsum-go/cmd/sigsum-verify@v0.8.0 186 | sigsum-verify -k age-sigsum-key.pub -p sigsum-trust-policy.txt \ 187 | age-v1.2.0-darwin-arm64.tar.gz.proof < age-v1.2.0-darwin-arm64.tar.gz 188 | ``` 189 | 190 | You can learn more about what's happening above in the [Sigsum 191 | docs](https://www.sigsum.org/getting-started/). 192 | 193 | ## Usage 194 | 195 | For the full documentation, read [the age(1) man page](https://filippo.io/age/age.1). 196 | 197 | ``` 198 | Usage: 199 | age [--encrypt] (-r RECIPIENT | -R PATH)... [--armor] [-o OUTPUT] [INPUT] 200 | age [--encrypt] --passphrase [--armor] [-o OUTPUT] [INPUT] 201 | age --decrypt [-i PATH]... [-o OUTPUT] [INPUT] 202 | 203 | Options: 204 | -e, --encrypt Encrypt the input to the output. Default if omitted. 205 | -d, --decrypt Decrypt the input to the output. 206 | -o, --output OUTPUT Write the result to the file at path OUTPUT. 207 | -a, --armor Encrypt to a PEM encoded format. 208 | -p, --passphrase Encrypt with a passphrase. 209 | -r, --recipient RECIPIENT Encrypt to the specified RECIPIENT. Can be repeated. 210 | -R, --recipients-file PATH Encrypt to recipients listed at PATH. Can be repeated. 211 | -i, --identity PATH Use the identity file at PATH. Can be repeated. 212 | 213 | INPUT defaults to standard input, and OUTPUT defaults to standard output. 214 | If OUTPUT exists, it will be overwritten. 215 | 216 | RECIPIENT can be an age public key generated by age-keygen ("age1...") 217 | or an SSH public key ("ssh-ed25519 AAAA...", "ssh-rsa AAAA..."). 218 | 219 | Recipient files contain one or more recipients, one per line. Empty lines 220 | and lines starting with "#" are ignored as comments. "-" may be used to 221 | read recipients from standard input. 222 | 223 | Identity files contain one or more secret keys ("AGE-SECRET-KEY-1..."), 224 | one per line, or an SSH key. Empty lines and lines starting with "#" are 225 | ignored as comments. Passphrase encrypted age files can be used as 226 | identity files. Multiple key files can be provided, and any unused ones 227 | will be ignored. "-" may be used to read identities from standard input. 228 | 229 | When --encrypt is specified explicitly, -i can also be used to encrypt to an 230 | identity file symmetrically, instead or in addition to normal recipients. 231 | ``` 232 | 233 | ### Multiple recipients 234 | 235 | Files can be encrypted to multiple recipients by repeating `-r/--recipient`. Every recipient will be able to decrypt the file. 236 | 237 | ``` 238 | $ age -o example.jpg.age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p \ 239 | -r age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg example.jpg 240 | ``` 241 | 242 | #### Recipient files 243 | 244 | Multiple recipients can also be listed one per line in one or more files passed with the `-R/--recipients-file` flag. 245 | 246 | ``` 247 | $ cat recipients.txt 248 | # Alice 249 | age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p 250 | # Bob 251 | age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg 252 | $ age -R recipients.txt example.jpg > example.jpg.age 253 | ``` 254 | 255 | If the argument to `-R` (or `-i`) is `-`, the file is read from standard input. 256 | 257 | ### Passphrases 258 | 259 | 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. 260 | 261 | ``` 262 | $ age -p secrets.txt > secrets.txt.age 263 | Enter passphrase (leave empty to autogenerate a secure one): 264 | Using the autogenerated passphrase "release-response-step-brand-wrap-ankle-pair-unusual-sword-train". 265 | $ age -d secrets.txt.age > secrets.txt 266 | Enter passphrase: 267 | ``` 268 | 269 | ### Passphrase-protected key files 270 | 271 | If an identity file passed to `-i` is a passphrase encrypted age file, it will be automatically decrypted. 272 | 273 | ``` 274 | $ age-keygen | age -p > key.age 275 | Public key: age1yhm4gctwfmrpz87tdslm550wrx6m79y9f2hdzt0lndjnehwj0ukqrjpyx5 276 | Enter passphrase (leave empty to autogenerate a secure one): 277 | Using the autogenerated passphrase "hip-roast-boring-snake-mention-east-wasp-honey-input-actress". 278 | $ age -r age1yhm4gctwfmrpz87tdslm550wrx6m79y9f2hdzt0lndjnehwj0ukqrjpyx5 secrets.txt > secrets.txt.age 279 | $ age -d -i key.age secrets.txt.age > secrets.txt 280 | Enter passphrase for identity file "key.age": 281 | ``` 282 | 283 | Passphrase-protected identity files are not necessary for most use cases, where access to the encrypted identity file implies access to the whole system. However, they can be useful if the identity file is stored remotely. 284 | 285 | ### SSH keys 286 | 287 | 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.) 288 | 289 | ``` 290 | $ age -R ~/.ssh/id_ed25519.pub example.jpg > example.jpg.age 291 | $ age -d -i ~/.ssh/id_ed25519 example.jpg.age > example.jpg 292 | ``` 293 | 294 | Note that SSH key support employs more complex cryptography, and embeds a public key tag in the encrypted file, making it possible to track files that are encrypted to a specific public key. 295 | 296 | #### Encrypting to a GitHub user 297 | 298 | Combining SSH key support and `-R`, you can easily encrypt a file to the SSH keys listed on a GitHub profile. 299 | 300 | ``` 301 | $ curl https://github.com/benjojo.keys | age -R - example.jpg > example.jpg.age 302 | ``` 303 | 304 | Keep in mind that people might not protect SSH keys long-term, since they are revokable when used only for authentication, and that SSH keys held on YubiKeys can't be used to decrypt files. 305 | -------------------------------------------------------------------------------- /age.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Package age implements file encryption according to the age-encryption.org/v1 6 | // specification. 7 | // 8 | // For most use cases, use the [Encrypt] and [Decrypt] functions with 9 | // [X25519Recipient] and [X25519Identity]. If passphrase encryption is required, use 10 | // [ScryptRecipient] and [ScryptIdentity]. For compatibility with existing SSH keys 11 | // use the filippo.io/age/agessh package. 12 | // 13 | // age encrypted files are binary and not malleable. For encoding them as text, 14 | // use the filippo.io/age/armor package. 15 | // 16 | // # Key management 17 | // 18 | // age does not have a global keyring. Instead, since age keys are small, 19 | // textual, and cheap, you are encouraged to generate dedicated keys for each 20 | // task and application. 21 | // 22 | // Recipient public keys can be passed around as command line flags and in 23 | // config files, while secret keys should be stored in dedicated files, through 24 | // secret management systems, or as environment variables. 25 | // 26 | // There is no default path for age keys. Instead, they should be stored at 27 | // application-specific paths. The CLI supports files where private keys are 28 | // listed one per line, ignoring empty lines and lines starting with "#". These 29 | // files can be parsed with ParseIdentities. 30 | // 31 | // When integrating age into a new system, it's recommended that you only 32 | // support X25519 keys, and not SSH keys. The latter are supported for manual 33 | // encryption operations. If you need to tie into existing key management 34 | // infrastructure, you might want to consider implementing your own Recipient 35 | // and Identity. 36 | // 37 | // # Backwards compatibility 38 | // 39 | // Files encrypted with a stable version (not alpha, beta, or release candidate) 40 | // of age, or with any v1.0.0 beta or release candidate, will decrypt with any 41 | // later versions of the v1 API. This might change in v2, in which case v1 will 42 | // be maintained with security fixes for compatibility with older files. 43 | // 44 | // If decrypting an older file poses a security risk, doing so might require an 45 | // explicit opt-in in the API. 46 | package age 47 | 48 | import ( 49 | "crypto/hmac" 50 | "crypto/rand" 51 | "errors" 52 | "fmt" 53 | "io" 54 | "sort" 55 | 56 | "filippo.io/age/internal/format" 57 | "filippo.io/age/internal/stream" 58 | ) 59 | 60 | // An Identity is passed to [Decrypt] to unwrap an opaque file key from a 61 | // recipient stanza. It can be for example a secret key like [X25519Identity], a 62 | // plugin, or a custom implementation. 63 | type Identity interface { 64 | // Unwrap must return an error wrapping [ErrIncorrectIdentity] if none of 65 | // the recipient stanzas match the identity, any other error will be 66 | // considered fatal. 67 | // 68 | // Most age API users won't need to interact with this method directly, and 69 | // should instead pass [Identity] implementations to [Decrypt]. 70 | Unwrap(stanzas []*Stanza) (fileKey []byte, err error) 71 | } 72 | 73 | // ErrIncorrectIdentity is returned by [Identity.Unwrap] if none of the 74 | // recipient stanzas match the identity. 75 | var ErrIncorrectIdentity = errors.New("incorrect identity for recipient block") 76 | 77 | // A Recipient is passed to [Encrypt] to wrap an opaque file key to one or more 78 | // recipient stanza(s). It can be for example a public key like [X25519Recipient], 79 | // a plugin, or a custom implementation. 80 | type Recipient interface { 81 | // Most age API users won't need to interact with this method directly, and 82 | // should instead pass [Recipient] implementations to [Encrypt]. 83 | Wrap(fileKey []byte) ([]*Stanza, error) 84 | } 85 | 86 | // RecipientWithLabels can be optionally implemented by a [Recipient], in which 87 | // case [Encrypt] will use WrapWithLabels instead of [Recipient.Wrap]. 88 | // 89 | // Encrypt will succeed only if the labels returned by all the recipients 90 | // (assuming the empty set for those that don't implement RecipientWithLabels) 91 | // are the same. 92 | // 93 | // This can be used to ensure a recipient is only used with other recipients 94 | // with equivalent properties (for example by setting a "postquantum" label) or 95 | // to ensure a recipient is always used alone (by returning a random label, for 96 | // example to preserve its authentication properties). 97 | type RecipientWithLabels interface { 98 | WrapWithLabels(fileKey []byte) (s []*Stanza, labels []string, err error) 99 | } 100 | 101 | // A Stanza is a section of the age header that encapsulates the file key as 102 | // encrypted to a specific recipient. 103 | // 104 | // Most age API users won't need to interact with this type directly, and should 105 | // instead pass [Recipient] implementations to [Encrypt] and [Identity] 106 | // implementations to [Decrypt]. 107 | type Stanza struct { 108 | Type string 109 | Args []string 110 | Body []byte 111 | } 112 | 113 | const fileKeySize = 16 114 | const streamNonceSize = 16 115 | 116 | // Encrypt encrypts a file to one or more recipients. 117 | // 118 | // Writes to the returned WriteCloser are encrypted and written to dst as an age 119 | // file. Every recipient will be able to decrypt the file. 120 | // 121 | // The caller must call Close on the WriteCloser when done for the last chunk to 122 | // be encrypted and flushed to dst. 123 | func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) { 124 | if len(recipients) == 0 { 125 | return nil, errors.New("no recipients specified") 126 | } 127 | 128 | fileKey := make([]byte, fileKeySize) 129 | if _, err := rand.Read(fileKey); err != nil { 130 | return nil, err 131 | } 132 | 133 | hdr := &format.Header{} 134 | var labels []string 135 | for i, r := range recipients { 136 | stanzas, l, err := wrapWithLabels(r, fileKey) 137 | if err != nil { 138 | return nil, fmt.Errorf("failed to wrap key for recipient #%d: %v", i, err) 139 | } 140 | sort.Strings(l) 141 | if i == 0 { 142 | labels = l 143 | } else if !slicesEqual(labels, l) { 144 | return nil, fmt.Errorf("incompatible recipients") 145 | } 146 | for _, s := range stanzas { 147 | hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s)) 148 | } 149 | } 150 | if mac, err := headerMAC(fileKey, hdr); err != nil { 151 | return nil, fmt.Errorf("failed to compute header MAC: %v", err) 152 | } else { 153 | hdr.MAC = mac 154 | } 155 | if err := hdr.Marshal(dst); err != nil { 156 | return nil, fmt.Errorf("failed to write header: %v", err) 157 | } 158 | 159 | nonce := make([]byte, streamNonceSize) 160 | if _, err := rand.Read(nonce); err != nil { 161 | return nil, err 162 | } 163 | if _, err := dst.Write(nonce); err != nil { 164 | return nil, fmt.Errorf("failed to write nonce: %v", err) 165 | } 166 | 167 | return stream.NewWriter(streamKey(fileKey, nonce), dst) 168 | } 169 | 170 | func wrapWithLabels(r Recipient, fileKey []byte) (s []*Stanza, labels []string, err error) { 171 | if r, ok := r.(RecipientWithLabels); ok { 172 | return r.WrapWithLabels(fileKey) 173 | } 174 | s, err = r.Wrap(fileKey) 175 | return 176 | } 177 | 178 | func slicesEqual(s1, s2 []string) bool { 179 | if len(s1) != len(s2) { 180 | return false 181 | } 182 | for i := range s1 { 183 | if s1[i] != s2[i] { 184 | return false 185 | } 186 | } 187 | return true 188 | } 189 | 190 | // NoIdentityMatchError is returned by [Decrypt] when none of the supplied 191 | // identities match the encrypted file. 192 | type NoIdentityMatchError struct { 193 | // Errors is a slice of all the errors returned to Decrypt by the Unwrap 194 | // calls it made. They all wrap [ErrIncorrectIdentity]. 195 | Errors []error 196 | } 197 | 198 | func (*NoIdentityMatchError) Error() string { 199 | return "no identity matched any of the recipients" 200 | } 201 | 202 | // Decrypt decrypts a file encrypted to one or more identities. 203 | // 204 | // It returns a Reader reading the decrypted plaintext of the age file read 205 | // from src. All identities will be tried until one successfully decrypts the file. 206 | // 207 | // If no identity matches the encrypted file, the returned error will be of type 208 | // [NoIdentityMatchError]. 209 | func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error) { 210 | if len(identities) == 0 { 211 | return nil, errors.New("no identities specified") 212 | } 213 | 214 | hdr, payload, err := format.Parse(src) 215 | if err != nil { 216 | return nil, fmt.Errorf("failed to read header: %w", err) 217 | } 218 | 219 | stanzas := make([]*Stanza, 0, len(hdr.Recipients)) 220 | for _, s := range hdr.Recipients { 221 | stanzas = append(stanzas, (*Stanza)(s)) 222 | } 223 | errNoMatch := &NoIdentityMatchError{} 224 | var fileKey []byte 225 | for _, id := range identities { 226 | fileKey, err = id.Unwrap(stanzas) 227 | if errors.Is(err, ErrIncorrectIdentity) { 228 | errNoMatch.Errors = append(errNoMatch.Errors, err) 229 | continue 230 | } 231 | if err != nil { 232 | return nil, err 233 | } 234 | 235 | break 236 | } 237 | if fileKey == nil { 238 | return nil, errNoMatch 239 | } 240 | 241 | if mac, err := headerMAC(fileKey, hdr); err != nil { 242 | return nil, fmt.Errorf("failed to compute header MAC: %v", err) 243 | } else if !hmac.Equal(mac, hdr.MAC) { 244 | return nil, errors.New("bad header MAC") 245 | } 246 | 247 | nonce := make([]byte, streamNonceSize) 248 | if _, err := io.ReadFull(payload, nonce); err != nil { 249 | return nil, fmt.Errorf("failed to read nonce: %w", err) 250 | } 251 | 252 | return stream.NewReader(streamKey(fileKey, nonce), payload) 253 | } 254 | 255 | // multiUnwrap is a helper that implements Identity.Unwrap in terms of a 256 | // function that unwraps a single recipient stanza. 257 | func multiUnwrap(unwrap func(*Stanza) ([]byte, error), stanzas []*Stanza) ([]byte, error) { 258 | for _, s := range stanzas { 259 | fileKey, err := unwrap(s) 260 | if errors.Is(err, ErrIncorrectIdentity) { 261 | // If we ever start returning something interesting wrapping 262 | // ErrIncorrectIdentity, we should let it make its way up through 263 | // Decrypt into NoIdentityMatchError.Errors. 264 | continue 265 | } 266 | if err != nil { 267 | return nil, err 268 | } 269 | return fileKey, nil 270 | } 271 | return nil, ErrIncorrectIdentity 272 | } 273 | -------------------------------------------------------------------------------- /age_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package age_test 6 | 7 | import ( 8 | "bytes" 9 | "fmt" 10 | "io" 11 | "log" 12 | "os" 13 | "strings" 14 | "testing" 15 | 16 | "filippo.io/age" 17 | ) 18 | 19 | func ExampleEncrypt() { 20 | publicKey := "age1cy0su9fwf3gf9mw868g5yut09p6nytfmmnktexz2ya5uqg9vl9sss4euqm" 21 | recipient, err := age.ParseX25519Recipient(publicKey) 22 | if err != nil { 23 | log.Fatalf("Failed to parse public key %q: %v", publicKey, err) 24 | } 25 | 26 | out := &bytes.Buffer{} 27 | 28 | w, err := age.Encrypt(out, recipient) 29 | if err != nil { 30 | log.Fatalf("Failed to create encrypted file: %v", err) 31 | } 32 | if _, err := io.WriteString(w, "Black lives matter."); err != nil { 33 | log.Fatalf("Failed to write to encrypted file: %v", err) 34 | } 35 | if err := w.Close(); err != nil { 36 | log.Fatalf("Failed to close encrypted file: %v", err) 37 | } 38 | 39 | fmt.Printf("Encrypted file size: %d\n", out.Len()) 40 | // Output: 41 | // Encrypted file size: 219 42 | } 43 | 44 | // DO NOT hardcode the private key. Store it in a secret storage solution, 45 | // on disk if the local machine is trusted, or have the user provide it. 46 | var privateKey string 47 | 48 | func init() { 49 | privateKey = "AGE-SECRET-KEY-184JMZMVQH3E6U0PSL869004Y3U2NYV7R30EU99CSEDNPH02YUVFSZW44VU" 50 | } 51 | 52 | func ExampleDecrypt() { 53 | identity, err := age.ParseX25519Identity(privateKey) 54 | if err != nil { 55 | log.Fatalf("Failed to parse private key: %v", err) 56 | } 57 | 58 | f, err := os.Open("testdata/example.age") 59 | if err != nil { 60 | log.Fatalf("Failed to open file: %v", err) 61 | } 62 | 63 | r, err := age.Decrypt(f, identity) 64 | if err != nil { 65 | log.Fatalf("Failed to open encrypted file: %v", err) 66 | } 67 | out := &bytes.Buffer{} 68 | if _, err := io.Copy(out, r); err != nil { 69 | log.Fatalf("Failed to read encrypted file: %v", err) 70 | } 71 | 72 | fmt.Printf("File contents: %q\n", out.Bytes()) 73 | // Output: 74 | // File contents: "Black lives matter." 75 | } 76 | 77 | func ExampleParseIdentities() { 78 | keyFile, err := os.Open("testdata/example_keys.txt") 79 | if err != nil { 80 | log.Fatalf("Failed to open private keys file: %v", err) 81 | } 82 | identities, err := age.ParseIdentities(keyFile) 83 | if err != nil { 84 | log.Fatalf("Failed to parse private key: %v", err) 85 | } 86 | 87 | f, err := os.Open("testdata/example.age") 88 | if err != nil { 89 | log.Fatalf("Failed to open file: %v", err) 90 | } 91 | 92 | r, err := age.Decrypt(f, identities...) 93 | if err != nil { 94 | log.Fatalf("Failed to open encrypted file: %v", err) 95 | } 96 | out := &bytes.Buffer{} 97 | if _, err := io.Copy(out, r); err != nil { 98 | log.Fatalf("Failed to read encrypted file: %v", err) 99 | } 100 | 101 | fmt.Printf("File contents: %q\n", out.Bytes()) 102 | // Output: 103 | // File contents: "Black lives matter." 104 | } 105 | 106 | func ExampleGenerateX25519Identity() { 107 | identity, err := age.GenerateX25519Identity() 108 | if err != nil { 109 | log.Fatalf("Failed to generate key pair: %v", err) 110 | } 111 | 112 | fmt.Printf("Public key: %s...\n", identity.Recipient().String()[:4]) 113 | fmt.Printf("Private key: %s...\n", identity.String()[:16]) 114 | // Output: 115 | // Public key: age1... 116 | // Private key: AGE-SECRET-KEY-1... 117 | } 118 | 119 | const helloWorld = "Hello, Twitch!" 120 | 121 | func TestEncryptDecryptX25519(t *testing.T) { 122 | a, err := age.GenerateX25519Identity() 123 | if err != nil { 124 | t.Fatal(err) 125 | } 126 | b, err := age.GenerateX25519Identity() 127 | if err != nil { 128 | t.Fatal(err) 129 | } 130 | buf := &bytes.Buffer{} 131 | w, err := age.Encrypt(buf, a.Recipient(), b.Recipient()) 132 | if err != nil { 133 | t.Fatal(err) 134 | } 135 | if _, err := io.WriteString(w, helloWorld); err != nil { 136 | t.Fatal(err) 137 | } 138 | if err := w.Close(); err != nil { 139 | t.Fatal(err) 140 | } 141 | 142 | out, err := age.Decrypt(buf, b) 143 | if err != nil { 144 | t.Fatal(err) 145 | } 146 | outBytes, err := io.ReadAll(out) 147 | if err != nil { 148 | t.Fatal(err) 149 | } 150 | if string(outBytes) != helloWorld { 151 | t.Errorf("wrong data: %q, excepted %q", outBytes, helloWorld) 152 | } 153 | } 154 | 155 | func TestEncryptDecryptScrypt(t *testing.T) { 156 | password := "twitch.tv/filosottile" 157 | 158 | r, err := age.NewScryptRecipient(password) 159 | if err != nil { 160 | t.Fatal(err) 161 | } 162 | r.SetWorkFactor(15) 163 | buf := &bytes.Buffer{} 164 | w, err := age.Encrypt(buf, r) 165 | if err != nil { 166 | t.Fatal(err) 167 | } 168 | if _, err := io.WriteString(w, helloWorld); err != nil { 169 | t.Fatal(err) 170 | } 171 | if err := w.Close(); err != nil { 172 | t.Fatal(err) 173 | } 174 | 175 | i, err := age.NewScryptIdentity(password) 176 | if err != nil { 177 | t.Fatal(err) 178 | } 179 | out, err := age.Decrypt(buf, i) 180 | if err != nil { 181 | t.Fatal(err) 182 | } 183 | outBytes, err := io.ReadAll(out) 184 | if err != nil { 185 | t.Fatal(err) 186 | } 187 | if string(outBytes) != helloWorld { 188 | t.Errorf("wrong data: %q, excepted %q", outBytes, helloWorld) 189 | } 190 | } 191 | 192 | func TestParseIdentities(t *testing.T) { 193 | tests := []struct { 194 | name string 195 | wantCount int 196 | wantErr bool 197 | file string 198 | }{ 199 | {"valid", 2, false, ` 200 | # this is a comment 201 | # AGE-SECRET-KEY-1705XN76M8EYQ8M9PY4E2G3KA8DN7NSCGT3V4HMN20H3GCX4AS6HSSTG8D3 202 | # 203 | 204 | AGE-SECRET-KEY-1D6K0SGAX3NU66R4GYFZY0UQWCLM3UUSF3CXLW4KXZM342WQSJ82QKU59QJ 205 | AGE-SECRET-KEY-19WUMFE89H3928FRJ5U3JYRNHM6CERQGKSQ584AQ8QY7T7R09D32SWE4DYH`}, 206 | {"invalid", 0, true, ` 207 | AGE-SECRET-KEY-1705XN76M8EYQ8M9PY4E2G3KA8DN7NSCGT3V4HMN20H3GCX4AS6HSSTG8D3 208 | AGE-SECRET-KEY--1D6K0SGAX3NU66R4GYFZY0UQWCLM3UUSF3CXLW4KXZM342WQSJ82QKU59Q`}, 209 | } 210 | for _, tt := range tests { 211 | t.Run(tt.name, func(t *testing.T) { 212 | got, err := age.ParseIdentities(strings.NewReader(tt.file)) 213 | if (err != nil) != tt.wantErr { 214 | t.Errorf("ParseIdentities() error = %v, wantErr %v", err, tt.wantErr) 215 | return 216 | } 217 | if len(got) != tt.wantCount { 218 | t.Errorf("ParseIdentities() returned %d identities, want %d", len(got), tt.wantCount) 219 | } 220 | }) 221 | } 222 | } 223 | 224 | type testRecipient struct { 225 | labels []string 226 | } 227 | 228 | func (testRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) { 229 | panic("expected WrapWithLabels instead") 230 | } 231 | 232 | func (t testRecipient) WrapWithLabels(fileKey []byte) (s []*age.Stanza, labels []string, err error) { 233 | return []*age.Stanza{{Type: "test"}}, t.labels, nil 234 | } 235 | 236 | func TestLabels(t *testing.T) { 237 | scrypt, err := age.NewScryptRecipient("xxx") 238 | if err != nil { 239 | t.Fatal(err) 240 | } 241 | i, err := age.GenerateX25519Identity() 242 | if err != nil { 243 | t.Fatal(err) 244 | } 245 | x25519 := i.Recipient() 246 | pqc := testRecipient{[]string{"postquantum"}} 247 | pqcAndFoo := testRecipient{[]string{"postquantum", "foo"}} 248 | fooAndPQC := testRecipient{[]string{"foo", "postquantum"}} 249 | 250 | if _, err := age.Encrypt(io.Discard, scrypt, scrypt); err == nil { 251 | t.Error("expected two scrypt recipients to fail") 252 | } 253 | if _, err := age.Encrypt(io.Discard, scrypt, x25519); err == nil { 254 | t.Error("expected x25519 mixed with scrypt to fail") 255 | } 256 | if _, err := age.Encrypt(io.Discard, x25519, scrypt); err == nil { 257 | t.Error("expected x25519 mixed with scrypt to fail") 258 | } 259 | if _, err := age.Encrypt(io.Discard, pqc, x25519); err == nil { 260 | t.Error("expected x25519 mixed with pqc to fail") 261 | } 262 | if _, err := age.Encrypt(io.Discard, x25519, pqc); err == nil { 263 | t.Error("expected x25519 mixed with pqc to fail") 264 | } 265 | if _, err := age.Encrypt(io.Discard, pqc, pqc); err != nil { 266 | t.Errorf("expected two pqc to work, got %v", err) 267 | } 268 | if _, err := age.Encrypt(io.Discard, pqc); err != nil { 269 | t.Errorf("expected one pqc to work, got %v", err) 270 | } 271 | if _, err := age.Encrypt(io.Discard, pqcAndFoo, pqc); err == nil { 272 | t.Error("expected pqc+foo mixed with pqc to fail") 273 | } 274 | if _, err := age.Encrypt(io.Discard, pqc, pqcAndFoo); err == nil { 275 | t.Error("expected pqc+foo mixed with pqc to fail") 276 | } 277 | if _, err := age.Encrypt(io.Discard, pqc, pqc, pqcAndFoo); err == nil { 278 | t.Error("expected pqc+foo mixed with pqc to fail") 279 | } 280 | if _, err := age.Encrypt(io.Discard, pqcAndFoo, pqcAndFoo); err != nil { 281 | t.Errorf("expected two pqc+foo to work, got %v", err) 282 | } 283 | if _, err := age.Encrypt(io.Discard, pqcAndFoo, fooAndPQC); err != nil { 284 | t.Errorf("expected pqc+foo mixed with foo+pqc to work, got %v", err) 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /agessh/agessh.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Package agessh provides age.Identity and age.Recipient implementations of 6 | // types "ssh-rsa" and "ssh-ed25519", which allow reusing existing SSH keys for 7 | // encryption with age-encryption.org/v1. 8 | // 9 | // These recipient types should only be used for compatibility with existing 10 | // keys, and native X25519 keys should be preferred otherwise. 11 | // 12 | // Note that these recipient types are not anonymous: the encrypted message will 13 | // include a short 32-bit ID of the public key. 14 | package agessh 15 | 16 | import ( 17 | "crypto/ed25519" 18 | "crypto/rand" 19 | "crypto/rsa" 20 | "crypto/sha256" 21 | "crypto/sha512" 22 | "errors" 23 | "fmt" 24 | "io" 25 | 26 | "filippo.io/age" 27 | "filippo.io/age/internal/format" 28 | "filippo.io/edwards25519" 29 | "golang.org/x/crypto/chacha20poly1305" 30 | "golang.org/x/crypto/curve25519" 31 | "golang.org/x/crypto/hkdf" 32 | "golang.org/x/crypto/ssh" 33 | ) 34 | 35 | func sshFingerprint(pk ssh.PublicKey) string { 36 | h := sha256.Sum256(pk.Marshal()) 37 | return format.EncodeToString(h[:4]) 38 | } 39 | 40 | const oaepLabel = "age-encryption.org/v1/ssh-rsa" 41 | 42 | type RSARecipient struct { 43 | sshKey ssh.PublicKey 44 | pubKey *rsa.PublicKey 45 | } 46 | 47 | var _ age.Recipient = &RSARecipient{} 48 | 49 | func NewRSARecipient(pk ssh.PublicKey) (*RSARecipient, error) { 50 | if pk.Type() != "ssh-rsa" { 51 | return nil, errors.New("SSH public key is not an RSA key") 52 | } 53 | r := &RSARecipient{ 54 | sshKey: pk, 55 | } 56 | 57 | if pk, ok := pk.(ssh.CryptoPublicKey); ok { 58 | if pk, ok := pk.CryptoPublicKey().(*rsa.PublicKey); ok { 59 | r.pubKey = pk 60 | } else { 61 | return nil, errors.New("unexpected public key type") 62 | } 63 | } else { 64 | return nil, errors.New("pk does not implement ssh.CryptoPublicKey") 65 | } 66 | if r.pubKey.Size() < 2048/8 { 67 | return nil, errors.New("RSA key size is too small") 68 | } 69 | return r, nil 70 | } 71 | 72 | func (r *RSARecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) { 73 | l := &age.Stanza{ 74 | Type: "ssh-rsa", 75 | Args: []string{sshFingerprint(r.sshKey)}, 76 | } 77 | 78 | wrappedKey, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, 79 | r.pubKey, fileKey, []byte(oaepLabel)) 80 | if err != nil { 81 | return nil, err 82 | } 83 | l.Body = wrappedKey 84 | 85 | return []*age.Stanza{l}, nil 86 | } 87 | 88 | type RSAIdentity struct { 89 | k *rsa.PrivateKey 90 | sshKey ssh.PublicKey 91 | } 92 | 93 | var _ age.Identity = &RSAIdentity{} 94 | 95 | func NewRSAIdentity(key *rsa.PrivateKey) (*RSAIdentity, error) { 96 | s, err := ssh.NewSignerFromKey(key) 97 | if err != nil { 98 | return nil, err 99 | } 100 | i := &RSAIdentity{ 101 | k: key, sshKey: s.PublicKey(), 102 | } 103 | return i, nil 104 | } 105 | 106 | func (i *RSAIdentity) Recipient() *RSARecipient { 107 | return &RSARecipient{ 108 | sshKey: i.sshKey, 109 | pubKey: &i.k.PublicKey, 110 | } 111 | } 112 | 113 | func (i *RSAIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) { 114 | return multiUnwrap(i.unwrap, stanzas) 115 | } 116 | 117 | func (i *RSAIdentity) unwrap(block *age.Stanza) ([]byte, error) { 118 | if block.Type != "ssh-rsa" { 119 | return nil, age.ErrIncorrectIdentity 120 | } 121 | if len(block.Args) != 1 { 122 | return nil, errors.New("invalid ssh-rsa recipient block") 123 | } 124 | 125 | if block.Args[0] != sshFingerprint(i.sshKey) { 126 | return nil, age.ErrIncorrectIdentity 127 | } 128 | 129 | fileKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, i.k, 130 | block.Body, []byte(oaepLabel)) 131 | if err != nil { 132 | return nil, fmt.Errorf("failed to decrypt file key: %v", err) 133 | } 134 | return fileKey, nil 135 | } 136 | 137 | type Ed25519Recipient struct { 138 | sshKey ssh.PublicKey 139 | theirPublicKey []byte 140 | } 141 | 142 | var _ age.Recipient = &Ed25519Recipient{} 143 | 144 | func NewEd25519Recipient(pk ssh.PublicKey) (*Ed25519Recipient, error) { 145 | if pk.Type() != "ssh-ed25519" { 146 | return nil, errors.New("SSH public key is not an Ed25519 key") 147 | } 148 | 149 | cpk, ok := pk.(ssh.CryptoPublicKey) 150 | if !ok { 151 | return nil, errors.New("pk does not implement ssh.CryptoPublicKey") 152 | } 153 | epk, ok := cpk.CryptoPublicKey().(ed25519.PublicKey) 154 | if !ok { 155 | return nil, errors.New("unexpected public key type") 156 | } 157 | mpk, err := ed25519PublicKeyToCurve25519(epk) 158 | if err != nil { 159 | return nil, fmt.Errorf("invalid Ed25519 public key: %v", err) 160 | } 161 | 162 | return &Ed25519Recipient{ 163 | sshKey: pk, 164 | theirPublicKey: mpk, 165 | }, nil 166 | } 167 | 168 | func ParseRecipient(s string) (age.Recipient, error) { 169 | pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(s)) 170 | if err != nil { 171 | return nil, fmt.Errorf("malformed SSH recipient: %q: %v", s, err) 172 | } 173 | 174 | var r age.Recipient 175 | switch t := pubKey.Type(); t { 176 | case "ssh-rsa": 177 | r, err = NewRSARecipient(pubKey) 178 | case "ssh-ed25519": 179 | r, err = NewEd25519Recipient(pubKey) 180 | default: 181 | return nil, fmt.Errorf("unknown SSH recipient type: %q", t) 182 | } 183 | if err != nil { 184 | return nil, fmt.Errorf("malformed SSH recipient: %q: %v", s, err) 185 | } 186 | 187 | return r, nil 188 | } 189 | 190 | func ed25519PublicKeyToCurve25519(pk ed25519.PublicKey) ([]byte, error) { 191 | // See https://blog.filippo.io/using-ed25519-keys-for-encryption and 192 | // https://pkg.go.dev/filippo.io/edwards25519#Point.BytesMontgomery. 193 | p, err := new(edwards25519.Point).SetBytes(pk) 194 | if err != nil { 195 | return nil, err 196 | } 197 | return p.BytesMontgomery(), nil 198 | } 199 | 200 | const ed25519Label = "age-encryption.org/v1/ssh-ed25519" 201 | 202 | func (r *Ed25519Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) { 203 | ephemeral := make([]byte, curve25519.ScalarSize) 204 | if _, err := rand.Read(ephemeral); err != nil { 205 | return nil, err 206 | } 207 | ourPublicKey, err := curve25519.X25519(ephemeral, curve25519.Basepoint) 208 | if err != nil { 209 | return nil, err 210 | } 211 | 212 | sharedSecret, err := curve25519.X25519(ephemeral, r.theirPublicKey) 213 | if err != nil { 214 | return nil, err 215 | } 216 | 217 | tweak := make([]byte, curve25519.ScalarSize) 218 | tH := hkdf.New(sha256.New, nil, r.sshKey.Marshal(), []byte(ed25519Label)) 219 | if _, err := io.ReadFull(tH, tweak); err != nil { 220 | return nil, err 221 | } 222 | sharedSecret, _ = curve25519.X25519(tweak, sharedSecret) 223 | 224 | l := &age.Stanza{ 225 | Type: "ssh-ed25519", 226 | Args: []string{sshFingerprint(r.sshKey), 227 | format.EncodeToString(ourPublicKey[:])}, 228 | } 229 | 230 | salt := make([]byte, 0, len(ourPublicKey)+len(r.theirPublicKey)) 231 | salt = append(salt, ourPublicKey...) 232 | salt = append(salt, r.theirPublicKey...) 233 | h := hkdf.New(sha256.New, sharedSecret, salt, []byte(ed25519Label)) 234 | wrappingKey := make([]byte, chacha20poly1305.KeySize) 235 | if _, err := io.ReadFull(h, wrappingKey); err != nil { 236 | return nil, err 237 | } 238 | 239 | wrappedKey, err := aeadEncrypt(wrappingKey, fileKey) 240 | if err != nil { 241 | return nil, err 242 | } 243 | l.Body = wrappedKey 244 | 245 | return []*age.Stanza{l}, nil 246 | } 247 | 248 | type Ed25519Identity struct { 249 | secretKey, ourPublicKey []byte 250 | sshKey ssh.PublicKey 251 | } 252 | 253 | var _ age.Identity = &Ed25519Identity{} 254 | 255 | func NewEd25519Identity(key ed25519.PrivateKey) (*Ed25519Identity, error) { 256 | s, err := ssh.NewSignerFromKey(key) 257 | if err != nil { 258 | return nil, err 259 | } 260 | i := &Ed25519Identity{ 261 | sshKey: s.PublicKey(), 262 | secretKey: ed25519PrivateKeyToCurve25519(key), 263 | } 264 | i.ourPublicKey, _ = curve25519.X25519(i.secretKey, curve25519.Basepoint) 265 | return i, nil 266 | } 267 | 268 | func ParseIdentity(pemBytes []byte) (age.Identity, error) { 269 | k, err := ssh.ParseRawPrivateKey(pemBytes) 270 | if err != nil { 271 | return nil, err 272 | } 273 | 274 | switch k := k.(type) { 275 | case *ed25519.PrivateKey: 276 | return NewEd25519Identity(*k) 277 | // ParseRawPrivateKey returns inconsistent types. See Issue 429. 278 | case ed25519.PrivateKey: 279 | return NewEd25519Identity(k) 280 | case *rsa.PrivateKey: 281 | return NewRSAIdentity(k) 282 | } 283 | 284 | return nil, fmt.Errorf("unsupported SSH identity type: %T", k) 285 | } 286 | 287 | func ed25519PrivateKeyToCurve25519(pk ed25519.PrivateKey) []byte { 288 | h := sha512.New() 289 | h.Write(pk.Seed()) 290 | out := h.Sum(nil) 291 | return out[:curve25519.ScalarSize] 292 | } 293 | 294 | func (i *Ed25519Identity) Recipient() *Ed25519Recipient { 295 | return &Ed25519Recipient{ 296 | sshKey: i.sshKey, 297 | theirPublicKey: i.ourPublicKey, 298 | } 299 | } 300 | 301 | func (i *Ed25519Identity) Unwrap(stanzas []*age.Stanza) ([]byte, error) { 302 | return multiUnwrap(i.unwrap, stanzas) 303 | } 304 | 305 | func (i *Ed25519Identity) unwrap(block *age.Stanza) ([]byte, error) { 306 | if block.Type != "ssh-ed25519" { 307 | return nil, age.ErrIncorrectIdentity 308 | } 309 | if len(block.Args) != 2 { 310 | return nil, errors.New("invalid ssh-ed25519 recipient block") 311 | } 312 | publicKey, err := format.DecodeString(block.Args[1]) 313 | if err != nil { 314 | return nil, fmt.Errorf("failed to parse ssh-ed25519 recipient: %v", err) 315 | } 316 | if len(publicKey) != curve25519.PointSize { 317 | return nil, errors.New("invalid ssh-ed25519 recipient block") 318 | } 319 | 320 | if block.Args[0] != sshFingerprint(i.sshKey) { 321 | return nil, age.ErrIncorrectIdentity 322 | } 323 | 324 | sharedSecret, err := curve25519.X25519(i.secretKey, publicKey) 325 | if err != nil { 326 | return nil, fmt.Errorf("invalid X25519 recipient: %v", err) 327 | } 328 | 329 | tweak := make([]byte, curve25519.ScalarSize) 330 | tH := hkdf.New(sha256.New, nil, i.sshKey.Marshal(), []byte(ed25519Label)) 331 | if _, err := io.ReadFull(tH, tweak); err != nil { 332 | return nil, err 333 | } 334 | sharedSecret, _ = curve25519.X25519(tweak, sharedSecret) 335 | 336 | salt := make([]byte, 0, len(publicKey)+len(i.ourPublicKey)) 337 | salt = append(salt, publicKey...) 338 | salt = append(salt, i.ourPublicKey...) 339 | h := hkdf.New(sha256.New, sharedSecret, salt, []byte(ed25519Label)) 340 | wrappingKey := make([]byte, chacha20poly1305.KeySize) 341 | if _, err := io.ReadFull(h, wrappingKey); err != nil { 342 | return nil, err 343 | } 344 | 345 | fileKey, err := aeadDecrypt(wrappingKey, block.Body) 346 | if err != nil { 347 | return nil, fmt.Errorf("failed to decrypt file key: %v", err) 348 | } 349 | return fileKey, nil 350 | } 351 | 352 | // multiUnwrap is copied from package age. It's a helper that implements 353 | // Identity.Unwrap in terms of a function that unwraps a single recipient 354 | // stanza. 355 | func multiUnwrap(unwrap func(*age.Stanza) ([]byte, error), stanzas []*age.Stanza) ([]byte, error) { 356 | for _, s := range stanzas { 357 | fileKey, err := unwrap(s) 358 | if errors.Is(err, age.ErrIncorrectIdentity) { 359 | // If we ever start returning something interesting wrapping 360 | // ErrIncorrectIdentity, we should let it make its way up through 361 | // Decrypt into NoIdentityMatchError.Errors. 362 | continue 363 | } 364 | if err != nil { 365 | return nil, err 366 | } 367 | return fileKey, nil 368 | } 369 | return nil, age.ErrIncorrectIdentity 370 | } 371 | 372 | // aeadEncrypt and aeadDecrypt are copied from package age. 373 | // 374 | // They don't limit the file key size because multi-key attacks are irrelevant 375 | // against the ssh-ed25519 recipient. Being an asymmetric recipient, it would 376 | // only allow a more efficient search for accepted public keys against a 377 | // decryption oracle, but the ssh-X recipients are not anonymous (they have a 378 | // short recipient hash). 379 | 380 | func aeadEncrypt(key, plaintext []byte) ([]byte, error) { 381 | aead, err := chacha20poly1305.New(key) 382 | if err != nil { 383 | return nil, err 384 | } 385 | nonce := make([]byte, chacha20poly1305.NonceSize) 386 | return aead.Seal(nil, nonce, plaintext, nil), nil 387 | } 388 | 389 | func aeadDecrypt(key, ciphertext []byte) ([]byte, error) { 390 | aead, err := chacha20poly1305.New(key) 391 | if err != nil { 392 | return nil, err 393 | } 394 | nonce := make([]byte, chacha20poly1305.NonceSize) 395 | return aead.Open(nil, nonce, ciphertext, nil) 396 | } 397 | -------------------------------------------------------------------------------- /agessh/agessh_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package agessh_test 6 | 7 | import ( 8 | "bytes" 9 | "crypto/ed25519" 10 | "crypto/rand" 11 | "crypto/rsa" 12 | "reflect" 13 | "testing" 14 | 15 | "filippo.io/age/agessh" 16 | "golang.org/x/crypto/ssh" 17 | ) 18 | 19 | func TestSSHRSARoundTrip(t *testing.T) { 20 | pk, err := rsa.GenerateKey(rand.Reader, 2048) 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | pub, err := ssh.NewPublicKey(&pk.PublicKey) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | 29 | r, err := agessh.NewRSARecipient(pub) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | i, err := agessh.NewRSAIdentity(pk) 34 | if err != nil { 35 | t.Fatal(err) 36 | } 37 | 38 | // TODO: replace this with (and go-diff) with go-cmp. 39 | if !reflect.DeepEqual(r, i.Recipient()) { 40 | t.Fatalf("i.Recipient is different from r") 41 | } 42 | 43 | fileKey := make([]byte, 16) 44 | if _, err := rand.Read(fileKey); err != nil { 45 | t.Fatal(err) 46 | } 47 | stanzas, err := r.Wrap(fileKey) 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | 52 | out, err := i.Unwrap(stanzas) 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | 57 | if !bytes.Equal(fileKey, out) { 58 | t.Errorf("invalid output: %x, expected %x", out, fileKey) 59 | } 60 | } 61 | 62 | func TestSSHEd25519RoundTrip(t *testing.T) { 63 | pub, priv, err := ed25519.GenerateKey(rand.Reader) 64 | if err != nil { 65 | t.Fatal(err) 66 | } 67 | sshPubKey, err := ssh.NewPublicKey(pub) 68 | if err != nil { 69 | t.Fatal(err) 70 | } 71 | 72 | r, err := agessh.NewEd25519Recipient(sshPubKey) 73 | if err != nil { 74 | t.Fatal(err) 75 | } 76 | i, err := agessh.NewEd25519Identity(priv) 77 | if err != nil { 78 | t.Fatal(err) 79 | } 80 | 81 | // TODO: replace this with (and go-diff) with go-cmp. 82 | if !reflect.DeepEqual(r, i.Recipient()) { 83 | t.Fatalf("i.Recipient is different from r") 84 | } 85 | 86 | fileKey := make([]byte, 16) 87 | if _, err := rand.Read(fileKey); err != nil { 88 | t.Fatal(err) 89 | } 90 | stanzas, err := r.Wrap(fileKey) 91 | if err != nil { 92 | t.Fatal(err) 93 | } 94 | 95 | out, err := i.Unwrap(stanzas) 96 | if err != nil { 97 | t.Fatal(err) 98 | } 99 | 100 | if !bytes.Equal(fileKey, out) { 101 | t.Errorf("invalid output: %x, expected %x", out, fileKey) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /agessh/encrypted_keys.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package agessh 6 | 7 | import ( 8 | "crypto" 9 | "crypto/ed25519" 10 | "crypto/rsa" 11 | "fmt" 12 | 13 | "filippo.io/age" 14 | "golang.org/x/crypto/ssh" 15 | ) 16 | 17 | // EncryptedSSHIdentity is an age.Identity implementation based on a passphrase 18 | // encrypted SSH private key. 19 | // 20 | // It requests the passphrase only if the public key matches a recipient stanza. 21 | // If the application knows it will always have to decrypt the private key, it 22 | // would be simpler to use ssh.ParseRawPrivateKeyWithPassphrase directly and 23 | // pass the result to NewEd25519Identity or NewRSAIdentity. 24 | type EncryptedSSHIdentity struct { 25 | pubKey ssh.PublicKey 26 | recipient age.Recipient 27 | pemBytes []byte 28 | passphrase func() ([]byte, error) 29 | 30 | decrypted age.Identity 31 | } 32 | 33 | // NewEncryptedSSHIdentity returns a new EncryptedSSHIdentity. 34 | // 35 | // pubKey must be the public key associated with the encrypted private key, and 36 | // it must have type "ssh-ed25519" or "ssh-rsa". For OpenSSH encrypted files it 37 | // can be extracted from an ssh.PassphraseMissingError, otherwise it can often 38 | // be found in ".pub" files. 39 | // 40 | // pemBytes must be a valid input to ssh.ParseRawPrivateKeyWithPassphrase. 41 | // passphrase is a callback that will be invoked by Unwrap when the passphrase 42 | // is necessary. 43 | func NewEncryptedSSHIdentity(pubKey ssh.PublicKey, pemBytes []byte, passphrase func() ([]byte, error)) (*EncryptedSSHIdentity, error) { 44 | i := &EncryptedSSHIdentity{ 45 | pubKey: pubKey, 46 | pemBytes: pemBytes, 47 | passphrase: passphrase, 48 | } 49 | switch t := pubKey.Type(); t { 50 | case "ssh-ed25519": 51 | r, err := NewEd25519Recipient(pubKey) 52 | if err != nil { 53 | return nil, err 54 | } 55 | i.recipient = r 56 | case "ssh-rsa": 57 | r, err := NewRSARecipient(pubKey) 58 | if err != nil { 59 | return nil, err 60 | } 61 | i.recipient = r 62 | default: 63 | return nil, fmt.Errorf("unsupported SSH key type: %v", t) 64 | } 65 | return i, nil 66 | } 67 | 68 | var _ age.Identity = &EncryptedSSHIdentity{} 69 | 70 | func (i *EncryptedSSHIdentity) Recipient() age.Recipient { 71 | return i.recipient 72 | } 73 | 74 | // Unwrap implements age.Identity. If the private key is still encrypted, and 75 | // any of the stanzas match the public key, it will request the passphrase. The 76 | // decrypted private key will be cached after the first successful invocation. 77 | func (i *EncryptedSSHIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) { 78 | if i.decrypted != nil { 79 | return i.decrypted.Unwrap(stanzas) 80 | } 81 | 82 | var match bool 83 | for _, s := range stanzas { 84 | if s.Type != i.pubKey.Type() { 85 | continue 86 | } 87 | if len(s.Args) < 1 { 88 | return nil, fmt.Errorf("invalid %v recipient block", i.pubKey.Type()) 89 | } 90 | if s.Args[0] != sshFingerprint(i.pubKey) { 91 | continue 92 | } 93 | match = true 94 | break 95 | } 96 | if !match { 97 | return nil, age.ErrIncorrectIdentity 98 | } 99 | 100 | passphrase, err := i.passphrase() 101 | if err != nil { 102 | return nil, fmt.Errorf("failed to obtain passphrase: %v", err) 103 | } 104 | k, err := ssh.ParseRawPrivateKeyWithPassphrase(i.pemBytes, passphrase) 105 | if err != nil { 106 | return nil, fmt.Errorf("failed to decrypt SSH key file: %v", err) 107 | } 108 | 109 | var pubKey interface { 110 | Equal(x crypto.PublicKey) bool 111 | } 112 | switch k := k.(type) { 113 | case *ed25519.PrivateKey: 114 | i.decrypted, err = NewEd25519Identity(*k) 115 | pubKey = k.Public().(ed25519.PublicKey) 116 | // ParseRawPrivateKey returns inconsistent types. See Issue 429. 117 | case ed25519.PrivateKey: 118 | i.decrypted, err = NewEd25519Identity(k) 119 | pubKey = k.Public().(ed25519.PublicKey) 120 | case *rsa.PrivateKey: 121 | i.decrypted, err = NewRSAIdentity(k) 122 | pubKey = &k.PublicKey 123 | default: 124 | return nil, fmt.Errorf("unexpected SSH key type: %T", k) 125 | } 126 | if err != nil { 127 | return nil, fmt.Errorf("invalid SSH key: %v", err) 128 | } 129 | 130 | if exp := i.pubKey.(ssh.CryptoPublicKey).CryptoPublicKey(); !pubKey.Equal(exp) { 131 | return nil, fmt.Errorf("mismatched private and public SSH key") 132 | } 133 | 134 | return i.decrypted.Unwrap(stanzas) 135 | } 136 | -------------------------------------------------------------------------------- /armor/armor.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Package armor provides a strict, streaming implementation of the ASCII 6 | // armoring format for age files. 7 | // 8 | // It's PEM with type "AGE ENCRYPTED FILE", 64 character columns, no headers, 9 | // and strict base64 decoding. 10 | package armor 11 | 12 | import ( 13 | "bufio" 14 | "bytes" 15 | "encoding/base64" 16 | "errors" 17 | "fmt" 18 | "io" 19 | 20 | "filippo.io/age/internal/format" 21 | ) 22 | 23 | const ( 24 | Header = "-----BEGIN AGE ENCRYPTED FILE-----" 25 | Footer = "-----END AGE ENCRYPTED FILE-----" 26 | ) 27 | 28 | type armoredWriter struct { 29 | started, closed bool 30 | encoder *format.WrappedBase64Encoder 31 | dst io.Writer 32 | } 33 | 34 | func (a *armoredWriter) Write(p []byte) (int, error) { 35 | if !a.started { 36 | if _, err := io.WriteString(a.dst, Header+"\n"); err != nil { 37 | return 0, err 38 | } 39 | } 40 | a.started = true 41 | return a.encoder.Write(p) 42 | } 43 | 44 | func (a *armoredWriter) Close() error { 45 | if a.closed { 46 | return errors.New("ArmoredWriter already closed") 47 | } 48 | a.closed = true 49 | if err := a.encoder.Close(); err != nil { 50 | return err 51 | } 52 | footer := Footer + "\n" 53 | if !a.encoder.LastLineIsEmpty() { 54 | footer = "\n" + footer 55 | } 56 | _, err := io.WriteString(a.dst, footer) 57 | return err 58 | } 59 | 60 | func NewWriter(dst io.Writer) io.WriteCloser { 61 | // TODO: write a test with aligned and misaligned sizes, and 8 and 10 steps. 62 | return &armoredWriter{ 63 | dst: dst, 64 | encoder: format.NewWrappedBase64Encoder(base64.StdEncoding, dst), 65 | } 66 | } 67 | 68 | type armoredReader struct { 69 | r *bufio.Reader 70 | started bool 71 | unread []byte // backed by buf 72 | buf [format.BytesPerLine]byte 73 | err error 74 | } 75 | 76 | func NewReader(r io.Reader) io.Reader { 77 | return &armoredReader{r: bufio.NewReader(r)} 78 | } 79 | 80 | func (r *armoredReader) Read(p []byte) (int, error) { 81 | if len(r.unread) > 0 { 82 | n := copy(p, r.unread) 83 | r.unread = r.unread[n:] 84 | return n, nil 85 | } 86 | if r.err != nil { 87 | return 0, r.err 88 | } 89 | 90 | getLine := func() ([]byte, error) { 91 | line, err := r.r.ReadBytes('\n') 92 | if err == io.EOF && len(line) == 0 { 93 | return nil, io.ErrUnexpectedEOF 94 | } else if err != nil && err != io.EOF { 95 | return nil, err 96 | } 97 | line = bytes.TrimSuffix(line, []byte("\n")) 98 | line = bytes.TrimSuffix(line, []byte("\r")) 99 | return line, nil 100 | } 101 | 102 | const maxWhitespace = 1024 103 | drainTrailing := func() error { 104 | buf, err := io.ReadAll(io.LimitReader(r.r, maxWhitespace)) 105 | if err != nil { 106 | return err 107 | } 108 | if len(bytes.TrimSpace(buf)) != 0 { 109 | return errors.New("trailing data after armored file") 110 | } 111 | if len(buf) == maxWhitespace { 112 | return errors.New("too much trailing whitespace") 113 | } 114 | return io.EOF 115 | } 116 | 117 | var removedWhitespace int 118 | for !r.started { 119 | line, err := getLine() 120 | if err != nil { 121 | return 0, r.setErr(err) 122 | } 123 | // Ignore leading whitespace. 124 | if len(bytes.TrimSpace(line)) == 0 { 125 | removedWhitespace += len(line) + 1 126 | if removedWhitespace > maxWhitespace { 127 | return 0, r.setErr(errors.New("too much leading whitespace")) 128 | } 129 | continue 130 | } 131 | if string(line) != Header { 132 | return 0, r.setErr(fmt.Errorf("invalid first line: %q", line)) 133 | } 134 | r.started = true 135 | } 136 | line, err := getLine() 137 | if err != nil { 138 | return 0, r.setErr(err) 139 | } 140 | if string(line) == Footer { 141 | return 0, r.setErr(drainTrailing()) 142 | } 143 | if len(line) > format.ColumnsPerLine { 144 | return 0, r.setErr(errors.New("column limit exceeded")) 145 | } 146 | r.unread = r.buf[:] 147 | n, err := base64.StdEncoding.Strict().Decode(r.unread, line) 148 | if err != nil { 149 | return 0, r.setErr(err) 150 | } 151 | r.unread = r.unread[:n] 152 | 153 | if n < format.BytesPerLine { 154 | line, err := getLine() 155 | if err != nil { 156 | return 0, r.setErr(err) 157 | } 158 | if string(line) != Footer { 159 | return 0, r.setErr(fmt.Errorf("invalid closing line: %q", line)) 160 | } 161 | r.setErr(drainTrailing()) 162 | } 163 | 164 | nn := copy(p, r.unread) 165 | r.unread = r.unread[nn:] 166 | return nn, nil 167 | } 168 | 169 | type Error struct { 170 | err error 171 | } 172 | 173 | func (e *Error) Error() string { 174 | return "invalid armor: " + e.err.Error() 175 | } 176 | 177 | func (e *Error) Unwrap() error { 178 | return e.err 179 | } 180 | 181 | func (r *armoredReader) setErr(err error) error { 182 | if err != io.EOF { 183 | err = &Error{err} 184 | } 185 | r.err = err 186 | return err 187 | } 188 | -------------------------------------------------------------------------------- /armor/armor_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | //go:build go1.18 6 | // +build go1.18 7 | 8 | package armor_test 9 | 10 | import ( 11 | "bytes" 12 | "crypto/rand" 13 | "encoding/pem" 14 | "fmt" 15 | "io" 16 | "log" 17 | "os" 18 | "path/filepath" 19 | "strings" 20 | "testing" 21 | 22 | "filippo.io/age" 23 | "filippo.io/age/armor" 24 | "filippo.io/age/internal/format" 25 | ) 26 | 27 | func ExampleNewWriter() { 28 | publicKey := "age1cy0su9fwf3gf9mw868g5yut09p6nytfmmnktexz2ya5uqg9vl9sss4euqm" 29 | recipient, err := age.ParseX25519Recipient(publicKey) 30 | if err != nil { 31 | log.Fatalf("Failed to parse public key %q: %v", publicKey, err) 32 | } 33 | 34 | buf := &bytes.Buffer{} 35 | armorWriter := armor.NewWriter(buf) 36 | 37 | w, err := age.Encrypt(armorWriter, recipient) 38 | if err != nil { 39 | log.Fatalf("Failed to create encrypted file: %v", err) 40 | } 41 | if _, err := io.WriteString(w, "Black lives matter."); err != nil { 42 | log.Fatalf("Failed to write to encrypted file: %v", err) 43 | } 44 | if err := w.Close(); err != nil { 45 | log.Fatalf("Failed to close encrypted file: %v", err) 46 | } 47 | 48 | if err := armorWriter.Close(); err != nil { 49 | log.Fatalf("Failed to close armor: %v", err) 50 | } 51 | 52 | fmt.Printf("%s[...]", buf.Bytes()[:35]) 53 | // Output: 54 | // -----BEGIN AGE ENCRYPTED FILE----- 55 | // [...] 56 | } 57 | 58 | var privateKey = "AGE-SECRET-KEY-184JMZMVQH3E6U0PSL869004Y3U2NYV7R30EU99CSEDNPH02YUVFSZW44VU" 59 | 60 | func ExampleNewReader() { 61 | fileContents := `-----BEGIN AGE ENCRYPTED FILE----- 62 | YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4YWdhZHZ0WG1PZldDT1hD 63 | K3RPRzFkUlJnWlFBQlUwemtjeXFRMFp6V1VFCnRzZFV3a3Vkd1dSUWw2eEtrRkVv 64 | SHcvZnp6Q3lqLy9HMkM4ZjUyUGdDZjQKLS0tIDlpVUpuVUQ5YUJyUENFZ0lNSTB2 65 | ekUvS3E5WjVUN0F5ZWR1ejhpeU5rZUUKsvPGYt7vf0o1kyJ1eVFMz1e4JnYYk1y1 66 | kB/RRusYjn+KVJ+KTioxj0THtzZPXcjFKuQ1 67 | -----END AGE ENCRYPTED FILE-----` 68 | 69 | // DO NOT hardcode the private key. Store it in a secret storage solution, 70 | // on disk if the local machine is trusted, or have the user provide it. 71 | identity, err := age.ParseX25519Identity(privateKey) 72 | if err != nil { 73 | log.Fatalf("Failed to parse private key %q: %v", privateKey, err) 74 | } 75 | 76 | out := &bytes.Buffer{} 77 | f := strings.NewReader(fileContents) 78 | armorReader := armor.NewReader(f) 79 | 80 | r, err := age.Decrypt(armorReader, identity) 81 | if err != nil { 82 | log.Fatalf("Failed to open encrypted file: %v", err) 83 | } 84 | if _, err := io.Copy(out, r); err != nil { 85 | log.Fatalf("Failed to read encrypted file: %v", err) 86 | } 87 | 88 | fmt.Printf("File contents: %q\n", out.Bytes()) 89 | // Output: 90 | // File contents: "Black lives matter." 91 | } 92 | 93 | func TestArmor(t *testing.T) { 94 | t.Run("PartialLine", func(t *testing.T) { testArmor(t, 611) }) 95 | t.Run("FullLine", func(t *testing.T) { testArmor(t, 10*format.BytesPerLine) }) 96 | } 97 | 98 | func testArmor(t *testing.T, size int) { 99 | buf := &bytes.Buffer{} 100 | w := armor.NewWriter(buf) 101 | plain := make([]byte, size) 102 | rand.Read(plain) 103 | if _, err := w.Write(plain); err != nil { 104 | t.Fatal(err) 105 | } 106 | if err := w.Close(); err != nil { 107 | t.Fatal(err) 108 | } 109 | 110 | block, _ := pem.Decode(buf.Bytes()) 111 | if block == nil { 112 | t.Fatal("PEM decoding failed") 113 | } 114 | if len(block.Headers) != 0 { 115 | t.Error("unexpected headers") 116 | } 117 | if block.Type != "AGE ENCRYPTED FILE" { 118 | t.Errorf("unexpected type %q", block.Type) 119 | } 120 | if !bytes.Equal(block.Bytes, plain) { 121 | t.Error("PEM decoded value doesn't match") 122 | } 123 | if !bytes.Equal(buf.Bytes(), pem.EncodeToMemory(block)) { 124 | t.Error("PEM re-encoded value doesn't match") 125 | } 126 | 127 | r := armor.NewReader(buf) 128 | out, err := io.ReadAll(r) 129 | if err != nil { 130 | t.Fatal(err) 131 | } 132 | if !bytes.Equal(out, plain) { 133 | t.Error("decoded value doesn't match") 134 | } 135 | } 136 | 137 | func FuzzMalleability(f *testing.F) { 138 | tests, err := filepath.Glob("../testdata/testkit/*") 139 | if err != nil { 140 | f.Fatal(err) 141 | } 142 | for _, test := range tests { 143 | contents, err := os.ReadFile(test) 144 | if err != nil { 145 | f.Fatal(err) 146 | } 147 | header, contents, ok := bytes.Cut(contents, []byte("\n\n")) 148 | if !ok { 149 | f.Fatal("testkit file without header") 150 | } 151 | if bytes.Contains(header, []byte("armored: yes")) { 152 | f.Add(contents) 153 | } 154 | } 155 | f.Fuzz(func(t *testing.T, data []byte) { 156 | r := armor.NewReader(bytes.NewReader(data)) 157 | content, err := io.ReadAll(r) 158 | if err != nil { 159 | if _, ok := err.(*armor.Error); !ok { 160 | t.Errorf("error type is %T: %v", err, err) 161 | } 162 | t.Skip() 163 | } 164 | buf := &bytes.Buffer{} 165 | w := armor.NewWriter(buf) 166 | if _, err := w.Write(content); err != nil { 167 | t.Fatal(err) 168 | } 169 | if err := w.Close(); err != nil { 170 | t.Fatal(err) 171 | } 172 | if !bytes.Equal(normalize(buf.Bytes()), normalize(data)) { 173 | t.Error("re-encoded output different from input") 174 | } 175 | }) 176 | } 177 | 178 | func normalize(f []byte) []byte { 179 | f = bytes.TrimSpace(f) 180 | f = bytes.Replace(f, []byte("\r\n"), []byte("\n"), -1) 181 | return f 182 | } 183 | -------------------------------------------------------------------------------- /cmd/age-keygen/keygen.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "flag" 9 | "fmt" 10 | "io" 11 | "log" 12 | "os" 13 | "runtime/debug" 14 | "time" 15 | 16 | "filippo.io/age" 17 | "golang.org/x/term" 18 | ) 19 | 20 | const usage = `Usage: 21 | age-keygen [-o OUTPUT] 22 | age-keygen -y [-o OUTPUT] [INPUT] 23 | 24 | Options: 25 | -o, --output OUTPUT Write the result to the file at path OUTPUT. 26 | -y Convert an identity file to a recipients file. 27 | 28 | age-keygen generates a new native X25519 key pair, and outputs it to 29 | standard output or to the OUTPUT file. 30 | 31 | If an OUTPUT file is specified, the public key is printed to standard error. 32 | If OUTPUT already exists, it is not overwritten. 33 | 34 | In -y mode, age-keygen reads an identity file from INPUT or from standard 35 | input and writes the corresponding recipient(s) to OUTPUT or to standard 36 | output, one per line, with no comments. 37 | 38 | Examples: 39 | 40 | $ age-keygen 41 | # created: 2021-01-02T15:30:45+01:00 42 | # public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z 43 | AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9 44 | 45 | $ age-keygen -o key.txt 46 | Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p 47 | 48 | $ age-keygen -y key.txt 49 | age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p` 50 | 51 | // Version can be set at link time to override debug.BuildInfo.Main.Version, 52 | // which is "(devel)" when building from within the module. See 53 | // golang.org/issue/29814 and golang.org/issue/29228. 54 | var Version string 55 | 56 | func main() { 57 | log.SetFlags(0) 58 | flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) } 59 | 60 | var ( 61 | versionFlag, convertFlag bool 62 | outFlag string 63 | ) 64 | 65 | flag.BoolVar(&versionFlag, "version", false, "print the version") 66 | flag.BoolVar(&convertFlag, "y", false, "convert identities to recipients") 67 | flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)") 68 | flag.StringVar(&outFlag, "output", "", "output to `FILE` (default stdout)") 69 | flag.Parse() 70 | if len(flag.Args()) != 0 && !convertFlag { 71 | errorf("too many arguments") 72 | } 73 | if len(flag.Args()) > 1 && convertFlag { 74 | errorf("too many arguments") 75 | } 76 | if versionFlag { 77 | if Version != "" { 78 | fmt.Println(Version) 79 | return 80 | } 81 | if buildInfo, ok := debug.ReadBuildInfo(); ok { 82 | fmt.Println(buildInfo.Main.Version) 83 | return 84 | } 85 | fmt.Println("(unknown)") 86 | return 87 | } 88 | 89 | out := os.Stdout 90 | if outFlag != "" { 91 | f, err := os.OpenFile(outFlag, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) 92 | if err != nil { 93 | errorf("failed to open output file %q: %v", outFlag, err) 94 | } 95 | defer func() { 96 | if err := f.Close(); err != nil { 97 | errorf("failed to close output file %q: %v", outFlag, err) 98 | } 99 | }() 100 | out = f 101 | } 102 | 103 | in := os.Stdin 104 | if inFile := flag.Arg(0); inFile != "" && inFile != "-" { 105 | f, err := os.Open(inFile) 106 | if err != nil { 107 | errorf("failed to open input file %q: %v", inFile, err) 108 | } 109 | defer f.Close() 110 | in = f 111 | } 112 | 113 | if convertFlag { 114 | convert(in, out) 115 | } else { 116 | if fi, err := out.Stat(); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 { 117 | warning("writing secret key to a world-readable file") 118 | } 119 | generate(out) 120 | } 121 | } 122 | 123 | func generate(out *os.File) { 124 | k, err := age.GenerateX25519Identity() 125 | if err != nil { 126 | errorf("internal error: %v", err) 127 | } 128 | 129 | if !term.IsTerminal(int(out.Fd())) { 130 | fmt.Fprintf(os.Stderr, "Public key: %s\n", k.Recipient()) 131 | } 132 | 133 | fmt.Fprintf(out, "# created: %s\n", time.Now().Format(time.RFC3339)) 134 | fmt.Fprintf(out, "# public key: %s\n", k.Recipient()) 135 | fmt.Fprintf(out, "%s\n", k) 136 | } 137 | 138 | func convert(in io.Reader, out io.Writer) { 139 | ids, err := age.ParseIdentities(in) 140 | if err != nil { 141 | errorf("failed to parse input: %v", err) 142 | } 143 | if len(ids) == 0 { 144 | errorf("no identities found in the input") 145 | } 146 | for _, id := range ids { 147 | id, ok := id.(*age.X25519Identity) 148 | if !ok { 149 | errorf("internal error: unexpected identity type: %T", id) 150 | } 151 | fmt.Fprintf(out, "%s\n", id.Recipient()) 152 | } 153 | } 154 | 155 | func errorf(format string, v ...interface{}) { 156 | log.Printf("age-keygen: error: "+format, v...) 157 | log.Fatalf("age-keygen: report unexpected or unhelpful errors at https://filippo.io/age/report") 158 | } 159 | 160 | func warning(msg string) { 161 | log.Printf("age-keygen: warning: %s", msg) 162 | } 163 | -------------------------------------------------------------------------------- /cmd/age/age_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "bufio" 9 | "os" 10 | "testing" 11 | 12 | "filippo.io/age" 13 | "github.com/rogpeppe/go-internal/testscript" 14 | ) 15 | 16 | func TestMain(m *testing.M) { 17 | os.Exit(testscript.RunMain(m, map[string]func() int{ 18 | "age": func() (exitCode int) { 19 | testOnlyPanicInsteadOfExit = true 20 | defer func() { 21 | if testOnlyDidExit { 22 | exitCode = recover().(int) 23 | } 24 | }() 25 | testOnlyConfigureScryptIdentity = func(r *age.ScryptRecipient) { 26 | r.SetWorkFactor(10) 27 | } 28 | testOnlyFixedRandomWord = "four" 29 | main() 30 | return 0 31 | }, 32 | "age-plugin-test": func() (exitCode int) { 33 | // TODO: use plugin server package once it's available. 34 | switch os.Args[1] { 35 | case "--age-plugin=recipient-v1": 36 | scanner := bufio.NewScanner(os.Stdin) 37 | scanner.Scan() // add-recipient 38 | scanner.Scan() // body 39 | scanner.Scan() // grease 40 | scanner.Scan() // body 41 | scanner.Scan() // wrap-file-key 42 | scanner.Scan() // body 43 | fileKey := scanner.Text() 44 | scanner.Scan() // extension-labels 45 | scanner.Scan() // body 46 | scanner.Scan() // done 47 | scanner.Scan() // body 48 | os.Stdout.WriteString("-> recipient-stanza 0 test\n") 49 | os.Stdout.WriteString(fileKey + "\n") 50 | scanner.Scan() // ok 51 | scanner.Scan() // body 52 | os.Stdout.WriteString("-> done\n\n") 53 | return 0 54 | case "--age-plugin=identity-v1": 55 | scanner := bufio.NewScanner(os.Stdin) 56 | scanner.Scan() // add-identity 57 | scanner.Scan() // body 58 | scanner.Scan() // grease 59 | scanner.Scan() // body 60 | scanner.Scan() // recipient-stanza 61 | scanner.Scan() // body 62 | fileKey := scanner.Text() 63 | scanner.Scan() // done 64 | scanner.Scan() // body 65 | os.Stdout.WriteString("-> file-key 0\n") 66 | os.Stdout.WriteString(fileKey + "\n") 67 | scanner.Scan() // ok 68 | scanner.Scan() // body 69 | os.Stdout.WriteString("-> done\n\n") 70 | return 0 71 | default: 72 | return 1 73 | } 74 | }, 75 | })) 76 | } 77 | 78 | func TestScript(t *testing.T) { 79 | testscript.Run(t, testscript.Params{ 80 | Dir: "testdata", 81 | // TODO: enable AGEDEBUG=plugin without breaking stderr checks. 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /cmd/age/encrypted_keys.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "bytes" 9 | "errors" 10 | "fmt" 11 | 12 | "filippo.io/age" 13 | ) 14 | 15 | // LazyScryptIdentity is an age.Identity that requests a passphrase only if it 16 | // encounters an scrypt stanza. After obtaining a passphrase, it delegates to 17 | // ScryptIdentity. 18 | type LazyScryptIdentity struct { 19 | Passphrase func() (string, error) 20 | } 21 | 22 | var _ age.Identity = &LazyScryptIdentity{} 23 | 24 | func (i *LazyScryptIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) { 25 | for _, s := range stanzas { 26 | if s.Type == "scrypt" && len(stanzas) != 1 { 27 | return nil, errors.New("an scrypt recipient must be the only one") 28 | } 29 | } 30 | if len(stanzas) != 1 || stanzas[0].Type != "scrypt" { 31 | return nil, age.ErrIncorrectIdentity 32 | } 33 | pass, err := i.Passphrase() 34 | if err != nil { 35 | return nil, fmt.Errorf("could not read passphrase: %v", err) 36 | } 37 | ii, err := age.NewScryptIdentity(pass) 38 | if err != nil { 39 | return nil, err 40 | } 41 | fileKey, err = ii.Unwrap(stanzas) 42 | if errors.Is(err, age.ErrIncorrectIdentity) { 43 | // ScryptIdentity returns ErrIncorrectIdentity for an incorrect 44 | // passphrase, which would lead Decrypt to returning "no identity 45 | // matched any recipient". That makes sense in the API, where there 46 | // might be multiple configured ScryptIdentity. Since in cmd/age there 47 | // can be only one, return a better error message. 48 | return nil, fmt.Errorf("incorrect passphrase") 49 | } 50 | return fileKey, err 51 | } 52 | 53 | type EncryptedIdentity struct { 54 | Contents []byte 55 | Passphrase func() (string, error) 56 | NoMatchWarning func() 57 | 58 | identities []age.Identity 59 | } 60 | 61 | var _ age.Identity = &EncryptedIdentity{} 62 | 63 | func (i *EncryptedIdentity) Recipients() ([]age.Recipient, error) { 64 | if i.identities == nil { 65 | if err := i.decrypt(); err != nil { 66 | return nil, err 67 | } 68 | } 69 | 70 | return identitiesToRecipients(i.identities) 71 | } 72 | 73 | func (i *EncryptedIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) { 74 | if i.identities == nil { 75 | if err := i.decrypt(); err != nil { 76 | return nil, err 77 | } 78 | } 79 | 80 | for _, id := range i.identities { 81 | fileKey, err = id.Unwrap(stanzas) 82 | if errors.Is(err, age.ErrIncorrectIdentity) { 83 | continue 84 | } 85 | if err != nil { 86 | return nil, err 87 | } 88 | return fileKey, nil 89 | } 90 | i.NoMatchWarning() 91 | return nil, age.ErrIncorrectIdentity 92 | } 93 | 94 | func (i *EncryptedIdentity) decrypt() error { 95 | d, err := age.Decrypt(bytes.NewReader(i.Contents), &LazyScryptIdentity{i.Passphrase}) 96 | if e := new(age.NoIdentityMatchError); errors.As(err, &e) { 97 | return fmt.Errorf("identity file is encrypted with age but not with a passphrase") 98 | } 99 | if err != nil { 100 | return fmt.Errorf("failed to decrypt identity file: %v", err) 101 | } 102 | i.identities, err = parseIdentities(d) 103 | return err 104 | } 105 | -------------------------------------------------------------------------------- /cmd/age/parse.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "bufio" 9 | "encoding/base64" 10 | "fmt" 11 | "io" 12 | "os" 13 | "strings" 14 | 15 | "filippo.io/age" 16 | "filippo.io/age/agessh" 17 | "filippo.io/age/armor" 18 | "filippo.io/age/plugin" 19 | "golang.org/x/crypto/cryptobyte" 20 | "golang.org/x/crypto/ssh" 21 | ) 22 | 23 | type gitHubRecipientError struct { 24 | username string 25 | } 26 | 27 | func (gitHubRecipientError) Error() string { 28 | return `"github:" recipients were removed from the design` 29 | } 30 | 31 | func parseRecipient(arg string) (age.Recipient, error) { 32 | switch { 33 | case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1: 34 | return plugin.NewRecipient(arg, pluginTerminalUI) 35 | case strings.HasPrefix(arg, "age1"): 36 | return age.ParseX25519Recipient(arg) 37 | case strings.HasPrefix(arg, "ssh-"): 38 | return agessh.ParseRecipient(arg) 39 | case strings.HasPrefix(arg, "github:"): 40 | name := strings.TrimPrefix(arg, "github:") 41 | return nil, gitHubRecipientError{name} 42 | } 43 | 44 | return nil, fmt.Errorf("unknown recipient type: %q", arg) 45 | } 46 | 47 | func parseRecipientsFile(name string) ([]age.Recipient, error) { 48 | var f *os.File 49 | if name == "-" { 50 | if stdinInUse { 51 | return nil, fmt.Errorf("standard input is used for multiple purposes") 52 | } 53 | stdinInUse = true 54 | f = os.Stdin 55 | } else { 56 | var err error 57 | f, err = os.Open(name) 58 | if err != nil { 59 | return nil, fmt.Errorf("failed to open recipient file: %v", err) 60 | } 61 | defer f.Close() 62 | } 63 | 64 | const recipientFileSizeLimit = 16 << 20 // 16 MiB 65 | const lineLengthLimit = 8 << 10 // 8 KiB, same as sshd(8) 66 | var recs []age.Recipient 67 | scanner := bufio.NewScanner(io.LimitReader(f, recipientFileSizeLimit)) 68 | var n int 69 | for scanner.Scan() { 70 | n++ 71 | line := scanner.Text() 72 | if strings.HasPrefix(line, "#") || line == "" { 73 | continue 74 | } 75 | if len(line) > lineLengthLimit { 76 | return nil, fmt.Errorf("%q: line %d is too long", name, n) 77 | } 78 | r, err := parseRecipient(line) 79 | if err != nil { 80 | if t, ok := sshKeyType(line); ok { 81 | // Skip unsupported but valid SSH public keys with a warning. 82 | warningf("recipients file %q: ignoring unsupported SSH key of type %q at line %d", name, t, n) 83 | continue 84 | } 85 | // Hide the error since it might unintentionally leak the contents 86 | // of confidential files. 87 | return nil, fmt.Errorf("%q: malformed recipient at line %d", name, n) 88 | } 89 | recs = append(recs, r) 90 | } 91 | if err := scanner.Err(); err != nil { 92 | return nil, fmt.Errorf("%q: failed to read recipients file: %v", name, err) 93 | } 94 | if len(recs) == 0 { 95 | return nil, fmt.Errorf("%q: no recipients found", name) 96 | } 97 | return recs, nil 98 | } 99 | 100 | func sshKeyType(s string) (string, bool) { 101 | // TODO: also ignore options? And maybe support multiple spaces and tabs as 102 | // field separators like OpenSSH? 103 | fields := strings.Split(s, " ") 104 | if len(fields) < 2 { 105 | return "", false 106 | } 107 | key, err := base64.StdEncoding.DecodeString(fields[1]) 108 | if err != nil { 109 | return "", false 110 | } 111 | k := cryptobyte.String(key) 112 | var typeLen uint32 113 | var typeBytes []byte 114 | if !k.ReadUint32(&typeLen) || !k.ReadBytes(&typeBytes, int(typeLen)) { 115 | return "", false 116 | } 117 | if t := fields[0]; t == string(typeBytes) { 118 | return t, true 119 | } 120 | return "", false 121 | } 122 | 123 | // parseIdentitiesFile parses a file that contains age or SSH keys. It returns 124 | // one or more of *age.X25519Identity, *agessh.RSAIdentity, *agessh.Ed25519Identity, 125 | // *agessh.EncryptedSSHIdentity, or *EncryptedIdentity. 126 | func parseIdentitiesFile(name string) ([]age.Identity, error) { 127 | var f *os.File 128 | if name == "-" { 129 | if stdinInUse { 130 | return nil, fmt.Errorf("standard input is used for multiple purposes") 131 | } 132 | stdinInUse = true 133 | f = os.Stdin 134 | } else { 135 | var err error 136 | f, err = os.Open(name) 137 | if err != nil { 138 | return nil, fmt.Errorf("failed to open file: %v", err) 139 | } 140 | defer f.Close() 141 | } 142 | 143 | b := bufio.NewReader(f) 144 | p, _ := b.Peek(14) // length of "age-encryption" and "-----BEGIN AGE" 145 | peeked := string(p) 146 | 147 | switch { 148 | // An age encrypted file, plain or armored. 149 | case peeked == "age-encryption" || peeked == "-----BEGIN AGE": 150 | var r io.Reader = b 151 | if peeked == "-----BEGIN AGE" { 152 | r = armor.NewReader(r) 153 | } 154 | const privateKeySizeLimit = 1 << 24 // 16 MiB 155 | contents, err := io.ReadAll(io.LimitReader(r, privateKeySizeLimit)) 156 | if err != nil { 157 | return nil, fmt.Errorf("failed to read %q: %v", name, err) 158 | } 159 | if len(contents) == privateKeySizeLimit { 160 | return nil, fmt.Errorf("failed to read %q: file too long", name) 161 | } 162 | return []age.Identity{&EncryptedIdentity{ 163 | Contents: contents, 164 | Passphrase: func() (string, error) { 165 | pass, err := readSecret(fmt.Sprintf("Enter passphrase for identity file %q:", name)) 166 | if err != nil { 167 | return "", fmt.Errorf("could not read passphrase: %v", err) 168 | } 169 | return string(pass), nil 170 | }, 171 | NoMatchWarning: func() { 172 | warningf("encrypted identity file %q didn't match file's recipients", name) 173 | }, 174 | }}, nil 175 | 176 | // Another PEM file, possibly an SSH private key. 177 | case strings.HasPrefix(peeked, "-----BEGIN"): 178 | const privateKeySizeLimit = 1 << 14 // 16 KiB 179 | contents, err := io.ReadAll(io.LimitReader(b, privateKeySizeLimit)) 180 | if err != nil { 181 | return nil, fmt.Errorf("failed to read %q: %v", name, err) 182 | } 183 | if len(contents) == privateKeySizeLimit { 184 | return nil, fmt.Errorf("failed to read %q: file too long", name) 185 | } 186 | return parseSSHIdentity(name, contents) 187 | 188 | // An unencrypted age identity file. 189 | default: 190 | ids, err := parseIdentities(b) 191 | if err != nil { 192 | return nil, fmt.Errorf("failed to read %q: %v", name, err) 193 | } 194 | return ids, nil 195 | } 196 | } 197 | 198 | func parseIdentity(s string) (age.Identity, error) { 199 | switch { 200 | case strings.HasPrefix(s, "AGE-PLUGIN-"): 201 | return plugin.NewIdentity(s, pluginTerminalUI) 202 | case strings.HasPrefix(s, "AGE-SECRET-KEY-1"): 203 | return age.ParseX25519Identity(s) 204 | default: 205 | return nil, fmt.Errorf("unknown identity type") 206 | } 207 | } 208 | 209 | // parseIdentities is like age.ParseIdentities, but supports plugin identities. 210 | func parseIdentities(f io.Reader) ([]age.Identity, error) { 211 | const privateKeySizeLimit = 1 << 24 // 16 MiB 212 | var ids []age.Identity 213 | scanner := bufio.NewScanner(io.LimitReader(f, privateKeySizeLimit)) 214 | var n int 215 | for scanner.Scan() { 216 | n++ 217 | line := scanner.Text() 218 | if strings.HasPrefix(line, "#") || line == "" { 219 | continue 220 | } 221 | 222 | i, err := parseIdentity(line) 223 | if err != nil { 224 | return nil, fmt.Errorf("error at line %d: %v", n, err) 225 | } 226 | ids = append(ids, i) 227 | 228 | } 229 | if err := scanner.Err(); err != nil { 230 | return nil, fmt.Errorf("failed to read secret keys file: %v", err) 231 | } 232 | if len(ids) == 0 { 233 | return nil, fmt.Errorf("no secret keys found") 234 | } 235 | return ids, nil 236 | } 237 | 238 | func parseSSHIdentity(name string, pemBytes []byte) ([]age.Identity, error) { 239 | id, err := agessh.ParseIdentity(pemBytes) 240 | if sshErr, ok := err.(*ssh.PassphraseMissingError); ok { 241 | pubKey := sshErr.PublicKey 242 | if pubKey == nil { 243 | pubKey, err = readPubFile(name) 244 | if err != nil { 245 | return nil, err 246 | } 247 | } 248 | passphrasePrompt := func() ([]byte, error) { 249 | pass, err := readSecret(fmt.Sprintf("Enter passphrase for %q:", name)) 250 | if err != nil { 251 | return nil, fmt.Errorf("could not read passphrase for %q: %v", name, err) 252 | } 253 | return pass, nil 254 | } 255 | i, err := agessh.NewEncryptedSSHIdentity(pubKey, pemBytes, passphrasePrompt) 256 | if err != nil { 257 | return nil, err 258 | } 259 | return []age.Identity{i}, nil 260 | } 261 | if err != nil { 262 | return nil, fmt.Errorf("malformed SSH identity in %q: %v", name, err) 263 | } 264 | 265 | return []age.Identity{id}, nil 266 | } 267 | 268 | func readPubFile(name string) (ssh.PublicKey, error) { 269 | if name == "-" { 270 | return nil, fmt.Errorf(`failed to obtain public key for "-" SSH key 271 | 272 | Use a file for which the corresponding ".pub" file exists, or convert the private key to a modern format with "ssh-keygen -p -m RFC4716"`) 273 | } 274 | f, err := os.Open(name + ".pub") 275 | if err != nil { 276 | return nil, fmt.Errorf(`failed to obtain public key for %q SSH key: %v 277 | 278 | Ensure %q exists, or convert the private key %q to a modern format with "ssh-keygen -p -m RFC4716"`, name, err, name+".pub", name) 279 | } 280 | defer f.Close() 281 | contents, err := io.ReadAll(f) 282 | if err != nil { 283 | return nil, fmt.Errorf("failed to read %q: %v", name+".pub", err) 284 | } 285 | pubKey, _, _, _, err := ssh.ParseAuthorizedKey(contents) 286 | if err != nil { 287 | return nil, fmt.Errorf("failed to parse %q: %v", name+".pub", err) 288 | } 289 | return pubKey, nil 290 | } 291 | -------------------------------------------------------------------------------- /cmd/age/testdata/ed25519.txt: -------------------------------------------------------------------------------- 1 | # encrypt and decrypt a file with -R 2 | age -R key.pem.pub -o test.age input 3 | age -d -i key.pem test.age 4 | cmp stdout input 5 | ! stderr . 6 | 7 | # encrypt and decrypt a file with -i 8 | age -e -i key.pem -o test.age input 9 | age -d -i key.pem test.age 10 | cmp stdout input 11 | ! stderr . 12 | 13 | # encrypt and decrypt a file with the wrong key 14 | age -R otherkey.pem.pub -o test.age input 15 | ! age -d -i key.pem test.age 16 | stderr 'no identity matched any of the recipients' 17 | ! stdout . 18 | 19 | -- input -- 20 | test 21 | -- key.pem -- 22 | -----BEGIN OPENSSH PRIVATE KEY----- 23 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 24 | QyNTUxOQAAACB/aTuac9tiWRGrKEtixFlryYlGCPTOpdbmXN9RRmDF2gAAAKDgV/GC4Ffx 25 | ggAAAAtzc2gtZWQyNTUxOQAAACB/aTuac9tiWRGrKEtixFlryYlGCPTOpdbmXN9RRmDF2g 26 | AAAECvFoQXQzXgJLQ+Gz4PfEcfyZwC2gUjOiWTD//mTPyD8H9pO5pz22JZEasoS2LEWWvJ 27 | iUYI9M6l1uZc31FGYMXaAAAAG2ZpbGlwcG9AQmlzdHJvbWF0aC1NMS5sb2NhbAEC 28 | -----END OPENSSH PRIVATE KEY----- 29 | -- key.pem.pub -- 30 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH9pO5pz22JZEasoS2LEWWvJiUYI9M6l1uZc31FGYMXa 31 | -- otherkey.pem.pub -- 32 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJFlMdZUMrWjJ3hh60MLALXSqUdAjBo/qEMJzvpekpoM 33 | -------------------------------------------------------------------------------- /cmd/age/testdata/encrypted_keys.txt: -------------------------------------------------------------------------------- 1 | # TODO: age-encrypted private keys, multiple identities, -i ordering, -e -i, 2 | # age file password prompt during encryption 3 | 4 | [!linux] [!darwin] skip # no pty support 5 | [darwin] [go1.20] skip # https://go.dev/issue/61779 6 | 7 | # use an encrypted OpenSSH private key without .pub file 8 | age -R key_ed25519.pub -o ed25519.age input 9 | rm key_ed25519.pub 10 | ttyin terminal 11 | age -d -i key_ed25519 ed25519.age 12 | cmp stdout input 13 | ! stderr . 14 | 15 | # -e -i with an encrypted OpenSSH private key 16 | age -e -i key_ed25519 -o ed25519.age input 17 | ttyin terminal 18 | age -d -i key_ed25519 ed25519.age 19 | cmp stdout input 20 | 21 | # a file encrypted to the wrong key does not ask for the password 22 | age -R key_ed25519_other.pub -o ed25519_other.age input 23 | ! age -d -i key_ed25519 ed25519_other.age 24 | stderr 'no identity matched any of the recipients' 25 | 26 | # use an encrypted legacy PEM private key with a .pub file 27 | age -R key_rsa_legacy.pub -o rsa_legacy.age input 28 | ttyin terminal 29 | age -d -i key_rsa_legacy rsa_legacy.age 30 | cmp stdout input 31 | ! stderr . 32 | age -R key_rsa_other.pub -o rsa_other.age input 33 | ! age -d -i key_rsa_legacy rsa_other.age 34 | stderr 'no identity matched any of the recipients' 35 | 36 | # -e -i with an encrypted legacy PEM private key 37 | age -e -i key_rsa_legacy -o rsa_legacy.age input 38 | ttyin terminal 39 | age -d -i key_rsa_legacy rsa_legacy.age 40 | cmp stdout input 41 | 42 | # legacy PEM private key without a .pub file causes an error 43 | rm key_rsa_legacy.pub 44 | ! age -d -i key_rsa_legacy rsa_legacy.age 45 | stderr 'key_rsa_legacy.pub' 46 | 47 | # mismatched .pub file causes an error 48 | cp key_rsa_legacy key_rsa_other 49 | ttyin terminal 50 | ! age -d -i key_rsa_other rsa_other.age 51 | stderr 'mismatched private and public SSH key' 52 | 53 | # buffer armored ciphertext before prompting if stdin is the terminal 54 | ttyin terminal 55 | age -e -i key_ed25519 -a -o test.age input 56 | exec cat test.age terminal # concatenated ciphertext + password 57 | ttyin -stdin stdout 58 | age -d -i key_ed25519 59 | ttyout 'Enter passphrase' 60 | ! stderr . 61 | cmp stdout input 62 | 63 | -- input -- 64 | test 65 | -- terminal -- 66 | password 67 | -- key_ed25519 -- 68 | -----BEGIN OPENSSH PRIVATE KEY----- 69 | b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABCuvb97i7 70 | U6Dz4+4SaF3kK1AAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIKaVctg4/hmFbfof 71 | Tv+yrC2IweO/Dd2AVDijFpaMO9fmAAAAoMO7yEnisRmzFdiExNt3XTYuLdP9m3jgOCroiF 72 | TtBhh1lAB2qggzWExMRP3Ak8+AloXEcWiACwBYnqwxhQMh0RDCDKC/H/4SXO+ds4HFWil+ 73 | 4bGF9wYZFU7IEjIK91CPGJ6YoWPn9dSdEjjbuCJtOMwHsysGyw5n/qSFPmSAPmA4YL2OzM 74 | WFOJ5gB5o1LKZkDTcdt7kPziIoVd5QkqpnYsE= 75 | -----END OPENSSH PRIVATE KEY----- 76 | -- key_ed25519.pub -- 77 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKaVctg4/hmFbfofTv+yrC2IweO/Dd2AVDijFpaMO9fm 78 | -- key_ed25519_other.pub -- 79 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINbTd+xfSBYKR/1Hp7FsoxwQAdIOk1Khye6ALBj7e1CV 80 | -- key_rsa_legacy -- 81 | -----BEGIN RSA PRIVATE KEY----- 82 | Proc-Type: 4,ENCRYPTED 83 | DEK-Info: AES-128-CBC,8045E7CF19D7794F4ADF5AC63179D985 84 | 85 | OESHhWCho337W1Ajg+iMbsZx/FPtHM3YPHu/d1U51ERIUh0wVof2SK0ooENokr6g 86 | O3fcv9Xga+Na4Ez+gsFRsIZOdqrJq+QBH0CAKi+Mz4KsU7teAobUBJgRB31Wt7eI 87 | 39KGZeaBJLMQ0FzQkDx5MCOg98iu9rt+Pg1bH8X88wV4vOv+tG4nmqgdpDmouo1Q 88 | uW1TJxrdPhkINjaPZZ7gvjS8wuG9+qwQY76I0hGun9secf4VZDysqUnUp8UHYovR 89 | dbvKCbglQy18mGL4kREJ/hH/9/maefS+pTMb2UX0onp9j7l3yNSvL4A4xW85ii6x 90 | liVMnZvLvbfPtI7jjZtC8CjshRkZke4fSZF2nZP7zK2qVcqDFCtemaks+0i2ksel 91 | D8clUKhBmq23VNAt+iy1stwHBporuaE6kEVJail5WPpgdfQjifpaMbTsZgOK+vGL 92 | GKi8vSJWfMU3lTf/N++ks2FWxdq0TgQirsKsQ5mWobfxc1XehvvdJj8hUtArrP32 93 | d4ge5DXPpmtkCzrc1+wt8Py/ANl9jV6c+4fCbpQ2snyzdFEhFtXHCEpMguN9MhKI 94 | gaZIfAxvYcQr8Gwew/IB02Phda9tvDiedHvyHGJmSy/87fR6ECh47VDFL/UYu4jG 95 | 0hRtAZMMddGNfoosnO6OKBd09cgvXKCsUrbpAI7dF5TP5ItDkVb08hW446jBdgS9 96 | 7QqB0rPmlAjsJi7fsrDw7Nq9pOdqqCEwUMc9Lztnv66aX1d/Y2vQm9mrsDbyZKqn 97 | g621rg7E4UHf7EGiDblfS234+TsNvwZ6sEbivU+3zqglPiOF71m6D0cKgaUZPOua 98 | GNdyQz5e73hYa+NJ76IZ+IqkoJAFXBkd1nWcN6DUBYiKvqd4qO9xD+JvNtiFlQ9d 99 | pyO9t4FTGvySh8CKyEUEdtj+2ftCIuZaUD2L5YJU1tlQV0EH42InOmkmphbHkW5v 100 | lNAoZAny1Z0P6O0gn7xtVrgd7paVQfDCJtkvsm5zR6Yei5FUgY/9NPaRotzuZVAY 101 | EfQC7JPdSdb5yusnXh7B9jGkgxhMIb6EPFFjIZ4iaV1RVgINSisGMSFzlqOz702b 102 | Cawsr9nD438cjzMNYEmrihZZBjHon6hHrLmM9Aj2xgprsoNLP1jJQ6WpZDlrYsj0 103 | XS0tSJmh0pM4Ey6j1VWNoaOxVseYLW7J9wGVfH/HJAc2k6Wg46P2e8lMT6Sj4YsT 104 | EguDhUjXrgePC53ohcSF+I6x35Q1D6ttMnc3ODzmIcCisxAvWdAqi1yRlnBotRwg 105 | S2vq3HU0yJFG8pJqw4vU9A9DlaMMT+ejEH+9xVwAWM+7n2lJcgthtWuShZCE6BB+ 106 | jVobSlTMArzQj4klTSbew1m9Waa6kKDezsAY66mryVNofCCeYDOBRecCm5JyMnWf 107 | WBVnNx+kZ/YyvYeBcSh34u8rkjqGpzfM/oPE7GwIoZvbAirjLohL7u8oq2bfAYG0 108 | /xIPwPJw1O3o5PHeu84bVIRqcKzGeaVL+5aUiZP9uNGUpqJWA5q2Sa5BOXV46yqO 109 | DIS8q7uPCSbt5mPXPDGJ1CupCdA1stUf2kb0cDJ+LpUbPND9SebBlxSuR1D/YGqv 110 | wlzfN5Usv/h/XNl98bYtpY8/skKPecyx3wG3VtwWH/5XVhvHz4TENjlKv/L2pbUC 111 | Dv83WcL1N/i+jerYxDRmGe3NQOvyW4JaNzzjgb74T7rE1/3lf6qkmUHjxfo4VZAF 112 | L/q2782OUs5Qt4/pYAIISzLdBw6XtTjZHirqa6YNrFvGucB3NG49AC0b1Z0acfrS 113 | iimC2TvZpwunlLbyz2SQQL2c1zQ3U/Yfh2F1Zt8o6kK3RgKSSx57rK6nV7hXMGGp 114 | C4HV3nLetZg8HexicqeRANLXuUDbCSpN8K4nW5G2g/yKPfsQHBV/RWEDfhndykja 115 | +SmoY5IB+2zEbCC3MWiP9ZdIcCYOsq8wDZESMMW40DlVICjrf6UOqQ+ogci20qLS 116 | CmpgmOPAaBZJG/sBU79eHUSjPCK6yDpSyc30oVn8FnoBTmOpt7R++Ub8RJxReXBt 117 | +6o0NXYCJNaeVnk1bE4iavkJrXJCZvu44VBLS0WUs9W8TD4Iq8kNHsfQsfOuBXnQ 118 | ncgoIe9HppnMGNoSzjYBNL/rprlbaOE55TkPqiQsiskRcaoeY53aTxoIykHmoj8G 119 | wJo/00IR+NYir7tr03Vriw+uywPPGucVJGWTUGsNbHlS5j941IltflIf6FitElNr 120 | JxVuJLgYiP3JhmWpdqA/uidYJMbIjunpn/8rVLrAil04SCSfUmaCdl7dkQ9x+3Mf 121 | Erm699vIBQwvv0i+mcwKEvqSrhhNQ2F7vrb7NL8I2wUEPgQbv1PxSV6X0aYcxYVI 122 | -----END RSA PRIVATE KEY----- 123 | -- key_rsa_legacy.pub -- 124 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCky7Clp8I3LVoqZWtat+QR6KmM0evFilmFhwenINIBbb8eS3ftDSkQy2YRrlAvO3h4EZffOIxANGL/yKVlRCIzvjsphi+tTHscZsQhwMnLEmxEayTq20hZKcwNA8TQdh2TW/w0KZmNZcxlTn4IK8W16komHcoH/qrRiXq8z3ROcfnv3Q4Hll9MUCwBkfy2DdBpWUMidQ1dAK4i3vXdseF74hJ0jFbPtS5mlpOsJZa0sdH1dnEl5M8wZS3PxyzM6JMkgzG7INp4sO/xGIisjl/QuSh2Fu93/EogdGXxIZChniUfzBx1DaHlerPPNSMP+uLbaOIAQrIPozhfdUdsCFDMoB7/PA6g1WVYZWAqjBZZW/GMOzPhih57NIFBSyMTzMi1KS6OBvYJvPf4IcvOa3May9ylLG/wZVhrHlQPbSsbRrraVtJ1P4gGQJ5U4d2AD2q+XtMb5f2i/holMXTVQl7Fa7RYi1TblDuW5OZCvmIawePBXAYbPg0OVFs3vAVEuAM= 125 | -- key_rsa_other.pub -- 126 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDQiCWw2W++gX4wcwpDo6QIouwQ9PPwCVe7QPICzxztG27mzeKRM4xT2LURGSaQqg7OYIUTGrLqNsaLZW+FHHQlRAVv1LEbdEFa5JermBMJ5j/HxamE/7oV60gMRlgKW+4IZhVMPgRZaaXU0YPb9oACdMNM8kPkc5JaOJ8iO6B1RViybjLD+tsEEPXLp3Mrj+sJqs+IvNlJKXdeefOjNrGmLHKIFdHiWlZ+aAW+QLfMQiNXoTbGybFUSpNEbmK/1ITiRAly94NoUK9LoriueXR+WJIm9wP4SfHw+hMBz1cywdF2wwKmWWegizV/USEmhyNXUzHZzjbkgE84DrIq+NA7SUmw6C8ClMjdnRnnoIyga99yMIrYMny1KW/bk1NK4u6Tv17E+FFOS3vf2Gcj01/jOmAUIQwL8MjAHhnsZ4XAA5NHa2NRGWm+hw7fx5uX42Gyz8HidFda5Lij1pASBcx4U3qwb62X+IVN50jGIP6kRNmGtMLY1JgaoGDDkw9r6mU= 127 | -------------------------------------------------------------------------------- /cmd/age/testdata/output_file.txt: -------------------------------------------------------------------------------- 1 | # https://github.com/FiloSottile/age/issues/57 2 | age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o test.age input 3 | ! age -o test.out -d -i wrong.txt test.age 4 | ! exists test.out 5 | ! age -o test.out -d test.age 6 | ! exists test.out 7 | ! age -o test.out -d -i notexist test.age 8 | ! exists test.out 9 | ! age -o test.out -d -i wrong.txt notexist 10 | ! exists test.out 11 | ! age -o test.out -r BAD 12 | ! exists test.out 13 | ! age -o test.out -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef notexist 14 | ! exists test.out 15 | ! age -o test.out -p notexist 16 | ! exists test.out 17 | 18 | # https://github.com/FiloSottile/age/issues/555 19 | age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o empty.age empty 20 | exists empty.age 21 | age -d -i key.txt empty.age 22 | ! stdout . 23 | ! stderr . 24 | age -d -i key.txt -o new empty.age 25 | ! stderr . 26 | cmp new empty 27 | 28 | # https://github.com/FiloSottile/age/issues/491 29 | cp input inputcopy 30 | ! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o inputcopy inputcopy 31 | stderr 'input and output file are the same' 32 | cmp inputcopy input 33 | ! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o ./inputcopy inputcopy 34 | stderr 'input and output file are the same' 35 | cmp inputcopy input 36 | mkdir foo 37 | ! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o inputcopy foo/../inputcopy 38 | stderr 'input and output file are the same' 39 | cmp inputcopy input 40 | cp key.txt keycopy 41 | age -e -i keycopy -o test.age input 42 | ! age -d -i keycopy -o keycopy test.age 43 | stderr 'input and output file are the same' 44 | cmp key.txt keycopy 45 | 46 | [!linux] [!darwin] skip # no pty support 47 | [darwin] [go1.20] skip # https://go.dev/issue/61779 48 | 49 | ttyin terminal 50 | ! age -p -o inputcopy inputcopy 51 | stderr 'input and output file are the same' 52 | cmp inputcopy input 53 | 54 | # https://github.com/FiloSottile/age/issues/159 55 | ttyin terminal 56 | age -p -a -o test.age input 57 | ttyin terminalwrong 58 | ! age -o test.out -d test.age 59 | ttyout 'Enter passphrase' 60 | stderr 'incorrect passphrase' 61 | ! exists test.out 62 | 63 | -- terminal -- 64 | password 65 | password 66 | -- terminalwrong -- 67 | wrong 68 | -- input -- 69 | age 70 | -- empty -- 71 | -- key.txt -- 72 | # created: 2021-02-02T13:09:43+01:00 73 | # public key: age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef 74 | AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0 75 | -- wrong.txt -- 76 | # created: 2024-06-16T12:14:00+02:00 77 | # public key: age10k7vsqmeg3sp8elfyq5ts55feg4huarpcaf9dmljn9umydg3gymsvx4dp9 78 | AGE-SECRET-KEY-1NPX08S4LELW9K68FKU0U05XXEKG6X7GT004TPNYLF86H3M00D3FQ3VQQNN 79 | -------------------------------------------------------------------------------- /cmd/age/testdata/pkcs8.txt: -------------------------------------------------------------------------------- 1 | # https://github.com/FiloSottile/age/discussions/428 2 | # encrypt and decrypt a file with an Ed25519 key encoded with PKCS#8 3 | age -e -i key.pem -o test.age input 4 | age -d -i key.pem test.age 5 | cmp stdout input 6 | ! stderr . 7 | 8 | -- input -- 9 | test 10 | -- key.pem -- 11 | -----BEGIN PRIVATE KEY----- 12 | MC4CAQAwBQYDK2VwBCIEIJT4Wpo+YG11yybKL/bYXQW7ekz4PAsmV/4tfmY1vU7x 13 | -----END PRIVATE KEY----- 14 | -------------------------------------------------------------------------------- /cmd/age/testdata/plugin.txt: -------------------------------------------------------------------------------- 1 | # encrypt and decrypt a file with a test plugin 2 | age -r age1test10qdmzv9q -o test.age input 3 | age -d -i key.txt test.age 4 | cmp stdout input 5 | ! stderr . 6 | 7 | # very long identity and recipient 8 | age -R long-recipient.txt -o test.age input 9 | age -d -i long-key.txt test.age 10 | cmp stdout input 11 | ! stderr . 12 | 13 | # check that path separators are rejected 14 | chmod 755 age-plugin-pwn/pwn 15 | mkdir $TMPDIR/age-plugin-pwn 16 | cp age-plugin-pwn/pwn $TMPDIR/age-plugin-pwn/pwn 17 | ! age -r age1pwn/pwn19gt89dfz input 18 | ! age -d -i pwn-identity.txt test.age 19 | ! age -d -j pwn/pwn test.age 20 | ! exists pwn 21 | 22 | -- input -- 23 | test 24 | -- key.txt -- 25 | AGE-PLUGIN-TEST-10Q32NLXM 26 | -- long-recipient.txt -- 27 | age1test10pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7qj6rl8p 28 | -- long-key.txt -- 29 | AGE-PLUGIN-TEST-10PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7Q5U8SUD 30 | -- pwn-identity.txt -- 31 | AGE-PLUGIN-PWN/PWN-19GYK4WLY 32 | -- age-plugin-pwn/pwn -- 33 | #!/bin/sh 34 | touch "$WORK/pwn" 35 | -------------------------------------------------------------------------------- /cmd/age/testdata/rsa.txt: -------------------------------------------------------------------------------- 1 | # encrypt and decrypt a file with -R 2 | age -R key.pem.pub -o test.age input 3 | age -d -i key.pem test.age 4 | cmp stdout input 5 | ! stderr . 6 | 7 | # encrypt and decrypt a file with -i 8 | age -e -i key.pem -o test.age input 9 | age -d -i key.pem test.age 10 | cmp stdout input 11 | ! stderr . 12 | 13 | # encrypt and decrypt a file with the wrong key 14 | age -R otherkey.pem.pub -o test.age input 15 | ! age -d -i key.pem test.age 16 | stderr 'no identity matched any of the recipients' 17 | 18 | -- input -- 19 | test 20 | -- key.pem -- 21 | -----BEGIN OPENSSH PRIVATE KEY----- 22 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn 23 | NhAAAAAwEAAQAAAYEA1C04rdClHoW4oG4bEGmaNqFy4DLoPJ0358w4XH+XBM3TiWcheouW 24 | kUG6m1yDmHk0t0oaaf4hOnetKovdyQQX73gGaq++rSu5VSvH7LbwABoG6PS/UbuZ4Vl9B0 25 | 5WVDqHVE9hNK4AHqBc373GU2mo8z5opKxEprmiS3HSd3K2wiMqL5E8XPOSm0p/isuYK57X 26 | VUexl73tB7iIMLklxjcjtP4REMoQhHKOMOdy2Q15dw5cYG+drtEArBRYkCZmd0Vp2ws9pj 27 | YzPVaOSkbdqSeLu+JVbH1wrwKhuBrA3eVlwjUTWkO4FHcNXkp773Mt4cXhKizTfbR2hQox 28 | Lsj31301Xd7dEpV63sqDW1e+a2L2dhemi8cjDMrPuW6Z19Lbti0quAb4+cSLAaJI4BHd1F 29 | 8o9XhK7EHVCdIIIQDKVzo1WyEsDwBjL1LB9rpxm4732sZyue0uygFzmM544QX+WsiJXgHP 30 | uC1Q/ynjLRm6ZMl16MwvY8B/XGQWxlOAbRJQG84fAAAFmEwAjV1MAI1dAAAAB3NzaC1yc2 31 | EAAAGBANQtOK3QpR6FuKBuGxBpmjahcuAy6DydN+fMOFx/lwTN04lnIXqLlpFBuptcg5h5 32 | NLdKGmn+ITp3rSqL3ckEF+94Bmqvvq0ruVUrx+y28AAaBuj0v1G7meFZfQdOVlQ6h1RPYT 33 | SuAB6gXN+9xlNpqPM+aKSsRKa5oktx0ndytsIjKi+RPFzzkptKf4rLmCue11VHsZe97Qe4 34 | iDC5JcY3I7T+ERDKEIRyjjDnctkNeXcOXGBvna7RAKwUWJAmZndFadsLPaY2Mz1WjkpG3a 35 | kni7viVWx9cK8CobgawN3lZcI1E1pDuBR3DV5Ke+9zLeHF4Sos0320doUKMS7I99d9NV3e 36 | 3RKVet7Kg1tXvmti9nYXpovHIwzKz7lumdfS27YtKrgG+PnEiwGiSOAR3dRfKPV4SuxB1Q 37 | nSCCEAylc6NVshLA8AYy9Swfa6cZuO99rGcrntLsoBc5jOeOEF/lrIiV4Bz7gtUP8p4y0Z 38 | umTJdejML2PAf1xkFsZTgG0SUBvOHwAAAAMBAAEAAAGBAKytAOu0Wi009sTZ1vzMdMzxJ+ 39 | R+ibKK4Oysr1HYJLesKvQwEncBE1C0BYJbEF4OhnCExmpsf+5tZ2iw25a01iX1sIMy9CNK 40 | 6lH+h36Gg1wR0n3Ucb+6xck4YyCHCIsT9v8OezW8Riympe8RK07HNtB/gfpCmLx3ZzWvNH 41 | Ix0bq9k5+Su2WKdU4cmyACAZ2+b9DfwBCWaUlXTL8abzuZtF2gR5M6X6bq8/2o3zb2WFwk 42 | O9nf/JxBTCK/jDQEjG+U9MyGxZIW5DeG1nNFtOzJoT8krIkeSOjQ5XQrkjCw+yihSCWMG+ 43 | s+SKO77u30SO7OCENsFIXpUzpt6+JmazlXjLW/OdYNooQMHtqCZzVMRgxiy3gDGF35YvgV 44 | VnP5gVEW9HEZ0kD+x4Rl2kB6bV7jMi8BXrazQ1EmTasJFg1pv6iRJWzY1JoP2kRfgiHGL6 45 | OqgrXakqo3hMJuz+JRU2/hlF13743MiIxpcbaaRqURoWuNRLHitVWE35/XVCez0C6OwQAA 46 | AMEAoh106+3JbiZI19iRICR247IoOLpSSed98eQj+l3OYfJ86cQipSjxdSPWcP58yyyElY 47 | d9q6K16sDTLAlRJzF7MFxSc80JY6RgFq/Sy4Jm0/Z10wwJhTgOkxq6IynzLnO7goRirE31 48 | jxGif4nI2IYEQvv6MOD8TWA4axxGMw2StYB6P4R5peozf81oR6m79ERIDSkrm0RYYn931r 49 | gVuxvo3ABVxMtg1lV80LJMayy87Oi8BehGBxMBgsKtQaH8+5h7AAAAwQD+8lJpBcrrHQKk 50 | 3o2XAZxB5Fool4f2iuZWTxA1vq0/TCUcEodrdWfLuDeVbDsFemW0vBSkKzf4NlZSs2DAKl 51 | YWT6y18eyDyJXn0TNVTeO3F5mkkX5spqbjDcESSs3whIuDqXU++3sII7iMzGw50tDP4Dw6 52 | TViEVM3anpeqlAbkciR5o9IJx3nRcGh81Bs4gticcRF0vqiJoAhNlSZXR1XMjevwt68i+4 53 | RKPPQsTM7uJLm236VUhDivO1OJcBTLW7MAAADBANUNqH+//G4gIruBO3BsIvbzDw0DgRam 54 | R1tqqn4g53boiv1RPtUJ2GbkCsisy5pU+JdTN7ekFEF8KWuunjImkfVyAiTFsHHmOoXV3Z 55 | EX0mNDXOlKOP2YAIMrDt5CkPdEh6qQG21LCZXTWmwheZ9iN2vOl/fKqUW9lqd/kTe6WsON 56 | hIpZhs2+oz54Riq1ZwzO9NkcYrvZoDKbDopL1r2ibw0mkgCJrxpWi0Yt2Iooh4GXXqP5C9 57 | T8hrZCbrVJkjKd5QAAABtmaWxpcHBvQEJpc3Ryb21hdGgtTTEubG9jYWwBAgMEBQY= 58 | -----END OPENSSH PRIVATE KEY----- 59 | -- key.pem.pub -- 60 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDULTit0KUehbigbhsQaZo2oXLgMug8nTfnzDhcf5cEzdOJZyF6i5aRQbqbXIOYeTS3Shpp/iE6d60qi93JBBfveAZqr76tK7lVK8fstvAAGgbo9L9Ru5nhWX0HTlZUOodUT2E0rgAeoFzfvcZTaajzPmikrESmuaJLcdJ3crbCIyovkTxc85KbSn+Ky5grntdVR7GXve0HuIgwuSXGNyO0/hEQyhCEco4w53LZDXl3Dlxgb52u0QCsFFiQJmZ3RWnbCz2mNjM9Vo5KRt2pJ4u74lVsfXCvAqG4GsDd5WXCNRNaQ7gUdw1eSnvvcy3hxeEqLNN9tHaFCjEuyPfXfTVd3t0SlXreyoNbV75rYvZ2F6aLxyMMys+5bpnX0tu2LSq4Bvj5xIsBokjgEd3UXyj1eErsQdUJ0gghAMpXOjVbISwPAGMvUsH2unGbjvfaxnK57S7KAXOYznjhBf5ayIleAc+4LVD/KeMtGbpkyXXozC9jwH9cZBbGU4BtElAbzh8= 61 | -- otherkey.pem.pub -- 62 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDF0OPu95EY25O5KmYFLIkiZZFKUlfvaRgmfIT6OcZvPRXBzo0MS/lcrYvAc0RsUVbZ1B3Y9oWmKt/IMXTztCXiza70rO1NI7ciayv5svY/wGMoveutddhA64IjrQKs4m+6Qmjs/dYTnfsk1BzmXrdRKUSqH6c4Id7pRLC1ySLu+4og3nTTpBRBpg+uSkc4Ua6ce6A6RX14PPJ+TAXMfZyKNyaubQhgzLB/CfdXxZqWdAnyooiE7fb6CEB5uppnA5BpPdcWAkSixbwxRHbRC+OSCqMOV6+z+NlO/qSOKJcXfCQnJP/qjJTJde0dYhXG4RILOzIkGVieGJJONDXvj61mMj568IhJz0AEf/UMhvEL79iJ6yZW82Go/zcYkDDfd3KRE3pW+6p9Onu3XqOiQABS+9rEVRBnqYsPajiHBIanBeXpWKGbjznakvxhdRifhOWwAsQDfLmGzh+JnV1vOUjyxKtLNv9zi/oeuYCaIyF7F6en8LMbYSz8YONMZygGxMU= 63 | -------------------------------------------------------------------------------- /cmd/age/testdata/scrypt.txt: -------------------------------------------------------------------------------- 1 | [!linux] [!darwin] skip # no pty support 2 | [darwin] [go1.20] skip # https://go.dev/issue/61779 3 | 4 | # encrypt with a provided passphrase 5 | stdin input 6 | ttyin terminal 7 | age -p -o test.age 8 | ttyout 'Enter passphrase' 9 | ! stderr . 10 | ! stdout . 11 | 12 | # decrypt with a provided passphrase 13 | ttyin terminal 14 | age -d test.age 15 | ttyout 'Enter passphrase' 16 | ! stderr . 17 | cmp stdout input 18 | 19 | # decrypt with the wrong passphrase 20 | ttyin wrong 21 | ! age -d test.age 22 | stderr 'incorrect passphrase' 23 | 24 | # encrypt with a generated passphrase 25 | stdin input 26 | ttyin empty 27 | age -p -o test.age 28 | ! stderr . 29 | ! stdout . 30 | ttyin autogenerated 31 | age -d test.age 32 | cmp stdout input 33 | 34 | # fail when -i is present 35 | ttyin terminal 36 | ! age -d -i key.txt test.age 37 | stderr 'file is passphrase-encrypted but identities were specified' 38 | 39 | # fail when passphrases don't match 40 | ttyin wrong 41 | ! age -p -o fail.age 42 | stderr 'passphrases didn''t match' 43 | ! exists fail.age 44 | 45 | -- terminal -- 46 | password 47 | password 48 | -- wrong -- 49 | PASSWORD 50 | password 51 | -- input -- 52 | test 53 | -- key.txt -- 54 | # created: 2021-02-02T13:09:43+01:00 55 | # public key: age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef 56 | AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0 57 | -- autogenerated -- 58 | four-four-four-four-four-four-four-four-four-four 59 | -- empty -- 60 | 61 | -------------------------------------------------------------------------------- /cmd/age/testdata/terminal.txt: -------------------------------------------------------------------------------- 1 | [!linux] [!darwin] skip # no pty support 2 | [darwin] [go1.20] skip # https://go.dev/issue/61779 3 | 4 | # controlling terminal is used instead of stdin/stderr 5 | ttyin terminal 6 | age -p -o test.age input 7 | ! stderr . 8 | 9 | # autogenerated passphrase is printed to terminal 10 | ttyin empty 11 | age -p -o test.age input 12 | ttyout 'autogenerated passphrase' 13 | ! stderr . 14 | 15 | # with no controlling terminal, stdin terminal is used 16 | ## TODO: enable once https://golang.org/issue/53601 is fixed 17 | ## and Noctty is added to testscript. 18 | # noctty 19 | # ttyin -stdin terminal 20 | # age -p -o test.age input 21 | # ! stderr . 22 | 23 | # no terminal causes an error 24 | ## TODO: enable once https://golang.org/issue/53601 is fixed 25 | ## and Noctty is added to testscript. 26 | # noctty 27 | # ! age -p -o test.age input 28 | # stderr 'standard input is not a terminal' 29 | 30 | # prompt for password before plaintext if stdin is the terminal 31 | exec cat terminal input # concatenated password + input 32 | ttyin -stdin stdout 33 | age -p -a -o test.age 34 | ttyout 'Enter passphrase' 35 | ! stderr . 36 | # check the file was encrypted correctly 37 | ttyin terminal 38 | age -d test.age 39 | cmp stdout input 40 | 41 | # buffer armored ciphertext before prompting if stdin is the terminal 42 | ttyin terminal 43 | age -p -a -o test.age input 44 | exec cat test.age terminal # concatenated ciphertext + password 45 | ttyin -stdin stdout 46 | age -d 47 | ttyout 'Enter passphrase' 48 | ! stderr . 49 | cmp stdout input 50 | 51 | -- input -- 52 | test 53 | -- terminal -- 54 | password 55 | password 56 | -- empty -- 57 | 58 | -------------------------------------------------------------------------------- /cmd/age/testdata/usage.txt: -------------------------------------------------------------------------------- 1 | # -help 2 | age -p -help 3 | ! stdout . 4 | stderr 'Usage:' 5 | 6 | # -h 7 | age -p -h 8 | ! stdout . 9 | stderr 'Usage:' 10 | 11 | # unknown flag 12 | ! age -p -this-flag-does-not-exist 13 | ! stdout . 14 | stderr 'flag provided but not defined' 15 | stderr 'Usage:' 16 | 17 | # no arguments 18 | ! age 19 | ! stdout . 20 | stderr 'Usage:' 21 | -------------------------------------------------------------------------------- /cmd/age/testdata/x25519.txt: -------------------------------------------------------------------------------- 1 | # encrypt and decrypt a file with -r 2 | age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o test.age input 3 | age -d -i key.txt test.age 4 | cmp stdout input 5 | ! stderr . 6 | 7 | # encrypt and decrypt a file with -i 8 | age -e -i key.txt -o test.age input 9 | age -d -i key.txt test.age 10 | cmp stdout input 11 | ! stderr . 12 | 13 | # encrypt and decrypt a file with the wrong key 14 | age -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -o test.age input 15 | ! age -d -i key.txt test.age 16 | stderr 'no identity matched any of the recipients' 17 | 18 | -- input -- 19 | test 20 | -- key.txt -- 21 | # created: 2021-02-02T13:09:43+01:00 22 | # public key: age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef 23 | AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0 24 | -------------------------------------------------------------------------------- /cmd/age/tui.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | // This file implements the terminal UI of cmd/age. The rules are: 8 | // 9 | // - Anything that requires user interaction goes to the terminal, 10 | // and is erased afterwards if possible. This UI would be possible 11 | // to replace with a pinentry with no output or UX changes. 12 | // 13 | // - Everything else goes to standard error with an "age:" prefix. 14 | // No capitalized initials and no periods at the end. 15 | 16 | import ( 17 | "bytes" 18 | "errors" 19 | "fmt" 20 | "io" 21 | "log" 22 | "os" 23 | "runtime" 24 | 25 | "filippo.io/age/armor" 26 | "filippo.io/age/plugin" 27 | "golang.org/x/term" 28 | ) 29 | 30 | // l is a logger with no prefixes. 31 | var l = log.New(os.Stderr, "", 0) 32 | 33 | func printf(format string, v ...interface{}) { 34 | l.Printf("age: "+format, v...) 35 | } 36 | 37 | func errorf(format string, v ...interface{}) { 38 | l.Printf("age: error: "+format, v...) 39 | l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report") 40 | exit(1) 41 | } 42 | 43 | func warningf(format string, v ...interface{}) { 44 | l.Printf("age: warning: "+format, v...) 45 | } 46 | 47 | func errorWithHint(error string, hints ...string) { 48 | l.Printf("age: error: %s", error) 49 | for _, hint := range hints { 50 | l.Printf("age: hint: %s", hint) 51 | } 52 | l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report") 53 | exit(1) 54 | } 55 | 56 | // If testOnlyPanicInsteadOfExit is true, exit will set testOnlyDidExit and 57 | // panic instead of calling os.Exit. This way, the wrapper in TestMain can 58 | // recover the panic and return the exit code only if it was originated in exit. 59 | var testOnlyPanicInsteadOfExit bool 60 | var testOnlyDidExit bool 61 | 62 | func exit(code int) { 63 | if testOnlyPanicInsteadOfExit { 64 | testOnlyDidExit = true 65 | panic(code) 66 | } 67 | os.Exit(code) 68 | } 69 | 70 | // clearLine clears the current line on the terminal, or opens a new line if 71 | // terminal escape codes don't work. 72 | func clearLine(out io.Writer) { 73 | const ( 74 | CUI = "\033[" // Control Sequence Introducer 75 | CPL = CUI + "F" // Cursor Previous Line 76 | EL = CUI + "K" // Erase in Line 77 | ) 78 | 79 | // First, open a new line, which is guaranteed to work everywhere. Then, try 80 | // to erase the line above with escape codes. 81 | // 82 | // (We use CRLF instead of LF to work around an apparent bug in WSL2's 83 | // handling of CONOUT$. Only when running a Windows binary from WSL2, the 84 | // cursor would not go back to the start of the line with a simple LF. 85 | // Honestly, it's impressive CONIN$ and CONOUT$ work at all inside WSL2.) 86 | fmt.Fprintf(out, "\r\n"+CPL+EL) 87 | } 88 | 89 | // withTerminal runs f with the terminal input and output files, if available. 90 | // withTerminal does not open a non-terminal stdin, so the caller does not need 91 | // to check stdinInUse. 92 | func withTerminal(f func(in, out *os.File) error) error { 93 | if runtime.GOOS == "windows" { 94 | in, err := os.OpenFile("CONIN$", os.O_RDWR, 0) 95 | if err != nil { 96 | return err 97 | } 98 | defer in.Close() 99 | out, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0) 100 | if err != nil { 101 | return err 102 | } 103 | defer out.Close() 104 | return f(in, out) 105 | } else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil { 106 | defer tty.Close() 107 | return f(tty, tty) 108 | } else if term.IsTerminal(int(os.Stdin.Fd())) { 109 | return f(os.Stdin, os.Stdin) 110 | } else { 111 | return fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err) 112 | } 113 | } 114 | 115 | func printfToTerminal(format string, v ...interface{}) error { 116 | return withTerminal(func(_, out *os.File) error { 117 | _, err := fmt.Fprintf(out, "age: "+format+"\n", v...) 118 | return err 119 | }) 120 | } 121 | 122 | // readSecret reads a value from the terminal with no echo. The prompt is ephemeral. 123 | func readSecret(prompt string) (s []byte, err error) { 124 | err = withTerminal(func(in, out *os.File) error { 125 | fmt.Fprintf(out, "%s ", prompt) 126 | defer clearLine(out) 127 | s, err = term.ReadPassword(int(in.Fd())) 128 | return err 129 | }) 130 | return 131 | } 132 | 133 | // readCharacter reads a single character from the terminal with no echo. The 134 | // prompt is ephemeral. 135 | func readCharacter(prompt string) (c byte, err error) { 136 | err = withTerminal(func(in, out *os.File) error { 137 | fmt.Fprintf(out, "%s ", prompt) 138 | defer clearLine(out) 139 | 140 | oldState, err := term.MakeRaw(int(in.Fd())) 141 | if err != nil { 142 | return err 143 | } 144 | defer term.Restore(int(in.Fd()), oldState) 145 | 146 | b := make([]byte, 1) 147 | if _, err := in.Read(b); err != nil { 148 | return err 149 | } 150 | 151 | c = b[0] 152 | return nil 153 | }) 154 | return 155 | } 156 | 157 | var pluginTerminalUI = &plugin.ClientUI{ 158 | DisplayMessage: func(name, message string) error { 159 | printf("%s plugin: %s", name, message) 160 | return nil 161 | }, 162 | RequestValue: func(name, message string, _ bool) (s string, err error) { 163 | defer func() { 164 | if err != nil { 165 | warningf("could not read value for age-plugin-%s: %v", name, err) 166 | } 167 | }() 168 | secret, err := readSecret(message) 169 | if err != nil { 170 | return "", err 171 | } 172 | return string(secret), nil 173 | }, 174 | Confirm: func(name, message, yes, no string) (choseYes bool, err error) { 175 | defer func() { 176 | if err != nil { 177 | warningf("could not read value for age-plugin-%s: %v", name, err) 178 | } 179 | }() 180 | if no == "" { 181 | message += fmt.Sprintf(" (press enter for %q)", yes) 182 | _, err := readSecret(message) 183 | if err != nil { 184 | return false, err 185 | } 186 | return true, nil 187 | } 188 | message += fmt.Sprintf(" (press [1] for %q or [2] for %q)", yes, no) 189 | for { 190 | selection, err := readCharacter(message) 191 | if err != nil { 192 | return false, err 193 | } 194 | switch selection { 195 | case '1': 196 | return true, nil 197 | case '2': 198 | return false, nil 199 | case '\x03': // CTRL-C 200 | return false, errors.New("user cancelled prompt") 201 | default: 202 | warningf("reading value for age-plugin-%s: invalid selection %q", name, selection) 203 | } 204 | } 205 | }, 206 | WaitTimer: func(name string) { 207 | printf("waiting on %s plugin...", name) 208 | }, 209 | } 210 | 211 | func bufferTerminalInput(in io.Reader) (io.Reader, error) { 212 | buf := &bytes.Buffer{} 213 | if _, err := buf.ReadFrom(ReaderFunc(func(p []byte) (n int, err error) { 214 | if bytes.Contains(buf.Bytes(), []byte(armor.Footer+"\n")) { 215 | return 0, io.EOF 216 | } 217 | return in.Read(p) 218 | })); err != nil { 219 | return nil, err 220 | } 221 | return buf, nil 222 | } 223 | 224 | type ReaderFunc func(p []byte) (n int, err error) 225 | 226 | func (f ReaderFunc) Read(p []byte) (n int, err error) { return f(p) } 227 | -------------------------------------------------------------------------------- /cmd/age/wordlist.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "crypto/rand" 9 | "encoding/binary" 10 | "strings" 11 | ) 12 | 13 | var testOnlyFixedRandomWord string 14 | 15 | func randomWord() string { 16 | if testOnlyFixedRandomWord != "" { 17 | return testOnlyFixedRandomWord 18 | } 19 | buf := make([]byte, 2) 20 | if _, err := rand.Read(buf); err != nil { 21 | panic(err) 22 | } 23 | n := binary.BigEndian.Uint16(buf) 24 | return wordlist[int(n)%2048] 25 | } 26 | 27 | // wordlist is the BIP39 list of 2048 english words, and it's used to generate 28 | // the suggested passphrases. 29 | var wordlist = strings.Split(`abandon ability able about above absent absorb abstract absurd abuse access accident account accuse achieve acid acoustic acquire across act action actor actress actual adapt add addict address adjust admit adult advance advice aerobic affair afford afraid again age agent agree ahead aim air airport aisle alarm album alcohol alert alien all alley allow almost alone alpha already also alter always amateur amazing among amount amused analyst anchor ancient anger angle angry animal ankle announce annual another answer antenna antique anxiety any apart apology appear apple approve april arch arctic area arena argue arm armed armor army around arrange arrest arrive arrow art artefact artist artwork ask aspect assault asset assist assume asthma athlete atom attack attend attitude attract auction audit august aunt author auto autumn average avocado avoid awake aware away awesome awful awkward axis baby bachelor bacon badge bag balance balcony ball bamboo banana banner bar barely bargain barrel base basic basket battle beach bean beauty because become beef before begin behave behind believe below belt bench benefit best betray better between beyond bicycle bid bike bind biology bird birth bitter black blade blame blanket blast bleak bless blind blood blossom blouse blue blur blush board boat body boil bomb bone bonus book boost border boring borrow boss bottom bounce box boy bracket brain brand brass brave bread breeze brick bridge brief bright bring brisk broccoli broken bronze broom brother brown brush bubble buddy budget buffalo build bulb bulk bullet bundle bunker burden burger burst bus business busy butter buyer buzz cabbage cabin cable cactus cage cake call calm camera camp can canal cancel candy cannon canoe canvas canyon capable capital captain car carbon card cargo carpet carry cart case cash casino castle casual cat catalog catch category cattle caught cause caution cave ceiling celery cement census century cereal certain chair chalk champion change chaos chapter charge chase chat cheap check cheese chef cherry chest chicken chief child chimney choice choose chronic chuckle chunk churn cigar cinnamon circle citizen city civil claim clap clarify claw clay clean clerk clever click client cliff climb clinic clip clock clog close cloth cloud clown club clump cluster clutch coach coast coconut code coffee coil coin collect color column combine come comfort comic common company concert conduct confirm congress connect consider control convince cook cool copper copy coral core corn correct cost cotton couch country couple course cousin cover coyote crack cradle craft cram crane crash crater crawl crazy cream credit creek crew cricket crime crisp critic crop cross crouch crowd crucial cruel cruise crumble crunch crush cry crystal cube culture cup cupboard curious current curtain curve cushion custom cute cycle dad damage damp dance danger daring dash daughter dawn day deal debate debris decade december decide decline decorate decrease deer defense define defy degree delay deliver demand demise denial dentist deny depart depend deposit depth deputy derive describe desert design desk despair destroy detail detect develop device devote diagram dial diamond diary dice diesel diet differ digital dignity dilemma dinner dinosaur direct dirt disagree discover disease dish dismiss disorder display distance divert divide divorce dizzy doctor document dog doll dolphin domain donate donkey donor door dose double dove draft dragon drama drastic draw dream dress drift drill drink drip drive drop drum dry duck dumb dune during dust dutch duty dwarf dynamic eager eagle early earn earth easily east easy echo ecology economy edge edit educate effort egg eight either elbow elder electric elegant element elephant elevator elite else embark embody embrace emerge emotion employ empower empty enable enact end endless endorse enemy energy enforce engage engine enhance enjoy enlist enough enrich enroll ensure enter entire entry envelope episode equal equip era erase erode erosion error erupt escape essay essence estate eternal ethics evidence evil evoke evolve exact example excess exchange excite exclude excuse execute exercise exhaust exhibit exile exist exit exotic expand expect expire explain expose express extend extra eye eyebrow fabric face faculty fade faint faith fall false fame family famous fan fancy fantasy farm fashion fat fatal father fatigue fault favorite feature february federal fee feed feel female fence festival fetch fever few fiber fiction field figure file film filter final find fine finger finish fire firm first fiscal fish fit fitness fix flag flame flash flat flavor flee flight flip float flock floor flower fluid flush fly foam focus fog foil fold follow food foot force forest forget fork fortune forum forward fossil foster found fox fragile frame frequent fresh friend fringe frog front frost frown frozen fruit fuel fun funny furnace fury future gadget gain galaxy gallery game gap garage garbage garden garlic garment gas gasp gate gather gauge gaze general genius genre gentle genuine gesture ghost giant gift giggle ginger giraffe girl give glad glance glare glass glide glimpse globe gloom glory glove glow glue goat goddess gold good goose gorilla gospel gossip govern gown grab grace grain grant grape grass gravity great green grid grief grit grocery group grow grunt guard guess guide guilt guitar gun gym habit hair half hammer hamster hand happy harbor hard harsh harvest hat have hawk hazard head health heart heavy hedgehog height hello helmet help hen hero hidden high hill hint hip hire history hobby hockey hold hole holiday hollow home honey hood hope horn horror horse hospital host hotel hour hover hub huge human humble humor hundred hungry hunt hurdle hurry hurt husband hybrid ice icon idea identify idle ignore ill illegal illness image imitate immense immune impact impose improve impulse inch include income increase index indicate indoor industry infant inflict inform inhale inherit initial inject injury inmate inner innocent input inquiry insane insect inside inspire install intact interest into invest invite involve iron island isolate issue item ivory jacket jaguar jar jazz jealous jeans jelly jewel job join joke journey joy judge juice jump jungle junior junk just kangaroo keen keep ketchup key kick kid kidney kind kingdom kiss kit kitchen kite kitten kiwi knee knife knock know lab label labor ladder lady lake lamp language laptop large later latin laugh laundry lava law lawn lawsuit layer lazy leader leaf learn leave lecture left leg legal legend leisure lemon lend length lens leopard lesson letter level liar liberty library license life lift light like limb limit link lion liquid list little live lizard load loan lobster local lock logic lonely long loop lottery loud lounge love loyal lucky luggage lumber lunar lunch luxury lyrics machine mad magic magnet maid mail main major make mammal man manage mandate mango mansion manual maple marble march margin marine market marriage mask mass master match material math matrix matter maximum maze meadow mean measure meat mechanic medal media melody melt member memory mention menu mercy merge merit merry mesh message metal method middle midnight milk million mimic mind minimum minor minute miracle mirror misery miss mistake mix mixed mixture mobile model modify mom moment monitor monkey monster month moon moral more morning mosquito mother motion motor mountain mouse move movie much muffin mule multiply muscle museum mushroom music must mutual myself mystery myth naive name napkin narrow nasty nation nature near neck need negative neglect neither nephew nerve nest net network neutral never news next nice night noble noise nominee noodle normal north nose notable note nothing notice novel now nuclear number nurse nut oak obey object oblige obscure observe obtain obvious occur ocean october odor off offer office often oil okay old olive olympic omit once one onion online only open opera opinion oppose option orange orbit orchard order ordinary organ orient original orphan ostrich other outdoor outer output outside oval oven over own owner oxygen oyster ozone pact paddle page pair palace palm panda panel panic panther paper parade parent park parrot party pass patch path patient patrol pattern pause pave payment peace peanut pear peasant pelican pen penalty pencil people pepper perfect permit person pet phone photo phrase physical piano picnic picture piece pig pigeon pill pilot pink pioneer pipe pistol pitch pizza place planet plastic plate play please pledge pluck plug plunge poem poet point polar pole police pond pony pool popular portion position possible post potato pottery poverty powder power practice praise predict prefer prepare present pretty prevent price pride primary print priority prison private prize problem process produce profit program project promote proof property prosper protect proud provide public pudding pull pulp pulse pumpkin punch pupil puppy purchase purity purpose purse push put puzzle pyramid quality quantum quarter question quick quit quiz quote rabbit raccoon race rack radar radio rail rain raise rally ramp ranch random range rapid rare rate rather raven raw razor ready real reason rebel rebuild recall receive recipe record recycle reduce reflect reform refuse region regret regular reject relax release relief rely remain remember remind remove render renew rent reopen repair repeat replace report require rescue resemble resist resource response result retire retreat return reunion reveal review reward rhythm rib ribbon rice rich ride ridge rifle right rigid ring riot ripple risk ritual rival river road roast robot robust rocket romance roof rookie room rose rotate rough round route royal rubber rude rug rule run runway rural sad saddle sadness safe sail salad salmon salon salt salute same sample sand satisfy satoshi sauce sausage save say scale scan scare scatter scene scheme school science scissors scorpion scout scrap screen script scrub sea search season seat second secret section security seed seek segment select sell seminar senior sense sentence series service session settle setup seven shadow shaft shallow share shed shell sheriff shield shift shine ship shiver shock shoe shoot shop short shoulder shove shrimp shrug shuffle shy sibling sick side siege sight sign silent silk silly silver similar simple since sing siren sister situate six size skate sketch ski skill skin skirt skull slab slam sleep slender slice slide slight slim slogan slot slow slush small smart smile smoke smooth snack snake snap sniff snow soap soccer social sock soda soft solar soldier solid solution solve someone song soon sorry sort soul sound soup source south space spare spatial spawn speak special speed spell spend sphere spice spider spike spin spirit split spoil sponsor spoon sport spot spray spread spring spy square squeeze squirrel stable stadium staff stage stairs stamp stand start state stay steak steel stem step stereo stick still sting stock stomach stone stool story stove strategy street strike strong struggle student stuff stumble style subject submit subway success such sudden suffer sugar suggest suit summer sun sunny sunset super supply supreme sure surface surge surprise surround survey suspect sustain swallow swamp swap swarm swear sweet swift swim swing switch sword symbol symptom syrup system table tackle tag tail talent talk tank tape target task taste tattoo taxi teach team tell ten tenant tennis tent term test text thank that theme then theory there they thing this thought three thrive throw thumb thunder ticket tide tiger tilt timber time tiny tip tired tissue title toast tobacco today toddler toe together toilet token tomato tomorrow tone tongue tonight tool tooth top topic topple torch tornado tortoise toss total tourist toward tower town toy track trade traffic tragic train transfer trap trash travel tray treat tree trend trial tribe trick trigger trim trip trophy trouble truck true truly trumpet trust truth try tube tuition tumble tuna tunnel turkey turn turtle twelve twenty twice twin twist two type typical ugly umbrella unable unaware uncle uncover under undo unfair unfold unhappy uniform unique unit universe unknown unlock until unusual unveil update upgrade uphold upon upper upset urban urge usage use used useful useless usual utility vacant vacuum vague valid valley valve van vanish vapor various vast vault vehicle velvet vendor venture venue verb verify version very vessel veteran viable vibrant vicious victory video view village vintage violin virtual virus visa visit visual vital vivid vocal voice void volcano volume vote voyage wage wagon wait walk wall walnut want warfare warm warrior wash wasp waste water wave way wealth weapon wear weasel weather web wedding weekend weird welcome west wet whale what wheat wheel when where whip whisper wide width wife wild will win window wine wing wink winner winter wire wisdom wise wish witness wolf woman wonder wood wool word work world worry worth wrap wreck wrestle wrist write wrong yard year yellow you young youth zebra zero zone zoo`, " ") 30 | -------------------------------------------------------------------------------- /doc/age-keygen.1: -------------------------------------------------------------------------------- 1 | .\" generated with Ronn-NG/v0.9.1 2 | .\" http://github.com/apjanke/ronn-ng/tree/0.9.1 3 | .TH "AGE\-KEYGEN" "1" "June 2024" "" 4 | .SH "NAME" 5 | \fBage\-keygen\fR \- generate age(1) key pairs 6 | .SH "SYNOPSIS" 7 | \fBage\-keygen\fR [\fB\-o\fR \fIOUTPUT\fR] 8 | .br 9 | \fBage\-keygen\fR \fB\-y\fR [\fB\-o\fR \fIOUTPUT\fR] [\fIINPUT\fR] 10 | .br 11 | .SH "DESCRIPTION" 12 | \fBage\-keygen\fR generates a new native age(1) key pair, and outputs the identity to standard output or to the \fIOUTPUT\fR file\. The output includes the public key and the current time as comments\. 13 | .P 14 | If the output is not going to a terminal, \fBage\-keygen\fR prints the public key to standard error\. 15 | .SH "OPTIONS" 16 | .TP 17 | \fB\-o\fR, \fB\-\-output\fR=\fIOUTPUT\fR 18 | Write the identity to \fIOUTPUT\fR instead of standard output\. 19 | .IP 20 | If \fIOUTPUT\fR already exists, it is not overwritten\. 21 | .TP 22 | \fB\-y\fR 23 | Read an identity file from \fIINPUT\fR or from standard input and output the corresponding recipient(s), one per line, with no comments\. 24 | .TP 25 | \fB\-\-version\fR 26 | Print the version and exit\. 27 | .SH "EXAMPLES" 28 | Generate a new identity: 29 | .IP "" 4 30 | .nf 31 | $ age\-keygen 32 | # created: 2021\-01\-02T15:30:45+01:00 33 | # public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z 34 | AGE\-SECRET\-KEY\-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9 35 | .fi 36 | .IP "" 0 37 | .P 38 | Write a new identity to \fBkey\.txt\fR: 39 | .IP "" 4 40 | .nf 41 | $ age\-keygen \-o key\.txt 42 | Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p 43 | .fi 44 | .IP "" 0 45 | .P 46 | Convert an identity to a recipient: 47 | .IP "" 4 48 | .nf 49 | $ age\-keygen \-y key\.txt 50 | age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p 51 | .fi 52 | .IP "" 0 53 | .SH "SEE ALSO" 54 | age(1) 55 | .SH "AUTHORS" 56 | Filippo Valsorda \fIage@filippo\.io\fR 57 | -------------------------------------------------------------------------------- /doc/age-keygen.1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | age-keygen(1) - generate age(1) key pairs 7 | 44 | 45 | 52 | 53 |
54 | 55 | 64 | 65 |
    66 |
  1. age-keygen(1)
  2. 67 |
  3. 68 |
  4. age-keygen(1)
  5. 69 |
70 | 71 | 72 | 73 |

NAME

74 |

75 | age-keygen - generate age(1) key pairs 76 |

77 |

SYNOPSIS

78 | 79 |

age-keygen [-o OUTPUT]
80 | age-keygen -y [-o OUTPUT] [INPUT]

81 | 82 |

DESCRIPTION

83 | 84 |

age-keygen generates a new native age(1) key pair, and outputs the identity to 85 | standard output or to the OUTPUT file. The output includes the public key and 86 | the current time as comments.

87 | 88 |

If the output is not going to a terminal, age-keygen prints the public key to 89 | standard error.

90 | 91 |

OPTIONS

92 | 93 |
94 |
95 | -o, --output=OUTPUT 96 |
97 |
Write the identity to OUTPUT instead of standard output. 98 | 99 |

If OUTPUT already exists, it is not overwritten.

100 |
101 |
-y
102 |
Read an identity file from INPUT or from standard input and output the 103 | corresponding recipient(s), one per line, with no comments.
104 |
--version
105 |
Print the version and exit.
106 |
107 | 108 |

EXAMPLES

109 | 110 |

Generate a new identity:

111 | 112 |
$ age-keygen
113 | # created: 2021-01-02T15:30:45+01:00
114 | # public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z
115 | AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9
116 | 
117 | 118 |

Write a new identity to key.txt:

119 | 120 |
$ age-keygen -o key.txt
121 | Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
122 | 
123 | 124 |

Convert an identity to a recipient:

125 | 126 |
$ age-keygen -y key.txt
127 | age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
128 | 
129 | 130 |

SEE ALSO

131 | 132 |

age(1)

133 | 134 |

AUTHORS

135 | 136 |

Filippo Valsorda age@filippo.io

137 | 138 |
    139 |
  1. 140 |
  2. June 2024
  3. 141 |
  4. age-keygen(1)
  5. 142 |
143 | 144 |
145 | 146 | 147 | -------------------------------------------------------------------------------- /doc/age-keygen.1.ronn: -------------------------------------------------------------------------------- 1 | age-keygen(1) -- generate age(1) key pairs 2 | ==================================================== 3 | 4 | ## SYNOPSIS 5 | 6 | `age-keygen` [`-o` ]
7 | `age-keygen` `-y` [`-o` ] []
8 | 9 | ## DESCRIPTION 10 | 11 | `age-keygen` generates a new native age(1) key pair, and outputs the identity to 12 | standard output or to the file. The output includes the public key and 13 | the current time as comments. 14 | 15 | If the output is not going to a terminal, `age-keygen` prints the public key to 16 | standard error. 17 | 18 | ## OPTIONS 19 | 20 | * `-o`, `--output`=: 21 | Write the identity to instead of standard output. 22 | 23 | If already exists, it is not overwritten. 24 | 25 | * `-y`: 26 | Read an identity file from or from standard input and output the 27 | corresponding recipient(s), one per line, with no comments. 28 | 29 | * `--version`: 30 | Print the version and exit. 31 | 32 | ## EXAMPLES 33 | 34 | Generate a new identity: 35 | 36 | $ age-keygen 37 | # created: 2021-01-02T15:30:45+01:00 38 | # public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z 39 | AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9 40 | 41 | Write a new identity to `key.txt`: 42 | 43 | $ age-keygen -o key.txt 44 | Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p 45 | 46 | Convert an identity to a recipient: 47 | 48 | $ age-keygen -y key.txt 49 | age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p 50 | 51 | ## SEE ALSO 52 | 53 | age(1) 54 | 55 | ## AUTHORS 56 | 57 | Filippo Valsorda 58 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module filippo.io/age 2 | 3 | go 1.19 4 | 5 | require ( 6 | filippo.io/edwards25519 v1.1.0 7 | golang.org/x/crypto v0.24.0 8 | golang.org/x/sys v0.21.0 9 | golang.org/x/term v0.21.0 10 | ) 11 | 12 | // Test dependencies. 13 | require ( 14 | c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 15 | github.com/rogpeppe/go-internal v1.12.0 16 | golang.org/x/tools v0.22.0 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= 2 | c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= 3 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 4 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 5 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 6 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 7 | golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= 8 | golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= 9 | golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= 10 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 11 | golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= 12 | golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= 13 | golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= 14 | golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= 15 | -------------------------------------------------------------------------------- /internal/bech32/bech32.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017 Takatoshi Nakagawa 2 | // Copyright (c) 2019 The age Authors 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in 12 | // all copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | // THE SOFTWARE. 21 | 22 | // Package bech32 is a modified version of the reference implementation of BIP173. 23 | package bech32 24 | 25 | import ( 26 | "fmt" 27 | "strings" 28 | ) 29 | 30 | var charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" 31 | 32 | var generator = []uint32{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3} 33 | 34 | func polymod(values []byte) uint32 { 35 | chk := uint32(1) 36 | for _, v := range values { 37 | top := chk >> 25 38 | chk = (chk & 0x1ffffff) << 5 39 | chk = chk ^ uint32(v) 40 | for i := 0; i < 5; i++ { 41 | bit := top >> i & 1 42 | if bit == 1 { 43 | chk ^= generator[i] 44 | } 45 | } 46 | } 47 | return chk 48 | } 49 | 50 | func hrpExpand(hrp string) []byte { 51 | h := []byte(strings.ToLower(hrp)) 52 | var ret []byte 53 | for _, c := range h { 54 | ret = append(ret, c>>5) 55 | } 56 | ret = append(ret, 0) 57 | for _, c := range h { 58 | ret = append(ret, c&31) 59 | } 60 | return ret 61 | } 62 | 63 | func verifyChecksum(hrp string, data []byte) bool { 64 | return polymod(append(hrpExpand(hrp), data...)) == 1 65 | } 66 | 67 | func createChecksum(hrp string, data []byte) []byte { 68 | values := append(hrpExpand(hrp), data...) 69 | values = append(values, []byte{0, 0, 0, 0, 0, 0}...) 70 | mod := polymod(values) ^ 1 71 | ret := make([]byte, 6) 72 | for p := range ret { 73 | shift := 5 * (5 - p) 74 | ret[p] = byte(mod>>shift) & 31 75 | } 76 | return ret 77 | } 78 | 79 | func convertBits(data []byte, frombits, tobits byte, pad bool) ([]byte, error) { 80 | var ret []byte 81 | acc := uint32(0) 82 | bits := byte(0) 83 | maxv := byte(1<>frombits != 0 { 86 | return nil, fmt.Errorf("invalid data range: data[%d]=%d (frombits=%d)", idx, value, frombits) 87 | } 88 | acc = acc<= tobits { 91 | bits -= tobits 92 | ret = append(ret, byte(acc>>bits)&maxv) 93 | } 94 | } 95 | if pad { 96 | if bits > 0 { 97 | ret = append(ret, byte(acc<<(tobits-bits))&maxv) 98 | } 99 | } else if bits >= frombits { 100 | return nil, fmt.Errorf("illegal zero padding") 101 | } else if byte(acc<<(tobits-bits))&maxv != 0 { 102 | return nil, fmt.Errorf("non-zero padding") 103 | } 104 | return ret, nil 105 | } 106 | 107 | // Encode encodes the HRP and a bytes slice to Bech32. If the HRP is uppercase, 108 | // the output will be uppercase. 109 | func Encode(hrp string, data []byte) (string, error) { 110 | values, err := convertBits(data, 8, 5, true) 111 | if err != nil { 112 | return "", err 113 | } 114 | if len(hrp) < 1 { 115 | return "", fmt.Errorf("invalid HRP: %q", hrp) 116 | } 117 | for p, c := range hrp { 118 | if c < 33 || c > 126 { 119 | return "", fmt.Errorf("invalid HRP character: hrp[%d]=%d", p, c) 120 | } 121 | } 122 | if strings.ToUpper(hrp) != hrp && strings.ToLower(hrp) != hrp { 123 | return "", fmt.Errorf("mixed case HRP: %q", hrp) 124 | } 125 | lower := strings.ToLower(hrp) == hrp 126 | hrp = strings.ToLower(hrp) 127 | var ret strings.Builder 128 | ret.WriteString(hrp) 129 | ret.WriteString("1") 130 | for _, p := range values { 131 | ret.WriteByte(charset[p]) 132 | } 133 | for _, p := range createChecksum(hrp, values) { 134 | ret.WriteByte(charset[p]) 135 | } 136 | if lower { 137 | return ret.String(), nil 138 | } 139 | return strings.ToUpper(ret.String()), nil 140 | } 141 | 142 | // Decode decodes a Bech32 string. If the string is uppercase, the HRP will be uppercase. 143 | func Decode(s string) (hrp string, data []byte, err error) { 144 | if strings.ToLower(s) != s && strings.ToUpper(s) != s { 145 | return "", nil, fmt.Errorf("mixed case") 146 | } 147 | pos := strings.LastIndex(s, "1") 148 | if pos < 1 || pos+7 > len(s) { 149 | return "", nil, fmt.Errorf("separator '1' at invalid position: pos=%d, len=%d", pos, len(s)) 150 | } 151 | hrp = s[:pos] 152 | for p, c := range hrp { 153 | if c < 33 || c > 126 { 154 | return "", nil, fmt.Errorf("invalid character human-readable part: s[%d]=%d", p, c) 155 | } 156 | } 157 | s = strings.ToLower(s) 158 | for p, c := range s[pos+1:] { 159 | d := strings.IndexRune(charset, c) 160 | if d == -1 { 161 | return "", nil, fmt.Errorf("invalid character data part: s[%d]=%v", p, c) 162 | } 163 | data = append(data, byte(d)) 164 | } 165 | if !verifyChecksum(hrp, data) { 166 | return "", nil, fmt.Errorf("invalid checksum") 167 | } 168 | data, err = convertBits(data[:len(data)-6], 5, 8, false) 169 | if err != nil { 170 | return "", nil, err 171 | } 172 | return hrp, data, nil 173 | } 174 | -------------------------------------------------------------------------------- /internal/bech32/bech32_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013-2017 The btcsuite developers 2 | // Copyright (c) 2016-2017 The Lightning Network Developers 3 | // Copyright (c) 2019 The age Authors 4 | // 5 | // Permission to use, copy, modify, and distribute this software for any 6 | // purpose with or without fee is hereby granted, provided that the above 7 | // copyright notice and this permission notice appear in all copies. 8 | // 9 | // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | // WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | // MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | // ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | // WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | // ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | // OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | 17 | package bech32_test 18 | 19 | import ( 20 | "strings" 21 | "testing" 22 | 23 | "filippo.io/age/internal/bech32" 24 | ) 25 | 26 | func TestBech32(t *testing.T) { 27 | tests := []struct { 28 | str string 29 | valid bool 30 | }{ 31 | {"A12UEL5L", true}, // empty 32 | {"a12uel5l", true}, 33 | {"an83characterlonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1tt5tgs", true}, 34 | {"abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw", true}, 35 | {"11qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc8247j", true}, 36 | {"split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", true}, 37 | 38 | // invalid checksum 39 | {"split1checkupstagehandshakeupstreamerranterredcaperred2y9e2w", false}, 40 | // invalid character (space) in hrp 41 | {"s lit1checkupstagehandshakeupstreamerranterredcaperredp8hs2p", false}, 42 | {"split1cheo2y9e2w", false}, // invalid character (o) in data part 43 | {"split1a2y9w", false}, // too short data part 44 | {"1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", false}, // empty hrp 45 | // invalid character (DEL) in hrp 46 | {"spl" + string(rune(127)) + "t1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", false}, 47 | 48 | // long vectors that we do accept despite the spec, see Issue 453 49 | {"long10pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7qfcsvr0", true}, 50 | {"an84characterslonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1569pvx", true}, 51 | 52 | // BIP 173 invalid vectors. 53 | {"pzry9x0s0muk", false}, 54 | {"1pzry9x0s0muk", false}, 55 | {"x1b4n0q5v", false}, 56 | {"li1dgmt3", false}, 57 | {"de1lg7wt\xff", false}, 58 | {"A1G7SGD8", false}, 59 | {"10a06t8", false}, 60 | {"1qzzfhee", false}, 61 | } 62 | 63 | for _, test := range tests { 64 | str := test.str 65 | hrp, decoded, err := bech32.Decode(str) 66 | if !test.valid { 67 | // Invalid string decoding should result in error. 68 | if err == nil { 69 | t.Errorf("expected decoding to fail for invalid string %v", test.str) 70 | } 71 | continue 72 | } 73 | 74 | // Valid string decoding should result in no error. 75 | if err != nil { 76 | t.Errorf("expected string to be valid bech32: %v", err) 77 | } 78 | 79 | // Check that it encodes to the same string. 80 | encoded, err := bech32.Encode(hrp, decoded) 81 | if err != nil { 82 | t.Errorf("encoding failed: %v", err) 83 | } 84 | if encoded != str { 85 | t.Errorf("expected data to encode to %v, but got %v", str, encoded) 86 | } 87 | 88 | // Flip a bit in the string an make sure it is caught. 89 | pos := strings.LastIndexAny(str, "1") 90 | flipped := str[:pos+1] + string((str[pos+1] ^ 1)) + str[pos+2:] 91 | if _, _, err = bech32.Decode(flipped); err == nil { 92 | t.Error("expected decoding to fail") 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /internal/format/format.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Package format implements the age file format. 6 | package format 7 | 8 | import ( 9 | "bufio" 10 | "bytes" 11 | "encoding/base64" 12 | "errors" 13 | "fmt" 14 | "io" 15 | "strings" 16 | ) 17 | 18 | type Header struct { 19 | Recipients []*Stanza 20 | MAC []byte 21 | } 22 | 23 | // Stanza is assignable to age.Stanza, and if this package is made public, 24 | // age.Stanza can be made a type alias of this type. 25 | type Stanza struct { 26 | Type string 27 | Args []string 28 | Body []byte 29 | } 30 | 31 | var b64 = base64.RawStdEncoding.Strict() 32 | 33 | func DecodeString(s string) ([]byte, error) { 34 | // CR and LF are ignored by DecodeString, but we don't want any malleability. 35 | if strings.ContainsAny(s, "\n\r") { 36 | return nil, errors.New(`unexpected newline character`) 37 | } 38 | return b64.DecodeString(s) 39 | } 40 | 41 | var EncodeToString = b64.EncodeToString 42 | 43 | const ColumnsPerLine = 64 44 | 45 | const BytesPerLine = ColumnsPerLine / 4 * 3 46 | 47 | // NewWrappedBase64Encoder returns a WrappedBase64Encoder that writes to dst. 48 | func NewWrappedBase64Encoder(enc *base64.Encoding, dst io.Writer) *WrappedBase64Encoder { 49 | w := &WrappedBase64Encoder{dst: dst} 50 | w.enc = base64.NewEncoder(enc, WriterFunc(w.writeWrapped)) 51 | return w 52 | } 53 | 54 | type WriterFunc func(p []byte) (int, error) 55 | 56 | func (f WriterFunc) Write(p []byte) (int, error) { return f(p) } 57 | 58 | // WrappedBase64Encoder is a standard base64 encoder that inserts an LF 59 | // character every ColumnsPerLine bytes. It does not insert a newline neither at 60 | // the beginning nor at the end of the stream, but it ensures the last line is 61 | // shorter than ColumnsPerLine, which means it might be empty. 62 | type WrappedBase64Encoder struct { 63 | enc io.WriteCloser 64 | dst io.Writer 65 | written int 66 | buf bytes.Buffer 67 | } 68 | 69 | func (w *WrappedBase64Encoder) Write(p []byte) (int, error) { return w.enc.Write(p) } 70 | 71 | func (w *WrappedBase64Encoder) Close() error { 72 | return w.enc.Close() 73 | } 74 | 75 | func (w *WrappedBase64Encoder) writeWrapped(p []byte) (int, error) { 76 | if w.buf.Len() != 0 { 77 | panic("age: internal error: non-empty WrappedBase64Encoder.buf") 78 | } 79 | for len(p) > 0 { 80 | toWrite := ColumnsPerLine - (w.written % ColumnsPerLine) 81 | if toWrite > len(p) { 82 | toWrite = len(p) 83 | } 84 | n, _ := w.buf.Write(p[:toWrite]) 85 | w.written += n 86 | p = p[n:] 87 | if w.written%ColumnsPerLine == 0 { 88 | w.buf.Write([]byte("\n")) 89 | } 90 | } 91 | if _, err := w.buf.WriteTo(w.dst); err != nil { 92 | // We always return n = 0 on error because it's hard to work back to the 93 | // input length that ended up written out. Not ideal, but Write errors 94 | // are not recoverable anyway. 95 | return 0, err 96 | } 97 | return len(p), nil 98 | } 99 | 100 | // LastLineIsEmpty returns whether the last output line was empty, either 101 | // because no input was written, or because a multiple of BytesPerLine was. 102 | // 103 | // Calling LastLineIsEmpty before Close is meaningless. 104 | func (w *WrappedBase64Encoder) LastLineIsEmpty() bool { 105 | return w.written%ColumnsPerLine == 0 106 | } 107 | 108 | const intro = "age-encryption.org/v1\n" 109 | 110 | var stanzaPrefix = []byte("->") 111 | var footerPrefix = []byte("---") 112 | 113 | func (r *Stanza) Marshal(w io.Writer) error { 114 | if _, err := w.Write(stanzaPrefix); err != nil { 115 | return err 116 | } 117 | for _, a := range append([]string{r.Type}, r.Args...) { 118 | if _, err := io.WriteString(w, " "+a); err != nil { 119 | return err 120 | } 121 | } 122 | if _, err := io.WriteString(w, "\n"); err != nil { 123 | return err 124 | } 125 | ww := NewWrappedBase64Encoder(b64, w) 126 | if _, err := ww.Write(r.Body); err != nil { 127 | return err 128 | } 129 | if err := ww.Close(); err != nil { 130 | return err 131 | } 132 | _, err := io.WriteString(w, "\n") 133 | return err 134 | } 135 | 136 | func (h *Header) MarshalWithoutMAC(w io.Writer) error { 137 | if _, err := io.WriteString(w, intro); err != nil { 138 | return err 139 | } 140 | for _, r := range h.Recipients { 141 | if err := r.Marshal(w); err != nil { 142 | return err 143 | } 144 | } 145 | _, err := fmt.Fprintf(w, "%s", footerPrefix) 146 | return err 147 | } 148 | 149 | func (h *Header) Marshal(w io.Writer) error { 150 | if err := h.MarshalWithoutMAC(w); err != nil { 151 | return err 152 | } 153 | mac := b64.EncodeToString(h.MAC) 154 | _, err := fmt.Fprintf(w, " %s\n", mac) 155 | return err 156 | } 157 | 158 | type StanzaReader struct { 159 | r *bufio.Reader 160 | err error 161 | } 162 | 163 | func NewStanzaReader(r *bufio.Reader) *StanzaReader { 164 | return &StanzaReader{r: r} 165 | } 166 | 167 | func (r *StanzaReader) ReadStanza() (s *Stanza, err error) { 168 | // Read errors are unrecoverable. 169 | if r.err != nil { 170 | return nil, r.err 171 | } 172 | defer func() { r.err = err }() 173 | 174 | s = &Stanza{} 175 | 176 | line, err := r.r.ReadBytes('\n') 177 | if err != nil { 178 | return nil, fmt.Errorf("failed to read line: %w", err) 179 | } 180 | if !bytes.HasPrefix(line, stanzaPrefix) { 181 | return nil, fmt.Errorf("malformed stanza opening line: %q", line) 182 | } 183 | prefix, args := splitArgs(line) 184 | if prefix != string(stanzaPrefix) || len(args) < 1 { 185 | return nil, fmt.Errorf("malformed stanza: %q", line) 186 | } 187 | for _, a := range args { 188 | if !isValidString(a) { 189 | return nil, fmt.Errorf("malformed stanza: %q", line) 190 | } 191 | } 192 | s.Type = args[0] 193 | s.Args = args[1:] 194 | 195 | for { 196 | line, err := r.r.ReadBytes('\n') 197 | if err != nil { 198 | return nil, fmt.Errorf("failed to read line: %w", err) 199 | } 200 | 201 | b, err := DecodeString(strings.TrimSuffix(string(line), "\n")) 202 | if err != nil { 203 | if bytes.HasPrefix(line, footerPrefix) || bytes.HasPrefix(line, stanzaPrefix) { 204 | return nil, fmt.Errorf("malformed body line %q: stanza ended without a short line\nnote: this might be a file encrypted with an old beta version of age or rage; use age v1.0.0-beta6 or rage to decrypt it", line) 205 | } 206 | return nil, errorf("malformed body line %q: %v", line, err) 207 | } 208 | if len(b) > BytesPerLine { 209 | return nil, errorf("malformed body line %q: too long", line) 210 | } 211 | s.Body = append(s.Body, b...) 212 | if len(b) < BytesPerLine { 213 | // A stanza body always ends with a short line. 214 | return s, nil 215 | } 216 | } 217 | } 218 | 219 | type ParseError struct { 220 | err error 221 | } 222 | 223 | func (e *ParseError) Error() string { 224 | return "parsing age header: " + e.err.Error() 225 | } 226 | 227 | func (e *ParseError) Unwrap() error { 228 | return e.err 229 | } 230 | 231 | func errorf(format string, a ...interface{}) error { 232 | return &ParseError{fmt.Errorf(format, a...)} 233 | } 234 | 235 | // Parse returns the header and a Reader that begins at the start of the 236 | // payload. 237 | func Parse(input io.Reader) (*Header, io.Reader, error) { 238 | h := &Header{} 239 | rr := bufio.NewReader(input) 240 | 241 | line, err := rr.ReadString('\n') 242 | if err != nil { 243 | return nil, nil, errorf("failed to read intro: %w", err) 244 | } 245 | if line != intro { 246 | return nil, nil, errorf("unexpected intro: %q", line) 247 | } 248 | 249 | sr := NewStanzaReader(rr) 250 | for { 251 | peek, err := rr.Peek(len(footerPrefix)) 252 | if err != nil { 253 | return nil, nil, errorf("failed to read header: %w", err) 254 | } 255 | 256 | if bytes.Equal(peek, footerPrefix) { 257 | line, err := rr.ReadBytes('\n') 258 | if err != nil { 259 | return nil, nil, fmt.Errorf("failed to read header: %w", err) 260 | } 261 | 262 | prefix, args := splitArgs(line) 263 | if prefix != string(footerPrefix) || len(args) != 1 { 264 | return nil, nil, errorf("malformed closing line: %q", line) 265 | } 266 | h.MAC, err = DecodeString(args[0]) 267 | if err != nil || len(h.MAC) != 32 { 268 | return nil, nil, errorf("malformed closing line %q: %v", line, err) 269 | } 270 | break 271 | } 272 | 273 | s, err := sr.ReadStanza() 274 | if err != nil { 275 | return nil, nil, fmt.Errorf("failed to parse header: %w", err) 276 | } 277 | h.Recipients = append(h.Recipients, s) 278 | } 279 | 280 | // If input is a bufio.Reader, rr might be equal to input because 281 | // bufio.NewReader short-circuits. In this case we can just return it (and 282 | // we would end up reading the buffer twice if we prepended the peek below). 283 | if rr == input { 284 | return h, rr, nil 285 | } 286 | // Otherwise, unwind the bufio overread and return the unbuffered input. 287 | buf, err := rr.Peek(rr.Buffered()) 288 | if err != nil { 289 | return nil, nil, errorf("internal error: %v", err) 290 | } 291 | payload := io.MultiReader(bytes.NewReader(buf), input) 292 | return h, payload, nil 293 | } 294 | 295 | func splitArgs(line []byte) (string, []string) { 296 | l := strings.TrimSuffix(string(line), "\n") 297 | parts := strings.Split(l, " ") 298 | return parts[0], parts[1:] 299 | } 300 | 301 | func isValidString(s string) bool { 302 | if len(s) == 0 { 303 | return false 304 | } 305 | for _, c := range s { 306 | if c < 33 || c > 126 { 307 | return false 308 | } 309 | } 310 | return true 311 | } 312 | -------------------------------------------------------------------------------- /internal/format/format_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | //go:build go1.18 6 | // +build go1.18 7 | 8 | package format_test 9 | 10 | import ( 11 | "bytes" 12 | "io" 13 | "os" 14 | "path/filepath" 15 | "testing" 16 | 17 | "filippo.io/age/internal/format" 18 | ) 19 | 20 | func TestStanzaMarshal(t *testing.T) { 21 | s := &format.Stanza{ 22 | Type: "test", 23 | Args: []string{"1", "2", "3"}, 24 | Body: nil, // empty 25 | } 26 | buf := &bytes.Buffer{} 27 | s.Marshal(buf) 28 | if exp := "-> test 1 2 3\n\n"; buf.String() != exp { 29 | t.Errorf("wrong empty stanza encoding: expected %q, got %q", exp, buf.String()) 30 | } 31 | 32 | buf.Reset() 33 | s.Body = []byte("AAA") 34 | s.Marshal(buf) 35 | if exp := "-> test 1 2 3\nQUFB\n"; buf.String() != exp { 36 | t.Errorf("wrong normal stanza encoding: expected %q, got %q", exp, buf.String()) 37 | } 38 | 39 | buf.Reset() 40 | s.Body = bytes.Repeat([]byte("A"), format.BytesPerLine) 41 | s.Marshal(buf) 42 | if exp := "-> test 1 2 3\nQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB\n\n"; buf.String() != exp { 43 | t.Errorf("wrong 64 columns stanza encoding: expected %q, got %q", exp, buf.String()) 44 | } 45 | } 46 | 47 | func FuzzMalleability(f *testing.F) { 48 | tests, err := filepath.Glob("../../testdata/testkit/*") 49 | if err != nil { 50 | f.Fatal(err) 51 | } 52 | for _, test := range tests { 53 | contents, err := os.ReadFile(test) 54 | if err != nil { 55 | f.Fatal(err) 56 | } 57 | _, contents, ok := bytes.Cut(contents, []byte("\n\n")) 58 | if !ok { 59 | f.Fatal("testkit file without header") 60 | } 61 | f.Add(contents) 62 | } 63 | f.Fuzz(func(t *testing.T, data []byte) { 64 | h, payload, err := format.Parse(bytes.NewReader(data)) 65 | if err != nil { 66 | if h != nil { 67 | t.Error("h != nil on error") 68 | } 69 | if payload != nil { 70 | t.Error("payload != nil on error") 71 | } 72 | t.Skip() 73 | } 74 | w := &bytes.Buffer{} 75 | if err := h.Marshal(w); err != nil { 76 | t.Fatal(err) 77 | } 78 | if _, err := io.Copy(w, payload); err != nil { 79 | t.Fatal(err) 80 | } 81 | if !bytes.Equal(w.Bytes(), data) { 82 | t.Error("Marshal output different from input") 83 | } 84 | }) 85 | } 86 | -------------------------------------------------------------------------------- /internal/stream/stream.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Package stream implements a variant of the STREAM chunked encryption scheme. 6 | package stream 7 | 8 | import ( 9 | "crypto/cipher" 10 | "errors" 11 | "fmt" 12 | "io" 13 | 14 | "golang.org/x/crypto/chacha20poly1305" 15 | ) 16 | 17 | const ChunkSize = 64 * 1024 18 | 19 | type Reader struct { 20 | a cipher.AEAD 21 | src io.Reader 22 | 23 | unread []byte // decrypted but unread data, backed by buf 24 | buf [encChunkSize]byte 25 | 26 | err error 27 | nonce [chacha20poly1305.NonceSize]byte 28 | } 29 | 30 | const ( 31 | encChunkSize = ChunkSize + chacha20poly1305.Overhead 32 | lastChunkFlag = 0x01 33 | ) 34 | 35 | func NewReader(key []byte, src io.Reader) (*Reader, error) { 36 | aead, err := chacha20poly1305.New(key) 37 | if err != nil { 38 | return nil, err 39 | } 40 | return &Reader{ 41 | a: aead, 42 | src: src, 43 | }, nil 44 | } 45 | 46 | func (r *Reader) Read(p []byte) (int, error) { 47 | if len(r.unread) > 0 { 48 | n := copy(p, r.unread) 49 | r.unread = r.unread[n:] 50 | return n, nil 51 | } 52 | if r.err != nil { 53 | return 0, r.err 54 | } 55 | if len(p) == 0 { 56 | return 0, nil 57 | } 58 | 59 | last, err := r.readChunk() 60 | if err != nil { 61 | r.err = err 62 | return 0, err 63 | } 64 | 65 | n := copy(p, r.unread) 66 | r.unread = r.unread[n:] 67 | 68 | if last { 69 | // Ensure there is an EOF after the last chunk as expected. In other 70 | // words, check for trailing data after a full-length final chunk. 71 | // Hopefully, the underlying reader supports returning EOF even if it 72 | // had previously returned an EOF to ReadFull. 73 | if _, err := r.src.Read(make([]byte, 1)); err == nil { 74 | r.err = errors.New("trailing data after end of encrypted file") 75 | } else if err != io.EOF { 76 | r.err = fmt.Errorf("non-EOF error reading after end of encrypted file: %w", err) 77 | } else { 78 | r.err = io.EOF 79 | } 80 | } 81 | 82 | return n, nil 83 | } 84 | 85 | // readChunk reads the next chunk of ciphertext from r.src and makes it available 86 | // in r.unread. last is true if the chunk was marked as the end of the message. 87 | // readChunk must not be called again after returning a last chunk or an error. 88 | func (r *Reader) readChunk() (last bool, err error) { 89 | if len(r.unread) != 0 { 90 | panic("stream: internal error: readChunk called with dirty buffer") 91 | } 92 | 93 | in := r.buf[:] 94 | n, err := io.ReadFull(r.src, in) 95 | switch { 96 | case err == io.EOF: 97 | // A message can't end without a marked chunk. This message is truncated. 98 | return false, io.ErrUnexpectedEOF 99 | case err == io.ErrUnexpectedEOF: 100 | // The last chunk can be short, but not empty unless it's the first and 101 | // only chunk. 102 | if !nonceIsZero(&r.nonce) && n == r.a.Overhead() { 103 | return false, errors.New("last chunk is empty, try age v1.0.0, and please consider reporting this") 104 | } 105 | in = in[:n] 106 | last = true 107 | setLastChunkFlag(&r.nonce) 108 | case err != nil: 109 | return false, err 110 | } 111 | 112 | outBuf := make([]byte, 0, ChunkSize) 113 | out, err := r.a.Open(outBuf, r.nonce[:], in, nil) 114 | if err != nil && !last { 115 | // Check if this was a full-length final chunk. 116 | last = true 117 | setLastChunkFlag(&r.nonce) 118 | out, err = r.a.Open(outBuf, r.nonce[:], in, nil) 119 | } 120 | if err != nil { 121 | return false, errors.New("failed to decrypt and authenticate payload chunk") 122 | } 123 | 124 | incNonce(&r.nonce) 125 | r.unread = r.buf[:copy(r.buf[:], out)] 126 | return last, nil 127 | } 128 | 129 | func incNonce(nonce *[chacha20poly1305.NonceSize]byte) { 130 | for i := len(nonce) - 2; i >= 0; i-- { 131 | nonce[i]++ 132 | if nonce[i] != 0 { 133 | break 134 | } else if i == 0 { 135 | // The counter is 88 bits, this is unreachable. 136 | panic("stream: chunk counter wrapped around") 137 | } 138 | } 139 | } 140 | 141 | func setLastChunkFlag(nonce *[chacha20poly1305.NonceSize]byte) { 142 | nonce[len(nonce)-1] = lastChunkFlag 143 | } 144 | 145 | func nonceIsZero(nonce *[chacha20poly1305.NonceSize]byte) bool { 146 | return *nonce == [chacha20poly1305.NonceSize]byte{} 147 | } 148 | 149 | type Writer struct { 150 | a cipher.AEAD 151 | dst io.Writer 152 | unwritten []byte // backed by buf 153 | buf [encChunkSize]byte 154 | nonce [chacha20poly1305.NonceSize]byte 155 | err error 156 | } 157 | 158 | func NewWriter(key []byte, dst io.Writer) (*Writer, error) { 159 | aead, err := chacha20poly1305.New(key) 160 | if err != nil { 161 | return nil, err 162 | } 163 | w := &Writer{ 164 | a: aead, 165 | dst: dst, 166 | } 167 | w.unwritten = w.buf[:0] 168 | return w, nil 169 | } 170 | 171 | func (w *Writer) Write(p []byte) (n int, err error) { 172 | // TODO: consider refactoring with a bytes.Buffer. 173 | if w.err != nil { 174 | return 0, w.err 175 | } 176 | if len(p) == 0 { 177 | return 0, nil 178 | } 179 | 180 | total := len(p) 181 | for len(p) > 0 { 182 | freeBuf := w.buf[len(w.unwritten):ChunkSize] 183 | n := copy(freeBuf, p) 184 | p = p[n:] 185 | w.unwritten = w.unwritten[:len(w.unwritten)+n] 186 | 187 | if len(w.unwritten) == ChunkSize && len(p) > 0 { 188 | if err := w.flushChunk(notLastChunk); err != nil { 189 | w.err = err 190 | return 0, err 191 | } 192 | } 193 | } 194 | return total, nil 195 | } 196 | 197 | // Close flushes the last chunk. It does not close the underlying Writer. 198 | func (w *Writer) Close() error { 199 | if w.err != nil { 200 | return w.err 201 | } 202 | 203 | w.err = w.flushChunk(lastChunk) 204 | if w.err != nil { 205 | return w.err 206 | } 207 | 208 | w.err = errors.New("stream.Writer is already closed") 209 | return nil 210 | } 211 | 212 | const ( 213 | lastChunk = true 214 | notLastChunk = false 215 | ) 216 | 217 | func (w *Writer) flushChunk(last bool) error { 218 | if !last && len(w.unwritten) != ChunkSize { 219 | panic("stream: internal error: flush called with partial chunk") 220 | } 221 | 222 | if last { 223 | setLastChunkFlag(&w.nonce) 224 | } 225 | buf := w.a.Seal(w.buf[:0], w.nonce[:], w.unwritten, nil) 226 | _, err := w.dst.Write(buf) 227 | w.unwritten = w.buf[:0] 228 | incNonce(&w.nonce) 229 | return err 230 | } 231 | -------------------------------------------------------------------------------- /internal/stream/stream_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package stream_test 6 | 7 | import ( 8 | "bytes" 9 | "crypto/rand" 10 | "fmt" 11 | "testing" 12 | 13 | "filippo.io/age/internal/stream" 14 | "golang.org/x/crypto/chacha20poly1305" 15 | ) 16 | 17 | const cs = stream.ChunkSize 18 | 19 | func TestRoundTrip(t *testing.T) { 20 | for _, stepSize := range []int{512, 600, 1000, cs} { 21 | for _, length := range []int{0, 1000, cs, cs + 100} { 22 | t.Run(fmt.Sprintf("len=%d,step=%d", length, stepSize), 23 | func(t *testing.T) { testRoundTrip(t, stepSize, length) }) 24 | } 25 | } 26 | } 27 | 28 | func testRoundTrip(t *testing.T, stepSize, length int) { 29 | src := make([]byte, length) 30 | if _, err := rand.Read(src); err != nil { 31 | t.Fatal(err) 32 | } 33 | buf := &bytes.Buffer{} 34 | key := make([]byte, chacha20poly1305.KeySize) 35 | if _, err := rand.Read(key); err != nil { 36 | t.Fatal(err) 37 | } 38 | 39 | w, err := stream.NewWriter(key, buf) 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | var n int 45 | for n < length { 46 | b := length - n 47 | if b > stepSize { 48 | b = stepSize 49 | } 50 | nn, err := w.Write(src[n : n+b]) 51 | if err != nil { 52 | t.Fatal(err) 53 | } 54 | if nn != b { 55 | t.Errorf("Write returned %d, expected %d", nn, b) 56 | } 57 | n += nn 58 | 59 | nn, err = w.Write(src[n:n]) 60 | if err != nil { 61 | t.Fatal(err) 62 | } 63 | if nn != 0 { 64 | t.Errorf("Write returned %d, expected 0", nn) 65 | } 66 | } 67 | 68 | if err := w.Close(); err != nil { 69 | t.Error("Close returned an error:", err) 70 | } 71 | 72 | t.Logf("buffer size: %d", buf.Len()) 73 | 74 | r, err := stream.NewReader(key, buf) 75 | if err != nil { 76 | t.Fatal(err) 77 | } 78 | 79 | n = 0 80 | readBuf := make([]byte, stepSize) 81 | for n < length { 82 | nn, err := r.Read(readBuf) 83 | if err != nil { 84 | t.Fatalf("Read error at index %d: %v", n, err) 85 | } 86 | 87 | if !bytes.Equal(readBuf[:nn], src[n:n+nn]) { 88 | t.Errorf("wrong data at indexes %d - %d", n, n+nn) 89 | } 90 | 91 | n += nn 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /logo/README.md: -------------------------------------------------------------------------------- 1 | The logos available in this folder are Copyright 2021 Filippo Valsorda. 2 | 3 | Permission is granted to use the logos as long as they are unaltered, are not 4 | combined with other text or graphic, and are not used to imply your project is 5 | endorsed by or affiliated with the age project. 6 | 7 | This permission can be revoked or rescinded for any reason and at any time, 8 | selectively or otherwise. 9 | 10 | If you require different terms, please email age-logo@filippo.io. 11 | 12 | The logos were designed by [Studiovagante](https://www.studiovagante.it). 13 | -------------------------------------------------------------------------------- /logo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FiloSottile/age/0447d8d089ca7e1ebe85a93b7ef0151a83e5a7d7/logo/logo.png -------------------------------------------------------------------------------- /logo/logo_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FiloSottile/age/0447d8d089ca7e1ebe85a93b7ef0151a83e5a7d7/logo/logo_white.png -------------------------------------------------------------------------------- /parse.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package age 6 | 7 | import ( 8 | "bufio" 9 | "fmt" 10 | "io" 11 | "strings" 12 | ) 13 | 14 | // ParseIdentities parses a file with one or more private key encodings, one per 15 | // line. Empty lines and lines starting with "#" are ignored. 16 | // 17 | // This is the same syntax as the private key files accepted by the CLI, except 18 | // the CLI also accepts SSH private keys, which are not recommended for the 19 | // average application. 20 | // 21 | // Currently, all returned values are of type *X25519Identity, but different 22 | // types might be returned in the future. 23 | func ParseIdentities(f io.Reader) ([]Identity, error) { 24 | const privateKeySizeLimit = 1 << 24 // 16 MiB 25 | var ids []Identity 26 | scanner := bufio.NewScanner(io.LimitReader(f, privateKeySizeLimit)) 27 | var n int 28 | for scanner.Scan() { 29 | n++ 30 | line := scanner.Text() 31 | if strings.HasPrefix(line, "#") || line == "" { 32 | continue 33 | } 34 | i, err := ParseX25519Identity(line) 35 | if err != nil { 36 | return nil, fmt.Errorf("error at line %d: %v", n, err) 37 | } 38 | ids = append(ids, i) 39 | } 40 | if err := scanner.Err(); err != nil { 41 | return nil, fmt.Errorf("failed to read secret keys file: %v", err) 42 | } 43 | if len(ids) == 0 { 44 | return nil, fmt.Errorf("no secret keys found") 45 | } 46 | return ids, nil 47 | } 48 | 49 | // ParseRecipients parses a file with one or more public key encodings, one per 50 | // line. Empty lines and lines starting with "#" are ignored. 51 | // 52 | // This is the same syntax as the recipients files accepted by the CLI, except 53 | // the CLI also accepts SSH recipients, which are not recommended for the 54 | // average application. 55 | // 56 | // Currently, all returned values are of type *X25519Recipient, but different 57 | // types might be returned in the future. 58 | func ParseRecipients(f io.Reader) ([]Recipient, error) { 59 | const recipientFileSizeLimit = 1 << 24 // 16 MiB 60 | var recs []Recipient 61 | scanner := bufio.NewScanner(io.LimitReader(f, recipientFileSizeLimit)) 62 | var n int 63 | for scanner.Scan() { 64 | n++ 65 | line := scanner.Text() 66 | if strings.HasPrefix(line, "#") || line == "" { 67 | continue 68 | } 69 | r, err := ParseX25519Recipient(line) 70 | if err != nil { 71 | // Hide the error since it might unintentionally leak the contents 72 | // of confidential files. 73 | return nil, fmt.Errorf("malformed recipient at line %d", n) 74 | } 75 | recs = append(recs, r) 76 | } 77 | if err := scanner.Err(); err != nil { 78 | return nil, fmt.Errorf("failed to read recipients file: %v", err) 79 | } 80 | if len(recs) == 0 { 81 | return nil, fmt.Errorf("no recipients found") 82 | } 83 | return recs, nil 84 | } 85 | -------------------------------------------------------------------------------- /plugin/client_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The age Authors 2 | // 3 | // Use of this source code is governed by a BSD-style 4 | // license that can be found in the LICENSE file or at 5 | // https://developers.google.com/open-source/licenses/bsd 6 | 7 | package plugin 8 | 9 | import ( 10 | "bufio" 11 | "io" 12 | "os" 13 | "path/filepath" 14 | "runtime" 15 | "testing" 16 | 17 | "filippo.io/age" 18 | "filippo.io/age/internal/bech32" 19 | ) 20 | 21 | func TestMain(m *testing.M) { 22 | switch filepath.Base(os.Args[0]) { 23 | // TODO: deduplicate from cmd/age TestMain. 24 | case "age-plugin-test": 25 | switch os.Args[1] { 26 | case "--age-plugin=recipient-v1": 27 | scanner := bufio.NewScanner(os.Stdin) 28 | scanner.Scan() // add-recipient 29 | scanner.Scan() // body 30 | scanner.Scan() // grease 31 | scanner.Scan() // body 32 | scanner.Scan() // wrap-file-key 33 | scanner.Scan() // body 34 | fileKey := scanner.Text() 35 | scanner.Scan() // extension-labels 36 | scanner.Scan() // body 37 | scanner.Scan() // done 38 | scanner.Scan() // body 39 | os.Stdout.WriteString("-> recipient-stanza 0 test\n") 40 | os.Stdout.WriteString(fileKey + "\n") 41 | scanner.Scan() // ok 42 | scanner.Scan() // body 43 | os.Stdout.WriteString("-> done\n\n") 44 | os.Exit(0) 45 | default: 46 | panic(os.Args[1]) 47 | } 48 | case "age-plugin-testpqc": 49 | switch os.Args[1] { 50 | case "--age-plugin=recipient-v1": 51 | scanner := bufio.NewScanner(os.Stdin) 52 | scanner.Scan() // add-recipient 53 | scanner.Scan() // body 54 | scanner.Scan() // grease 55 | scanner.Scan() // body 56 | scanner.Scan() // wrap-file-key 57 | scanner.Scan() // body 58 | fileKey := scanner.Text() 59 | scanner.Scan() // extension-labels 60 | scanner.Scan() // body 61 | scanner.Scan() // done 62 | scanner.Scan() // body 63 | os.Stdout.WriteString("-> recipient-stanza 0 test\n") 64 | os.Stdout.WriteString(fileKey + "\n") 65 | scanner.Scan() // ok 66 | scanner.Scan() // body 67 | os.Stdout.WriteString("-> labels postquantum\n\n") 68 | scanner.Scan() // ok 69 | scanner.Scan() // body 70 | os.Stdout.WriteString("-> done\n\n") 71 | os.Exit(0) 72 | default: 73 | panic(os.Args[1]) 74 | } 75 | default: 76 | os.Exit(m.Run()) 77 | } 78 | } 79 | 80 | func TestLabels(t *testing.T) { 81 | if runtime.GOOS == "windows" { 82 | t.Skip("Windows support is TODO") 83 | } 84 | temp := t.TempDir() 85 | testOnlyPluginPath = temp 86 | t.Cleanup(func() { testOnlyPluginPath = "" }) 87 | ex, err := os.Executable() 88 | if err != nil { 89 | t.Fatal(err) 90 | } 91 | if err := os.Link(ex, filepath.Join(temp, "age-plugin-test")); err != nil { 92 | t.Fatal(err) 93 | } 94 | if err := os.Chmod(filepath.Join(temp, "age-plugin-test"), 0755); err != nil { 95 | t.Fatal(err) 96 | } 97 | if err := os.Link(ex, filepath.Join(temp, "age-plugin-testpqc")); err != nil { 98 | t.Fatal(err) 99 | } 100 | if err := os.Chmod(filepath.Join(temp, "age-plugin-testpqc"), 0755); err != nil { 101 | t.Fatal(err) 102 | } 103 | 104 | name, err := bech32.Encode("age1test", nil) 105 | if err != nil { 106 | t.Fatal(err) 107 | } 108 | testPlugin, err := NewRecipient(name, &ClientUI{}) 109 | if err != nil { 110 | t.Fatal(err) 111 | } 112 | namePQC, err := bech32.Encode("age1testpqc", nil) 113 | if err != nil { 114 | t.Fatal(err) 115 | } 116 | testPluginPQC, err := NewRecipient(namePQC, &ClientUI{}) 117 | if err != nil { 118 | t.Fatal(err) 119 | } 120 | 121 | if _, err := age.Encrypt(io.Discard, testPluginPQC); err != nil { 122 | t.Errorf("expected one pqc to work, got %v", err) 123 | } 124 | if _, err := age.Encrypt(io.Discard, testPluginPQC, testPluginPQC); err != nil { 125 | t.Errorf("expected two pqc to work, got %v", err) 126 | } 127 | if _, err := age.Encrypt(io.Discard, testPluginPQC, testPlugin); err == nil { 128 | t.Errorf("expected one pqc and one normal to fail") 129 | } 130 | if _, err := age.Encrypt(io.Discard, testPlugin, testPluginPQC); err == nil { 131 | t.Errorf("expected one pqc and one normal to fail") 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /plugin/encode.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package plugin 6 | 7 | import ( 8 | "fmt" 9 | "strings" 10 | 11 | "filippo.io/age/internal/bech32" 12 | ) 13 | 14 | // EncodeIdentity encodes a plugin identity string for a plugin with the given 15 | // name. If the name is invalid, it returns an empty string. 16 | func EncodeIdentity(name string, data []byte) string { 17 | if !validPluginName(name) { 18 | return "" 19 | } 20 | s, _ := bech32.Encode("AGE-PLUGIN-"+strings.ToUpper(name)+"-", data) 21 | return s 22 | } 23 | 24 | // ParseIdentity decodes a plugin identity string. It returns the plugin name 25 | // in lowercase and the encoded data. 26 | func ParseIdentity(s string) (name string, data []byte, err error) { 27 | hrp, data, err := bech32.Decode(s) 28 | if err != nil { 29 | return "", nil, fmt.Errorf("invalid identity encoding: %v", err) 30 | } 31 | if !strings.HasPrefix(hrp, "AGE-PLUGIN-") || !strings.HasSuffix(hrp, "-") { 32 | return "", nil, fmt.Errorf("not a plugin identity: %v", err) 33 | } 34 | name = strings.TrimSuffix(strings.TrimPrefix(hrp, "AGE-PLUGIN-"), "-") 35 | name = strings.ToLower(name) 36 | if !validPluginName(name) { 37 | return "", nil, fmt.Errorf("invalid plugin name: %q", name) 38 | } 39 | return name, data, nil 40 | } 41 | 42 | // EncodeRecipient encodes a plugin recipient string for a plugin with the given 43 | // name. If the name is invalid, it returns an empty string. 44 | func EncodeRecipient(name string, data []byte) string { 45 | if !validPluginName(name) { 46 | return "" 47 | } 48 | s, _ := bech32.Encode("age1"+strings.ToLower(name), data) 49 | return s 50 | } 51 | 52 | // ParseRecipient decodes a plugin recipient string. It returns the plugin name 53 | // in lowercase and the encoded data. 54 | func ParseRecipient(s string) (name string, data []byte, err error) { 55 | hrp, data, err := bech32.Decode(s) 56 | if err != nil { 57 | return "", nil, fmt.Errorf("invalid recipient encoding: %v", err) 58 | } 59 | if !strings.HasPrefix(hrp, "age1") { 60 | return "", nil, fmt.Errorf("not a plugin recipient: %v", err) 61 | } 62 | name = strings.TrimPrefix(hrp, "age1") 63 | if !validPluginName(name) { 64 | return "", nil, fmt.Errorf("invalid plugin name: %q", name) 65 | } 66 | return name, data, nil 67 | } 68 | 69 | func validPluginName(name string) bool { 70 | if name == "" { 71 | return false 72 | } 73 | allowed := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+-._" 74 | for _, r := range name { 75 | if !strings.ContainsRune(allowed, r) { 76 | return false 77 | } 78 | } 79 | return true 80 | } 81 | -------------------------------------------------------------------------------- /plugin/encode_go1.20.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | //go:build go1.20 6 | 7 | package plugin 8 | 9 | import ( 10 | "crypto/ecdh" 11 | "fmt" 12 | 13 | "filippo.io/age/internal/bech32" 14 | ) 15 | 16 | // EncodeX25519Recipient encodes a native X25519 recipient from a 17 | // [crypto/ecdh.X25519] public key. It's meant for plugins that implement 18 | // identities that are compatible with native recipients. 19 | func EncodeX25519Recipient(pk *ecdh.PublicKey) (string, error) { 20 | if pk.Curve() != ecdh.X25519() { 21 | return "", fmt.Errorf("wrong ecdh Curve") 22 | } 23 | return bech32.Encode("age", pk.Bytes()) 24 | } 25 | -------------------------------------------------------------------------------- /primitives.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package age 6 | 7 | import ( 8 | "crypto/hmac" 9 | "crypto/sha256" 10 | "errors" 11 | "io" 12 | 13 | "filippo.io/age/internal/format" 14 | "golang.org/x/crypto/chacha20poly1305" 15 | "golang.org/x/crypto/hkdf" 16 | ) 17 | 18 | // aeadEncrypt encrypts a message with a one-time key. 19 | func aeadEncrypt(key, plaintext []byte) ([]byte, error) { 20 | aead, err := chacha20poly1305.New(key) 21 | if err != nil { 22 | return nil, err 23 | } 24 | // The nonce is fixed because this function is only used in places where the 25 | // spec guarantees each key is only used once (by deriving it from values 26 | // that include fresh randomness), allowing us to save the overhead. 27 | // For the code that encrypts the actual payload, look at the 28 | // filippo.io/age/internal/stream package. 29 | nonce := make([]byte, chacha20poly1305.NonceSize) 30 | return aead.Seal(nil, nonce, plaintext, nil), nil 31 | } 32 | 33 | var errIncorrectCiphertextSize = errors.New("encrypted value has unexpected length") 34 | 35 | // aeadDecrypt decrypts a message of an expected fixed size. 36 | // 37 | // The message size is limited to mitigate multi-key attacks, where a ciphertext 38 | // can be crafted that decrypts successfully under multiple keys. Short 39 | // ciphertexts can only target two keys, which has limited impact. 40 | func aeadDecrypt(key []byte, size int, ciphertext []byte) ([]byte, error) { 41 | aead, err := chacha20poly1305.New(key) 42 | if err != nil { 43 | return nil, err 44 | } 45 | if len(ciphertext) != size+aead.Overhead() { 46 | return nil, errIncorrectCiphertextSize 47 | } 48 | nonce := make([]byte, chacha20poly1305.NonceSize) 49 | return aead.Open(nil, nonce, ciphertext, nil) 50 | } 51 | 52 | func headerMAC(fileKey []byte, hdr *format.Header) ([]byte, error) { 53 | h := hkdf.New(sha256.New, fileKey, nil, []byte("header")) 54 | hmacKey := make([]byte, 32) 55 | if _, err := io.ReadFull(h, hmacKey); err != nil { 56 | return nil, err 57 | } 58 | hh := hmac.New(sha256.New, hmacKey) 59 | if err := hdr.MarshalWithoutMAC(hh); err != nil { 60 | return nil, err 61 | } 62 | return hh.Sum(nil), nil 63 | } 64 | 65 | func streamKey(fileKey, nonce []byte) []byte { 66 | h := hkdf.New(sha256.New, fileKey, nonce, []byte("payload")) 67 | streamKey := make([]byte, chacha20poly1305.KeySize) 68 | if _, err := io.ReadFull(h, streamKey); err != nil { 69 | panic("age: internal error: failed to read from HKDF: " + err.Error()) 70 | } 71 | return streamKey 72 | } 73 | -------------------------------------------------------------------------------- /recipients_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package age_test 6 | 7 | import ( 8 | "bytes" 9 | "crypto/rand" 10 | "testing" 11 | 12 | "filippo.io/age" 13 | ) 14 | 15 | func TestX25519RoundTrip(t *testing.T) { 16 | i, err := age.GenerateX25519Identity() 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | r := i.Recipient() 21 | 22 | if r1, err := age.ParseX25519Recipient(r.String()); err != nil { 23 | t.Fatal(err) 24 | } else if r1.String() != r.String() { 25 | t.Errorf("recipient did not round-trip through parsing: got %q, want %q", r1, r) 26 | } 27 | if i1, err := age.ParseX25519Identity(i.String()); err != nil { 28 | t.Fatal(err) 29 | } else if i1.String() != i.String() { 30 | t.Errorf("identity did not round-trip through parsing: got %q, want %q", i1, i) 31 | } 32 | 33 | fileKey := make([]byte, 16) 34 | if _, err := rand.Read(fileKey); err != nil { 35 | t.Fatal(err) 36 | } 37 | stanzas, err := r.Wrap(fileKey) 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | 42 | out, err := i.Unwrap(stanzas) 43 | if err != nil { 44 | t.Fatal(err) 45 | } 46 | 47 | if !bytes.Equal(fileKey, out) { 48 | t.Errorf("invalid output: %x, expected %x", out, fileKey) 49 | } 50 | } 51 | 52 | func TestScryptRoundTrip(t *testing.T) { 53 | password := "twitch.tv/filosottile" 54 | 55 | r, err := age.NewScryptRecipient(password) 56 | if err != nil { 57 | t.Fatal(err) 58 | } 59 | r.SetWorkFactor(15) 60 | i, err := age.NewScryptIdentity(password) 61 | if err != nil { 62 | t.Fatal(err) 63 | } 64 | 65 | fileKey := make([]byte, 16) 66 | if _, err := rand.Read(fileKey); err != nil { 67 | t.Fatal(err) 68 | } 69 | stanzas, err := r.Wrap(fileKey) 70 | if err != nil { 71 | t.Fatal(err) 72 | } 73 | 74 | out, err := i.Unwrap(stanzas) 75 | if err != nil { 76 | t.Fatal(err) 77 | } 78 | 79 | if !bytes.Equal(fileKey, out) { 80 | t.Errorf("invalid output: %x, expected %x", out, fileKey) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /scrypt.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The age Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package age 6 | 7 | import ( 8 | "crypto/rand" 9 | "encoding/hex" 10 | "errors" 11 | "fmt" 12 | "regexp" 13 | "strconv" 14 | 15 | "filippo.io/age/internal/format" 16 | "golang.org/x/crypto/chacha20poly1305" 17 | "golang.org/x/crypto/scrypt" 18 | ) 19 | 20 | const scryptLabel = "age-encryption.org/v1/scrypt" 21 | 22 | // ScryptRecipient is a password-based recipient. Anyone with the password can 23 | // decrypt the message. 24 | // 25 | // If a ScryptRecipient is used, it must be the only recipient for the file: it 26 | // can't be mixed with other recipient types and can't be used multiple times 27 | // for the same file. 28 | // 29 | // Its use is not recommended for automated systems, which should prefer 30 | // X25519Recipient. 31 | type ScryptRecipient struct { 32 | password []byte 33 | workFactor int 34 | } 35 | 36 | var _ Recipient = &ScryptRecipient{} 37 | 38 | // NewScryptRecipient returns a new ScryptRecipient with the provided password. 39 | func NewScryptRecipient(password string) (*ScryptRecipient, error) { 40 | if len(password) == 0 { 41 | return nil, errors.New("passphrase can't be empty") 42 | } 43 | r := &ScryptRecipient{ 44 | password: []byte(password), 45 | // TODO: automatically scale this to 1s (with a min) in the CLI. 46 | workFactor: 18, // 1s on a modern machine 47 | } 48 | return r, nil 49 | } 50 | 51 | // SetWorkFactor sets the scrypt work factor to 2^logN. 52 | // It must be called before Wrap. 53 | // 54 | // If SetWorkFactor is not called, a reasonable default is used. 55 | func (r *ScryptRecipient) SetWorkFactor(logN int) { 56 | if logN > 30 || logN < 1 { 57 | panic("age: SetWorkFactor called with illegal value") 58 | } 59 | r.workFactor = logN 60 | } 61 | 62 | const scryptSaltSize = 16 63 | 64 | func (r *ScryptRecipient) Wrap(fileKey []byte) ([]*Stanza, error) { 65 | salt := make([]byte, scryptSaltSize) 66 | if _, err := rand.Read(salt[:]); err != nil { 67 | return nil, err 68 | } 69 | 70 | logN := r.workFactor 71 | l := &Stanza{ 72 | Type: "scrypt", 73 | Args: []string{format.EncodeToString(salt), strconv.Itoa(logN)}, 74 | } 75 | 76 | salt = append([]byte(scryptLabel), salt...) 77 | k, err := scrypt.Key(r.password, salt, 1< 30 || logN < 1 { 142 | panic("age: SetMaxWorkFactor called with illegal value") 143 | } 144 | i.maxWorkFactor = logN 145 | } 146 | 147 | func (i *ScryptIdentity) Unwrap(stanzas []*Stanza) ([]byte, error) { 148 | for _, s := range stanzas { 149 | if s.Type == "scrypt" && len(stanzas) != 1 { 150 | return nil, errors.New("an scrypt recipient must be the only one") 151 | } 152 | } 153 | return multiUnwrap(i.unwrap, stanzas) 154 | } 155 | 156 | var digitsRe = regexp.MustCompile(`^[1-9][0-9]*$`) 157 | 158 | func (i *ScryptIdentity) unwrap(block *Stanza) ([]byte, error) { 159 | if block.Type != "scrypt" { 160 | return nil, ErrIncorrectIdentity 161 | } 162 | if len(block.Args) != 2 { 163 | return nil, errors.New("invalid scrypt recipient block") 164 | } 165 | salt, err := format.DecodeString(block.Args[0]) 166 | if err != nil { 167 | return nil, fmt.Errorf("failed to parse scrypt salt: %v", err) 168 | } 169 | if len(salt) != scryptSaltSize { 170 | return nil, errors.New("invalid scrypt recipient block") 171 | } 172 | if w := block.Args[1]; !digitsRe.MatchString(w) { 173 | return nil, fmt.Errorf("scrypt work factor encoding invalid: %q", w) 174 | } 175 | logN, err := strconv.Atoi(block.Args[1]) 176 | if err != nil { 177 | return nil, fmt.Errorf("failed to parse scrypt work factor: %v", err) 178 | } 179 | if logN > i.maxWorkFactor { 180 | return nil, fmt.Errorf("scrypt work factor too large: %v", logN) 181 | } 182 | if logN <= 0 { // unreachable 183 | return nil, fmt.Errorf("invalid scrypt work factor: %v", logN) 184 | } 185 | 186 | salt = append([]byte(scryptLabel), salt...) 187 | k, err := scrypt.Key(i.password, salt, 1<