├── .github └── workflows │ ├── build.yaml │ ├── codeql-analysis.yml │ ├── dependabot-auto-approve.yaml │ └── dependabot-auto-merge.yaml ├── .gitignore ├── .goreleaser.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── age.go ├── go.mod ├── go.sum ├── keyring.go ├── run_tests ├── siv.go ├── strongbox-logo.png ├── strongbox.go ├── strongbox_local_test.go ├── strongbox_test.go └── testdata ├── .gitattributes ├── .strongbox_identity └── .strongbox_recipient /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | pull_request: 5 | push: 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | goreleaser: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | - name: Set up Go 19 | uses: actions/setup-go@v3 20 | with: 21 | go-version-file: 'go.mod' 22 | cache: true 23 | - name: Test 24 | run: make test 25 | - name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v3 27 | with: 28 | # either 'goreleaser' (default) or 'goreleaser-pro' 29 | distribution: goreleaser 30 | version: latest 31 | args: release --clean 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GH_PAT }} 34 | # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution 35 | # GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} 36 | tests: 37 | runs-on: ubuntu-latest 38 | steps: 39 | - name: Checkout 40 | uses: actions/checkout@v4 41 | with: 42 | fetch-depth: 0 43 | - name: Set up Go 44 | uses: actions/setup-go@v5 45 | with: 46 | go-version-file: 'go.mod' 47 | cache: true 48 | - name: test 49 | run: go test ./... 50 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [master] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [master] 20 | schedule: 21 | - cron: "30 11 * * 3" 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ["go"] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v3 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v2 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v2 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v2 72 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-approve.yaml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/automating-dependabot-with-github-actions#approve-a-pull-request 2 | name: Dependabot auto-approve 3 | on: pull_request 4 | 5 | permissions: 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v1.6.0 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - name: Approve a PR 19 | run: gh pr review --approve "$PR_URL" 20 | env: 21 | PR_URL: ${{github.event.pull_request.html_url}} 22 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 23 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yaml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/automating-dependabot-with-github-actions#enable-auto-merge-on-a-pull-request 2 | name: Dependabot auto-merge 3 | on: pull_request 4 | 5 | permissions: 6 | pull-requests: write 7 | contents: write 8 | 9 | jobs: 10 | dependabot: 11 | runs-on: ubuntu-latest 12 | if: ${{ github.actor == 'dependabot[bot]' }} 13 | steps: 14 | - name: Dependabot metadata 15 | id: metadata 16 | uses: dependabot/fetch-metadata@v1.6.0 17 | with: 18 | github-token: "${{ secrets.GITHUB_TOKEN }}" 19 | - name: Enable auto-merge for Dependabot PRs 20 | run: gh pr merge --auto --merge "$PR_URL" 21 | env: 22 | PR_URL: ${{github.event.pull_request.html_url}} 23 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | strongbox 2 | # strongbox binary built for integration tests 3 | /strongbox-test-bin 4 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # Build customization 2 | builds: 3 | - id: main 4 | env: 5 | - CGO_ENABLED=0 6 | goos: 7 | - darwin 8 | - linux 9 | - freebsd 10 | goarch: 11 | - amd64 12 | - arm 13 | - arm64 14 | ignore: 15 | - goos: freebsd 16 | goarch: arm64 17 | 18 | release: 19 | github: 20 | owner: uw-labs 21 | name: strongbox 22 | 23 | archives: 24 | - builds: 25 | - main 26 | format: binary 27 | files: 28 | - none* 29 | 30 | brews: 31 | - name: strongbox 32 | description: Encryption for git users 33 | homepage: https://github.com/uw-labs/strongbox 34 | license: LGPL-3.0 35 | directory: Formula 36 | repository: 37 | owner: uw-labs 38 | name: homebrew-tap 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22-alpine 2 | 3 | RUN apk --no-cache add git 4 | 5 | ENV GOPATH=/go CGO_ENABLED=0 6 | COPY . /go/src/github.com/uw-labs/strongbox 7 | WORKDIR /go/src/github.com/uw-labs/strongbox 8 | 9 | ENTRYPOINT ["/bin/sh", "/go/src/github.com/uw-labs/strongbox/run_tests"] 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | IMAGE=strongbox-test 2 | 3 | .DEFAULT_GOAL := test 4 | 5 | build-test-image: 6 | docker build -t $(IMAGE) -f Dockerfile . 7 | 8 | test: build-test-image 9 | docker run --tmpfs /root:rw --rm $(IMAGE) 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Strongbox](strongbox-logo.png) 2 | 3 | Encryption for Git users. 4 | 5 | Strongbox makes it easy to encrypt and decrypt files stored in Git, with 6 | minimal divergence from a typical Git workflow. Once installed, Strongbox 7 | enables normal use of commands such as `git diff` etc. and all of the files 8 | that should be encrypted in the repository remain decrypted on your working 9 | copy. 10 | 11 | It supports use of different keys per directory if wanted. It can cover as many 12 | or as few files as you wish based on 13 | [.gitattributes](https://www.git-scm.com/docs/gitattributes) 14 | 15 | ## Installation 16 | 17 | You can obtain a binary from https://github.com/uw-labs/strongbox/releases 18 | 19 | Alternatively, assuming you have a working [Go](https://golang.org) installation, you can 20 | install via the following command: 21 | 22 | ```console 23 | $ go install github.com/uw-labs/strongbox/v2@v2.0.0 24 | ``` 25 | 26 | ### Homebrew 27 | 28 | If you're on macOS or Linux and have [Homebrew](https://brew.sh/) installed, 29 | getting Strongbox is as simple as running: 30 | 31 | ```console 32 | $ brew install uw-labs/tap/strongbox 33 | ``` 34 | 35 | ## Usage 36 | 37 | Strongbox supports [age](https://github.com/FiloSottile/age) and 38 | [siv](https://pkg.go.dev/github.com/jacobsa/crypto/siv?utm_source=godoc) 39 | encryption. Age is the recommended option. 40 | 41 | | encryption | identity / keyring file | recipient / key file | 42 | | ---------- | ----------------------- | -------------------- | 43 | | age | .strongbox_identity | .strongbox_recipient | 44 | | siv | .strongbox_keyring | .strongbox-keyid | 45 | 46 | If both identity / key files are present in the same directory, 47 | `.strongbox_identity` (age) will be preferred. 48 | 49 | 1. As a one time action, install the plugin by running `strongbox -git-config`. 50 | This will edit global Git config to enable Strongbox filter and diff 51 | configuration. 52 | 53 | 2. In each repository you want to use Strongbox, create `.gitattributes` file 54 | containing the patterns to be managed by Strongbox. 55 | 56 | For example: 57 | 58 | ``` 59 | secrets/* filter=strongbox diff=strongbox merge=strongbox 60 | ``` 61 | 62 | 3. Generate a key to use for the encryption, for example: 63 | ```console 64 | strongbox -gen-identity my-key 65 | ``` 66 | This will generate a new [age](https://github.com/FiloSottile/age) keypair 67 | and place it in `~/.strongbox_identity`. You can specify alternative 68 | location using `-identity-file` flag or setting `$HOME` envvar. 69 | 70 | 4. Include `.strongbox_recipient` file in your repository 71 | (https://github.com/FiloSottile/age?tab=readme-ov-file#recipient-files). 72 | This can be in the same directory as the protected resource(s) or any parent 73 | directory. When searching for `.strongbox_recipient` for a given resource, 74 | Strongbox will recurse up the directory structure until it finds the file. 75 | This allows using different keys for different subdirectories within a 76 | repository. 77 | 78 | 5. If Strongbox identity file is stored in different location `-identity-file` 79 | can be used. ie `strongbox [-identity-file ] 80 | -gen-identity key-name` 81 | 82 | ## Existing project 83 | 84 | Strongbox uses [clean and smudge 85 | filters](https://git-scm.com/book/en/v2/Customizing-Git-Git-Attributes#filters_a) 86 | to encrypt and decrypt files. 87 | 88 | If you are cloning a project that uses Strongbox, you will need to have 89 | identity in your Strongbox identity file prior to cloning (checkout). Otherwise 90 | that filter will fail and not decrypt files on checkout. 91 | 92 | If you already have the project locally and added identity, you can remove and 93 | checkout the files to force the filter: 94 | ``` 95 | rm && git checkout -- 96 | ``` 97 | 98 | ## Verification 99 | 100 | Following a `git add`, you can verify the file is encrypted in the index: 101 | 102 | ```console 103 | $ git show :/path/to/file 104 | ``` 105 | 106 | Verify a file is encrypted in the commit: 107 | 108 | ```console 109 | $ git show HEAD:/path/to/file 110 | ``` 111 | 112 | What you should see is a Strongbox encrypted resource, and this is what would 113 | be pushed to the remote. 114 | 115 | Compare an entire branch (as it would appear on the remote) to master: 116 | 117 | ```console 118 | $ git diff-index -p master 119 | ``` 120 | 121 | ## Key rotation 122 | 123 | To rotate keys, update the `.strongbox_recipient` with the new value, then 124 | `touch` all files/directories covered by `.gitattributes`. All affected files 125 | should now show up as "modified". 126 | 127 | ## Security 128 | 129 | Strongbox uses [age](https://github.com/FiloSottile/age) and SIV-AES as defined 130 | in rfc5297. 131 | 132 | ## Testing 133 | 134 | Run integration tests: 135 | 136 | ```console 137 | $ make test 138 | ``` 139 | 140 | ## SIV manual decryption 141 | Following commands can be used to decrypt files outside of the Git flow: 142 | 143 | ```console 144 | # decrypt using default keyring file `$HOME/.strongbox_keyring` 145 | strongbox -decrypt -recursive 146 | 147 | # decrypt using `keyring_file_path` 148 | strongbox -keyring -decrypt -recursive 149 | 150 | # decrypt using private key `` 151 | strongbox -key -decrypt -recursive 152 | 153 | # decrypt single file with given key 154 | strongbox -decrypt -key 155 | ``` 156 | 157 | ## Known issues 158 | 159 | ### Clone file ordering (SIV only) 160 | 161 | Given a `.strongbox-keyid` in the root of the repository and an encrypted file 162 | in the same directory,*and* alphabetically it comes before the key-id file. 163 | 164 | Git checks out files alphanumerically, so if the strongboxed file is being 165 | checked out before the `.strongbox-keyid` is present on disk, strongbox will 166 | fail to find the decryption key. 167 | 168 | Order of files being cloned is dictated by the index. 169 | 170 | #### Workarounds 171 | 172 | 1. Clone repository, let the descryption fail. Delete encrypted files and do 173 | `git checkout` on the deleted files. 174 | 2. Move affected files down to a subdirectory from `.strongbox-keyid` file 175 | -------------------------------------------------------------------------------- /age.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "strings" 12 | 13 | "filippo.io/age" 14 | "filippo.io/age/armor" 15 | ) 16 | 17 | const ( 18 | recipientFilename = ".strongbox_recipient" 19 | defaultIdentityFilename = ".strongbox_identity" 20 | ) 21 | 22 | var identityFilename string 23 | 24 | func ageGenIdentity(desc string) { 25 | identity, err := age.GenerateX25519Identity() 26 | if err != nil { 27 | log.Fatalf("Failed to generate identity: %v", err) 28 | } 29 | 30 | fmt.Printf("public key: %s\n", identity.Recipient().String()) 31 | 32 | f, err := os.OpenFile(identityFilename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 33 | if err != nil { 34 | log.Fatal(err) 35 | } 36 | defer f.Close() 37 | // we assume that file has a trailing newline 38 | if _, err := f.Write([]byte(fmt.Sprintf("# description: %s\n# public key: %s\n%s\n", desc, identity.Recipient().String(), identity.String()))); err != nil { 39 | log.Fatal(err) 40 | } 41 | if err := f.Close(); err != nil { 42 | log.Fatal(err) 43 | } 44 | } 45 | 46 | func ageFileToRecipient(filename string) ([]age.Recipient, error) { 47 | file, err := os.Open(filename) 48 | if err != nil { 49 | return nil, err 50 | } 51 | defer file.Close() 52 | return age.ParseRecipients(file) 53 | } 54 | 55 | func ageEncrypt(w io.Writer, r []age.Recipient, in []byte, f string) { 56 | // We have to do check the following because age's encryption is non 57 | // deterministic 58 | // 59 | // if there's no difference between the decrypted version of the file 60 | // at HEAD and the new contents AND file's recipient hasn't changed, do 61 | // not re-encrypt 62 | if agePlaintextEqual(in, f) && !ageRecipientChanged(f) { 63 | fah := ageFileAtHEAD(f) 64 | if _, err := io.Copy(w, bytes.NewReader(fah)); err != nil { 65 | log.Fatal(err) 66 | } 67 | return 68 | } 69 | 70 | armorWriter := armor.NewWriter(w) 71 | wc, err := age.Encrypt(armorWriter, r...) 72 | if err != nil { 73 | log.Fatalf("Failed to create encrypted file: %v", err) 74 | } 75 | if _, err := io.Copy(wc, bytes.NewReader(in)); err != nil { 76 | log.Fatal(err) 77 | } 78 | if err := wc.Close(); err != nil { 79 | log.Fatalf("Failed to close encrypted file: %v", err) 80 | } 81 | if err := armorWriter.Close(); err != nil { 82 | log.Fatalf("Failed to close armor: %v", err) 83 | } 84 | } 85 | 86 | func ageDecrypt(w io.Writer, in []byte) { 87 | identityFile, err := os.Open(identityFilename) 88 | if err != nil { 89 | // identity file doesn't exist, copy as is and return 90 | if _, err = io.Copy(w, bytes.NewReader(in)); err != nil { 91 | log.Println(err) 92 | } 93 | return 94 | } 95 | defer identityFile.Close() 96 | identities, err := age.ParseIdentities(identityFile) 97 | if err != nil { 98 | // could not parse identity file, copy as is and return 99 | if _, err = io.Copy(w, bytes.NewReader(in)); err != nil { 100 | log.Println(err) 101 | } 102 | return 103 | } 104 | armorReader := armor.NewReader(bytes.NewReader(in)) 105 | ar, err := age.Decrypt(armorReader, identities...) 106 | if err != nil { 107 | // couldn't find the key, copy as is and return 108 | if _, err = io.Copy(w, bytes.NewReader(in)); err != nil { 109 | log.Println(err) 110 | } 111 | return 112 | } 113 | if _, err := io.Copy(w, ar); err != nil { 114 | log.Fatal(err) 115 | } 116 | } 117 | 118 | func agePlaintextEqual(in []byte, f string) bool { 119 | command := []string{"cat-file", "-e", fmt.Sprintf("HEAD:%s", f)} 120 | cmd := exec.Command("git", command...) 121 | // if git cat-file -e fails, then the file doesn't exist at HEAD, so it's new, 122 | // meaning we need to encrypt it for the first time 123 | if _, err := cmd.CombinedOutput(); err != nil { 124 | return false 125 | } 126 | 127 | fileAtHEAD := ageFileAtHEAD(f) 128 | // potentially re-encrypting SIV file 129 | if !strings.HasPrefix(string(fileAtHEAD), armor.Header) { 130 | return false 131 | } 132 | var plaintext bytes.Buffer 133 | ageDecrypt(&plaintext, fileAtHEAD) 134 | return bytes.Equal(plaintext.Bytes(), in) 135 | } 136 | 137 | func ageFileAtHEAD(f string) []byte { 138 | command := []string{"cat-file", "-p", fmt.Sprintf("HEAD:%s", f)} 139 | cmd := exec.Command("git", command...) 140 | fileAtHEAD, err := cmd.CombinedOutput() 141 | if err != nil { 142 | log.Fatal(err) 143 | } 144 | return fileAtHEAD 145 | } 146 | 147 | func ageRecipientChanged(filename string) bool { 148 | path := filepath.Dir(filename) 149 | for { 150 | if fi, err := os.Stat(path); err == nil && fi.IsDir() { 151 | ageRecipientFilename := filepath.Join(path, recipientFilename) 152 | // If we found `.strongbox_recipient` - compare it with HEAD version 153 | if keyFile, err := os.Stat(ageRecipientFilename); err == nil && !keyFile.IsDir() { 154 | fah := ageFileAtHEAD(ageRecipientFilename) 155 | fod, err := os.ReadFile(ageRecipientFilename) 156 | if err != nil { 157 | log.Fatalf("Failed to open private keys file: %v", err) 158 | } 159 | return !bytes.Equal(fah, fod) 160 | } 161 | } 162 | if path == "." { 163 | break 164 | } 165 | path = filepath.Dir(path) 166 | } 167 | 168 | return false 169 | } 170 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/uw-labs/strongbox/v2 2 | 3 | go 1.21 4 | toolchain go1.24.1 5 | 6 | require ( 7 | github.com/jacobsa/crypto v0.0.0-20190317225127-9f44e2d11115 8 | github.com/jacobsa/oglematchers v0.0.0-20150720000706-141901ea67cd // indirect 9 | github.com/jacobsa/oglemock v0.0.0-20150831005832-e94d794d06ff // indirect 10 | github.com/jacobsa/ogletest v0.0.0-20170503003838-80d50a735a11 // indirect 11 | github.com/jacobsa/reqtrace v0.0.0-20150505043853-245c9e0234cb // indirect 12 | github.com/stretchr/testify v1.7.0 13 | golang.org/x/net v0.38.0 // indirect 14 | gopkg.in/yaml.v2 v2.4.0 15 | ) 16 | 17 | require filippo.io/age v1.2.1 18 | 19 | require ( 20 | github.com/davecgh/go-spew v1.1.0 // indirect 21 | github.com/pmezard/go-difflib v1.0.0 // indirect 22 | golang.org/x/crypto v0.36.0 // indirect 23 | golang.org/x/sys v0.31.0 // indirect 24 | gopkg.in/yaml.v3 v3.0.0 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /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 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o= 4 | filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= 5 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/jacobsa/crypto v0.0.0-20190317225127-9f44e2d11115 h1:YuDUUFNM21CAbyPOpOP8BicaTD/0klJEKt5p8yuw+uY= 8 | github.com/jacobsa/crypto v0.0.0-20190317225127-9f44e2d11115/go.mod h1:LadVJg0XuawGk+8L1rYnIED8451UyNxEMdTWCEt5kmU= 9 | github.com/jacobsa/oglematchers v0.0.0-20150720000706-141901ea67cd h1:9GCSedGjMcLZCrusBZuo4tyKLpKUPenUUqi34AkuFmA= 10 | github.com/jacobsa/oglematchers v0.0.0-20150720000706-141901ea67cd/go.mod h1:TlmyIZDpGmwRoTWiakdr+HA1Tukze6C6XbRVidYq02M= 11 | github.com/jacobsa/oglemock v0.0.0-20150831005832-e94d794d06ff h1:2xRHTvkpJ5zJmglXLRqHiZQNjUoOkhUyhTAhEQvPAWw= 12 | github.com/jacobsa/oglemock v0.0.0-20150831005832-e94d794d06ff/go.mod h1:gJWba/XXGl0UoOmBQKRWCJdHrr3nE0T65t6ioaj3mLI= 13 | github.com/jacobsa/ogletest v0.0.0-20170503003838-80d50a735a11 h1:BMb8s3ENQLt5ulwVIHVDWFHp8eIXmbfSExkvdn9qMXI= 14 | github.com/jacobsa/ogletest v0.0.0-20170503003838-80d50a735a11/go.mod h1:+DBdDyfoO2McrOyDemRBq0q9CMEByef7sYl7JH5Q3BI= 15 | github.com/jacobsa/reqtrace v0.0.0-20150505043853-245c9e0234cb h1:uSWBjJdMf47kQlXMwWEfmc864bA1wAC+Kl3ApryuG9Y= 16 | github.com/jacobsa/reqtrace v0.0.0-20150505043853-245c9e0234cb/go.mod h1:ivcmUvxXWjb27NsPEaiYK7AidlZXS7oQ5PowUS9z3I4= 17 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 18 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 19 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 20 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 21 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 22 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 23 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 24 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 25 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 26 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 27 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 28 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 29 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 30 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 31 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 32 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 33 | gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= 34 | gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 35 | -------------------------------------------------------------------------------- /keyring.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | 9 | "gopkg.in/yaml.v2" 10 | ) 11 | 12 | type keyRing interface { 13 | Load() error 14 | Save() error 15 | AddKey(name string, keyID []byte, key []byte) 16 | Key(keyID []byte) ([]byte, error) 17 | } 18 | 19 | type fileKeyRing struct { 20 | fileName string 21 | KeyEntries []keyEntry 22 | } 23 | 24 | type keyEntry struct { 25 | Description string `yaml:"description"` 26 | KeyID string `yaml:"key-id"` 27 | Key string `yaml:"key"` 28 | } 29 | 30 | func (kr *fileKeyRing) AddKey(desc string, keyID []byte, key []byte) { 31 | kr.KeyEntries = append(kr.KeyEntries, keyEntry{ 32 | Description: desc, 33 | KeyID: string(encode(keyID[:])), 34 | Key: string(encode(key[:])), 35 | }) 36 | } 37 | 38 | func (kr *fileKeyRing) Key(keyID []byte) ([]byte, error) { 39 | b64 := string(encode(keyID[:])) 40 | 41 | for _, ke := range kr.KeyEntries { 42 | if ke.KeyID == b64 { 43 | dec, err := decode([]byte(ke.Key)) 44 | if err != nil { 45 | return []byte{}, err 46 | } 47 | if len(dec) != 32 { 48 | return []byte{}, fmt.Errorf("unexpected length of key: %d", len(dec)) 49 | } 50 | return dec, nil 51 | } 52 | } 53 | 54 | return []byte{}, errKeyNotFound 55 | } 56 | 57 | func (kr *fileKeyRing) Load() error { 58 | 59 | bytes, err := os.ReadFile(kr.fileName) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | err = yaml.Unmarshal(bytes, kr) 65 | return err 66 | } 67 | 68 | func (kr *fileKeyRing) Save() error { 69 | ser, err := yaml.Marshal(kr) 70 | if err != nil { 71 | log.Fatal(err) 72 | } 73 | 74 | path := filepath.Dir(kr.fileName) 75 | _, err = os.Stat(path) 76 | if os.IsNotExist(err) { 77 | err := os.MkdirAll(path, 0700) 78 | if err != nil { 79 | return fmt.Errorf("error creating strongbox home folder: %s", err) 80 | } 81 | } 82 | 83 | return os.WriteFile(kr.fileName, ser, 0600) 84 | } 85 | -------------------------------------------------------------------------------- /run_tests: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | PATH=$PATH:$GOPATH/bin 4 | 5 | go get -t . 6 | go install 7 | 8 | go get -t ./ 9 | go test -v -tags=integration ./ 10 | -------------------------------------------------------------------------------- /siv.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "crypto/rand" 7 | "crypto/sha256" 8 | "encoding/base64" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "io/fs" 13 | "log" 14 | "os" 15 | "path/filepath" 16 | "strings" 17 | 18 | "github.com/jacobsa/crypto/siv" 19 | ) 20 | 21 | var ( 22 | keyLoader = key 23 | kr keyRing 24 | prefix = []byte("# STRONGBOX ENCRYPTED RESOURCE ;") 25 | defaultPrefix = []byte("# STRONGBOX ENCRYPTED RESOURCE ; See https://github.com/uw-labs/strongbox\n") 26 | 27 | errKeyNotFound = errors.New("key not found") 28 | ) 29 | 30 | func genKey(desc string) { 31 | err := kr.Load() 32 | if err != nil && !os.IsNotExist(err) { 33 | log.Fatal(err) 34 | } 35 | 36 | key := make([]byte, 32) 37 | _, err = rand.Read(key) 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | 42 | keyID := sha256.Sum256(key) 43 | 44 | kr.AddKey(desc, keyID[:], key) 45 | 46 | err = kr.Save() 47 | if err != nil { 48 | log.Fatal(err) 49 | } 50 | } 51 | 52 | // recursiveDecrypt will try and recursively decrypt files 53 | // if 'key' is provided then it will decrypt all encrypted files with given key 54 | // otherwise it will find key based on file location 55 | // if error is generated in finding key or in decryption then it will continue with next file 56 | // function will only return early if it failed to read/write files 57 | func recursiveDecrypt(target string, givenKey []byte) error { 58 | var decErrors []string 59 | err := filepath.WalkDir(target, func(path string, entry fs.DirEntry, err error) error { 60 | // always return on error 61 | if err != nil { 62 | return err 63 | } 64 | 65 | // only process files 66 | if entry.IsDir() { 67 | // skip .git directory 68 | if entry.Name() == ".git" { 69 | return fs.SkipDir 70 | } 71 | return nil 72 | } 73 | 74 | file, err := os.OpenFile(path, os.O_RDWR, 0) 75 | if err != nil { 76 | return err 77 | } 78 | defer file.Close() 79 | 80 | // for optimisation only read required chunk of the file and verify if encrypted 81 | chunk := make([]byte, len(defaultPrefix)) 82 | _, err = file.Read(chunk) 83 | if err != nil && err != io.EOF { 84 | return err 85 | } 86 | 87 | if !bytes.HasPrefix(chunk, prefix) { 88 | return nil 89 | } 90 | 91 | key := givenKey 92 | if len(key) == 0 { 93 | key, err = keyLoader(path) 94 | if err != nil { 95 | // continue with next file 96 | decErrors = append(decErrors, fmt.Sprintf("unable to find key file:%s err:%s", path, err)) 97 | return nil 98 | } 99 | } 100 | 101 | // read entire file from the beginning 102 | file.Seek(0, io.SeekStart) 103 | in, err := io.ReadAll(file) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | out, err := decrypt(in, key) 109 | if err != nil { 110 | // continue with next file 111 | decErrors = append(decErrors, fmt.Sprintf("unable to decrypt file:%s err:%s", path, err)) 112 | return nil 113 | } 114 | 115 | if err := file.Truncate(0); err != nil { 116 | return err 117 | } 118 | if _, err := file.Seek(0, io.SeekStart); err != nil { 119 | return err 120 | } 121 | if _, err := file.Write(out); err != nil { 122 | return err 123 | } 124 | return nil 125 | }) 126 | if err != nil { 127 | return err 128 | } 129 | if len(decErrors) > 0 { 130 | for _, e := range decErrors { 131 | log.Println(e) 132 | } 133 | return fmt.Errorf("unable to decrypt some files") 134 | } 135 | 136 | return nil 137 | } 138 | 139 | func encrypt(b, key []byte) ([]byte, error) { 140 | b = compress(b) 141 | out, err := siv.Encrypt(nil, key, b, nil) 142 | if err != nil { 143 | return nil, err 144 | } 145 | var buf []byte 146 | buf = append(buf, defaultPrefix...) 147 | b64 := encode(out) 148 | for len(b64) > 0 { 149 | l := 76 150 | if len(b64) < 76 { 151 | l = len(b64) 152 | } 153 | buf = append(buf, b64[0:l]...) 154 | buf = append(buf, '\n') 155 | b64 = b64[l:] 156 | } 157 | return buf, nil 158 | } 159 | 160 | func decrypt(enc []byte, priv []byte) ([]byte, error) { 161 | // strip prefix and any comment up to end of line 162 | spl := bytes.SplitN(enc, []byte("\n"), 2) 163 | if len(spl) != 2 { 164 | return nil, errors.New("couldn't split on end of line") 165 | } 166 | b64encoded := spl[1] 167 | b64decoded, err := decode(b64encoded) 168 | if err != nil { 169 | return nil, err 170 | } 171 | decrypted, err := siv.Decrypt(priv, b64decoded, nil) 172 | if err != nil { 173 | return nil, err 174 | } 175 | decrypted = decompress(decrypted) 176 | return decrypted, nil 177 | } 178 | 179 | func compress(b []byte) []byte { 180 | var buf bytes.Buffer 181 | zw := gzip.NewWriter(&buf) 182 | _, err := zw.Write(b) 183 | if err != nil { 184 | log.Fatal(err) 185 | } 186 | if err := zw.Close(); err != nil { 187 | log.Fatal(err) 188 | } 189 | return buf.Bytes() 190 | } 191 | 192 | func decompress(b []byte) []byte { 193 | zr, err := gzip.NewReader(bytes.NewReader(b)) 194 | if err != nil { 195 | log.Fatal(err) 196 | } 197 | b, err = io.ReadAll(zr) 198 | if err != nil { 199 | log.Fatal(err) 200 | } 201 | if err := zr.Close(); err != nil { 202 | log.Fatal(err) 203 | } 204 | return b 205 | } 206 | 207 | func encode(decoded []byte) []byte { 208 | b64 := make([]byte, base64.StdEncoding.EncodedLen(len(decoded))) 209 | base64.StdEncoding.Encode(b64, decoded) 210 | return b64 211 | } 212 | 213 | func decode(encoded []byte) ([]byte, error) { 214 | decoded := make([]byte, len(encoded)) 215 | i, err := base64.StdEncoding.Decode(decoded, encoded) 216 | if err != nil { 217 | return nil, err 218 | } 219 | return decoded[0:i], nil 220 | } 221 | 222 | // key returns private key and error 223 | func key(filename string) ([]byte, error) { 224 | keyID, err := findKey(filename) 225 | if err != nil { 226 | return []byte{}, err 227 | } 228 | 229 | err = kr.Load() 230 | if err != nil { 231 | return []byte{}, err 232 | } 233 | 234 | key, err := kr.Key(keyID) 235 | if err != nil { 236 | return []byte{}, err 237 | } 238 | 239 | return key, nil 240 | } 241 | 242 | func findKey(filename string) ([]byte, error) { 243 | path := filepath.Dir(filename) 244 | for { 245 | if fi, err := os.Stat(path); err == nil && fi.IsDir() { 246 | keyFilename := filepath.Join(path, ".strongbox-keyid") 247 | if keyFile, err := os.Stat(keyFilename); err == nil && !keyFile.IsDir() { 248 | return readKeyID(keyFilename) 249 | } 250 | } 251 | if path == "." { 252 | break 253 | } 254 | path = filepath.Dir(path) 255 | } 256 | return []byte{}, fmt.Errorf("failed to find key id for file %s", filename) 257 | } 258 | 259 | func readKeyID(filename string) ([]byte, error) { 260 | fp, err := os.ReadFile(filename) 261 | if err != nil { 262 | return []byte{}, err 263 | } 264 | 265 | b64 := strings.TrimSpace(string(fp)) 266 | b, err := decode([]byte(b64)) 267 | if err != nil { 268 | return []byte{}, err 269 | } 270 | if len(b) != 32 { 271 | return []byte{}, fmt.Errorf("unexpected key length %d", len(b)) 272 | } 273 | return b, nil 274 | } 275 | 276 | func sivFileToKey(filename string) ([]byte, error) { 277 | keyID, err := readKeyID(filename) 278 | if err != nil { 279 | return []byte{}, err 280 | } 281 | 282 | err = kr.Load() 283 | if err != nil { 284 | return []byte{}, err 285 | } 286 | 287 | key, err := kr.Key(keyID) 288 | if err != nil { 289 | return []byte{}, err 290 | } 291 | 292 | return key, nil 293 | } 294 | -------------------------------------------------------------------------------- /strongbox-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uw-labs/strongbox/59523bf091530a67c58932db1061df782dbac41e/strongbox-logo.png -------------------------------------------------------------------------------- /strongbox.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "log" 10 | "os" 11 | "os/exec" 12 | "os/user" 13 | "path/filepath" 14 | "strings" 15 | 16 | "filippo.io/age" 17 | "filippo.io/age/armor" 18 | ) 19 | 20 | // https://stackoverflow.com/a/28323276 21 | type arrayFlags []string 22 | 23 | func (i *arrayFlags) String() string { 24 | return strings.Join(*i, " ") 25 | } 26 | 27 | func (i *arrayFlags) Set(value string) error { 28 | *i = append(*i, value) 29 | return nil 30 | } 31 | 32 | var ( 33 | version = "dev" 34 | commit = "none" 35 | date = "unknown" 36 | builtBy = "unknown" 37 | 38 | mergeFileFlags arrayFlags 39 | 40 | // flags 41 | flagDecrypt = flag.Bool("decrypt", false, "Decrypt single resource") 42 | flagGenIdentity = flag.String("gen-identity", "", "Generate a new identity and add it to your strongbox identity file") 43 | flagGenKey = flag.String("gen-key", "", "Generate a new key and add it to your strongbox keyring") 44 | flagGitConfig = flag.Bool("git-config", false, "Configure git for strongbox use") 45 | flagIdentityFile = flag.String("identity-file", "", "strongbox identity file, if not set default '$HOME/.strongbox_identity' will be used") 46 | flagKey = flag.String("key", "", "Private key to use to decrypt") 47 | flagKeyRing = flag.String("keyring", "", "strongbox keyring file path, if not set default '$HOME/.strongbox_keyring' will be used") 48 | flagRecursive = flag.Bool("recursive", false, "Recursively decrypt all files under given folder, must be used with -decrypt flag") 49 | 50 | flagClean = flag.String("clean", "", "intended to be called internally by git") 51 | flagSmudge = flag.String("smudge", "", "intended to be called internally by git") 52 | flagDiff = flag.String("diff", "", "intended to be called internally by git") 53 | 54 | flagVersion = flag.Bool("version", false, "Strongbox version") 55 | ) 56 | 57 | func usage() { 58 | fmt.Fprintf(os.Stderr, "Usage:\n\n") 59 | fmt.Fprintf(os.Stderr, "\tstrongbox -git-config\n") 60 | fmt.Fprintf(os.Stderr, "\tstrongbox [-identity-file PATH] -gen-identity IDENTITY_NAME\n") 61 | fmt.Fprintf(os.Stderr, "\tstrongbox [-keyring KEYRING_FILEPATH] -gen-key KEY_NAME\n") 62 | fmt.Fprintf(os.Stderr, "\tstrongbox [-keyring KEYRING_FILEPATH] -decrypt -recursive [-key KEY] [PATH]\n") 63 | fmt.Fprintf(os.Stderr, "\tstrongbox [-keyring KEYRING_FILEPATH] -decrypt -key KEY [PATH]\n") 64 | fmt.Fprintf(os.Stderr, "\tstrongbox -version\n") 65 | fmt.Fprintf(os.Stderr, "\n(age) if -identity-file flag is not set, default '$HOME/.strongbox_identity' will be used\n") 66 | fmt.Fprintf(os.Stderr, "(siv) if -keyring flag is not set default file '$HOME/.strongbox_keyring' or '$STRONGBOX_HOME/.strongbox_keyring' will be used as keyring\n") 67 | os.Exit(2) 68 | } 69 | 70 | func main() { 71 | log.SetPrefix("strongbox: ") 72 | log.SetFlags(log.LstdFlags | log.Lshortfile) 73 | 74 | flag.Var(&mergeFileFlags, "merge-file", "intended to be called internally by git") 75 | 76 | flag.Usage = usage 77 | flag.Parse() 78 | 79 | if *flagVersion || (flag.NArg() == 1 && flag.Arg(0) == "version") { 80 | fmt.Printf("version=%s commit=%s date=%s builtBy=%s\n", version, commit, date, builtBy) 81 | return 82 | } 83 | 84 | if *flagGitConfig { 85 | gitConfig() 86 | return 87 | } 88 | 89 | if *flagDiff != "" { 90 | diff(*flagDiff) 91 | return 92 | } 93 | 94 | // Set up keyring file name 95 | home := deriveHome() 96 | kr = &fileKeyRing{fileName: filepath.Join(home, ".strongbox_keyring")} 97 | 98 | if *flagIdentityFile != "" { 99 | identityFilename = *flagIdentityFile 100 | } else { 101 | identityFilename = filepath.Join(home, defaultIdentityFilename) 102 | } 103 | 104 | // if keyring flag is set replace default keyRing 105 | if *flagKeyRing != "" { 106 | kr = &fileKeyRing{fileName: *flagKeyRing} 107 | // verify keyring is valid 108 | if err := kr.Load(); err != nil { 109 | log.Fatalf("unable to load keyring file:%s err:%s", *flagKeyRing, err) 110 | } 111 | } 112 | 113 | if *flagGenKey != "" { 114 | genKey(*flagGenKey) 115 | return 116 | } 117 | 118 | if *flagDecrypt { 119 | // handle recursive 120 | if *flagRecursive { 121 | var err error 122 | 123 | target := flag.Arg(0) 124 | if target == "" { 125 | target, err = os.Getwd() 126 | if err != nil { 127 | log.Fatalf("target path not provided and unable to get cwd err:%s", err) 128 | } 129 | } 130 | // for recursive decryption 'key' flag is optional but if provided 131 | // it should be valid and all encrypted file will be decrypted using it 132 | dk, err := decode([]byte(*flagKey)) 133 | if err != nil && *flagKey != "" { 134 | log.Fatalf("Unable to decode given private key %v", err) 135 | } 136 | 137 | if err = recursiveDecrypt(target, dk); err != nil { 138 | log.Fatalln(err) 139 | } 140 | return 141 | } 142 | 143 | if *flagKey == "" { 144 | log.Fatalf("Must provide a `-key` when using -decrypt") 145 | } 146 | decryptCLI() 147 | return 148 | } 149 | 150 | if *flagRecursive { 151 | log.Println("-recursive flag is only supported with -decrypt") 152 | usage() 153 | } 154 | 155 | if *flagGenIdentity != "" { 156 | ageGenIdentity(*flagGenIdentity) 157 | return 158 | } 159 | 160 | if *flagClean != "" { 161 | clean(os.Stdin, os.Stdout, *flagClean) 162 | return 163 | } 164 | if *flagSmudge != "" { 165 | smudge(os.Stdin, os.Stdout, *flagSmudge) 166 | return 167 | } 168 | if len(mergeFileFlags) > 0 { 169 | if len(mergeFileFlags) != 8 { 170 | log.Fatalf("expected 8 -merge-file arguments, got %d: %v", len(mergeFileFlags), mergeFileFlags) 171 | } 172 | os.Exit(mergeFile()) 173 | } 174 | } 175 | 176 | func deriveHome() string { 177 | // try explicitly set STRONGBOX_HOME 178 | if home := os.Getenv("STRONGBOX_HOME"); home != "" { 179 | return home 180 | } 181 | // try HOME env var 182 | if home := os.Getenv("HOME"); home != "" { 183 | return home 184 | } 185 | // Try user.Current which works in most cases, but may not work with CGO disabled. 186 | u, err := user.Current() 187 | if err == nil && u.HomeDir != "" { 188 | return u.HomeDir 189 | } 190 | log.Fatal("Could not find $STRONGBOX_HOME, $HOME or call os/user.Current(). Please set $STRONGBOX_HOME, $HOME or recompile with CGO enabled") 191 | // not reached 192 | return "" 193 | } 194 | 195 | func decryptCLI() { 196 | var fn string 197 | if flag.Arg(0) == "" { 198 | // no file passed, try to read stdin 199 | fn = "/dev/stdin" 200 | } else { 201 | fn = flag.Arg(0) 202 | } 203 | fb, err := os.ReadFile(fn) 204 | if err != nil { 205 | log.Fatalf("Unable to read file to decrypt %v", err) 206 | } 207 | dk, err := decode([]byte(*flagKey)) 208 | if err != nil { 209 | log.Fatalf("Unable to decode private key %v", err) 210 | } 211 | out, err := decrypt(fb, dk) 212 | if err != nil { 213 | log.Fatalf("Unable to decrypt %v", err) 214 | } 215 | fmt.Printf("%s", out) 216 | } 217 | 218 | func gitConfig() { 219 | args := [][]string{ 220 | {"config", "--global", "--replace-all", "filter.strongbox.clean", "strongbox -clean %f"}, 221 | {"config", "--global", "--replace-all", "filter.strongbox.smudge", "strongbox -smudge %f"}, 222 | {"config", "--global", "--replace-all", "filter.strongbox.required", "true"}, 223 | 224 | {"config", "--global", "--replace-all", "diff.strongbox.textconv", "strongbox -diff"}, 225 | {"config", "--global", "--replace-all", "merge.strongbox.driver", "strongbox -merge-file %O -merge-file %A -merge-file %B -merge-file %L -merge-file %P -merge-file %S -merge-file %X -merge-file %Y"}, 226 | } 227 | for _, command := range args { 228 | cmd := exec.Command("git", command...) 229 | if out, err := cmd.CombinedOutput(); err != nil { 230 | log.Fatal(string(out)) 231 | } 232 | } 233 | log.Println("git global configuration updated successfully") 234 | } 235 | 236 | func diff(filename string) { 237 | f, err := os.Open(filename) 238 | if err != nil { 239 | log.Fatal(err) 240 | } 241 | defer func() { 242 | if err = f.Close(); err != nil { 243 | log.Fatal(err) 244 | } 245 | }() 246 | _, err = io.Copy(os.Stdout, f) 247 | if err != nil { 248 | log.Fatal(err) 249 | } 250 | } 251 | 252 | func clean(r io.Reader, w io.Writer, filename string) { 253 | // Read the file, fail on error 254 | in, err := io.ReadAll(r) 255 | if err != nil { 256 | log.Fatal(err) 257 | } 258 | // Check the file is plaintext, if its an encrypted strongbox or age file, copy as is, and exit 0 259 | if bytes.HasPrefix(in, prefix) || strings.HasPrefix(string(in), armor.Header) { 260 | _, err = io.Copy(w, bytes.NewReader(in)) 261 | if err != nil { 262 | log.Fatal(err) 263 | } 264 | return 265 | } 266 | // File is plaintext and needs to be encrypted, get the recipient or a 267 | // key, fail on error 268 | recipient, key, err := findRecipients(filename) 269 | if err != nil { 270 | log.Fatal(err) 271 | } 272 | 273 | // found recipient file and plaintext differs from HEAD 274 | if recipient != nil { 275 | ageEncrypt(w, recipient, in, filename) 276 | } 277 | if key != nil { 278 | // encrypt the file, fail on error 279 | out, err := encrypt(in, key) 280 | if err != nil { 281 | log.Fatal(err) 282 | } 283 | // write out encrypted file, fail on error 284 | _, err = io.Copy(w, bytes.NewReader(out)) 285 | if err != nil { 286 | log.Fatal(err) 287 | } 288 | } 289 | } 290 | 291 | // Called by git on `git checkout` 292 | func smudge(r io.Reader, w io.Writer, filename string) { 293 | in, err := io.ReadAll(r) 294 | if err != nil { 295 | log.Fatal(err) 296 | } 297 | 298 | if strings.HasPrefix(string(in), armor.Header) { 299 | ageDecrypt(w, in) 300 | return 301 | } 302 | if bytes.HasPrefix(in, prefix) { 303 | key, err := keyLoader(filename) 304 | if err != nil { 305 | // don't log error if its keyNotFound 306 | switch err { 307 | case errKeyNotFound: 308 | default: 309 | log.Println(err) 310 | } 311 | // Couldn't load the key, just copy as is and return 312 | if _, err = io.Copy(w, bytes.NewReader(in)); err != nil { 313 | log.Println(err) 314 | } 315 | return 316 | } 317 | 318 | out, err := decrypt(in, key) 319 | if err != nil { 320 | log.Println(err) 321 | out = in 322 | } 323 | if _, err := io.Copy(w, bytes.NewReader(out)); err != nil { 324 | log.Println(err) 325 | } 326 | return 327 | } 328 | 329 | // file is a non-siv and non-age file, copy as is and exit 330 | _, err = io.Copy(w, bytes.NewReader(in)) 331 | if err != nil { 332 | log.Fatal(err) 333 | } 334 | } 335 | 336 | func mergeFile() int { 337 | // https://git-scm.com/docs/gitattributes#_defining_a_custom_merge_driver 338 | // 339 | // The merge driver is expected to leave the result of the merge in the file 340 | // named with %A by overwriting it, and exit with zero status if it managed to 341 | // merge them cleanly, or non-zero if there were conflicts. When the driver 342 | // crashes it is expected to exit with non-zero status that are higher than 128, 343 | // and in such a case, the merge results in a failure (which is different 344 | // from producing a conflict). hence exit code -1 is used here on failure 345 | base := mergeFileFlags[0] // %O 346 | current := mergeFileFlags[1] // %A 347 | other := mergeFileFlags[2] // %B 348 | markerSize := mergeFileFlags[3] // %L 349 | _ = mergeFileFlags[4] // %P 350 | label1 := mergeFileFlags[5] // %S 351 | label2 := mergeFileFlags[6] // %X 352 | label3 := mergeFileFlags[7] // %Y 353 | 354 | tempBase, err := smudgeToFile(base) // Smudge base 355 | if err != nil { 356 | log.Printf("%s", err) 357 | return -1 358 | } 359 | defer os.Remove(tempBase) 360 | 361 | tempCurrent, err := smudgeToFile(current) // Smudge current 362 | if err != nil { 363 | log.Printf("%s", err) 364 | return -1 365 | } 366 | defer os.Remove(tempCurrent) 367 | 368 | tempOther, err := smudgeToFile(other) // Smudge other 369 | if err != nil { 370 | log.Printf("%s", err) 371 | return -1 372 | } 373 | defer os.Remove(tempOther) 374 | 375 | var stdOut bytes.Buffer 376 | var errOut bytes.Buffer 377 | // Run git merge-file 378 | cmd := exec.Command("git", "merge-file", 379 | "--marker-size="+markerSize, 380 | "--stdout", 381 | "-L", label1, 382 | "-L", label2, 383 | "-L", label3, 384 | tempCurrent, 385 | tempBase, 386 | tempOther) 387 | cmd.Stdout = &stdOut 388 | cmd.Stderr = &errOut 389 | 390 | // The exit value of `git merge-file` is negative on error, and the number of 391 | // conflicts otherwise (truncated to 127 if there are more than that many conflicts). 392 | // If the merge was clean, the exit value is 0. 393 | mergeErr := cmd.Run() 394 | 395 | // write merged value if produced 396 | if stdOut.Len() > 0 { 397 | if err := os.WriteFile(current, stdOut.Bytes(), 0644); err != nil { 398 | log.Printf("failed to write merged file: %s", err) 399 | return -1 400 | } 401 | } 402 | 403 | // match exit code of `git merge-file` command 404 | if mergeErr != nil { 405 | var execError *exec.ExitError 406 | if errors.As(mergeErr, &execError) { 407 | fmt.Println(errOut.String()) 408 | return execError.ExitCode() 409 | } 410 | log.Printf("git merge-file failed: %s %s", errOut.String(), mergeErr) 411 | return -1 412 | } 413 | return 0 414 | } 415 | 416 | func smudgeToFile(filename string) (string, error) { 417 | // Open the input file 418 | file, err := os.Open(filename) 419 | if err != nil { 420 | return "", fmt.Errorf("failed to open file %s: %w", filename, err) 421 | } 422 | defer file.Close() 423 | 424 | // Create a buffer to hold the processed output 425 | var buf strings.Builder 426 | smudge(file, &buf, filename) 427 | 428 | // Write the buffer content to a temporary file 429 | return createTempFile(buf.String()), nil 430 | } 431 | 432 | func createTempFile(content string) string { 433 | // Create a temporary file 434 | tmpFile, err := os.CreateTemp("", "merge-file-*.tmp") 435 | if err != nil { 436 | log.Fatalf("failed to create temporary file: %v", err) 437 | } 438 | defer tmpFile.Close() 439 | 440 | // Write the content to the file 441 | if _, err := tmpFile.WriteString(content); err != nil { 442 | log.Fatalf("failed to write to temporary file: %v", err) 443 | } 444 | 445 | return tmpFile.Name() // Return the file path 446 | } 447 | 448 | // Finds closest age recipient or siv keyid 449 | func findRecipients(filename string) ([]age.Recipient, []byte, error) { 450 | path := filepath.Dir(filename) 451 | for { 452 | if fi, err := os.Stat(path); err == nil && fi.IsDir() { 453 | ageRecipientFilename := filepath.Join(path, recipientFilename) 454 | // If we found `.strongbox_recipient` - parse it and return 455 | if keyFile, err := os.Stat(ageRecipientFilename); err == nil && !keyFile.IsDir() { 456 | recipients, err := ageFileToRecipient(ageRecipientFilename) 457 | return recipients, nil, err 458 | } 459 | // If we found `strongbox-keyid` - get the corresponding key and return it 460 | keyFilename := filepath.Join(path, ".strongbox-keyid") 461 | if keyFile, err := os.Stat(keyFilename); err == nil && !keyFile.IsDir() { 462 | key, err := sivFileToKey(keyFilename) 463 | return nil, key, err 464 | } 465 | } 466 | if path == "." { 467 | return nil, nil, fmt.Errorf("failed to find recipient or keyid for file %s", filename) 468 | } 469 | path = filepath.Dir(path) 470 | } 471 | } 472 | -------------------------------------------------------------------------------- /strongbox_local_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | const _STRONGBOX_TEST_BINARY = "strongbox-test-bin" 15 | 16 | var binaryBuilt = false 17 | 18 | func ensureStrongboxBuilt(t *testing.T) { 19 | if !binaryBuilt { 20 | // build the binary once per test run 21 | _, err := runCmd("go", "build", "-o", _STRONGBOX_TEST_BINARY, ".") 22 | require.NoError(t, err) 23 | binaryBuilt = true 24 | } 25 | } 26 | 27 | func mustRunGitCmd(t *testing.T, dir string, args ...string) string { 28 | t.Helper() 29 | out, err := runGitCmd(dir, args...) 30 | require.NoError(t, err) 31 | return out 32 | } 33 | 34 | func runGitCmd(dir string, args ...string) (string, error) { 35 | args = append([]string{"-C", dir}, args...) 36 | return runCmd("git", args...) 37 | } 38 | 39 | func runCmd(name string, args ...string) (string, error) { 40 | var stdout strings.Builder 41 | var stderr strings.Builder 42 | 43 | cmd := exec.Command(name, args...) 44 | cmd.Stdout = &stdout 45 | cmd.Stderr = &stderr 46 | 47 | err := cmd.Run() 48 | if err != nil { 49 | return stdout.String(), fmt.Errorf( 50 | "running '%s' failed: %w\nstderr: %s", 51 | strings.Join(cmd.Args, " "), 52 | err, 53 | stderr.String(), 54 | ) 55 | } 56 | 57 | return stdout.String(), nil 58 | } 59 | 60 | var gitConfigured = false 61 | 62 | func configureGit(t *testing.T, repoDir string) { 63 | t.Helper() 64 | cwd, err := os.Getwd() 65 | require.NoError(t, err) 66 | 67 | // pass a rooted path (and not just ./file) so we can run it from worktrees 68 | testBinPath := filepath.Join(cwd, _STRONGBOX_TEST_BINARY) 69 | gitConfigPath := filepath.Join(cwd, "testdata", "git-test-config") 70 | 71 | // avoid reading any configuration files outside this repo 72 | // https://git-scm.com/docs/git#Documentation/git.txt-codeGITCONFIGGLOBALcode 73 | t.Setenv("GIT_CONFIG_GLOBAL", gitConfigPath) 74 | 75 | if !gitConfigured { 76 | // we only read from the single config file, so set some identity 77 | // information 78 | mustRunGitCmd(t, repoDir, "config", "set", "--global", "user.name", "strongbox-tester") 79 | mustRunGitCmd( 80 | t, 81 | repoDir, 82 | "config", 83 | "set", 84 | "--global", 85 | "user.email", 86 | "strongbox-tester@example.com", 87 | ) 88 | 89 | // setup strongbox 90 | mustRunGitCmd( 91 | t, 92 | repoDir, 93 | "config", 94 | "set", 95 | "--global", 96 | "filter.strongbox.clean", 97 | testBinPath+" -clean %f", 98 | ) 99 | mustRunGitCmd( 100 | t, 101 | repoDir, 102 | "config", 103 | "set", 104 | "--global", 105 | "filter.strongbox.smudge", 106 | testBinPath+" -smudge %f", 107 | ) 108 | mustRunGitCmd(t, repoDir, "config", "set", "--global", "filter.strongbox.required", "true") 109 | mustRunGitCmd( 110 | t, 111 | repoDir, 112 | "config", 113 | "set", 114 | "--global", 115 | "diff.strongbox.textconv", 116 | testBinPath+" -diff", 117 | ) 118 | gitConfigured = true 119 | } 120 | } 121 | 122 | func configureStrongbox(t *testing.T, repoDir string) { 123 | // tell strongbox to use our testing keys 124 | t.Setenv("STRONGBOX_HOME", filepath.Join(repoDir, "testdata")) 125 | } 126 | 127 | func setupWorkTree(t *testing.T, name string) string { 128 | t.Helper() 129 | worktreePath := filepath.Join(t.TempDir(), name) 130 | 131 | mustRunGitCmd(t, ".", "worktree", "add", "--quiet", "--detach", worktreePath) 132 | t.Cleanup(func() { 133 | // practically, this shouldn't error. But even if it does, it's not 134 | // a big deal: the TempDir is cleaned up at the end of testing anyway, and 135 | // git eventually stops tracking worktrees on paths that don't exist 136 | if _, err := runGitCmd(".", "worktree", "remove", "--force", worktreePath); err != nil { 137 | t.Logf( 138 | "failed to remove worktree %s (you may want to manually remove it): %v", 139 | worktreePath, 140 | err, 141 | ) 142 | } 143 | }) 144 | 145 | configureGit(t, worktreePath) 146 | configureStrongbox(t, worktreePath) 147 | 148 | return worktreePath 149 | } 150 | 151 | func TestGitIntegration_Filtering(t *testing.T) { 152 | ensureStrongboxBuilt(t) 153 | repoDir := setupWorkTree(t, t.Name()) 154 | 155 | rawContent := "t0ps3cret\n" 156 | expectedEncryptedPrefix := "-----BEGIN AGE ENCRYPTED FILE-----" 157 | secretPath := filepath.Join("testdata", "secret-"+t.Name()+".txt") 158 | require.NoError( 159 | t, 160 | os.WriteFile( 161 | filepath.Join(repoDir, secretPath), 162 | []byte(rawContent), 163 | 0o400, 164 | ), 165 | ) 166 | mustRunGitCmd(t, repoDir, "add", secretPath) 167 | 168 | diff, err := runGitCmd(repoDir, "diff", "--exit-code", secretPath) 169 | require.NoError( 170 | t, 171 | err, 172 | "git detected a diff after adding the file (probably an issue with smudging and cleaning):\n%s", 173 | diff, 174 | ) 175 | mustRunGitCmd(t, repoDir, "commit", "--message", "Add a secret") 176 | 177 | worktreeContent, err := os.ReadFile(filepath.Join(repoDir, secretPath)) 178 | require.NoError(t, err) 179 | require.Equal(t, rawContent, string(worktreeContent), "file in worktree should be decrypted") 180 | require.Equal( 181 | t, 182 | expectedEncryptedPrefix, 183 | mustRunGitCmd(t, repoDir, "show", "HEAD:"+secretPath)[:len(expectedEncryptedPrefix)], 184 | "checked-in file should be encrypted", 185 | ) 186 | } 187 | -------------------------------------------------------------------------------- /strongbox_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "regexp" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/stretchr/testify/assert" 14 | yaml "gopkg.in/yaml.v2" 15 | ) 16 | 17 | var ( 18 | HOME = deriveHome() 19 | defaultRepoDir = "/tmp/test-proj/" 20 | defaultBranch = "main" 21 | ) 22 | 23 | func command(dir, name string, arg ...string) (out []byte, err error) { 24 | fmt.Printf("[%s]> %s %s\n", dir, name, strings.Join(arg, " ")) 25 | cmd := exec.Command(name, arg...) 26 | cmd.Dir = dir 27 | out, err = cmd.CombinedOutput() 28 | fmt.Println(string(out)) 29 | return 30 | } 31 | 32 | func assertCommand(t *testing.T, dir, name string, arg ...string) (out []byte) { 33 | fmt.Printf("[%s]> %s %s\n", dir, name, strings.Join(arg, " ")) 34 | out, err := command(dir, name, arg...) 35 | if err != nil { 36 | t.Fatal(string(out)) 37 | } 38 | return 39 | } 40 | 41 | func assertWriteFile(t *testing.T, filename string, data []byte, perm os.FileMode) { 42 | err := os.WriteFile(filename, data, perm) 43 | if err != nil { 44 | t.Fatal(err) 45 | } 46 | } 47 | 48 | func assertReadFile(t *testing.T, filename string) string { 49 | data, err := os.ReadFile(filename) 50 | if err != nil { 51 | t.Fatal(err) 52 | } 53 | return string(data) 54 | } 55 | 56 | func keysFromKR(t *testing.T, name string) (key, keyID string) { 57 | kr := make(map[string]interface{}) 58 | krf, err := os.ReadFile(HOME + "/.strongbox_keyring") 59 | if err != nil { 60 | t.Fatal(err) 61 | } 62 | err = yaml.Unmarshal(krf, kr) 63 | if err != nil { 64 | t.Fatal(err) 65 | } 66 | kes := kr["keyentries"].([]interface{}) 67 | 68 | for k := range kes { 69 | desc := kes[k].(map[interface{}]interface{})["description"].(string) 70 | if name == desc { 71 | return kes[k].(map[interface{}]interface{})["key"].(string), 72 | kes[k].(map[interface{}]interface{})["key-id"].(string) 73 | } 74 | } 75 | t.Fatal(fmt.Sprintf("no keyId for give desc: %s", name)) 76 | return "", "" 77 | } 78 | 79 | func recipients() [][][]byte { 80 | f, _ := os.ReadFile(HOME + "/.strongbox_identity") 81 | r := regexp.MustCompile(`(?m)^.*public key.*(age.*)$`) 82 | return r.FindAllSubmatch(f, -1) 83 | } 84 | 85 | func setupRepo(repoDir string) { 86 | out, err := command("/", "mkdir", repoDir) 87 | if err != nil { 88 | fmt.Fprintf(os.Stderr, "%v", string(out)) 89 | os.Exit(1) 90 | } 91 | out, err = command(repoDir, "git", "config", "--global", "init.defaultBranch", defaultBranch) 92 | if err != nil { 93 | fmt.Fprintf(os.Stderr, "%v", string(out)) 94 | os.Exit(1) 95 | } 96 | out, err = command(repoDir, "git", "init") 97 | if err != nil { 98 | fmt.Fprintf(os.Stderr, "%v", string(out)) 99 | os.Exit(1) 100 | } 101 | } 102 | 103 | func TestMain(m *testing.M) { 104 | out, err := command("/", "git", "config", "--global", "user.email", "you@example.com") 105 | if err != nil { 106 | fmt.Fprintf(os.Stderr, "%v", string(out)) 107 | os.Exit(1) 108 | } 109 | out, err = command("/", "git", "config", "--global", "user.name", "test") 110 | if err != nil { 111 | fmt.Fprintf(os.Stderr, "%v", string(out)) 112 | os.Exit(1) 113 | } 114 | out, err = command("/", "strongbox", "-git-config") 115 | if err != nil { 116 | fmt.Fprintf(os.Stderr, "%v", string(out)) 117 | os.Exit(1) 118 | } 119 | out, err = command("/", "strongbox", "-gen-key", "test00") 120 | if err != nil { 121 | fmt.Fprintf(os.Stderr, "%v", string(out)) 122 | os.Exit(1) 123 | } 124 | out, err = command("/", "strongbox", "-gen-identity", "ident1") 125 | if err != nil { 126 | fmt.Fprintf(os.Stderr, "%v", string(out)) 127 | os.Exit(1) 128 | } 129 | out, err = command("/", "strongbox", "-gen-identity", "ident2") 130 | if err != nil { 131 | fmt.Fprintf(os.Stderr, "%v", string(out)) 132 | os.Exit(1) 133 | } 134 | 135 | setupRepo(defaultRepoDir) 136 | 137 | os.Exit(m.Run()) 138 | } 139 | 140 | func TestMergeDriverMerge(t *testing.T) { 141 | testMergeDriver(t, "merge") 142 | } 143 | 144 | func TestMergeDriverRebase(t *testing.T) { 145 | testMergeDriver(t, "rebase") 146 | } 147 | 148 | func testMergeDriver(t *testing.T, mergeOrRebase string) { 149 | repoDir := HOME + "/test-merge-driver-" + mergeOrRebase + "/" 150 | secDir := "secrets/dir0/" 151 | secFileName := "sec0" 152 | secFilePath := secDir + secFileName 153 | secVal := "secret123wallaby" 154 | _, keyID := keysFromKR(t, "test00") 155 | ga := "secrets/* filter=strongbox diff=strongbox merge=strongbox" 156 | branch1 := "branch-1" 157 | branch2 := "branch-2" 158 | 159 | setupRepo(repoDir) 160 | 161 | assertWriteFile(t, repoDir+"/.gitattributes", []byte(ga), 0644) 162 | assertWriteFile(t, repoDir+"/.strongbox-keyid", []byte(keyID), 0644) 163 | assertCommand(t, repoDir, "mkdir", "-p", secDir) 164 | assertCommand(t, repoDir, "ls", "-a", "-l") 165 | 166 | assertWriteFile(t, repoDir+secFilePath, []byte(secVal), 0644) 167 | assertCommand(t, repoDir, "git", "add", ".") 168 | assertCommand(t, repoDir, "git", "commit", "-m", "first commit") 169 | 170 | // create branch-1 and update secret file 171 | assertCommand(t, repoDir, "git", "checkout", "-b", branch1) 172 | assertWriteFile(t, repoDir+secFilePath, []byte(secVal+branch1), 0644) 173 | assertCommand(t, repoDir, "git", "add", ".") 174 | assertCommand(t, repoDir, "git", "commit", "-m", "updated secret") 175 | 176 | assertCommand(t, repoDir, "git", "checkout", defaultBranch) 177 | 178 | // test 1: merge/rebase with conflict 179 | // create branch-2 and update secret file 180 | assertCommand(t, repoDir, "git", "checkout", "-b", branch2) 181 | assertWriteFile(t, repoDir+secFilePath, []byte(secVal+branch2), 0644) 182 | assertCommand(t, repoDir, "git", "add", ".") 183 | assertCommand(t, repoDir, "git", "commit", "-m", "updated secret") 184 | 185 | // while on branch2 try merge/rebase branch1 186 | command(repoDir, "git", mergeOrRebase, branch1) 187 | 188 | out, _ := command(repoDir, "cat", secFilePath) 189 | if mergeOrRebase == "merge" { 190 | assert.Equal(t, string(out), "<<<<<<< HEAD\n"+secVal+branch2+"\n=======\n"+secVal+branch1+"\n>>>>>>> "+branch1+"\n") 191 | } else { 192 | assert.Contains(t, string(out), "<<<<<<< HEAD\n"+secVal+branch1+"\n=======\n"+secVal+branch2+"\n>>>>>>> ") 193 | } 194 | command(repoDir, "git", mergeOrRebase, "--abort") 195 | 196 | // test 2: merge/rebase without conflict 197 | command(repoDir, "git", "checkout", defaultBranch) 198 | command(repoDir, "git", "branch", "-D", branch2) 199 | // update secret same as branch1 to avoid conflict 200 | assertWriteFile(t, repoDir+secFilePath, []byte(secVal+branch1), 0644) 201 | assertCommand(t, repoDir, "git", "add", ".") 202 | assertCommand(t, repoDir, "git", "commit", "-m", "updated secret") 203 | 204 | assertCommand(t, repoDir, "git", "checkout", "-b", branch2) 205 | assertWriteFile(t, repoDir+secFilePath, []byte(secVal+branch1+"\n"+secVal+branch2), 0644) 206 | assertCommand(t, repoDir, "git", "add", ".") 207 | assertCommand(t, repoDir, "git", "commit", "-m", "updated secret") 208 | 209 | // while on branch2 try merge/rebase branch1 210 | command(repoDir, "git", mergeOrRebase, branch1) 211 | out, _ = command(repoDir, "cat", secFilePath) 212 | assert.Equal(t, string(out), secVal+branch1+"\n"+secVal+branch2) 213 | } 214 | 215 | func TestSimpleEnc(t *testing.T) { 216 | repoDir := defaultRepoDir 217 | _, keyID := keysFromKR(t, "test00") 218 | secVal := "secret123wombat" 219 | 220 | ga := `secret filter=strongbox diff=strongbox 221 | secrets/* filter=strongbox diff=strongbox` 222 | assertWriteFile(t, repoDir+"/.gitattributes", []byte(ga), 0644) 223 | assertWriteFile(t, repoDir+"/.strongbox-keyid", []byte(keyID), 0644) 224 | assertWriteFile(t, repoDir+"/secret", []byte(secVal), 0644) 225 | assertCommand(t, repoDir, "git", "add", ".") 226 | assertCommand(t, repoDir, "git", "commit", "-m", "\"TestSimpleEnc\"") 227 | ptOut, _ := command(repoDir, "git", "show", "--", "secret") 228 | encOut, _ := command(repoDir, "git", "show", "HEAD:secret") 229 | 230 | assert.Contains(t, string(ptOut), secVal, "no plaintext") 231 | assert.Contains(t, string(encOut), "STRONGBOX ENCRYPTED RESOURCE", "no plaintext") 232 | } 233 | 234 | func TestNestedEnc(t *testing.T) { 235 | repoDir := defaultRepoDir 236 | secVal := "secret123croc" 237 | 238 | assertCommand(t, repoDir, "mkdir", "-p", "secrets/dir0") 239 | assertWriteFile(t, repoDir+"/secrets/dir0/sec0", []byte(secVal), 0644) 240 | 241 | assertCommand(t, repoDir, "git", "add", ".") 242 | assertCommand(t, repoDir, "git", "commit", "-m", "\"TestNestedEnc\"") 243 | 244 | ptOut, _ := command(repoDir, "git", "show") 245 | encOut, _ := command(repoDir, "git", "show", "HEAD:secret") 246 | 247 | assert.Contains(t, string(ptOut), secVal, "no plaintext") 248 | assert.Contains(t, string(encOut), "STRONGBOX ENCRYPTED RESOURCE", "no plaintext") 249 | } 250 | 251 | func TestMissingKey(t *testing.T) { 252 | repoDir := defaultRepoDir 253 | secVal := "secret-missing-key" 254 | 255 | // remove the key for encryption 256 | assertCommand(t, "/", "mv", HOME+"/.strongbox_keyring", HOME+"/.strongbox_keyring.bkup") 257 | 258 | assertCommand(t, "/", "strongbox", "-gen-key", "tmp") 259 | 260 | assertWriteFile(t, repoDir+"/secrets/sec-missing-key", []byte(secVal), 0644) 261 | _, err := command(repoDir, "git", "add", ".") 262 | assert.Error(t, err, "Should error on add attempt") 263 | 264 | // clean up 265 | assertCommand(t, "/", "mv", HOME+"/.strongbox_keyring.bkup", HOME+"/.strongbox_keyring") 266 | 267 | // as the correct is now present, should not error and present untracked changes 268 | assertCommand(t, repoDir, "git", "status") 269 | 270 | // remove the file 271 | assertCommand(t, "/", "rm", repoDir+"/secrets/sec-missing-key") 272 | } 273 | 274 | func TestRecursiveDecryption(t *testing.T) { 275 | repoDir := HOME + "/test-rec-dec" 276 | 277 | assertCommand(t, "/", "mkdir", "-p", repoDir+"/secrets/") 278 | assertCommand(t, "/", "mkdir", "-p", repoDir+"/app/secrets/") 279 | 280 | assertCommand(t, repoDir, "git", "init") 281 | 282 | // generate new private keys 283 | assertCommand(t, "/", "strongbox", "-gen-key", "rec-dec-01") 284 | assertCommand(t, "/", "strongbox", "-gen-key", "rec-dec-02") 285 | 286 | pKey1, keyID1 := keysFromKR(t, "rec-dec-01") 287 | pKey2, keyID2 := keysFromKR(t, "rec-dec-02") 288 | 289 | secVal := "secret123wombat" 290 | 291 | ga := `secret filter=strongbox diff=strongbox 292 | secrets/* filter=strongbox diff=strongbox 293 | */secrets/* filter=strongbox diff=strongbox` 294 | 295 | // setup root keyID and nested app folder with different keyID 296 | assertWriteFile(t, repoDir+"/.gitattributes", []byte(ga), 0644) 297 | assertWriteFile(t, repoDir+"/.strongbox-keyid", []byte(keyID1), 0644) 298 | assertWriteFile(t, repoDir+"/app/.strongbox-keyid", []byte(keyID2), 0644) 299 | 300 | // Write plan secrets 301 | assertWriteFile(t, repoDir+"/secret", []byte(secVal+"01"), 0644) 302 | assertWriteFile(t, repoDir+"/secrets/s2", []byte(secVal+"02"), 0644) 303 | assertWriteFile(t, repoDir+"/app/secrets/s3", []byte(secVal+"03"), 0644) 304 | 305 | // set test dir as Home because git command will use default strongbox key 306 | assertCommand(t, repoDir, "git", "add", ".") 307 | assertCommand(t, repoDir, "git", "commit", "-m", "\"TestSimpleEnc\"") 308 | 309 | // Make sure files are encrypted 310 | ptOut01, _ := command(repoDir, "git", "show", "--", "secret") 311 | encOut01, _ := command(repoDir, "git", "show", "HEAD:secret") 312 | assert.Contains(t, string(ptOut01), secVal+"01", "should be in plain text") 313 | assert.Contains(t, string(encOut01), "STRONGBOX ENCRYPTED RESOURCE", "should be encrypted") 314 | 315 | ptOut02, _ := command(repoDir, "git", "show", "--", "secrets/s2") 316 | encOut02, _ := command(repoDir, "git", "show", "HEAD:secrets/s2") 317 | assert.Contains(t, string(ptOut02), secVal+"02", "should be in plain text") 318 | assert.Contains(t, string(encOut02), "STRONGBOX ENCRYPTED RESOURCE", "should be encrypted") 319 | 320 | ptOut03, _ := command(repoDir, "git", "show", "--", "app/secrets/s3") 321 | encOut03, _ := command(repoDir, "git", "show", "HEAD:app/secrets/s3") 322 | assert.Contains(t, string(ptOut03), secVal+"03", "should be in plain text") 323 | assert.Contains(t, string(encOut03), "STRONGBOX ENCRYPTED RESOURCE", "should be encrypted") 324 | 325 | // TEST 1 (using default keyring file location) 326 | //override local file with encrypted content 327 | assertWriteFile(t, repoDir+"/secret", encOut01, 0644) 328 | assertWriteFile(t, repoDir+"/secrets/s2", encOut02, 0644) 329 | assertWriteFile(t, repoDir+"/app/secrets/s3", encOut03, 0644) 330 | 331 | // run command from the root of the target folder without path arg 332 | assertCommand(t, repoDir, "strongbox", "-decrypt", "-recursive") 333 | 334 | // make sure all files are decrypted 335 | assert.Contains(t, assertReadFile(t, repoDir+"/secret"), secVal+"01", "should be in plain text") 336 | assert.Contains(t, assertReadFile(t, repoDir+"/secrets/s2"), secVal+"02", "should be in plain text") 337 | assert.Contains(t, assertReadFile(t, repoDir+"/app/secrets/s3"), secVal+"03", "should be in plain text") 338 | 339 | // TEST 2 (using custom keyring file location) 340 | // override local file with encrypted content 341 | assertWriteFile(t, repoDir+"/secret", encOut01, 0644) 342 | assertWriteFile(t, repoDir+"/secrets/s2", encOut02, 0644) 343 | assertWriteFile(t, repoDir+"/app/secrets/s3", encOut03, 0644) 344 | 345 | keyRingPath := repoDir + "/.keyring" 346 | // move keyring file 347 | assertCommand(t, "/", "mv", HOME+"/.strongbox_keyring", keyRingPath) 348 | // run command from outside of the target folder 349 | assertCommand(t, "/", "strongbox", "-keyring", keyRingPath, "-decrypt", "-recursive", repoDir) 350 | 351 | // make sure all files are decrypted 352 | assert.Contains(t, assertReadFile(t, repoDir+"/secret"), secVal+"01", "should be in plain text") 353 | assert.Contains(t, assertReadFile(t, repoDir+"/secrets/s2"), secVal+"02", "should be in plain text") 354 | assert.Contains(t, assertReadFile(t, repoDir+"/app/secrets/s3"), secVal+"03", "should be in plain text") 355 | 356 | // TEST 3.1 (using given private key) 357 | // override local file with encrypted content 358 | assertWriteFile(t, repoDir+"/secret", encOut01, 0644) 359 | assertWriteFile(t, repoDir+"/secrets/s2", encOut02, 0644) 360 | assertWriteFile(t, repoDir+"/app/secrets/s3", encOut03, 0644) 361 | 362 | //since rec-dec-01 is not used to encrypt app folders secret so expect error 363 | command(repoDir, "strongbox", "-key", pKey1, "-decrypt", "-recursive", ".") 364 | 365 | assert.Contains(t, assertReadFile(t, repoDir+"/secret"), secVal+"01", "should be in plain text") 366 | assert.Contains(t, assertReadFile(t, repoDir+"/secrets/s2"), secVal+"02", "should be in plain text") 367 | assert.Contains(t, assertReadFile(t, repoDir+"/app/secrets/s3"), "STRONGBOX ENCRYPTED RESOURCE", "should be encrypted") 368 | 369 | // TEST 3.2 (using custom keyring file location) 370 | // override local file with encrypted content 371 | assertWriteFile(t, repoDir+"/secret", encOut01, 0644) 372 | assertWriteFile(t, repoDir+"/secrets/s2", encOut02, 0644) 373 | assertWriteFile(t, repoDir+"/app/secrets/s3", encOut03, 0644) 374 | 375 | //since rec-dec-02 is not used to encrypt root folders secrets so expect error 376 | command(repoDir, "strongbox", "-key", pKey2, "-decrypt", "-recursive", ".") 377 | 378 | assert.Contains(t, assertReadFile(t, repoDir+"/secret"), "STRONGBOX ENCRYPTED RESOURCE", "should be encrypted") 379 | assert.Contains(t, assertReadFile(t, repoDir+"/secrets/s2"), "STRONGBOX ENCRYPTED RESOURCE", "should be encrypted") 380 | assert.Contains(t, assertReadFile(t, repoDir+"/app/secrets/s3"), secVal+"03", "should be in plain text") 381 | } 382 | 383 | func TestAgeEnc(t *testing.T) { 384 | repoDir := defaultRepoDir 385 | secVal := "age_secret1" 386 | 387 | assertCommand(t, repoDir, "mkdir", "-p", "age/secrets") 388 | 389 | assertWriteFile(t, repoDir+"/age/.gitattributes", []byte(`secrets/* filter=strongbox diff=strongbox`), 0644) 390 | 391 | assertWriteFile(t, repoDir+"/.strongbox_recipient", recipients()[0][1], 0644) 392 | 393 | assertWriteFile(t, repoDir+"/age/secrets/secret", []byte(secVal), 0644) 394 | 395 | assertCommand(t, repoDir, "git", "add", ".") 396 | assertCommand(t, repoDir, "git", "commit", "-m", "\"TestAgeEnc\"") 397 | 398 | ptOut, _ := command(repoDir, "git", "show") 399 | encOut, _ := command(repoDir, "git", "show", "HEAD:age/secrets/secret") 400 | 401 | assert.Contains(t, string(ptOut), secVal, "no plaintext") 402 | assert.Contains(t, string(encOut), "-----BEGIN AGE ENCRYPTED FILE-----", "missing age header") 403 | } 404 | 405 | func TestAgeKeyUpdate(t *testing.T) { 406 | repoDir := defaultRepoDir 407 | 408 | // update recipient 409 | assertWriteFile(t, repoDir+"/.strongbox_recipient", recipients()[1][1], 0644) 410 | 411 | assertCommand(t, repoDir, "touch", "age/secrets/secret") 412 | assertCommand(t, repoDir, "git", "add", ".") 413 | assertCommand(t, repoDir, "git", "commit", "-m", "\"TestAgeKeyUpdate\"") 414 | 415 | encOut, _ := command(repoDir, "git", "show", "HEAD:age/secrets/secret") 416 | encOutPrevCommit, _ := command(repoDir, "git", "show", "HEAD^1:age/secrets/secret") 417 | 418 | assert.Contains(t, string(encOut), "-----BEGIN AGE ENCRYPTED FILE-----", "missing age header") 419 | assert.NotEqual(t, string(encOut), string(encOutPrevCommit), "cipher text hasn't changed") 420 | } 421 | 422 | func TestAgeSimulateDeterministic(t *testing.T) { 423 | repoDir := defaultRepoDir 424 | 425 | assertCommand(t, repoDir, "touch", "age/secrets/secret") 426 | 427 | status, _ := command(repoDir, "git", "status") 428 | 429 | assert.NotContains(t, string(status), "age/secrets/secret", "secret file showing up in diff") 430 | } 431 | -------------------------------------------------------------------------------- /testdata/.gitattributes: -------------------------------------------------------------------------------- 1 | /secret*.txt diff=strongbox filter=strongbox 2 | -------------------------------------------------------------------------------- /testdata/.strongbox_identity: -------------------------------------------------------------------------------- 1 | # description: test-key 2 | # public key: age1q4tfxy9645k4mtzk0v6w5lgn5m9dav0y4wy0fk0crxxve7q08utsxa8wpl 3 | AGE-SECRET-KEY-1EMJF0QD9M8RZ5XKRJVSZF6U70VGZCZDKA5RCQNHFQR2F3PUAPMRQ7FXA9H 4 | -------------------------------------------------------------------------------- /testdata/.strongbox_recipient: -------------------------------------------------------------------------------- 1 | age1q4tfxy9645k4mtzk0v6w5lgn5m9dav0y4wy0fk0crxxve7q08utsxa8wpl 2 | --------------------------------------------------------------------------------