Full Code of FiloSottile/age for AI

main 4a3a4ef00aa1 cached
94 files
444.6 KB
146.2k tokens
443 symbols
1 requests
Download .txt
Showing preview only (470K chars total). Download the full file or copy to clipboard to get everything.
Repository: FiloSottile/age
Branch: main
Commit: 4a3a4ef00aa1
Files: 94
Total size: 444.6 KB

Directory structure:
gitextract_a732ze8h/

├── .gitattributes
├── .github/
│   ├── CONTRIBUTING.md
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug-report.md
│   │   └── config.yml
│   └── workflows/
│       ├── LICENSE.suffix.txt
│       ├── build.yml
│       ├── certs/
│       │   ├── README
│       │   ├── uitacllc.crt
│       │   └── uitacllc.key
│       ├── interop.yml
│       ├── ronn.yml
│       └── test.yml
├── LICENSE
├── README.md
├── SIGSUM.md
├── age.go
├── age_test.go
├── agessh/
│   ├── agessh.go
│   ├── agessh_test.go
│   └── encrypted_keys.go
├── armor/
│   ├── armor.go
│   └── armor_test.go
├── cmd/
│   ├── age/
│   │   ├── age.go
│   │   ├── age_test.go
│   │   ├── encrypted_keys.go
│   │   ├── parse.go
│   │   ├── testdata/
│   │   │   ├── armor.txt
│   │   │   ├── batchpass.txt
│   │   │   ├── duplicates.txt
│   │   │   ├── ed25519.txt
│   │   │   ├── encrypted_keys.txt
│   │   │   ├── hybrid.txt
│   │   │   ├── keygen.txt
│   │   │   ├── output_file.txt
│   │   │   ├── pkcs8.txt
│   │   │   ├── plugin.txt
│   │   │   ├── rsa.txt
│   │   │   ├── scrypt.txt
│   │   │   ├── terminal.txt
│   │   │   ├── usage.txt
│   │   │   └── x25519.txt
│   │   ├── tui.go
│   │   └── wordlist.go
│   ├── age-inspect/
│   │   └── inspect.go
│   ├── age-keygen/
│   │   └── keygen.go
│   └── age-plugin-batchpass/
│       └── plugin-batchpass.go
├── doc/
│   ├── age-inspect.1
│   ├── age-inspect.1.html
│   ├── age-inspect.1.ronn
│   ├── age-keygen.1
│   ├── age-keygen.1.html
│   ├── age-keygen.1.ronn
│   ├── age-plugin-batchpass.1
│   ├── age-plugin-batchpass.1.html
│   ├── age-plugin-batchpass.1.ronn
│   ├── age.1
│   ├── age.1.html
│   └── age.1.ronn
├── extra/
│   ├── age-plugin-pq/
│   │   └── plugin-pq.go
│   ├── age-plugin-tag/
│   │   └── plugin-tag.go
│   └── age-plugin-tagpq/
│       └── plugin-tagpq.go
├── go.mod
├── go.sum
├── internal/
│   ├── bech32/
│   │   ├── bech32.go
│   │   └── bech32_test.go
│   ├── format/
│   │   ├── format.go
│   │   └── format_test.go
│   ├── inspect/
│   │   ├── inspect.go
│   │   └── inspect_test.go
│   ├── stream/
│   │   ├── stream.go
│   │   └── stream_test.go
│   └── term/
│       ├── term.go
│       └── term_windows.go
├── logo/
│   └── README.md
├── parse.go
├── plugin/
│   ├── client.go
│   ├── client_test.go
│   ├── encode.go
│   ├── example_test.go
│   ├── plugin.go
│   └── tui.go
├── pq.go
├── primitives.go
├── recipients_test.go
├── scrypt.go
├── tag/
│   ├── internal/
│   │   ├── age-plugin-tagtest/
│   │   │   └── plugin-tagtest.go
│   │   └── tagtest/
│   │       └── tagtest.go
│   ├── tag.go
│   └── tag_test.go
├── testdata/
│   ├── example.age
│   ├── example.zip.age
│   └── example_keys.txt
├── testkit_test.go
└── x25519.go

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitattributes
================================================
*.age binary
testdata/testkit/* binary


================================================
FILE: .github/CONTRIBUTING.md
================================================
## Issues

I want to hear about any issues you encounter while using age.

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)**! ✨

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.

## Pull requests

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.

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.

To learn more, please see my [maintenance policy](https://github.com/FiloSottile/FiloSottile/blob/main/maintenance.md).

<!-- ## Feature requests

age is small, simple, and config-free by design. A lot of effort is put into resisting scope creep and enabling use cases by integrating and interoperating well with other projects rather than by adding features.

In particular, I'm unlikely to merge into the main repo anything I don't use myself, as I would not be the best person to maintain it. However, I'm always happy to discuss, learn about, and link to any age-related project! -->

## Other ways to contribute

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.

* **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.
* Integrate age into existing projects that might use it, for example replacing legacy alternatives.
* Build and maintain an [age plugin](https://c2sp.org/age-plugin) for a KMS or platform.
* Watch the [discussions](https://github.com/FiloSottile/age/discussions) and help other users.
* Provide bindings in a language or framework that doesn't support age well.
* Package age for an ecosystem that doesn't have packages yet.

If you build or write something related to age, [let me know](https://github.com/FiloSottile/age/discussions/new?category=general)! 💖


================================================
FILE: .github/ISSUE_TEMPLATE/bug-report.md
================================================
---
name: Bug report 🐞
about: Did you encounter a bug in this implementation?
title: ''
labels: ''
assignees: ''

---

## Environment

* OS:
* age version:

## What were you trying to do

## What happened

```
<insert terminal transcript here>
```


================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
contact_links:
  - name: UX report ✨
    url: https://github.com/FiloSottile/age/discussions/new?category=UX-reports
    about: Was age hard to use? It's not you, it's us. We want to hear about it.
  - name: Spec feedback 📃
    url: https://github.com/FiloSottile/age/discussions/new?category=Spec-feedback
    about: Have a comment about the age spec as it's implemented by this and other tools?
  - name: Questions, feature requests, and more 💬
    url: https://github.com/FiloSottile/age/discussions
    about: Do you need support? Did you make something with age? Do you have an idea? Tell us about it!


================================================
FILE: .github/workflows/LICENSE.suffix.txt
================================================

---

Copyright 2009 The Go Authors.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:

   * Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
   * Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
   * Neither the name of Google LLC nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.


================================================
FILE: .github/workflows/build.yml
================================================
name: Build and upload binaries
on:
  release:
    types: [published]
  push:
  pull_request:
permissions:
  contents: read
jobs:
  build:
    name: Build binaries
    runs-on: ubuntu-latest
    strategy:
      matrix:
        include:
          - {GOOS: linux, GOARCH: amd64}
          - {GOOS: linux, GOARCH: arm, GOARM: 6}
          - {GOOS: linux, GOARCH: arm64}
          - {GOOS: darwin, GOARCH: arm64}
          - {GOOS: darwin, GOARCH: amd64}
          - {GOOS: windows, GOARCH: amd64}
          - {GOOS: freebsd, GOARCH: amd64}
    steps:
      - name: Checkout repository
        uses: actions/checkout@v5
        with:
          fetch-depth: 0
          persist-credentials: false
      - name: Install Go
        uses: actions/setup-go@v6
        with:
          go-version-file: go.mod
          cache: false
      - name: Build binary
        run: |
          VERSION="$(git describe --tags)"
          DIR="$(mktemp -d)"
          mkdir "$DIR/age"
          go build -o "$DIR/age" -trimpath ./cmd/...
          cp LICENSE "$DIR/age/LICENSE"
          cat .github/workflows/LICENSE.suffix.txt >> "$DIR/age/LICENSE"
          if [ "$GOOS" == "windows" ]; then
            sudo apt-get update && sudo apt-get install -y osslsigncode
            if [ -n "${{ secrets.SIGN_PASS }}" ]; then
              for exe in "$DIR"/age/*.exe; do
                /usr/bin/osslsigncode sign -t "http://timestamp.comodoca.com" \
                  -certs .github/workflows/certs/uitacllc.crt \
                  -key .github/workflows/certs/uitacllc.key \
                  -pass "${{ secrets.SIGN_PASS }}" \
                  -n age -in "$exe" -out "$exe.signed"
                mv "$exe.signed" "$exe"
              done
            fi
            ( cd "$DIR"; zip age.zip -r age )
            mv "$DIR/age.zip" "age-$VERSION-$GOOS-$GOARCH.zip"
          else
            tar -cvzf "age-$VERSION-$GOOS-$GOARCH.tar.gz" -C "$DIR" age
          fi
        env:
          CGO_ENABLED: 0
          GOOS: ${{ matrix.GOOS }}
          GOARCH: ${{ matrix.GOARCH }}
          GOARM: ${{ matrix.GOARM }}
      - name: Upload workflow artifacts
        uses: actions/upload-artifact@v4
        with:
          name: age-artifacts-${{ matrix.GOOS }}-${{ matrix.GOARCH }}
          path: age-*
  source:
    name: Package source code
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v5
        with:
          fetch-depth: 0
          persist-credentials: false
      - name: Install Go
        uses: actions/setup-go@v6
        with:
          go-version-file: go.mod
          cache: false
      - name: Create source tarball
        run: |
          VERSION="$(git describe --tags)"
          DIR="$(mktemp -d)"
          mkdir "$DIR/age"
          git archive --format=tar.gz HEAD | tar -xz -C "$DIR/age"
          ( cd "$DIR/age"; go mod vendor )
          for cmd in "$DIR"/age/{cmd,extra}/*; do
            echo "package main" >> "$cmd/version.go"
            echo "" >> "$cmd/version.go"
            echo "func init() { Version = \"$VERSION\" }" >> "$cmd/version.go"
          done
          tar -cvzf "age-$VERSION-source.tar.gz" -C "$DIR" age
      - name: Upload workflow artifacts
        uses: actions/upload-artifact@v4
        with:
          name: age-artifacts-source
          path: age-*-source.tar.gz
  upload:
    name: Upload and attest release artifacts
    if: github.event_name == 'release'
    needs: [build, source]
    permissions:
      contents: write
      attestations: write
      id-token: write
    runs-on: ubuntu-latest
    steps:
      - name: Download workflow artifacts
        uses: actions/download-artifact@v4
        with:
          pattern: age-artifacts-*
          merge-multiple: true
      - name: Generate artifacts attestation
        uses: actions/attest-build-provenance@v3
        with:
          subject-path: age-*
      - name: Upload release artifacts
        run: gh release upload "$GITHUB_REF_NAME" age-*
        env:
          GH_REPO: ${{ github.repository }}
          GH_TOKEN: ${{ github.token }}


================================================
FILE: .github/workflows/certs/README
================================================
In this folder there are

    uitacllc.crt

        PKCS#7 encoded certificate chain for a code signing certificate issued
        to Up in the Air Consulting LLC valid until Sep 26 23:59:59 2024 GMT.

        https://crt.sh/?id=5339775059

    uitacllc.key

        PEM encrypted private key for the leaf certificate above.
        Its passphrase is long and randomly generated, so the awful legacy key
        derivation doesn't really matter, and it makes osslsigncode happy.


================================================
FILE: .github/workflows/certs/uitacllc.key
================================================
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-256-CBC,B93C1A166F3677D68FB9CB3E8A184729

UriYsaq3tLyvycDDB2YeQ+9L1P5VCPcfVkYR1ocleF8WxNDUPdz3RqbryAZZdXVO
0bcvAHTXkdI4Oiw5mN0S8fGsNq9zn+pyResx3lXtgN3oCDCe2SQn28uEEKxPzud5
0NRXYoBP+pLDjiuQ/6Lp7DovnAO/uxaPFvYRMiknNVOhwyHGWZyuUe01S9J9im7y
vgc1wkyQzmABIhARynEXHp3KnM9aF8X1/ck839lQRBFrvRFNm5rqiON26spr1Hu5
znrbVGROYk0XNdH5VHDk7V9k+v2WLL/b4nxlMymZpDzr9pXzX8olpLnQrarsMbHe
ysfXNTtQi5Dq6KXURW8VA4DmxAzTRNUxe2aA4JnAEyFU5LDLetTN9F9M7BUkHbXH
RpSbZqDjPwg7U98vuSwxjIkncHSiYYi3FmSoupLvV+eIP6qRSgONdzGlP5NTn4Lh
N1lYMPHPldH6UjLHrldkYN16TQlrqNHZExN91XvsZVjpyAgErY18xwi3CTEco45D
fRqsiWXtoas4LkafhSY0vfl5aFhY9YPUpS6uFdgWBvgcQeYb8meX5Nr4dNXVk5Wa
yRlYlW/X0TWC0T9qaBOPN/z7OWO5aL4jYRcKQQ+aR8gFcHGGCpRAKD369OneXfOQ
MD9UHoPG4WTBg/NU9OSskcywfuSOkwAGfBVNXrnEj6tYFjsjYK2nC2gm+opUCfm0
a1FeDb5nQSOgOJKUCO6Aj+0NvDvVLUOsTk1lfzSugIkmUOdV+rXHnrZC+90q8KfN
S2JlzwSZNg0e+VxZpnD7k7axHkbHrbebtrLvzKVnrh3s0OFAXN0isMw7yhhWtzUe
mPoQTZusLDOAJe/QPuNlDUgr4uoVZtoXrPzoZZkw2VFLwYy2g/EYvlK9BdVVTnRm
9Hq9IBDrZw+SV/7roaeVOXbzrQoxEoXcL7eo6iWvV5Q7Ll5C4ovelHKy3IAzcpYP
6LKfxAO2sIKTALrHbtBNG+O4RTtxOva1hyg27V4v2k53CF/GhoBRPSpbbupwppXc
lJJ9RtMTRfhCv/ObhdsJED+YUqFifTJfcnQ1iGN8dnBuGrjXxVCN0wgmv46Pdhn0
tUfGlkFquOOWamaVaIvp6JCVUDa1ezMzleILoYvrxvOuP+dGVrwTwVCXpx4JuUgp
d72/w+EnqlZnwsAzdrErJFXnHux981ZoojmG94km1B6gPPwMB8JRcD67lfhG/vne
IpTuuzGaSInf24cGNig01hbBuKSg79yNY0llkECPBXbEhfkemEMhg1WHoNP2eG8j
MHS5OCT5KiOfi77pSO3M2mGB1HWYE5R0lcMibukK9ZdyIYcTeMZ0RcGm6YSNv570
ok/Ex4LUCW66AIWFefmbIOtJSIMHlNKWRPJwnJxVoE5qgH0f/2xL3k15vpI55lAS
sabzegnYlElPbUlZGhgwjKknxgqMhFIW/ZS0h2FukFLwipr4qI47nHWz5dguNkYn
48sSKg3YMhVx/sT+X2A/6zqsC+p4PT7Ti5ruWb7S9L9vRuBdIDNE9qAwuz0g8Bs3
WhOx6OW2ZqDQEuRhN0lyGA0mwRC4HPFE9b8dnN8lNm+RsnMfNoFxzPnqtsxhEAwa
2a4ijT97ka94lDy7WQ2bwLRz7trKV/T6MeETKE4s7+z2dMTr1f8IwA2uCovFmO9T
aMQAePFEtDT3qwIPu0zH1ocSCkZ50f7RgVmp4FNn03uT/TnsASrr5CS9m8A9gjEn
QiztQyqt27fTT61YkNdA6lwbpFiByugVbS+mWsNa9kvBkgQkcMQwgrELmU9sYdBT
nRMa60i0nEINT/x3zFvT6R7Dl/O8/QhXLeYv20X2roghPw48IovLb8x7dT3YEQSn
ARIXXVPxwOVvS8xcCa69/+1HjC6vNG9dNNnAsVHxB8mDTBqmmLzAMOVzDoNWEgDd
zoRhQ3ORb1brPlKWg8um/svLiSV63ZYi2J8LPamoGmZ/7J8i5rjOpOeG493UICBR
JymmYGUo6/C1Ze8swdMHApVU/spo0s8BCGkMjYUAaxXD7RufN2DuY30Vny/DMn4y
XasuHS9RstD2Okv25PD06Y2H52HJ6MNdArmPZRe0k2ZbhATs5dXOfmaF5Z0f4IkE
G+hsxE1wlCo900ewntx16sBCbI0v9aE+Napf2+ueqPQ06CdfiTG5yOmeXzgR/8zS
KVmTHpmmFpYtj/N350BLAVb/Hwzmh+ieWnO7TUjvNAHUn2i5LZU65rN3GOlPyIlz
DzB2T6KjOUPFKqSRrIin14HLyf5w0vDuJhe5Zpe0hhYKvoKhwCEVefbmkasWeso3
xsXxOOoL39GA0QpYjR6ztqR8fS9jTeu5IY+zY5LO8yS7+StP3H8CcqRMuxb3ntym
-----END RSA PRIVATE KEY-----


================================================
FILE: .github/workflows/interop.yml
================================================
name: Interoperability tests
on: push
permissions:
  contents: read
jobs:
  trigger:
    name: Trigger
    runs-on: ubuntu-latest
    steps:
      - name: Trigger interoperability tests in str4d/rage
        run: >
          gh api repos/str4d/rage/dispatches
            --field event_type="age-interop-request"
            --field client_payload[sha]="$GITHUB_SHA"
        env:
          GITHUB_TOKEN: ${{ secrets.RAGE_INTEROP_ACCESS_TOKEN }}


================================================
FILE: .github/workflows/ronn.yml
================================================
name: Generate man pages
on:
  push:
    branches:
      - '**'
    paths:
      - '**.ronn'
      - '**/ronn.yml'
permissions:
  contents: read
jobs:
  ronn:
    name: Ronn
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          persist-credentials: false
      - uses: geomys/sandboxed-step@v1.2.1
        with:
          persist-workspace-changes: true
          run: |
            sudo apt-get update && sudo apt-get install -y ronn
            bash -O globstar -c 'ronn **/*.ronn'
            # rdiscount randomizes the output for no good reason, which causes
            # changes to always get committed. Sigh.
            # https://github.com/davidfstr/rdiscount/blob/6b1471ec3/ext/generate.c#L781-L795
            for f in doc/*.html; do
              awk '/Filippo Valsorda/ { $0 = "<p>Filippo Valsorda <a href=\"mailto:age@filippo.io\" data-bare-link=\"true\">age@filippo.io</a></p>" } { print }' "$f" > "$f.tmp"
              mv "$f.tmp" "$f"
            done
      - uses: actions/upload-artifact@v4
        with:
          name: man-pages
          path: |
            doc/*.1
            doc/*.html
  commit:
    name: Commit changes
    needs: ronn
    permissions:
      contents: write
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          persist-credentials: true
      - uses: actions/download-artifact@v4
        with:
          name: man-pages
          path: doc/
      - name: Commit and push if changed
        run: |-
          git config user.name "GitHub Actions"
          git config user.email "actions@users.noreply.github.com"
          git add doc/
          git commit -m "doc: regenerate groff and html man pages" || exit 0
          git push


================================================
FILE: .github/workflows/test.yml
================================================
name: Go tests
on:
  push:
  pull_request:
  schedule: # daily at 09:42 UTC
    - cron: '42 9 * * *'
  workflow_dispatch:
permissions:
  contents: read
jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        go:
          - { go-version: stable }
          - { go-version: oldstable }
        os:
          - ubuntu-latest
          - macos-latest
          - windows-latest
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v5
        with:
          persist-credentials: false
      - uses: actions/setup-go@v6
        with:
          go-version: ${{ matrix.go.go-version }}
      - run: |
          go test -race ./...
  test-latest:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        go:
          - { go-version: stable }
          - { go-version: oldstable }
    steps:
      - uses: actions/checkout@v5
        with:
          persist-credentials: false
      - uses: actions/setup-go@v6
        with:
          go-version: ${{ matrix.go.go-version }}
      - uses: geomys/sandboxed-step@v1.2.1
        with:
          run: |
            go get -u -t ./...
            go test -race ./...
  staticcheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          persist-credentials: false
      - uses: actions/setup-go@v6
        with:
          go-version: stable
      - uses: geomys/sandboxed-step@v1.2.1
        with:
          run: go run honnef.co/go/tools/cmd/staticcheck@latest ./...
  govulncheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          persist-credentials: false
      - uses: actions/setup-go@v6
        with:
          go-version: stable
      - uses: geomys/sandboxed-step@v1.2.1
        with:
          run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...


================================================
FILE: LICENSE
================================================
Copyright 2019 The age Authors
Copyright 2019 Google LLC
Copyright 2022 Filippo Valsorda

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:

   * Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
   * Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
   * Neither the name of the age project nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.


================================================
FILE: README.md
================================================
<p align="center">
    <picture>
        <source media="(prefers-color-scheme: dark)" srcset="https://github.com/FiloSottile/age/blob/main/logo/logo_white.svg">
        <source media="(prefers-color-scheme: light)" srcset="https://github.com/FiloSottile/age/blob/main/logo/logo.svg">
        <img alt="The age logo, a wireframe of St. Peters dome in Rome, with the text: age, file encryption" width="600" src="https://github.com/FiloSottile/age/blob/main/logo/logo.svg">
    </picture>
</p>

[![Go Reference](https://pkg.go.dev/badge/filippo.io/age.svg)](https://pkg.go.dev/filippo.io/age)
[![man page](<https://img.shields.io/badge/age(1)-man%20page-lightgrey>)](https://filippo.io/age/age.1)
[![C2SP specification](https://img.shields.io/badge/%C2%A7%23-specification-blueviolet)](https://age-encryption.org/v1)

age is a simple, modern and secure file encryption tool, format, and Go library.

It features small explicit keys, post-quantum support, no config options, and UNIX-style composability.

```
$ age-keygen -o key.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
$ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age
$ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
```

📜 The format specification is at [age-encryption.org/v1](https://age-encryption.org/v1). age was designed by [@benjojo](https://github.com/benjojo) and [@FiloSottile](https://github.com/FiloSottile).

🦀 An alternative interoperable Rust implementation is available at [github.com/str4d/rage](https://github.com/str4d/rage).

🌍 [Typage](https://github.com/FiloSottile/typage) is a TypeScript implementation. It works in the browser, Node.js, Deno, and Bun.

🔑 Hardware PIV tokens such as YubiKeys are supported through the [age-plugin-yubikey](https://github.com/str4d/age-plugin-yubikey) plugin.

✨ For more plugins, implementations, tools, and integrations, check out the [awesome age](https://github.com/FiloSottile/awesome-age) list.

💬 The author pronounces it `[aɡe̞]` [with a hard *g*](https://translate.google.com/?sl=it&text=aghe), like GIF, and it's always spelled lowercase.

## Installation

<table>
    <tr>
        <td>Homebrew (macOS or Linux)</td>
        <td>
            <code>brew install age</code>
        </td>
    </tr>
    <tr>
        <td>MacPorts</td>
        <td>
            <code>port install age</code>
        </td>
    </tr>
    <tr>
        <td>Windows</td>
        <td>
            <code>winget install --id FiloSottile.age</code>
        </td>
    </tr>
    <tr>
        <td>Alpine Linux v3.15+</td>
        <td>
            <code>apk add age</code>
        </td>
    </tr>
    <tr>
        <td>Arch Linux</td>
        <td>
            <code>pacman -S age</code>
        </td>
    </tr>
    <tr>
        <td>Debian 12+ (Bookworm)</td>
        <td>
            <code>apt install age</code>
        </td>
    </tr>
    <tr>
        <td>Debian 11 (Bullseye)</td>
        <td>
            <code>apt install age/bullseye-backports</code>
            (<a href="https://backports.debian.org/Instructions/#index2h2">enable backports</a> for age v1.0.0+)
        </td>
    </tr>
    <tr>
        <td>Fedora 33+</td>
        <td>
            <code>dnf install age</code>
        </td>
    </tr>
    <tr>
        <td>Gentoo Linux</td>
        <td>
            <code>emerge app-crypt/age</code>
        </td>
    </tr>
    <tr>
        <td>Guix System</td>
        <td>
            <code>guix package -i age</code>
        </td>
    </tr>
    <tr>
        <td>NixOS / Nix</td>
        <td>
            <code>nix-env -i age</code>
        </td>
    </tr>
    <tr>
        <td>openSUSE Tumbleweed</td>
        <td>
            <code>zypper install age</code>
        </td>
    </tr>
    <tr>
        <td>Ubuntu 22.04+</td>
        <td>
            <code>apt install age</code>
        </td>
    </tr>
    <tr>
        <td>Void Linux</td>
        <td>
            <code>xbps-install age</code>
        </td>
    </tr>
    <tr>
        <td>FreeBSD</td>
        <td>
            <code>pkg install age</code> (security/age)
        </td>
    </tr>
    <tr>
        <td>OpenBSD 6.7+</td>
        <td>
            <code>pkg_add age</code> (security/age)
        </td>
    </tr>
    <tr>
        <td>Chocolatey (Windows)</td>
        <td>
            <code>choco install age.portable</code>
        </td>
    </tr>
    <tr>
        <td>Scoop (Windows)</td>
        <td>
            <code>scoop bucket add extras && scoop install age</code>
        </td>
    </tr>
</table>

On Windows, Linux, macOS, and FreeBSD you can use the pre-built binaries.

```
https://dl.filippo.io/age/latest?for=linux/amd64
https://dl.filippo.io/age/v1.3.1?for=darwin/arm64
...
```

If you download the pre-built binaries, you can check their [Sigsum proofs](./SIGSUM.md).

If your system has [a supported version of Go](https://go.dev/dl/), you can build from source.

```
go install filippo.io/age/cmd/...@latest
```

Help from new packagers is very welcome.

## Usage

For the full documentation, read [the age(1) man page](https://filippo.io/age/age.1).

```
Usage:
    age [--encrypt] (-r RECIPIENT | -R PATH)... [--armor] [-o OUTPUT] [INPUT]
    age [--encrypt] --passphrase [--armor] [-o OUTPUT] [INPUT]
    age --decrypt [-i PATH]... [-o OUTPUT] [INPUT]

Options:
    -e, --encrypt               Encrypt the input to the output. Default if omitted.
    -d, --decrypt               Decrypt the input to the output.
    -o, --output OUTPUT         Write the result to the file at path OUTPUT.
    -a, --armor                 Encrypt to a PEM encoded format.
    -p, --passphrase            Encrypt with a passphrase.
    -r, --recipient RECIPIENT   Encrypt to the specified RECIPIENT. Can be repeated.
    -R, --recipients-file PATH  Encrypt to recipients listed at PATH. Can be repeated.
    -i, --identity PATH         Use the identity file at PATH. Can be repeated.

INPUT defaults to standard input, and OUTPUT defaults to standard output.
If OUTPUT exists, it will be overwritten.

RECIPIENT can be an age public key generated by age-keygen ("age1...")
or an SSH public key ("ssh-ed25519 AAAA...", "ssh-rsa AAAA...").

Recipient files contain one or more recipients, one per line. Empty lines
and lines starting with "#" are ignored as comments. "-" may be used to
read recipients from standard input.

Identity files contain one or more secret keys ("AGE-SECRET-KEY-1..."),
one per line, or an SSH key. Empty lines and lines starting with "#" are
ignored as comments. Passphrase encrypted age files can be used as
identity files. Multiple key files can be provided, and any unused ones
will be ignored. "-" may be used to read identities from standard input.

When --encrypt is specified explicitly, -i can also be used to encrypt to an
identity file symmetrically, instead or in addition to normal recipients.
```

### Multiple recipients

Files can be encrypted to multiple recipients by repeating `-r/--recipient`. Every recipient will be able to decrypt the file.

```
$ age -o example.jpg.age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p \
    -r age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg example.jpg
```

#### Recipient files

Multiple recipients can also be listed one per line in one or more files passed with the `-R/--recipients-file` flag.

```
$ cat recipients.txt
# Alice
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
# Bob
age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg
$ age -R recipients.txt example.jpg > example.jpg.age
```

If the argument to `-R` (or `-i`) is `-`, the file is read from standard input.

### Post-quantum keys

To generate hybrid post-quantum keys, which are secure against future quantum
computer attacks, use the `-pq` flag with `age-keygen`. This may become the
default in the future.

Post-quantum identities start with `AGE-SECRET-KEY-PQ-1...` and recipients with
`age1pq1...`. The recipients are unfortunately ~2000 characters long.

```
$ age-keygen -pq -o key.txt
$ age-keygen -y key.txt > recipient.txt
$ age -R recipient.txt example.jpg > example.jpg.age
$ age -d -i key.txt example.jpg.age > example.jpg
```

Support for post-quantum keys is built into age v1.3.0 and later. Alternatively,
the `age-plugin-pq` binary can be installed and placed in `$PATH` to add support
to any version and implementation of age that supports plugins. Recipients will
work out of the box, while identities will have to be converted to plugin
identities with `age-plugin-pq -identity`.

### Passphrases

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.

```
$ age -p secrets.txt > secrets.txt.age
Enter passphrase (leave empty to autogenerate a secure one):
Using the autogenerated passphrase "release-response-step-brand-wrap-ankle-pair-unusual-sword-train".
$ age -d secrets.txt.age > secrets.txt
Enter passphrase:
```

### Passphrase-protected key files

If an identity file passed to `-i` is a passphrase encrypted age file, it will be automatically decrypted.

```
$ age-keygen | age -p > key.age
Public key: age1yhm4gctwfmrpz87tdslm550wrx6m79y9f2hdzt0lndjnehwj0ukqrjpyx5
Enter passphrase (leave empty to autogenerate a secure one):
Using the autogenerated passphrase "hip-roast-boring-snake-mention-east-wasp-honey-input-actress".
$ age -r age1yhm4gctwfmrpz87tdslm550wrx6m79y9f2hdzt0lndjnehwj0ukqrjpyx5 secrets.txt > secrets.txt.age
$ age -d -i key.age secrets.txt.age > secrets.txt
Enter passphrase for identity file "key.age":
```

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.

### SSH keys

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.)

```
$ age -R ~/.ssh/id_ed25519.pub example.jpg > example.jpg.age
$ age -d -i ~/.ssh/id_ed25519 example.jpg.age > example.jpg
```

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.

#### Encrypting to a GitHub user

Combining SSH key support and `-R`, you can easily encrypt a file to the SSH keys listed on a GitHub profile.

```
$ curl https://github.com/benjojo.keys | age -R - example.jpg > example.jpg.age
```

Keep in mind that people might not protect SSH keys long-term, since they are revokable when used only for authentication, and that SSH keys held on YubiKeys can't be used to decrypt files.

### Inspecting encrypted files

The `age-inspect` command can display metadata about an encrypted file without decrypting it, including the recipient types, whether it uses post-quantum encryption, and the payload size.

```
$ age-inspect secrets.age
secrets.age is an age file, version "age-encryption.org/v1".

This file is encrypted to the following recipient types:
  - "mlkem768x25519"

This file uses post-quantum encryption.

Size breakdown (assuming it decrypts successfully):

    Header                      1627 bytes
    Encryption overhead           32 bytes
    Payload                       42 bytes
                        -------------------
    Total                       1701 bytes

```

For scripting, use `--json` to get machine-readable output.


================================================
FILE: SIGSUM.md
================================================
If you download the pre-built binaries of version v1.2.0+, you can check their
[Sigsum](https://www.sigsum.org) proofs, which are like signatures with extra
transparency: you can cryptographically verify that every proof is logged in a
public append-only log, so the age project can be held accountable for every
binary release we ever produced. This is similar to what the [Go Checksum
Database](https://go.dev/blog/module-mirror-launch) provides.

```
cat << EOF > age-sigsum-key.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM1WpnEswJLPzvXJDiswowy48U+G+G1kmgwUE2eaRHZG
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAz2WM5CyPLqiNjk7CLl4roDXwKhQ0QExXLebukZEZFS
EOF

curl -JLO "https://dl.filippo.io/age/v1.3.1?for=darwin/arm64"
curl -JLO "https://dl.filippo.io/age/v1.3.1?for=darwin/arm64&proof"

go install sigsum.org/sigsum-go/cmd/sigsum-verify@v0.13.1
sigsum-verify -k age-sigsum-key.pub -P sigsum-generic-2025-1 \
    age-v1.3.1-darwin-arm64.tar.gz.proof < age-v1.3.1-darwin-arm64.tar.gz
```

You can learn more about what's happening above in the [Sigsum
docs](https://www.sigsum.org/getting-started/).

### Release playbook

Dear future me, to sign a new release and produce Sigsum proofs, run the following

```
VERSION=v1.3.1
go install sigsum.org/sigsum-go/cmd/sigsum-verify@latest
go install github.com/tillitis/tkey-ssh-agent/cmd/tkey-ssh-agent@main
tkey-ssh-agent --agent-socket tkey-ssh-agent.sock --uss
passage -c other/tkey-ssh-sigsum-age
SSH_AUTH_SOCK=tkey-ssh-agent.sock ssh-add -L > tkey-ssh-agent.pub
passage other/sigsum-ratelimit > sigsum-ratelimit
gh release download $VERSION --repo FiloSottile/age --dir artifacts/
SSH_AUTH_SOCK=tkey-ssh-agent.sock sigsum-submit -k tkey-ssh-agent.pub -P sigsum-generic-2025-1 -a sigsum-ratelimit -d filippo.io artifacts/*
gh release upload $VERSION --repo FiloSottile/age artifacts/*.proof
```

In the future, we will move to reproducing the artifacts locally, and signing
those instead of the ones built by GitHub Actions.


================================================
FILE: age.go
================================================
// Copyright 2019 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// Package age implements file encryption according to the age-encryption.org/v1
// specification.
//
// For most use cases, use the [Encrypt] and [Decrypt] functions with
// [HybridRecipient] and [HybridIdentity]. If passphrase encryption is
// required, use [ScryptRecipient] and [ScryptIdentity]. For compatibility with
// existing SSH keys use the filippo.io/age/agessh package.
//
// age encrypted files are binary and not malleable. For encoding them as text,
// use the filippo.io/age/armor package.
//
// # Key management
//
// age does not have a global keyring. Instead, since age keys are small,
// textual, and cheap, you are encouraged to generate dedicated keys for each
// task and application.
//
// Recipient public keys can be passed around as command line flags and in
// config files, while secret keys should be stored in dedicated files, through
// secret management systems, or as environment variables.
//
// There is no default path for age keys. Instead, they should be stored at
// application-specific paths. The CLI supports files where private keys are
// listed one per line, ignoring empty lines and lines starting with "#". These
// files can be parsed with [ParseIdentities].
//
// When integrating age into a new system, it's recommended that you only
// support native (X25519 and hybrid) keys, and not SSH keys. The latter are
// supported for manual encryption operations. If you need to tie into existing
// key management infrastructure, you might want to consider implementing your
// own [Recipient] and [Identity].
//
// # Backwards compatibility
//
// Files encrypted with a stable version (not alpha, beta, or release candidate)
// of age, or with any v1.0.0 beta or release candidate, will decrypt with any
// later versions of the v1 API. This might change in v2, in which case v1 will
// be maintained with security fixes for compatibility with older files.
//
// If decrypting an older file poses a security risk, doing so might require an
// explicit opt-in in the API.
package age

import (
	"bytes"
	"crypto/hmac"
	"crypto/rand"
	"errors"
	"fmt"
	"io"
	"slices"
	"sort"

	"filippo.io/age/internal/format"
	"filippo.io/age/internal/stream"
)

// An Identity is passed to [Decrypt] to unwrap an opaque file key from a
// recipient stanza. It can be for example a secret key like [HybridIdentity], a
// plugin, or a custom implementation.
type Identity interface {
	// Unwrap must return an error wrapping [ErrIncorrectIdentity] if none of
	// the recipient stanzas match the identity, any other error will be
	// considered fatal.
	//
	// Most age API users won't need to interact with this method directly, and
	// should instead pass [Identity] implementations to [Decrypt].
	Unwrap(stanzas []*Stanza) (fileKey []byte, err error)
}

// ErrIncorrectIdentity is returned by [Identity.Unwrap] if none of the
// recipient stanzas match the identity.
var ErrIncorrectIdentity = errors.New("incorrect identity for recipient block")

// A Recipient is passed to [Encrypt] to wrap an opaque file key to one or more
// recipient stanza(s). It can be for example a public key like [HybridRecipient],
// a plugin, or a custom implementation.
type Recipient interface {
	// Most age API users won't need to interact with this method directly, and
	// should instead pass [Recipient] implementations to [Encrypt].
	Wrap(fileKey []byte) ([]*Stanza, error)
}

// RecipientWithLabels can be optionally implemented by a [Recipient], in which
// case [Encrypt] will use WrapWithLabels instead of [Recipient.Wrap].
//
// Encrypt will succeed only if the labels returned by all the recipients
// (assuming the empty set for those that don't implement RecipientWithLabels)
// are the same.
//
// This can be used to ensure a recipient is only used with other recipients
// with equivalent properties (for example by setting a "postquantum" label) or
// to ensure a recipient is always used alone (by returning a random label, for
// example to preserve its authentication properties).
type RecipientWithLabels interface {
	WrapWithLabels(fileKey []byte) (s []*Stanza, labels []string, err error)
}

// A Stanza is a section of the age header that encapsulates the file key as
// encrypted to a specific recipient.
//
// Most age API users won't need to interact with this type directly, and should
// instead pass [Recipient] implementations to [Encrypt] and [Identity]
// implementations to [Decrypt].
type Stanza struct {
	Type string
	Args []string
	Body []byte
}

const fileKeySize = 16
const streamNonceSize = 16

func encryptHdr(fileKey []byte, recipients ...Recipient) (*format.Header, error) {
	if len(recipients) == 0 {
		return nil, errors.New("no recipients specified")
	}

	hdr := &format.Header{}
	var labels []string
	for i, r := range recipients {
		stanzas, l, err := wrapWithLabels(r, fileKey)
		if err != nil {
			return nil, fmt.Errorf("failed to wrap key for recipient #%d: %w", i, err)
		}
		sort.Strings(l)
		if i == 0 {
			labels = l
		} else if !slicesEqual(labels, l) {
			return nil, incompatibleLabelsError(labels, l)
		}
		for _, s := range stanzas {
			hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s))
		}
	}
	if mac, err := headerMAC(fileKey, hdr); err != nil {
		return nil, fmt.Errorf("failed to compute header MAC: %v", err)
	} else {
		hdr.MAC = mac
	}
	return hdr, nil
}

// Encrypt encrypts a file to one or more recipients. Every recipient will be
// able to decrypt the file.
//
// Writes to the returned WriteCloser are encrypted and written to dst as an age
// file. The caller must call Close on the WriteCloser when done for the last
// chunk to be encrypted and flushed to dst.
func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
	fileKey := make([]byte, fileKeySize)
	rand.Read(fileKey)

	hdr, err := encryptHdr(fileKey, recipients...)
	if err != nil {
		return nil, err
	}
	if err := hdr.Marshal(dst); err != nil {
		return nil, fmt.Errorf("failed to write header: %w", err)
	}

	nonce := make([]byte, streamNonceSize)
	rand.Read(nonce)
	if _, err := dst.Write(nonce); err != nil {
		return nil, fmt.Errorf("failed to write nonce: %w", err)
	}

	return stream.NewEncryptWriter(streamKey(fileKey, nonce), dst)
}

// EncryptReader encrypts a file to one or more recipients. Every recipient will be
// able to decrypt the file.
//
// Reads from the returned Reader produce the encrypted file, where the plaintext
// is read from src.
func EncryptReader(src io.Reader, recipients ...Recipient) (io.Reader, error) {
	fileKey := make([]byte, fileKeySize)
	rand.Read(fileKey)

	hdr, err := encryptHdr(fileKey, recipients...)
	if err != nil {
		return nil, err
	}
	buf := &bytes.Buffer{}
	if err := hdr.Marshal(buf); err != nil {
		return nil, fmt.Errorf("failed to prepare header: %w", err)
	}

	nonce := make([]byte, streamNonceSize)
	rand.Read(nonce)

	r, err := stream.NewEncryptReader(streamKey(fileKey, nonce), src)
	if err != nil {
		return nil, err
	}
	return io.MultiReader(buf, bytes.NewReader(nonce), r), nil
}

func wrapWithLabels(r Recipient, fileKey []byte) (s []*Stanza, labels []string, err error) {
	if r, ok := r.(RecipientWithLabels); ok {
		return r.WrapWithLabels(fileKey)
	}
	s, err = r.Wrap(fileKey)
	return
}

func slicesEqual(s1, s2 []string) bool {
	if len(s1) != len(s2) {
		return false
	}
	for i := range s1 {
		if s1[i] != s2[i] {
			return false
		}
	}
	return true
}

func incompatibleLabelsError(l1, l2 []string) error {
	hasPQ1 := slices.Contains(l1, "postquantum")
	hasPQ2 := slices.Contains(l2, "postquantum")
	if hasPQ1 != hasPQ2 {
		return fmt.Errorf("incompatible recipients: can't mix post-quantum and classic recipients, or the file would be vulnerable to quantum computers")
	}
	return fmt.Errorf("incompatible recipients: %q and %q can't be mixed", l1, l2)
}

// NoIdentityMatchError is returned by [Decrypt] when none of the supplied
// identities match the encrypted file.
type NoIdentityMatchError struct {
	// Errors is a slice of all the errors returned to Decrypt by the Unwrap
	// calls it made. They all wrap [ErrIncorrectIdentity].
	Errors []error
	// StanzaTypes are the first argument of each recipient stanza in the
	// encrypted file's header.
	StanzaTypes []string
}

func (e *NoIdentityMatchError) Error() string {
	if len(e.Errors) == 1 {
		return "identity did not match any of the recipients: " + e.Errors[0].Error()
	}
	return "no identity matched any of the recipients"
}

func (e *NoIdentityMatchError) Unwrap() []error {
	return e.Errors
}

// Decrypt decrypts a file encrypted to one or more identities.
// All identities will be tried until one successfully decrypts the file.
// Native, non-interactive identities are tried before any other identities.
//
// Decrypt returns a Reader reading the decrypted plaintext of the age file read
// from src. If no identity matches the encrypted file, the returned error will
// be of type [NoIdentityMatchError].
func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error) {
	hdr, payload, err := format.Parse(src)
	if err != nil {
		return nil, fmt.Errorf("failed to read header: %w", err)
	}

	fileKey, err := decryptHdr(hdr, identities...)
	if err != nil {
		return nil, err
	}

	nonce := make([]byte, streamNonceSize)
	if _, err := io.ReadFull(payload, nonce); err != nil {
		return nil, fmt.Errorf("failed to read nonce: %w", err)
	}

	return stream.NewDecryptReader(streamKey(fileKey, nonce), payload)
}

// DecryptReaderAt decrypts a file encrypted to one or more identities.
// All identities will be tried until one successfully decrypts the file.
// Native, non-interactive identities are tried before any other identities.
//
// DecryptReaderAt takes an underlying [io.ReaderAt] and its total encrypted
// size, and returns a ReaderAt of the decrypted plaintext and the plaintext
// size. These can be used for example to instantiate an [io.SectionReader],
// which implements [io.Reader] and [io.Seeker], or for [zip.NewReader].
// Note that ReaderAt by definition disregards the seek position of src.
//
// The ReadAt method of the returned ReaderAt can be called concurrently.
// The ReaderAt will internally cache the most recently decrypted chunk.
// DecryptReaderAt reads and decrypts the final chunk before returning,
// to authenticate the plaintext size.
//
// If no identity matches the encrypted file, the returned error will be of
// type [NoIdentityMatchError].
func DecryptReaderAt(src io.ReaderAt, encryptedSize int64, identities ...Identity) (io.ReaderAt, int64, error) {
	srcReader := io.NewSectionReader(src, 0, encryptedSize)
	hdr, payload, err := format.Parse(srcReader)
	if err != nil {
		return nil, 0, fmt.Errorf("failed to read header: %w", err)
	}
	buf := &bytes.Buffer{}
	if err := hdr.Marshal(buf); err != nil {
		return nil, 0, fmt.Errorf("failed to serialize header: %w", err)
	}

	fileKey, err := decryptHdr(hdr, identities...)
	if err != nil {
		return nil, 0, err
	}

	nonce := make([]byte, streamNonceSize)
	if _, err := io.ReadFull(payload, nonce); err != nil {
		return nil, 0, fmt.Errorf("failed to read nonce: %w", err)
	}

	payloadOffset := int64(buf.Len()) + int64(len(nonce))
	payloadSize := encryptedSize - payloadOffset
	plaintextSize, err := stream.PlaintextSize(payloadSize)
	if err != nil {
		return nil, 0, err
	}
	payloadReaderAt := io.NewSectionReader(src, payloadOffset, payloadSize)
	r, err := stream.NewDecryptReaderAt(streamKey(fileKey, nonce), payloadReaderAt, payloadSize)
	if err != nil {
		return nil, 0, err
	}
	return r, plaintextSize, nil
}

func decryptHdr(hdr *format.Header, identities ...Identity) ([]byte, error) {
	if len(identities) == 0 {
		return nil, errors.New("no identities specified")
	}
	slices.SortStableFunc(identities, func(a, b Identity) int {
		var aIsNative, bIsNative bool
		switch a.(type) {
		case *X25519Identity, *HybridIdentity, *ScryptIdentity:
			aIsNative = true
		}
		switch b.(type) {
		case *X25519Identity, *HybridIdentity, *ScryptIdentity:
			bIsNative = true
		}
		if aIsNative && !bIsNative {
			return -1
		}
		if !aIsNative && bIsNative {
			return 1
		}
		return 0
	})

	stanzas := make([]*Stanza, 0, len(hdr.Recipients))
	errNoMatch := &NoIdentityMatchError{}
	for _, s := range hdr.Recipients {
		errNoMatch.StanzaTypes = append(errNoMatch.StanzaTypes, s.Type)
		stanzas = append(stanzas, (*Stanza)(s))
	}
	var fileKey []byte
	for _, id := range identities {
		var err error
		fileKey, err = id.Unwrap(stanzas)
		if errors.Is(err, ErrIncorrectIdentity) {
			errNoMatch.Errors = append(errNoMatch.Errors, err)
			continue
		}
		if err != nil {
			return nil, err
		}

		break
	}
	if fileKey == nil {
		return nil, errNoMatch
	}

	if mac, err := headerMAC(fileKey, hdr); err != nil {
		return nil, fmt.Errorf("failed to compute header MAC: %v", err)
	} else if !hmac.Equal(mac, hdr.MAC) {
		return nil, errors.New("bad header MAC")
	}

	return fileKey, nil
}

// multiUnwrap is a helper that implements Identity.Unwrap in terms of a
// function that unwraps a single recipient stanza.
func multiUnwrap(unwrap func(*Stanza) ([]byte, error), stanzas []*Stanza) ([]byte, error) {
	for _, s := range stanzas {
		fileKey, err := unwrap(s)
		if errors.Is(err, ErrIncorrectIdentity) {
			// If we ever start returning something interesting wrapping
			// ErrIncorrectIdentity, we should let it make its way up through
			// Decrypt into NoIdentityMatchError.Errors.
			continue
		}
		if err != nil {
			return nil, err
		}
		return fileKey, nil
	}
	return nil, ErrIncorrectIdentity
}

// ExtractHeader returns a detached header from the src file.
//
// The detached header can be decrypted with [DecryptHeader] (for example on a
// different system, without sharing the ciphertext) and then the file key can
// be used with [NewInjectedFileKeyIdentity].
//
// This is a low-level function that most users won't need.
func ExtractHeader(src io.Reader) ([]byte, error) {
	hdr, _, err := format.Parse(src)
	if err != nil {
		return nil, fmt.Errorf("failed to read header: %w", err)
	}
	buf := &bytes.Buffer{}
	if err := hdr.Marshal(buf); err != nil {
		return nil, fmt.Errorf("failed to serialize header: %w", err)
	}
	return buf.Bytes(), nil
}

// DecryptHeader decrypts a detached header and returns a file key.
//
// The detached header can be produced by [ExtractHeader], and the
// returned file key can be used with [NewInjectedFileKeyIdentity].
//
// This is a low-level function that most users won't need.
// It is the caller's responsibility to keep track of what file the
// returned file key decrypts, and to ensure the file key is not used
// for any other purpose.
func DecryptHeader(header []byte, identities ...Identity) ([]byte, error) {
	hdr, _, err := format.Parse(bytes.NewReader(header))
	if err != nil {
		return nil, fmt.Errorf("failed to read header: %w", err)
	}
	return decryptHdr(hdr, identities...)
}

type injectedFileKeyIdentity struct {
	fileKey []byte
}

// NewInjectedFileKeyIdentity returns an [Identity] that always produces
// a fixed file key, allowing the use of a file key obtained out-of-band,
// for example via [DecryptHeader].
//
// This is a low-level function that most users won't need.
func NewInjectedFileKeyIdentity(fileKey []byte) Identity {
	return injectedFileKeyIdentity{fileKey}
}

func (i injectedFileKeyIdentity) Unwrap(stanzas []*Stanza) (fileKey []byte, err error) {
	return i.fileKey, nil
}


================================================
FILE: age_test.go
================================================
// Copyright 2019 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package age_test

import (
	"archive/zip"
	"bytes"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"log"
	"os"
	"slices"
	"strings"
	"testing"

	"filippo.io/age"
)

func ExampleEncrypt() {
	publicKey := "age1cy0su9fwf3gf9mw868g5yut09p6nytfmmnktexz2ya5uqg9vl9sss4euqm"
	recipient, err := age.ParseX25519Recipient(publicKey)
	if err != nil {
		log.Fatalf("Failed to parse public key %q: %v", publicKey, err)
	}

	out := &bytes.Buffer{}

	w, err := age.Encrypt(out, recipient)
	if err != nil {
		log.Fatalf("Failed to create encrypted file: %v", err)
	}
	if _, err := io.WriteString(w, "Black lives matter."); err != nil {
		log.Fatalf("Failed to write to encrypted file: %v", err)
	}
	if err := w.Close(); err != nil {
		log.Fatalf("Failed to close encrypted file: %v", err)
	}

	fmt.Printf("Encrypted file size: %d\n", out.Len())
	// Output:
	// Encrypted file size: 219
}

// DO NOT hardcode the private key. Store it in a secret storage solution,
// on disk if the local machine is trusted, or have the user provide it.
var privateKey string

func init() {
	privateKey = "AGE-SECRET-KEY-184JMZMVQH3E6U0PSL869004Y3U2NYV7R30EU99CSEDNPH02YUVFSZW44VU"
}

func ExampleDecrypt() {
	identity, err := age.ParseX25519Identity(privateKey)
	if err != nil {
		log.Fatalf("Failed to parse private key: %v", err)
	}

	f, err := os.Open("testdata/example.age")
	if err != nil {
		log.Fatalf("Failed to open file: %v", err)
	}

	r, err := age.Decrypt(f, identity)
	if err != nil {
		log.Fatalf("Failed to open encrypted file: %v", err)
	}
	out := &bytes.Buffer{}
	if _, err := io.Copy(out, r); err != nil {
		log.Fatalf("Failed to read encrypted file: %v", err)
	}

	fmt.Printf("File contents: %q\n", out.Bytes())
	// Output:
	// File contents: "Black lives matter."
}

func ExampleParseIdentities() {
	keyFile, err := os.Open("testdata/example_keys.txt")
	if err != nil {
		log.Fatalf("Failed to open private keys file: %v", err)
	}
	identities, err := age.ParseIdentities(keyFile)
	if err != nil {
		log.Fatalf("Failed to parse private key: %v", err)
	}

	f, err := os.Open("testdata/example.age")
	if err != nil {
		log.Fatalf("Failed to open file: %v", err)
	}

	r, err := age.Decrypt(f, identities...)
	if err != nil {
		log.Fatalf("Failed to open encrypted file: %v", err)
	}
	out := &bytes.Buffer{}
	if _, err := io.Copy(out, r); err != nil {
		log.Fatalf("Failed to read encrypted file: %v", err)
	}

	fmt.Printf("File contents: %q\n", out.Bytes())
	// Output:
	// File contents: "Black lives matter."
}

func ExampleGenerateX25519Identity() {
	identity, err := age.GenerateX25519Identity()
	if err != nil {
		log.Fatalf("Failed to generate key pair: %v", err)
	}

	fmt.Printf("Public key: %s...\n", identity.Recipient().String()[:4])
	fmt.Printf("Private key: %s...\n", identity.String()[:16])
	// Output:
	// Public key: age1...
	// Private key: AGE-SECRET-KEY-1...
}

const helloWorld = "Hello, Twitch!"

func TestEncryptDecryptX25519(t *testing.T) {
	a, err := age.GenerateX25519Identity()
	if err != nil {
		t.Fatal(err)
	}
	b, err := age.GenerateX25519Identity()
	if err != nil {
		t.Fatal(err)
	}
	buf := &bytes.Buffer{}
	w, err := age.Encrypt(buf, a.Recipient(), b.Recipient())
	if err != nil {
		t.Fatal(err)
	}
	if _, err := io.WriteString(w, helloWorld); err != nil {
		t.Fatal(err)
	}
	if err := w.Close(); err != nil {
		t.Fatal(err)
	}

	out, err := age.Decrypt(buf, b)
	if err != nil {
		t.Fatal(err)
	}
	outBytes, err := io.ReadAll(out)
	if err != nil {
		t.Fatal(err)
	}
	if string(outBytes) != helloWorld {
		t.Errorf("wrong data: %q, excepted %q", outBytes, helloWorld)
	}
}

func TestEncryptDecryptScrypt(t *testing.T) {
	password := "twitch.tv/filosottile"

	r, err := age.NewScryptRecipient(password)
	if err != nil {
		t.Fatal(err)
	}
	r.SetWorkFactor(15)
	buf := &bytes.Buffer{}
	w, err := age.Encrypt(buf, r)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := io.WriteString(w, helloWorld); err != nil {
		t.Fatal(err)
	}
	if err := w.Close(); err != nil {
		t.Fatal(err)
	}

	i, err := age.NewScryptIdentity(password)
	if err != nil {
		t.Fatal(err)
	}
	out, err := age.Decrypt(buf, i)
	if err != nil {
		t.Fatal(err)
	}
	outBytes, err := io.ReadAll(out)
	if err != nil {
		t.Fatal(err)
	}
	if string(outBytes) != helloWorld {
		t.Errorf("wrong data: %q, excepted %q", outBytes, helloWorld)
	}
}

func ExampleDecryptReaderAt() {
	identity, err := age.ParseX25519Identity(privateKey)
	if err != nil {
		log.Fatalf("Failed to parse private key: %v", err)
	}

	f, err := os.Open("testdata/example.zip.age")
	if err != nil {
		log.Fatalf("Failed to open file: %v", err)
	}
	stat, err := f.Stat()
	if err != nil {
		log.Fatalf("Failed to stat file: %v", err)
	}

	r, size, err := age.DecryptReaderAt(f, stat.Size(), identity)
	if err != nil {
		log.Fatalf("Failed to open encrypted file: %v", err)
	}

	z, err := zip.NewReader(r, size)
	if err != nil {
		log.Fatalf("Failed to open zip: %v", err)
	}
	contents, err := fs.ReadFile(z, "example.txt")
	if err != nil {
		log.Fatalf("Failed to read file from zip: %v", err)
	}

	fmt.Printf("File contents: %q\n", contents)
	// Output:
	// File contents: "Black lives matter."
}

func TestParseIdentities(t *testing.T) {
	tests := []struct {
		name      string
		wantCount int
		wantErr   bool
		file      string
	}{
		{"valid", 2, false, `
# this is a comment
# AGE-SECRET-KEY-1705XN76M8EYQ8M9PY4E2G3KA8DN7NSCGT3V4HMN20H3GCX4AS6HSSTG8D3
#

AGE-SECRET-KEY-1D6K0SGAX3NU66R4GYFZY0UQWCLM3UUSF3CXLW4KXZM342WQSJ82QKU59QJ
AGE-SECRET-KEY-19WUMFE89H3928FRJ5U3JYRNHM6CERQGKSQ584AQ8QY7T7R09D32SWE4DYH`},
		{"invalid", 0, true, `
AGE-SECRET-KEY-1705XN76M8EYQ8M9PY4E2G3KA8DN7NSCGT3V4HMN20H3GCX4AS6HSSTG8D3
AGE-SECRET-KEY--1D6K0SGAX3NU66R4GYFZY0UQWCLM3UUSF3CXLW4KXZM342WQSJ82QKU59Q`},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := age.ParseIdentities(strings.NewReader(tt.file))
			if (err != nil) != tt.wantErr {
				t.Errorf("ParseIdentities() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if len(got) != tt.wantCount {
				t.Errorf("ParseIdentities() returned %d identities, want %d", len(got), tt.wantCount)
			}
		})
	}
}

type testRecipient struct {
	labels []string
}

func (testRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
	panic("expected WrapWithLabels instead")
}

func (t testRecipient) WrapWithLabels(fileKey []byte) (s []*age.Stanza, labels []string, err error) {
	return []*age.Stanza{{Type: "test"}}, t.labels, nil
}

func TestLabels(t *testing.T) {
	scrypt, err := age.NewScryptRecipient("xxx")
	if err != nil {
		t.Fatal(err)
	}
	i, err := age.GenerateX25519Identity()
	if err != nil {
		t.Fatal(err)
	}
	x25519 := i.Recipient()
	pqc := testRecipient{[]string{"postquantum"}}
	pqcAndFoo := testRecipient{[]string{"postquantum", "foo"}}
	fooAndPQC := testRecipient{[]string{"foo", "postquantum"}}

	if _, err := age.Encrypt(io.Discard, scrypt, scrypt); err == nil {
		t.Error("expected two scrypt recipients to fail")
	}
	if _, err := age.Encrypt(io.Discard, scrypt, x25519); err == nil {
		t.Error("expected x25519 mixed with scrypt to fail")
	}
	if _, err := age.Encrypt(io.Discard, x25519, scrypt); err == nil {
		t.Error("expected x25519 mixed with scrypt to fail")
	}
	if _, err := age.Encrypt(io.Discard, pqc, x25519); err == nil {
		t.Error("expected x25519 mixed with pqc to fail")
	}
	if _, err := age.Encrypt(io.Discard, x25519, pqc); err == nil {
		t.Error("expected x25519 mixed with pqc to fail")
	}
	if _, err := age.Encrypt(io.Discard, pqc, pqc); err != nil {
		t.Errorf("expected two pqc to work, got %v", err)
	}
	if _, err := age.Encrypt(io.Discard, pqc); err != nil {
		t.Errorf("expected one pqc to work, got %v", err)
	}
	if _, err := age.Encrypt(io.Discard, pqcAndFoo, pqc); err == nil {
		t.Error("expected pqc+foo mixed with pqc to fail")
	}
	if _, err := age.Encrypt(io.Discard, pqc, pqcAndFoo); err == nil {
		t.Error("expected pqc+foo mixed with pqc to fail")
	}
	if _, err := age.Encrypt(io.Discard, pqc, pqc, pqcAndFoo); err == nil {
		t.Error("expected pqc+foo mixed with pqc to fail")
	}
	if _, err := age.Encrypt(io.Discard, pqcAndFoo, pqcAndFoo); err != nil {
		t.Errorf("expected two pqc+foo to work, got %v", err)
	}
	if _, err := age.Encrypt(io.Discard, pqcAndFoo, fooAndPQC); err != nil {
		t.Errorf("expected pqc+foo mixed with foo+pqc to work, got %v", err)
	}
}

// testIdentity is a non-native identity that records if Unwrap is called.
type testIdentity struct {
	called bool
}

func (ti *testIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
	ti.called = true
	return nil, age.ErrIncorrectIdentity
}

func TestDecryptNativeIdentitiesFirst(t *testing.T) {
	correct, err := age.GenerateX25519Identity()
	if err != nil {
		t.Fatal(err)
	}
	unrelated, err := age.GenerateX25519Identity()
	if err != nil {
		t.Fatal(err)
	}

	buf := &bytes.Buffer{}
	w, err := age.Encrypt(buf, correct.Recipient())
	if err != nil {
		t.Fatal(err)
	}
	if err := w.Close(); err != nil {
		t.Fatal(err)
	}

	nonNative := &testIdentity{}

	// Pass identities: unrelated native, non-native, correct native.
	// Native identities should be tried first, so correct should match
	// before nonNative is ever called.
	_, err = age.Decrypt(bytes.NewReader(buf.Bytes()), unrelated, nonNative, correct)
	if err != nil {
		t.Fatal(err)
	}

	if nonNative.called {
		t.Error("non-native identity was called, but native identities should be tried first")
	}
}

type stanzaTypeRecipient string

func (s stanzaTypeRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
	return []*age.Stanza{{Type: string(s)}}, nil
}

func TestNoIdentityMatchErrorStanzaTypes(t *testing.T) {
	a, err := age.GenerateX25519Identity()
	if err != nil {
		t.Fatal(err)
	}
	b, err := age.GenerateX25519Identity()
	if err != nil {
		t.Fatal(err)
	}
	wrong, err := age.GenerateX25519Identity()
	if err != nil {
		t.Fatal(err)
	}

	buf := &bytes.Buffer{}
	w, err := age.Encrypt(buf, a.Recipient(), stanzaTypeRecipient("other"), b.Recipient())
	if err != nil {
		t.Fatal(err)
	}
	if _, err := io.WriteString(w, helloWorld); err != nil {
		t.Fatal(err)
	}
	if err := w.Close(); err != nil {
		t.Fatal(err)
	}

	_, err = age.Decrypt(bytes.NewReader(buf.Bytes()), wrong)
	if err == nil {
		t.Fatal("expected decryption to fail")
	}

	var noMatch *age.NoIdentityMatchError
	if !errors.As(err, &noMatch) {
		t.Fatalf("expected NoIdentityMatchError, got %T: %v", err, err)
	}

	want := []string{"X25519", "other", "X25519"}
	if !slices.Equal(noMatch.StanzaTypes, want) {
		t.Errorf("StanzaTypes = %v, want %v", noMatch.StanzaTypes, want)
	}
}

func TestScryptIdentityErrors(t *testing.T) {
	t.Run("not passphrase-encrypted", func(t *testing.T) {
		i, err := age.GenerateX25519Identity()
		if err != nil {
			t.Fatal(err)
		}

		buf := &bytes.Buffer{}
		w, err := age.Encrypt(buf, i.Recipient())
		if err != nil {
			t.Fatal(err)
		}
		if err := w.Close(); err != nil {
			t.Fatal(err)
		}

		scryptID, err := age.NewScryptIdentity("password")
		if err != nil {
			t.Fatal(err)
		}
		_, err = age.Decrypt(bytes.NewReader(buf.Bytes()), scryptID)
		if err == nil {
			t.Fatal("expected decryption to fail")
		}
		if !errors.Is(err, age.ErrIncorrectIdentity) {
			t.Errorf("expected ErrIncorrectIdentity, got %v", err)
		}
		if !strings.Contains(err.Error(), "not passphrase-encrypted") {
			t.Errorf("expected error to mention 'not passphrase-encrypted', got %v", err)
		}
	})

	t.Run("incorrect passphrase", func(t *testing.T) {
		r, err := age.NewScryptRecipient("correct-password")
		if err != nil {
			t.Fatal(err)
		}
		r.SetWorkFactor(10) // Low for fast test

		buf := &bytes.Buffer{}
		w, err := age.Encrypt(buf, r)
		if err != nil {
			t.Fatal(err)
		}
		if err := w.Close(); err != nil {
			t.Fatal(err)
		}

		scryptID, err := age.NewScryptIdentity("wrong-password")
		if err != nil {
			t.Fatal(err)
		}
		_, err = age.Decrypt(bytes.NewReader(buf.Bytes()), scryptID)
		if err == nil {
			t.Fatal("expected decryption to fail")
		}
		if !errors.Is(err, age.ErrIncorrectIdentity) {
			t.Errorf("expected ErrIncorrectIdentity, got %v", err)
		}
		if !strings.Contains(err.Error(), "incorrect passphrase") {
			t.Errorf("expected error to mention 'incorrect passphrase', got %v", err)
		}
	})
}

func TestDetachedHeader(t *testing.T) {
	i, err := age.GenerateX25519Identity()
	if err != nil {
		t.Fatal(err)
	}

	buf := &bytes.Buffer{}
	w, err := age.Encrypt(buf, i.Recipient())
	if err != nil {
		t.Fatal(err)
	}
	if _, err := io.WriteString(w, helloWorld); err != nil {
		t.Fatal(err)
	}
	if err := w.Close(); err != nil {
		t.Fatal(err)
	}
	encrypted := buf.Bytes()

	header, err := age.ExtractHeader(bytes.NewReader(encrypted))
	if err != nil {
		t.Fatal(err)
	}

	fileKey, err := age.DecryptHeader(header, i)
	if err != nil {
		t.Fatal(err)
	}

	identity := age.NewInjectedFileKeyIdentity(fileKey)
	out, err := age.Decrypt(bytes.NewReader(encrypted), identity)
	if err != nil {
		t.Fatal(err)
	}
	outBytes, err := io.ReadAll(out)
	if err != nil {
		t.Fatal(err)
	}
	if string(outBytes) != helloWorld {
		t.Errorf("wrong data: %q, expected %q", outBytes, helloWorld)
	}
}

func TestEncryptReader(t *testing.T) {
	a, err := age.GenerateX25519Identity()
	if err != nil {
		t.Fatal(err)
	}
	r, err := age.EncryptReader(strings.NewReader(helloWorld), a.Recipient())
	if err != nil {
		t.Fatal(err)
	}
	buf := &bytes.Buffer{}
	if _, err := io.Copy(buf, r); err != nil {
		t.Fatal(err)
	}

	out, err := age.Decrypt(buf, a)
	if err != nil {
		t.Fatal(err)
	}
	outBytes, err := io.ReadAll(out)
	if err != nil {
		t.Fatal(err)
	}
	if string(outBytes) != helloWorld {
		t.Errorf("wrong data: %q, excepted %q", outBytes, helloWorld)
	}
}


================================================
FILE: agessh/agessh.go
================================================
// Copyright 2019 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// Package agessh provides age.Identity and age.Recipient implementations of
// types "ssh-rsa" and "ssh-ed25519", which allow reusing existing SSH keys for
// encryption with age-encryption.org/v1.
//
// These recipient types should only be used for compatibility with existing
// keys, and native keys should be preferred otherwise.
//
// Note that these recipient types are not anonymous: the encrypted message will
// include a short 32-bit ID of the public key.
package agessh

import (
	"crypto/ed25519"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/sha512"
	"errors"
	"fmt"
	"io"

	"filippo.io/age"
	"filippo.io/age/internal/format"
	"filippo.io/edwards25519"
	"golang.org/x/crypto/chacha20poly1305"
	"golang.org/x/crypto/curve25519"
	"golang.org/x/crypto/hkdf"
	"golang.org/x/crypto/ssh"
)

func sshFingerprint(pk ssh.PublicKey) string {
	h := sha256.Sum256(pk.Marshal())
	return format.EncodeToString(h[:4])
}

const oaepLabel = "age-encryption.org/v1/ssh-rsa"

type RSARecipient struct {
	sshKey ssh.PublicKey
	pubKey *rsa.PublicKey
}

var _ age.Recipient = &RSARecipient{}

func NewRSARecipient(pk ssh.PublicKey) (*RSARecipient, error) {
	if pk.Type() != "ssh-rsa" {
		return nil, errors.New("SSH public key is not an RSA key")
	}
	r := &RSARecipient{
		sshKey: pk,
	}

	if pk, ok := pk.(ssh.CryptoPublicKey); ok {
		if pk, ok := pk.CryptoPublicKey().(*rsa.PublicKey); ok {
			r.pubKey = pk
		} else {
			return nil, errors.New("unexpected public key type")
		}
	} else {
		return nil, errors.New("pk does not implement ssh.CryptoPublicKey")
	}
	if r.pubKey.Size() < 2048/8 {
		return nil, errors.New("RSA key size is too small")
	}
	return r, nil
}

func (r *RSARecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
	l := &age.Stanza{
		Type: "ssh-rsa",
		Args: []string{sshFingerprint(r.sshKey)},
	}

	wrappedKey, err := rsa.EncryptOAEP(sha256.New(), rand.Reader,
		r.pubKey, fileKey, []byte(oaepLabel))
	if err != nil {
		return nil, err
	}
	l.Body = wrappedKey

	return []*age.Stanza{l}, nil
}

type RSAIdentity struct {
	k      *rsa.PrivateKey
	sshKey ssh.PublicKey
}

var _ age.Identity = &RSAIdentity{}

func NewRSAIdentity(key *rsa.PrivateKey) (*RSAIdentity, error) {
	s, err := ssh.NewSignerFromKey(key)
	if err != nil {
		return nil, err
	}
	i := &RSAIdentity{
		k: key, sshKey: s.PublicKey(),
	}
	return i, nil
}

func (i *RSAIdentity) Recipient() *RSARecipient {
	return &RSARecipient{
		sshKey: i.sshKey,
		pubKey: &i.k.PublicKey,
	}
}

func (i *RSAIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
	return multiUnwrap(i.unwrap, stanzas)
}

func (i *RSAIdentity) unwrap(block *age.Stanza) ([]byte, error) {
	if block.Type != "ssh-rsa" {
		return nil, age.ErrIncorrectIdentity
	}
	if len(block.Args) != 1 {
		return nil, errors.New("invalid ssh-rsa recipient block")
	}

	if block.Args[0] != sshFingerprint(i.sshKey) {
		return nil, age.ErrIncorrectIdentity
	}

	fileKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, i.k,
		block.Body, []byte(oaepLabel))
	if err != nil {
		return nil, fmt.Errorf("failed to decrypt file key: %v", err)
	}
	return fileKey, nil
}

type Ed25519Recipient struct {
	sshKey         ssh.PublicKey
	theirPublicKey []byte
}

var _ age.Recipient = &Ed25519Recipient{}

func NewEd25519Recipient(pk ssh.PublicKey) (*Ed25519Recipient, error) {
	if pk.Type() != "ssh-ed25519" {
		return nil, errors.New("SSH public key is not an Ed25519 key")
	}

	cpk, ok := pk.(ssh.CryptoPublicKey)
	if !ok {
		return nil, errors.New("pk does not implement ssh.CryptoPublicKey")
	}
	epk, ok := cpk.CryptoPublicKey().(ed25519.PublicKey)
	if !ok {
		return nil, errors.New("unexpected public key type")
	}
	mpk, err := ed25519PublicKeyToCurve25519(epk)
	if err != nil {
		return nil, fmt.Errorf("invalid Ed25519 public key: %v", err)
	}

	return &Ed25519Recipient{
		sshKey:         pk,
		theirPublicKey: mpk,
	}, nil
}

func ParseRecipient(s string) (age.Recipient, error) {
	pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(s))
	if err != nil {
		return nil, fmt.Errorf("malformed SSH recipient: %q: %v", s, err)
	}

	var r age.Recipient
	switch t := pubKey.Type(); t {
	case "ssh-rsa":
		r, err = NewRSARecipient(pubKey)
	case "ssh-ed25519":
		r, err = NewEd25519Recipient(pubKey)
	default:
		return nil, fmt.Errorf("unknown SSH recipient type: %q", t)
	}
	if err != nil {
		return nil, fmt.Errorf("malformed SSH recipient: %q: %v", s, err)
	}

	return r, nil
}

func ed25519PublicKeyToCurve25519(pk ed25519.PublicKey) ([]byte, error) {
	// See https://blog.filippo.io/using-ed25519-keys-for-encryption and
	// https://pkg.go.dev/filippo.io/edwards25519#Point.BytesMontgomery.
	p, err := new(edwards25519.Point).SetBytes(pk)
	if err != nil {
		return nil, err
	}
	return p.BytesMontgomery(), nil
}

const ed25519Label = "age-encryption.org/v1/ssh-ed25519"

func (r *Ed25519Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
	ephemeral := make([]byte, curve25519.ScalarSize)
	if _, err := rand.Read(ephemeral); err != nil {
		return nil, err
	}
	ourPublicKey, err := curve25519.X25519(ephemeral, curve25519.Basepoint)
	if err != nil {
		return nil, err
	}

	sharedSecret, err := curve25519.X25519(ephemeral, r.theirPublicKey)
	if err != nil {
		return nil, err
	}

	tweak := make([]byte, curve25519.ScalarSize)
	tH := hkdf.New(sha256.New, nil, r.sshKey.Marshal(), []byte(ed25519Label))
	if _, err := io.ReadFull(tH, tweak); err != nil {
		return nil, err
	}
	sharedSecret, _ = curve25519.X25519(tweak, sharedSecret)

	l := &age.Stanza{
		Type: "ssh-ed25519",
		Args: []string{sshFingerprint(r.sshKey),
			format.EncodeToString(ourPublicKey[:])},
	}

	salt := make([]byte, 0, len(ourPublicKey)+len(r.theirPublicKey))
	salt = append(salt, ourPublicKey...)
	salt = append(salt, r.theirPublicKey...)
	h := hkdf.New(sha256.New, sharedSecret, salt, []byte(ed25519Label))
	wrappingKey := make([]byte, chacha20poly1305.KeySize)
	if _, err := io.ReadFull(h, wrappingKey); err != nil {
		return nil, err
	}

	wrappedKey, err := aeadEncrypt(wrappingKey, fileKey)
	if err != nil {
		return nil, err
	}
	l.Body = wrappedKey

	return []*age.Stanza{l}, nil
}

type Ed25519Identity struct {
	secretKey, ourPublicKey []byte
	sshKey                  ssh.PublicKey
}

var _ age.Identity = &Ed25519Identity{}

func NewEd25519Identity(key ed25519.PrivateKey) (*Ed25519Identity, error) {
	s, err := ssh.NewSignerFromKey(key)
	if err != nil {
		return nil, err
	}
	i := &Ed25519Identity{
		sshKey:    s.PublicKey(),
		secretKey: ed25519PrivateKeyToCurve25519(key),
	}
	i.ourPublicKey, _ = curve25519.X25519(i.secretKey, curve25519.Basepoint)
	return i, nil
}

func ParseIdentity(pemBytes []byte) (age.Identity, error) {
	k, err := ssh.ParseRawPrivateKey(pemBytes)
	if err != nil {
		return nil, err
	}

	switch k := k.(type) {
	case *ed25519.PrivateKey:
		return NewEd25519Identity(*k)
	// ParseRawPrivateKey returns inconsistent types. See Issue 429.
	case ed25519.PrivateKey:
		return NewEd25519Identity(k)
	case *rsa.PrivateKey:
		return NewRSAIdentity(k)
	}

	return nil, fmt.Errorf("unsupported SSH identity type: %T", k)
}

func ed25519PrivateKeyToCurve25519(pk ed25519.PrivateKey) []byte {
	h := sha512.New()
	h.Write(pk.Seed())
	out := h.Sum(nil)
	return out[:curve25519.ScalarSize]
}

func (i *Ed25519Identity) Recipient() *Ed25519Recipient {
	return &Ed25519Recipient{
		sshKey:         i.sshKey,
		theirPublicKey: i.ourPublicKey,
	}
}

func (i *Ed25519Identity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
	return multiUnwrap(i.unwrap, stanzas)
}

func (i *Ed25519Identity) unwrap(block *age.Stanza) ([]byte, error) {
	if block.Type != "ssh-ed25519" {
		return nil, age.ErrIncorrectIdentity
	}
	if len(block.Args) != 2 {
		return nil, errors.New("invalid ssh-ed25519 recipient block")
	}
	publicKey, err := format.DecodeString(block.Args[1])
	if err != nil {
		return nil, fmt.Errorf("failed to parse ssh-ed25519 recipient: %v", err)
	}
	if len(publicKey) != curve25519.PointSize {
		return nil, errors.New("invalid ssh-ed25519 recipient block")
	}

	if block.Args[0] != sshFingerprint(i.sshKey) {
		return nil, age.ErrIncorrectIdentity
	}

	sharedSecret, err := curve25519.X25519(i.secretKey, publicKey)
	if err != nil {
		return nil, fmt.Errorf("invalid X25519 recipient: %v", err)
	}

	tweak := make([]byte, curve25519.ScalarSize)
	tH := hkdf.New(sha256.New, nil, i.sshKey.Marshal(), []byte(ed25519Label))
	if _, err := io.ReadFull(tH, tweak); err != nil {
		return nil, err
	}
	sharedSecret, _ = curve25519.X25519(tweak, sharedSecret)

	salt := make([]byte, 0, len(publicKey)+len(i.ourPublicKey))
	salt = append(salt, publicKey...)
	salt = append(salt, i.ourPublicKey...)
	h := hkdf.New(sha256.New, sharedSecret, salt, []byte(ed25519Label))
	wrappingKey := make([]byte, chacha20poly1305.KeySize)
	if _, err := io.ReadFull(h, wrappingKey); err != nil {
		return nil, err
	}

	fileKey, err := aeadDecrypt(wrappingKey, block.Body)
	if err != nil {
		return nil, fmt.Errorf("failed to decrypt file key: %v", err)
	}
	return fileKey, nil
}

// multiUnwrap is copied from package age. It's a helper that implements
// Identity.Unwrap in terms of a function that unwraps a single recipient
// stanza.
func multiUnwrap(unwrap func(*age.Stanza) ([]byte, error), stanzas []*age.Stanza) ([]byte, error) {
	for _, s := range stanzas {
		fileKey, err := unwrap(s)
		if errors.Is(err, age.ErrIncorrectIdentity) {
			// If we ever start returning something interesting wrapping
			// ErrIncorrectIdentity, we should let it make its way up through
			// Decrypt into NoIdentityMatchError.Errors.
			continue
		}
		if err != nil {
			return nil, err
		}
		return fileKey, nil
	}
	return nil, age.ErrIncorrectIdentity
}

// aeadEncrypt and aeadDecrypt are copied from package age.
//
// They don't limit the file key size because multi-key attacks are irrelevant
// against the ssh-ed25519 recipient. Being an asymmetric recipient, it would
// only allow a more efficient search for accepted public keys against a
// decryption oracle, but the ssh-X recipients are not anonymous (they have a
// short recipient hash).

func aeadEncrypt(key, plaintext []byte) ([]byte, error) {
	aead, err := chacha20poly1305.New(key)
	if err != nil {
		return nil, err
	}
	nonce := make([]byte, chacha20poly1305.NonceSize)
	return aead.Seal(nil, nonce, plaintext, nil), nil
}

func aeadDecrypt(key, ciphertext []byte) ([]byte, error) {
	aead, err := chacha20poly1305.New(key)
	if err != nil {
		return nil, err
	}
	nonce := make([]byte, chacha20poly1305.NonceSize)
	return aead.Open(nil, nonce, ciphertext, nil)
}


================================================
FILE: agessh/agessh_test.go
================================================
// Copyright 2019 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package agessh_test

import (
	"bytes"
	"crypto/ed25519"
	"crypto/rand"
	"crypto/rsa"
	"reflect"
	"testing"

	"filippo.io/age/agessh"
	"golang.org/x/crypto/ssh"
)

func TestSSHRSARoundTrip(t *testing.T) {
	pk, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		t.Fatal(err)
	}
	pub, err := ssh.NewPublicKey(&pk.PublicKey)
	if err != nil {
		t.Fatal(err)
	}

	r, err := agessh.NewRSARecipient(pub)
	if err != nil {
		t.Fatal(err)
	}
	i, err := agessh.NewRSAIdentity(pk)
	if err != nil {
		t.Fatal(err)
	}

	// TODO: replace this with (and go-diff) with go-cmp.
	if !reflect.DeepEqual(r, i.Recipient()) {
		t.Fatalf("i.Recipient is different from r")
	}

	fileKey := make([]byte, 16)
	if _, err := rand.Read(fileKey); err != nil {
		t.Fatal(err)
	}
	stanzas, err := r.Wrap(fileKey)
	if err != nil {
		t.Fatal(err)
	}

	out, err := i.Unwrap(stanzas)
	if err != nil {
		t.Fatal(err)
	}

	if !bytes.Equal(fileKey, out) {
		t.Errorf("invalid output: %x, expected %x", out, fileKey)
	}
}

func TestSSHEd25519RoundTrip(t *testing.T) {
	pub, priv, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		t.Fatal(err)
	}
	sshPubKey, err := ssh.NewPublicKey(pub)
	if err != nil {
		t.Fatal(err)
	}

	r, err := agessh.NewEd25519Recipient(sshPubKey)
	if err != nil {
		t.Fatal(err)
	}
	i, err := agessh.NewEd25519Identity(priv)
	if err != nil {
		t.Fatal(err)
	}

	// TODO: replace this with (and go-diff) with go-cmp.
	if !reflect.DeepEqual(r, i.Recipient()) {
		t.Fatalf("i.Recipient is different from r")
	}

	fileKey := make([]byte, 16)
	if _, err := rand.Read(fileKey); err != nil {
		t.Fatal(err)
	}
	stanzas, err := r.Wrap(fileKey)
	if err != nil {
		t.Fatal(err)
	}

	out, err := i.Unwrap(stanzas)
	if err != nil {
		t.Fatal(err)
	}

	if !bytes.Equal(fileKey, out) {
		t.Errorf("invalid output: %x, expected %x", out, fileKey)
	}
}


================================================
FILE: agessh/encrypted_keys.go
================================================
// Copyright 2019 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package agessh

import (
	"crypto"
	"crypto/ed25519"
	"crypto/rsa"
	"fmt"

	"filippo.io/age"
	"golang.org/x/crypto/ssh"
)

// EncryptedSSHIdentity is an age.Identity implementation based on a passphrase
// encrypted SSH private key.
//
// It requests the passphrase only if the public key matches a recipient stanza.
// If the application knows it will always have to decrypt the private key, it
// would be simpler to use ssh.ParseRawPrivateKeyWithPassphrase directly and
// pass the result to NewEd25519Identity or NewRSAIdentity.
type EncryptedSSHIdentity struct {
	pubKey     ssh.PublicKey
	recipient  age.Recipient
	pemBytes   []byte
	passphrase func() ([]byte, error)

	decrypted age.Identity
}

// NewEncryptedSSHIdentity returns a new EncryptedSSHIdentity.
//
// pubKey must be the public key associated with the encrypted private key, and
// it must have type "ssh-ed25519" or "ssh-rsa". For OpenSSH encrypted files it
// can be extracted from an ssh.PassphraseMissingError, otherwise it can often
// be found in ".pub" files.
//
// pemBytes must be a valid input to ssh.ParseRawPrivateKeyWithPassphrase.
// passphrase is a callback that will be invoked by Unwrap when the passphrase
// is necessary.
func NewEncryptedSSHIdentity(pubKey ssh.PublicKey, pemBytes []byte, passphrase func() ([]byte, error)) (*EncryptedSSHIdentity, error) {
	i := &EncryptedSSHIdentity{
		pubKey:     pubKey,
		pemBytes:   pemBytes,
		passphrase: passphrase,
	}
	switch t := pubKey.Type(); t {
	case "ssh-ed25519":
		r, err := NewEd25519Recipient(pubKey)
		if err != nil {
			return nil, err
		}
		i.recipient = r
	case "ssh-rsa":
		r, err := NewRSARecipient(pubKey)
		if err != nil {
			return nil, err
		}
		i.recipient = r
	default:
		return nil, fmt.Errorf("unsupported SSH key type: %v", t)
	}
	return i, nil
}

var _ age.Identity = &EncryptedSSHIdentity{}

func (i *EncryptedSSHIdentity) Recipient() age.Recipient {
	return i.recipient
}

// Unwrap implements age.Identity. If the private key is still encrypted, and
// any of the stanzas match the public key, it will request the passphrase. The
// decrypted private key will be cached after the first successful invocation.
func (i *EncryptedSSHIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
	if i.decrypted != nil {
		return i.decrypted.Unwrap(stanzas)
	}

	var match bool
	for _, s := range stanzas {
		if s.Type != i.pubKey.Type() {
			continue
		}
		if len(s.Args) < 1 {
			return nil, fmt.Errorf("invalid %v recipient block", i.pubKey.Type())
		}
		if s.Args[0] != sshFingerprint(i.pubKey) {
			continue
		}
		match = true
		break
	}
	if !match {
		return nil, age.ErrIncorrectIdentity
	}

	passphrase, err := i.passphrase()
	if err != nil {
		return nil, fmt.Errorf("failed to obtain passphrase: %v", err)
	}
	k, err := ssh.ParseRawPrivateKeyWithPassphrase(i.pemBytes, passphrase)
	if err != nil {
		return nil, fmt.Errorf("failed to decrypt SSH key file: %v", err)
	}

	var pubKey interface {
		Equal(x crypto.PublicKey) bool
	}
	switch k := k.(type) {
	case *ed25519.PrivateKey:
		i.decrypted, err = NewEd25519Identity(*k)
		pubKey = k.Public().(ed25519.PublicKey)
	// ParseRawPrivateKey returns inconsistent types. See Issue 429.
	case ed25519.PrivateKey:
		i.decrypted, err = NewEd25519Identity(k)
		pubKey = k.Public().(ed25519.PublicKey)
	case *rsa.PrivateKey:
		i.decrypted, err = NewRSAIdentity(k)
		pubKey = &k.PublicKey
	default:
		return nil, fmt.Errorf("unexpected SSH key type: %T", k)
	}
	if err != nil {
		return nil, fmt.Errorf("invalid SSH key: %v", err)
	}

	if exp := i.pubKey.(ssh.CryptoPublicKey).CryptoPublicKey(); !pubKey.Equal(exp) {
		return nil, fmt.Errorf("mismatched private and public SSH key")
	}

	return i.decrypted.Unwrap(stanzas)
}


================================================
FILE: armor/armor.go
================================================
// Copyright 2019 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// Package armor provides a strict, streaming implementation of the ASCII
// armoring format for age files.
//
// It's PEM with type "AGE ENCRYPTED FILE", 64 character columns, no headers,
// and strict base64 decoding.
package armor

import (
	"bufio"
	"bytes"
	"encoding/base64"
	"errors"
	"fmt"
	"io"

	"filippo.io/age/internal/format"
)

const (
	Header = "-----BEGIN AGE ENCRYPTED FILE-----"
	Footer = "-----END AGE ENCRYPTED FILE-----"
)

type armoredWriter struct {
	started, closed bool
	encoder         *format.WrappedBase64Encoder
	dst             io.Writer
}

func (a *armoredWriter) Write(p []byte) (int, error) {
	if !a.started {
		if _, err := io.WriteString(a.dst, Header+"\n"); err != nil {
			return 0, err
		}
	}
	a.started = true
	return a.encoder.Write(p)
}

func (a *armoredWriter) Close() error {
	if a.closed {
		return errors.New("ArmoredWriter already closed")
	}
	a.closed = true
	if err := a.encoder.Close(); err != nil {
		return err
	}
	footer := Footer + "\n"
	if !a.encoder.LastLineIsEmpty() {
		footer = "\n" + footer
	}
	_, err := io.WriteString(a.dst, footer)
	return err
}

func NewWriter(dst io.Writer) io.WriteCloser {
	// TODO: write a test with aligned and misaligned sizes, and 8 and 10 steps.
	return &armoredWriter{
		dst:     dst,
		encoder: format.NewWrappedBase64Encoder(base64.StdEncoding, dst),
	}
}

type armoredReader struct {
	r       *bufio.Reader
	started bool
	unread  []byte // backed by buf
	buf     [format.BytesPerLine]byte
	err     error
}

func NewReader(r io.Reader) io.Reader {
	return &armoredReader{r: bufio.NewReader(r)}
}

func (r *armoredReader) Read(p []byte) (int, error) {
	if len(r.unread) > 0 {
		n := copy(p, r.unread)
		r.unread = r.unread[n:]
		return n, nil
	}
	if r.err != nil {
		return 0, r.err
	}

	getLine := func() ([]byte, error) {
		line, err := r.r.ReadBytes('\n')
		if err == io.EOF && len(line) == 0 {
			return nil, io.ErrUnexpectedEOF
		} else if err != nil && err != io.EOF {
			return nil, err
		}
		line = bytes.TrimSuffix(line, []byte("\n"))
		line = bytes.TrimSuffix(line, []byte("\r"))
		return line, nil
	}

	const maxWhitespace = 1024
	drainTrailing := func() error {
		buf, err := io.ReadAll(io.LimitReader(r.r, maxWhitespace))
		if err != nil {
			return err
		}
		if len(bytes.TrimSpace(buf)) != 0 {
			return errors.New("trailing data after armored file")
		}
		if len(buf) == maxWhitespace {
			return errors.New("too much trailing whitespace")
		}
		return io.EOF
	}

	var removedWhitespace int
	for !r.started {
		line, err := getLine()
		if err != nil {
			return 0, r.setErr(err)
		}
		// Ignore leading whitespace.
		if len(bytes.TrimSpace(line)) == 0 {
			removedWhitespace += len(line) + 1
			if removedWhitespace > maxWhitespace {
				return 0, r.setErr(errors.New("too much leading whitespace"))
			}
			continue
		}
		if string(line) != Header {
			return 0, r.setErr(fmt.Errorf("invalid first line: %q", line))
		}
		r.started = true
	}
	line, err := getLine()
	if err != nil {
		return 0, r.setErr(err)
	}
	if string(line) == Footer {
		return 0, r.setErr(drainTrailing())
	}
	if len(line) == 0 {
		return 0, r.setErr(errors.New("empty line in armored data"))
	}
	if len(line) > format.ColumnsPerLine {
		return 0, r.setErr(errors.New("column limit exceeded"))
	}
	r.unread = r.buf[:]
	n, err := base64.StdEncoding.Strict().Decode(r.unread, line)
	if err != nil {
		return 0, r.setErr(err)
	}
	r.unread = r.unread[:n]

	if n < format.BytesPerLine {
		line, err := getLine()
		if err != nil {
			return 0, r.setErr(err)
		}
		if string(line) != Footer {
			return 0, r.setErr(fmt.Errorf("invalid closing line: %q", line))
		}
		r.setErr(drainTrailing())
	}

	nn := copy(p, r.unread)
	r.unread = r.unread[nn:]
	return nn, nil
}

type Error struct {
	err error
}

func (e *Error) Error() string {
	return "invalid armor: " + e.err.Error()
}

func (e *Error) Unwrap() error {
	return e.err
}

func (r *armoredReader) setErr(err error) error {
	if err != io.EOF {
		err = &Error{err}
	}
	r.err = err
	return err
}


================================================
FILE: armor/armor_test.go
================================================
// Copyright 2019 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

//go:build go1.18

package armor_test

import (
	"bytes"
	"crypto/rand"
	"encoding/pem"
	"fmt"
	"io"
	"log"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"filippo.io/age"
	"filippo.io/age/armor"
	"filippo.io/age/internal/format"
)

func ExampleNewWriter() {
	publicKey := "age1cy0su9fwf3gf9mw868g5yut09p6nytfmmnktexz2ya5uqg9vl9sss4euqm"
	recipient, err := age.ParseX25519Recipient(publicKey)
	if err != nil {
		log.Fatalf("Failed to parse public key %q: %v", publicKey, err)
	}

	buf := &bytes.Buffer{}
	armorWriter := armor.NewWriter(buf)

	w, err := age.Encrypt(armorWriter, recipient)
	if err != nil {
		log.Fatalf("Failed to create encrypted file: %v", err)
	}
	if _, err := io.WriteString(w, "Black lives matter."); err != nil {
		log.Fatalf("Failed to write to encrypted file: %v", err)
	}
	if err := w.Close(); err != nil {
		log.Fatalf("Failed to close encrypted file: %v", err)
	}

	if err := armorWriter.Close(); err != nil {
		log.Fatalf("Failed to close armor: %v", err)
	}

	fmt.Printf("%s[...]", buf.Bytes()[:35])
	// Output:
	// -----BEGIN AGE ENCRYPTED FILE-----
	// [...]
}

var privateKey = "AGE-SECRET-KEY-184JMZMVQH3E6U0PSL869004Y3U2NYV7R30EU99CSEDNPH02YUVFSZW44VU"

func ExampleNewReader() {
	fileContents := `-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4YWdhZHZ0WG1PZldDT1hD
K3RPRzFkUlJnWlFBQlUwemtjeXFRMFp6V1VFCnRzZFV3a3Vkd1dSUWw2eEtrRkVv
SHcvZnp6Q3lqLy9HMkM4ZjUyUGdDZjQKLS0tIDlpVUpuVUQ5YUJyUENFZ0lNSTB2
ekUvS3E5WjVUN0F5ZWR1ejhpeU5rZUUKsvPGYt7vf0o1kyJ1eVFMz1e4JnYYk1y1
kB/RRusYjn+KVJ+KTioxj0THtzZPXcjFKuQ1
-----END AGE ENCRYPTED FILE-----`

	// DO NOT hardcode the private key. Store it in a secret storage solution,
	// on disk if the local machine is trusted, or have the user provide it.
	identity, err := age.ParseX25519Identity(privateKey)
	if err != nil {
		log.Fatalf("Failed to parse private key %q: %v", privateKey, err)
	}

	out := &bytes.Buffer{}
	f := strings.NewReader(fileContents)
	armorReader := armor.NewReader(f)

	r, err := age.Decrypt(armorReader, identity)
	if err != nil {
		log.Fatalf("Failed to open encrypted file: %v", err)
	}
	if _, err := io.Copy(out, r); err != nil {
		log.Fatalf("Failed to read encrypted file: %v", err)
	}

	fmt.Printf("File contents: %q\n", out.Bytes())
	// Output:
	// File contents: "Black lives matter."
}

func TestArmor(t *testing.T) {
	t.Run("PartialLine", func(t *testing.T) { testArmor(t, 611) })
	t.Run("FullLine", func(t *testing.T) { testArmor(t, 10*format.BytesPerLine) })
}

func testArmor(t *testing.T, size int) {
	buf := &bytes.Buffer{}
	w := armor.NewWriter(buf)
	plain := make([]byte, size)
	rand.Read(plain)
	if _, err := w.Write(plain); err != nil {
		t.Fatal(err)
	}
	if err := w.Close(); err != nil {
		t.Fatal(err)
	}

	block, _ := pem.Decode(buf.Bytes())
	if block == nil {
		t.Fatal("PEM decoding failed")
	}
	if len(block.Headers) != 0 {
		t.Error("unexpected headers")
	}
	if block.Type != "AGE ENCRYPTED FILE" {
		t.Errorf("unexpected type %q", block.Type)
	}
	if !bytes.Equal(block.Bytes, plain) {
		t.Error("PEM decoded value doesn't match")
	}
	if !bytes.Equal(buf.Bytes(), pem.EncodeToMemory(block)) {
		t.Error("PEM re-encoded value doesn't match")
	}

	r := armor.NewReader(buf)
	out, err := io.ReadAll(r)
	if err != nil {
		t.Fatal(err)
	}
	if !bytes.Equal(out, plain) {
		t.Error("decoded value doesn't match")
	}
}

func FuzzMalleability(f *testing.F) {
	tests, err := filepath.Glob("../testdata/testkit/*")
	if err != nil {
		f.Fatal(err)
	}
	for _, test := range tests {
		contents, err := os.ReadFile(test)
		if err != nil {
			f.Fatal(err)
		}
		header, contents, ok := bytes.Cut(contents, []byte("\n\n"))
		if !ok {
			f.Fatal("testkit file without header")
		}
		if bytes.Contains(header, []byte("armored: yes")) {
			f.Add(contents)
		}
	}
	f.Fuzz(func(t *testing.T, data []byte) {
		r := armor.NewReader(bytes.NewReader(data))
		content, err := io.ReadAll(r)
		if err != nil {
			if _, ok := err.(*armor.Error); !ok {
				t.Errorf("error type is %T: %v", err, err)
			}
			t.Skip()
		}
		buf := &bytes.Buffer{}
		w := armor.NewWriter(buf)
		if _, err := w.Write(content); err != nil {
			t.Fatal(err)
		}
		if err := w.Close(); err != nil {
			t.Fatal(err)
		}
		if !bytes.Equal(normalize(buf.Bytes()), normalize(data)) {
			t.Error("re-encoded output different from input")
		}
	})
}

func normalize(f []byte) []byte {
	f = bytes.TrimSpace(f)
	f = bytes.Replace(f, []byte("\r\n"), []byte("\n"), -1)
	return f
}


================================================
FILE: cmd/age/age.go
================================================
// Copyright 2019 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
	"bufio"
	"bytes"
	"errors"
	"flag"
	"fmt"
	"io"
	"iter"
	"os"
	"path/filepath"
	"regexp"
	"runtime/debug"
	"slices"
	"strings"
	"unicode"

	"filippo.io/age"
	"filippo.io/age/agessh"
	"filippo.io/age/armor"
	"filippo.io/age/internal/term"
	"filippo.io/age/plugin"
)

const usage = `Usage:
    age [--encrypt] (-r RECIPIENT | -R PATH)... [--armor] [-o OUTPUT] [INPUT]
    age [--encrypt] --passphrase [--armor] [-o OUTPUT] [INPUT]
    age --decrypt [-i PATH]... [-o OUTPUT] [INPUT]

Options:
    -e, --encrypt               Encrypt the input to the output. Default if omitted.
    -d, --decrypt               Decrypt the input to the output.
    -o, --output OUTPUT         Write the result to the file at path OUTPUT.
    -a, --armor                 Encrypt to a PEM encoded format.
    -p, --passphrase            Encrypt with a passphrase.
    -r, --recipient RECIPIENT   Encrypt to the specified RECIPIENT. Can be repeated.
    -R, --recipients-file PATH  Encrypt to recipients listed at PATH. Can be repeated.
    -i, --identity PATH         Use the identity file at PATH. Can be repeated.

INPUT defaults to standard input, and OUTPUT defaults to standard output.
If OUTPUT exists, it will be overwritten.

RECIPIENT can be an age public key generated by age-keygen ("age1...")
or an SSH public key ("ssh-ed25519 AAAA...", "ssh-rsa AAAA...").

Recipient files contain one or more recipients, one per line. Empty lines
and lines starting with "#" are ignored as comments. "-" may be used to
read recipients from standard input.

Identity files contain one or more secret keys ("AGE-SECRET-KEY-1..."),
one per line, or an SSH key. Empty lines and lines starting with "#" are
ignored as comments. Passphrase encrypted age files can be used as
identity files. Multiple key files can be provided, and any unused ones
will be ignored. "-" may be used to read identities from standard input.

When --encrypt is specified explicitly, -i can also be used to encrypt to an
identity file symmetrically, instead or in addition to normal recipients.

Example:
    $ age-keygen -o key.txt
    Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
    $ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age
    $ age --decrypt -i key.txt -o data.tar.gz data.tar.gz.age`

// stdinInUse is used to ensure only one of input, recipients, or identities
// file is read from stdin. It's a singleton like os.Stdin.
var stdinInUse bool

type multiFlag []string

func (f *multiFlag) String() string { return fmt.Sprint(*f) }

func (f *multiFlag) Set(value string) error {
	*f = append(*f, value)
	return nil
}

type identityFlag struct {
	Type, Value string
}

// identityFlags tracks -i and -j flags, preserving their relative order, so
// that "age -d -j agent -i encrypted-fallback-keys.age" behaves as expected.
type identityFlags []identityFlag

func (f *identityFlags) addIdentityFlag(value string) error {
	*f = append(*f, identityFlag{Type: "i", Value: value})
	return nil
}

func (f *identityFlags) addPluginFlag(value string) error {
	*f = append(*f, identityFlag{Type: "j", Value: value})
	return nil
}

// Version can be set at link time to override debug.BuildInfo.Main.Version when
// building manually without git history. It should look like "v1.2.3".
var Version string

func main() {
	flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }

	if len(os.Args) == 1 {
		flag.Usage()
		os.Exit(1)
	}

	var (
		outFlag                          string
		decryptFlag, encryptFlag         bool
		passFlag, versionFlag, armorFlag bool
		recipientFlags                   multiFlag
		recipientsFileFlags              multiFlag
		identityFlags                    identityFlags
	)

	flag.BoolVar(&versionFlag, "version", false, "print the version")
	flag.BoolVar(&decryptFlag, "d", false, "decrypt the input")
	flag.BoolVar(&decryptFlag, "decrypt", false, "decrypt the input")
	flag.BoolVar(&encryptFlag, "e", false, "encrypt the input")
	flag.BoolVar(&encryptFlag, "encrypt", false, "encrypt the input")
	flag.BoolVar(&passFlag, "p", false, "use a passphrase")
	flag.BoolVar(&passFlag, "passphrase", false, "use a passphrase")
	flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)")
	flag.StringVar(&outFlag, "output", "", "output to `FILE` (default stdout)")
	flag.BoolVar(&armorFlag, "a", false, "generate an armored file")
	flag.BoolVar(&armorFlag, "armor", false, "generate an armored file")
	flag.Var(&recipientFlags, "r", "recipient (can be repeated)")
	flag.Var(&recipientFlags, "recipient", "recipient (can be repeated)")
	flag.Var(&recipientsFileFlags, "R", "recipients file (can be repeated)")
	flag.Var(&recipientsFileFlags, "recipients-file", "recipients file (can be repeated)")
	flag.Func("i", "identity (can be repeated)", identityFlags.addIdentityFlag)
	flag.Func("identity", "identity (can be repeated)", identityFlags.addIdentityFlag)
	flag.Func("j", "data-less plugin (can be repeated)", identityFlags.addPluginFlag)
	flag.Parse()

	if versionFlag {
		if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
			Version = buildInfo.Main.Version
		}
		fmt.Println(Version)
		return
	}

	if flag.NArg() > 1 {
		var hints []string
		quotedArgs := strings.Trim(fmt.Sprintf("%q", flag.Args()), "[]")

		// If the second argument looks like a flag, suggest moving the first
		// argument to the back (as long as the arguments don't need quoting).
		if strings.HasPrefix(flag.Arg(1), "-") {
			hints = append(hints, "the input file must be specified after all flags")

			safe := true
			unsafeShell := regexp.MustCompile(`[^\w@%+=:,./-]`)
			if slices.ContainsFunc(os.Args, unsafeShell.MatchString) {
				safe = false
			}
			if safe {
				i := len(os.Args) - flag.NArg()
				newArgs := append([]string{}, os.Args[:i]...)
				newArgs = append(newArgs, os.Args[i+1:]...)
				newArgs = append(newArgs, os.Args[i])
				hints = append(hints, "did you mean:")
				hints = append(hints, "    "+strings.Join(newArgs, " "))
			}
		} else {
			hints = append(hints, "only a single input file may be specified at a time")
		}

		errorWithHint("too many INPUT arguments: "+quotedArgs, hints...)
	}

	switch {
	case decryptFlag:
		if encryptFlag {
			errorf("-e/--encrypt can't be used with -d/--decrypt")
		}
		if armorFlag {
			errorWithHint("-a/--armor can't be used with -d/--decrypt",
				"note that armored files are detected automatically, try again without -a/--armor")
		}
		if passFlag {
			errorWithHint("-p/--passphrase can't be used with -d/--decrypt",
				"note that password protected files are detected automatically")
		}
		if len(recipientFlags) > 0 {
			errorWithHint("-r/--recipient can't be used with -d/--decrypt",
				"did you mean to use -i/--identity to specify a private key?")
		}
		if len(recipientsFileFlags) > 0 {
			errorWithHint("-R/--recipients-file can't be used with -d/--decrypt",
				"did you mean to use -i/--identity to specify a private key?")
		}
	default: // encrypt
		if len(identityFlags) > 0 && !encryptFlag {
			errorWithHint("-i/--identity and -j can't be used in encryption mode unless symmetric encryption is explicitly selected with -e/--encrypt",
				"did you forget to specify -d/--decrypt?")
		}
		if len(recipientFlags)+len(recipientsFileFlags)+len(identityFlags) == 0 && !passFlag {
			errorWithHint("missing recipients",
				"did you forget to specify -r/--recipient, -R/--recipients-file or -p/--passphrase?")
		}
		if len(recipientFlags) > 0 && passFlag {
			errorf("-p/--passphrase can't be combined with -r/--recipient")
		}
		if len(recipientsFileFlags) > 0 && passFlag {
			errorf("-p/--passphrase can't be combined with -R/--recipients-file")
		}
		if len(identityFlags) > 0 && passFlag {
			errorf("-p/--passphrase can't be combined with -i/--identity and -j")
		}
	}

	warnDuplicates(slices.Values(recipientFlags), "recipient")
	warnDuplicates(slices.Values(recipientsFileFlags), "recipients file")
	warnDuplicates(func(yield func(string) bool) {
		for _, f := range identityFlags {
			if f.Type == "i" && !yield(f.Value) {
				return
			}
		}
	}, "identity file")

	var inUseFiles []string
	for _, i := range identityFlags {
		if i.Type != "i" {
			continue
		}
		inUseFiles = append(inUseFiles, absPath(i.Value))
	}
	for _, f := range recipientsFileFlags {
		inUseFiles = append(inUseFiles, absPath(f))
	}

	var in io.Reader = os.Stdin
	var out io.Writer = os.Stdout
	if name := flag.Arg(0); name != "" && name != "-" {
		inUseFiles = append(inUseFiles, absPath(name))
		f, err := os.Open(name)
		if err != nil {
			errorf("failed to open input file %q: %v", name, err)
		}
		defer f.Close()
		in = f
	} else {
		stdinInUse = true
		if decryptFlag && term.IsTerminal(os.Stdin) {
			// If the input comes from a TTY, assume it's armored, and buffer up
			// to the END line (or EOF/EOT) so that a password prompt or the
			// output don't get in the way of typing the input. See Issue 364.
			buf, err := bufferTerminalInput(in)
			if err != nil {
				errorf("failed to buffer terminal input: %v", err)
			}
			in = buf
		}
	}
	if name := outFlag; name != "" && name != "-" {
		for _, f := range inUseFiles {
			if f == absPath(name) {
				errorf("input and output file are the same: %q", name)
			}
		}
		f := newLazyOpener(name)
		defer func() {
			if err := f.Close(); err != nil {
				errorf("failed to close output file %q: %v", name, err)
			}
		}()
		out = f
	} else if term.IsTerminal(os.Stdout) {
		buf := &bytes.Buffer{}
		defer func() {
			if out == buf {
				io.Copy(os.Stdout, buf)
			}
		}()
		if name != "-" {
			if decryptFlag {
				// Buffer the output to check it's printable.
				out = buf
				defer func() {
					if bytes.ContainsFunc(buf.Bytes(), func(r rune) bool {
						return r != '\n' && r != '\r' && r != '\t' && unicode.IsControl(r)
					}) {
						errorWithHint("refusing to output binary to the terminal",
							`force anyway with "-o -"`)
					}
				}()
			} else if !armorFlag {
				// If the output wouldn't be armored, refuse to send binary to
				// the terminal unless explicitly requested with "-o -".
				errorWithHint("refusing to output binary to the terminal",
					"did you mean to use -a/--armor?",
					`force anyway with "-o -"`)
			}
		}
		if in == os.Stdin && term.IsTerminal(os.Stdin) {
			// If the input comes from a TTY and output will go to a TTY,
			// buffer it up so it doesn't get in the way of typing the input.
			out = buf
		}
	}

	switch {
	case decryptFlag && len(identityFlags) == 0:
		decryptPass(in, out)
	case decryptFlag:
		decryptNotPass(identityFlags, in, out)
	case passFlag:
		encryptPass(in, out, armorFlag)
	default:
		encryptNotPass(recipientFlags, recipientsFileFlags, identityFlags, in, out, armorFlag)
	}
}

func passphrasePromptForEncryption() (string, error) {
	pass, err := term.ReadSecret("Enter passphrase (leave empty to autogenerate a secure one):")
	if err != nil {
		return "", fmt.Errorf("could not read passphrase: %v", err)
	}
	p := string(pass)
	if p == "" {
		var words []string
		for range 10 {
			words = append(words, randomWord())
		}
		p = strings.Join(words, "-")
		err := printfToTerminal("using autogenerated passphrase %q", p)
		if err != nil {
			return "", fmt.Errorf("could not print passphrase: %v", err)
		}
	} else {
		confirm, err := term.ReadSecret("Confirm passphrase:")
		if err != nil {
			return "", fmt.Errorf("could not read passphrase: %v", err)
		}
		if string(confirm) != p {
			return "", fmt.Errorf("passphrases didn't match")
		}
	}
	return p, nil
}

func encryptNotPass(recs, files []string, identities identityFlags, in io.Reader, out io.Writer, armor bool) {
	var recipients []age.Recipient
	for _, arg := range recs {
		r, err := parseRecipient(arg)
		if err, ok := err.(gitHubRecipientError); ok {
			errorWithHint(err.Error(), "instead, use recipient files like",
				"    curl -O https://github.com/"+err.username+".keys",
				"    age -R "+err.username+".keys")
		}
		if err != nil {
			errorf("%v", err)
		}
		recipients = append(recipients, r)
	}
	for _, name := range files {
		recs, err := parseRecipientsFile(name)
		if err != nil {
			errorf("failed to parse recipient file %q: %v", name, err)
		}
		recipients = append(recipients, recs...)
	}
	for _, f := range identities {
		switch f.Type {
		case "i":
			ids, err := parseIdentitiesFile(f.Value)
			if err != nil {
				errorf("reading %q: %v", f.Value, err)
			}
			r, err := identitiesToRecipients(ids)
			if err != nil {
				errorf("internal error processing %q: %v", f.Value, err)
			}
			recipients = append(recipients, r...)
		case "j":
			id, err := plugin.NewIdentityWithoutData(f.Value, plugin.NewTerminalUI(printf, warningf))
			if err != nil {
				errorf("initializing %q: %v", f.Value, err)
			}
			recipients = append(recipients, id.Recipient())
		}
	}
	encrypt(recipients, in, out, armor)
}

func encryptPass(in io.Reader, out io.Writer, armor bool) {
	pass, err := passphrasePromptForEncryption()
	if err != nil {
		errorf("%v", err)
	}

	r, err := age.NewScryptRecipient(pass)
	if err != nil {
		errorf("%v", err)
	}
	testOnlyConfigureScryptIdentity(r)
	encrypt([]age.Recipient{r}, in, out, armor)
}

var testOnlyConfigureScryptIdentity = func(*age.ScryptRecipient) {}

func encrypt(recipients []age.Recipient, in io.Reader, out io.Writer, withArmor bool) {
	if withArmor {
		a := armor.NewWriter(out)
		defer func() {
			if err := a.Close(); err != nil {
				errorf("%v", err)
			}
		}()
		out = a
	}
	w, err := age.Encrypt(out, recipients...)
	if e := new(plugin.NotFoundError); errors.As(err, &e) {
		errorWithHint(err.Error(),
			fmt.Sprintf("you might want to install the %q plugin", e.Name),
			"visit https://age-encryption.org/awesome#plugins for a list of available plugins")
	} else if err != nil {
		errorf("%v", err)
	}
	if _, err := io.Copy(w, in); err != nil {
		errorf("%v", err)
	}
	if err := w.Close(); err != nil {
		errorf("%v", err)
	}
}

// crlfMangledIntro and utf16MangledIntro are the intro lines of the age format
// after mangling by various versions of PowerShell redirection, truncated to
// the length of the correct intro line. See issue 290.
const crlfMangledIntro = "age-encryption.org/v1" + "\r"
const utf16MangledIntro = "\xff\xfe" + "a\x00g\x00e\x00-\x00e\x00n\x00c\x00r\x00y\x00p\x00"

type rejectScryptIdentity struct{}

func (rejectScryptIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
	if len(stanzas) != 1 || stanzas[0].Type != "scrypt" {
		return nil, age.ErrIncorrectIdentity
	}
	errorWithHint("file is passphrase-encrypted but identities were specified with -i/--identity or -j",
		"remove all -i/--identity/-j flags to decrypt passphrase-encrypted files")
	panic("unreachable")
}

func decryptNotPass(flags identityFlags, in io.Reader, out io.Writer) {
	var identities []age.Identity
	for _, f := range flags {
		switch f.Type {
		case "i":
			ids, err := parseIdentitiesFile(f.Value)
			if err != nil {
				errorf("reading %q: %v", f.Value, err)
			}
			identities = append(identities, ids...)
		case "j":
			id, err := plugin.NewIdentityWithoutData(f.Value, plugin.NewTerminalUI(printf, warningf))
			if err != nil {
				errorf("initializing %q: %v", f.Value, err)
			}
			identities = append(identities, id)
		}
	}
	identities = append(identities, rejectScryptIdentity{})
	decrypt(identities, in, out)
}

func decryptPass(in io.Reader, out io.Writer) {
	identities := []age.Identity{
		// If there is an scrypt recipient (it will have to be the only one and)
		// this identity will be invoked.
		lazyScryptIdentity,
	}

	decrypt(identities, in, out)
}

func decrypt(identities []age.Identity, in io.Reader, out io.Writer) {
	rr := bufio.NewReader(in)
	if intro, _ := rr.Peek(len(crlfMangledIntro)); string(intro) == crlfMangledIntro ||
		string(intro) == utf16MangledIntro {
		errorWithHint("invalid header intro",
			"it looks like this file was corrupted by PowerShell redirection",
			"consider using -o or -a to encrypt files in PowerShell")
	}

	const maxWhitespace = 1024
	start, _ := rr.Peek(maxWhitespace + len(armor.Header))
	if strings.HasPrefix(string(bytes.TrimSpace(start)), armor.Header) {
		in = armor.NewReader(rr)
	} else {
		in = rr
	}

	r, err := age.Decrypt(in, identities...)
	if e := new(plugin.NotFoundError); errors.As(err, &e) {
		errorWithHint(err.Error(),
			fmt.Sprintf("you might want to install the %q plugin", e.Name),
			"visit https://age-encryption.org/awesome#plugins for a list of available plugins")
	} else if errors.As(err, new(*age.NoIdentityMatchError)) &&
		len(identities) == 1 && identities[0] == lazyScryptIdentity {
		errorWithHint("the file is not passphrase-encrypted, identities are required",
			"specify identities with -i/--identity or -j to decrypt this file")
	} else if err != nil {
		errorf("%v", err)
	}
	out.Write(nil) // trigger the lazyOpener even if r is empty
	if _, err := io.Copy(out, r); err != nil {
		errorf("%v", err)
	}
}

var lazyScryptIdentity = &LazyScryptIdentity{passphrasePromptForDecryption}

func passphrasePromptForDecryption() (string, error) {
	pass, err := term.ReadSecret("Enter passphrase:")
	if err != nil {
		return "", fmt.Errorf("could not read passphrase: %v", err)
	}
	return string(pass), nil
}

func identitiesToRecipients(ids []age.Identity) ([]age.Recipient, error) {
	var recipients []age.Recipient
	for _, id := range ids {
		switch id := id.(type) {
		case *age.X25519Identity:
			recipients = append(recipients, id.Recipient())
		case *age.HybridIdentity:
			recipients = append(recipients, id.Recipient())
		case *plugin.Identity:
			recipients = append(recipients, id.Recipient())
		case *agessh.RSAIdentity:
			recipients = append(recipients, id.Recipient())
		case *agessh.Ed25519Identity:
			recipients = append(recipients, id.Recipient())
		case *agessh.EncryptedSSHIdentity:
			recipients = append(recipients, id.Recipient())
		case *EncryptedIdentity:
			r, err := id.Recipients()
			if err != nil {
				return nil, err
			}
			recipients = append(recipients, r...)
		default:
			return nil, fmt.Errorf("unexpected identity type: %T", id)
		}
	}
	return recipients, nil
}

type lazyOpener struct {
	name string
	f    *os.File
	err  error
}

func newLazyOpener(name string) io.WriteCloser {
	return &lazyOpener{name: name}
}

func (l *lazyOpener) Write(p []byte) (n int, err error) {
	if l.f == nil && l.err == nil {
		l.f, l.err = os.Create(l.name)
	}
	if l.err != nil {
		return 0, l.err
	}
	return l.f.Write(p)
}

func (l *lazyOpener) Close() error {
	if l.f != nil {
		return l.f.Close()
	}
	return nil
}

func absPath(name string) string {
	if abs, err := filepath.Abs(name); err == nil {
		return abs
	}
	return name
}

func warnDuplicates(s iter.Seq[string], name string) {
	seen := make(map[string]bool)
	warned := make(map[string]bool)
	for e := range s {
		if seen[e] && !warned[e] {
			warningf("duplicate %s %q", name, e)
			warned[e] = true
		}
		seen[e] = true
	}
}


================================================
FILE: cmd/age/age_test.go
================================================
// Copyright 2019 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
	"os"
	"os/exec"
	"path/filepath"
	"sync"
	"testing"

	"filippo.io/age"
	"filippo.io/age/plugin"
	"github.com/rogpeppe/go-internal/testscript"
)

func TestMain(m *testing.M) {
	testscript.Main(m, map[string]func(){
		"age": func() {
			testOnlyConfigureScryptIdentity = func(r *age.ScryptRecipient) {
				r.SetWorkFactor(10)
			}
			testOnlyFixedRandomWord = "four"
			main()
		},
		"age-plugin-test": func() {
			p, _ := plugin.New("test")
			p.HandleRecipient(func(data []byte) (age.Recipient, error) {
				return testPlugin{}, nil
			})
			p.HandleIdentity(func(data []byte) (age.Identity, error) {
				return testPlugin{}, nil
			})
			os.Exit(p.Main())
		},
	})
}

type testPlugin struct{}

func (testPlugin) Wrap(fileKey []byte) ([]*age.Stanza, error) {
	return []*age.Stanza{{Type: "test", Body: fileKey}}, nil
}

func (testPlugin) Unwrap(ss []*age.Stanza) ([]byte, error) {
	if len(ss) == 1 && ss[0].Type == "test" {
		return ss[0].Body, nil
	}
	return nil, age.ErrIncorrectIdentity
}

var buildExtraCommands = sync.OnceValue(func() error {
	bindir := filepath.SplitList(os.Getenv("PATH"))[0]
	// Build age-keygen and age-plugin-pq into the test binary directory.
	cmd := exec.Command("go", "build", "-o", bindir)
	if testing.CoverMode() != "" {
		cmd.Args = append(cmd.Args, "-cover")
	}
	cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-keygen")
	cmd.Args = append(cmd.Args, "filippo.io/age/extra/age-plugin-pq")
	cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-plugin-batchpass")
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	return cmd.Run()
})

func TestScript(t *testing.T) {
	testscript.Run(t, testscript.Params{
		Dir: "testdata",
		Setup: func(e *testscript.Env) error {
			return buildExtraCommands()
		},
		// TODO: enable AGEDEBUG=plugin without breaking stderr checks.
	})
}


================================================
FILE: cmd/age/encrypted_keys.go
================================================
// Copyright 2019 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
	"bytes"
	"errors"
	"fmt"

	"filippo.io/age"
)

// LazyScryptIdentity is an age.Identity that requests a passphrase only if it
// encounters an scrypt stanza. After obtaining a passphrase, it delegates to
// ScryptIdentity.
type LazyScryptIdentity struct {
	Passphrase func() (string, error)
}

var _ age.Identity = &LazyScryptIdentity{}

func (i *LazyScryptIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
	for _, s := range stanzas {
		if s.Type == "scrypt" && len(stanzas) != 1 {
			return nil, errors.New("an scrypt recipient must be the only one")
		}
	}
	if len(stanzas) != 1 || stanzas[0].Type != "scrypt" {
		return nil, age.ErrIncorrectIdentity
	}
	pass, err := i.Passphrase()
	if err != nil {
		return nil, fmt.Errorf("could not read passphrase: %v", err)
	}
	ii, err := age.NewScryptIdentity(pass)
	if err != nil {
		return nil, err
	}
	fileKey, err = ii.Unwrap(stanzas)
	if errors.Is(err, age.ErrIncorrectIdentity) {
		// ScryptIdentity returns ErrIncorrectIdentity for an incorrect
		// passphrase, which would lead Decrypt to returning "no identity
		// matched any recipient". That makes sense in the API, where there
		// might be multiple configured ScryptIdentity. Since in cmd/age there
		// can be only one, return a better error message.
		return nil, fmt.Errorf("incorrect passphrase")
	}
	return fileKey, err
}

type EncryptedIdentity struct {
	Contents       []byte
	Passphrase     func() (string, error)
	NoMatchWarning func()

	identities []age.Identity
}

var _ age.Identity = &EncryptedIdentity{}

func (i *EncryptedIdentity) Recipients() ([]age.Recipient, error) {
	if i.identities == nil {
		if err := i.decrypt(); err != nil {
			return nil, err
		}
	}

	return identitiesToRecipients(i.identities)
}

func (i *EncryptedIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
	if i.identities == nil {
		if err := i.decrypt(); err != nil {
			return nil, err
		}
	}

	for _, id := range i.identities {
		fileKey, err = id.Unwrap(stanzas)
		if errors.Is(err, age.ErrIncorrectIdentity) {
			continue
		}
		if err != nil {
			return nil, err
		}
		return fileKey, nil
	}
	i.NoMatchWarning()
	return nil, age.ErrIncorrectIdentity
}

func (i *EncryptedIdentity) decrypt() error {
	d, err := age.Decrypt(bytes.NewReader(i.Contents), &LazyScryptIdentity{i.Passphrase})
	if e := new(age.NoIdentityMatchError); errors.As(err, &e) {
		return fmt.Errorf("identity file is encrypted with age but not with a passphrase")
	}
	if err != nil {
		return fmt.Errorf("failed to decrypt identity file: %v", err)
	}
	i.identities, err = parseIdentities(d)
	return err
}


================================================
FILE: cmd/age/parse.go
================================================
// Copyright 2019 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
	"bufio"
	"encoding/base64"
	"fmt"
	"io"
	"os"
	"strings"
	"unicode/utf8"

	"filippo.io/age"
	"filippo.io/age/agessh"
	"filippo.io/age/armor"
	"filippo.io/age/internal/term"
	"filippo.io/age/plugin"
	"filippo.io/age/tag"
	"golang.org/x/crypto/cryptobyte"
	"golang.org/x/crypto/ssh"
)

type gitHubRecipientError struct {
	username string
}

func (gitHubRecipientError) Error() string {
	return `"github:" recipients were removed from the design`
}

func parseRecipient(arg string) (age.Recipient, error) {
	switch {
	case strings.HasPrefix(arg, "age1tag1") || strings.HasPrefix(arg, "age1tagpq1"):
		return tag.ParseRecipient(arg)
	case strings.HasPrefix(arg, "age1pq1"):
		return age.ParseHybridRecipient(arg)
	case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1:
		return plugin.NewRecipient(arg, plugin.NewTerminalUI(printf, warningf))
	case strings.HasPrefix(arg, "age1"):
		return age.ParseX25519Recipient(arg)
	case strings.HasPrefix(arg, "ssh-"):
		return agessh.ParseRecipient(arg)
	case strings.HasPrefix(arg, "github:"):
		name := strings.TrimPrefix(arg, "github:")
		return nil, gitHubRecipientError{name}
	}

	return nil, fmt.Errorf("unknown recipient type: %q", arg)
}

func parseRecipientsFile(name string) ([]age.Recipient, error) {
	var f *os.File
	if name == "-" {
		if stdinInUse {
			return nil, fmt.Errorf("standard input is used for multiple purposes")
		}
		stdinInUse = true
		f = os.Stdin
	} else {
		var err error
		f, err = os.Open(name)
		if err != nil {
			return nil, fmt.Errorf("failed to open recipient file: %v", err)
		}
		defer f.Close()
	}

	const recipientFileSizeLimit = 16 << 20 // 16 MiB
	const lineLengthLimit = 8 << 10         // 8 KiB, same as sshd(8)
	var recs []age.Recipient
	scanner := bufio.NewScanner(io.LimitReader(f, recipientFileSizeLimit))
	var n int
	for scanner.Scan() {
		n++
		line := scanner.Text()
		if strings.HasPrefix(line, "#") || line == "" {
			continue
		}
		if !utf8.ValidString(line) {
			return nil, fmt.Errorf("%q: recipients file is not valid UTF-8", name)
		}
		if len(line) > lineLengthLimit {
			return nil, fmt.Errorf("%q: line %d is too long", name, n)
		}
		r, err := parseRecipient(line)
		if err != nil {
			if t, ok := sshKeyType(line); ok {
				// Skip unsupported but valid SSH public keys with a warning.
				warningf("recipients file %q: ignoring unsupported SSH key of type %q at line %d", name, t, n)
				continue
			}
			if strings.HasPrefix(line, "AGE-") {
				return nil, fmt.Errorf("%q: error at line %d: apparent identity found in recipients file", name, n)
			}
			// Hide the error since it might unintentionally leak the contents
			// of confidential files.
			return nil, fmt.Errorf("%q: malformed recipient at line %d", name, n)
		}
		recs = append(recs, r)
	}
	if err := scanner.Err(); err != nil {
		return nil, fmt.Errorf("%q: failed to read recipients file: %v", name, err)
	}
	if len(recs) == 0 {
		return nil, fmt.Errorf("%q: no recipients found", name)
	}
	return recs, nil
}

func sshKeyType(s string) (string, bool) {
	// TODO: also ignore options? And maybe support multiple spaces and tabs as
	// field separators like OpenSSH?
	fields := strings.Split(s, " ")
	if len(fields) < 2 {
		return "", false
	}
	key, err := base64.StdEncoding.DecodeString(fields[1])
	if err != nil {
		return "", false
	}
	k := cryptobyte.String(key)
	var typeLen uint32
	var typeBytes []byte
	if !k.ReadUint32(&typeLen) || !k.ReadBytes(&typeBytes, int(typeLen)) {
		return "", false
	}
	if t := fields[0]; t == string(typeBytes) {
		return t, true
	}
	return "", false
}

// parseIdentitiesFile parses a file that contains age or SSH keys. It returns
// one or more of *[age.X25519Identity], *[age.HybridIdentity],
// *[agessh.RSAIdentity], *[agessh.Ed25519Identity],
// *[agessh.EncryptedSSHIdentity], or *[EncryptedIdentity].
func parseIdentitiesFile(name string) ([]age.Identity, error) {
	var f *os.File
	if name == "-" {
		if stdinInUse {
			return nil, fmt.Errorf("standard input is used for multiple purposes")
		}
		stdinInUse = true
		f = os.Stdin
	} else {
		var err error
		f, err = os.Open(name)
		if err != nil {
			return nil, fmt.Errorf("failed to open file: %v", err)
		}
		defer f.Close()
	}

	b := bufio.NewReader(f)
	p, _ := b.Peek(14) // length of "age-encryption" and "-----BEGIN AGE"
	peeked := string(p)

	switch {
	// An age encrypted file, plain or armored.
	case peeked == "age-encryption" || peeked == "-----BEGIN AGE":
		var r io.Reader = b
		if peeked == "-----BEGIN AGE" {
			r = armor.NewReader(r)
		}
		const privateKeySizeLimit = 1 << 24 // 16 MiB
		contents, err := io.ReadAll(io.LimitReader(r, privateKeySizeLimit))
		if err != nil {
			return nil, fmt.Errorf("failed to read %q: %v", name, err)
		}
		if len(contents) == privateKeySizeLimit {
			return nil, fmt.Errorf("failed to read %q: file too long", name)
		}
		return []age.Identity{&EncryptedIdentity{
			Contents: contents,
			Passphrase: func() (string, error) {
				pass, err := term.ReadSecret(fmt.Sprintf("Enter passphrase for identity file %q:", name))
				if err != nil {
					return "", fmt.Errorf("could not read passphrase: %v", err)
				}
				return string(pass), nil
			},
			NoMatchWarning: func() {
				warningf("encrypted identity file %q didn't match file's recipients", name)
			},
		}}, nil

	// Another PEM file, possibly an SSH private key.
	case strings.HasPrefix(peeked, "-----BEGIN"):
		const privateKeySizeLimit = 1 << 14 // 16 KiB
		contents, err := io.ReadAll(io.LimitReader(b, privateKeySizeLimit))
		if err != nil {
			return nil, fmt.Errorf("failed to read %q: %v", name, err)
		}
		if len(contents) == privateKeySizeLimit {
			return nil, fmt.Errorf("failed to read %q: file too long", name)
		}
		return parseSSHIdentity(name, contents)

	// An unencrypted age identity file.
	default:
		ids, err := parseIdentities(b)
		if err != nil {
			return nil, fmt.Errorf("failed to read %q: %v", name, err)
		}
		return ids, nil
	}
}

func parseIdentity(s string) (age.Identity, error) {
	switch {
	case strings.HasPrefix(s, "AGE-PLUGIN-"):
		return plugin.NewIdentity(s, plugin.NewTerminalUI(printf, warningf))
	case strings.HasPrefix(s, "AGE-SECRET-KEY-1"):
		return age.ParseX25519Identity(s)
	case strings.HasPrefix(s, "AGE-SECRET-KEY-PQ-1"):
		return age.ParseHybridIdentity(s)
	default:
		return nil, fmt.Errorf("unknown identity type")
	}
}

// parseIdentities is like [age.ParseIdentities], but supports plugin identities.
func parseIdentities(f io.Reader) ([]age.Identity, error) {
	const privateKeySizeLimit = 1 << 24 // 16 MiB
	var ids []age.Identity
	scanner := bufio.NewScanner(io.LimitReader(f, privateKeySizeLimit))
	var n int
	for scanner.Scan() {
		n++
		line := scanner.Text()
		if strings.HasPrefix(line, "#") || line == "" {
			continue
		}
		if !utf8.ValidString(line) {
			return nil, fmt.Errorf("identities file is not valid UTF-8")
		}
		i, err := parseIdentity(line)
		if err != nil {
			if strings.HasPrefix(line, "age1") {
				return nil, fmt.Errorf("error at line %d: apparent recipient found in identities file", n)
			}
			return nil, fmt.Errorf("error at line %d: %v", n, err)
		}
		ids = append(ids, i)
	}
	if err := scanner.Err(); err != nil {
		return nil, fmt.Errorf("failed to read identities file: %v", err)
	}
	if len(ids) == 0 {
		return nil, fmt.Errorf("no identities found")
	}
	return ids, nil
}

func parseSSHIdentity(name string, pemBytes []byte) ([]age.Identity, error) {
	id, err := agessh.ParseIdentity(pemBytes)
	if sshErr, ok := err.(*ssh.PassphraseMissingError); ok {
		pubKey := sshErr.PublicKey
		if pubKey == nil {
			pubKey, err = readPubFile(name)
			if err != nil {
				return nil, err
			}
		}
		passphrasePrompt := func() ([]byte, error) {
			pass, err := term.ReadSecret(fmt.Sprintf("Enter passphrase for %q:", name))
			if err != nil {
				return nil, fmt.Errorf("could not read passphrase for %q: %v", name, err)
			}
			return pass, nil
		}
		i, err := agessh.NewEncryptedSSHIdentity(pubKey, pemBytes, passphrasePrompt)
		if err != nil {
			return nil, err
		}
		return []age.Identity{i}, nil
	}
	if err != nil {
		return nil, fmt.Errorf("malformed SSH identity in %q: %v", name, err)
	}

	return []age.Identity{id}, nil
}

func readPubFile(name string) (ssh.PublicKey, error) {
	if name == "-" {
		return nil, fmt.Errorf(`failed to obtain public key for "-" SSH key

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"`)
	}
	f, err := os.Open(name + ".pub")
	if err != nil {
		return nil, fmt.Errorf(`failed to obtain public key for %q SSH key: %v

Ensure %q exists, or convert the private key %q to a modern format with "ssh-keygen -p -m RFC4716"`, name, err, name+".pub", name)
	}
	defer f.Close()
	contents, err := io.ReadAll(f)
	if err != nil {
		return nil, fmt.Errorf("failed to read %q: %v", name+".pub", err)
	}
	pubKey, _, _, _, err := ssh.ParseAuthorizedKey(contents)
	if err != nil {
		return nil, fmt.Errorf("failed to parse %q: %v", name+".pub", err)
	}
	return pubKey, nil
}


================================================
FILE: cmd/age/testdata/armor.txt
================================================
age -d -i key.txt armored_with_leading_and_trailing_whitespace.txt
stdout test

-- key.txt --
# created: 2025-12-23T22:21:12+01:00
# public key: age15w9kgvgggmfra4sz6vk39kz4mveuq2sfv5vmcu090y0k2sluepaqv7z2fv
AGE-SECRET-KEY-18J6FVYJE2AFSJ0RPH6M29GMUU62UVRSCNWUJZSGETH6R38Q5AZ3S2DHAZ9

-- armored_with_leading_and_trailing_whitespace.txt --

   

-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA5ODhFNHR6RVg0SGVHZFBM
clBEclEzZ3NvOGhqVE9tcFZnbTc2c3R5a0Q4ClZjVzBLNjdxRElZV3E0Z3ZpZ255
T3JWTFBHRFA2cytpWWtkeU45dDRadmcKLS0tIHV3L3hOVmJjL0hMRXBQa05lMlRs
ZW45TndPeE9GcmRNeWFkR3YxeHg0YzQKJBp6KRlFFUE8jbAQUBlcAwaaQcPAflJD
pWGoOjYP33gTxJHNPg==
-----END AGE ENCRYPTED FILE-----

   


================================================
FILE: cmd/age/testdata/batchpass.txt
================================================
# encrypt and decrypt with AGE_PASSPHRASE
env AGE_PASSPHRASE_WORK_FACTOR=5
env AGE_PASSPHRASE=password
age -e -j batchpass -o test.age input
age -d -j batchpass test.age
cmp stdout input

# decrypt with AGE_PASSPHRASE_MAX_WORK_FACTOR
env AGE_PASSPHRASE_MAX_WORK_FACTOR=10
age -d -j batchpass test.age
cmp stdout input

# AGE_PASSPHRASE_MAX_WORK_FACTOR lower than work factor
env AGE_PASSPHRASE_MAX_WORK_FACTOR=3
! age -d -j batchpass test.age
stderr 'work factor'
env AGE_PASSPHRASE_MAX_WORK_FACTOR=

# error: both AGE_PASSPHRASE and AGE_PASSPHRASE_FD set
env AGE_PASSPHRASE=password
env AGE_PASSPHRASE_FD=3
! age -e -j batchpass -a input
stderr 'mutually exclusive'

# error: neither AGE_PASSPHRASE nor AGE_PASSPHRASE_FD set
env AGE_PASSPHRASE=
env AGE_PASSPHRASE_FD=
! age -e -j batchpass -a test.age
stderr 'must be set'

# error: incorrect passphrase
env AGE_PASSPHRASE=wrongpassword
! age -d -j batchpass test.age
stderr 'incorrect passphrase'

# error: encrypting to other recipients along with passphrase
env AGE_PASSPHRASE=password
! age -e -j batchpass -r age1gc2zwslg9j25pg9e9ld7623aewc2gjzquzgq90m75r75myqdt5cq3yudvh -a input
stderr 'incompatible recipients'
! age -e -r age1gc2zwslg9j25pg9e9ld7623aewc2gjzquzgq90m75r75myqdt5cq3yudvh -j batchpass -a input
stderr 'incompatible recipients'

# decrypt with native scrypt
[!linux] [!darwin] skip # no pty support
[darwin] [go1.20] skip # https://go.dev/issue/61779
ttyin terminal
age -d test.age
cmp stdout input

-- terminal --
password
password
-- input --
test


================================================
FILE: cmd/age/testdata/duplicates.txt
================================================
# Test duplicate recipient detection
age -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -r age1yv2sxrjnyymx4qkn3ehdd5zdq4xnrea5ljm4hsmgxn5tpv985anqkrwqa7 -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -o test.age input
stderr 'warning: duplicate recipient "age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85"'

# Test duplicates separated by different argument
age -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -a -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -o test2.age input
stderr 'warning: duplicate recipient "age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85"'

# Test duplicate recipients file detection
age -R recipients1.txt -R recipients2.txt -R recipients1.txt -o test3.age input
stderr 'warning: duplicate recipients file "recipients1.txt"'

# Test duplicates separated by output flag
age -R recipients1.txt -o test4.age -R recipients1.txt input
stderr 'warning: duplicate recipients file "recipients1.txt"'

# First create an encrypted file for decrypt tests
age -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -o encrypted.age input

# Test duplicate identity file detection (decrypt mode)
age -d -i key1.txt -i key2.txt -i key1.txt encrypted.age
stderr 'warning: duplicate identity file "key1.txt"'

# Test duplicates separated by different argument in decrypt mode
age -d -i key1.txt -o test.out -i key1.txt encrypted.age
stderr 'warning: duplicate identity file "key1.txt"'

# Test no warning when no duplicates
age -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -r age1yv2sxrjnyymx4qkn3ehdd5zdq4xnrea5ljm4hsmgxn5tpv985anqkrwqa7 -o test5.age input
! stderr 'warning: duplicate'

# Test multiple duplicates (same value repeated 3+ times)
age -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -o test6.age input
stderr 'warning: duplicate recipient "age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85"'

-- input --
test data
-- recipients1.txt --
age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85
-- recipients2.txt --
age1yv2sxrjnyymx4qkn3ehdd5zdq4xnrea5ljm4hsmgxn5tpv985anqkrwqa7
-- key1.txt --
# created: 2025-12-22T22:06:22+01:00
# public key: age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85
AGE-SECRET-KEY-1WRM2S8SP3XSKLLXAXS489EXZNKCKRZWYQLQ8D2NRNQWCVAPSMA9SC5JWZQ
-- key2.txt --
# created: 2025-12-22T22:06:27+01:00
# public key: age1yv2sxrjnyymx4qkn3ehdd5zdq4xnrea5ljm4hsmgxn5tpv985anqkrwqa7
AGE-SECRET-KEY-1WZ3MRPAWEWR4DG474H460MXX7J2T0TEYNJ0SKQDMKP02JU7UJ9UQFGLZCE


================================================
FILE: cmd/age/testdata/ed25519.txt
================================================
# encrypt and decrypt a file with -R
age -R key.pem.pub -o test.age input
age -d -i key.pem test.age
cmp stdout input
! stderr .

# encrypt and decrypt a file with -i
age -e -i key.pem -o test.age input
age -d -i key.pem test.age
cmp stdout input
! stderr .

# encrypt and decrypt a file with the wrong key
age -R otherkey.pem.pub -o test.age input
! age -d -i key.pem test.age
stderr 'no identity matched any of the recipients'
! stdout .

-- input --
test
-- key.pem --
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACB/aTuac9tiWRGrKEtixFlryYlGCPTOpdbmXN9RRmDF2gAAAKDgV/GC4Ffx
ggAAAAtzc2gtZWQyNTUxOQAAACB/aTuac9tiWRGrKEtixFlryYlGCPTOpdbmXN9RRmDF2g
AAAECvFoQXQzXgJLQ+Gz4PfEcfyZwC2gUjOiWTD//mTPyD8H9pO5pz22JZEasoS2LEWWvJ
iUYI9M6l1uZc31FGYMXaAAAAG2ZpbGlwcG9AQmlzdHJvbWF0aC1NMS5sb2NhbAEC
-----END OPENSSH PRIVATE KEY-----
-- key.pem.pub --
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH9pO5pz22JZEasoS2LEWWvJiUYI9M6l1uZc31FGYMXa
-- otherkey.pem.pub --
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJFlMdZUMrWjJ3hh60MLALXSqUdAjBo/qEMJzvpekpoM


================================================
FILE: cmd/age/testdata/encrypted_keys.txt
================================================
# TODO: age-encrypted private keys, multiple identities, -i ordering, -e -i,
# age file password prompt during encryption

[!linux] [!darwin] skip # no pty support
[darwin] [go1.20] skip # https://go.dev/issue/61779

# use an encrypted OpenSSH private key without .pub file
age -R key_ed25519.pub -o ed25519.age input
rm key_ed25519.pub
ttyin terminal
age -d -i key_ed25519 ed25519.age
cmp stdout input
! stderr .

# -e -i with an encrypted OpenSSH private key
age -e -i key_ed25519 -o ed25519.age input
ttyin terminal
age -d -i key_ed25519 ed25519.age
cmp stdout input

# a file encrypted to the wrong key does not ask for the password
age -R key_ed25519_other.pub -o ed25519_other.age input
! age -d -i key_ed25519 ed25519_other.age
stderr 'no identity matched any of the recipients'

# use an encrypted legacy PEM private key with a .pub file
age -R key_rsa_legacy.pub -o rsa_legacy.age input
ttyin terminal
age -d -i key_rsa_legacy rsa_legacy.age
cmp stdout input
! stderr .
age -R key_rsa_other.pub -o rsa_other.age input
! age -d -i key_rsa_legacy rsa_other.age
stderr 'no identity matched any of the recipients'

# -e -i with an encrypted legacy PEM private key
age -e -i key_rsa_legacy -o rsa_legacy.age input
ttyin terminal
age -d -i key_rsa_legacy rsa_legacy.age
cmp stdout input

# legacy PEM private key without a .pub file causes an error
rm key_rsa_legacy.pub
! age -d -i key_rsa_legacy rsa_legacy.age
stderr 'key_rsa_legacy.pub'

# mismatched .pub file causes an error
cp key_rsa_legacy key_rsa_other
ttyin terminal
! age -d -i key_rsa_other rsa_other.age
stderr 'mismatched private and public SSH key'

# buffer armored ciphertext before prompting if stdin is the terminal
ttyin terminal
age -e -i key_ed25519 -a -o test.age input
exec cat test.age terminal # concatenated ciphertext + password
ttyin -stdin stdout
age -d -i key_ed25519
ttyout 'Enter passphrase'
! stderr .
cmp stdout input

-- input --
test
-- terminal --
password
-- key_ed25519 --
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABCuvb97i7
U6Dz4+4SaF3kK1AAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIKaVctg4/hmFbfof
Tv+yrC2IweO/Dd2AVDijFpaMO9fmAAAAoMO7yEnisRmzFdiExNt3XTYuLdP9m3jgOCroiF
TtBhh1lAB2qggzWExMRP3Ak8+AloXEcWiACwBYnqwxhQMh0RDCDKC/H/4SXO+ds4HFWil+
4bGF9wYZFU7IEjIK91CPGJ6YoWPn9dSdEjjbuCJtOMwHsysGyw5n/qSFPmSAPmA4YL2OzM
WFOJ5gB5o1LKZkDTcdt7kPziIoVd5QkqpnYsE=
-----END OPENSSH PRIVATE KEY-----
-- key_ed25519.pub --
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKaVctg4/hmFbfofTv+yrC2IweO/Dd2AVDijFpaMO9fm
-- key_ed25519_other.pub --
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINbTd+xfSBYKR/1Hp7FsoxwQAdIOk1Khye6ALBj7e1CV
-- key_rsa_legacy --
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-128-CBC,8045E7CF19D7794F4ADF5AC63179D985

OESHhWCho337W1Ajg+iMbsZx/FPtHM3YPHu/d1U51ERIUh0wVof2SK0ooENokr6g
O3fcv9Xga+Na4Ez+gsFRsIZOdqrJq+QBH0CAKi+Mz4KsU7teAobUBJgRB31Wt7eI
39KGZeaBJLMQ0FzQkDx5MCOg98iu9rt+Pg1bH8X88wV4vOv+tG4nmqgdpDmouo1Q
uW1TJxrdPhkINjaPZZ7gvjS8wuG9+qwQY76I0hGun9secf4VZDysqUnUp8UHYovR
dbvKCbglQy18mGL4kREJ/hH/9/maefS+pTMb2UX0onp9j7l3yNSvL4A4xW85ii6x
liVMnZvLvbfPtI7jjZtC8CjshRkZke4fSZF2nZP7zK2qVcqDFCtemaks+0i2ksel
D8clUKhBmq23VNAt+iy1stwHBporuaE6kEVJail5WPpgdfQjifpaMbTsZgOK+vGL
GKi8vSJWfMU3lTf/N++ks2FWxdq0TgQirsKsQ5mWobfxc1XehvvdJj8hUtArrP32
d4ge5DXPpmtkCzrc1+wt8Py/ANl9jV6c+4fCbpQ2snyzdFEhFtXHCEpMguN9MhKI
gaZIfAxvYcQr8Gwew/IB02Phda9tvDiedHvyHGJmSy/87fR6ECh47VDFL/UYu4jG
0hRtAZMMddGNfoosnO6OKBd09cgvXKCsUrbpAI7dF5TP5ItDkVb08hW446jBdgS9
7QqB0rPmlAjsJi7fsrDw7Nq9pOdqqCEwUMc9Lztnv66aX1d/Y2vQm9mrsDbyZKqn
g621rg7E4UHf7EGiDblfS234+TsNvwZ6sEbivU+3zqglPiOF71m6D0cKgaUZPOua
GNdyQz5e73hYa+NJ76IZ+IqkoJAFXBkd1nWcN6DUBYiKvqd4qO9xD+JvNtiFlQ9d
pyO9t4FTGvySh8CKyEUEdtj+2ftCIuZaUD2L5YJU1tlQV0EH42InOmkmphbHkW5v
lNAoZAny1Z0P6O0gn7xtVrgd7paVQfDCJtkvsm5zR6Yei5FUgY/9NPaRotzuZVAY
EfQC7JPdSdb5yusnXh7B9jGkgxhMIb6EPFFjIZ4iaV1RVgINSisGMSFzlqOz702b
Cawsr9nD438cjzMNYEmrihZZBjHon6hHrLmM9Aj2xgprsoNLP1jJQ6WpZDlrYsj0
XS0tSJmh0pM4Ey6j1VWNoaOxVseYLW7J9wGVfH/HJAc2k6Wg46P2e8lMT6Sj4YsT
EguDhUjXrgePC53ohcSF+I6x35Q1D6ttMnc3ODzmIcCisxAvWdAqi1yRlnBotRwg
S2vq3HU0yJFG8pJqw4vU9A9DlaMMT+ejEH+9xVwAWM+7n2lJcgthtWuShZCE6BB+
jVobSlTMArzQj4klTSbew1m9Waa6kKDezsAY66mryVNofCCeYDOBRecCm5JyMnWf
WBVnNx+kZ/YyvYeBcSh34u8rkjqGpzfM/oPE7GwIoZvbAirjLohL7u8oq2bfAYG0
/xIPwPJw1O3o5PHeu84bVIRqcKzGeaVL+5aUiZP9uNGUpqJWA5q2Sa5BOXV46yqO
DIS8q7uPCSbt5mPXPDGJ1CupCdA1stUf2kb0cDJ+LpUbPND9SebBlxSuR1D/YGqv
wlzfN5Usv/h/XNl98bYtpY8/skKPecyx3wG3VtwWH/5XVhvHz4TENjlKv/L2pbUC
Dv83WcL1N/i+jerYxDRmGe3NQOvyW4JaNzzjgb74T7rE1/3lf6qkmUHjxfo4VZAF
L/q2782OUs5Qt4/pYAIISzLdBw6XtTjZHirqa6YNrFvGucB3NG49AC0b1Z0acfrS
iimC2TvZpwunlLbyz2SQQL2c1zQ3U/Yfh2F1Zt8o6kK3RgKSSx57rK6nV7hXMGGp
C4HV3nLetZg8HexicqeRANLXuUDbCSpN8K4nW5G2g/yKPfsQHBV/RWEDfhndykja
+SmoY5IB+2zEbCC3MWiP9ZdIcCYOsq8wDZESMMW40DlVICjrf6UOqQ+ogci20qLS
CmpgmOPAaBZJG/sBU79eHUSjPCK6yDpSyc30oVn8FnoBTmOpt7R++Ub8RJxReXBt
+6o0NXYCJNaeVnk1bE4iavkJrXJCZvu44VBLS0WUs9W8TD4Iq8kNHsfQsfOuBXnQ
ncgoIe9HppnMGNoSzjYBNL/rprlbaOE55TkPqiQsiskRcaoeY53aTxoIykHmoj8G
wJo/00IR+NYir7tr03Vriw+uywPPGucVJGWTUGsNbHlS5j941IltflIf6FitElNr
JxVuJLgYiP3JhmWpdqA/uidYJMbIjunpn/8rVLrAil04SCSfUmaCdl7dkQ9x+3Mf
Erm699vIBQwvv0i+mcwKEvqSrhhNQ2F7vrb7NL8I2wUEPgQbv1PxSV6X0aYcxYVI
-----END RSA PRIVATE KEY-----
-- key_rsa_legacy.pub --
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCky7Clp8I3LVoqZWtat+QR6KmM0evFilmFhwenINIBbb8eS3ftDSkQy2YRrlAvO3h4EZffOIxANGL/yKVlRCIzvjsphi+tTHscZsQhwMnLEmxEayTq20hZKcwNA8TQdh2TW/w0KZmNZcxlTn4IK8W16komHcoH/qrRiXq8z3ROcfnv3Q4Hll9MUCwBkfy2DdBpWUMidQ1dAK4i3vXdseF74hJ0jFbPtS5mlpOsJZa0sdH1dnEl5M8wZS3PxyzM6JMkgzG7INp4sO/xGIisjl/QuSh2Fu93/EogdGXxIZChniUfzBx1DaHlerPPNSMP+uLbaOIAQrIPozhfdUdsCFDMoB7/PA6g1WVYZWAqjBZZW/GMOzPhih57NIFBSyMTzMi1KS6OBvYJvPf4IcvOa3May9ylLG/wZVhrHlQPbSsbRrraVtJ1P4gGQJ5U4d2AD2q+XtMb5f2i/holMXTVQl7Fa7RYi1TblDuW5OZCvmIawePBXAYbPg0OVFs3vAVEuAM=
-- key_rsa_other.pub --
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDQiCWw2W++gX4wcwpDo6QIouwQ9PPwCVe7QPICzxztG27mzeKRM4xT2LURGSaQqg7OYIUTGrLqNsaLZW+FHHQlRAVv1LEbdEFa5JermBMJ5j/HxamE/7oV60gMRlgKW+4IZhVMPgRZaaXU0YPb9oACdMNM8kPkc5JaOJ8iO6B1RViybjLD+tsEEPXLp3Mrj+sJqs+IvNlJKXdeefOjNrGmLHKIFdHiWlZ+aAW+QLfMQiNXoTbGybFUSpNEbmK/1ITiRAly94NoUK9LoriueXR+WJIm9wP4SfHw+hMBz1cywdF2wwKmWWegizV/USEmhyNXUzHZzjbkgE84DrIq+NA7SUmw6C8ClMjdnRnnoIyga99yMIrYMny1KW/bk1NK4u6Tv17E+FFOS3vf2Gcj01/jOmAUIQwL8MjAHhnsZ4XAA5NHa2NRGWm+hw7fx5uX42Gyz8HidFda5Lij1pASBcx4U3qwb62X+IVN50jGIP6kRNmGtMLY1JgaoGDDkw9r6mU=


================================================
FILE: cmd/age/testdata/hybrid.txt
================================================
# encrypt and decrypt a file with -r
age -r age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0 -o test.age input
age -d -i key.txt test.age
cmp stdout input
! stderr .

# encrypt and decrypt a file with -i
age -e -i key.txt -o test.age input
age -d -i key.txt test.age
cmp stdout input
! stderr .

# encrypt and decrypt a file with the wrong key
age -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -o test.age input
! age -d -i key.txt test.age
stderr 'no identity matched any of the recipients'

age -r age1pq13tgqupu990y5qlwwjy4zrzwvxg54cq89x9pxzdjenv4cl7affsqeerksrc6ufw3ndp4h5p3l5hm2jekkd7uay2mlh9eqg3sjcwmv5dcrwguc8djf83qr8gswtdrpav3jpw47kgkh6z0rnfk92xyqeyce48t02n4qz68augry3r3nd4rpdsw998vnnxzd2vepypktx2wxhfvd56jw7p32te6ttcyhc7tkqy2ks77jtjl8cgyal43pcd3g9wkcsgytccqtcnzcucjjdadpr0v7ye8u9fqusq6cq2ah0e4ejrltc8sk6763d7mjkuchmc5tkx6xztygqjx4ldxqh7jzkkyuv6ywt07ys4x09eq7gat4wuznrvgs3pkxjju8t9n0gpfqrsv658m5v09ltqc3c5z30n43ld43dwe5dn8nsk89f43tzsw2zta5kvl95zv0a94tc5y9r4ncj8g63fgljzhdxl96eg39rgd56yuvr2xdvavkk93a85n99jpjeqkfveprqc7eqft0wp7qgxcgsrwcdymgp0cqaj93jp4ckln36ylcwdzew234esa2awty25zc6pmc0jcpkrfuuptr9z5a4kx9ff4ppfff2lw95um853tcg47fa444er9k9j4e8vy5hfjx626j3tz3kc94jl8r66ahfprwtcyxae3swe9xxjhx5dfnt53n5fs2ut83t597wscjg9jc4f6h4uen2ll2ne30w7uvts8vuc5mpcfpp9espa78y0f8588m62kzez5kxxtcxykdkrnjx08w5az3pr6g6dsykwazcxtetpkq85mg7kay84z58slmvxmq56342pm6v8y3evr5wvc9g4j87jfhmryymkz9wk2en2v6tfy83rz657t0w7p3va46jt22f29u9ggzt9nu6qtguw4cp7cpj572ccyenw5rdh43h6s54g73gvxapsq3hw5yfkgttcyhkrjf9ykzd3wm5zsqgp5fca0nr56dnqe3kqrszasagqne0a83cguzwj9aw456jaxg9rurq4n4f6qec4srd0rxqfqc6rw22c2p4xyg25pls4dmxvce2fah8m6shue84preky2h0c4sa9y9tjuzn20gr9aav96e689nqx23s7zz0lvhq925jdkn04cdpzfscvhh93vh9f2tmwkzsq7pq0ncjk8g8cu75u77lyej46a4m2fefy8v2x4nzay05kulhyzk69520r7fdg3fzh0z7ysequcltr94hyf88h46hfujk30x9jp8u0pcjywh06g7tv483ypmc3pm3x3z4h9ph66lx228t3r96hyt764yxe2rn5clnfxkj3k5wf5hffehj58y56qyykwayk6qt9skvctxw20v9xy5ppnld3lushqpsutxht7cqygypdn5d2ppdgaerqzgehyrpdwzkhu0qe8w3u5h6htz8aa5zfpzy4s7sl8zdv3q0gq8ez08p86e4v3m82882yvawrvyrcxewrznwkvvez8m5aj6ktgvynyy0kc2trmtjzvjlxdf06e3rjmf55lwxrucfwu2sxdtnte83fgq0tvr2juv3pfqp8ddsrnckzqcfcvjp02mfg5y4aqlsunxpfcrdm46fphwsslrudwrfh542xg62kphca6h3xxqn538pprkknt35y00ygvrse5mxpvnstvcrnak5qduhf5dqslkn0yeadgpq6tv4wzy98kdjdzp22cpq6dy3kve856y2qlk7elyqyzj7ezpnh3vjwwmcm7ctp23k2sct86jd46ztplsq5vdjg9lyspxt0k8qx5udu9lwgulzapn3kdg7yd2zdz5dqf9933mpzwc32x8uxn8h2v5hlhdd4qk0uvwxhxyul8keaw39pz2avywk3wfly6veet9pjnj7nqecrgz824whs9sf7wl2shxk9kvkteht4z9x3w2gc6hqz5y -o test.age input
! age -d -i key.txt test.age
stderr 'no identity matched any of the recipients'

# cannot mix hybrid and X25519 recipients
! age -r age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0 -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -o test.age input
stderr 'incompatible'

! age -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -r age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0 -o test.age input
stderr 'incompatible'

# convert to plugin identity and use plugin
exec age-plugin-pq -identity -o key-plugin.txt key.txt

age -e -i key.txt -o test.age input
age -d -i key-plugin.txt test.age
cmp stdout input
! stderr .

age -e -i key-plugin.txt -o test.age input
age -d -i key.txt test.age
cmp stdout input
! stderr .

-- input --
test
-- key.txt --
# created: 2025-11-17T13:27:37+01:00
# public key: age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0
AGE-SECRET-KEY-PQ-16XDSLZ3XCSZ3236YJ5J0T9NAPLZTP96LJKQCVHJYUDTQVJR5J5PQTDPQCX


================================================
FILE: cmd/age/testdata/keygen.txt
================================================
exec age-keygen
stdout '# created: 20'
stdout '# public key: age1'
stdout 'AGE-SECRET-KEY-1'
stderr 'Public key: age1'

exec age-keygen -pq
stdout '# created: 20'
stdout '# public key: age1pq1'
stdout 'AGE-SECRET-KEY-PQ-1'
stderr 'Public key: age1pq1'

exec age-keygen -pq -o key.txt
! stdout .
stderr 'Public key: age1pq1'
grep '# created: 20' key.txt
grep '# public key: age1pq1' key.txt
grep 'AGE-SECRET-KEY-PQ-1' key.txt

stdin key.txt
exec age-keygen -y
stdout age1pq1

exec age-keygen -y key.txt
stdout age1pq1


================================================
FILE: cmd/age/testdata/output_file.txt
================================================
# https://github.com/FiloSottile/age/issues/57
age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o test.age input
! age -o test.out -d -i wrong.txt test.age
! exists test.out
! age -o test.out -d test.age
! exists test.out
! age -o test.out -d -i notexist test.age
! exists test.out
! age -o test.out -d -i wrong.txt notexist
! exists test.out
! age -o test.out -r BAD
! exists test.out
! age -o test.out -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef notexist
! exists test.out
! age -o test.out -p notexist
! exists test.out

# https://github.com/FiloSottile/age/issues/555
age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o empty.age empty
exists empty.age
age -d -i key.txt empty.age
! stdout .
! stderr .
age -d -i key.txt -o new empty.age
! stderr .
cmp new empty

# https://github.com/FiloSottile/age/issues/491
cp input inputcopy
! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o inputcopy inputcopy
stderr 'input and output file are the same'
cmp inputcopy input
! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o ./inputcopy inputcopy
stderr 'input and output file are the same'
cmp inputcopy input
mkdir foo
! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o inputcopy foo/../inputcopy
stderr 'input and output file are the same'
cmp inputcopy input
cp key.txt keycopy
age -e -i keycopy -o test.age input
! age -d -i keycopy -o keycopy test.age
stderr 'input and output file are the same'
cmp key.txt keycopy

[!linux] [!darwin] skip # no pty support
[darwin] [go1.20] skip # https://go.dev/issue/61779

ttyin terminal
! age -p -o inputcopy inputcopy
stderr 'input and output file are the same'
cmp inputcopy input

# https://github.com/FiloSottile/age/issues/159
ttyin terminal
age -p -a -o test.age input
ttyin terminalwrong
! age -o test.out -d test.age
ttyout 'Enter passphrase'
stderr 'incorrect passphrase'
! exists test.out

-- terminal --
password
password
-- terminalwrong --
wrong
-- input --
age
-- empty --
-- key.txt --
# created: 2021-02-02T13:09:43+01:00
# public key: age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef
AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0
-- wrong.txt --
# created: 2024-06-16T12:14:00+02:00
# public key: age10k7vsqmeg3sp8elfyq5ts55feg4huarpcaf9dmljn9umydg3gymsvx4dp9
AGE-SECRET-KEY-1NPX08S4LELW9K68FKU0U05XXEKG6X7GT004TPNYLF86H3M00D3FQ3VQQNN


================================================
FILE: cmd/age/testdata/pkcs8.txt
================================================
# https://github.com/FiloSottile/age/discussions/428
# encrypt and decrypt a file with an Ed25519 key encoded with PKCS#8
age -e -i key.pem -o test.age input
age -d -i key.pem test.age
cmp stdout input
! stderr .

-- input --
test
-- key.pem --
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIJT4Wpo+YG11yybKL/bYXQW7ekz4PAsmV/4tfmY1vU7x
-----END PRIVATE KEY-----


================================================
FILE: cmd/age/testdata/plugin.txt
================================================
# encrypt and decrypt a file with a test plugin
age -r age1test10qdmzv9q -o test.age input
age -d -i key.txt test.age
cmp stdout input
! stderr .

# very long identity and recipient
age -R long-recipient.txt -o test.age input
age -d -i long-key.txt test.age
cmp stdout input
! stderr .

# check that path separators are rejected
chmod 755 age-plugin-pwn/pwn
mkdir $TMPDIR/age-plugin-pwn
cp age-plugin-pwn/pwn $TMPDIR/age-plugin-pwn/pwn
! age -r age1pwn/pwn19gt89dfz input
! age -d -i pwn-identity.txt test.age
! age -d -j pwn/pwn test.age
! exists pwn

# check plugin not found hint
! age -r age1nonexistentplugin1pt5d8z -o test1.age
stderr /awesome#plugins
! age -d -i nonexistent-identity.txt test.age
stderr /awesome#plugins

-- input --
test
-- key.txt --
AGE-PLUGIN-TEST-10Q32NLXM
-- long-recipient.txt --
age1test10pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7qj6rl8p
-- long-key.txt --
AGE-PLUGIN-TEST-10PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7Q5U8SUD
-- pwn-identity.txt --
AGE-PLUGIN-PWN/PWN-19GYK4WLY
-- age-plugin-pwn/pwn --
#!/bin/sh
touch "$WORK/pwn"
-- nonexistent-identity.txt --
AGE-PLUGIN-NONEXISTENTPLUGIN-1R4XFW4


================================================
FILE: cmd/age/testdata/rsa.txt
================================================
# encrypt and decrypt a file with -R
age -R key.pem.pub -o test.age input
age -d -i key.pem test.age
cmp stdout input
! stderr .

# encrypt and decrypt a file with -i
age -e -i key.pem -o test.age input
age -d -i key.pem test.age
cmp stdout input
! stderr .

# encrypt and decrypt a file with the wrong key
age -R otherkey.pem.pub -o test.age input
! age -d -i key.pem test.age
stderr 'no identity matched any of the recipients'

-- input --
test
-- key.pem --
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEA1C04rdClHoW4oG4bEGmaNqFy4DLoPJ0358w4XH+XBM3TiWcheouW
kUG6m1yDmHk0t0oaaf4hOnetKovdyQQX73gGaq++rSu5VSvH7LbwABoG6PS/UbuZ4Vl9B0
5WVDqHVE9hNK4AHqBc373GU2mo8z5opKxEprmiS3HSd3K2wiMqL5E8XPOSm0p/isuYK57X
VUexl73tB7iIMLklxjcjtP4REMoQhHKOMOdy2Q15dw5cYG+drtEArBRYkCZmd0Vp2ws9pj
YzPVaOSkbdqSeLu+JVbH1wrwKhuBrA3eVlwjUTWkO4FHcNXkp773Mt4cXhKizTfbR2hQox
Lsj31301Xd7dEpV63sqDW1e+a2L2dhemi8cjDMrPuW6Z19Lbti0quAb4+cSLAaJI4BHd1F
8o9XhK7EHVCdIIIQDKVzo1WyEsDwBjL1LB9rpxm4732sZyue0uygFzmM544QX+WsiJXgHP
uC1Q/ynjLRm6ZMl16MwvY8B/XGQWxlOAbRJQG84fAAAFmEwAjV1MAI1dAAAAB3NzaC1yc2
EAAAGBANQtOK3QpR6FuKBuGxBpmjahcuAy6DydN+fMOFx/lwTN04lnIXqLlpFBuptcg5h5
NLdKGmn+ITp3rSqL3ckEF+94Bmqvvq0ruVUrx+y28AAaBuj0v1G7meFZfQdOVlQ6h1RPYT
SuAB6gXN+9xlNpqPM+aKSsRKa5oktx0ndytsIjKi+RPFzzkptKf4rLmCue11VHsZe97Qe4
iDC5JcY3I7T+ERDKEIRyjjDnctkNeXcOXGBvna7RAKwUWJAmZndFadsLPaY2Mz1WjkpG3a
kni7viVWx9cK8CobgawN3lZcI1E1pDuBR3DV5Ke+9zLeHF4Sos0320doUKMS7I99d9NV3e
3RKVet7Kg1tXvmti9nYXpovHIwzKz7lumdfS27YtKrgG+PnEiwGiSOAR3dRfKPV4SuxB1Q
nSCCEAylc6NVshLA8AYy9Swfa6cZuO99rGcrntLsoBc5jOeOEF/lrIiV4Bz7gtUP8p4y0Z
umTJdejML2PAf1xkFsZTgG0SUBvOHwAAAAMBAAEAAAGBAKytAOu0Wi009sTZ1vzMdMzxJ+
R+ibKK4Oysr1HYJLesKvQwEncBE1C0BYJbEF4OhnCExmpsf+5tZ2iw25a01iX1sIMy9CNK
6lH+h36Gg1wR0n3Ucb+6xck4YyCHCIsT9v8OezW8Riympe8RK07HNtB/gfpCmLx3ZzWvNH
Ix0bq9k5+Su2WKdU4cmyACAZ2+b9DfwBCWaUlXTL8abzuZtF2gR5M6X6bq8/2o3zb2WFwk
O9nf/JxBTCK/jDQEjG+U9MyGxZIW5DeG1nNFtOzJoT8krIkeSOjQ5XQrkjCw+yihSCWMG+
s+SKO77u30SO7OCENsFIXpUzpt6+JmazlXjLW/OdYNooQMHtqCZzVMRgxiy3gDGF35YvgV
VnP5gVEW9HEZ0kD+x4Rl2kB6bV7jMi8BXrazQ1EmTasJFg1pv6iRJWzY1JoP2kRfgiHGL6
OqgrXakqo3hMJuz+JRU2/hlF13743MiIxpcbaaRqURoWuNRLHitVWE35/XVCez0C6OwQAA
AMEAoh106+3JbiZI19iRICR247IoOLpSSed98eQj+l3OYfJ86cQipSjxdSPWcP58yyyElY
d9q6K16sDTLAlRJzF7MFxSc80JY6RgFq/Sy4Jm0/Z10wwJhTgOkxq6IynzLnO7goRirE31
jxGif4nI2IYEQvv6MOD8TWA4axxGMw2StYB6P4R5peozf81oR6m79ERIDSkrm0RYYn931r
gVuxvo3ABVxMtg1lV80LJMayy87Oi8BehGBxMBgsKtQaH8+5h7AAAAwQD+8lJpBcrrHQKk
3o2XAZxB5Fool4f2iuZWTxA1vq0/TCUcEodrdWfLuDeVbDsFemW0vBSkKzf4NlZSs2DAKl
YWT6y18eyDyJXn0TNVTeO3F5mkkX5spqbjDcESSs3whIuDqXU++3sII7iMzGw50tDP4Dw6
TViEVM3anpeqlAbkciR5o9IJx3nRcGh81Bs4gticcRF0vqiJoAhNlSZXR1XMjevwt68i+4
RKPPQsTM7uJLm236VUhDivO1OJcBTLW7MAAADBANUNqH+//G4gIruBO3BsIvbzDw0DgRam
R1tqqn4g53boiv1RPtUJ2GbkCsisy5pU+JdTN7ekFEF8KWuunjImkfVyAiTFsHHmOoXV3Z
EX0mNDXOlKOP2YAIMrDt5CkPdEh6qQG21LCZXTWmwheZ9iN2vOl/fKqUW9lqd/kTe6WsON
hIpZhs2+oz54Riq1ZwzO9NkcYrvZoDKbDopL1r2ibw0mkgCJrxpWi0Yt2Iooh4GXXqP5C9
T8hrZCbrVJkjKd5QAAABtmaWxpcHBvQEJpc3Ryb21hdGgtTTEubG9jYWwBAgMEBQY=
-----END OPENSSH PRIVATE KEY-----
-- key.pem.pub --
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDULTit0KUehbigbhsQaZo2oXLgMug8nTfnzDhcf5cEzdOJZyF6i5aRQbqbXIOYeTS3Shpp/iE6d60qi93JBBfveAZqr76tK7lVK8fstvAAGgbo9L9Ru5nhWX0HTlZUOodUT2E0rgAeoFzfvcZTaajzPmikrESmuaJLcdJ3crbCIyovkTxc85KbSn+Ky5grntdVR7GXve0HuIgwuSXGNyO0/hEQyhCEco4w53LZDXl3Dlxgb52u0QCsFFiQJmZ3RWnbCz2mNjM9Vo5KRt2pJ4u74lVsfXCvAqG4GsDd5WXCNRNaQ7gUdw1eSnvvcy3hxeEqLNN9tHaFCjEuyPfXfTVd3t0SlXreyoNbV75rYvZ2F6aLxyMMys+5bpnX0tu2LSq4Bvj5xIsBokjgEd3UXyj1eErsQdUJ0gghAMpXOjVbISwPAGMvUsH2unGbjvfaxnK57S7KAXOYznjhBf5ayIleAc+4LVD/KeMtGbpkyXXozC9jwH9cZBbGU4BtElAbzh8=
-- otherkey.pem.pub --
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDF0OPu95EY25O5KmYFLIkiZZFKUlfvaRgmfIT6OcZvPRXBzo0MS/lcrYvAc0RsUVbZ1B3Y9oWmKt/IMXTztCXiza70rO1NI7ciayv5svY/wGMoveutddhA64IjrQKs4m+6Qmjs/dYTnfsk1BzmXrdRKUSqH6c4Id7pRLC1ySLu+4og3nTTpBRBpg+uSkc4Ua6ce6A6RX14PPJ+TAXMfZyKNyaubQhgzLB/CfdXxZqWdAnyooiE7fb6CEB5uppnA5BpPdcWAkSixbwxRHbRC+OSCqMOV6+z+NlO/qSOKJcXfCQnJP/qjJTJde0dYhXG4RILOzIkGVieGJJONDXvj61mMj568IhJz0AEf/UMhvEL79iJ6yZW82Go/zcYkDDfd3KRE3pW+6p9Onu3XqOiQABS+9rEVRBnqYsPajiHBIanBeXpWKGbjznakvxhdRifhOWwAsQDfLmGzh+JnV1vOUjyxKtLNv9zi/oeuYCaIyF7F6en8LMbYSz8YONMZygGxMU=


================================================
FILE: cmd/age/testdata/scrypt.txt
================================================
[!linux] [!darwin] skip # no pty support
[darwin] [go1.20] skip # https://go.dev/issue/61779

# encrypt with a provided passphrase
stdin input
ttyin terminal
age -p -o test.age
ttyout 'Enter passphrase'
! stderr .
! stdout .

# decrypt with a provided passphrase
ttyin terminal
age -d test.age
ttyout 'Enter passphrase'
! stderr .
cmp stdout input

# decrypt with the wrong passphrase
ttyin wrong
! age -d test.age
stderr 'incorrect passphrase'

# encrypt with a generated passphrase
stdin input
ttyin empty
age -p -o test.age
! stderr .
! stdout .
ttyin autogenerated
age -d test.age
cmp stdout input

# fail when -i is present
ttyin terminal
! age -d -i key.txt test.age
stderr 'file is passphrase-encrypted but identities were specified'

# fail when passphrases don't match
ttyin wrong
! age -p -o fail.age
stderr 'passphrases didn''t match'
! exists fail.age

# fail when -i is missing
age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o test.age input
! age -d test.age
stderr 'file is not passphrase-encrypted, identities are required'

-- terminal --
password
password
-- wrong --
PASSWORD
password
-- input --
test
-- key.txt --
# created: 2021-02-02T13:09:43+01:00
# public key: age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef
AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0
-- autogenerated --
four-four-four-four-four-four-four-four-four-four
-- empty --



================================================
FILE: cmd/age/testdata/terminal.txt
================================================
[!linux] [!darwin] skip # no pty support
[darwin] [go1.20] skip # https://go.dev/issue/61779

# controlling terminal is used instead of stdin/stderr
ttyin terminal
age -p -o test.age input
! stderr .

# autogenerated passphrase is printed to terminal
ttyin empty
age -p -o test.age input
ttyout 'autogenerated passphrase'
! stderr .

# with no controlling terminal, stdin terminal is used
## TODO: enable once https://golang.org/issue/53601 is fixed
## and Noctty is added to testscript.
# noctty
# ttyin -stdin terminal
# age -p -o test.age input
# ! stderr .

# no terminal causes an error
## TODO: enable once https://golang.org/issue/53601 is fixed
## and Noctty is added to testscript.
# noctty
# ! age -p -o test.age input
# stderr 'standard input is not a terminal'

# prompt for password before plaintext if stdin is the terminal
exec cat terminal input # concatenated password + input
ttyin -stdin stdout
age -p -a -o test.age
ttyout 'Enter passphrase'
! stderr .
# check the file was encrypted correctly
ttyin terminal
age -d test.age
cmp stdout input

# buffer armored ciphertext before prompting if stdin is the terminal
ttyin terminal
age -p -a -o test.age input
exec cat test.age terminal # concatenated ciphertext + password
ttyin -stdin stdout
age -d
ttyout 'Enter passphrase'
! stderr .
cmp stdout input

-- input --
test
-- terminal --
password
password
-- empty --



================================================
FILE: cmd/age/testdata/usage.txt
================================================
# -help
age -p -help
! stdout .
stderr 'Usage:'

# -h
age -p -h
! stdout .
stderr 'Usage:'

# unknown flag
! age -p -this-flag-does-not-exist
! stdout .
stderr 'flag provided but not defined'
stderr 'Usage:'

# no arguments
! age
! stdout .
stderr 'Usage:'


================================================
FILE: cmd/age/testdata/x25519.txt
================================================
# encrypt and decrypt a file with -r
age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o test.age input
age -d -i key.txt test.age
cmp stdout input
! stderr .

# encrypt and decrypt a file with -i
age -e -i key.txt -o test.age input
age -d -i key.txt test.age
cmp stdout input
! stderr .

# encrypt and decrypt a file with the wrong key
age -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -o test.age input
! age -d -i key.txt test.age
stderr 'no identity matched any of the recipients'

# decrypt an empty file
! age -d -i key.txt empty
stderr empty

-- empty --
-- input --
test
-- key.txt --
# created: 2021-02-02T13:09:43+01:00
# public key: age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef
AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0


================================================
FILE: cmd/age/tui.go
================================================
// Copyright 2021 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

// This file implements the terminal UI of cmd/age. The rules are:
//
//   - Anything that requires user interaction goes to the terminal,
//     and is erased afterwards if possible. This UI would be possible
//     to replace with a pinentry with no output or UX changes.
//
//   - Everything else goes to standard error with an "age:" prefix.
//     No capitalized initials and no periods at the end.
//
// The one exception is the autogenerated passphrase, which goes to
// the terminal, since we really want it to reach the user only.

import (
	"bytes"
	"fmt"
	"io"
	"log"
	"os"

	"filippo.io/age/armor"
	"filippo.io/age/internal/term"
)

// l is a logger with no prefixes.
var l = log.New(os.Stderr, "", 0)

func printf(format string, v ...any) {
	l.Printf("age: "+format, v...)
}

func errorf(format string, v ...any) {
	l.Printf("age: error: "+format, v...)
	l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report")
	os.Exit(1)
}

func warningf(format string, v ...any) {
	l.Printf("age: warning: "+format, v...)
}

func errorWithHint(error string, hints ...string) {
	l.Printf("age: error: %s", error)
	for _, hint := range hints {
		l.Printf("age: hint: %s", hint)
	}
	l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report")
	os.Exit(1)
}

func printfToTerminal(format string, v ...any) error {
	return term.WithTerminal(func(_, out *os.File) error {
		_, err := fmt.Fprintf(out, "age: "+format+"\n", v...)
		return err
	})
}

func bufferTerminalInput(in io.Reader) (io.Reader, error) {
	buf := &bytes.Buffer{}
	if _, err := buf.ReadFrom(ReaderFunc(func(p []byte) (n int, err error) {
		if bytes.Contains(buf.Bytes(), []byte(armor.Footer+"\n")) {
			return 0, io.EOF
		}
		return in.Read(p)
	})); err != nil {
		return nil, err
	}
	return buf, nil
}

type ReaderFunc func(p []byte) (n int, err error)

func (f ReaderFunc) Read(p []byte) (n int, err error) { return f(p) }


================================================
FILE: cmd/age/wordlist.go
================================================
// Copyright 2019 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
	"crypto/rand"
	"encoding/binary"
	"strings"
)

var testOnlyFixedRandomWord string

func randomWord() string {
	if testOnlyFixedRandomWord != "" {
		return testOnlyFixedRandomWord
	}
	buf := make([]byte, 2)
	if _, err := rand.Read(buf); err != nil {
		panic(err)
	}
	n := binary.BigEndian.Uint16(buf)
	return wordlist[int(n)%2048]
}

// wordlist is the BIP39 list of 2048 english words, and it's used to generate
// the suggested passphrases.
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`, " ")


================================================
FILE: cmd/age-inspect/inspect.go
================================================
// Copyright 2025 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"log"
	"os"
	"runtime/debug"

	"filippo.io/age/internal/inspect"
)

const usage = `Usage:
    age-inspect [--json] [INPUT]

Options:
    --json                      Output machine-readable JSON.

INPUT defaults to standard input. "-" may be used as INPUT to explicitly
read from standard input.`

// Version can be set at link time to override debug.BuildInfo.Main.Version when
// building manually without git history. It should look like "v1.2.3".
var Version string

func main() {
	flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }

	var (
		versionFlag bool
		jsonFlag    bool
	)

	flag.BoolVar(&versionFlag, "version", false, "print the version")
	flag.BoolVar(&jsonFlag, "json", false, "output machine-readable JSON")
	flag.Parse()

	if versionFlag {
		if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
			Version = buildInfo.Main.Version
		}
		fmt.Println(Version)
		return
	}

	if flag.NArg() > 1 {
		flag.Usage()
		os.Exit(1)
	}

	in := os.Stdin
	var fileSize int64 = -1
	if name := flag.Arg(0); name != "" && name != "-" {
		f, err := os.Open(name)
		if err != nil {
			errorf("failed to open input file %q: %v", name, err)
		}
		defer f.Close()
		in = f
		if stat, err := f.Stat(); err == nil && stat.Mode().IsRegular() {
			fileSize = stat.Size()
		}
	}

	data, err := inspect.Inspect(in, fileSize)
	if err != nil {
		errorf("inspection failed: %v", err)
	}

	if jsonFlag {
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "    ")
		if err := enc.Encode(data); err != nil {
			errorf("failed to encode JSON output: %v", err)
		}
	} else {
		name := flag.Arg(0)
		if name == "" {
			name = "<stdin>"
		}
		fmt.Printf("%s is an age file, version %q.\n", name, data.Version)
		fmt.Printf("\n")
		if data.Armor {
			fmt.Printf("This file is ASCII-armored.\n")
			fmt.Printf("\n")
		}
		fmt.Printf("This file is encrypted to the following recipient types:\n")
		for _, t := range data.StanzaTypes {
			fmt.Printf("  - %q\n", t)
		}
		fmt.Printf("\n")
		switch data.Postquantum {
		case "yes":
			fmt.Printf("This file uses post-quantum encryption.\n")
			fmt.Printf("\n")
		case "no":
			fmt.Printf("This file does NOT use post-quantum encryption.\n")
			fmt.Printf("\n")
		}
		fmt.Printf("Size breakdown (assuming it decrypts successfully):\n")
		fmt.Printf("\n")
		fmt.Printf("    Header              % 12d bytes\n", data.Sizes.Header)
		if data.Armor {
			fmt.Printf("    Armor overhead      % 12d bytes\n", data.Sizes.Armor)
		}
		fmt.Printf("    Encryption overhead % 12d bytes\n", data.Sizes.Overhead)
		fmt.Printf("    Payload             % 12d bytes\n", data.Sizes.MinPayload)
		fmt.Printf("                        -------------------\n")
		total := data.Sizes.Header + data.Sizes.Overhead + data.Sizes.MinPayload + data.Sizes.Armor
		fmt.Printf("    Total               % 12d bytes\n", total)
		fmt.Printf("\n")
		fmt.Printf("Tip: for machine-readable output, use --json.\n")
	}
}

// l is a logger with no prefixes.
var l = log.New(os.Stderr, "", 0)

func errorf(format string, v ...any) {
	l.Printf("age-inspect: error: "+format, v...)
	l.Printf("age-inspect: report unexpected or unhelpful errors at https://filippo.io/age/report")
	os.Exit(1)
}


================================================
FILE: cmd/age-keygen/keygen.go
================================================
// Copyright 2019 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
	"flag"
	"fmt"
	"io"
	"log"
	"os"
	"runtime/debug"
	"time"

	"filippo.io/age"
	"golang.org/x/term"
)

const usage = `Usage:
    age-keygen [-pq] [-o OUTPUT]
    age-keygen -y [-o OUTPUT] [INPUT]

Options:
    -pq                       Generate a post-quantum hybrid ML-KEM-768 + X25519 key pair.
                              (This might become the default in the future.)
    -o, --output OUTPUT       Write the result to the file at path OUTPUT.
    -y                        Convert an identity file to a recipients file.

age-keygen generates a new native X25519 or, with the -pq flag, post-quantum
hybrid ML-KEM-768 + X25519 key pair, and outputs it to standard output or to
the OUTPUT file.

If an OUTPUT file is specified, the public key is printed to standard error.
If OUTPUT already exists, it is not overwritten.

In -y mode, age-keygen reads an identity file from INPUT or from standard
input and writes the corresponding recipient(s) to OUTPUT or to standard
output, one per line, with no comments.

Examples:

    $ age-keygen
    # created: 2021-01-02T15:30:45+01:00
    # public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z
    AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9

    $ age-keygen -pq
    # created: 2025-11-17T12:15:17+01:00
    # public key: age1pq1pd[... 1950 more characters ...]
    AGE-SECRET-KEY-PQ-1XXC4XS9DXHZ6TREKQTT3XECY8VNNU7GJ83C3Y49D0GZ3ZUME4JWS6QC3EF

    $ age-keygen -o key.txt
    Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

    $ age-keygen -y key.txt
    age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p`

// Version can be set at link time to override debug.BuildInfo.Main.Version when
// building manually without git history. It should look like "v1.2.3".
var Version string

func main() {
	log.SetFlags(0)
	flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }

	var outFlag string
	var pqFlag, versionFlag, convertFlag bool

	flag.BoolVar(&versionFlag, "version", false, "print the version")
	flag.BoolVar(&pqFlag, "pq", false, "generate a post-quantum key pair")
	flag.BoolVar(&convertFlag, "y", false, "convert identities to recipients")
	flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)")
	flag.StringVar(&outFlag, "output", "", "output to `FILE` (default stdout)")
	flag.Parse()

	if versionFlag {
		if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
			Version = buildInfo.Main.Version
		}
		fmt.Println(Version)
		return
	}

	if len(flag.Args()) != 0 && !convertFlag {
		errorf("too many arguments")
	}
	if len(flag.Args()) > 1 && convertFlag {
		errorf("too many arguments")
	}
	if pqFlag && convertFlag {
		errorf("-pq cannot be used with -y")
	}

	out := os.Stdout
	if outFlag != "" {
		f, err := os.OpenFile(outFlag, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
		if err != nil {
			errorf("failed to open output file %q: %v", outFlag, err)
		}
		defer func() {
			if err := f.Close(); err != nil {
				errorf("failed to close output file %q: %v", outFlag, err)
			}
		}()
		out = f
	}

	in := os.Stdin
	if inFile := flag.Arg(0); inFile != "" && inFile != "-" {
		f, err := os.Open(inFile)
		if err != nil {
			errorf("failed to open input file %q: %v", inFile, err)
		}
		defer f.Close()
		in = f
	}

	if convertFlag {
		convert(in, out)
	} else {
		if fi, err := out.Stat(); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 {
			warning("writing secret key to a world-readable file")
		}
		generate(out, pqFlag)
	}
}

func generate(out *os.File, pq bool) {
	var i age.Identity
	var r age.Recipient
	if pq {
		k, err := age.GenerateHybridIdentity()
		if err != nil {
			errorf("internal error: %v", err)
		}
		i = k
		r = k.Recipient()
	} else {
		k, err := age.GenerateX25519Identity()
		if err != nil {
			errorf("internal error: %v", err)
		}
		i = k
		r = k.Recipient()
	}

	if !term.IsTerminal(int(out.Fd())) {
		fmt.Fprintf(os.Stderr, "Public key: %s\n", r)
	}

	fmt.Fprintf(out, "# created: %s\n", time.Now().Format(time.RFC3339))
	fmt.Fprintf(out, "# public key: %s\n", r)
	fmt.Fprintf(out, "%s\n", i)
}

func convert(in io.Reader, out io.Writer) {
	ids, err := age.ParseIdentities(in)
	if err != nil {
		errorf("failed to parse input: %v", err)
	}
	if len(ids) == 0 {
		errorf("no identities found in the input")
	}
	for _, id := range ids {
		switch id := id.(type) {
		case *age.X25519Identity:
			fmt.Fprintf(out, "%s\n", id.Recipient())
		case *age.HybridIdentity:
			fmt.Fprintf(out, "%s\n", id.Recipient())
		default:
			errorf("internal error: unexpected identity type: %T", id)
		}

	}
}

func errorf(format string, v ...any) {
	log.Printf("age-keygen: error: "+format, v...)
	log.Fatalf("age-keygen: report unexpected or unhelpful errors at https://filippo.io/age/report")
}

func warning(msg string) {
	log.Printf("age-keygen: warning: %s", msg)
}


================================================
FILE: cmd/age-plugin-batchpass/plugin-batchpass.go
================================================
package main

import (
	"errors"
	"flag"
	"fmt"
	"io"
	"log"
	"os"
	"runtime/debug"
	"strconv"
	"strings"

	"filippo.io/age"
	"filippo.io/age/plugin"
)

const usage = `age-plugin-batchpass is an age plugin that enables non-interactive
passphrase-based encryption and decryption using environment variables.

WARNING: IN 90% OF CASES, YOU DON'T NEED THIS PLUGIN.

This functionality is not built into the age CLI because most applications
should use native keys instead of scripting passphrase-based encryption.

Humans are notoriously bad at remembering and generating strong passphrases.
age uses scrypt to partially mitigate this, which is necessarily very slow.

If a computer will be doing the remembering anyway, you can and should use
native keys instead. There is no need to manage separate public and private
keys, you encrypt directly to the private key:

    $ age-keygen -o key.txt
    $ age -e -i key.txt file.txt > file.txt.age
    $ age -d -i key.txt file.txt.age > file.txt

Likewise, you can store a native identity string in an environment variable
or through your CI secrets manager and use it to encrypt and decrypt files
non-interactively:

    $ export AGE_SECRET=$(age-keygen)
    $ age -e -i <(echo "$AGE_SECRET") file.txt > file.txt.age
    $ age -d -i <(echo "$AGE_SECRET") file.txt.age > file.txt

The age CLI also natively supports passphrase-encrypted identity files, so you
can use that functionality to non-interactively encrypt multiple files such that
you will be able to decrypt them later by entering the same passphrase:

    $ age-keygen -pq | age -p -o encrypted-identity.txt
    Public key: age1pq1cd[... 1950 more characters ...]
    Enter passphrase (leave empty to autogenerate a secure one):
    age: using autogenerated passphrase "eternal-erase-keen-suffer-fog-exclude-huge-scorpion-escape-scrub"
    $ age -r age1pq1cd[... 1950 more characters ...] file.txt > file.txt.age
    $ age -d -i encrypted-identity.txt file.txt.age > file.txt
    Enter passphrase for identity file "encrypted-identity.txt":

Finally, when using this plugin care should be taken not to let the password be
persisted in the shell history or leaked to other users on multi-user systems.

Usage:

    $ AGE_PASSPHRASE=password age -e -j batchpass file.txt > file.txt.age

    $ AGE_PASSPHRASE=password age -d -j batchpass file.txt.age > file.txt

Alternatively, you can use AGE_PASSPHRASE_FD to read the passphrase from
a file descriptor. Trailing newlines are stripped from the file contents.

When encrypting, you can set AGE_PASSPHRASE_WORK_FACTOR to adjust the scrypt
work factor (between 1 and 30, default 18). Higher values are more secure
but slower.

When decrypting, you can set AGE_PASSPHRASE_MAX_WORK_FACTOR to limit the
maximum scrypt work factor accepted (between 1 and 30, default 30). This can
be used to avoid very slow decryptions.`

// Version can be set at link time to override debug.BuildInfo.Main.Version when
// building manually without git history. It should look like "v1.2.3".
var Version string

func main() {
	flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }

	p, err := plugin.New("batchpass")
	if err != nil {
		log.Fatal(err)
	}
	p.RegisterFlags(nil)

	versionFlag := flag.Bool("version", false, "print the version")
	flag.Parse()

	if *versionFlag {
		if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
			Version = buildInfo.Main.Version
		}
		fmt.Println(Version)
		return
	}

	p.HandleIdentityAsRecipient(func(data []byte) (age.Recipient, error) {
		if len(data) != 0 {
			return nil, fmt.Errorf("batchpass identity does not take any payload")
		}
		pass, err := passphrase()
		if err != nil {
			return nil, err
		}
		r, err := age.NewScryptRecipient(pass)
		if err != nil {
			return nil, fmt.Errorf("failed to create scrypt recipient: %v", err)
		}
		if envWorkFactor := os.Getenv("AGE_PASSPHRASE_WORK_FACTOR"); envWorkFactor != "" {
			workFactor, err := strconv.Atoi(envWorkFactor)
			if err != nil {
				return nil, fmt.Errorf("invalid AGE_PASSPHRASE_WORK_FACTOR: %v", err)
			}
			if workFactor > 30 || workFactor < 1 {
				return nil, fmt.Errorf("AGE_PASSPHRASE_WORK_FACTOR must be between 1 and 30")
			}
			r.SetWorkFactor(workFactor)
		}
		return r, nil
	})
	p.HandleIdentity(func(data []byte) (age.Identity, error) {
		if len(data) != 0 {
			return nil, fmt.Errorf("batchpass identity does not take any payload")
		}
		pass, err := passphrase()
		if err != nil {
			return nil, err
		}
		maxWorkFactor := 0
		if envMaxWorkFactor := os.Getenv("AGE_PASSPHRASE_MAX_WORK_FACTOR"); envMaxWorkFactor != "" {
			maxWorkFactor, err = strconv.Atoi(envMaxWorkFactor)
			if err != nil {
				return nil, fmt.Errorf("invalid AGE_PASSPHRASE_MAX_WORK_FACTOR: %v", err)
			}
			if maxWorkFactor > 30 || maxWorkFactor < 1 {
				return nil, fmt.Errorf("AGE_PASSPHRASE_MAX_WORK_FACTOR must be between 1 and 30")
			}
		}
		return &batchpassIdentity{password: pass, maxWorkFactor: maxWorkFactor}, nil
	})
	os.Exit(p.Main())
}

type batchpassIdentity struct {
	password      string
	maxWorkFactor int
}

func (i *batchpassIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
	for _, s := range stanzas {
		if s.Type == "scrypt" && len(stanzas) != 1 {
			return nil, errors.New("an scrypt recipient must be the only one")
		}
	}
	if len(stanzas) != 1 || stanzas[0].Type != "scrypt" {
		// Don't fallback to other identities, this plugin should mostly be used
		// in isolation, from the CLI.
		return nil, fmt.Errorf("file is not passphrase-encrypted")
	}
	ii, err := age.NewScryptIdentity(i.password)
	if err != nil {
		return nil, err
	}
	if i.maxWorkFactor != 0 {
		ii.SetMaxWorkFactor(i.maxWorkFactor)
	}
	fileKey, err := ii.Unwrap(stanzas)
	if errors.Is(err, age.ErrIncorrectIdentity) {
		// ScryptIdentity returns ErrIncorrectIdentity to make it possible to
		// try multiple passphrases from the API. If a user is invoking this
		// plugin, it's safe to say they expect it to be the only mechanism to
		// decrypt a passphrase-protected file.
		return nil, fmt.Errorf("incorrect passphrase")
	}
	return fileKey, err
}

func passphrase() (string, error) {
	envPASSPHRASE := os.Getenv("AGE_PASSPHRASE")
	envFD := os.Getenv("AGE_PASSPHRASE_FD")
	if envPASSPHRASE != "" && envFD != "" {
		return "", fmt.Errorf("AGE_PASSPHRASE and AGE_PASSPHRASE_FD are mutually exclusive")
	}
	if envPASSPHRASE == "" && envFD == "" {
		return "", fmt.Errorf("either AGE_PASSPHRASE or AGE_PASSPHRASE_FD must be set")
	}

	if envPASSPHRASE != "" {
		return envPASSPHRASE, nil
	}

	fd, err := strconv.Atoi(envFD)
	if err != nil {
		return "", fmt.Errorf("invalid AGE_PASSPHRASE_FD: %v", err)
	}
	f := os.NewFile(uintptr(fd), "AGE_PASSPHRASE_FD")
	if f == nil {
		return "", fmt.Errorf("failed to open file descriptor %d", fd)
	}
	defer f.Close()
	const maxPassphraseSize = 1024 * 1024 // 1 MiB
	b, err := io.ReadAll(io.LimitReader(f, maxPassphraseSize+1))
	if err != nil {
		return "", fmt.Errorf("failed to read passphrase from fd %d: %v", fd, err)
	}
	if len(b) > maxPassphraseSize {
		return "", fmt.Errorf("passphrase from fd %d is too long", fd)
	}
	return strings.TrimRight(string(b), "\r\n"), nil
}


================================================
FILE: doc/age-inspect.1
================================================
.\" generated with Ronn-NG/v0.9.1
.\" http://github.com/apjanke/ronn-ng/tree/0.9.1
.TH "AGE\-INSPECT" "1" "December 2025" ""
.SH "NAME"
\fBage\-inspect\fR \- inspect age(1) encrypted files
.SH "SYNOPSIS"
\fBage\-inspect\fR [\fB\-\-json\fR] [\fIINPUT\fR]
.SH "DESCRIPTION"
\fBage\-inspect\fR reads an age(1) encrypted file from \fIINPUT\fR (or standard input) and displays metadata about it without decrypting\.
.P
This includes the recipient types, whether it uses post\-quantum encryption, and a size breakdown of the file components\.
.SH "OPTIONS"
.TP
\fB\-\-json\fR
Output machine\-readable JSON instead of human\-readable text\.
.TP
\fB\-\-version\fR
Print the version and exit\.
.SH "JSON FORMAT"
When \fB\-\-json\fR is specified, the output is a JSON object with these fields:
.IP "\[ci]" 4
\fBversion\fR: The age format version (e\.g\., \fB"age\-encryption\.org/v1"\fR)\.
.IP "\[ci]" 4
\fBpostquantum\fR: Whether the file uses post\-quantum encryption: \fB"yes"\fR, \fB"no"\fR, or \fB"unknown"\fR\.
.IP "\[ci]" 4
\fBarmor\fR: Boolean indicating whether the file is ASCII\-armored\.
.IP "\[ci]" 4
\fBstanza_types\fR: Array of recipient stanza type strings (e\.g\., \fB["X25519"]\fR or \fB["mlkem768x25519"]\fR)\.
.IP "\[ci]" 4
\fBsizes\fR: Object containing size information in bytes:
.IP "\[ci]" 4
\fBheader\fR: Size of the age header\.
.IP "\[ci]" 4
\fBarmor\fR: Armor encoding overhead (0 if not armored)\.
.IP "\[ci]" 4
\fBoverhead\fR: Stream encryption overhead\.
.IP "\[ci]" 4
\fBmin_payload\fR, \fBmax_payload\fR: Payload size bounds (currently always matching)\.
.IP "\[ci]" 4
\fBmin_padding\fR, \fBmax_padding\fR: Padding size bounds (currently always 0)\.
.IP "" 0
.IP
The fields add up to the total size of the file\.
.IP "" 0
.SH "EXAMPLES"
Inspect an encrypted file:
.IP "" 4
.nf
$ age\-inspect secrets\.age
secrets\.age is an age file, version "age\-encryption\.org/v1"\.

This file is encrypted to the following recipient types:
\- "mlkem768x25519"

This file uses post\-quantum encryption\.

Size breakdown (assuming it decrypts successfully):

    Header                      1627 bytes
    Encryption overhead           32 bytes
    Payload                       42 bytes
                        \-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-
    Total                       1701 bytes

Tip: for machine\-readable output, use \-\-json\.
.fi
.IP "" 0
.P
Get JSON output for scripting:
.IP "" 4
.nf
$ age\-inspect \-\-json secrets\.age
{
    "version": "age\-encryption\.org/v1",
    "postquantum": "yes",
    "armor": false,
    "stanza_types": [
        "mlkem768x25519"
    ],
    "sizes": {
        "header": 1627,
        "armor": 0,
        "overhead": 32,
        "min_payload": 42,
        "max_payload": 42,
        "min_padding": 0,
        "max_padding": 0
    }
}
.fi
.IP "" 0
.SH "SEE ALSO"
age(1), age\-keygen(1)
.SH "AUTHORS"
Filippo Valsorda \fIage@filippo\.io\fR


================================================
FILE: doc/age-inspect.1.html
================================================
<!DOCTYPE html>
<html>
<head>
  <meta http-equiv='content-type' content='text/html;charset=utf8'>
  <meta name='generator' content='Ronn-NG/v0.9.1 (http://github.com/apjanke/ronn-ng/tree/0.9.1)'>
  <title>age-inspect(1) - inspect age(1) encrypted files</title>
  <style type='text/css' media='all'>
  /* style: man */
  body#manpage {margin:0}
  .mp {max-width:100ex;padding:0 9ex 1ex 4ex}
  .mp p,.mp pre,.mp ul,.mp ol,.mp dl {margin:0 0 20px 0}
  .mp h2 {margin:10px 0 0 0}
  .mp > p,.mp > pre,.mp > ul,.mp > ol,.mp > dl {margin-left:8ex}
  .mp h3 {margin:0 0 0 4ex}
  .mp dt {margin:0;clear:left}
  .mp dt.flush {float:left;width:8ex}
  .mp dd {margin:0 0 0 9ex}
  .mp h1,.mp h2,.
Download .txt
gitextract_a732ze8h/

├── .gitattributes
├── .github/
│   ├── CONTRIBUTING.md
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug-report.md
│   │   └── config.yml
│   └── workflows/
│       ├── LICENSE.suffix.txt
│       ├── build.yml
│       ├── certs/
│       │   ├── README
│       │   ├── uitacllc.crt
│       │   └── uitacllc.key
│       ├── interop.yml
│       ├── ronn.yml
│       └── test.yml
├── LICENSE
├── README.md
├── SIGSUM.md
├── age.go
├── age_test.go
├── agessh/
│   ├── agessh.go
│   ├── agessh_test.go
│   └── encrypted_keys.go
├── armor/
│   ├── armor.go
│   └── armor_test.go
├── cmd/
│   ├── age/
│   │   ├── age.go
│   │   ├── age_test.go
│   │   ├── encrypted_keys.go
│   │   ├── parse.go
│   │   ├── testdata/
│   │   │   ├── armor.txt
│   │   │   ├── batchpass.txt
│   │   │   ├── duplicates.txt
│   │   │   ├── ed25519.txt
│   │   │   ├── encrypted_keys.txt
│   │   │   ├── hybrid.txt
│   │   │   ├── keygen.txt
│   │   │   ├── output_file.txt
│   │   │   ├── pkcs8.txt
│   │   │   ├── plugin.txt
│   │   │   ├── rsa.txt
│   │   │   ├── scrypt.txt
│   │   │   ├── terminal.txt
│   │   │   ├── usage.txt
│   │   │   └── x25519.txt
│   │   ├── tui.go
│   │   └── wordlist.go
│   ├── age-inspect/
│   │   └── inspect.go
│   ├── age-keygen/
│   │   └── keygen.go
│   └── age-plugin-batchpass/
│       └── plugin-batchpass.go
├── doc/
│   ├── age-inspect.1
│   ├── age-inspect.1.html
│   ├── age-inspect.1.ronn
│   ├── age-keygen.1
│   ├── age-keygen.1.html
│   ├── age-keygen.1.ronn
│   ├── age-plugin-batchpass.1
│   ├── age-plugin-batchpass.1.html
│   ├── age-plugin-batchpass.1.ronn
│   ├── age.1
│   ├── age.1.html
│   └── age.1.ronn
├── extra/
│   ├── age-plugin-pq/
│   │   └── plugin-pq.go
│   ├── age-plugin-tag/
│   │   └── plugin-tag.go
│   └── age-plugin-tagpq/
│       └── plugin-tagpq.go
├── go.mod
├── go.sum
├── internal/
│   ├── bech32/
│   │   ├── bech32.go
│   │   └── bech32_test.go
│   ├── format/
│   │   ├── format.go
│   │   └── format_test.go
│   ├── inspect/
│   │   ├── inspect.go
│   │   └── inspect_test.go
│   ├── stream/
│   │   ├── stream.go
│   │   └── stream_test.go
│   └── term/
│       ├── term.go
│       └── term_windows.go
├── logo/
│   └── README.md
├── parse.go
├── plugin/
│   ├── client.go
│   ├── client_test.go
│   ├── encode.go
│   ├── example_test.go
│   ├── plugin.go
│   └── tui.go
├── pq.go
├── primitives.go
├── recipients_test.go
├── scrypt.go
├── tag/
│   ├── internal/
│   │   ├── age-plugin-tagtest/
│   │   │   └── plugin-tagtest.go
│   │   └── tagtest/
│   │       └── tagtest.go
│   ├── tag.go
│   └── tag_test.go
├── testdata/
│   ├── example.age
│   ├── example.zip.age
│   └── example_keys.txt
├── testkit_test.go
└── x25519.go
Download .txt
SYMBOL INDEX (443 symbols across 46 files)

FILE: age.go
  type Identity (line 65) | type Identity interface
  type Recipient (line 82) | type Recipient interface
  type RecipientWithLabels (line 99) | type RecipientWithLabels interface
  type Stanza (line 109) | type Stanza struct
  constant fileKeySize (line 115) | fileKeySize = 16
  constant streamNonceSize (line 116) | streamNonceSize = 16
  function encryptHdr (line 118) | func encryptHdr(fileKey []byte, recipients ...Recipient) (*format.Header...
  function Encrypt (line 154) | func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, er...
  function EncryptReader (line 180) | func EncryptReader(src io.Reader, recipients ...Recipient) (io.Reader, e...
  function wrapWithLabels (line 203) | func wrapWithLabels(r Recipient, fileKey []byte) (s []*Stanza, labels []...
  function slicesEqual (line 211) | func slicesEqual(s1, s2 []string) bool {
  function incompatibleLabelsError (line 223) | func incompatibleLabelsError(l1, l2 []string) error {
  type NoIdentityMatchError (line 234) | type NoIdentityMatchError struct
    method Error (line 243) | func (e *NoIdentityMatchError) Error() string {
    method Unwrap (line 250) | func (e *NoIdentityMatchError) Unwrap() []error {
  function Decrypt (line 261) | func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error) {
  function DecryptReaderAt (line 297) | func DecryptReaderAt(src io.ReaderAt, encryptedSize int64, identities .....
  function decryptHdr (line 332) | func decryptHdr(hdr *format.Header, identities ...Identity) ([]byte, err...
  function multiUnwrap (line 390) | func multiUnwrap(unwrap func(*Stanza) ([]byte, error), stanzas []*Stanza...
  function ExtractHeader (line 414) | func ExtractHeader(src io.Reader) ([]byte, error) {
  function DecryptHeader (line 435) | func DecryptHeader(header []byte, identities ...Identity) ([]byte, error) {
  type injectedFileKeyIdentity (line 443) | type injectedFileKeyIdentity struct
    method Unwrap (line 456) | func (i injectedFileKeyIdentity) Unwrap(stanzas []*Stanza) (fileKey []...
  function NewInjectedFileKeyIdentity (line 452) | func NewInjectedFileKeyIdentity(fileKey []byte) Identity {

FILE: age_test.go
  function ExampleEncrypt (line 23) | func ExampleEncrypt() {
  function init (line 52) | func init() {
  function ExampleDecrypt (line 56) | func ExampleDecrypt() {
  function ExampleParseIdentities (line 81) | func ExampleParseIdentities() {
  function ExampleGenerateX25519Identity (line 110) | func ExampleGenerateX25519Identity() {
  constant helloWorld (line 123) | helloWorld = "Hello, Twitch!"
  function TestEncryptDecryptX25519 (line 125) | func TestEncryptDecryptX25519(t *testing.T) {
  function TestEncryptDecryptScrypt (line 159) | func TestEncryptDecryptScrypt(t *testing.T) {
  function ExampleDecryptReaderAt (line 196) | func ExampleDecryptReaderAt() {
  function TestParseIdentities (line 230) | func TestParseIdentities(t *testing.T) {
  type testRecipient (line 262) | type testRecipient struct
    method Wrap (line 266) | func (testRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
    method WrapWithLabels (line 270) | func (t testRecipient) WrapWithLabels(fileKey []byte) (s []*age.Stanza...
  function TestLabels (line 274) | func TestLabels(t *testing.T) {
  type testIdentity (line 327) | type testIdentity struct
    method Unwrap (line 331) | func (ti *testIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
  function TestDecryptNativeIdentitiesFirst (line 336) | func TestDecryptNativeIdentitiesFirst(t *testing.T) {
  type stanzaTypeRecipient (line 370) | type stanzaTypeRecipient
    method Wrap (line 372) | func (s stanzaTypeRecipient) Wrap(fileKey []byte) ([]*age.Stanza, erro...
  function TestNoIdentityMatchErrorStanzaTypes (line 376) | func TestNoIdentityMatchErrorStanzaTypes(t *testing.T) {
  function TestScryptIdentityErrors (line 418) | func TestScryptIdentityErrors(t *testing.T) {
  function TestDetachedHeader (line 483) | func TestDetachedHeader(t *testing.T) {
  function TestEncryptReader (line 526) | func TestEncryptReader(t *testing.T) {

FILE: agessh/agessh.go
  function sshFingerprint (line 35) | func sshFingerprint(pk ssh.PublicKey) string {
  constant oaepLabel (line 40) | oaepLabel = "age-encryption.org/v1/ssh-rsa"
  type RSARecipient (line 42) | type RSARecipient struct
    method Wrap (line 72) | func (r *RSARecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
  function NewRSARecipient (line 49) | func NewRSARecipient(pk ssh.PublicKey) (*RSARecipient, error) {
  type RSAIdentity (line 88) | type RSAIdentity struct
    method Recipient (line 106) | func (i *RSAIdentity) Recipient() *RSARecipient {
    method Unwrap (line 113) | func (i *RSAIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
    method unwrap (line 117) | func (i *RSAIdentity) unwrap(block *age.Stanza) ([]byte, error) {
  function NewRSAIdentity (line 95) | func NewRSAIdentity(key *rsa.PrivateKey) (*RSAIdentity, error) {
  type Ed25519Recipient (line 137) | type Ed25519Recipient struct
    method Wrap (line 202) | func (r *Ed25519Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
  function NewEd25519Recipient (line 144) | func NewEd25519Recipient(pk ssh.PublicKey) (*Ed25519Recipient, error) {
  function ParseRecipient (line 168) | func ParseRecipient(s string) (age.Recipient, error) {
  function ed25519PublicKeyToCurve25519 (line 190) | func ed25519PublicKeyToCurve25519(pk ed25519.PublicKey) ([]byte, error) {
  constant ed25519Label (line 200) | ed25519Label = "age-encryption.org/v1/ssh-ed25519"
  type Ed25519Identity (line 248) | type Ed25519Identity struct
    method Recipient (line 294) | func (i *Ed25519Identity) Recipient() *Ed25519Recipient {
    method Unwrap (line 301) | func (i *Ed25519Identity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
    method unwrap (line 305) | func (i *Ed25519Identity) unwrap(block *age.Stanza) ([]byte, error) {
  function NewEd25519Identity (line 255) | func NewEd25519Identity(key ed25519.PrivateKey) (*Ed25519Identity, error) {
  function ParseIdentity (line 268) | func ParseIdentity(pemBytes []byte) (age.Identity, error) {
  function ed25519PrivateKeyToCurve25519 (line 287) | func ed25519PrivateKeyToCurve25519(pk ed25519.PrivateKey) []byte {
  function multiUnwrap (line 355) | func multiUnwrap(unwrap func(*age.Stanza) ([]byte, error), stanzas []*ag...
  function aeadEncrypt (line 380) | func aeadEncrypt(key, plaintext []byte) ([]byte, error) {
  function aeadDecrypt (line 389) | func aeadDecrypt(key, ciphertext []byte) ([]byte, error) {

FILE: agessh/agessh_test.go
  function TestSSHRSARoundTrip (line 19) | func TestSSHRSARoundTrip(t *testing.T) {
  function TestSSHEd25519RoundTrip (line 62) | func TestSSHEd25519RoundTrip(t *testing.T) {

FILE: agessh/encrypted_keys.go
  type EncryptedSSHIdentity (line 24) | type EncryptedSSHIdentity struct
    method Recipient (line 70) | func (i *EncryptedSSHIdentity) Recipient() age.Recipient {
    method Unwrap (line 77) | func (i *EncryptedSSHIdentity) Unwrap(stanzas []*age.Stanza) (fileKey ...
  function NewEncryptedSSHIdentity (line 43) | func NewEncryptedSSHIdentity(pubKey ssh.PublicKey, pemBytes []byte, pass...

FILE: armor/armor.go
  constant Header (line 24) | Header = "-----BEGIN AGE ENCRYPTED FILE-----"
  constant Footer (line 25) | Footer = "-----END AGE ENCRYPTED FILE-----"
  type armoredWriter (line 28) | type armoredWriter struct
    method Write (line 34) | func (a *armoredWriter) Write(p []byte) (int, error) {
    method Close (line 44) | func (a *armoredWriter) Close() error {
  function NewWriter (line 60) | func NewWriter(dst io.Writer) io.WriteCloser {
  type armoredReader (line 68) | type armoredReader struct
    method Read (line 80) | func (r *armoredReader) Read(p []byte) (int, error) {
    method setErr (line 184) | func (r *armoredReader) setErr(err error) error {
  function NewReader (line 76) | func NewReader(r io.Reader) io.Reader {
  type Error (line 172) | type Error struct
    method Error (line 176) | func (e *Error) Error() string {
    method Unwrap (line 180) | func (e *Error) Unwrap() error {

FILE: armor/armor_test.go
  function ExampleNewWriter (line 26) | func ExampleNewWriter() {
  function ExampleNewReader (line 59) | func ExampleNewReader() {
  function TestArmor (line 92) | func TestArmor(t *testing.T) {
  function testArmor (line 97) | func testArmor(t *testing.T, size int) {
  function FuzzMalleability (line 136) | func FuzzMalleability(f *testing.F) {
  function normalize (line 177) | func normalize(f []byte) []byte {

FILE: cmd/age-inspect/inspect.go
  constant usage (line 18) | usage = `Usage:
  function main (line 31) | func main() {
  function errorf (line 124) | func errorf(format string, v ...any) {

FILE: cmd/age-keygen/keygen.go
  constant usage (line 20) | usage = `Usage:
  function main (line 63) | func main() {
  function generate (line 129) | func generate(out *os.File, pq bool) {
  function convert (line 157) | func convert(in io.Reader, out io.Writer) {
  function errorf (line 178) | func errorf(format string, v ...any) {
  function warning (line 183) | func warning(msg string) {

FILE: cmd/age-plugin-batchpass/plugin-batchpass.go
  constant usage (line 18) | usage = `age-plugin-batchpass is an age plugin that enables non-interactive
  function main (line 81) | func main() {
  type batchpassIdentity (line 148) | type batchpassIdentity struct
    method Unwrap (line 153) | func (i *batchpassIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, err...
  function passphrase (line 182) | func passphrase() (string, error) {

FILE: cmd/age/age.go
  constant usage (line 30) | usage = `Usage:
  type multiFlag (line 74) | type multiFlag
    method String (line 76) | func (f *multiFlag) String() string { return fmt.Sprint(*f) }
    method Set (line 78) | func (f *multiFlag) Set(value string) error {
  type identityFlag (line 83) | type identityFlag struct
  type identityFlags (line 89) | type identityFlags
    method addIdentityFlag (line 91) | func (f *identityFlags) addIdentityFlag(value string) error {
    method addPluginFlag (line 96) | func (f *identityFlags) addPluginFlag(value string) error {
  function main (line 105) | func main() {
  function passphrasePromptForEncryption (line 323) | func passphrasePromptForEncryption() (string, error) {
  function encryptNotPass (line 351) | func encryptNotPass(recs, files []string, identities identityFlags, in i...
  function encryptPass (line 395) | func encryptPass(in io.Reader, out io.Writer, armor bool) {
  function encrypt (line 411) | func encrypt(recipients []age.Recipient, in io.Reader, out io.Writer, wi...
  constant crlfMangledIntro (line 440) | crlfMangledIntro = "age-encryption.org/v1" + "\r"
  constant utf16MangledIntro (line 441) | utf16MangledIntro = "\xff\xfe" + "a\x00g\x00e\x00-\x00e\x00n\x00c\x00r\x...
  type rejectScryptIdentity (line 443) | type rejectScryptIdentity struct
    method Unwrap (line 445) | func (rejectScryptIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, err...
  function decryptNotPass (line 454) | func decryptNotPass(flags identityFlags, in io.Reader, out io.Writer) {
  function decryptPass (line 476) | func decryptPass(in io.Reader, out io.Writer) {
  function decrypt (line 486) | func decrypt(identities []age.Identity, in io.Reader, out io.Writer) {
  function passphrasePromptForDecryption (line 523) | func passphrasePromptForDecryption() (string, error) {
  function identitiesToRecipients (line 531) | func identitiesToRecipients(ids []age.Identity) ([]age.Recipient, error) {
  type lazyOpener (line 560) | type lazyOpener struct
    method Write (line 570) | func (l *lazyOpener) Write(p []byte) (n int, err error) {
    method Close (line 580) | func (l *lazyOpener) Close() error {
  function newLazyOpener (line 566) | func newLazyOpener(name string) io.WriteCloser {
  function absPath (line 587) | func absPath(name string) string {
  function warnDuplicates (line 594) | func warnDuplicates(s iter.Seq[string], name string) {

FILE: cmd/age/age_test.go
  function TestMain (line 19) | func TestMain(m *testing.M) {
  type testPlugin (line 41) | type testPlugin struct
    method Wrap (line 43) | func (testPlugin) Wrap(fileKey []byte) ([]*age.Stanza, error) {
    method Unwrap (line 47) | func (testPlugin) Unwrap(ss []*age.Stanza) ([]byte, error) {
  function TestScript (line 69) | func TestScript(t *testing.T) {

FILE: cmd/age/encrypted_keys.go
  type LazyScryptIdentity (line 18) | type LazyScryptIdentity struct
    method Unwrap (line 24) | func (i *LazyScryptIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []...
  type EncryptedIdentity (line 53) | type EncryptedIdentity struct
    method Recipients (line 63) | func (i *EncryptedIdentity) Recipients() ([]age.Recipient, error) {
    method Unwrap (line 73) | func (i *EncryptedIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []b...
    method decrypt (line 94) | func (i *EncryptedIdentity) decrypt() error {

FILE: cmd/age/parse.go
  type gitHubRecipientError (line 26) | type gitHubRecipientError struct
    method Error (line 30) | func (gitHubRecipientError) Error() string {
  function parseRecipient (line 34) | func parseRecipient(arg string) (age.Recipient, error) {
  function parseRecipientsFile (line 54) | func parseRecipientsFile(name string) ([]age.Recipient, error) {
  function sshKeyType (line 113) | func sshKeyType(s string) (string, bool) {
  function parseIdentitiesFile (line 140) | func parseIdentitiesFile(name string) ([]age.Identity, error) {
  function parseIdentity (line 212) | func parseIdentity(s string) (age.Identity, error) {
  function parseIdentities (line 226) | func parseIdentities(f io.Reader) ([]age.Identity, error) {
  function parseSSHIdentity (line 258) | func parseSSHIdentity(name string, pemBytes []byte) ([]age.Identity, err...
  function readPubFile (line 288) | func readPubFile(name string) (ssh.PublicKey, error) {

FILE: cmd/age/tui.go
  function printf (line 33) | func printf(format string, v ...any) {
  function errorf (line 37) | func errorf(format string, v ...any) {
  function warningf (line 43) | func warningf(format string, v ...any) {
  function errorWithHint (line 47) | func errorWithHint(error string, hints ...string) {
  function printfToTerminal (line 56) | func printfToTerminal(format string, v ...any) error {
  function bufferTerminalInput (line 63) | func bufferTerminalInput(in io.Reader) (io.Reader, error) {
  type ReaderFunc (line 76) | type ReaderFunc
    method Read (line 78) | func (f ReaderFunc) Read(p []byte) (n int, err error) { return f(p) }

FILE: cmd/age/wordlist.go
  function randomWord (line 15) | func randomWord() string {

FILE: extra/age-plugin-pq/plugin-pq.go
  constant usage (line 16) | usage = `Usage:
  function main (line 37) | func main() {
  function convert (line 126) | func convert(in io.Reader, out io.Writer) {
  function errorf (line 144) | func errorf(format string, v ...any) {
  function warning (line 149) | func warning(msg string) {

FILE: extra/age-plugin-tag/plugin-tag.go
  constant usage (line 15) | usage = `age-plugin-tag is an age plugin for P-256 tagged recipients. Th...
  function main (line 26) | func main() {

FILE: extra/age-plugin-tagpq/plugin-tagpq.go
  constant usage (line 15) | usage = `age-plugin-tagpq is an age plugin for ML-KEM-768 + P-256 post-q...
  function main (line 27) | func main() {

FILE: internal/bech32/bech32.go
  function polymod (line 34) | func polymod(values []byte) uint32 {
  function hrpExpand (line 50) | func hrpExpand(hrp string) []byte {
  function verifyChecksum (line 63) | func verifyChecksum(hrp string, data []byte) bool {
  function createChecksum (line 67) | func createChecksum(hrp string, data []byte) []byte {
  function convertBits (line 79) | func convertBits(data []byte, frombits, tobits byte, pad bool) ([]byte, ...
  function Encode (line 109) | func Encode(hrp string, data []byte) (string, error) {
  function Decode (line 143) | func Decode(s string) (hrp string, data []byte, err error) {

FILE: internal/bech32/bech32_test.go
  function TestBech32 (line 26) | func TestBech32(t *testing.T) {

FILE: internal/format/format.go
  type Header (line 18) | type Header struct
    method MarshalWithoutMAC (line 133) | func (h *Header) MarshalWithoutMAC(w io.Writer) error {
    method Marshal (line 146) | func (h *Header) Marshal(w io.Writer) error {
  type Stanza (line 25) | type Stanza struct
    method Marshal (line 110) | func (r *Stanza) Marshal(w io.Writer) error {
  function DecodeString (line 33) | func DecodeString(s string) ([]byte, error) {
  constant ColumnsPerLine (line 43) | ColumnsPerLine = 64
  constant BytesPerLine (line 45) | BytesPerLine = ColumnsPerLine / 4 * 3
  function NewWrappedBase64Encoder (line 48) | func NewWrappedBase64Encoder(enc *base64.Encoding, dst io.Writer) *Wrapp...
  type WriterFunc (line 54) | type WriterFunc
    method Write (line 56) | func (f WriterFunc) Write(p []byte) (int, error) { return f(p) }
  type WrappedBase64Encoder (line 62) | type WrappedBase64Encoder struct
    method Write (line 69) | func (w *WrappedBase64Encoder) Write(p []byte) (int, error) { return w...
    method Close (line 71) | func (w *WrappedBase64Encoder) Close() error {
    method writeWrapped (line 75) | func (w *WrappedBase64Encoder) writeWrapped(p []byte) (int, error) {
    method LastLineIsEmpty (line 101) | func (w *WrappedBase64Encoder) LastLineIsEmpty() bool {
  constant intro (line 105) | intro = "age-encryption.org/v1\n"
  type StanzaReader (line 155) | type StanzaReader struct
    method ReadStanza (line 164) | func (r *StanzaReader) ReadStanza() (s *Stanza, err error) {
  function NewStanzaReader (line 160) | func NewStanzaReader(r *bufio.Reader) *StanzaReader {
  type ParseError (line 216) | type ParseError struct
    method Error (line 220) | func (e *ParseError) Error() string {
    method Unwrap (line 224) | func (e *ParseError) Unwrap() error {
  function errorf (line 228) | func errorf(format string, a ...any) error {
  function Parse (line 234) | func Parse(input io.Reader) (*Header, io.Reader, error) {
  function splitArgs (line 294) | func splitArgs(line []byte) (string, []string) {
  function isValidString (line 300) | func isValidString(s string) bool {

FILE: internal/format/format_test.go
  function TestStanzaMarshal (line 19) | func TestStanzaMarshal(t *testing.T) {
  function FuzzMalleability (line 46) | func FuzzMalleability(f *testing.F) {

FILE: internal/inspect/inspect.go
  type Metadata (line 15) | type Metadata struct
  function Inspect (line 33) | func Inspect(r io.Reader, fileSize int64) (*Metadata, error) {
  type trackReader (line 99) | type trackReader struct
    method Read (line 105) | func (tr *trackReader) Read(p []byte) (int, error) {
  function streamOverhead (line 116) | func streamOverhead(payloadSize int64) (int64, error) {

FILE: internal/inspect/inspect_test.go
  function TestStreamOverhead (line 10) | func TestStreamOverhead(t *testing.T) {

FILE: internal/stream/stream.go
  constant ChunkSize (line 20) | ChunkSize = 64 * 1024
  function EncryptedChunkCount (line 22) | func EncryptedChunkCount(encryptedSize int64) (int64, error) {
  function PlaintextSize (line 38) | func PlaintextSize(encryptedSize int64) (int64, error) {
  type DecryptReader (line 47) | type DecryptReader struct
    method Read (line 71) | func (r *DecryptReader) Read(p []byte) (int, error) {
    method readChunk (line 113) | func (r *DecryptReader) readChunk() (last bool, err error) {
  constant encChunkSize (line 59) | encChunkSize  = ChunkSize + chacha20poly1305.Overhead
  constant lastChunkFlag (line 60) | lastChunkFlag = 0x01
  function NewDecryptReader (line 63) | func NewDecryptReader(key []byte, src io.Reader) (*DecryptReader, error) {
  function incNonce (line 154) | func incNonce(nonce *[chacha20poly1305.NonceSize]byte) {
  function nonceForChunk (line 165) | func nonceForChunk(chunkIndex int64) *[chacha20poly1305.NonceSize]byte {
  function setLastChunkFlag (line 171) | func setLastChunkFlag(nonce *[chacha20poly1305.NonceSize]byte) {
  function nonceIsZero (line 175) | func nonceIsZero(nonce *[chacha20poly1305.NonceSize]byte) bool {
  type EncryptWriter (line 179) | type EncryptWriter struct
    method Write (line 195) | func (w *EncryptWriter) Write(p []byte) (n int, err error) {
    method Close (line 222) | func (w *EncryptWriter) Close() error {
    method flushChunk (line 241) | func (w *EncryptWriter) flushChunk(last bool) error {
  function NewEncryptWriter (line 187) | func NewEncryptWriter(key []byte, dst io.Writer) (*EncryptWriter, error) {
  constant lastChunk (line 237) | lastChunk    = true
  constant notLastChunk (line 238) | notLastChunk = false
  type EncryptReader (line 257) | type EncryptReader struct
    method Read (line 278) | func (r *EncryptReader) Read(p []byte) (int, error) {
    method feedBuffer (line 303) | func (r *EncryptReader) feedBuffer() error {
  function NewEncryptReader (line 270) | func NewEncryptReader(key []byte, src io.Reader) (*EncryptReader, error) {
  type DecryptReaderAt (line 349) | type DecryptReaderAt struct
    method ReadAt (line 394) | func (r *DecryptReaderAt) ReadAt(p []byte, off int64) (n int, err erro...
  type cachedChunk (line 357) | type cachedChunk struct
  function NewDecryptReaderAt (line 362) | func NewDecryptReaderAt(key []byte, src io.ReaderAt, size int64) (*Decry...

FILE: internal/stream/stream_test.go
  constant cs (line 19) | cs = stream.ChunkSize
  function TestRoundTrip (line 21) | func TestRoundTrip(t *testing.T) {
  function testRoundTrip (line 36) | func testRoundTrip(t *testing.T, stepSize, length int) {
  type trackingReaderAt (line 188) | type trackingReaderAt struct
    method ReadAt (line 193) | func (t *trackingReaderAt) ReadAt(p []byte, off int64) (int, error) {
    method reset (line 198) | func (t *trackingReaderAt) reset() {
  function TestDecryptReaderAt (line 202) | func TestDecryptReaderAt(t *testing.T) {
  function TestDecryptReaderAtEmpty (line 414) | func TestDecryptReaderAtEmpty(t *testing.T) {
  function TestDecryptReaderAtSingleChunk (line 469) | func TestDecryptReaderAtSingleChunk(t *testing.T) {
  function TestDecryptReaderAtFullChunks (line 533) | func TestDecryptReaderAtFullChunks(t *testing.T) {
  function TestDecryptReaderAtWrongKey (line 595) | func TestDecryptReaderAtWrongKey(t *testing.T) {
  function TestDecryptReaderAtInvalidSize (line 631) | func TestDecryptReaderAtInvalidSize(t *testing.T) {
  function TestDecryptReaderAtTruncated (line 676) | func TestDecryptReaderAtTruncated(t *testing.T) {
  function TestDecryptReaderAtTruncatedChunk (line 708) | func TestDecryptReaderAtTruncatedChunk(t *testing.T) {
  function TestDecryptReaderAtConcurrent (line 746) | func TestDecryptReaderAtConcurrent(t *testing.T) {
  function TestDecryptReaderAtCorrupted (line 908) | func TestDecryptReaderAtCorrupted(t *testing.T) {

FILE: internal/term/term.go
  function clearLine (line 18) | func clearLine(out *os.File) {
  function WithTerminal (line 41) | func WithTerminal(f func(in, out *os.File) error) error {
  function ReadSecret (line 65) | func ReadSecret(prompt string) (s []byte, err error) {
  function ReadPublic (line 76) | func ReadPublic(prompt string) (s []byte, err error) {
  function ReadCharacter (line 97) | func ReadCharacter(prompt string) (c byte, err error) {
  function IsTerminal (line 120) | func IsTerminal(f *os.File) bool {

FILE: internal/term/term_windows.go
  function init (line 15) | func init() {

FILE: parse.go
  function ParseIdentities (line 24) | func ParseIdentities(f io.Reader) ([]Identity, error) {
  function parseIdentity (line 53) | func parseIdentity(arg string) (Identity, error) {
  function ParseRecipients (line 74) | func ParseRecipients(f io.Reader) ([]Recipient, error) {
  function parseRecipient (line 103) | func parseRecipient(arg string) (Recipient, error) {

FILE: plugin/client.go
  type Recipient (line 28) | type Recipient struct
    method Name (line 53) | func (r *Recipient) Name() string {
    method String (line 59) | func (r *Recipient) String() string {
    method Wrap (line 66) | func (r *Recipient) Wrap(fileKey []byte) (stanzas []*age.Stanza, err e...
    method WrapWithLabels (line 71) | func (r *Recipient) WrapWithLabels(fileKey []byte) (stanzas []*age.Sta...
  function NewRecipient (line 40) | func NewRecipient(s string, ui *ClientUI) (*Recipient, error) {
  type Identity (line 172) | type Identity struct
    method Name (line 203) | func (i *Identity) Name() string {
    method String (line 208) | func (i *Identity) String() string {
    method Recipient (line 215) | func (i *Identity) Recipient() *Recipient {
    method Unwrap (line 224) | func (i *Identity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err ...
  function NewIdentity (line 180) | func NewIdentity(s string, ui *ClientUI) (*Identity, error) {
  function NewIdentityWithoutData (line 190) | func NewIdentityWithoutData(name string, ui *ClientUI) (*Identity, error) {
  type ClientUI (line 319) | type ClientUI struct
    method handle (line 339) | func (c *ClientUI) handle(name string, conn *clientConnection, s *form...
    method readStanza (line 392) | func (c *ClientUI) readStanza(name string, r *format.StanzaReader) (*f...
  type clientConnection (line 399) | type clientConnection struct
    method Close (line 475) | func (cc *clientConnection) Close() error {
  type NotFoundError (line 408) | type NotFoundError struct
    method Error (line 416) | func (e *NotFoundError) Error() string {
    method Unwrap (line 420) | func (e *NotFoundError) Unwrap() error {
  function openClientConnection (line 426) | func openClientConnection(name, protocol string) (*clientConnection, err...
  function writeStanza (line 483) | func writeStanza(conn io.Writer, t string, args ...string) error {
  function writeStanzaWithBody (line 488) | func writeStanzaWithBody(conn io.Writer, t string, body []byte) error {
  function writeGrease (line 493) | func writeGrease(conn io.Writer) (sent bool, err error) {

FILE: plugin/client_test.go
  function TestMain (line 24) | func TestMain(m *testing.M) {
  type testRecipient (line 52) | type testRecipient struct
    method Wrap (line 54) | func (testRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
  type testPQCRecipient (line 58) | type testPQCRecipient struct
    method Wrap (line 62) | func (testPQCRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
    method WrapWithLabels (line 66) | func (testPQCRecipient) WrapWithLabels(fileKey []byte) ([]*age.Stanza,...
  function TestLabels (line 70) | func TestLabels(t *testing.T) {
  function TestNotFound (line 125) | func TestNotFound(t *testing.T) {
  function TestPluginError (line 177) | func TestPluginError(t *testing.T) {

FILE: plugin/encode.go
  function EncodeIdentity (line 19) | func EncodeIdentity(name string, data []byte) string {
  function ParseIdentity (line 29) | func ParseIdentity(s string) (name string, data []byte, err error) {
  function EncodeRecipient (line 47) | func EncodeRecipient(name string, data []byte) string {
  function ParseRecipient (line 57) | func ParseRecipient(s string) (name string, data []byte, err error) {
  function validPluginName (line 72) | func validPluginName(name string) bool {
  function EncodeX25519Recipient (line 88) | func EncodeX25519Recipient(pk *ecdh.PublicKey) (string, error) {
  function EncodeHybridRecipient (line 99) | func EncodeHybridRecipient(pq *mlkem.EncapsulationKey768, t *ecdh.Public...

FILE: plugin/example_test.go
  type Recipient (line 11) | type Recipient struct
    method Wrap (line 13) | func (r *Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
  function NewRecipient (line 17) | func NewRecipient(data []byte) (*Recipient, error) {
  type Identity (line 21) | type Identity struct
    method Unwrap (line 23) | func (i *Identity) Unwrap(s []*age.Stanza) ([]byte, error) {
  function NewIdentity (line 27) | func NewIdentity(data []byte) (*Identity, error) {
  function ExamplePlugin_main (line 31) | func ExamplePlugin_main() {

FILE: plugin/plugin.go
  type Plugin (line 28) | type Plugin struct
    method Name (line 55) | func (p *Plugin) Name() string {
    method RegisterFlags (line 65) | func (p *Plugin) RegisterFlags(fs *flag.FlagSet) {
    method HandleRecipient (line 82) | func (p *Plugin) HandleRecipient(f func(data []byte) (age.Recipient, e...
    method HandleIdentityAsRecipient (line 99) | func (p *Plugin) HandleIdentityAsRecipient(f func(data []byte) (age.Re...
    method HandleIdentity (line 112) | func (p *Plugin) HandleIdentity(f func(data []byte) (age.Identity, err...
    method HandleRecipientEncoding (line 123) | func (p *Plugin) HandleRecipientEncoding(f func(recipient string) (age...
    method HandleIdentityEncodingAsRecipient (line 131) | func (p *Plugin) HandleIdentityEncodingAsRecipient(f func(identity str...
    method HandleIdentityEncoding (line 141) | func (p *Plugin) HandleIdentityEncoding(f func(identity string) (age.I...
    method Main (line 151) | func (p *Plugin) Main() int {
    method SetIO (line 172) | func (p *Plugin) SetIO(stdin io.Reader, stdout, stderr io.Writer) {
    method RecipientV1 (line 182) | func (p *Plugin) RecipientV1() int {
    method IdentityV1 (line 371) | func (p *Plugin) IdentityV1() int {
    method DisplayMessage (line 495) | func (p *Plugin) DisplayMessage(message string) error {
    method RequestValue (line 517) | func (p *Plugin) RequestValue(prompt string, secret bool) (string, err...
    method Confirm (line 545) | func (p *Plugin) Confirm(prompt, yes, no string) (choseYes bool, err e...
    method fatalInteractf (line 569) | func (p *Plugin) fatalInteractf(format string, args ...any) error {
    method fatalf (line 575) | func (p *Plugin) fatalf(format string, args ...any) int {
    method recipientError (line 600) | func (p *Plugin) recipientError(idx int, err error) int {
    method identityError (line 607) | func (p *Plugin) identityError(idx int, err error) int {
    method writeError (line 654) | func (p *Plugin) writeError(args []string, err error) error {
  function New (line 49) | func New(name string) (*Plugin, error) {
  function wrapWithLabels (line 352) | func wrapWithLabels(r age.Recipient, fileKey []byte) ([]*age.Stanza, []s...
  function checkLabels (line 360) | func checkLabels(ll, labels []string) error {
  function expectStanzaWithNoBody (line 580) | func expectStanzaWithNoBody(s *format.Stanza, wantArgs int) error {
  function expectStanzaWithBody (line 590) | func expectStanzaWithBody(s *format.Stanza, wantArgs int) error {
  function expectOk (line 614) | func expectOk(sr *format.StanzaReader) error {
  function readOkOrFail (line 625) | func readOkOrFail(sr *format.StanzaReader) (*format.Stanza, error) {
  function expectUnsupported (line 643) | func expectUnsupported(sr *format.StanzaReader) error {
  function slicesEqual (line 666) | func slicesEqual(s1, s2 []string) bool {

FILE: plugin/tui.go
  function NewTerminalUI (line 16) | func NewTerminalUI(printf, warningf func(format string, v ...any)) *Clie...

FILE: pq.go
  constant pqLabel (line 18) | pqLabel = "age-encryption.org/mlkem768x25519"
  type HybridRecipient (line 28) | type HybridRecipient struct
    method Wrap (line 60) | func (r *HybridRecipient) Wrap(fileKey []byte) ([]*Stanza, error) {
    method WrapWithLabels (line 71) | func (r *HybridRecipient) WrapWithLabels(fileKey []byte) ([]*Stanza, [...
    method String (line 91) | func (r *HybridRecipient) String() string {
  function newHybridRecipient (line 35) | func newHybridRecipient(publicKey []byte) (*HybridRecipient, error) {
  function ParseHybridRecipient (line 45) | func ParseHybridRecipient(s string) (*HybridRecipient, error) {
  type HybridIdentity (line 98) | type HybridIdentity struct
    method Unwrap (line 139) | func (i *HybridIdentity) Unwrap(stanzas []*Stanza) ([]byte, error) {
    method unwrap (line 143) | func (i *HybridIdentity) unwrap(block *Stanza) ([]byte, error) {
    method Recipient (line 172) | func (i *HybridIdentity) Recipient() *HybridRecipient {
    method String (line 177) | func (i *HybridIdentity) String() string {
  function newHybridIdentity (line 105) | func newHybridIdentity(secretKey []byte) (*HybridIdentity, error) {
  function GenerateHybridIdentity (line 114) | func GenerateHybridIdentity() (*HybridIdentity, error) {
  function ParseHybridIdentity (line 124) | func ParseHybridIdentity(s string) (*HybridIdentity, error) {

FILE: primitives.go
  function aeadEncrypt (line 19) | func aeadEncrypt(key, plaintext []byte) ([]byte, error) {
  function aeadDecrypt (line 40) | func aeadDecrypt(key []byte, size int, ciphertext []byte) ([]byte, error) {
  function headerMAC (line 52) | func headerMAC(fileKey []byte, hdr *format.Header) ([]byte, error) {
  function streamKey (line 65) | func streamKey(fileKey, nonce []byte) []byte {

FILE: recipients_test.go
  function TestX25519RoundTrip (line 16) | func TestX25519RoundTrip(t *testing.T) {
  function TestHybridRoundTrip (line 53) | func TestHybridRoundTrip(t *testing.T) {
  function TestHybridMixingRestrictions (line 90) | func TestHybridMixingRestrictions(t *testing.T) {
  function TestScryptRoundTrip (line 114) | func TestScryptRoundTrip(t *testing.T) {

FILE: scrypt.go
  constant scryptLabel (line 20) | scryptLabel = "age-encryption.org/v1/scrypt"
  type ScryptRecipient (line 31) | type ScryptRecipient struct
    method SetWorkFactor (line 55) | func (r *ScryptRecipient) SetWorkFactor(logN int) {
    method Wrap (line 64) | func (r *ScryptRecipient) Wrap(fileKey []byte) ([]*Stanza, error) {
    method WrapWithLabels (line 102) | func (r *ScryptRecipient) WrapWithLabels(fileKey []byte) (stanzas []*S...
  function NewScryptRecipient (line 39) | func NewScryptRecipient(password string) (*ScryptRecipient, error) {
  constant scryptSaltSize (line 62) | scryptSaltSize = 16
  type ScryptIdentity (line 115) | type ScryptIdentity struct
    method SetMaxWorkFactor (line 140) | func (i *ScryptIdentity) SetMaxWorkFactor(logN int) {
    method Unwrap (line 147) | func (i *ScryptIdentity) Unwrap(stanzas []*Stanza) ([]byte, error) {
    method unwrap (line 164) | func (i *ScryptIdentity) unwrap(block *Stanza) ([]byte, error) {
  function NewScryptIdentity (line 123) | func NewScryptIdentity(password string) (*ScryptIdentity, error) {

FILE: tag/internal/age-plugin-tagtest/plugin-tagtest.go
  constant classicRecipient (line 21) | classicRecipient = "age1tag1qwe0kafsjrar4txm6heqnhpfuggzr0gvznz7fvygxrlq...
  constant hybridRecipient (line 23) | hybridRecipient = "age1tagpq14h4z7cks9sxftfc8tq4xektt4854ur9rv76tvujdvtz...
  function init (line 25) | func init() {
  function main (line 36) | func main() {
  type tagtestIdentity (line 50) | type tagtestIdentity struct
    method Unwrap (line 52) | func (i *tagtestIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) {

FILE: tag/internal/tagtest/tagtest.go
  type ClassicIdentity (line 19) | type ClassicIdentity struct
    method Recipient (line 33) | func (i *ClassicIdentity) Recipient() *tag.Recipient {
    method Unwrap (line 46) | func (i *ClassicIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) {
  function NewClassicIdentity (line 25) | func NewClassicIdentity(seed string) *ClassicIdentity {
  type HybridIdentity (line 89) | type HybridIdentity struct
    method Recipient (line 103) | func (i *HybridIdentity) Recipient() *tag.Recipient {
    method Unwrap (line 111) | func (i *HybridIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) {
  function NewHybridIdentity (line 95) | func NewHybridIdentity(seed string) *HybridIdentity {

FILE: tag/tag.go
  type Recipient (line 35) | type Recipient struct
    method Hybrid (line 96) | func (r *Recipient) Hybrid() bool {
    method Wrap (line 100) | func (r *Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
    method Tag (line 109) | func (r *Recipient) Tag(enc []byte) ([]byte, error) {
    method WrapWithLabels (line 136) | func (r *Recipient) WrapWithLabels(fileKey []byte) ([]*age.Stanza, []s...
    method Bytes (line 172) | func (r *Recipient) Bytes() []byte {
    method String (line 184) | func (r *Recipient) String() string {
  function ParseRecipient (line 43) | func ParseRecipient(s string) (*Recipient, error) {
  constant compressedPointSize (line 66) | compressedPointSize = 1 + 32
  constant uncompressedPointSize (line 67) | uncompressedPointSize = 1 + 32 + 32
  function NewClassicRecipient (line 70) | func NewClassicRecipient(publicKey []byte) (*Recipient, error) {
  function NewHybridRecipient (line 87) | func NewHybridRecipient(publicKey []byte) (*Recipient, error) {

FILE: tag/tag_test.go
  function TestClassicRoundTrip (line 17) | func TestClassicRoundTrip(t *testing.T) {
  function TestHybridRoundTrip (line 64) | func TestHybridRoundTrip(t *testing.T) {
  function TestTagHybridMixingRestrictions (line 111) | func TestTagHybridMixingRestrictions(t *testing.T) {

FILE: testkit_test.go
  function forEachVector (line 31) | func forEachVector(t *testing.T, f func(t *testing.T, v *vector)) {
  type vector (line 49) | type vector struct
  function parseVector (line 58) | func parseVector(t *testing.T, test []byte) *vector {
  function TestVectors (line 142) | func TestVectors(t *testing.T) {
  function testDecrypt (line 152) | func testDecrypt(t *testing.T, v *vector) []byte {
  function testDecryptReaderAt (line 209) | func testDecryptReaderAt(t *testing.T, v *vector, plaintext []byte) {
  function testInspect (line 264) | func testInspect(t *testing.T, v *vector, plaintext []byte) {
  function testVectorRoundTrip (line 296) | func testVectorRoundTrip(t *testing.T, v *vector) {
  function streamKey (line 408) | func streamKey(fileKey, nonce []byte) []byte {

FILE: x25519.go
  constant x25519Label (line 22) | x25519Label = "age-encryption.org/v1/X25519"
  type X25519Recipient (line 30) | type X25519Recipient struct
    method Wrap (line 65) | func (r *X25519Recipient) Wrap(fileKey []byte) ([]*Stanza, error) {
    method String (line 104) | func (r *X25519Recipient) String() string {
  function newX25519RecipientFromPoint (line 37) | func newX25519RecipientFromPoint(publicKey []byte) (*X25519Recipient, er...
  function ParseX25519Recipient (line 50) | func ParseX25519Recipient(s string) (*X25519Recipient, error) {
  type X25519Identity (line 112) | type X25519Identity struct
    method Unwrap (line 157) | func (i *X25519Identity) Unwrap(stanzas []*Stanza) ([]byte, error) {
    method unwrap (line 161) | func (i *X25519Identity) unwrap(block *Stanza) ([]byte, error) {
    method Recipient (line 200) | func (i *X25519Identity) Recipient() *X25519Recipient {
    method String (line 207) | func (i *X25519Identity) String() string {
  function newX25519IdentityFromScalar (line 119) | func newX25519IdentityFromScalar(secretKey []byte) (*X25519Identity, err...
  function GenerateX25519Identity (line 132) | func GenerateX25519Identity() (*X25519Identity, error) {
  function ParseX25519Identity (line 142) | func ParseX25519Identity(s string) (*X25519Identity, error) {
Condensed preview — 94 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (492K chars).
[
  {
    "path": ".gitattributes",
    "chars": 39,
    "preview": "*.age binary\ntestdata/testkit/* binary\n"
  },
  {
    "path": ".github/CONTRIBUTING.md",
    "chars": 2934,
    "preview": "## Issues\n\nI want to hear about any issues you encounter while using age.\n\nParticularly appreciated are well researched,"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug-report.md",
    "chars": 248,
    "preview": "---\nname: Bug report 🐞\nabout: Did you encounter a bug in this implementation?\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n#"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "chars": 607,
    "preview": "contact_links:\n  - name: UX report ✨\n    url: https://github.com/FiloSottile/age/discussions/new?category=UX-reports\n   "
  },
  {
    "path": ".github/workflows/LICENSE.suffix.txt",
    "chars": 1459,
    "preview": "\n---\n\nCopyright 2009 The Go Authors.\n\nRedistribution and use in source and binary forms, with or without\nmodification, a"
  },
  {
    "path": ".github/workflows/build.yml",
    "chars": 4102,
    "preview": "name: Build and upload binaries\non:\n  release:\n    types: [published]\n  push:\n  pull_request:\npermissions:\n  contents: r"
  },
  {
    "path": ".github/workflows/certs/README",
    "chars": 479,
    "preview": "In this folder there are\n\n    uitacllc.crt\n\n        PKCS#7 encoded certificate chain for a code signing certificate issu"
  },
  {
    "path": ".github/workflows/certs/uitacllc.key",
    "chars": 2546,
    "preview": "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-256-CBC,B93C1A166F3677D68FB9CB3E8A184729\n\nUriYsaq3t"
  },
  {
    "path": ".github/workflows/interop.yml",
    "chars": 445,
    "preview": "name: Interoperability tests\non: push\npermissions:\n  contents: read\njobs:\n  trigger:\n    name: Trigger\n    runs-on: ubun"
  },
  {
    "path": ".github/workflows/ronn.yml",
    "chars": 1761,
    "preview": "name: Generate man pages\non:\n  push:\n    branches:\n      - '**'\n    paths:\n      - '**.ronn'\n      - '**/ronn.yml'\npermi"
  },
  {
    "path": ".github/workflows/test.yml",
    "chars": 1853,
    "preview": "name: Go tests\non:\n  push:\n  pull_request:\n  schedule: # daily at 09:42 UTC\n    - cron: '42 9 * * *'\n  workflow_dispatch"
  },
  {
    "path": "LICENSE",
    "chars": 1516,
    "preview": "Copyright 2019 The age Authors\nCopyright 2019 Google LLC\nCopyright 2022 Filippo Valsorda\n\nRedistribution and use in sour"
  },
  {
    "path": "README.md",
    "chars": 11618,
    "preview": "<p align=\"center\">\n    <picture>\n        <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://github.com/FiloSot"
  },
  {
    "path": "SIGSUM.md",
    "chars": 1969,
    "preview": "If you download the pre-built binaries of version v1.2.0+, you can check their\n[Sigsum](https://www.sigsum.org) proofs, "
  },
  {
    "path": "age.go",
    "chars": 15539,
    "preview": "// Copyright 2019 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "age_test.go",
    "chars": 13787,
    "preview": "// Copyright 2019 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "agessh/agessh.go",
    "chars": 10700,
    "preview": "// Copyright 2019 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "agessh/agessh_test.go",
    "chars": 2001,
    "preview": "// Copyright 2019 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "agessh/encrypted_keys.go",
    "chars": 3902,
    "preview": "// Copyright 2019 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "armor/armor.go",
    "chars": 4183,
    "preview": "// Copyright 2019 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "armor/armor_test.go",
    "chars": 4626,
    "preview": "// Copyright 2019 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "cmd/age/age.go",
    "chars": 19087,
    "preview": "// Copyright 2019 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "cmd/age/age_test.go",
    "chars": 1990,
    "preview": "// Copyright 2019 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "cmd/age/encrypted_keys.go",
    "chars": 2799,
    "preview": "// Copyright 2019 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "cmd/age/parse.go",
    "chars": 9237,
    "preview": "// Copyright 2019 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "cmd/age/testdata/armor.txt",
    "chars": 699,
    "preview": "age -d -i key.txt armored_with_leading_and_trailing_whitespace.txt\nstdout test\n\n-- key.txt --\n# created: 2025-12-23T22:2"
  },
  {
    "path": "cmd/age/testdata/batchpass.txt",
    "chars": 1522,
    "preview": "# encrypt and decrypt with AGE_PASSPHRASE\nenv AGE_PASSPHRASE_WORK_FACTOR=5\nenv AGE_PASSPHRASE=password\nage -e -j batchpa"
  },
  {
    "path": "cmd/age/testdata/duplicates.txt",
    "chars": 2721,
    "preview": "# Test duplicate recipient detection\nage -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -r age1yv2sxr"
  },
  {
    "path": "cmd/age/testdata/ed25519.txt",
    "chars": 1094,
    "preview": "# encrypt and decrypt a file with -R\nage -R key.pem.pub -o test.age input\nage -d -i key.pem test.age\ncmp stdout input\n! "
  },
  {
    "path": "cmd/age/testdata/encrypted_keys.txt",
    "chars": 6365,
    "preview": "# TODO: age-encrypted private keys, multiple identities, -i ordering, -e -i,\n# age file password prompt during encryptio"
  },
  {
    "path": "cmd/age/testdata/hybrid.txt",
    "chars": 11102,
    "preview": "# encrypt and decrypt a file with -r\nage -r age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9"
  },
  {
    "path": "cmd/age/testdata/keygen.txt",
    "chars": 517,
    "preview": "exec age-keygen\nstdout '# created: 20'\nstdout '# public key: age1'\nstdout 'AGE-SECRET-KEY-1'\nstderr 'Public key: age1'\n\n"
  },
  {
    "path": "cmd/age/testdata/output_file.txt",
    "chars": 2473,
    "preview": "# https://github.com/FiloSottile/age/issues/57\nage -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o "
  },
  {
    "path": "cmd/age/testdata/pkcs8.txt",
    "chars": 364,
    "preview": "# https://github.com/FiloSottile/age/discussions/428\n# encrypt and decrypt a file with an Ed25519 key encoded with PKCS#"
  },
  {
    "path": "cmd/age/testdata/plugin.txt",
    "chars": 4321,
    "preview": "# encrypt and decrypt a file with a test plugin\nage -r age1test10qdmzv9q -o test.age input\nage -d -i key.txt test.age\ncm"
  },
  {
    "path": "cmd/age/testdata/rsa.txt",
    "chars": 4230,
    "preview": "# encrypt and decrypt a file with -R\nage -R key.pem.pub -o test.age input\nage -d -i key.pem test.age\ncmp stdout input\n! "
  },
  {
    "path": "cmd/age/testdata/scrypt.txt",
    "chars": 1431,
    "preview": "[!linux] [!darwin] skip # no pty support\n[darwin] [go1.20] skip # https://go.dev/issue/61779\n\n# encrypt with a provided "
  },
  {
    "path": "cmd/age/testdata/terminal.txt",
    "chars": 1385,
    "preview": "[!linux] [!darwin] skip # no pty support\n[darwin] [go1.20] skip # https://go.dev/issue/61779\n\n# controlling terminal is "
  },
  {
    "path": "cmd/age/testdata/usage.txt",
    "chars": 257,
    "preview": "# -help\nage -p -help\n! stdout .\nstderr 'Usage:'\n\n# -h\nage -p -h\n! stdout .\nstderr 'Usage:'\n\n# unknown flag\n! age -p -thi"
  },
  {
    "path": "cmd/age/testdata/x25519.txt",
    "chars": 823,
    "preview": "# encrypt and decrypt a file with -r\nage -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o test.age i"
  },
  {
    "path": "cmd/age/tui.go",
    "chars": 2121,
    "preview": "// Copyright 2021 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "cmd/age/wordlist.go",
    "chars": 13780,
    "preview": "// Copyright 2019 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "cmd/age-inspect/inspect.go",
    "chars": 3416,
    "preview": "// Copyright 2025 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "cmd/age-keygen/keygen.go",
    "chars": 5067,
    "preview": "// Copyright 2019 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "cmd/age-plugin-batchpass/plugin-batchpass.go",
    "chars": 7166,
    "preview": "package main\n\nimport (\n\t\"errors\"\n\t\"flag\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"os\"\n\t\"runtime/debug\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"filippo.io/"
  },
  {
    "path": "doc/age-inspect.1",
    "chars": 2895,
    "preview": ".\\\" generated with Ronn-NG/v0.9.1\n.\\\" http://github.com/apjanke/ronn-ng/tree/0.9.1\n.TH \"AGE\\-INSPECT\" \"1\" \"December 2025"
  },
  {
    "path": "doc/age-inspect.1.html",
    "chars": 6662,
    "preview": "<!DOCTYPE html>\n<html>\n<head>\n  <meta http-equiv='content-type' content='text/html;charset=utf8'>\n  <meta name='generato"
  },
  {
    "path": "doc/age-inspect.1.ronn",
    "chars": 2608,
    "preview": "age-inspect(1) -- inspect age(1) encrypted files\n====================================================\n\n## SYNOPSIS\n\n`age"
  },
  {
    "path": "doc/age-keygen.1",
    "chars": 2163,
    "preview": ".\\\" generated with Ronn-NG/v0.9.1\n.\\\" http://github.com/apjanke/ronn-ng/tree/0.9.1\n.TH \"AGE\\-KEYGEN\" \"1\" \"December 2025\""
  },
  {
    "path": "doc/age-keygen.1.html",
    "chars": 5737,
    "preview": "<!DOCTYPE html>\n<html>\n<head>\n  <meta http-equiv='content-type' content='text/html;charset=utf8'>\n  <meta name='generato"
  },
  {
    "path": "doc/age-keygen.1.ronn",
    "chars": 1833,
    "preview": "age-keygen(1) -- generate age(1) key pairs\n====================================================\n\n## SYNOPSIS\n\n`age-keyge"
  },
  {
    "path": "doc/age-plugin-batchpass.1",
    "chars": 3724,
    "preview": ".\\\" generated with Ronn-NG/v0.9.1\n.\\\" http://github.com/apjanke/ronn-ng/tree/0.9.1\n.TH \"AGE\\-PLUGIN\\-BATCHPASS\" \"1\" \"Dec"
  },
  {
    "path": "doc/age-plugin-batchpass.1.html",
    "chars": 7333,
    "preview": "<!DOCTYPE html>\n<html>\n<head>\n  <meta http-equiv='content-type' content='text/html;charset=utf8'>\n  <meta name='generato"
  },
  {
    "path": "doc/age-plugin-batchpass.1.ronn",
    "chars": 3388,
    "preview": "age-plugin-batchpass(1) -- non-interactive passphrase encryption plugin for age(1)\n====================================="
  },
  {
    "path": "doc/age.1",
    "chars": 15035,
    "preview": ".\\\" generated with Ronn-NG/v0.9.1\n.\\\" http://github.com/apjanke/ronn-ng/tree/0.9.1\n.TH \"AGE\" \"1\" \"December 2025\" \"\"\n.SH "
  },
  {
    "path": "doc/age.1.html",
    "chars": 21579,
    "preview": "<!DOCTYPE html>\n<html>\n<head>\n  <meta http-equiv='content-type' content='text/html;charset=utf8'>\n  <meta name='generato"
  },
  {
    "path": "doc/age.1.ronn",
    "chars": 13894,
    "preview": "age(1) -- simple, modern, and secure file encryption\n====================================================\n\n## SYNOPSIS\n\n"
  },
  {
    "path": "extra/age-plugin-pq/plugin-pq.go",
    "chars": 4206,
    "preview": "package main\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"os\"\n\t\"runtime/debug\"\n\n\t\"filippo.io/age\"\n\t\"filippo.io/age/internal/b"
  },
  {
    "path": "extra/age-plugin-tag/plugin-tag.go",
    "chars": 1230,
    "preview": "package main\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"runtime/debug\"\n\n\t\"filippo.io/age\"\n\t\"filippo.io/age/plugin\"\n\t\"filipp"
  },
  {
    "path": "extra/age-plugin-tagpq/plugin-tagpq.go",
    "chars": 1266,
    "preview": "package main\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"runtime/debug\"\n\n\t\"filippo.io/age\"\n\t\"filippo.io/age/plugin\"\n\t\"filipp"
  },
  {
    "path": "go.mod",
    "chars": 426,
    "preview": "module filippo.io/age\n\ngo 1.24.0\n\n// Release build version.\ntoolchain go1.25.5\n\nrequire (\n\tfilippo.io/edwards25519 v1.1."
  },
  {
    "path": "go.sum",
    "chars": 1483,
    "preview": "c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=\nc2sp.org/CCTV/age v"
  },
  {
    "path": "internal/bech32/bech32.go",
    "chars": 5008,
    "preview": "// Copyright (c) 2017 Takatoshi Nakagawa\n// Copyright (c) 2019 The age Authors\n//\n// Permission is hereby granted, free "
  },
  {
    "path": "internal/bech32/bech32_test.go",
    "chars": 5003,
    "preview": "// Copyright (c) 2013-2017 The btcsuite developers\n// Copyright (c) 2016-2017 The Lightning Network Developers\n// Copyri"
  },
  {
    "path": "internal/format/format.go",
    "chars": 8053,
    "preview": "// Copyright 2019 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "internal/format/format_test.go",
    "chars": 1982,
    "preview": "// Copyright 2021 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "internal/inspect/inspect.go",
    "chars": 3452,
    "preview": "package inspect\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"fmt\"\n\t\"io\"\n\t\"strings\"\n\n\t\"filippo.io/age/armor\"\n\t\"filippo.io/age/internal/f"
  },
  {
    "path": "internal/inspect/inspect_test.go",
    "chars": 1273,
    "preview": "package inspect\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\n\t\"filippo.io/age/internal/stream\"\n)\n\nfunc TestStreamOverhead(t *testing.T) "
  },
  {
    "path": "internal/stream/stream.go",
    "chars": 11831,
    "preview": "// Copyright 2019 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "internal/stream/stream_test.go",
    "chars": 24376,
    "preview": "// Copyright 2019 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "internal/term/term.go",
    "chars": 3464,
    "preview": "package term\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"runtime\"\n\n\t\"golang.org/x/term\"\n)\n\n// enableVirtualTerminalProcessing tries to enab"
  },
  {
    "path": "internal/term/term_windows.go",
    "chars": 1490,
    "preview": "// Copyright 2022 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "logo/README.md",
    "chars": 523,
    "preview": "The logos available in this folder are Copyright 2021 Filippo Valsorda.\n\nPermission is granted to use the logos as long "
  },
  {
    "path": "parse.go",
    "chars": 3475,
    "preview": "// Copyright 2021 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "plugin/client.go",
    "chars": 13418,
    "preview": "// Copyright 2021 Google LLC\n//\n// Use of this source code is governed by a BSD-style\n// license that can be found in th"
  },
  {
    "path": "plugin/client_test.go",
    "chars": 6261,
    "preview": "// Copyright 2023 The age Authors\n//\n// Use of this source code is governed by a BSD-style\n// license that can be found "
  },
  {
    "path": "plugin/encode.go",
    "chars": 3437,
    "preview": "// Copyright 2023 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "plugin/example_test.go",
    "chars": 785,
    "preview": "package plugin_test\n\nimport (\n\t\"log\"\n\t\"os\"\n\n\t\"filippo.io/age\"\n\t\"filippo.io/age/plugin\"\n)\n\ntype Recipient struct{}\n\nfunc "
  },
  {
    "path": "plugin/plugin.go",
    "chars": 20123,
    "preview": "// Package plugin implements the age plugin protocol.\n//\n// [Recipient] and [Indentity] are plugin clients, that execute"
  },
  {
    "path": "plugin/tui.go",
    "chars": 2056,
    "preview": "package plugin\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\t\"filippo.io/age/internal/term\"\n)\n\n// NewTerminalUI returns a [ClientUI] that"
  },
  {
    "path": "pq.go",
    "chars": 5804,
    "preview": "// Copyright 2025 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "primitives.go",
    "chars": 2357,
    "preview": "// Copyright 2019 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "recipients_test.go",
    "chars": 3361,
    "preview": "// Copyright 2019 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "scrypt.go",
    "chars": 6892,
    "preview": "// Copyright 2019 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "tag/internal/age-plugin-tagtest/plugin-tagtest.go",
    "chars": 3539,
    "preview": "// Command age-plugin-tagtest is a that decrypts files encrypted to fixed\n// age1tag1... or age1tagpq1... recipients for"
  },
  {
    "path": "tag/internal/tagtest/tagtest.go",
    "chars": 4132,
    "preview": "// Copyright 2025 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "tag/tag.go",
    "chars": 5923,
    "preview": "// Copyright 2025 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "tag/tag_test.go",
    "chars": 3428,
    "preview": "// Copyright 2025 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "testdata/example.age",
    "chars": 201,
    "preview": "age-encryption.org/v1\n-> X25519 8hrlM+ZBG3Dd4fF2+a583zdTIWDk8/R41kCYZsvwTW4\nyO4PYdlMWDJ+CxgUNRqY5Z0T/m+g3FCh5jIxGLbCVXc\n"
  },
  {
    "path": "testdata/example.zip.age",
    "chars": 284,
    "preview": "age-encryption.org/v1\n-> X25519 5CD81lZA72aQi0v6EnniOGkwaswpZ0AxCZNdiUVzP04\nol9DvdkiZWeRI4vMKRBVNxowDKwir4UPqYinSM5zqUI\n"
  },
  {
    "path": "testdata/example_keys.txt",
    "chars": 114,
    "preview": "# Test key for ExampleParseIdentities.\nAGE-SECRET-KEY-184JMZMVQH3E6U0PSL869004Y3U2NYV7R30EU99CSEDNPH02YUVFSZW44VU\n"
  },
  {
    "path": "testkit_test.go",
    "chars": 11113,
    "preview": "// Copyright 2022 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  },
  {
    "path": "x25519.go",
    "chars": 6376,
    "preview": "// Copyright 2019 The age Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license"
  }
]

// ... and 1 more files (download for full content)

About this extraction

This page contains the full source code of the FiloSottile/age GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 94 files (444.6 KB), approximately 146.2k tokens, and a symbol index with 443 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!