├── .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 | 
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 |
--------------------------------------------------------------------------------