├── .github └── workflows │ └── build-and-test.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── cmd └── age-plugin-fido2-hmac │ ├── main.go │ └── testdata │ ├── backcomp.txtar │ ├── multiple-stanzas.txtar │ ├── pin-no.txtar │ ├── pin-yes.txtar │ ├── symmetric-pin-no.txtar │ └── symmetric-pin-yes.txtar ├── docs ├── spec-v1.md └── spec-v2.md ├── e2e └── test_device.bin ├── go.mod ├── go.sum ├── internal ├── bech32 │ └── bech32.go └── mlock │ ├── main.go │ ├── missing.go │ └── unix.go └── pkg └── plugin ├── cli.go ├── fido2_utils.go ├── fido2_utils_test.go ├── identity.go ├── plugin.go ├── plugin_test.go └── recipient.go /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | release: 5 | types: [ "published" ] 6 | push: 7 | branches: [ "main" ] 8 | pull_request: 9 | branches: [ "main" ] 10 | 11 | env: 12 | GO_VERSION: '>=1.22.1' 13 | CGO_ENABLED: 1 14 | 15 | jobs: 16 | build-linux-amd64: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version: ${{ env.GO_VERSION }} 25 | 26 | - name: Install libfido2 27 | run: sudo apt-get install -y libfido2-dev 28 | 29 | - name: Test 30 | run: go test -v ./... 31 | 32 | - name: Package 33 | run: | 34 | VERSION="$(git describe --tags --always)" 35 | go build -ldflags "-X main.Version=$VERSION" -v ./cmd/... 36 | DIR="$(mktemp -d)" 37 | mkdir "$DIR/age-plugin-fido2-hmac" 38 | cp LICENSE "$DIR/age-plugin-fido2-hmac" 39 | mv age-plugin-fido2-hmac "$DIR/age-plugin-fido2-hmac" 40 | tar -cvzf "age-plugin-fido2-hmac-$VERSION-linux-amd64.tar.gz" -C "$DIR" age-plugin-fido2-hmac 41 | env: 42 | CGO_ENABLED: ${{ env.CGO_ENABLED }} 43 | GOARCH: amd64 44 | 45 | - name: Upload workflow artifacts 46 | uses: actions/upload-artifact@v4 47 | with: 48 | name: plugin-binaries-linux 49 | path: age-plugin-fido2-hmac* 50 | 51 | build-darwin-arm64: 52 | runs-on: macos-14 53 | steps: 54 | - uses: actions/checkout@v4 55 | 56 | - name: Set up Go 57 | uses: actions/setup-go@v5 58 | with: 59 | go-version: ${{ env.GO_VERSION }} 60 | 61 | - name: Set up Homebrew 62 | id: set-up-homebrew 63 | uses: Homebrew/actions/setup-homebrew@master 64 | 65 | - name: Install libfido2 66 | run: brew install libfido2 67 | 68 | - name: Test 69 | run: go test -v ./... 70 | 71 | - name: Package 72 | run: | 73 | VERSION="$(git describe --tags --always)" 74 | go build -ldflags "-X main.Version=$VERSION" -v ./cmd/... 75 | DIR="$(mktemp -d)" 76 | mkdir "$DIR/age-plugin-fido2-hmac" 77 | cp LICENSE "$DIR/age-plugin-fido2-hmac" 78 | mv age-plugin-fido2-hmac "$DIR/age-plugin-fido2-hmac" 79 | tar -cvzf "age-plugin-fido2-hmac-$VERSION-darwin-arm64.tar.gz" -C "$DIR" age-plugin-fido2-hmac 80 | env: 81 | CGO_ENABLED: ${{ env.CGO_ENABLED }} 82 | GOARCH: arm64 83 | 84 | - name: Upload workflow artifacts 85 | uses: actions/upload-artifact@v4 86 | with: 87 | name: plugin-binaries-darwin-arm64 88 | path: age-plugin-fido2-hmac* 89 | 90 | build-darwin-amd64: 91 | runs-on: macos-13 92 | steps: 93 | - uses: actions/checkout@v4 94 | 95 | - name: Set up Go 96 | uses: actions/setup-go@v5 97 | with: 98 | go-version: ${{ env.GO_VERSION }} 99 | 100 | - name: Set up Homebrew 101 | id: set-up-homebrew 102 | uses: Homebrew/actions/setup-homebrew@master 103 | 104 | - name: Install libfido2 105 | run: brew install libfido2 106 | 107 | - name: Test 108 | run: go test -v ./... 109 | 110 | - name: Package 111 | run: | 112 | VERSION="$(git describe --tags --always)" 113 | go build -ldflags "-X main.Version=$VERSION" -v ./cmd/... 114 | DIR="$(mktemp -d)" 115 | mkdir "$DIR/age-plugin-fido2-hmac" 116 | cp LICENSE "$DIR/age-plugin-fido2-hmac" 117 | mv age-plugin-fido2-hmac "$DIR/age-plugin-fido2-hmac" 118 | tar -cvzf "age-plugin-fido2-hmac-$VERSION-darwin-amd64.tar.gz" -C "$DIR" age-plugin-fido2-hmac 119 | env: 120 | CGO_ENABLED: ${{ env.CGO_ENABLED }} 121 | GOARCH: amd64 122 | 123 | - name: Upload workflow artifacts 124 | uses: actions/upload-artifact@v4 125 | with: 126 | name: plugin-binaries-darwin-amd64 127 | path: age-plugin-fido2-hmac* 128 | 129 | # TODO: fix and uncomment if needed 130 | # build-windows-amd64: 131 | # runs-on: windows-latest 132 | # steps: 133 | # - uses: actions/checkout@v4 134 | 135 | # - name: Set up Go 136 | # uses: actions/setup-go@v5 137 | # with: 138 | # go-version: ${{ env.GO_VERSION }} 139 | 140 | # - name: Install libfido2 141 | # run: | 142 | # Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser 143 | # Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression 144 | # scoop bucket add keys.pub https://github.com/keys-pub/scoop-bucket 145 | # scoop install libfido2 146 | 147 | # - name: Test 148 | # run: go test -v ./... 149 | 150 | # - name: Package 151 | # run: | 152 | # VERSION="$(git describe --tags --always)" 153 | # go build -ldflags "-X main.Version=$VERSION" -v ./cmd/... 154 | # DIR="$(mktemp -d)" 155 | # mkdir "$DIR/age-plugin-fido2-hmac" 156 | # cp LICENSE "$DIR/age-plugin-fido2-hmac" 157 | # mv age-plugin-fido2-hmac "$DIR/age-plugin-fido2-hmac" 158 | # OLDDIR="$(pwd)" 159 | # cd "$DIR" 160 | # zip age-plugin-fido2-hmac.zip -r age-plugin-fido2-hmac 161 | # cd "$OLDDIR" 162 | # mv "$DIR/age-plugin-fido2-hmac.zip" "age-plugin-fido2-hmac-$VERSION-windows-amd64.zip" 163 | # env: 164 | # CGO_ENABLED: ${{ env.CGO_ENABLED }} 165 | # GOARCH: amd64 166 | 167 | # - name: Upload workflow artifacts 168 | # uses: actions/upload-artifact@v4 169 | # with: 170 | # name: plugin-binaries-windows-amd64 171 | # path: age-plugin-fido2-hmac* 172 | 173 | release: 174 | name: Upload release binaries 175 | if: github.event_name == 'release' 176 | needs: ["build-linux-amd64", "build-darwin-amd64", "build-darwin-arm64"] 177 | permissions: 178 | contents: write 179 | runs-on: ubuntu-latest 180 | steps: 181 | - name: Download workflow artifacts 182 | uses: actions/download-artifact@v4 183 | 184 | - name: Upload release artifacts 185 | run: | 186 | gh release upload "$GITHUB_REF_NAME" plugin-binaries-darwin-amd64/age-plugin-fido2-hmac* 187 | gh release upload "$GITHUB_REF_NAME" plugin-binaries-darwin-arm64/age-plugin-fido2-hmac* 188 | gh release upload "$GITHUB_REF_NAME" plugin-binaries-linux/age-plugin-fido2-hmac* 189 | env: 190 | GH_REPO: ${{ github.repository }} 191 | GH_TOKEN: ${{ github.token }} 192 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | ./age-plugin-fido2-hmac 24 | *.enc 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 https://github.com/olastor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | go build -ldflags "-X main.Version=$$(git describe --tags --always)" ./cmd/... 3 | 4 | test: build 5 | go test -v ./... 6 | 7 | test-e2e: build 8 | testscript ./cmd/age-plugin-fido2-hmac/testdata/*.txtar 9 | 10 | format: 11 | go fmt ./pkg/... ./cmd/... 12 | 13 | clean: 14 | rm -f age-plugin-fido2-hmac 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # age-plugin-fido2-hmac 2 | 3 | ⚠️ Consider this plugin to be experimental until the version v1.0.0 is published! ⚠️ 4 | 5 | --- 6 | 7 | :key: Encrypt files with fido2 keys that support the "hmac-secret" extension. 8 | 9 | :hash: Unlimited generation of recipients/identities because generated fido2 credentials are stateless. 10 | 11 | :memo: See [the spec](https://github.com/olastor/age-plugin-fido2-hmac/blob/main/docs/spec-v2.md) for more details. 12 | 13 | --- 14 | 15 | 16 | ## Requirements 17 | 18 | - [age](https://github.com/FiloSottile/age) (>= 1.1.0) or [rage](https://github.com/str4d/rage) 19 | - [libfido2](https://developers.yubico.com/libfido2/) 20 | 21 | **Ubuntu (>= 20.04)** 22 | 23 | ```bash 24 | sudo apt install libfido2-1 libfido2-dev libfido2-doc fido2-tools 25 | ``` 26 | 27 | **Fedora (>= 34)** 28 | 29 | ```bash 30 | sudo dnf install libfido2 libfido2-devel fido2-tools 31 | ``` 32 | 33 | **Mac OS** 34 | 35 | ```bash 36 | brew install libfido2 37 | ``` 38 | 39 | ## Installation 40 | 41 | Download a the latest binary from the [release page](https://github.com/olastor/age-plugin-fido2-hmac/releases). Copy the binary to your `$PATH` (preferably in `$(which age)`) and make sure it's executable. 42 | 43 | You can also use the following script for installation: 44 | 45 | - Installs binary to `~/.local/bin/age-plugin-fido2-hmac` (change to your preferred directory) 46 | - Make sure to adjust `OS` and `ARCH` if needed (`OS=darwin ARCH=arm64` for Apple Silicon, `OS=darwin ARCH=amd64` for older Macs) 47 | 48 | ```bash 49 | cd "$(mktemp -d)" 50 | VERSION=v0.3.0 OS=linux ARCH=amd64; curl -L "https://github.com/olastor/age-plugin-fido2-hmac/releases/download/$VERSION/age-plugin-fido2-hmac-$VERSION-$OS-$ARCH.tar.gz" -o age-plugin-fido2-hmac.tar.gz 51 | tar -xzf age-plugin-fido2-hmac.tar.gz 52 | mv age-plugin-fido2-hmac/age-plugin-fido2-hmac ~/.local/bin 53 | ``` 54 | 55 | Please note that Windows builds are currently not enabled, but if you need them please open a new issue and I'll try to look into it. 56 | 57 | ## Build from source 58 | 59 | ```bash 60 | git clone https://github.com/olastor/age-plugin-fido2-hmac.git 61 | cd age-plugin-fido2-hmac 62 | make build 63 | mv ./age-plugin-fido2-hmac ~/.local/bin/age-plugin-fido2-hmac 64 | ``` 65 | 66 | (requires Go 1.22) 67 | 68 | ## Usage 69 | 70 | ### Generate a new recpient/identity 71 | 72 | Generate new credentials with the following command: 73 | 74 | ``` 75 | $ age-plugin-fido2-hmac -g 76 | [*] Please insert your token now... 77 | Please enter your PIN: 78 | [*] Please touch your token... 79 | [*] Do you want to require a PIN for decryption? [y/n]: y 80 | [*] Please touch your token... 81 | [*] Are you fine with having a separate identity (better privacy)? [y/n]: y 82 | # created: 2024-04-21T16:54:23+02:00 83 | # public key: age1zdy49ek6z60q9r34vf5mmzkx6u43pr9haqdh5lqdg7fh5tpwlfwqea356l 84 | AGE-PLUGIN-FIDO2-HMAC-1QQPQZRFR7ZZ2WCV... 85 | ``` 86 | 87 | You can decide between storing your fido2 credential / salt inside the encrypted file header (benefit: no separate identity / downside: ciphertexts can be linked) or in a separate identity (benefit: native age recipient, unlinkabilty / downside: keep identity stored securely somewhere). To decrypt files without an identity, add `-j fido2-hmac` instead of `-i identity.txt` to your age command (e.g. `age -d -j fido2-hmac -o test.txt test.txt.enc`) or use the output of `age-plugin-fido2-hmac -m` as the identity alternatively. 88 | 89 | You are responsible for knowing which token matches your recipient / identity. There is no token identifier stored. If you have multiple tokens and forgot which one you used, there's no other way than trial/error to find out which one it was. 90 | 91 | If you require a PIN for decryption, you (obviously) must not forget it. The PIN check is not just an UI guard, but the token changes the secret it uses internal! 92 | 93 | ### Encrypting/Decrypting 94 | 95 | **Encryption:** 96 | 97 | ```bash 98 | age -r age1... -o test.txt.enc test.txt 99 | ``` 100 | 101 | **Decryption:** 102 | 103 | ```bash 104 | age -d -j fido2-hmac -o test-decrypted.txt test.txt.enc 105 | ``` 106 | 107 | or 108 | 109 | ```bash 110 | age -d -i identity.txt -o test-decrypted.txt test.txt.enc 111 | ``` 112 | 113 | ### Choosing a different algorithm 114 | 115 | By default, one of the following algorithms is picked (in that order): ES256, EdDSA, RS256. If you want the credential to use a specific algorithm, use the `-a` parameter: 116 | 117 | ```bash 118 | age-plugin-fido2-hmac -a eddsa -g 119 | ``` 120 | 121 | Note that 122 | 123 | - your authenticator **may not support** all algorithms, 124 | - the length of recipient/identity strings **can increase dramatically** by using a different algorithm. 125 | 126 | The default (in most cases) is "es256", which should provide the smallest recipient/identity strings. 127 | 128 | ## Testing 129 | 130 | ### Unit Tests 131 | 132 | In order to run unit tests, execute: 133 | 134 | ```bash 135 | make test 136 | ``` 137 | 138 | ### E2E Tests 139 | 140 | End-to-end tests can currently no be run in the CI/CD pipeline because they require a virtual fido2 token to be mounted. 141 | 142 | Use the following to setup a virtual test device with pin "1234" that always accepts any assertions: 143 | 144 | ```bash 145 | go install github.com/rogpeppe/go-internal/cmd/testscript@latest # check PATH includes $HOME/go/bin/ 146 | sudo dnf install usbip clang clang-devel 147 | git clone https://github.com/Nitrokey/nitrokey-3-firmware.git 148 | cd nitrokey-3-firmware/runners/usbip 149 | cargo build 150 | cargo run -- --ifs ../../../e2e/test_device.bin 151 | make attach # separate shell 152 | ``` 153 | 154 | Then run the tests using: 155 | 156 | ```bash 157 | make test-e2e 158 | ``` 159 | -------------------------------------------------------------------------------- /cmd/age-plugin-fido2-hmac/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "filippo.io/age" 11 | page "filippo.io/age/plugin" 12 | "github.com/keys-pub/go-libfido2" 13 | "github.com/olastor/age-plugin-fido2-hmac/pkg/plugin" 14 | ) 15 | 16 | var Version string 17 | 18 | const USAGE = `Usage: 19 | age-plugin-fido2-hmac [-s] [-a ALG] -g 20 | age-plugin-fido2-hmac -m 21 | 22 | Options: 23 | -g, --generate Generate new credentials interactively. 24 | -s, --symmetric Use symmetric encryption and use a new salt for every encryption. 25 | The token must be present for every operation. 26 | -m, --magic-identity Print the magic identity to use when no identity is required. 27 | -a, --algorithm Choose a specific algorithm when creating the fido2 credential. 28 | Can be one of 'es256', 'eddsa', or 'rs256'. Default: es256 29 | -v, --version Show the version. 30 | -h, --help Show this help message. 31 | 32 | Examples: 33 | $ age-plugin-fido2-hmac -g > identity.txt # only contains an identity if chosen by user 34 | $ cat identity.txt | grep 'public key' | grep -oP 'age1.*' > recipient.txt 35 | $ echo 'secret' | age -R recipient.txt -o secret.enc 36 | $ age -d -i identity.txt secret.enc # when you created an identity 37 | $ age -d -j fido2-hmac secret.enc # when there is no identity 38 | 39 | Environment Variables: 40 | 41 | FIDO2_TOKEN This variable can be used to force a specific device path. Please note that 42 | /dev/hid* paths are ephemeral and fido2 tokens (mostly) have no identifier. 43 | Therefore, it's in general not recommended to use this environment variable.` 44 | 45 | func main() { 46 | var ( 47 | pluginFlag string 48 | algorithmFlag string 49 | generateFlag bool 50 | helpFlag bool 51 | versionFlag bool 52 | symmetricFlag bool 53 | deprecatedMagicFlag bool 54 | ) 55 | 56 | flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", USAGE) } 57 | 58 | flag.StringVar(&pluginFlag, "age-plugin", "", "") 59 | 60 | flag.StringVar(&algorithmFlag, "a", "es256", "") 61 | flag.StringVar(&algorithmFlag, "algorithm", "es256", "") 62 | 63 | flag.BoolVar(&generateFlag, "g", false, "") 64 | flag.BoolVar(&generateFlag, "generate", false, "") 65 | flag.BoolVar(&generateFlag, "n", false, "") 66 | 67 | flag.BoolVar(&deprecatedMagicFlag, "m", false, "") 68 | flag.BoolVar(&deprecatedMagicFlag, "magic-identity", false, "") 69 | 70 | flag.BoolVar(&symmetricFlag, "s", false, "") 71 | flag.BoolVar(&symmetricFlag, "symmetric", false, "") 72 | 73 | flag.BoolVar(&versionFlag, "v", false, "") 74 | flag.BoolVar(&versionFlag, "version", false, "") 75 | 76 | flag.BoolVar(&helpFlag, "h", false, "") 77 | flag.BoolVar(&helpFlag, "help", false, "") 78 | 79 | flag.Parse() 80 | 81 | if helpFlag { 82 | flag.Usage() 83 | os.Exit(0) 84 | } 85 | 86 | if deprecatedMagicFlag { 87 | fmt.Print("AGE-PLUGIN-FIDO2-HMAC-1VE5KGMEJ945X6CTRM2TF76") 88 | os.Exit(0) 89 | } 90 | 91 | if generateFlag { 92 | algorithm := libfido2.ES256 93 | 94 | if algorithmFlag != "" { 95 | switch strings.TrimSpace(strings.ToLower(algorithmFlag)) { 96 | case "es256": 97 | algorithm = libfido2.ES256 98 | case "rs256": 99 | algorithm = libfido2.RS256 100 | case "eddsa": 101 | algorithm = libfido2.EDDSA 102 | default: 103 | fmt.Fprintf(os.Stderr, "Unknown algorithm: \"%s\"", algorithmFlag) 104 | os.Exit(1) 105 | } 106 | } 107 | 108 | recipientStr, identityStr, err := plugin.NewCredentialsCli(algorithm, symmetricFlag) 109 | if err != nil { 110 | fmt.Fprintf(os.Stderr, "Failed: %s", err) 111 | os.Exit(1) 112 | } 113 | 114 | if identityStr != "" { 115 | fmt.Fprintf(os.Stdout, "# public key: %s\n%s\n", recipientStr, identityStr) 116 | } else { 117 | fmt.Fprint(os.Stdout, "# for decryption, use `age -d -j fido2-hmac` without any identity file.\n") 118 | fmt.Fprintf(os.Stdout, "# public key: %s\n%s\n", recipientStr, identityStr) 119 | } 120 | 121 | os.Exit(0) 122 | } 123 | 124 | if pluginFlag == "recipient-v1" { 125 | p, err := page.New("fido2-hmac") 126 | if err != nil { 127 | os.Exit(1) 128 | } 129 | p.HandleRecipient(func(data []byte) (age.Recipient, error) { 130 | r, err := plugin.ParseFido2HmacRecipient(page.EncodeRecipient("fido2-hmac", data)) 131 | if err != nil { 132 | return nil, err 133 | } 134 | 135 | if r.Version == 1 { 136 | r.Device, err = plugin.FindDevice(50*time.Second, p.DisplayMessage) 137 | if err != nil { 138 | return nil, err 139 | } 140 | } 141 | 142 | r.Plugin = p 143 | return r, nil 144 | }) 145 | p.HandleIdentityAsRecipient(func(data []byte) (age.Recipient, error) { 146 | i, err := plugin.ParseFido2HmacIdentity(page.EncodeIdentity("fido2-hmac", data)) 147 | if err != nil { 148 | return nil, err 149 | } 150 | 151 | i.Device, err = plugin.FindDevice(50*time.Second, p.DisplayMessage) 152 | if err != nil { 153 | return nil, err 154 | } 155 | 156 | i.Plugin = p 157 | 158 | return i, nil 159 | }) 160 | if exitCode := p.RecipientV1(); exitCode != 0 { 161 | os.Exit(exitCode) 162 | } 163 | os.Exit(0) 164 | } 165 | 166 | if pluginFlag == "identity-v1" { 167 | p, err := page.New("fido2-hmac") 168 | if err != nil { 169 | os.Exit(1) 170 | } 171 | p.HandleIdentity(func(data []byte) (age.Identity, error) { 172 | i, err := plugin.ParseFido2HmacIdentity(page.EncodeIdentity("fido2-hmac", data)) 173 | if err != nil { 174 | return nil, err 175 | } 176 | 177 | i.Device, err = plugin.FindDevice(50*time.Second, p.DisplayMessage) 178 | if err != nil { 179 | return nil, err 180 | } 181 | 182 | i.Plugin = p 183 | return i, nil 184 | }) 185 | if exitCode := p.IdentityV1(); exitCode != 0 { 186 | os.Exit(exitCode) 187 | } 188 | os.Exit(0) 189 | } 190 | 191 | if versionFlag && Version != "" { 192 | fmt.Println(Version) 193 | os.Exit(0) 194 | } 195 | 196 | flag.Usage() 197 | os.Exit(1) 198 | } 199 | -------------------------------------------------------------------------------- /cmd/age-plugin-fido2-hmac/testdata/backcomp.txtar: -------------------------------------------------------------------------------- 1 | # for this test the virtual device must use `--ifs e2e/test_device.bin` ! 2 | 3 | ttyin -stdin pin 4 | exec age -i id_pin -d id_pin_enc 5 | cmp stdout plaintext 6 | 7 | exec age -i id_nopin -d id_nopin_enc 8 | cmp stdout plaintext 9 | 10 | ttyin -stdin pin 11 | exec age -i magic.txt -d rec_pin_enc 12 | cmp stdout plaintext 13 | 14 | exec age -i magic.txt -d rec_nopin_enc 15 | cmp stdout plaintext 16 | 17 | ttyin -stdin pin 18 | exec age -R rec_pin -o cipher1.txt plaintext 19 | 20 | ttyin -stdin pin 21 | exec age -i magic.txt -d cipher1.txt 22 | cmp stdout plaintext 23 | 24 | exec age -R rec_nopin -o cipher2.txt plaintext 25 | 26 | exec age -i magic.txt -d cipher2.txt 27 | cmp stdout plaintext 28 | 29 | ttyin -stdin pin 30 | exec age -e -i id_pin -o cipher3.txt plaintext 31 | 32 | ttyin -stdin pin 33 | ! exec age -i magic.txt -d cipher3.txt 34 | 35 | ttyin -stdin pin 36 | exec age -i id_pin -d cipher3.txt 37 | 38 | exec age -e -i id_nopin -o cipher4.txt plaintext 39 | ! exec age -i magic.txt -d cipher4.txt 40 | exec age -i id_nopin -d cipher4.txt 41 | 42 | -- magic.txt -- 43 | AGE-PLUGIN-FIDO2-HMAC-1VE5KGMEJ945X6CTRM2TF76 44 | -- pin-wrong -- 45 | 3333 46 | -- pin -- 47 | 1234 48 | -- id_pin -- 49 | AGE-PLUGIN-FIDO2-HMAC-1QQQSRGCQTPAVMHKZ5RJ56QV6H3V83Z4XGSJAD8F8VSKT64AMP9G8FLT96AG2F7ZRPPWH8RXXZR2DDNUC63Z2JAKUXZN96NZFH9U5XFVTSMUJ2VM0AZAJ8WSNRVTD5HZDUZ2ZADUHG8DWAL7K5K7CXL2D8TZ7HZ0QSAMR62LR3AE2HYP4A0WT02U9PKG343RSQ5FTHS4Y7DXQ8SGPFNMRSYCCR9YY4NHESJ4PZQJSY737H45PENLUHQ77CCAZY0ZF855C8E3A 50 | -- id_pin_enc -- 51 | -----BEGIN AGE ENCRYPTED FILE----- 52 | YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IGZpZG8yLWhtYWMgZGlXb1JrUGQzOXpy 53 | Q2w0M01hZ1lLZlNnV1V3RUpwRXNLcDhqUWIxM0s5WSBZeENscVJMQ3c2cHMvbE91 54 | CmVVSnl6bEh5dHAyd1RaeTRaWlRtcmNZeE5GV1lrdkNZTEJUYSt1Ymt2WEEKLS0t 55 | IFNlSHpVRE1IdFFFLzZZWjZKVWlEOFpTc0JESTRSYkY1S0FjQ3NsOXZremsKA7VJ 56 | CujBew2husuAsG3olHegEnMpvZkV37A+7QWLKPZyhkPP6jlheXQ8 57 | -----END AGE ENCRYPTED FILE----- 58 | -- id_nopin -- 59 | AGE-PLUGIN-FIDO2-HMAC-1QQQSPGCQTPA9DH805XYWQTJRTWQMARRE8KJA8Z998340EETKT55MUDHCTTMJU38GDPWNYA6GY9UTJ9MKH9TYTS0XSKAH2KWF6PC3H82R7W87JDF0W2MJX73HDKEPYQVEUDQ9DYCVJ3QK66PVGYR8JLWWZFNW2ETLVYX3KTNWT0X2L78PJ8AZ2AT0NFT8Y9XS7CMVFVHH5JA2CGCPF3JSCCAH40DXHX4QLGX56QJS3ZFWRU5QWPWTA2VYAGSE53XNL5X6TLC3 60 | -- id_nopin_enc -- 61 | -----BEGIN AGE ENCRYPTED FILE----- 62 | YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IGZpZG8yLWhtYWMgaWZrY0xRWjRSWUgw 63 | MnZvODJSdFppbTNtSlJOT3ZQYW9CS3RZVlVPckR3MCBlZ0RUQTk2dXVEc2s4ZU5P 64 | CjB1czFycmh5Z1Y1NDVidFhnUVVQVTFpYWhVcm9MczJlZEp4SUVXNDNZUjAKLS0t 65 | IER4bG5USVcwcTBhUXdYR0drckQwZ0t0bkt5cXVzc1BmK0JnYTRETzRJZjgKt585 66 | c4kdOFY2Pp4szsQwxOrz2jswKfyuNiE5gDd1t9rax0OAICcSDgou 67 | -----END AGE ENCRYPTED FILE----- 68 | -- rec_pin -- 69 | age1fido2-hmac1qqqsrgcqtpar475wuj0hnfl2ea7395ce8r7h49kynccnc2rnu7nqefq7d97ynl5slfdl9etn465xetqdhf8ly924gcnk7tvazt0zutwszkcpkev0yj36p9ywzaxuy68jc9sqt9vcttvrwzd8jg7xlh0tgqax8n95cfk78tfh9fqjjlensrquqw0zrcjk65tffqg2wquqg958k8spfnslvevxxuhp8k7j2r796qjsg3egereemd8hsd0r2dk5zmdkmc4hvxq8 70 | -- rec_pin_enc -- 71 | -----BEGIN AGE ENCRYPTED FILE----- 72 | YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IGZpZG8yLWhtYWMgZnpIc1BRNEc2QjRT 73 | Nmk4ODhSenV2MEtTQm1abFdmRjRyZkx2UEFTaTVwVSBhNS9SQ0s4a01ZcFpuV3dC 74 | IEFRIG93QlllanI2anVTZmVhZnF6MzBTMHhrNC9YcVd4SjR4UENoejU2WU1wQjVw 75 | ZkVuK2tQcGI4dVZ6cnFoc3JBMjZUL0lWVlVZbmJ5MmRFdDR1TGRBVnNCdGxqeVNq 76 | b0pTT0YwM0NhUExCWUFXVm1GcllOd21ua2p4djNldEFPbVBNdE1KdDQ2MDNLa0Vw 77 | ZnpPQXdjQTU0aDRsYlZGcFNCQ25BNEJCYUhzZUFVemg5bVdHTnk0VDI5SlEvRjBD 78 | VUVSeWpJODUyMDk0TmVOVGJVRnR0dDQKQ2Z4SFd2YXRvZm9welVCNHlMNWR3S3FL 79 | SllhbjVZbUI0T3BObFNZRTQrWQotLS0gMi84WWtVVStJa0hybFIvcysyd21XTDFx 80 | VFVkN1FmQjkzNVZwekZYdWVUMAqds0DTFz2XE38ugoczIFttRcADioVQJ11zHoFn 81 | vFpsvq572Y4wl4PsT9Y= 82 | -----END AGE ENCRYPTED FILE----- 83 | -- rec_nopin -- 84 | age1fido2-hmac1qqqspgcqtpavglpky9n2mz9n7mzyj4p6tgncnr28saewskqykljdgf2524gmcxramxy87rtk0z7vnz5hylr6gy4rnmhr3zuff2psxd2uv0m06uqcyjnd3s8u5qt2mfwz5rqce904plvn9fy3a2cw5zklg5xmemzlhplvhlw9s464tlccjkcpx6rfjp7d57l72yc3kzhlfplgmwspfjsw32w4wc5u8uxdv68q6qjs87ztp790hvflyxxrc47ulca66ymz9e4v 85 | -- rec_nopin_enc -- 86 | -----BEGIN AGE ENCRYPTED FILE----- 87 | YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IGZpZG8yLWhtYWMgbW51cHg4WGRGVURM 88 | aVhWZGFCczk0SFVmRFFLS2JPaEJPbm1GVXk0YTNmbyBsVC9sdnl3U3RMaUtIVXBD 89 | IEFBIG93Qlllc1I4TmlGbXJZaXo5c1JKVkRwYUo0bU5SNGR5NkZnRXQrVFVKVlJW 90 | VWJ3WWZkbUlmdzEyZUx6SmlwY254NlFTbzU3dU9JdUpTb01ETlZ4ajl2MXdHQ1Nt 91 | Mk1EOG9CYXRwY0tnd1l5VjlRL1pNcVNSNnJEcUN0OUZEYnpzWDdoK3kvM0ZoWFZW 92 | L3hpVnNCTm9hWkI4Mm52K1VURWJDdjlJZm8yNkFVeWc2S25WZGluRDhNMW1qZzBD 93 | VUQrRXNQaXZ1eFB5R01QRmZjL2p1dEUKZFlROHVzdVVZZWduNGhXUGIrTW10RjF0 94 | WVhKWWRnTnZ2RVRuc3NTWEZJdwotLS0gbUw4aStCQ3pqTGhXbHRLaHJ6QXM4MHls 95 | MldLS0tPbUFXSVhrM0JYTTUwNAqZOwcAHm6lloFqOUbcHplG7a2Hd1O4d6y9C/0a 96 | JB95Z1YyleSJMgWW4+I= 97 | -----END AGE ENCRYPTED FILE----- 98 | -- plaintext -- 99 | plaintext 100 | -- the-end -- 101 | -------------------------------------------------------------------------------- /cmd/age-plugin-fido2-hmac/testdata/multiple-stanzas.txtar: -------------------------------------------------------------------------------- 1 | # fido key must have pin 1234 2 | 3 | exec age-keygen 4 | cp stdout identity0.txt 5 | 6 | ttyin -stdin yes-pin-no-identity 7 | exec age-plugin-fido2-hmac -g 8 | cp stdout identity1.txt 9 | 10 | ttyin -stdin no-pin-yes-identity 11 | exec age-plugin-fido2-hmac -g 12 | cp stdout identity2.txt 13 | 14 | exec bash -c 'cat identity0.txt identity1.txt identity2.txt | grep -oP "age1.*" > recipients.txt' 15 | 16 | exec age -R recipients.txt -o ciphertext plaintext 17 | 18 | ttyin -stdin pin 19 | exec age -d -o plaintext1 -j fido2-hmac ciphertext 20 | cmp plaintext plaintext1 21 | 22 | exec age -d -o plaintext2 -i identity2.txt ciphertext 23 | cmp plaintext plaintext2 24 | 25 | -- pin -- 26 | 1234 27 | -- yes-pin-no-identity -- 28 | 1234 29 | y 30 | n 31 | 32 | -- no-pin-yes-identity -- 33 | 1234 34 | n 35 | y 36 | 37 | -- plaintext -- 38 | this is the plaintext 39 | -------------------------------------------------------------------------------- /cmd/age-plugin-fido2-hmac/testdata/pin-no.txtar: -------------------------------------------------------------------------------- 1 | # fido key must have pin 1234 2 | 3 | ttyin -stdin no-pin-no-identity 4 | exec age-plugin-fido2-hmac -g 5 | cp stdout out1 6 | 7 | ttyin -stdin no-pin-yes-identity 8 | exec age-plugin-fido2-hmac -g 9 | cp stdout out2 10 | 11 | grep -count=1 'age1fido2-hmac' out1 12 | 13 | ! grep 'age1fido2-hmac' out2 14 | ! grep 'AGE-PLUGIN-FIDO2-HMAC-1VE5KGMEJ945X6CTRM2TF76' out2 15 | grep -count=1 'age1' out2 16 | grep -count=1 'AGE-PLUGIN-FIDO2-HMAC-' out2 17 | 18 | exec bash -c 'cat out1 | grep -oP "age1.*" > recipient1.txt' 19 | ! stderr . 20 | exec bash -c 'cat out2 | grep -oP "age1.*" > recipient2.txt' 21 | ! stderr . 22 | 23 | exec bash -c 'cat out2 | tail -n1 > identity2.txt' 24 | ! stderr . 25 | 26 | exec age -R recipient1.txt -o ciphertext1 plaintext 27 | ! stderr . 28 | 29 | exec age -R recipient2.txt -o ciphertext2 plaintext 30 | ! stderr . 31 | 32 | exec age -d -o plaintext1 -j fido2-hmac ciphertext1 33 | cmp plaintext plaintext1 34 | 35 | ! exec age -d -o plaintext2 -j fido2-hmac ciphertext2 36 | exec age -d -o plaintext2 -i identity2.txt ciphertext2 37 | cmp plaintext plaintext2 38 | 39 | exec age -e -i identity2.txt -o ciphertext3 plaintext 40 | ! exec age -d -o plaintext3 -j fido2-hmac ciphertext3 41 | exec age -d -o plaintext3 -i identity2.txt ciphertext3 42 | cmp plaintext plaintext3 43 | 44 | -- no-pin-no-identity -- 45 | 1234 46 | n 47 | n 48 | 49 | -- no-pin-yes-identity -- 50 | 1234 51 | n 52 | y 53 | 54 | -- plaintext -- 55 | this is the plaintext 56 | -------------------------------------------------------------------------------- /cmd/age-plugin-fido2-hmac/testdata/pin-yes.txtar: -------------------------------------------------------------------------------- 1 | # fido key must have pin 1234 2 | ttyin -stdin yes-pin-no-identity 3 | exec age-plugin-fido2-hmac -g 4 | cp stdout out1 5 | 6 | ttyin -stdin yes-pin-yes-identity 7 | exec age-plugin-fido2-hmac -g 8 | cp stdout out2 9 | 10 | grep -count=1 'age1fido2-hmac' out1 11 | 12 | ! grep 'age1fido2-hmac' out2 13 | ! grep 'AGE-PLUGIN-FIDO2-HMAC-1VE5KGMEJ945X6CTRM2TF76' out2 14 | grep -count=1 'age1' out2 15 | grep -count=1 'AGE-PLUGIN-FIDO2-HMAC-' out2 16 | 17 | exec bash -c 'cat out1 | grep -oP "age1.*" > recipient1.txt' 18 | ! stderr . 19 | exec bash -c 'cat out2 | grep -oP "age1.*" > recipient2.txt' 20 | ! stderr . 21 | 22 | exec bash -c 'cat out2 | tail -n1 > identity2.txt' 23 | ! stderr . 24 | 25 | exec age -R recipient1.txt -o ciphertext1 plaintext 26 | ! stderr . 27 | 28 | exec age -R recipient2.txt -o ciphertext2 plaintext 29 | ! stderr . 30 | 31 | ttyin -stdin pin-wrong 32 | ! exec age -d -o plaintext1 -j fido2-hmac ciphertext1 33 | stderr 'pin invalid' 34 | 35 | ttyin -stdin pin-wrong 36 | ! exec age -d -o plaintext2 -i identity2.txt ciphertext2 37 | stderr 'pin invalid' 38 | 39 | ttyin -stdin pin 40 | exec age -d -o plaintext1 -j fido2-hmac ciphertext1 41 | cmp plaintext plaintext1 42 | 43 | ttyin -stdin pin 44 | exec age -d -o plaintext2 -i identity2.txt ciphertext2 45 | cmp plaintext plaintext2 46 | 47 | ! exec age -d -o plaintext2 -i magic.txt ciphertext2 48 | 49 | ttyin -stdin pin 50 | exec age -e -i identity2.txt -o ciphertext3 plaintext 51 | 52 | ttyin -stdin pin 53 | ! exec age -d -o plaintext3 -i magic.txt ciphertext3 54 | 55 | ttyin -stdin pin 56 | exec age -d -o plaintext3 -i identity2.txt ciphertext3 57 | cmp plaintext plaintext3 58 | 59 | 60 | -- pin-wrong -- 61 | 3333 62 | -- pin -- 63 | 1234 64 | -- yes-pin-no-identity -- 65 | 1234 66 | y 67 | n 68 | 69 | -- yes-pin-yes-identity -- 70 | 1234 71 | y 72 | y 73 | 74 | -- magic.txt -- 75 | AGE-PLUGIN-FIDO2-HMAC-1VE5KGMEJ945X6CTRM2TF76 76 | -- plaintext -- 77 | this is the plaintext 78 | -------------------------------------------------------------------------------- /cmd/age-plugin-fido2-hmac/testdata/symmetric-pin-no.txtar: -------------------------------------------------------------------------------- 1 | # fido key must have pin 1234 2 | ttyin -stdin tty-no 3 | exec age-plugin-fido2-hmac --symmetric -g 4 | cp stdout out1 5 | 6 | ttyin -stdin tty-yes 7 | exec age-plugin-fido2-hmac --symmetric -g 8 | cp stdout out2 9 | 10 | grep -count=1 'age1fido2-hmac' out1 11 | 12 | grep 'age1fido2-hmac' out2 13 | grep -count=1 'AGE-PLUGIN-FIDO2-HMAC-' out2 14 | 15 | exec bash -c 'cat out1 | grep -oP "age1.*" > recipient1.txt' 16 | ! stderr . 17 | exec bash -c 'cat out2 | grep -oP "age1.*" > recipient2.txt' 18 | ! stderr . 19 | 20 | exec bash -c 'cat out2 | tail -n1 > identity2.txt' 21 | ! stderr . 22 | 23 | exec age -R recipient1.txt -o ciphertext1 plaintext 24 | stderr 'touch your token' 25 | 26 | exec age -R recipient2.txt -o ciphertext2 plaintext 27 | stderr 'touch your token' 28 | 29 | exec age -d -o plaintext1 -j fido2-hmac ciphertext1 30 | cmp plaintext plaintext1 31 | 32 | exec age -d -o plaintext2 -i identity2.txt ciphertext2 33 | cmp plaintext plaintext2 34 | 35 | exec age -e -i identity2.txt -o ciphertext3 plaintext 36 | ! exec age -d -o plaintext3 -j fido2-hmac ciphertext3 37 | exec age -d -o plaintext3 -i identity2.txt ciphertext3 38 | cmp plaintext plaintext3 39 | 40 | -- tty-no -- 41 | 1234 42 | n 43 | n 44 | 45 | -- tty-yes -- 46 | 1234 47 | n 48 | y 49 | 50 | -- plaintext -- 51 | this is the plaintext 52 | -------------------------------------------------------------------------------- /cmd/age-plugin-fido2-hmac/testdata/symmetric-pin-yes.txtar: -------------------------------------------------------------------------------- 1 | # fido key must have pin 1234 2 | ttyin -stdin tty-no 3 | exec age-plugin-fido2-hmac -s -g 4 | cp stdout out1 5 | 6 | ttyin -stdin tty-yes 7 | exec age-plugin-fido2-hmac --symmetric --generate 8 | cp stdout out2 9 | 10 | grep -count=1 'age1fido2-hmac' out1 11 | 12 | grep 'age1fido2-hmac' out2 13 | grep -count=1 'AGE-PLUGIN-FIDO2-HMAC-' out2 14 | 15 | exec bash -c 'cat out1 | grep -oP "age1fido2-hmac.*" > recipient1.txt' 16 | ! stderr . 17 | exec bash -c 'cat out2 | grep -oP "age1fido2-hmac.*" > recipient2.txt' 18 | ! stderr . 19 | 20 | exec bash -c 'cat out2 | tail -n1 > identity2.txt' 21 | ! stderr . 22 | 23 | ttyin -stdin pin 24 | exec age -R recipient1.txt -o ciphertext1 plaintext 25 | stderr 'touch your token' 26 | 27 | ttyin -stdin pin 28 | exec age -R recipient2.txt -o ciphertext2 plaintext 29 | stderr 'touch your token' 30 | 31 | ttyin -stdin pin-wrong 32 | ! exec age -d -o plaintext1 -j fido2-hmac ciphertext1 33 | stderr 'pin invalid' 34 | 35 | ttyin -stdin pin-wrong 36 | ! exec age -d -o plaintext2 -i identity2.txt ciphertext2 37 | stderr 'pin invalid' 38 | 39 | ttyin -stdin pin 40 | exec age -d -o plaintext1 -j fido2-hmac ciphertext1 41 | stderr 'touch your token' 42 | cmp plaintext plaintext1 43 | 44 | ttyin -stdin pin 45 | exec age -d -o plaintext2 -i identity2.txt ciphertext2 46 | cmp plaintext plaintext2 47 | 48 | ttyin -stdin pin 49 | exec age -e -i identity2.txt -o ciphertext3 plaintext 50 | 51 | ttyin -stdin pin 52 | ! exec age -d -o plaintext3 -j fido2-hmac ciphertext3 53 | 54 | ttyin -stdin pin 55 | exec age -d -o plaintext3 -i identity2.txt ciphertext3 56 | cmp plaintext plaintext3 57 | 58 | -- pin-wrong -- 59 | 3333 60 | -- pin -- 61 | 1234 62 | -- tty-no -- 63 | 1234 64 | y 65 | n 66 | 67 | -- tty-yes -- 68 | 1234 69 | y 70 | y 71 | 72 | -- plaintext -- 73 | this is the plaintext 74 | -------------------------------------------------------------------------------- /docs/spec-v1.md: -------------------------------------------------------------------------------- 1 | # Plugin Specification: age-plugin-fido2-hmac 2 | 3 | - Version: 1.0.0 4 | - Status: Draft 5 | 6 | # Motivation 7 | 8 | This plugin's purpose is to enable encryption and decryption of files with age and FIDO2 tokens (such as YubiKeys, NitroKeys etc.). Unlike `age-plugin-yubikey` [1], which stores a key on the token, file keys are wrapped in a _stateless manner_ by utilizing the `hmac-secret` extension [2], similar to how systemd-cryptenroll implements it [3]. Thus, this plugin is inspired by the proof-of-concept plugin `age-plugin-fido` [4] and seeks to 9 | 10 | - be compliant with the age plugin specification [5], 11 | - implement the notion of recipients/identities using _non-discoverable credentials_, 12 | - support encryption/decryption with one or more fido2 tokens, 13 | - and provide decent user experience and error handling. 14 | 15 | ## Constants 16 | 17 | - Plugin Name: `fido2-hmac` 18 | - Binary Name: `age-plugin-fido2-hmac` 19 | - Recipient Prefix: `age1fido2-hmac` 20 | - Identity Prefix: `AGE-PLUGIN-FIDO2-HMAC-` 21 | 22 | ## Recipients & Identities 23 | 24 | For wrapping/unwrapping the file key provided by age, both the (physical) **fido2 authenticator** and the **non-discoverable credential** generated with it must be present. 25 | 26 | Depending on whether or not the _credential id_ is kept as a separate secret there are two ways of defining recipients and identites. This plugin supports both types. 27 | 28 | ### Encryption using "recipients" 29 | 30 | If the _credential ID_ shall be treated as _public_ information, the plugin includes it both in an age recipient string and the stanza stored in the header of the encrypted file. For decryption, only the fido2 token needs to be presented. However, since age might enforce the presence of an identity, the plugin in this case accepts a static, "magic" identity, which simply is the BECH32-encoded plugin name: `AGE-PLUGIN-FIDO2-HMAC-1VE5KGMEJ945X6CTRM2TF76`. 31 | 32 | This mode of encryption opts for convenience, but does not protect well against compromise of the (physical) fido2 token. It is therefore recommended to activate "user verification", i.e., via PIN, when creating the recipient. 33 | 34 | #### Format 35 | 36 | The recipient encodes the following data in Bech32 using the recipient's HRP: 37 | 38 | ``` 39 | +-------------------------------------------------------+ 40 | | version (2 bytes) | pin flag (1 byte) | credential id | 41 | +-------------------------------------------------------+ 42 | ``` 43 | 44 | - the version which is incremented on every change of the format (big-endian unsigned short) 45 | - a boolean indicating whether user verification via PIN must be used for assertions 46 | - the variable-length credential ID 47 | 48 | ### Encryption using "identities" 49 | 50 | In contrast to the above, the plugin also allows for creating age identities which contain a credential id, treating them as _private_ information. For encryption, the identity is provided instead of a recipient and the credential id is **not** included in the stanza. Thus, decryption requires the exact same identity in addition to the presence of the (physical) fido2 token. 51 | 52 | This mode of encryption emphasizes security and anonymity. Without the age identity, it is impossible to decrypt the file or identify the fido2 token used for encryption. The user is responsible for keeping the age identity secret and preventing it from being lossed. 53 | 54 | #### Format 55 | 56 | The format is identical to the recipient format, but uses identity-HRP. 57 | 58 | ### Generating Credentials 59 | 60 | The plugin MUST create non-discoverable credentials. It supports PIN as a means for user verification, but no other methods such as biometric features. 61 | 62 | ## Stanza Format 63 | 64 | Whenever a _recipient_ is used for encryption, the following stanza is generated: 65 | 66 | ``` 67 | -> fido2-hmac 68 | 69 | ``` 70 | 71 | 72 | If instead an _identity_ is used, the stanza has the following shape: 73 | 74 | ``` 75 | -> fido2-hmac 76 | 77 | ``` 78 | 79 | ## File Key Wrapping 80 | 81 | ### Encryption 82 | 83 | 1. Generate a random 32 byte `salt`. 84 | 2. Retrieve the HMAC `secret` from the fido2 token using the credential id and the `salt`. 85 | 3. Generate a random 12-byte `nonce`. 86 | 4. Encrypt the file key provided by age with ChaCha20Poly1305, using `secret` as key and the previously generated `nonce`. 87 | 88 | The resulting ciphertext is passed to age in the stanza. 89 | 90 | ### Decryption 91 | 92 | 1. Get the `nonce` and `salt` from the stanza. 93 | 2. Retrieve the `secret` on the fido2 token using the credential id (which is either extracted from the stanza or an identity) and the `salt`. 94 | 3. Decrypt the wrapped file key using the `secret` and `nonce`. 95 | 96 | # UX Considerations 97 | 98 | ## Invalid PIN Error Handling 99 | 100 | The plugin MUST show appropriate error messages about incorrect PINs. If there is only one retry left, the plugin MUST abort immediately without using up this last try. 101 | 102 | ## Multiple Tokens 103 | 104 | The plugin SHOULD try to minimize the amount of user interaction required. 105 | 106 | - The plugin MUST be able to decrypt the file with any of the valid tokens. It MUST NOT require one specific valid token to be presented. The user chooses which one to use. 107 | - The plugin MUST NOT expect the user to insert the same fido2 token multiple times for decryption. All necessary operations with a specific token MUST be done while the token is inserted the first time. For encryption, it is expected that the user does not use multiple recipients/identities that map to the same token. 108 | - The plugin MUST be able to deal with both with multiple tokens being available simultaneously and tokens being presented sequentially by the user. 109 | - Whenever silent detection of a token that can decrypt the file is possible, the plugin SHOULD not ask the user to choose or insert a different token. All operations that can be done silently SHOULD first be exhausted before requiring user interactions. 110 | - The plugin SHOULD be cautious of making redundant assertions with user verification and retrying assertions. UV often means PIN verification, and tokens have a limited amount of tries after which the token can get locked and needs a reset. 111 | - The timeout for inserting a token SHOULD be long enough for the user to overcome common physical challenges of finding and inserting it. 112 | 113 | ### Generating New Credentials 114 | 115 | When creating a new recipient/identity and there are multiple tokens available, the plugin MUST initiate a selection process. The user selects which token to use by proving user precence (tapping on the security key) for the token that shall be used. 116 | 117 | ### Encryption 118 | 119 | A file may be encrypted with multiple tokens to prevent decryption not being possible if one token gets lost. 120 | 121 | At encryption, the file key must be wrapped by possiblly multiple tokens if there are multiple target recipients/identities. 122 | 123 | While there are remaining recipients/identities that the file key needs to be wrapped with, do the following: 124 | 125 | 1. Initialize an empty _ignore list_. 126 | 2. If no tokens are available or all available tokens are in the _ignore list_, wait until the user has one at least one new token. 127 | 3. Sort all available tokens by their `alwaysUv` property such that the ones with `false` are before the ones with `true`. Then iterate over all tokens: 128 | 1. If `alwaysUv` equals `false`, perform a silent assertion without hmac-extension for each recipient's/identity's credential. 129 | 1. If the assertion succeeds, then send a second assertion using the hmac-assertion and the correct uv flag to obtain the secret for encryption. Remove the recipient/identity from the list of remaining recipients/identities. Add the token to the ignore list. Terminate if none are remaining. 130 | 2. If the assertion fails with `CTAP2_ERR_INVALID_CREDENTIAL`, then proceed to checking the next recipient/identity. If this is the last recipient/identity to check, then show an error to the user that this token does not match, and add the token to the _ignore list_. 131 | 3. If any other error is raised, show an error message and abort. 132 | 2. If `alwaysUv` equals `true`, then pick the first remaining recipient/identity and do an assertion with the hmac-secret extension. 133 | 1. If the assertion succeeds, then use the secret for encryption and remove the recipient/identity from the list of remaining recipients/identities. Add the token to the ignore list. Terminate, if none are remaining. 134 | 2. If the assertion fails with `CTAP2_ERR_INVALID_CREDENTIAL`, then repeat 3.2 using the next remaining recipient/identity. 135 | 3. If any other error is raised, show an error message and abort. 136 | 4. If there are still recipients/identities left, then goto 2. 137 | 138 | ### Decryption 139 | 140 | At decryption, the file key must be unwrapped with any one of the valid tokens. 141 | 142 | 1. Initialize an empty _ignore list_. 143 | 2. If no tokens are available or all available tokens are in the _ignore list_, wait until the user has one at least one new token. 144 | 3. Sort all available tokens by their `alwaysUv` property such that the ones with `false` are before the ones with `true`. Then iterate over all tokens: 145 | 1. If `alwaysUv` equals `false`, perform a silent assertion without hmac-extension for each recipient's/identity's credential. 146 | 1. If the assertion succeeds, then send a second assertion using the hmac-assertion and the correct `UV` flag to obtain the secret for encryption. Terminate and use the secret for decryption upon success. 147 | 2. If the assertion fails with `CTAP2_ERR_INVALID_CREDENTIAL`, then proceed to checking the next recipient/identity. If this is the last recipient/identity to check, then show an error to the user that this token does not match, and add the token to the _ignore list_. 148 | 3. If any other error is raised, show an error message and abort. 149 | 2. If `alwaysUv` equals `true`, then do trial and error for all recipients/identities. 150 | 1. If the assertion succeeds, then terminate and use the obtained secret for decryption. 151 | 2. If the assertion fails with `CTAP2_ERR_INVALID_CREDENTIAL`, then goto 3.2. 152 | 3. If any other error is raised, show an error message and abort. 153 | 3. Add the token to the _ignore list_. 154 | 4. Goto 2 155 | 156 | #### Caveats 157 | 158 | For identities, there is (purposely) not possible to link a stanza to an identity without performing an HMAC assertion and testing the decryption. In this case the plugin is forced to do perform "trial and error" to find out which salt/nonce was used once a token was recognized to map to an identity. Per assertion at most two HMACs can be calculated, which means if the number of times the user has to tap the token and needs to enter the PIN could be as high as `ceil((number of anonymous stanzas for this plugin) / 2)`. As it seems unlikely that the average user would use more than two identity-based tokens, keeping it this way is better than extending the stanza with addtional information which could decrease the level of anonymity/security. 159 | 160 | # References 161 | 162 | [1] https://github.com/str4d/age-plugin-yubikey \ 163 | [2] https://fidoalliance.org/specs/fido-v2.1-rd-20210309/fido-client-to-authenticator-protocol-v2.1-rd-20210309.html#sctn-hmac-secret-extension \ 164 | [3] https://www.freedesktop.org/software/systemd/man/systemd-cryptenroll.html \ 165 | [4] https://github.com/riastradh/age-plugin-fido \ 166 | [5] https://github.com/C2SP/C2SP/blob/main/age-plugin.md \ 167 | [6] https://fidoalliance.org/specs/fido-v2.1-rd-20210309/fido-client-to-authenticator-protocol-v2.1-rd-20210309.html#error-responses \ 168 | -------------------------------------------------------------------------------- /docs/spec-v2.md: -------------------------------------------------------------------------------- 1 | # Plugin Specification: age-plugin-fido2-hmac 2 | 3 | - Version: 2.0.0 4 | - Status: Draft 5 | 6 | # Motivation 7 | 8 | This plugin's purpose is to enable encryption and decryption of files with age and FIDO2 tokens (such as YubiKeys, NitroKeys etc.). Unlike `age-plugin-yubikey` [1], which only supports the Yubikeys series 4+, this plugin supports the fido2-only series and any fido2 token in general that implements the `hmac-secret` extension [2]. In comparison to the proof-of-concept plugin `age-plugin-fido` [4], this plugin adds the ability for encryption in absence of the authenticator and protection via PIN. 9 | 10 | ## Constants 11 | 12 | - Plugin Name: `fido2-hmac` 13 | - Binary Name: `age-plugin-fido2-hmac` 14 | - Recipient Prefix: `age1fido2-hmac` (or the native X25519 recipient prefix) 15 | - Identity Prefix: `AGE-PLUGIN-FIDO2-HMAC-` 16 | - Relying Party Name: `age-encryption.org` 17 | 18 | In the following, "authenticator" refers to a (physical) fido2 device/token. 19 | 20 | ## Background: FIDO2's "hmac-secret" extension 21 | 22 | ``` 23 | ┌─────────────────────────────┐ 24 | │ [fido2 token] │ 25 | [CRED_ID], RP_ID, SALT ──►│ ├──► OUTPUT 26 | │ CRED_ID ─► secret │ 27 | │ OUTPUT = hmac(SALT, secret) │ 28 | └─────────────────────────────┘ 29 | ``` 30 | 31 | The "hmac-secret" extension enables the generation an hmac using an user-defined salt and a credential-specific secret only "known" to the device (see [here](https://fidoalliance.org/specs/fido-v2.1-rd-20210309/fido-client-to-authenticator-protocol-v2.1-rd-20210309.html#authenticatorGetAssertion)). Moreover: 32 | 33 | - Non-discoverable credentials are nearly stateless (resetting the token might still invalidate the credential). The key material is not stored on the authenticator, but is wrapped in the `CRED_ID` and can only recovered with the authenticator that generated it. For discoverable credentials, the `CRED_ID` is optional. The credential is stored on the authenticator and can be _discovered_ only using the `RP_ID` (relying party id). 34 | - The hmac-secret extension allows for passing one (or two) 32 byte salt(s). A credential-specific secret is used to generate one (or two) hmac output(s) on the device. Only the output leaves the device, not the secret. The secret is different if a PIN is used. 35 | 36 | ## Considerations 37 | 38 | ### Security Goals 39 | 40 | #### SG-1: Secure Encryption 41 | 42 | The encryption/wrapping of the file key MUST use a secure encryption algorithm. The key material MUST have proper entropy. It MUST NOT be possible to recover the key without physical access to the authenticator and knowledge of the PIN (if set and meant to be required). 43 | 44 | #### SG-2: Minimal Exposure of Secret Key 45 | 46 | The secret key (the HMAC-output) MUST be kept only in memory temporarily for performing the necessary cryptographic operations to unwrap a file key or generate a new recipient/identity. 47 | 48 | #### SG-3: User Presence/Verification for Decryption 49 | 50 | The secret key MUST only be generated with user presence (i.e. touching the authenticator). The user MUST be given the choice (during generation of new credentials) to additionally require user verification via PIN for every decryption. 51 | 52 | #### SG-4: Unlinkability 53 | 54 | It SHOULD NOT be possible to identify two files as being encrypted to the same recipient by only inspecting the public metdata (the stanzas). 55 | 56 | ### UX Goals 57 | 58 | #### UG-1: Intuitive CLI 59 | 60 | The plugin MUST be simple and intuitive to use. It MUST only serve the purpose of encrypting files with fido2 tokens. Technicalities SHOULD be hidden or explained on a high level if it's important information to the user. 61 | 62 | #### UG-2: User Absence for Encryption 63 | 64 | Encryption SHOULD be possible in an asymmetric fashion, where the authenticator is optional for encryption, but mandatory for decryption. 65 | 66 | #### UG-3: No Separate Identities 67 | 68 | It SHOULD be possible to use the authenticator for decryption without any additional identity file. 69 | 70 | ### Trade-Offs 71 | 72 | Using a new salt for every encryption would mean the authenticator must be present and challenged for a new hmac output. UG-2 cannot be achieved in this case. If encryption is done in absence of the authenticator, reusing the same combination of a salt and credential is therefore implied. 73 | 74 | Moreover, SG-4 conflicts with UG-3. In order to not have a separate identity, the salt and the credential ID must be included as public metadata in the stanza, as these provide the necessary information to recover the secret key. Even using discoverable credentials, the (separate) salt must still be treated as public metadata and can be used to link ciphertexts. Only if a fixed salt would be used at all times, both SG-4 and UG-3 would be possible (with discoverable credentials only). However, this might raise further concerns about whether or not the hmac output of a fixed salt can be trusted for cryptography. 75 | 76 | Considering the mentioned conflicts of interest, **two user groups** are distinguished in the following: 77 | 78 | - **Group 1**: Good UX over privacy. Fulfills SG-1, SG-2, SG-3, UG-1, UG-2, UG-3. 79 | - Being able to use the plugin without storing a separate identity file is more important than unlikability. 80 | - The authenticator should never be needed to present for encryption. 81 | - **Group 2**: Security and privacy aspects are most important. Fulfills: SG-1, SG-2, SG-3, SG-4, UG-1, UG-2 82 | - To achieve unlikability, this group is willing to securely store a separate identity file that is required for decryption. 83 | 84 | ## Format Specification 85 | 86 | ### Identity 87 | 88 | ``` 89 | +--------------+----------------+------------+---------------------------------+ 90 | | version (2B) | pin flag (1B) | salt (32B) | credential id (variable length) | 91 | +--------------+----------------+------------+---------------------------------+ 92 | ``` 93 | 94 | The identity (only used for _group 2_) consists of: 95 | 96 | - the identity format version ("2") which is incremented on every change of the format (big-endian unsigned short) 97 | - a byte representation of either "0" (no PIN) or "1" (use PIN) 98 | - a 32 byte long, randomly generated salt 99 | - the credential id of a non-discoverable fido2 credential with enabled hmac-secret extension 100 | 101 | Note that _group 1_ uses a fixed, dummy identity instead. 102 | 103 | ### Recipient 104 | 105 | ``` 106 | +--------------+---------------------+---------------+------------+---------------------------------+ 107 | | version (2B) | x25519 pubkey (32B) | pin flag (1B) | salt (32B) | credential id (variable length) | 108 | +--------------+---------------------+---------------+------------+---------------------------------+ 109 | ``` 110 | 111 | The recipient (only used for _group 1_) consists of: 112 | 113 | - the recipient format version ("2") which is incremented on every change of the format (big-endian unsigned short) 114 | - 32 bytes of a native age x25519 public key derived from the private key (the hmac secret) 115 | - the rest is identical to the data of a separate identity 116 | 117 | Note that _group 2_ uses native x25519 recipients instead of plugin recipients. 118 | 119 | ### Stanza 120 | 121 | For _group 2_, the native X25519 stanza is used. 122 | 123 | For _group 1_, the first stanza **argument** (after the plugin name) is the base64-encoded version number of the stanza format ("2"). The second argument is the first X25519 stanza argument (the ephemeral share) after wrapping the file key. The remaining arguments are identitcal to the identity (excluding version number), i.e. three base64-encoded (unpadded) strings containing the data parts of the identity data. 124 | 125 | ``` 126 | -> fido2-hmac 127 | 128 | ``` 129 | 130 | The stanza **body** is for both groups identical to the native X25519 stanza body after wrapping the file key. 131 | 132 | Note: Future versions may differentiate identity and stanza versions. 133 | 134 | ## Protocol Specification 135 | 136 | ### Generating New Recipients/Identities 137 | 138 | 1. Ask the user to insert the authenticator 139 | 2. If the authenticator is protected via PIN, ask for the PIN (assumed to be required for creating credentials for most authenticators) 140 | 3. Generate a new fido2 credential 141 | - Set "RK" to `false` (non-discoverable) 142 | - Enable the "hmac-secret" extension 143 | - Use the plugin's relying party ID 144 | - Use random values for user id/name 145 | 4. Generate a 32 byte salt using a CSPRNG 146 | 5. Ask the user whether to require a PIN for decryption 147 | 6. Challenge the authenticator for the hmac output 148 | - Use the desired PIN preference (the internal secret changes dependent on it!) 149 | - Use the previously generated credential ID and salt 150 | 7. Derive an X25519 public key using the 32 byte hmac output as a private key 151 | 8. Discard the hmac output 152 | 9. Ask the user which _group_ they belong to 153 | 10. Encode the appropriate recipient and identity as specified above 154 | 155 | Note: The hmac secret MUST NOT be included in the identity (let alone recipient). 156 | 157 | ### File Key Wrapping 158 | 159 | The plugin MUST only use plugin recipients for wrapping. 160 | 161 | For _group 2_, encryption happens without any plugin interactions since the recipient is a native age recipient. 162 | 163 | For _group 1_, the encryption MUST use the age API to encrypt the file key to the X25519 public key in the plugin recipient. The PIN flag, salt and credential ID are copied into the stanza to enable future unwrapping. 164 | 165 | ### File Key Unwrapping 166 | 167 | The plugin MUST try both plugin and native X25519 stanzas for unwrapping. It MUST only accept plugin identities. 168 | 169 | For both _groups_, the hmac secret MUST be obtained by challenging the authenticator using the correct PIN flag, salt and credential ID (either obtained from the stanza or identity). 170 | 171 | The hmac output is interpreted as a native age identity of type X25519. The stanza body MUST be unwrapped using the native age API. 172 | 173 | # References 174 | 175 | [1] https://github.com/str4d/age-plugin-yubikey \ 176 | [2] https://fidoalliance.org/specs/fido-v2.1-rd-20210309/fido-client-to-authenticator-protocol-v2.1-rd-20210309.html#sctn-hmac-secret-extension \ 177 | [3] https://www.freedesktop.org/software/systemd/man/systemd-cryptenroll.html \ 178 | [4] https://github.com/riastradh/age-plugin-fido \ 179 | -------------------------------------------------------------------------------- /e2e/test_device.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olastor/age-plugin-fido2-hmac/67672e3f3b3bcaa03c3ad02e240e926540d59ea2/e2e/test_device.bin -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/olastor/age-plugin-fido2-hmac 2 | 3 | go 1.22 4 | 5 | toolchain go1.22.1 6 | 7 | require filippo.io/age v1.2.1-0.20240621125216-8734a853bcdf 8 | 9 | require ( 10 | github.com/keys-pub/go-libfido2 v1.5.4-0.20250104233141-2534349bd685 11 | golang.org/x/crypto v0.31.0 12 | golang.org/x/sys v0.28.0 13 | golang.org/x/term v0.27.0 14 | ) 15 | 16 | require ( 17 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 18 | github.com/pkg/errors v0.9.1 // indirect 19 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 20 | github.com/stretchr/testify v1.8.4 // indirect 21 | ) 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= 2 | c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= 3 | filippo.io/age v1.2.1-0.20240621125216-8734a853bcdf h1:h5r1/hjjyn9Abz1g1pvVGE20Gqgh0rvUFdS6sWHceFY= 4 | filippo.io/age v1.2.1-0.20240621125216-8734a853bcdf/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 7 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/keys-pub/go-libfido2 v1.5.4-0.20250104233141-2534349bd685 h1:zSJ+NjvdW6SKXv9+EGfbaXYveyamZKw2SE2uJdURCMQ= 9 | github.com/keys-pub/go-libfido2 v1.5.4-0.20250104233141-2534349bd685/go.mod h1:92J9LtSBl0UyUWljElJpTbMMNhC6VeY8dshsu40qjjo= 10 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 11 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 12 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 13 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 14 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 15 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 16 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 17 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 18 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 19 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 20 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 21 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 22 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 23 | golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= 24 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 25 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 26 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 27 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 28 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 29 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 30 | -------------------------------------------------------------------------------- /internal/bech32/bech32.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017 Takatoshi Nakagawa 2 | // Copyright (c) 2019 The age Authors 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in 12 | // all copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | // THE SOFTWARE. 21 | 22 | // Package bech32 is a modified version of the reference implementation of BIP173. 23 | package bech32 24 | 25 | import ( 26 | "fmt" 27 | "strings" 28 | ) 29 | 30 | var charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" 31 | 32 | var generator = []uint32{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3} 33 | 34 | func polymod(values []byte) uint32 { 35 | chk := uint32(1) 36 | for _, v := range values { 37 | top := chk >> 25 38 | chk = (chk & 0x1ffffff) << 5 39 | chk = chk ^ uint32(v) 40 | for i := 0; i < 5; i++ { 41 | bit := top >> i & 1 42 | if bit == 1 { 43 | chk ^= generator[i] 44 | } 45 | } 46 | } 47 | return chk 48 | } 49 | 50 | func hrpExpand(hrp string) []byte { 51 | h := []byte(strings.ToLower(hrp)) 52 | var ret []byte 53 | for _, c := range h { 54 | ret = append(ret, c>>5) 55 | } 56 | ret = append(ret, 0) 57 | for _, c := range h { 58 | ret = append(ret, c&31) 59 | } 60 | return ret 61 | } 62 | 63 | func verifyChecksum(hrp string, data []byte) bool { 64 | return polymod(append(hrpExpand(hrp), data...)) == 1 65 | } 66 | 67 | func createChecksum(hrp string, data []byte) []byte { 68 | values := append(hrpExpand(hrp), data...) 69 | values = append(values, []byte{0, 0, 0, 0, 0, 0}...) 70 | mod := polymod(values) ^ 1 71 | ret := make([]byte, 6) 72 | for p := range ret { 73 | shift := 5 * (5 - p) 74 | ret[p] = byte(mod>>shift) & 31 75 | } 76 | return ret 77 | } 78 | 79 | func convertBits(data []byte, frombits, tobits byte, pad bool) ([]byte, error) { 80 | var ret []byte 81 | acc := uint32(0) 82 | bits := byte(0) 83 | maxv := byte(1<>frombits != 0 { 86 | return nil, fmt.Errorf("invalid data range: data[%d]=%d (frombits=%d)", idx, value, frombits) 87 | } 88 | acc = acc<= tobits { 91 | bits -= tobits 92 | ret = append(ret, byte(acc>>bits)&maxv) 93 | } 94 | } 95 | if pad { 96 | if bits > 0 { 97 | ret = append(ret, byte(acc<<(tobits-bits))&maxv) 98 | } 99 | } else if bits >= frombits { 100 | return nil, fmt.Errorf("illegal zero padding") 101 | } else if byte(acc<<(tobits-bits))&maxv != 0 { 102 | return nil, fmt.Errorf("non-zero padding") 103 | } 104 | return ret, nil 105 | } 106 | 107 | // Encode encodes the HRP and a bytes slice to Bech32. If the HRP is uppercase, 108 | // the output will be uppercase. 109 | func Encode(hrp string, data []byte) (string, error) { 110 | values, err := convertBits(data, 8, 5, true) 111 | if err != nil { 112 | return "", err 113 | } 114 | if len(hrp) < 1 { 115 | return "", fmt.Errorf("invalid HRP: %q", hrp) 116 | } 117 | for p, c := range hrp { 118 | if c < 33 || c > 126 { 119 | return "", fmt.Errorf("invalid HRP character: hrp[%d]=%d", p, c) 120 | } 121 | } 122 | if strings.ToUpper(hrp) != hrp && strings.ToLower(hrp) != hrp { 123 | return "", fmt.Errorf("mixed case HRP: %q", hrp) 124 | } 125 | lower := strings.ToLower(hrp) == hrp 126 | hrp = strings.ToLower(hrp) 127 | var ret strings.Builder 128 | ret.WriteString(hrp) 129 | ret.WriteString("1") 130 | for _, p := range values { 131 | ret.WriteByte(charset[p]) 132 | } 133 | for _, p := range createChecksum(hrp, values) { 134 | ret.WriteByte(charset[p]) 135 | } 136 | if lower { 137 | return ret.String(), nil 138 | } 139 | return strings.ToUpper(ret.String()), nil 140 | } 141 | 142 | // Decode decodes a Bech32 string. If the string is uppercase, the HRP will be uppercase. 143 | func Decode(s string) (hrp string, data []byte, err error) { 144 | if strings.ToLower(s) != s && strings.ToUpper(s) != s { 145 | return "", nil, fmt.Errorf("mixed case") 146 | } 147 | pos := strings.LastIndex(s, "1") 148 | if pos < 1 || pos+7 > len(s) { 149 | return "", nil, fmt.Errorf("separator '1' at invalid position: pos=%d, len=%d", pos, len(s)) 150 | } 151 | hrp = s[:pos] 152 | for p, c := range hrp { 153 | if c < 33 || c > 126 { 154 | return "", nil, fmt.Errorf("invalid character human-readable part: s[%d]=%d", p, c) 155 | } 156 | } 157 | s = strings.ToLower(s) 158 | for p, c := range s[pos+1:] { 159 | d := strings.IndexRune(charset, c) 160 | if d == -1 { 161 | return "", nil, fmt.Errorf("invalid character data part: s[%d]=%v", p, c) 162 | } 163 | data = append(data, byte(d)) 164 | } 165 | if !verifyChecksum(hrp, data) { 166 | return "", nil, fmt.Errorf("invalid checksum") 167 | } 168 | data, err = convertBits(data[:len(data)-6], 5, 8, false) 169 | if err != nil { 170 | return "", nil, err 171 | } 172 | return hrp, data, nil 173 | } 174 | -------------------------------------------------------------------------------- /internal/mlock/main.go: -------------------------------------------------------------------------------- 1 | package mlock 2 | 3 | func Mlock(b []byte) error { 4 | return mlock(b) 5 | } 6 | -------------------------------------------------------------------------------- /internal/mlock/missing.go: -------------------------------------------------------------------------------- 1 | //go:build android || darwin || nacl || netbsd || plan9 || windows 2 | 3 | package mlock 4 | 5 | func mlock(b []byte) error { 6 | return nil 7 | } 8 | -------------------------------------------------------------------------------- /internal/mlock/unix.go: -------------------------------------------------------------------------------- 1 | //go:build dragonfly || freebsd || linux || openbsd || solaris 2 | 3 | package mlock 4 | 5 | import ( 6 | "golang.org/x/sys/unix" 7 | ) 8 | 9 | func mlock (b []byte) error { 10 | return unix.Mlock(b) 11 | } 12 | -------------------------------------------------------------------------------- /pkg/plugin/cli.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "bufio" 5 | "crypto/rand" 6 | "filippo.io/age" 7 | "fmt" 8 | "github.com/keys-pub/go-libfido2" 9 | "golang.org/x/term" 10 | "os" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | func promptYesNo(s string) (bool, error) { 16 | reader := bufio.NewReader(os.Stdin) 17 | 18 | fmt.Fprintf(os.Stderr, "%s [y/n]: ", s) 19 | 20 | response, err := reader.ReadString('\n') 21 | if err != nil { 22 | return false, err 23 | } 24 | 25 | return strings.HasPrefix(strings.ToLower(strings.TrimSpace(response)), "y"), nil 26 | } 27 | 28 | func NewCredentials( 29 | algorithm libfido2.CredentialType, 30 | symmetric bool, 31 | displayMessage func(message string) error, 32 | requestValue func(prompt string, secret bool) (string, error), 33 | confirm func(prompt, yes, no string) (choseYes bool, err error), 34 | ) (string, string, error) { 35 | var device *libfido2.Device 36 | 37 | displayMessage("Please insert your token now...") 38 | 39 | device, err := FindDevice(50*time.Second, displayMessage) 40 | if err != nil { 41 | return "", "", err 42 | } 43 | 44 | hasPinSet, err := HasPinSet(device) 45 | if err != nil { 46 | return "", "", err 47 | } 48 | 49 | pin := "" 50 | if hasPinSet { 51 | pin, err = requestValue("Please enter your PIN: ", true) 52 | if err != nil { 53 | return "", "", err 54 | } 55 | } 56 | 57 | displayMessage("Please touch your token...") 58 | credId, err := generateNewCredential(device, pin, algorithm) 59 | if err != nil { 60 | return "", "", err 61 | } 62 | 63 | requirePin := false 64 | if hasPinSet { 65 | requirePin, err = confirm("Do you want to require a PIN for decryption?", "yes", "no") 66 | if err != nil { 67 | return "", "", err 68 | } 69 | } 70 | 71 | if !requirePin { 72 | pin = "" 73 | } 74 | 75 | var identity *Fido2HmacIdentity 76 | var recipient *Fido2HmacRecipient 77 | var x25519Recipient *age.X25519Recipient 78 | 79 | if symmetric { 80 | identity = &Fido2HmacIdentity{ 81 | Version: 1, 82 | RequirePin: requirePin, 83 | Salt: nil, 84 | CredId: credId, 85 | Device: device, 86 | } 87 | recipient, err = identity.Recipient() 88 | if err != nil { 89 | return "", "", err 90 | } 91 | } else { 92 | salt := make([]byte, 32) 93 | if _, err := rand.Read(salt); err != nil { 94 | return "", "", err 95 | } 96 | 97 | identity = &Fido2HmacIdentity{ 98 | Version: 2, 99 | RequirePin: requirePin, 100 | Salt: salt, 101 | CredId: credId, 102 | Device: device, 103 | } 104 | 105 | _, err = identity.obtainSecretFromToken(pin) 106 | if err != nil { 107 | return "", "", err 108 | } 109 | 110 | recipient, err = identity.Recipient() 111 | identity.ClearSecret() 112 | if err != nil { 113 | return "", "", err 114 | } 115 | 116 | x25519Recipient, err = recipient.X25519Recipient() 117 | if err != nil { 118 | return "", "", err 119 | } 120 | } 121 | 122 | wantsSeparateIdentity, err := confirm( 123 | "Are you fine with having a separate identity (better privacy)?", 124 | "yes", 125 | "no", 126 | ) 127 | 128 | if wantsSeparateIdentity { 129 | if recipient.Version == 1 { 130 | return recipient.String(), identity.String(), nil 131 | } else { 132 | return x25519Recipient.String(), identity.String(), nil 133 | } 134 | } else { 135 | return recipient.String(), "", nil 136 | } 137 | } 138 | 139 | func NewCredentialsCli( 140 | algorithm libfido2.CredentialType, 141 | symmetric bool, 142 | ) (string, string, error) { 143 | displayMessage := func(message string) error { 144 | fmt.Fprintf(os.Stderr, "[*] %s\n", message) 145 | return nil 146 | } 147 | requestValue := func(message string, _ bool) (s string, err error) { 148 | fmt.Fprintf(os.Stderr, message) 149 | secretBytes, err := term.ReadPassword(int(os.Stdin.Fd())) 150 | if err != nil { 151 | return "", err 152 | } 153 | 154 | return string(secretBytes), nil 155 | } 156 | confirm := func(message, yes, no string) (choseYes bool, err error) { 157 | answerYes, err := promptYesNo(fmt.Sprintf("[*] %s", message)) 158 | if err != nil { 159 | return false, err 160 | } 161 | 162 | return answerYes, nil 163 | } 164 | 165 | return NewCredentials( 166 | algorithm, 167 | symmetric, 168 | displayMessage, 169 | requestValue, 170 | confirm, 171 | ) 172 | } 173 | -------------------------------------------------------------------------------- /pkg/plugin/fido2_utils.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "slices" 8 | "time" 9 | 10 | "github.com/keys-pub/go-libfido2" 11 | ) 12 | 13 | func listEligibleDevices() ([]*libfido2.Device, error) { 14 | locs, err := libfido2.DeviceLocations() 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | devs := []*libfido2.Device{} 20 | for _, loc := range locs { 21 | dev, _ := libfido2.NewDevice(loc.Path) 22 | 23 | isFido, err := dev.IsFIDO2() 24 | if err != nil || !isFido { 25 | continue 26 | } 27 | 28 | info, err := dev.Info() 29 | if err != nil { 30 | continue 31 | } 32 | 33 | if !slices.Contains(info.Extensions, string(libfido2.HMACSecretExtension)) { 34 | continue 35 | } 36 | 37 | devs = append(devs, dev) 38 | } 39 | 40 | return devs, nil 41 | } 42 | 43 | func FindDevice( 44 | timeout time.Duration, 45 | displayMessage func(message string) error, 46 | ) (*libfido2.Device, error) { 47 | devicePathFromEnv := os.Getenv("FIDO2_TOKEN") 48 | if devicePathFromEnv != "" { 49 | displayMessage(fmt.Sprintf("Using device path from env: %s", devicePathFromEnv)) 50 | return libfido2.NewDevice(devicePathFromEnv) 51 | } 52 | 53 | start := time.Now() 54 | 55 | for { 56 | if time.Since(start) >= timeout { 57 | break 58 | } 59 | 60 | devs, err := listEligibleDevices() 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | if len(devs) == 1 { 66 | return devs[0], nil 67 | } else if len(devs) > 1 { 68 | msg := fmt.Sprintf("Found %d devices. Please touch the one you want to use.", len(devs)) 69 | displayMessage(msg) 70 | return libfido2.SelectDevice(devs, 10*time.Second) 71 | } 72 | 73 | time.Sleep(200 * time.Millisecond) 74 | } 75 | 76 | return nil, errors.New("Timed out waiting for device.") 77 | } 78 | 79 | func HasPinSet(device *libfido2.Device) (bool, error) { 80 | info, err := device.Info() 81 | if err != nil { 82 | return false, err 83 | } 84 | 85 | for _, option := range info.Options { 86 | if option.Name != "clientPin" { 87 | continue 88 | } 89 | 90 | return option.Value == "true", nil 91 | } 92 | 93 | return false, nil 94 | } 95 | 96 | func generateNewCredential( 97 | device *libfido2.Device, 98 | pin string, 99 | algorithm libfido2.CredentialType, 100 | ) (credId []byte, error error) { 101 | cdh := libfido2.RandBytes(32) 102 | userId := libfido2.RandBytes(32) 103 | userName := b64.EncodeToString(libfido2.RandBytes(6)) 104 | 105 | attest, err := device.MakeCredential( 106 | cdh, 107 | libfido2.RelyingParty{ 108 | ID: RELYING_PARTY, 109 | }, 110 | libfido2.User{ 111 | ID: userId, 112 | Name: userName, 113 | }, 114 | algorithm, 115 | pin, 116 | &libfido2.MakeCredentialOpts{ 117 | Extensions: []libfido2.Extension{libfido2.HMACSecretExtension}, 118 | RK: "false", 119 | }, 120 | ) 121 | 122 | if err != nil { 123 | return nil, err 124 | } 125 | 126 | return attest.CredentialID, nil 127 | } 128 | 129 | func getHmacSecret(device *libfido2.Device, credId []byte, salt []byte, pin string) ([]byte, error) { 130 | if len(salt) != 32 { 131 | return nil, errors.New("Salt must be 32 bytes!") 132 | } 133 | 134 | cdh := libfido2.RandBytes(32) 135 | 136 | assertion, err := device.Assertion( 137 | RELYING_PARTY, 138 | cdh, 139 | [][]byte{credId}, 140 | pin, 141 | &libfido2.AssertionOpts{ 142 | Extensions: []libfido2.Extension{libfido2.HMACSecretExtension}, 143 | HMACSalt: salt, 144 | }, 145 | ) 146 | 147 | if err != nil { 148 | return nil, err 149 | } 150 | 151 | if assertion.HMACSecret == nil || len(assertion.HMACSecret) != 32 { 152 | return nil, errors.New("invalid hmac secret") 153 | } 154 | 155 | return assertion.HMACSecret, nil 156 | } 157 | -------------------------------------------------------------------------------- /pkg/plugin/fido2_utils_test.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "fmt" 5 | "github.com/keys-pub/go-libfido2" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | func TestEnforcePin(t *testing.T) { 11 | locs, err := libfido2.DeviceLocations() 12 | if err != nil { 13 | t.Error(err) 14 | } 15 | 16 | var dev *libfido2.Device 17 | if len(locs) != 1 || locs[0].Product != "Nitrokey 3" { 18 | fmt.Printf("Testing with virtual test device not possible\n") 19 | return 20 | } 21 | 22 | dev, _ = libfido2.NewDevice(locs[0].Path) 23 | info, _ := dev.Info() 24 | aaguid := fmt.Sprintf("%s", info.AAGUID) 25 | if info == nil || aaguid != "AAGUID0123456789" { 26 | fmt.Printf("Testing with virtual test device not possible.\n") 27 | return 28 | } 29 | 30 | // these were generated with the simulated device and the ifs=e2e/test_device.bin 31 | // recStr := "age14wj87kklx0nm6ek9sze0n4un2xrctweh37gw2hqxyl0h5asf633qtpymdv" 32 | idStr := "AGE-PLUGIN-FIDO2-HMAC-1QQPQRUMQXAUCGHS3STVLMHH5NGF0G026RXKQTY7EG7ZH4N2PM7UEX9875VQ9S7MU804DNHMK3L9CRUSKN3WRCNH4NHG90DZST8CZU5JVZJAPS83PVADFHLWDSATKX06H57EP2QZ2PGFJ8MU4VF2E0LDZYV0478T4SX4W7G7Y52JD3KPUY0AMYN66G52CX0XXE3GHZJU5WJ8F4WK5X9JD68FU3TA9FDZ900K7L3ADSRJWA284XADJ3CP4YGEE8N970J5SZNX9LHC8MZVSK8ANXDUJ8UP9PWPE8P4A45AWV4F0JLEZMDH9VRGRDDKRL" 33 | 34 | i, err := ParseFido2HmacIdentity(idStr) 35 | if err != nil { 36 | t.Error(err) 37 | } 38 | 39 | secretPin, err := getHmacSecret(dev, i.CredId, i.Salt, "1234") 40 | if err != nil { 41 | t.Error(err) 42 | } 43 | 44 | secretNoPin, err := getHmacSecret(dev, i.CredId, i.Salt, "") 45 | if err != nil { 46 | t.Error(err) 47 | } 48 | 49 | if reflect.DeepEqual(secretNoPin, secretPin) { 50 | t.Error("Secrets with or without PIN must not be the same") 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /pkg/plugin/identity.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/binary" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "slices" 10 | "sort" 11 | "strings" 12 | 13 | "filippo.io/age" 14 | "github.com/keys-pub/go-libfido2" 15 | "github.com/olastor/age-plugin-fido2-hmac/internal/bech32" 16 | "github.com/olastor/age-plugin-fido2-hmac/internal/mlock" 17 | "golang.org/x/crypto/chacha20poly1305" 18 | "golang.org/x/term" 19 | ) 20 | 21 | func (i *Fido2HmacIdentity) X25519Identity() (*age.X25519Identity, error) { 22 | identityStr, _ := bech32.Encode("AGE-SECRET-KEY-", i.secretKey) 23 | return age.ParseX25519Identity(strings.ToUpper(identityStr)) 24 | } 25 | 26 | func (i *Fido2HmacIdentity) Recipient() (*Fido2HmacRecipient, error) { 27 | switch i.Version { 28 | case 1: 29 | return &Fido2HmacRecipient{ 30 | Version: 1, 31 | RequirePin: i.RequirePin, 32 | CredId: i.CredId, 33 | }, nil 34 | case 2: 35 | x25519Identity, err := i.X25519Identity() 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | _, theirPublicKey, err := bech32.Decode(x25519Identity.Recipient().String()) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | return &Fido2HmacRecipient{ 46 | Version: 2, 47 | TheirPublicKey: theirPublicKey, 48 | RequirePin: i.RequirePin, 49 | Salt: i.Salt, 50 | CredId: i.CredId, 51 | }, nil 52 | default: 53 | return nil, fmt.Errorf("unsupported identity version %x", i.Version) 54 | } 55 | } 56 | 57 | // the pin can be passed if it's known already to avoid re-asking, but it's optional 58 | func (i *Fido2HmacIdentity) obtainSecretFromToken(pin string) (string, error) { 59 | if i.Device == nil { 60 | return pin, fmt.Errorf("device not specified, cannot obtain secret.") 61 | } 62 | 63 | if i.RequirePin && pin == "" { 64 | msg := "Please enter your PIN:" 65 | if i.Plugin != nil { 66 | var err error 67 | pin, err = i.RequestSecret(msg) 68 | if err != nil { 69 | return pin, err 70 | } 71 | } else { 72 | fmt.Fprintf(os.Stderr, "[*] %s\n", msg) 73 | pinBytes, err := term.ReadPassword(int(os.Stdin.Fd())) 74 | if err != nil { 75 | return pin, err 76 | } 77 | 78 | pin = string(pinBytes) 79 | } 80 | } 81 | 82 | err := i.DisplayMessage("Please touch your token") 83 | if err != nil { 84 | return pin, err 85 | } 86 | 87 | if i.RequirePin { 88 | i.secretKey, err = getHmacSecret(i.Device, i.CredId, i.Salt, pin) 89 | } else { 90 | i.secretKey, err = getHmacSecret(i.Device, i.CredId, i.Salt, "") 91 | } 92 | 93 | if err != nil { 94 | return pin, err 95 | } 96 | 97 | err = mlock.Mlock(i.secretKey) 98 | if err != nil { 99 | err = i.DisplayMessage(fmt.Sprintf("Warning: Failed to call mlock: %s", err)) 100 | if err != nil { 101 | return pin, err 102 | } 103 | } 104 | 105 | return pin, nil 106 | } 107 | 108 | func (i *Fido2HmacIdentity) Wrap(fileKey []byte) ([]*age.Stanza, error) { 109 | switch i.Version { 110 | case 1: 111 | i.Nonce = make([]byte, 12) 112 | if _, err := rand.Read(i.Nonce); err != nil { 113 | return nil, err 114 | } 115 | 116 | if i.Nonce == nil || len(i.Nonce) != 12 { 117 | return nil, fmt.Errorf("incomplete identity, missing or invalid nonce for encryption") 118 | } 119 | 120 | i.Salt = make([]byte, 32) 121 | if _, err := rand.Read(i.Salt); err != nil { 122 | return nil, err 123 | } 124 | 125 | _, err := i.obtainSecretFromToken("") 126 | if err != nil { 127 | return nil, err 128 | } 129 | 130 | if i.secretKey == nil || len(i.secretKey) != 32 { 131 | return nil, fmt.Errorf("incomplete identity, missing or invalid secret key") 132 | } 133 | 134 | aead, err := chacha20poly1305.New(i.secretKey) 135 | if err != nil { 136 | return nil, err 137 | } 138 | 139 | ciphertext := aead.Seal(nil, i.Nonce, fileKey, nil) 140 | 141 | stanzaArgs := make([]string, 2) 142 | stanzaArgs[0] = b64.EncodeToString(i.Salt) 143 | stanzaArgs[1] = b64.EncodeToString(i.Nonce) 144 | 145 | stanza := &age.Stanza{ 146 | Type: PLUGIN_NAME, 147 | Args: stanzaArgs, 148 | Body: ciphertext, 149 | } 150 | 151 | return []*age.Stanza{stanza}, nil 152 | case 2: 153 | _, err := i.obtainSecretFromToken("") 154 | if err != nil { 155 | return nil, err 156 | } 157 | 158 | if i.secretKey == nil || len(i.secretKey) != 32 { 159 | return nil, fmt.Errorf("incomplete identity, missing or invalid secret key") 160 | } 161 | 162 | recipient, err := i.Recipient() 163 | if err != nil { 164 | return nil, err 165 | } 166 | 167 | x25519Recipient, err := recipient.X25519Recipient() 168 | if err != nil { 169 | return nil, err 170 | } 171 | 172 | // encrypting with an identity means we can use an X25519 stanza 173 | return x25519Recipient.Wrap(fileKey) 174 | default: 175 | return nil, fmt.Errorf("unsupported recipient version %x", i.Version) 176 | } 177 | } 178 | 179 | func (i *Fido2HmacIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) { 180 | if len(stanzas) == 0 { 181 | return nil, fmt.Errorf("list of stanzas is empty") 182 | } 183 | 184 | var pluginStanzas []*Fido2HmacStanza 185 | var x25519Stanzas []*age.Stanza 186 | 187 | for _, stanza := range stanzas { 188 | if stanza.Type == PLUGIN_NAME { 189 | stanzaData, err := ParseFido2HmacStanza(stanza) 190 | if err != nil { 191 | return nil, err 192 | } 193 | 194 | pluginStanzas = append(pluginStanzas, stanzaData) 195 | } else if stanza.Type == "X25519" && i.Version == 2 { 196 | x25519Stanzas = append(x25519Stanzas, stanza) 197 | } 198 | } 199 | 200 | if len(pluginStanzas)+len(x25519Stanzas) == 0 { 201 | // there were stanzas provided, but non of them are compatible 202 | // with the plugin or the identity version 203 | return nil, age.ErrIncorrectIdentity 204 | } 205 | 206 | // this mixes up the indexes, so don't use them for errors 207 | sort.SliceStable(pluginStanzas, func(k, l int) bool { 208 | // make sure to first try the identities without pin 209 | return !pluginStanzas[k].RequirePin 210 | }) 211 | 212 | // only ask once for the pin if needed and store it here temporarily thereafter 213 | pin := "" 214 | 215 | var err error 216 | 217 | // if the version is two and there is a cred id we expect to unwrap x25519 stanzas 218 | if i.Version == 2 && i.CredId != nil && len(x25519Stanzas) > 0 { 219 | if i.secretKey == nil { 220 | pin, err = i.obtainSecretFromToken(pin) 221 | if err != nil { 222 | if errors.Is(err, libfido2.ErrNoCredentials) { 223 | // since the cred ID is the same for all stanzas and it does not match the token, 224 | // we can tell the controller to try the next identity 225 | return nil, age.ErrIncorrectIdentity 226 | } 227 | 228 | return nil, err 229 | } 230 | } 231 | 232 | x25519Identity, err := i.X25519Identity() 233 | if err != nil { 234 | return nil, err 235 | } 236 | 237 | fileKey, err := x25519Identity.Unwrap(x25519Stanzas) 238 | i.ClearSecret() 239 | 240 | if err == nil { 241 | // do not return an error here because it might be that there is 242 | // still another plugin stanza that does not match the identity, 243 | // but can decrypt with the fido2 token "standalone" 244 | return fileKey, nil 245 | } 246 | } 247 | 248 | for _, fidoStanza := range pluginStanzas { 249 | if fidoStanza.CredId == nil && (i.CredId == nil || fidoStanza.Version != i.Version) { 250 | // incompatible: cred id needs to exists in either stanza or identity 251 | // if the stanza contains the cred id, then we can basically ignore the identity 252 | // because all relevant data is in the stanza. but if the stanza does not include 253 | // the cred id, then the identity needs to hold it and must have a matching version 254 | continue 255 | } 256 | 257 | // some fields need to be copied over from the stanza 258 | // create a temporary identity with this fields to preserve 259 | // the original field values of i 260 | id := *i 261 | id.Salt = fidoStanza.Salt 262 | id.Nonce = fidoStanza.Nonce 263 | if i.CredId == nil { 264 | id.Version = fidoStanza.Version 265 | id.CredId = fidoStanza.CredId 266 | id.RequirePin = fidoStanza.RequirePin 267 | } 268 | 269 | if !(i.Version == 2 && i.secretKey != nil && slices.Equal(i.CredId, id.CredId)) { 270 | if (i.RequirePin || fidoStanza.RequirePin) && pin == "" { 271 | pin, err = i.RequestSecret("Please enter you PIN:") 272 | if err != nil { 273 | return nil, err 274 | } 275 | } 276 | 277 | // needs to be called for every stanza because at least the salt changed 278 | pin, err = id.obtainSecretFromToken(pin) 279 | if err != nil { 280 | if errors.Is(err, libfido2.ErrNoCredentials) { 281 | // just because this one stanza didn't match the token doesn't mean 282 | // any of the other stanzas left don't match. do not error here early! 283 | continue 284 | } 285 | 286 | return nil, err 287 | } 288 | } 289 | 290 | switch id.Version { 291 | case 1: 292 | aead, err := chacha20poly1305.New(id.secretKey) 293 | if err != nil { 294 | return nil, err 295 | } 296 | 297 | plaintext, err := aead.Open(nil, id.Nonce, fidoStanza.Body, nil) 298 | mlock.Mlock(plaintext) 299 | 300 | if err != nil { 301 | continue 302 | } 303 | 304 | return plaintext, nil 305 | case 2: 306 | x25519Identity, err := id.X25519Identity() 307 | if err != nil { 308 | return nil, err 309 | } 310 | 311 | plaintext, err := x25519Identity.Unwrap([]*age.Stanza{&age.Stanza{ 312 | Type: "X25519", 313 | Args: []string{string(fidoStanza.X25519Share)}, 314 | Body: fidoStanza.Body, 315 | }}) 316 | 317 | if err != nil { 318 | // TODO: differentiate error handling? 319 | return nil, err 320 | } 321 | 322 | return plaintext, nil 323 | default: 324 | return nil, fmt.Errorf("unsupported identity version %x", i.Version) 325 | } 326 | } 327 | 328 | return nil, age.ErrIncorrectIdentity 329 | } 330 | 331 | func (i *Fido2HmacIdentity) ClearSecret() { 332 | if i.secretKey != nil { 333 | for j := 0; j < cap(i.secretKey); j++ { 334 | i.secretKey[j] = 0 335 | } 336 | } 337 | 338 | i.secretKey = nil 339 | } 340 | 341 | func (i *Fido2HmacIdentity) DisplayMessage(msg string) error { 342 | if i.Plugin != nil { 343 | err := i.Plugin.DisplayMessage(msg) 344 | if err != nil { 345 | return err 346 | } 347 | } else { 348 | fmt.Fprintf(os.Stderr, "[*] %s\n", msg) 349 | } 350 | 351 | return nil 352 | } 353 | 354 | func (i *Fido2HmacIdentity) RequestSecret(msg string) (result string, err error) { 355 | if i.Plugin != nil { 356 | var err error 357 | result, err = i.Plugin.RequestValue(msg, true) 358 | if err != nil { 359 | return "", err 360 | } 361 | } else { 362 | fmt.Fprintf(os.Stderr, "[*] %s\n", msg) 363 | resultBytes, err := term.ReadPassword(int(os.Stdin.Fd())) 364 | if err != nil { 365 | return "", err 366 | } 367 | 368 | result = string(resultBytes) 369 | } 370 | 371 | return 372 | } 373 | 374 | func (i *Fido2HmacIdentity) String() string { 375 | requirePinByte := byte(0) 376 | if i.RequirePin { 377 | requirePinByte = byte(1) 378 | } 379 | 380 | version := make([]byte, 2) 381 | binary.BigEndian.PutUint16(version, i.Version) 382 | 383 | switch i.Version { 384 | case 1: 385 | data := slices.Concat( 386 | version, 387 | []byte{requirePinByte}, 388 | i.CredId, 389 | ) 390 | 391 | s, _ := bech32.Encode(IDENTITY_HRP, data) 392 | 393 | return strings.ToUpper(s) 394 | case 2: 395 | data := slices.Concat( 396 | version, 397 | []byte{requirePinByte}, 398 | i.Salt, 399 | i.CredId, 400 | ) 401 | 402 | s, _ := bech32.Encode(IDENTITY_HRP, data) 403 | 404 | return strings.ToUpper(s) 405 | default: 406 | return "" 407 | } 408 | } 409 | -------------------------------------------------------------------------------- /pkg/plugin/plugin.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/binary" 6 | "fmt" 7 | "strings" 8 | 9 | "filippo.io/age" 10 | page "filippo.io/age/plugin" 11 | "github.com/keys-pub/go-libfido2" 12 | "github.com/olastor/age-plugin-fido2-hmac/internal/bech32" 13 | ) 14 | 15 | var b64 = base64.RawStdEncoding.Strict() 16 | 17 | const ( 18 | PLUGIN_NAME = "fido2-hmac" 19 | RECIPIENT_HRP = "age1" + PLUGIN_NAME 20 | IDENTITY_HRP = "age-plugin-" + PLUGIN_NAME + "-" 21 | RELYING_PARTY = "age-encryption.org" 22 | STANZA_FORMAT_VERSION uint16 = 2 23 | ) 24 | 25 | type Fido2HmacRecipient struct { 26 | Version uint16 27 | TheirPublicKey []byte 28 | RequirePin bool 29 | Salt []byte 30 | CredId []byte 31 | Plugin *page.Plugin 32 | 33 | // only when the version is 1, the device must be set 34 | Device *libfido2.Device 35 | } 36 | 37 | type Fido2HmacIdentity struct { 38 | Version uint16 39 | secretKey []byte 40 | RequirePin bool 41 | Salt []byte 42 | CredId []byte 43 | Plugin *page.Plugin 44 | Nonce []byte 45 | Device *libfido2.Device 46 | } 47 | 48 | // data structure for stanza with parsed args 49 | type Fido2HmacStanza struct { 50 | Version uint16 51 | RequirePin bool 52 | Salt []byte 53 | CredId []byte 54 | X25519Share string 55 | Nonce []byte 56 | Body []byte 57 | } 58 | 59 | // Checks if an identity is a "data-less" identity. This method is backwards-compatible with older plugin versions that used a custom "magic" identity. 60 | func IsDatalessIdentity(identity string) bool { 61 | // the first one is the legacy special identity of this plugin 62 | // the second one is the identity passed from age when using -j 63 | return identity == "AGE-PLUGIN-FIDO2-HMAC-1VE5KGMEJ945X6CTRM2TF76" || identity == "AGE-PLUGIN-FIDO2-HMAC-188VDVA" 64 | } 65 | 66 | func ParseFido2HmacRecipient(recipient string) (*Fido2HmacRecipient, error) { 67 | hrp, data, err := bech32.Decode(recipient) 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | if hrp != RECIPIENT_HRP { 73 | return nil, fmt.Errorf("malformed recipient %s: invalid type %s", recipient, hrp) 74 | } 75 | 76 | format_version := binary.BigEndian.Uint16(data[0:2]) 77 | 78 | switch format_version { 79 | case 1: 80 | return &Fido2HmacRecipient{ 81 | Version: 1, 82 | TheirPublicKey: nil, 83 | RequirePin: data[2] == byte(1), 84 | Salt: nil, 85 | CredId: data[3:], 86 | }, nil 87 | case 2: 88 | return &Fido2HmacRecipient{ 89 | Version: 2, 90 | TheirPublicKey: data[2:34], 91 | RequirePin: data[34] == byte(1), 92 | Salt: data[35:67], 93 | CredId: data[67:], 94 | }, nil 95 | default: 96 | return nil, fmt.Errorf("unsupported recipient version %x", format_version) 97 | } 98 | } 99 | 100 | func ParseFido2HmacIdentity(identity string) (*Fido2HmacIdentity, error) { 101 | if IsDatalessIdentity(identity) { 102 | return &Fido2HmacIdentity{ 103 | Version: 2, 104 | }, nil 105 | } 106 | 107 | hrp, data, err := bech32.Decode(strings.ToLower(identity)) 108 | if err != nil { 109 | return nil, err 110 | } 111 | 112 | if hrp != IDENTITY_HRP { 113 | return nil, fmt.Errorf("malformed identity %s: invalid type %s", identity, hrp) 114 | } 115 | 116 | format_version := binary.BigEndian.Uint16(data[0:2]) 117 | 118 | switch format_version { 119 | case 1: 120 | return &Fido2HmacIdentity{ 121 | Version: 1, 122 | secretKey: nil, 123 | RequirePin: data[2] == byte(1), 124 | Salt: nil, 125 | CredId: data[3:], 126 | }, nil 127 | case 2: 128 | return &Fido2HmacIdentity{ 129 | Version: 2, 130 | secretKey: nil, 131 | RequirePin: data[2] == byte(1), 132 | Salt: data[3:35], 133 | CredId: data[35:], 134 | }, nil 135 | default: 136 | return nil, fmt.Errorf("unsupported identity version %x", format_version) 137 | } 138 | } 139 | 140 | func ParseFido2HmacStanza(stanza *age.Stanza) (*Fido2HmacStanza, error) { 141 | stanzaData := &Fido2HmacStanza{Body: stanza.Body} 142 | 143 | stanzaVersionBytes, err := b64.DecodeString(stanza.Args[0]) 144 | if err != nil { 145 | return nil, err 146 | } 147 | 148 | // v1 format has no stanza version, so it's recognized by length of salt instead 149 | if len(stanzaVersionBytes) == 32 { 150 | stanzaData.Version = 1 151 | } else { 152 | stanzaData.Version = binary.BigEndian.Uint16(stanzaVersionBytes) 153 | } 154 | 155 | switch stanzaData.Version { 156 | case 1: 157 | if len(stanza.Args) != 2 && len(stanza.Args) != 4 { 158 | return nil, fmt.Errorf("invalid length of stanza args: %d", len(stanza.Args)) 159 | } 160 | 161 | stanzaData.Salt, err = b64.DecodeString(stanza.Args[0]) 162 | if err != nil { 163 | return nil, fmt.Errorf("salt in stanza is malformed") 164 | } 165 | 166 | stanzaData.Nonce, err = b64.DecodeString(stanza.Args[1]) 167 | if err != nil { 168 | return nil, fmt.Errorf("nonce in stanza is malformed") 169 | } 170 | 171 | if len(stanza.Args) == 4 { 172 | stanzaData.RequirePin = stanza.Args[2] == "AQ" 173 | stanzaData.CredId, err = b64.DecodeString(stanza.Args[3]) 174 | if err != nil { 175 | return nil, fmt.Errorf("cred id in stanza is malformed") 176 | } 177 | } 178 | case 2: 179 | stanzaData.X25519Share = stanza.Args[1] 180 | 181 | requirePin, err := b64.DecodeString(stanza.Args[2]) 182 | if err != nil { 183 | return nil, fmt.Errorf("require pin flag in stanza is malformed") 184 | } 185 | stanzaData.RequirePin = requirePin[0] == byte(1) 186 | 187 | stanzaData.Salt, err = b64.DecodeString(stanza.Args[3]) 188 | if err != nil { 189 | return nil, fmt.Errorf("salt in stanza is malformed") 190 | } 191 | 192 | stanzaData.CredId, err = b64.DecodeString(stanza.Args[4]) 193 | if err != nil { 194 | return nil, fmt.Errorf("cred id in stanza is malformed") 195 | } 196 | 197 | default: 198 | return nil, fmt.Errorf("unsupported stanza version %d", stanzaData.Version) 199 | } 200 | 201 | return stanzaData, nil 202 | } 203 | -------------------------------------------------------------------------------- /pkg/plugin/plugin_test.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "crypto/rand" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestRecipientFormat(t *testing.T) { 10 | for _, requirePin := range []bool{true, false} { 11 | theirPublicKey := make([]byte, 32) 12 | if _, err := rand.Read(theirPublicKey); err != nil { 13 | t.Error(err) 14 | } 15 | 16 | salt := make([]byte, 32) 17 | if _, err := rand.Read(salt); err != nil { 18 | t.Error(err) 19 | } 20 | 21 | credId := make([]byte, 50) 22 | if _, err := rand.Read(credId); err != nil { 23 | t.Error(err) 24 | } 25 | 26 | rec := &Fido2HmacRecipient{ 27 | Version: 2, 28 | TheirPublicKey: theirPublicKey, 29 | Salt: salt, 30 | CredId: credId, 31 | RequirePin: requirePin, 32 | } 33 | 34 | rec2, err := ParseFido2HmacRecipient(rec.String()) 35 | if err != nil { 36 | t.Error(err) 37 | } 38 | 39 | if !reflect.DeepEqual(rec.TheirPublicKey, rec2.TheirPublicKey) { 40 | t.Error("Public key changed") 41 | } 42 | if !reflect.DeepEqual(rec.Salt, rec2.Salt) { 43 | t.Error("Salt changed") 44 | } 45 | if !reflect.DeepEqual(rec.CredId, rec2.CredId) { 46 | t.Error("Cred ID changed") 47 | } 48 | if !reflect.DeepEqual(rec.RequirePin, rec2.RequirePin) { 49 | t.Error("RequirePIN changed") 50 | } 51 | if !reflect.DeepEqual(rec, rec2) { 52 | t.Error("Recipients have changed") 53 | } 54 | } 55 | } 56 | 57 | func TestIdentityFormat(t *testing.T) { 58 | for _, requirePin := range []bool{true, false} { 59 | secretKey := make([]byte, 32) 60 | if _, err := rand.Read(secretKey); err != nil { 61 | t.Error(err) 62 | } 63 | 64 | salt := make([]byte, 32) 65 | if _, err := rand.Read(salt); err != nil { 66 | t.Error(err) 67 | } 68 | 69 | credId := make([]byte, 50) 70 | if _, err := rand.Read(credId); err != nil { 71 | t.Error(err) 72 | } 73 | 74 | id := &Fido2HmacIdentity{ 75 | Version: 2, 76 | secretKey: secretKey, 77 | Salt: salt, 78 | CredId: credId, 79 | RequirePin: requirePin, 80 | } 81 | 82 | id2, err := ParseFido2HmacIdentity(id.String()) 83 | if err != nil { 84 | t.Error(err) 85 | } 86 | 87 | if id2.secretKey != nil { 88 | t.Error("Secret key not cleared") 89 | } 90 | if !reflect.DeepEqual(id.Salt, id2.Salt) { 91 | t.Error("Salt changed") 92 | } 93 | if !reflect.DeepEqual(id.CredId, id2.CredId) { 94 | t.Error("Cred ID changed") 95 | } 96 | if !reflect.DeepEqual(id.RequirePin, id2.RequirePin) { 97 | t.Error("RequirePIN changed") 98 | } 99 | 100 | id2.secretKey = secretKey 101 | if !reflect.DeepEqual(id, id2) { 102 | t.Error("Identities have changed") 103 | } 104 | } 105 | } 106 | 107 | func TestFormattingV1(t *testing.T) { 108 | testRecipients := []string{ 109 | "age1fido2-hmac1qqqsrgcqtpax86tmayc47ve7p300dpxj20uxrmwj0dc83z9qygw2gadfw395jr4fqjjc0r7vdn4qkaftxzjeemycun4ll9kp38x3czl4jk5jdqx969yjfeahhrqf8q9qgyf6s45d5avhser2uk8t6f5xy0e58qduv8m3hhk0w0zupfftexnzweth5fumd9mtmr7v6xlek8n6yespf3saz75wdmp2452v78nfxqjsfrmccw30j9aw7vezt5u7rkcu6uhr5n8a", 110 | "age1fido2-hmac1qqqspgcqtpa0kc9v37necfmsj2r22dnw2f8hlj9f4r3y70r57stv9r4tt2a3wjk7jzdzl0yguvhk98vafkuvq9ud2sl73jpdz3etefdhy5pa0yejjtw6h3jvlcrph5kfdzgxk5y97zhhqnq4jhjm8vyr65wfu0dp2gw8hfqaazqc3dsqtr3qktzmjf594u87v98z0aa3z8vwpwgpfjym66eznchm6gpr4kkpwqjszpeuudu2nlpve3t3q4mpeeacrymhu5th", 111 | } 112 | testIdentities := []string{ 113 | "AGE-PLUGIN-FIDO2-HMAC-1QQQSRGCQTPAYYLSEE84VECJJTC2E2DCSP4WY04SZ05Q98QURMU4Z79T6VFQ6R8T4PX97N6CARHPASL509TG83TWVMMYE6XT6UV0497G43DSA4RSXTTKQXWU0YMTNLH9CNX5C0V7SUED4UJSM35YJASRUXEZZHPKWUN4WT88JVCGLVNC6SUJWN43TZNQ9WL3R5TJPCVYKNX0EC2QPF38SSHMD3PQJ4U9260RHGQJSDJ7JPGZG6Q9MDV3RCQRPT2FUVYUP0YCK", 114 | "AGE-PLUGIN-FIDO2-HMAC-1QQQSPGCQTPAP3Y2TAG9EZHHZVE9AUM9XATHE68A43X26RSQ8D4XRY90YKE4GX5XDZZYP6XXJWEN39FWJPX6KSTYVX3L4VH3JAMGDQAK6PPKQFEK8RXXLRMFFZJ2FDHDCHR6QPCCMYDZ0TJWPUKZY4D0U2TPJ5A9WRDL64K33UC4YKFG6RD8757PPVSX3556FA3NJZSXZZ53N6HSPFJST4FSXDV3XZC287FPDUQJSC5YNR4V994G83T7USMKLR2DLUSCFHEA6", 115 | } 116 | 117 | for _, r := range testRecipients { 118 | parsed, err := ParseFido2HmacRecipient(r) 119 | if err != nil { 120 | t.Error(err) 121 | } 122 | 123 | if !reflect.DeepEqual(r, parsed.String()) { 124 | t.Error("Recipient string changed") 125 | } 126 | } 127 | 128 | for _, i := range testIdentities { 129 | parsed, err := ParseFido2HmacIdentity(i) 130 | if err != nil { 131 | t.Error(err) 132 | } 133 | 134 | if !reflect.DeepEqual(i, parsed.String()) { 135 | t.Error("Identity string changed") 136 | } 137 | } 138 | } 139 | 140 | func TestWrapping(t *testing.T) { 141 | for i := 0; i < 1; i++ { 142 | secretKey := make([]byte, 32) 143 | if _, err := rand.Read(secretKey); err != nil { 144 | t.Error(err) 145 | } 146 | 147 | salt := make([]byte, 32) 148 | if _, err := rand.Read(salt); err != nil { 149 | t.Error(err) 150 | } 151 | 152 | credId := make([]byte, 50) 153 | if _, err := rand.Read(credId); err != nil { 154 | t.Error(err) 155 | } 156 | 157 | requirePin := i%2 == 0 158 | 159 | id := &Fido2HmacIdentity{ 160 | Version: 2, 161 | secretKey: secretKey, 162 | Salt: salt, 163 | CredId: credId, 164 | RequirePin: requirePin, 165 | } 166 | 167 | rec, err := id.Recipient() 168 | if err != nil { 169 | t.Error(err) 170 | } 171 | 172 | fileKey := make([]byte, 16) 173 | if _, err := rand.Read(fileKey); err != nil { 174 | t.Error(err) 175 | } 176 | 177 | stanzas, err := rec.Wrap(fileKey) 178 | if err != nil { 179 | t.Error(err) 180 | } 181 | 182 | fileKey2, err := id.Unwrap(stanzas) 183 | if err != nil { 184 | t.Error(err) 185 | } 186 | 187 | if !reflect.DeepEqual(fileKey, fileKey2) { 188 | t.Error("File keys do not match") 189 | } 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /pkg/plugin/recipient.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "encoding/binary" 5 | "fmt" 6 | "slices" 7 | 8 | "filippo.io/age" 9 | "github.com/olastor/age-plugin-fido2-hmac/internal/bech32" 10 | ) 11 | 12 | func (r *Fido2HmacRecipient) X25519Recipient() (*age.X25519Recipient, error) { 13 | recipientStr, _ := bech32.Encode("age", r.TheirPublicKey) 14 | return age.ParseX25519Recipient(recipientStr) 15 | } 16 | 17 | func (r *Fido2HmacRecipient) String() string { 18 | requirePinByte := byte(0) 19 | if r.RequirePin { 20 | requirePinByte = byte(1) 21 | } 22 | 23 | version := make([]byte, 2) 24 | binary.BigEndian.PutUint16(version, r.Version) 25 | 26 | switch r.Version { 27 | case 1: 28 | data := slices.Concat( 29 | version, 30 | []byte{requirePinByte}, 31 | r.CredId, 32 | ) 33 | 34 | s, _ := bech32.Encode(RECIPIENT_HRP, data) 35 | return s 36 | case 2: 37 | data := slices.Concat( 38 | version, 39 | r.TheirPublicKey, 40 | []byte{requirePinByte}, 41 | r.Salt, 42 | r.CredId, 43 | ) 44 | 45 | s, _ := bech32.Encode(RECIPIENT_HRP, data) 46 | return s 47 | default: 48 | return "" 49 | } 50 | } 51 | 52 | func (r *Fido2HmacRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) { 53 | switch r.Version { 54 | case 1: 55 | identity := &Fido2HmacIdentity{ 56 | Version: 1, 57 | RequirePin: r.RequirePin, 58 | CredId: r.CredId, 59 | Plugin: r.Plugin, 60 | Device: r.Device, 61 | } 62 | 63 | stanzas, err := identity.Wrap(fileKey) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | requirePinByte := byte(0) 69 | if r.RequirePin { 70 | requirePinByte = byte(1) 71 | } 72 | 73 | stanzas[0].Args = append( 74 | stanzas[0].Args, 75 | b64.EncodeToString([]byte{requirePinByte}), 76 | b64.EncodeToString(identity.CredId), 77 | ) 78 | 79 | return stanzas, nil 80 | case 2: 81 | x25519Recipient, err := r.X25519Recipient() 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | x25519Stanzas, err := x25519Recipient.Wrap(fileKey) 87 | if err != nil { 88 | return nil, err 89 | } 90 | 91 | requirePinByte := byte(0) 92 | if r.RequirePin { 93 | requirePinByte = byte(1) 94 | } 95 | 96 | version := make([]byte, 2) 97 | binary.BigEndian.PutUint16(version, uint16(STANZA_FORMAT_VERSION)) 98 | 99 | stanzaArgs := make([]string, 5) 100 | stanzaArgs[0] = b64.EncodeToString(version) 101 | stanzaArgs[1] = x25519Stanzas[0].Args[0] 102 | stanzaArgs[2] = b64.EncodeToString([]byte{requirePinByte}) 103 | stanzaArgs[3] = b64.EncodeToString(r.Salt) 104 | stanzaArgs[4] = b64.EncodeToString(r.CredId) 105 | 106 | stanza := &age.Stanza{ 107 | Type: PLUGIN_NAME, 108 | Args: stanzaArgs, 109 | Body: x25519Stanzas[0].Body, 110 | } 111 | 112 | return []*age.Stanza{stanza}, nil 113 | default: 114 | return nil, fmt.Errorf("cannot to wrap to recipient with format version %x", r.Version) 115 | } 116 | } 117 | --------------------------------------------------------------------------------