├── .github ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ └── go.yml ├── .gitignore ├── BUG-BOUNTY.md ├── CONTRIBUTING.md ├── Dockerfile-test ├── LICENSE.txt ├── README.md ├── TODO ├── api.go ├── api_test.go ├── backup ├── backup.go ├── crypto.go ├── crypto_test.go ├── tar.go ├── tar_test.go └── testdata │ ├── alice.txt │ └── bob │ └── bob.txt ├── bundle.go ├── bundle_test.go ├── client.go ├── client_test.go ├── cmd ├── keyrestore │ ├── README.md │ └── keyrestore.go ├── keysync │ ├── README.md │ └── keysync.go ├── keyunwrap │ ├── README.md │ └── keyunwrap.go └── monitor │ ├── checks.go │ ├── checks_test.go │ ├── email.go │ ├── fixtures_test.go │ ├── main.go │ └── main_test.go ├── config.go ├── config_test.go ├── fixtures ├── CA │ ├── cacert.crt │ ├── cacert.key │ └── localhost.crt ├── README ├── clients │ ├── abscert.yaml │ ├── client1.crt │ ├── client1.key │ ├── client1.yaml │ ├── client2-3.yaml │ ├── client2.crt │ ├── client2.key │ ├── client3.crt │ ├── client3.key │ ├── client4.crt │ ├── client4.key │ ├── client4.yaml │ ├── missingcert.yaml │ └── owners.yaml ├── configs │ ├── errorconfigs │ │ ├── absolutecert-config.yaml │ │ ├── missing-secrets-dir-config.yaml │ │ ├── missingkey-config.yaml │ │ ├── nonexistent-ca-file-config.yaml │ │ ├── nonexistent-client-dir-config.yaml │ │ ├── notyaml-client-config.yaml │ │ └── notyaml-test-config.yaml │ └── test-config.yaml ├── errorclients │ ├── missingkey │ │ └── missingkey.yaml │ └── notyaml │ │ └── notyaml.yaml ├── exportedSecretsBackupBundle.json ├── generate.sh ├── secretNormalOwner.json ├── secretWithoutBase64Padding.json ├── secret_General_Password.json ├── secret_Nobody_PgPass.json ├── secrets.json ├── secretsWithBadFilenameOverride.json └── secretsWithoutContent.json ├── go.mod ├── go.sum ├── keysync-sample-config.yaml ├── output └── write.go ├── ownership ├── lookup.go ├── lookup_test.go ├── mock.go ├── ownership.go └── ownership_test.go ├── secret.go ├── secret_test.go ├── syncer.go ├── syncer_test.go ├── testing ├── cacert.crt ├── clients │ ├── client.pem │ └── client.yaml ├── expected │ ├── content │ │ ├── Database_Password │ │ ├── General_Password │ │ ├── Nobody_PgPass │ │ └── NonexistentOwner_Pass │ └── ownership │ │ ├── Database_Password │ │ ├── General_Password │ │ ├── Nobody_PgPass │ │ └── NonexistentOwner_Pass ├── keysync-backup.key ├── keysync-config.yaml ├── keywhiz-config.yaml ├── keywhiz-server.jar ├── lib-signed │ └── bcprov-jdk15on.jar ├── resources │ ├── derivation.jceks │ ├── dev_and_test.crl │ ├── dev_and_test_keystore.p12 │ └── dev_and_test_truststore.p12 └── run-tests.sh ├── util_test.go ├── write.go └── write_test.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: github.com/sirupsen/logrus 10 | versions: 11 | - 1.7.1 12 | - 1.8.0 13 | -------------------------------------------------------------------------------- /.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: '34 16 * * 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', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v2 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v2 71 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | 7 | build: 8 | name: Build 9 | runs-on: ubuntu-latest 10 | steps: 11 | 12 | - name: Set up Go 13 | uses: actions/setup-go@v1 14 | with: 15 | go-version: 1.18 16 | id: go 17 | 18 | - name: Check out code into the Go module directory 19 | uses: actions/checkout@v2 20 | 21 | - name: Get dependencies 22 | run: go mod download 23 | 24 | - name: Run tests 25 | run: go test -v ./... 26 | 27 | - name: Build binaries 28 | run: go build -o . ./... 29 | 30 | lint: 31 | name: Lint 32 | runs-on: ubuntu-latest 33 | steps: 34 | 35 | - name: Check out code into the Go module directory 36 | uses: actions/checkout@v2 37 | 38 | - name: Run golangci-lint 39 | uses: golangci/golangci-lint-action@v2 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | fixtures/clients/client[1-4] 2 | testing/secrets 3 | /keysync 4 | /cmd/keysync/keysync 5 | /keyrestore 6 | /cmd/keyrestore/keyrestore 7 | /cmd/keyunwrap/keyunwrap 8 | -------------------------------------------------------------------------------- /BUG-BOUNTY.md: -------------------------------------------------------------------------------- 1 | Serious about security 2 | ====================== 3 | 4 | Square recognizes the important contributions the security research community 5 | can make. We therefore encourage reporting security issues with the code 6 | contained in this repository. 7 | 8 | If you believe you have discovered a security vulnerability, please follow the 9 | guidelines at . 10 | 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | We appreciate your desire to contribute code to this repo. You may do so 5 | through GitHub by forking the repository and sending a pull request. 6 | 7 | When submitting code, please make every effort to follow existing conventions 8 | and style in order to keep the code as readable as possible. Please also make 9 | sure all tests pass by running `go test ./...`, and format your code with `go fmt`. 10 | We also recommend using `golint`. 11 | 12 | Before your code can be accepted into the project you must also sign the 13 | Individual Contributor License Agreement. We use [cla-assistant.io][1] and you 14 | will be prompted to sign once a pull request is opened. 15 | 16 | [1]: https://cla-assistant.io/ 17 | -------------------------------------------------------------------------------- /Dockerfile-test: -------------------------------------------------------------------------------- 1 | FROM openjdk:8-jre-alpine 2 | 3 | RUN apk add --update bash go gcc git musl-dev diffutils util-linux coreutils curl && \ 4 | rm -rf /var/cache/apk/* 5 | 6 | COPY testing /opt/keysync/testing/ 7 | 8 | RUN adduser -S keysync-test && \ 9 | addgroup -S keysync-test && \ 10 | java -jar /opt/keysync/testing/keywhiz-server.jar migrate /opt/keysync/testing/keywhiz-config.yaml && \ 11 | java -jar /opt/keysync/testing/keywhiz-server.jar db-seed /opt/keysync/testing/keywhiz-config.yaml 12 | 13 | ENV GO111MODULE on 14 | COPY go.mod /opt/keysync 15 | COPY go.sum /opt/keysync 16 | WORKDIR /opt/keysync 17 | RUN go mod download 18 | 19 | COPY . /opt/keysync 20 | 21 | WORKDIR /opt/keysync/cmd/keysync 22 | RUN go build -o /usr/bin/keysync 23 | 24 | WORKDIR /opt/keysync/cmd/keyrestore 25 | RUN go build -o /usr/bin/keyrestore 26 | 27 | WORKDIR /opt/keysync/cmd/keyunwrap 28 | RUN go build -o /usr/bin/keyunwrap 29 | 30 | CMD /opt/keysync/testing/run-tests.sh 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deprecated 2 | **As of 9/18/23 this project is now deprecated and no longer maintained; we recommend using HashiCorp Vault as a more robust and actively supported alternative.** 3 | 4 | Keysync 5 | ------- 6 | 7 | [![license](https://img.shields.io/badge/license-apache_2.0-blue.svg?style=flat)](https://raw.githubusercontent.com/square/keysync/master/LICENSE.txt) 8 | [![report](https://goreportcard.com/badge/github.com/square/keysync)](https://goreportcard.com/report/github.com/square/keysync) 9 | 10 | Keysync is a production-ready program for accessing secrets in [Keywhiz](https://github.com/square/keywhiz). 11 | 12 | It is a replacement for the now-deprecated FUSE-based [keywhiz-fs](https://github.com/square/keywhiz-fs). 13 | 14 | ## Getting Started 15 | 16 | ### Building 17 | 18 | Keysync must be built with Go 1.11+. You can build keysync from source: 19 | 20 | ``` 21 | $ git clone https://github.com/square/keysync 22 | $ cd keysync 23 | $ go build github.com/square/keysync/cmd/keysync 24 | ``` 25 | 26 | This will generate a binary called `./keysync` 27 | 28 | #### Dependencies 29 | 30 | Keysync uses Go modules to manage dependencies. If you've cloned the repo into `GOPATH`, you should export `GO111MODULE=on` before running any `go` commands. All deps should be automatically fetched when using `go build` and `go test`. Add `go mod tidy` before committing. 31 | 32 | ### Testing 33 | 34 | Entire test suite: 35 | 36 | ``` 37 | go test ./... 38 | ``` 39 | 40 | Short, unit tests only: 41 | 42 | ``` 43 | go test -short ./... 44 | ``` 45 | 46 | ### Running locally 47 | 48 | Keysync requires access to Keywhiz to work properly. Assuming you run Keywhiz locally on default port (4444), you can start keysync with: 49 | 50 | ``` 51 | ./keysync --config keysync-config.yaml 52 | ``` 53 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | * Status checks that check something 2 | * Logging & Sentries on failure/success 3 | * Metrics 4 | * Backoff / throttling 5 | * Randomized interval 6 | * Optimization: Don't reload secret contents if unchanged (reduce server load) 7 | * Optimization: Share secret content with hardlink if possible (reduce tmpfs RAM usage) 8 | * Tamper detection of files 9 | * self-sandboxing with namespaces & cap_chown 10 | * `sudo unshare --mount become keysync ./keysync?` 11 | * Unit tests - normal go unit tests 12 | * Functional tests - against a test server 13 | * Integration tests - against a real keywhiz server 14 | * Factor out common client library 15 | * Kill dead code imported 16 | * Refactor & cleanup, move code into a `package keysync` so it can be reused. 17 | * Shutdown handling of some sort (catch signal, log, exit) 18 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package keysync 16 | 17 | import ( 18 | "encoding/json" 19 | "errors" 20 | "fmt" 21 | "net/http" 22 | "net/http/pprof" 23 | "strings" 24 | "time" 25 | 26 | "github.com/square/keysync/backup" 27 | 28 | "github.com/gorilla/mux" 29 | "github.com/sirupsen/logrus" 30 | sqmetrics "github.com/square/go-sq-metrics" 31 | ) 32 | 33 | var ( 34 | httpPost = []string{"POST"} 35 | httpGet = []string{"HEAD", "GET"} 36 | ) 37 | 38 | const ( 39 | pollIntervalFailureThresholdMultiplier = 10 40 | ) 41 | 42 | // APIServer holds state needed for responding to HTTP api requests 43 | type APIServer struct { 44 | backup backup.Backup 45 | syncer *Syncer 46 | logger *logrus.Entry 47 | } 48 | 49 | // StatusResponse from API endpoints 50 | type StatusResponse struct { 51 | Ok bool `json:"ok"` 52 | Message string `json:"message,omitempty"` 53 | Updated *Updated `json:"updated,omitempty"` 54 | } 55 | 56 | func writeSuccess(w http.ResponseWriter, updated *Updated) { 57 | resp := &StatusResponse{Ok: true, Updated: updated} 58 | out, _ := json.MarshalIndent(resp, "", " ") 59 | w.WriteHeader(http.StatusOK) 60 | _, _ = w.Write(out) 61 | _, _ = w.Write([]byte("\n")) 62 | } 63 | 64 | func writeError(w http.ResponseWriter, status int, err error) { 65 | resp := &StatusResponse{Ok: false, Message: err.Error()} 66 | out, _ := json.MarshalIndent(resp, "", "") 67 | w.WriteHeader(status) 68 | _, _ = w.Write(out) 69 | _, _ = w.Write([]byte("\n")) 70 | } 71 | 72 | func (a *APIServer) syncAll(w http.ResponseWriter, r *http.Request) { 73 | a.logger.Info("Syncing all from API") 74 | updated, errs := a.syncer.RunOnce() 75 | if len(errs) != 0 { 76 | err := fmt.Errorf("errors: %v", errs) 77 | a.logger.WithError(err).Warn("error syncing") 78 | writeError(w, http.StatusInternalServerError, err) 79 | return 80 | } 81 | 82 | writeSuccess(w, &updated) 83 | } 84 | 85 | func (a *APIServer) syncOne(w http.ResponseWriter, r *http.Request) { 86 | client, hasClient := mux.Vars(r)["client"] 87 | if !hasClient || client == "" { 88 | // Should be unreachable 89 | a.logger.Info("Invalid request: No client provided.") 90 | writeError(w, http.StatusBadRequest, errors.New("invalid request: no client provided")) 91 | return 92 | } 93 | 94 | // Sanitize the user-controlled client string for logging to prevent log message forgeries. 95 | sanitizedClient := strings.ReplaceAll(client, "\n", "") 96 | sanitizedClient = strings.ReplaceAll(sanitizedClient, "\r", "") 97 | 98 | logger := a.logger.WithField("client", sanitizedClient) 99 | logger.Info("Syncing one") 100 | a.syncer.syncMutex.Lock() 101 | defer a.syncer.syncMutex.Unlock() 102 | 103 | pendingCleanup, err := a.syncer.LoadClients() 104 | if err != nil { 105 | logger.WithError(err).Warn("Failed while loading clients") 106 | writeError(w, http.StatusInternalServerError, fmt.Errorf("failed while loading clients: %s", err)) 107 | return 108 | } 109 | // We do this in a defer because we want it to run regardless of which of the 110 | // below cases we end up in. 111 | defer pendingCleanup.cleanup(a.logger) 112 | 113 | var updated Updated 114 | if syncerEntry, ok := a.syncer.clients[client]; ok { 115 | updated, err = syncerEntry.Sync() 116 | if err != nil { 117 | logger.WithError(err).Warnf("Error syncing %s", sanitizedClient) 118 | writeError(w, http.StatusInternalServerError, fmt.Errorf("error syncing %s: %s", sanitizedClient, err)) 119 | return 120 | } 121 | } else if _, pending := pendingCleanup.Outputs[client]; !pending { 122 | // If it's not a current client, or one pending cleanup, return an error 123 | logger.Infof("Unknown client: %s", sanitizedClient) 124 | writeError(w, http.StatusNotFound, fmt.Errorf("unknown client: %s", sanitizedClient)) 125 | return 126 | } 127 | 128 | logger.WithFields(logrus.Fields{ 129 | "Added": updated.Added, 130 | "Changed": updated.Changed, 131 | "Deleted": updated.Deleted, 132 | }).Info("API requested sync complete") 133 | 134 | writeSuccess(w, &updated) 135 | } 136 | 137 | func (a *APIServer) runBackup(w http.ResponseWriter, r *http.Request) { 138 | if a.backup == nil { 139 | writeError(w, http.StatusServiceUnavailable, errors.New("Backups not configured")) 140 | return 141 | } 142 | 143 | if err := a.backup.Backup(); err != nil { 144 | writeError(w, http.StatusInternalServerError, err) 145 | } else { 146 | writeSuccess(w, nil) 147 | } 148 | } 149 | 150 | func (a *APIServer) status(w http.ResponseWriter, r *http.Request) { 151 | lastSuccess, ok := a.syncer.timeSinceLastSuccess() 152 | if !ok { 153 | writeError(w, http.StatusServiceUnavailable, errors.New("initial sync has not yet completed")) 154 | return 155 | } 156 | 157 | failureThreshold := a.syncer.pollInterval * pollIntervalFailureThresholdMultiplier 158 | if lastSuccess > failureThreshold { 159 | err := a.syncer.mostRecentError() 160 | writeError(w, http.StatusServiceUnavailable, fmt.Errorf("haven't synced in over %d seconds (most recent err: %s)", int64(lastSuccess/time.Second), err)) 161 | return 162 | } 163 | 164 | writeSuccess(w, nil) 165 | } 166 | 167 | // handle wraps the HandlerFunc with logging, and registers it in the given router. 168 | func handle(router *mux.Router, path string, methods []string, fn http.HandlerFunc, logger *logrus.Entry) { 169 | wrapped := func(w http.ResponseWriter, r *http.Request) { 170 | start := time.Now() 171 | fn(w, r) 172 | // Sanitize the URL string logging to prevent log message forgeries. 173 | sanitizedURL := strings.ReplaceAll(r.URL.String(), "\n", "") 174 | sanitizedURL = strings.ReplaceAll(sanitizedURL, "\r", "") 175 | logger.WithFields(logrus.Fields{ 176 | "url": sanitizedURL, 177 | "duration": time.Since(start), 178 | }).Info("Request") 179 | } 180 | router.HandleFunc(path, wrapped).Methods(methods...) 181 | } 182 | 183 | // NewAPIServer is the constructor for an APIServer 184 | func NewAPIServer(syncer *Syncer, backup backup.Backup, port uint16, baseLogger *logrus.Entry, metrics *sqmetrics.SquareMetrics) { 185 | logger := baseLogger.WithField("logger", "api_server") 186 | apiServer := APIServer{syncer: syncer, logger: logger, backup: backup} 187 | router := mux.NewRouter() 188 | 189 | // Debug endpoints 190 | handle(router, "/debug/pprof", httpGet, pprof.Index, logger) 191 | handle(router, "/debug/pprof/cmdline", httpGet, pprof.Cmdline, logger) 192 | handle(router, "/debug/pprof/profile", httpGet, pprof.Profile, logger) 193 | handle(router, "/debug/pprof/symbol", httpGet, pprof.Symbol, logger) 194 | 195 | // Sync endpoints 196 | handle(router, "/sync", httpPost, apiServer.syncAll, logger) 197 | handle(router, "/sync/{client}", httpPost, apiServer.syncOne, logger) 198 | 199 | // Create backup 200 | handle(router, "/backup", httpPost, apiServer.runBackup, logger) 201 | 202 | // Status and metrics endpoints 203 | router.HandleFunc("/status", apiServer.status).Methods(httpGet...) 204 | handle(router, "/metrics", httpGet, metrics.ServeHTTP, logger) 205 | 206 | go func() { 207 | err := http.ListenAndServe(fmt.Sprintf("localhost:%d", port), router) 208 | logger.WithError(err).WithField("port", port).Error("Listen and Serve") 209 | }() 210 | } 211 | -------------------------------------------------------------------------------- /api_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package keysync 16 | 17 | import ( 18 | "encoding/json" 19 | "fmt" 20 | "io/ioutil" 21 | "net" 22 | "net/http" 23 | "testing" 24 | "time" 25 | 26 | "github.com/square/keysync/backup" 27 | 28 | "github.com/sirupsen/logrus" 29 | "github.com/stretchr/testify/assert" 30 | "github.com/stretchr/testify/require" 31 | ) 32 | 33 | func randomPort() uint16 { 34 | addr, err := net.ResolveTCPAddr("tcp", "localhost:0") 35 | panicOnError(err) 36 | 37 | listener, err := net.ListenTCP("tcp", addr) 38 | panicOnError(err) 39 | 40 | port := listener.Addr().(*net.TCPAddr).Port 41 | listener.Close() 42 | 43 | return uint16(port) 44 | } 45 | 46 | func waitForServer(t *testing.T, port uint16) { 47 | for i := 0; i < 10; i++ { 48 | req, err := http.NewRequest("GET", fmt.Sprintf("http://localhost:%d/status", port), nil) 49 | if err != nil { 50 | t.Fatal("error building request?") 51 | } 52 | 53 | res, err := http.DefaultClient.Do(req) 54 | if err != nil || res.StatusCode != http.StatusOK { 55 | time.Sleep(1 * time.Second) 56 | continue 57 | } 58 | } 59 | } 60 | 61 | func TestApiSyncAllAndSyncClientSuccess(t *testing.T) { 62 | if testing.Short() { 63 | t.Skip("Skipping API test in short mode.") 64 | } 65 | 66 | port := randomPort() 67 | 68 | server := createDefaultServerWithDeletionRace() 69 | defer server.Close() 70 | 71 | // Load a test config 72 | syncer, err := createNewSyncer("fixtures/configs/test-config.yaml", server) 73 | require.Nil(t, err) 74 | 75 | NewAPIServer(syncer, nil, port, logrus.NewEntry(logrus.New()), metricsForTest()) 76 | waitForServer(t, port) 77 | 78 | // Test SyncAll success 79 | req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/sync", port), nil) 80 | require.Nil(t, err) 81 | 82 | res, err := http.DefaultClient.Do(req) 83 | require.Nil(t, err) 84 | 85 | data, _ := ioutil.ReadAll(res.Body) 86 | require.Nil(t, err) 87 | 88 | status := StatusResponse{} 89 | err = json.Unmarshal(data, &status) 90 | require.Nil(t, err) 91 | require.True(t, status.Ok) 92 | 93 | // Test SyncClientsuccess 94 | req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/sync/client1", port), nil) 95 | require.Nil(t, err) 96 | 97 | res, err = http.DefaultClient.Do(req) 98 | require.Nil(t, err) 99 | assert.Equal(t, http.StatusOK, res.StatusCode) 100 | 101 | data, _ = ioutil.ReadAll(res.Body) 102 | require.Nil(t, err) 103 | 104 | status = StatusResponse{} 105 | err = json.Unmarshal(data, &status) 106 | require.Nil(t, err) 107 | require.True(t, status.Ok) 108 | 109 | // Test SyncClient failure on nonexistent client 110 | req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/sync/non-existent", port), nil) 111 | require.Nil(t, err) 112 | 113 | res, err = http.DefaultClient.Do(req) 114 | require.Nil(t, err) 115 | assert.Equal(t, http.StatusNotFound, res.StatusCode) 116 | 117 | data, _ = ioutil.ReadAll(res.Body) 118 | require.Nil(t, err) 119 | 120 | status = StatusResponse{} 121 | err = json.Unmarshal(data, &status) 122 | require.Nil(t, err) 123 | require.False(t, status.Ok) 124 | } 125 | 126 | func TestApiSyncOneError(t *testing.T) { 127 | if testing.Short() { 128 | t.Skip("Skipping API test in short mode.") 129 | } 130 | 131 | port := randomPort() 132 | 133 | config, err := LoadConfig("fixtures/configs/errorconfigs/nonexistent-client-dir-config.yaml") 134 | require.Nil(t, err) 135 | 136 | syncer, err := NewSyncer(config, OutputDirCollection{}, logrus.NewEntry(logrus.New()), metricsForTest()) 137 | require.Nil(t, err) 138 | 139 | _, err = syncer.LoadClients() 140 | assert.NotNil(t, err) 141 | 142 | NewAPIServer(syncer, nil, port, logrus.NewEntry(logrus.New()), metricsForTest()) 143 | waitForServer(t, port) 144 | 145 | // Test error loading clients when syncing single client 146 | req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/sync/client1", port), nil) 147 | require.Nil(t, err) 148 | 149 | res, err := http.DefaultClient.Do(req) 150 | require.Nil(t, err) 151 | assert.Equal(t, http.StatusInternalServerError, res.StatusCode) 152 | 153 | // Test error loading clients when syncing all clients 154 | req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/sync", port), nil) 155 | require.Nil(t, err) 156 | 157 | res, err = http.DefaultClient.Do(req) 158 | require.Nil(t, err) 159 | assert.Equal(t, http.StatusInternalServerError, res.StatusCode) 160 | } 161 | 162 | // Ensure the /backup path returns an error if no backup is configured 163 | func TestNoBackup(t *testing.T) { 164 | if testing.Short() { 165 | t.Skip("Skipping API test in short mode.") 166 | } 167 | 168 | port := randomPort() 169 | 170 | server := createDefaultServerWithDeletionRace() 171 | defer server.Close() 172 | 173 | // Load a test config 174 | syncer, err := createNewSyncer("fixtures/configs/test-config.yaml", server) 175 | require.Nil(t, err) 176 | 177 | NewAPIServer(syncer, nil, port, logrus.NewEntry(logrus.New()), metricsForTest()) 178 | waitForServer(t, port) 179 | 180 | // Call the /backup API, which should return an error 181 | req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/backup", port), nil) 182 | require.Nil(t, err) 183 | 184 | res, err := http.DefaultClient.Do(req) 185 | require.Nil(t, err) 186 | 187 | require.EqualValues(t, http.StatusServiceUnavailable, res.StatusCode) 188 | } 189 | 190 | // stubBackup is used to verify the API server calls the backup object 191 | type stubBackup struct { 192 | backupCalls int 193 | } 194 | 195 | var _ backup.Backup = &stubBackup{} 196 | 197 | func (b *stubBackup) Backup() error { 198 | b.backupCalls++ 199 | return nil 200 | } 201 | 202 | func (b *stubBackup) Restore([]byte) error { 203 | return nil 204 | } 205 | 206 | func TestBackup(t *testing.T) { 207 | if testing.Short() { 208 | t.Skip("Skipping API test in short mode.") 209 | } 210 | 211 | port := randomPort() 212 | 213 | server := createDefaultServerWithDeletionRace() 214 | defer server.Close() 215 | 216 | // Load a test config 217 | syncer, err := createNewSyncer("fixtures/configs/test-config.yaml", server) 218 | require.Nil(t, err) 219 | 220 | stub := stubBackup{} 221 | 222 | NewAPIServer(syncer, &stub, port, logrus.NewEntry(logrus.New()), metricsForTest()) 223 | waitForServer(t, port) 224 | 225 | // Call the /backup API, which should call the Backup() method 226 | req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/backup", port), nil) 227 | require.Nil(t, err) 228 | 229 | res, err := http.DefaultClient.Do(req) 230 | require.Nil(t, err) 231 | 232 | require.EqualValues(t, http.StatusOK, res.StatusCode) 233 | 234 | // The backup object should have been called exactly once 235 | require.EqualValues(t, 1, stub.backupCalls) 236 | } 237 | 238 | func TestHealthCheck(t *testing.T) { 239 | if testing.Short() { 240 | t.Skip("Skipping API test in short mode.") 241 | } 242 | 243 | port := randomPort() 244 | 245 | config, err := LoadConfig("fixtures/configs/errorconfigs/nonexistent-client-dir-config.yaml") 246 | require.Nil(t, err) 247 | 248 | syncer, err := NewSyncer(config, OutputDirCollection{}, logrus.NewEntry(logrus.New()), metricsForTest()) 249 | require.Nil(t, err) 250 | 251 | _, err = syncer.LoadClients() 252 | assert.NotNil(t, err) 253 | 254 | NewAPIServer(syncer, nil, port, logrus.NewEntry(logrus.New()), metricsForTest()) 255 | waitForServer(t, port) 256 | 257 | // 1. Check that health check returns false if we've never had a success 258 | req, err := http.NewRequest("GET", fmt.Sprintf("http://localhost:%d/status", port), nil) 259 | require.Nil(t, err) 260 | 261 | res, err := http.DefaultClient.Do(req) 262 | require.Nil(t, err) 263 | assert.Equal(t, http.StatusServiceUnavailable, res.StatusCode) 264 | 265 | // 2. Check health is true under good conditions (make it look like there was a successful sync) 266 | syncer.updateSuccessTimestamp() 267 | 268 | req, err = http.NewRequest("GET", fmt.Sprintf("http://localhost:%d/status", port), nil) 269 | require.Nil(t, err) 270 | 271 | res, err = http.DefaultClient.Do(req) 272 | require.Nil(t, err) 273 | assert.Equal(t, http.StatusOK, res.StatusCode) 274 | } 275 | 276 | func TestMetricsReporting(t *testing.T) { 277 | if testing.Short() { 278 | t.Skip("Skipping API test in short mode.") 279 | } 280 | 281 | port := randomPort() 282 | 283 | config, err := LoadConfig("fixtures/configs/errorconfigs/nonexistent-client-dir-config.yaml") 284 | require.Nil(t, err) 285 | 286 | syncer, err := NewSyncer(config, OutputDirCollection{}, logrus.NewEntry(logrus.New()), metricsForTest()) 287 | require.Nil(t, err) 288 | 289 | _, err = syncer.LoadClients() 290 | assert.NotNil(t, err) 291 | 292 | NewAPIServer(syncer, nil, port, logrus.NewEntry(logrus.New()), metricsForTest()) 293 | waitForServer(t, port) 294 | 295 | // Check health under good conditions 296 | req, err := http.NewRequest("GET", fmt.Sprintf("http://localhost:%d/metrics", port), nil) 297 | require.Nil(t, err) 298 | 299 | res, err := http.DefaultClient.Do(req) 300 | require.Nil(t, err) 301 | assert.Equal(t, http.StatusOK, res.StatusCode) 302 | 303 | // Check that metrics is valid JSON (should be an array) 304 | body, _ := ioutil.ReadAll(res.Body) 305 | var parsed []interface{} 306 | err = json.Unmarshal(body, &parsed) 307 | 308 | if err != nil { 309 | t.Errorf("output from /metrics is not valid JSON, though it should be: %s", err) 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /backup/backup.go: -------------------------------------------------------------------------------- 1 | // package backup handles reading and writing encrypted .tar files from the secretsDirectory to 2 | // a backupPath using the key backupKey. 3 | package backup 4 | 5 | import ( 6 | "io/ioutil" 7 | 8 | "github.com/square/keysync/output" 9 | 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type Backup interface { 14 | Backup() error 15 | Restore(key []byte) error 16 | } 17 | 18 | type FileBackup struct { 19 | SecretsDirectory string 20 | BackupPath string 21 | BackupKeyPath string 22 | Pubkey *[32]byte 23 | Chown bool 24 | EnforceFS output.Filesystem 25 | } 26 | 27 | // Backup is intended to be implemented by FileBackup 28 | var _ Backup = &FileBackup{} 29 | 30 | // Backup loads all files in b.SecretsDirectory, tars, compresses, then encrypts with b.BackupKey 31 | // The content is written to b.BackupPath 32 | func (b *FileBackup) Backup() error { 33 | tarball, err := createTar(b.SecretsDirectory) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | // Encrypt it 39 | wrapped, encrypted, err := encrypt(tarball, b.Pubkey) 40 | if err != nil { 41 | return errors.Wrap(err, "error encrypting backup") 42 | } 43 | 44 | // We always write as r-- --- ---, aka 0400 45 | // UID/GID in this struct are ignored as chownFiles: false 46 | perms := output.FileInfo{Mode: 0400} 47 | // Write it out, and if it errored, wrapped the error 48 | _, err = output.WriteFileAtomically(b.BackupPath, false, perms, 0, encrypted) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | // Write out the wrapped key file 54 | _, err = output.WriteFileAtomically(b.BackupKeyPath, false, perms, 0, wrapped) 55 | return err 56 | } 57 | 58 | // Restore opens b.BackupPath, decrypts with an unwrapped key and writes contents to b.SecretsDirectory 59 | func (b *FileBackup) Restore(key []byte) error { 60 | ciphertext, err := ioutil.ReadFile(b.BackupPath) 61 | if err != nil { 62 | return errors.Wrap(err, "error reading backup") 63 | } 64 | 65 | tarball, err := decrypt(ciphertext, key) 66 | if err != nil { 67 | return errors.Wrap(err, "error decrypting backup") 68 | } 69 | 70 | if err := extractTar(tarball, b.Chown, b.SecretsDirectory, b.EnforceFS); err != nil { 71 | return errors.Wrap(err, "Error extracting tarball") 72 | } 73 | 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /backup/crypto.go: -------------------------------------------------------------------------------- 1 | package backup 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | 11 | "github.com/pkg/errors" 12 | "golang.org/x/crypto/nacl/box" 13 | ) 14 | 15 | // WrappedKey is the JSON-encoded "wrapped key" that a backup is encrypted with. 16 | type WrappedKey struct { 17 | Nonce []byte 18 | CipherText []byte 19 | SenderPubkey []byte 20 | } 21 | 22 | // wrap takes a public key, and an aes key to encrypt to the public key 23 | // It returns a value suitable for passing to `unwrap` (it's json) 24 | func wrap(recipientPubkey *[32]byte, keyToWrap []byte) (wrapped []byte, err error) { 25 | senderPubkey, privateKey, err := box.GenerateKey(rand.Reader) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | var nonce [24]byte 31 | if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil { 32 | return nil, errors.Wrap(err, "Error reading random for nonce") 33 | } 34 | 35 | ciphertext := box.Seal(nil, keyToWrap, &nonce, recipientPubkey, privateKey) 36 | 37 | // We deliberately do not retain the private key. We don't need it anymore, and it's 38 | // risky to keep around, as it can also decrypt the wrapped message. 39 | 40 | return json.Marshal(WrappedKey{Nonce: nonce[:], CipherText: ciphertext, SenderPubkey: (*senderPubkey)[:]}) 41 | } 42 | 43 | // Unwrap takes the wrapped key from `wrap`, along with the private key. It returns a key 44 | // suitable to be passed to Restore() 45 | func Unwrap(wrapped []byte, privateKey []byte) ([]byte, error) { 46 | wrappedKey := WrappedKey{} 47 | if err := json.Unmarshal(wrapped, &wrappedKey); err != nil { 48 | return nil, err 49 | } 50 | 51 | // box.Open takes fixed-size arrays, which don't JSON nicely. 52 | // So we manually check length and copy from a []byte slice to a [N]byte array. 53 | if len(wrappedKey.Nonce) != 24 { 54 | return nil, fmt.Errorf("incorrect nonce length: 24 != %d", len(wrappedKey.Nonce)) 55 | } 56 | var nonce [24]byte 57 | copy(nonce[:], wrappedKey.Nonce) 58 | 59 | if len(wrappedKey.SenderPubkey) != 32 { 60 | return nil, fmt.Errorf("incorrect public key length: 32 != %d", len(wrappedKey.SenderPubkey)) 61 | } 62 | var pubkey [32]byte 63 | copy(pubkey[:], wrappedKey.SenderPubkey) 64 | 65 | if len(privateKey) != 32 { 66 | return nil, fmt.Errorf("incorrect private key length: 32 != %d", len(privateKey)) 67 | } 68 | var privkey [32]byte 69 | copy(privkey[:], privateKey) 70 | 71 | if len(wrappedKey.CipherText) != 32 { 72 | return nil, fmt.Errorf("incorrect ciphertext: 32 != %d", len(wrappedKey.SenderPubkey)) 73 | } 74 | 75 | decrypted, ok := box.Open(nil, wrappedKey.CipherText, &nonce, &pubkey, &privkey) 76 | if !ok { 77 | return nil, errors.New("Decryption failed") 78 | } 79 | return decrypted, nil 80 | } 81 | 82 | func aesgcm(key []byte) (cipher.AEAD, error) { 83 | block, err := aes.NewCipher(key) 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | return cipher.NewGCM(block) 89 | } 90 | 91 | // Encrypt data with a new, randomly generated key. 92 | // Returns the key encrypted to pubkey, and the encrypted data 93 | func encrypt(data []byte, pubkey *[32]byte) (wrappedKey []byte, ciphertext []byte, err error) { 94 | key := make([]byte, 16) 95 | if _, err := io.ReadFull(rand.Reader, key); err != nil { 96 | return nil, nil, errors.Wrap(err, "Error reading random for key") 97 | } 98 | aesgcm, err := aesgcm(key) 99 | if err != nil { 100 | return nil, nil, err 101 | } 102 | 103 | nonce := make([]byte, aesgcm.NonceSize()) 104 | if _, err := io.ReadFull(rand.Reader, nonce); err != nil { 105 | return nil, nil, errors.Wrap(err, "Error reading random for nonce") 106 | } 107 | 108 | wrapped, err := wrap(pubkey, key) 109 | if err != nil { 110 | return nil, nil, err 111 | } 112 | 113 | // Seal appends to the first parameter, so we append ciphertext to the nonce 114 | return wrapped, aesgcm.Seal(nonce, nonce, data, nil), nil 115 | } 116 | 117 | // Decrypt takes encrypted data, and the `key` returned from `unwrap` 118 | func decrypt(data, key []byte) ([]byte, error) { 119 | aesgcm, err := aesgcm(key) 120 | if err != nil { 121 | return nil, err 122 | } 123 | 124 | nonceSize := aesgcm.NonceSize() 125 | // Nonce is prefixed to data 126 | return aesgcm.Open(nil, data[:nonceSize], data[nonceSize:], nil) 127 | } 128 | -------------------------------------------------------------------------------- /backup/crypto_test.go: -------------------------------------------------------------------------------- 1 | package backup 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "io" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "golang.org/x/crypto/nacl/box" 12 | ) 13 | 14 | // TestEncryptDecrypt takes a random buffer, encrypts, then unwraps and decrypts. 15 | func TestEncryptDecrypt(t *testing.T) { 16 | // Make a random buffer of data to test with: 17 | testData := make([]byte, 1234) 18 | copyData := make([]byte, 1234) 19 | _, err := io.ReadFull(rand.Reader, testData) 20 | require.NoError(t, err) 21 | copy(copyData, testData) 22 | 23 | pubkey, privkey, err := box.GenerateKey(rand.Reader) 24 | require.NoError(t, err) 25 | 26 | key, ciphertext, err := encrypt(testData, pubkey) 27 | assert.NoError(t, err) 28 | 29 | // from crypto/cipher/gcm.go 30 | gcmStandardNonceSize := 12 31 | gcmTagSize := 16 32 | 33 | // The ciphertext should be longer than the plaintext 34 | assert.Equal(t, len(testData)+gcmStandardNonceSize+gcmTagSize, len(ciphertext)) 35 | 36 | // We can't really make any other assertions about the ciphertext 37 | // But make sure the ciphertext doesn't literally contain the plaintext 38 | assert.False(t, bytes.Contains(ciphertext, copyData)) 39 | 40 | unwrappedKey, err := Unwrap(key, privkey[:]) 41 | assert.NoError(t, err) 42 | 43 | plaintext, err := decrypt(ciphertext, unwrappedKey) 44 | assert.NoError(t, err) 45 | 46 | // Verify the plaintext roundtripped 47 | assert.Equal(t, copyData, plaintext) 48 | 49 | // Verify the testData wasn't modified during encryption 50 | assert.Equal(t, copyData, testData) 51 | } 52 | -------------------------------------------------------------------------------- /backup/tar.go: -------------------------------------------------------------------------------- 1 | package backup 2 | 3 | import ( 4 | "archive/tar" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | 13 | "github.com/square/keysync/output" 14 | 15 | "github.com/pkg/errors" 16 | ) 17 | 18 | var NonCanonicalPathError = errors.New("non-canonical file path in archive") 19 | 20 | // Given a path to a directory, create and return a tarball of its content. 21 | // Careful, as this will pull the full contents into memory. 22 | // This is not a general-purpose function, but is intended to only work with Keysync 23 | // directories, which contain only non-executable regular files. 24 | func createTar(dir string) ([]byte, error) { 25 | var tarball bytes.Buffer 26 | tw := tar.NewWriter(&tarball) 27 | 28 | err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { 29 | if err != nil { 30 | return err 31 | } 32 | 33 | if info.IsDir() || !info.Mode().IsRegular() { 34 | // Skip directories and non-regular files. 35 | return nil 36 | } 37 | 38 | f, err := os.Open(path) 39 | if err != nil { 40 | return err 41 | } 42 | defer f.Close() // We explicitly call close below with error handling, but this extra one handles early returns 43 | 44 | // 2nd Argument to FileInfoHeader is only used for symlinks, which aren't relevant here 45 | header, err := tar.FileInfoHeader(info, "") 46 | if err != nil { 47 | return err 48 | } 49 | 50 | // Set the name to be relative to the base directory 51 | header.Name, err = filepath.Rel(dir, path) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | if err := tw.WriteHeader(header); err != nil { 57 | return err 58 | } 59 | 60 | if _, err := io.Copy(tw, f); err != nil { 61 | return err 62 | } 63 | 64 | if err := f.Close(); err != nil { 65 | return err 66 | } 67 | 68 | return nil 69 | }) 70 | if err != nil { 71 | return nil, err 72 | } 73 | 74 | // Tar writing adds a trailer in Close(), and can possibly return errors, so we need to check 75 | // errors here. We could also defer tw.Close(), but there's nothing to leak in the Tar Writer 76 | // other than the io.Writer that's passed in. That's the tarball buffer in this function, so 77 | // we don't need to worry about leaking FDs. Calling Close() a 2nd time is always an error, so 78 | // I think it makes the error handling trickier if we both explicitly and defer a call to Close. 79 | if err := tw.Close(); err != nil { 80 | return nil, err 81 | } 82 | return tarball.Bytes(), nil 83 | } 84 | 85 | // Given a tarball, write it out to dir, which must be empty or not exist 86 | // If Chown is true, set file ownership from the tarball. 87 | // This is intended to be only used with files from createTar. 88 | func extractTar(tarball []byte, chown bool, dirpath string, filesystem output.Filesystem) error { 89 | _, err := os.Open(dirpath) 90 | if os.IsNotExist(err) { 91 | // The directory doesn't exist, so try to make it. 92 | if err := os.MkdirAll(dirpath, 0755); err != nil { 93 | return errors.Wrapf(err, "could not create secrets directory %s", dirpath) 94 | } 95 | } else if err != nil { 96 | return errors.Wrapf(err, "error opening secrets directory %s", dirpath) 97 | } 98 | 99 | // Don't risk overwriting any existing files: 100 | if err := checkIfEmpty(dirpath); err != nil { 101 | return err 102 | } 103 | 104 | // At this point, the directory exists and is non-empty, so let's unpack files there 105 | tr := tar.NewReader(bytes.NewReader(tarball)) 106 | for { 107 | header, err := tr.Next() 108 | if err == io.EOF { 109 | break 110 | } else if err != nil { 111 | return errors.Wrap(err, "error reading tar header") 112 | } 113 | 114 | switch header.Typeflag { 115 | case tar.TypeDir: 116 | // We don't need to care about directories, because they're created by WriteFileAtomically 117 | case tar.TypeReg: 118 | fileInfo := output.FileInfo{Mode: os.FileMode(header.Mode), UID: header.Uid, GID: header.Gid} 119 | 120 | content, err := ioutil.ReadAll(tr) 121 | if err != nil { 122 | return errors.Wrapf(err, "error reading %s", header.Name) 123 | } 124 | 125 | // To avoid path-traversal issues a la https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-43798, 126 | // prepend a '/' to the front of the name before checking to see if it's canonical, since by default 127 | // filepath.Clean (https://pkg.go.dev/path/filepath#Clean) does not remove .. at the beginning of a 128 | // path unless it is rooted. 129 | // 130 | // DO NOT use path.Join or filepath.Join to prepend the '/', since that also Cleans the 131 | // resulting path before returning it. 132 | name := header.Name 133 | separator := string([]rune{os.PathSeparator}) 134 | if !strings.HasPrefix(name, separator) { 135 | name = separator + name 136 | } 137 | if name != filepath.Clean(name) { 138 | return fmt.Errorf("%w: %s", NonCanonicalPathError, header.Name) 139 | } 140 | path := filepath.Join(dirpath, header.Name) 141 | 142 | if _, err := output.WriteFileAtomically(path, chown, fileInfo, filesystem, content); err != nil { 143 | return err 144 | } 145 | default: 146 | return fmt.Errorf("unhandled file %s of type %v", header.Name, header.Typeflag) 147 | } 148 | } 149 | 150 | return nil 151 | } 152 | 153 | // checkIfEmptyDir verifies a given path contains no files 154 | func checkIfEmpty(dir string) error { 155 | var files []string 156 | if err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { 157 | if err != nil { 158 | return err 159 | } 160 | if !info.IsDir() { 161 | files = append(files, path) 162 | } 163 | return nil 164 | }); err != nil { 165 | return err 166 | } 167 | if len(files) > 0 { 168 | return fmt.Errorf("non-empty directory %s: %q", dir, files) 169 | } 170 | return nil 171 | } 172 | -------------------------------------------------------------------------------- /backup/tar_test.go: -------------------------------------------------------------------------------- 1 | package backup 2 | 3 | import ( 4 | "archive/tar" 5 | "bytes" 6 | "crypto/sha256" 7 | "encoding/hex" 8 | "errors" 9 | "io" 10 | "io/ioutil" 11 | "os" 12 | "path/filepath" 13 | "testing" 14 | 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | ) 18 | 19 | func testSha(t *testing.T, hash string, file *os.File) { 20 | sha := sha256.New() 21 | _, err := io.Copy(sha, file) 22 | assert.NoError(t, err) 23 | assert.Equal(t, hash, hex.EncodeToString(sha.Sum(nil))) 24 | } 25 | 26 | // TestCreateExtractTar makes a tar of the testdata/ folder, then extracts it to a temp directory 27 | // and validates files and permissions are present as expected. 28 | func TestCreateExtractTar(t *testing.T) { 29 | var testfiles = []struct { 30 | path string 31 | perm os.FileMode 32 | sha2hash string 33 | }{ 34 | {"alice.txt", 0440, "7708cf9d3d58e7a4e621ec2aa9fd47c678fd4a3411c804df060c041ee6237e4d"}, 35 | {"bob/bob.txt", 0404, "a802f68d223a903e282e310251585f26b1abdfe067854252d0f1bf33d334f768"}, 36 | } 37 | 38 | // Ensure the test files have expected permissions and content beforehand 39 | for _, test := range testfiles { 40 | file, err := os.Open(filepath.Join("testdata", test.path)) 41 | require.NoError(t, err) 42 | testSha(t, test.sha2hash, file) 43 | require.NoError(t, file.Chmod(test.perm)) 44 | } 45 | 46 | tar, err := createTar("testdata") 47 | require.NoError(t, err) 48 | 49 | tmpdir, err := ioutil.TempDir("", "test-create-extract-tar") 50 | require.NoError(t, err) 51 | defer os.RemoveAll(tmpdir) 52 | 53 | err = extractTar(tar, false, tmpdir, 0) 54 | require.NoError(t, err) 55 | 56 | for _, test := range testfiles { 57 | file, err := os.Open(filepath.Join(tmpdir, test.path)) 58 | assert.NoError(t, err) 59 | info, err := file.Stat() 60 | assert.NoError(t, err) 61 | assert.EqualValues(t, test.perm, info.Mode().Perm(), "unexpected permissions on %s: %s", test.path, info.Mode().String()) 62 | 63 | testSha(t, test.sha2hash, file) 64 | } 65 | } 66 | 67 | // TestCheckIfEmpty makes sure we detect files, which should help us avoid accidentally restoring 68 | // a backup when there's secrets in place. 69 | func TestCheckIfEmpty(t *testing.T) { 70 | tmpdir, err := ioutil.TempDir("", "test-create-extract-tar") 71 | require.NoError(t, err) 72 | defer os.RemoveAll(tmpdir) 73 | 74 | // Case 0: A non-existent path 75 | assert.Error(t, checkIfEmpty(filepath.Join(tmpdir, "this-path-does-not-exist"))) 76 | 77 | // Case 1: An empty directory. 78 | assert.NoError(t, checkIfEmpty(tmpdir)) 79 | 80 | // Case 2: Some directories nested 81 | nested, err := ioutil.TempDir(tmpdir, "nested") 82 | require.NoError(t, err) 83 | assert.NoError(t, checkIfEmpty(tmpdir)) 84 | 85 | // Case 3: file in nested. Should error since there's a file 86 | myfile := filepath.Join(nested, "myfile") 87 | assert.NoError(t, ioutil.WriteFile(myfile, []byte("hello world"), 0600)) 88 | assert.Error(t, checkIfEmpty(tmpdir)) 89 | 90 | // Case 4: Passed a file instead of a directory 91 | assert.Error(t, checkIfEmpty(myfile)) 92 | } 93 | 94 | func TestPathTraversal(t *testing.T) { 95 | paths := []string{ 96 | "../evil.txt", 97 | "../../evil.txt", 98 | "foo/../evil.txt", 99 | "foo/bar/../../evil.txt", 100 | "/foo/../evil.txt", 101 | } 102 | 103 | tmpdir, err := ioutil.TempDir("", "test-path-traversal") 104 | if err != nil { 105 | t.Fatalf("ioutil.TempDir failed: %v", err) 106 | } 107 | defer os.RemoveAll(tmpdir) 108 | 109 | for _, path := range paths { 110 | // Create a malicious tarball that tries to write to a relative directory. 111 | var tarball bytes.Buffer 112 | tw := tar.NewWriter(&tarball) 113 | data := []byte("something malicious") 114 | hdr := &tar.Header{ 115 | Name: path, 116 | Mode: 0600, 117 | Size: int64(len(data)), 118 | } 119 | if err := tw.WriteHeader(hdr); err != nil { 120 | t.Fatalf("WriteHeader(%v) failed: %v", hdr, err) 121 | } 122 | if _, err := tw.Write(data); err != nil { 123 | t.Fatalf("%s: Write(%s) failed: %v", path, data, err) 124 | } 125 | if err := tw.Close(); err != nil { 126 | t.Fatalf("%s: Close failed: %v", path, err) 127 | } 128 | 129 | err = extractTar(tarball.Bytes(), false, tmpdir, 0) 130 | if !errors.Is(err, NonCanonicalPathError) { 131 | t.Fatalf("%s: extractTar = %v, want %q", path, err, NonCanonicalPathError) 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /backup/testdata/alice.txt: -------------------------------------------------------------------------------- 1 | Alice is a fictional character, commonly used as a placeholder name in Cryptography. 2 | 3 | This file is used in tests. 4 | -------------------------------------------------------------------------------- /backup/testdata/bob/bob.txt: -------------------------------------------------------------------------------- 1 | Bob is a fictional character, commonly used as a placeholder name in Cryptography. 2 | 3 | This file is used in tests. -------------------------------------------------------------------------------- /bundle.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package keysync 16 | 17 | import ( 18 | "fmt" 19 | "io/ioutil" 20 | 21 | "github.com/pkg/errors" 22 | "github.com/sirupsen/logrus" 23 | ) 24 | 25 | // BackupBundleClient is a secrets client that reads from a Keywhiz backup bundle. 26 | type BackupBundleClient struct { 27 | secrets map[string]Secret 28 | logger *logrus.Entry 29 | } 30 | 31 | // NewBackupBundleClient creates a new BackupBundleClient instance given a backup JSON file. 32 | func NewBackupBundleClient(path string, logger *logrus.Entry) (Client, error) { 33 | raw, err := ioutil.ReadFile(path) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | parsed, err := ParseSecretList(raw) 39 | if err != nil { 40 | return nil, errors.Wrap(err, fmt.Sprintf("unable to parse secret list from path: %s", path)) 41 | } 42 | 43 | client := BackupBundleClient{ 44 | secrets: map[string]Secret{}, 45 | logger: logger.WithField("logger", "file_client"), 46 | } 47 | 48 | for _, secret := range parsed { 49 | name, err := secret.Filename() 50 | if err != nil { 51 | return nil, errors.Wrap(err, "unable to get secret's filename") 52 | } 53 | client.secrets[name] = secret 54 | } 55 | 56 | return &client, nil 57 | } 58 | 59 | // Secret returns secret with the given name from the bundle. 60 | func (c BackupBundleClient) Secret(name string) (secret *Secret, err error) { 61 | s, ok := c.secrets[name] 62 | if !ok { 63 | return nil, fmt.Errorf("unable to find %s in backup bundle", name) 64 | } 65 | return &s, nil 66 | } 67 | 68 | // SecretList returns all secrets in a bundle (unlike the real Keywhiz interface, 69 | // it will return secrets' contents as well). 70 | func (c BackupBundleClient) SecretList() (map[string]Secret, error) { 71 | return c.secrets, nil 72 | } 73 | 74 | // SecretListWithContents returns the requested secrets from a bundle. 75 | func (c BackupBundleClient) SecretListWithContents(secrets []string) (map[string]Secret, error) { 76 | result := map[string]Secret{} 77 | for _, name := range secrets { 78 | s, ok := c.secrets[name] 79 | if !ok { 80 | return nil, fmt.Errorf("unable to find %s in backup bundle", name) 81 | } 82 | result[name] = s 83 | } 84 | return result, nil 85 | } 86 | 87 | // Logger returns the logger for this client. 88 | func (c BackupBundleClient) Logger() *logrus.Entry { 89 | return c.logger 90 | } 91 | 92 | // RebuildClient for bundle clients is a no-op. 93 | func (c BackupBundleClient) RebuildClient() error { 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /bundle_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package keysync 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/sirupsen/logrus" 21 | "github.com/stretchr/testify/assert" 22 | "github.com/stretchr/testify/require" 23 | ) 24 | 25 | func TestBackupBundleReader(t *testing.T) { 26 | newAssert := assert.New(t) 27 | 28 | client, err := NewBackupBundleClient("fixtures/exportedSecretsBackupBundle.json", logrus.NewEntry(logrus.New())) 29 | require.Nil(t, err) 30 | 31 | secret, err := client.Secret("Hacking_Password") 32 | require.Nil(t, err) 33 | newAssert.Equal("Hacking_Password", secret.Name) 34 | newAssert.Equal("0444", secret.Mode) 35 | 36 | secret, err = client.Secret("General_Password") 37 | require.Nil(t, err) 38 | newAssert.Equal("General_Password", secret.Name) 39 | 40 | list, err := client.SecretList() 41 | require.Nil(t, err) 42 | newAssert.Equal(2, len(list)) 43 | 44 | secrets, err := client.SecretListWithContents([]string{"General_Password"}) 45 | require.Nil(t, err) 46 | newAssert.Equal(1, len(secrets)) 47 | retrieved, ok := secrets["General_Password"] 48 | newAssert.True(ok) 49 | newAssert.Equal("General_Password", retrieved.Name) 50 | newAssert.NotEmpty(retrieved.Content) 51 | } 52 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package keysync 16 | 17 | import ( 18 | "fmt" 19 | "net/http" 20 | "net/http/httptest" 21 | "net/url" 22 | "strings" 23 | "testing" 24 | 25 | "github.com/sirupsen/logrus" 26 | sqmetrics "github.com/square/go-sq-metrics" 27 | "github.com/stretchr/testify/assert" 28 | "github.com/stretchr/testify/require" 29 | ) 30 | 31 | var ( 32 | clientCert = "fixtures/clients/client1.crt" 33 | clientKey = "fixtures/clients/client1.key" 34 | testCaFile = "fixtures/CA/localhost.crt" 35 | ) 36 | 37 | func defaultClientConfig() *ClientConfig { 38 | return &ClientConfig{ 39 | Key: clientKey, 40 | Cert: clientCert, 41 | MaxRetries: 1, 42 | Timeout: "1s", 43 | MinBackoff: "1ms", 44 | MaxBackoff: "10ms", 45 | } 46 | } 47 | 48 | func TestClientCallsServer(t *testing.T) { 49 | newAssert := assert.New(t) 50 | 51 | server := createDefaultServer() 52 | defer server.Close() 53 | 54 | serverURL, _ := url.Parse(server.URL) 55 | client, err := NewClient(defaultClientConfig(), testCaFile, serverURL, logrus.NewEntry(logrus.New()), &sqmetrics.SquareMetrics{}) 56 | require.Nil(t, err) 57 | 58 | secrets, err := client.SecretListWithContents([]string{"Nobody_PgPass", "General_Password..0be68f903f8b7d86"}) 59 | newAssert.Nil(err) 60 | newAssert.Len(secrets, 2) 61 | 62 | data, err := client.(*KeywhizHTTPClient).RawSecretListWithContents([]string{"Nobody_PgPass", "General_Password..0be68f903f8b7d86"}) 63 | newAssert.Nil(err) 64 | newAssert.Equal(fixture("secrets.json"), data) 65 | 66 | secrets, err = client.SecretList() 67 | newAssert.Nil(err) 68 | newAssert.Len(secrets, 2) 69 | 70 | data, err = client.(*KeywhizHTTPClient).RawSecretList() 71 | newAssert.Nil(err) 72 | newAssert.Equal(fixture("secretsWithoutContent.json"), data) 73 | 74 | secret, err := client.Secret("Nobody_PgPass") 75 | require.Nil(t, err) 76 | newAssert.Equal("Nobody_PgPass", secret.Name) 77 | 78 | data, err = client.(*KeywhizHTTPClient).RawSecret("Nobody_PgPass") 79 | require.Nil(t, err) 80 | newAssert.Equal(fixture("secret_Nobody_PgPass.json"), data) 81 | 82 | _, err = client.Secret("unexisting") 83 | _, deleted := err.(SecretDeleted) 84 | newAssert.True(deleted) 85 | } 86 | 87 | func TestClientRebuild(t *testing.T) { 88 | serverURL, _ := url.Parse("http://dummy:8080") 89 | client, err := NewClient(defaultClientConfig(), testCaFile, serverURL, logrus.NewEntry(logrus.New()), &sqmetrics.SquareMetrics{}) 90 | require.Nil(t, err) 91 | 92 | http1 := client.(*KeywhizHTTPClient).httpClient 93 | err = client.RebuildClient() 94 | require.Nil(t, err) 95 | http2 := client.(*KeywhizHTTPClient).httpClient 96 | 97 | if http1 == http2 { 98 | t.Error("should not be same http client") 99 | } 100 | } 101 | 102 | func TestClientCallsServerErrors(t *testing.T) { 103 | newAssert := assert.New(t) 104 | 105 | server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 106 | switch { 107 | case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/secrets"): 108 | w.WriteHeader(500) 109 | case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/secret/500-error"): 110 | w.WriteHeader(500) 111 | default: 112 | w.WriteHeader(404) 113 | } 114 | })) 115 | server.TLS = testCerts(testCaFile) 116 | server.StartTLS() 117 | defer server.Close() 118 | 119 | serverURL, _ := url.Parse(server.URL) 120 | client, err := NewClient(defaultClientConfig(), testCaFile, serverURL, logrus.NewEntry(logrus.New()), &sqmetrics.SquareMetrics{}) 121 | require.Nil(t, err) 122 | 123 | secrets, err := client.SecretList() 124 | newAssert.NotNil(err) 125 | newAssert.Len(secrets, 0) 126 | 127 | data, err := client.(*KeywhizHTTPClient).RawSecretList() 128 | assert.Error(t, err) 129 | assert.Nil(t, data) 130 | 131 | secret, err := client.Secret("bar") 132 | newAssert.Nil(secret) 133 | _, deleted := err.(SecretDeleted) 134 | newAssert.True(deleted) 135 | 136 | data, err = client.(*KeywhizHTTPClient).RawSecret("bar") 137 | newAssert.Nil(data) 138 | newAssert.Error(err) 139 | _, deleted = err.(SecretDeleted) 140 | newAssert.True(deleted) 141 | 142 | data, err = client.(*KeywhizHTTPClient).RawSecret("500-error") 143 | newAssert.Nil(data) 144 | newAssert.True(err != nil) 145 | _, deleted = err.(SecretDeleted) 146 | newAssert.False(deleted) 147 | 148 | _, err = client.Secret("non-existent") 149 | newAssert.Nil(data) 150 | _, deleted = err.(SecretDeleted) 151 | newAssert.True(deleted) 152 | } 153 | 154 | func TestClientCallsServerIntermittentErrors(t *testing.T) { 155 | newAssert := assert.New(t) 156 | 157 | numCalls := 0 158 | server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 159 | switch { 160 | case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/secrets"): 161 | // simulate intermittent error 162 | if numCalls > 0 { 163 | fmt.Fprint(w, `[{"name" : "SecretA", "filename": "filenameA"}, 164 | {"name" : "SecretB", "filename": "filenameB"}]`) 165 | w.WriteHeader(200) 166 | } else { 167 | w.WriteHeader(500) 168 | } 169 | default: 170 | w.WriteHeader(404) 171 | } 172 | numCalls++ 173 | })) 174 | server.TLS = testCerts(testCaFile) 175 | server.StartTLS() 176 | defer server.Close() 177 | 178 | serverURL, _ := url.Parse(server.URL) 179 | cfg := defaultClientConfig() 180 | cfg.MaxRetries = 2 181 | client, err := NewClient(cfg, testCaFile, serverURL, logrus.NewEntry(logrus.New()), &sqmetrics.SquareMetrics{}) 182 | require.Nil(t, err) 183 | 184 | secrets, err := client.SecretList() 185 | newAssert.Nil(err) 186 | newAssert.Len(secrets, 2) 187 | } 188 | 189 | // Test a server that returns invalid secret JSON information 190 | func TestClientCorruptedResponses(t *testing.T) { 191 | newAssert := assert.New(t) 192 | 193 | server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 194 | switch { 195 | case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/secrets"): 196 | fmt.Fprint(w, "hi") 197 | case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/secret/foo"): 198 | fmt.Fprint(w, "hi again") 199 | default: 200 | w.WriteHeader(404) 201 | } 202 | })) 203 | server.TLS = testCerts(testCaFile) 204 | server.StartTLS() 205 | defer server.Close() 206 | 207 | serverURL, _ := url.Parse(server.URL) 208 | client, err := NewClient(defaultClientConfig(), testCaFile, serverURL, logrus.NewEntry(logrus.New()), &sqmetrics.SquareMetrics{}) 209 | require.Nil(t, err) 210 | 211 | _, err = client.SecretList() 212 | newAssert.NotNil(err) 213 | 214 | _, err = client.Secret("foo") 215 | require.NotNil(t, err) 216 | } 217 | 218 | func TestClientParsingError(t *testing.T) { 219 | newAssert := assert.New(t) 220 | 221 | server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) 222 | server.TLS = testCerts(testCaFile) 223 | server.StartTLS() 224 | defer server.Close() 225 | 226 | serverURL, _ := url.Parse(server.URL) 227 | client, err := NewClient(defaultClientConfig(), testCaFile, serverURL, logrus.NewEntry(logrus.New()), &sqmetrics.SquareMetrics{}) 228 | require.Nil(t, err) 229 | 230 | secrets, err := client.SecretList() 231 | newAssert.NotNil(err) 232 | newAssert.Len(secrets, 0) 233 | } 234 | 235 | func TestClientServerStatusSuccess(t *testing.T) { 236 | server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 237 | switch { 238 | case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/_status"): 239 | w.WriteHeader(200) 240 | default: 241 | w.WriteHeader(404) 242 | } 243 | })) 244 | server.TLS = testCerts(testCaFile) 245 | server.StartTLS() 246 | defer server.Close() 247 | 248 | serverURL, _ := url.Parse(server.URL) 249 | client, err := NewClient(defaultClientConfig(), testCaFile, serverURL, logrus.NewEntry(logrus.New()), &sqmetrics.SquareMetrics{}) 250 | require.Nil(t, err) 251 | 252 | _, err = client.(*KeywhizHTTPClient).ServerStatus() 253 | require.Nil(t, err) 254 | } 255 | 256 | func TestClientServerFailure(t *testing.T) { 257 | server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) 258 | serverURL, _ := url.Parse(server.URL) 259 | client, err := NewClient(defaultClientConfig(), testCaFile, serverURL, logrus.NewEntry(logrus.New()), &sqmetrics.SquareMetrics{}) 260 | require.Nil(t, err) 261 | 262 | _, err = client.(*KeywhizHTTPClient).ServerStatus() 263 | require.NotNil(t, err) 264 | 265 | _, err = client.Secret("secret") 266 | require.NotNil(t, err) 267 | 268 | _, err = client.SecretList() 269 | require.NotNil(t, err) 270 | } 271 | 272 | func TestNewClientFailure(t *testing.T) { 273 | server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) 274 | defer server.Close() 275 | 276 | config, err := LoadConfig("fixtures/configs/errorconfigs/nonexistent-ca-file-config.yaml") 277 | require.Nil(t, err) 278 | 279 | clientConfigs, err := config.LoadClients() 280 | require.Nil(t, err) 281 | 282 | // Try to load a client with an invalid CA file configured 283 | clientName := "client1" 284 | serverURL, _ := url.Parse(server.URL) 285 | cfg := defaultClientConfig() 286 | cfg.Cert = clientConfigs[clientName].Cert 287 | cfg.Key = clientConfigs[clientName].Key 288 | _, err = NewClient(cfg, config.CaFile, serverURL, logrus.NewEntry(logrus.New()), &sqmetrics.SquareMetrics{}) 289 | assert.NotNil(t, err) 290 | } 291 | 292 | func TestDuplicateFilenames(t *testing.T) { 293 | server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 294 | switch { 295 | case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/secrets"): 296 | fmt.Fprint(w, `[{"name" : "SecretA", "filename": "overridden_filename"}, 297 | {"name" : "SecretB", "filename": "overridden_filename"}]`) 298 | default: 299 | w.WriteHeader(404) 300 | } 301 | })) 302 | server.TLS = testCerts(testCaFile) 303 | server.StartTLS() 304 | defer server.Close() 305 | 306 | serverURL, _ := url.Parse(server.URL) 307 | client, err := NewClient(defaultClientConfig(), testCaFile, serverURL, logrus.NewEntry(logrus.New()), &sqmetrics.SquareMetrics{}) 308 | require.Nil(t, err) 309 | 310 | _, err = client.SecretList() 311 | assert.EqualError(t, err, "duplicate filename detected: overridden_filename on secrets SecretA and SecretB") 312 | } 313 | -------------------------------------------------------------------------------- /cmd/keyrestore/README.md: -------------------------------------------------------------------------------- 1 | The `keyrestore` command runs once to unpack & install a backed up secrets 2 | bundle from Keysync. This is useful to bootstrap secrets to recover from a 3 | datacenter outage and to avoid circular dependencies between Keywhiz and other 4 | services. 5 | -------------------------------------------------------------------------------- /cmd/keyrestore/keyrestore.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // This is the main entry point for Keysync. It assumes a bit more about the environment you're using keysync in than 16 | // the keysync library. In particular, you may want to have your own version of this for a different monitoring system 17 | // than Sentry, a different configuration or command line format, or any other customization you need. 18 | package main 19 | 20 | import ( 21 | "encoding/base64" 22 | "io/ioutil" 23 | "os" 24 | 25 | "github.com/sirupsen/logrus" 26 | "gopkg.in/alecthomas/kingpin.v2" 27 | 28 | "github.com/square/keysync" 29 | ) 30 | 31 | var log = logrus.New() 32 | 33 | func main() { 34 | var ( 35 | app = kingpin.New("keyrestore", "Unpack and install a Keysync backup") 36 | configFile = app.Flag("config", "The base YAML configuration file").PlaceHolder("config.yaml").Required().ExistingFile() 37 | keyFile = app.Flag("keyfile", "An unwrapped key, from keyunwrap").Required().ExistingFile() 38 | ) 39 | kingpin.MustParse(app.Parse(os.Args[1:])) 40 | 41 | logger := log.WithFields(logrus.Fields{}) 42 | logger.WithField("file", *configFile).Infof("Loading config") 43 | 44 | config, err := keysync.LoadConfig(*configFile) 45 | if err != nil { 46 | logger.WithError(err).Fatal("Failed loading configuration") 47 | } 48 | 49 | bup, err := keysync.BackupFromConfig(config) 50 | if err != nil { 51 | logger.WithError(err).Fatal("Failed setting up backup") 52 | } 53 | 54 | b64key, err := ioutil.ReadFile(*keyFile) 55 | if err != nil { 56 | logger.WithError(err).Fatal("Failed reading key") 57 | } 58 | 59 | key, err := base64.StdEncoding.DecodeString(string(b64key)) 60 | if err != nil { 61 | logger.WithError(err).Fatal("Failed decoding key") 62 | } 63 | 64 | logger.Info("Restoring backup") 65 | if err := bup.Restore(key); err != nil { 66 | logger.WithError(err).Warn("error restoring backup") 67 | } else { 68 | logger.Info("Backup restored") 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /cmd/keysync/README.md: -------------------------------------------------------------------------------- 1 | The `keysync` command connects to an HTTP backend and continously synchronizes 2 | local state to match what is on the server. 3 | -------------------------------------------------------------------------------- /cmd/keysync/keysync.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // This is the main entry point for Keysync. It assumes a bit more about the 16 | // environment you're using keysync in than the keysync library. In 17 | // particular, you may want to have your own version of this for a different 18 | // monitoring system than Sentry, a different configuration or command line 19 | // format, or any other customization you need. 20 | package main 21 | 22 | import ( 23 | "crypto/tls" 24 | "crypto/x509" 25 | "errors" 26 | "io/ioutil" 27 | stdlog "log" 28 | "net/http" 29 | "os" 30 | "time" 31 | 32 | "github.com/square/keysync" 33 | 34 | "github.com/evalphobia/logrus_sentry" 35 | "github.com/getsentry/raven-go" 36 | "github.com/rcrowley/go-metrics" 37 | "github.com/sirupsen/logrus" 38 | sqmetrics "github.com/square/go-sq-metrics" 39 | "gopkg.in/alecthomas/kingpin.v2" 40 | ) 41 | 42 | var log = logrus.New() 43 | 44 | // Release is passed to Sentry as the release. It is deliberately unset here 45 | // so that it can be set with the -X argument to the go linker. 46 | var release string 47 | 48 | func main() { 49 | var ( 50 | app = kingpin.New("keysync", "A client for Keywhiz") 51 | configFile = app.Flag("config", "The base YAML configuration file").PlaceHolder("config.yaml").Required().String() 52 | ) 53 | kingpin.MustParse(app.Parse(os.Args[1:])) 54 | 55 | hostname, err := os.Hostname() 56 | if err != nil { 57 | log.WithError(err).Error("Failed resolving hostname") 58 | hostname = "unknown" 59 | } 60 | logger := log.WithFields(logrus.Fields{ 61 | // https://github.com/evalphobia/logrus_sentry#special-fields 62 | "server_name": hostname, 63 | }) 64 | if release == "" { 65 | release = "(version not set)" 66 | } 67 | logger.WithField("release", release).Info("Keysync starting") 68 | 69 | logger.WithField("file", *configFile).Info("Loading config") 70 | config, err := keysync.LoadConfig(*configFile) 71 | 72 | if err != nil { 73 | logger.WithError(err).Fatal("Failed loading configuration") 74 | } 75 | 76 | if config.SentryDSN != "" { 77 | hook, err := configureLogrusSentry(config.SentryDSN, config.SentryCaFile) 78 | 79 | if err == nil { 80 | log.Hooks.Add(hook) 81 | logger.Debug("Logrus Sentry hook added") 82 | } else { 83 | logger.WithError(err).Error("Failed loading Sentry hook") 84 | } 85 | } 86 | 87 | captured, errorId := raven.CapturePanicAndWait(func() { 88 | metricsHandle := sqmetrics.NewMetrics("", config.MetricsPrefix, http.DefaultClient, 1*time.Second, metrics.DefaultRegistry, &stdlog.Logger{}) 89 | 90 | syncer, err := keysync.NewSyncer(config, keysync.OutputDirCollection{Config: config}, logger, metricsHandle) 91 | if err != nil { 92 | logger.WithError(err).Fatal("Failed while creating syncer") 93 | } 94 | 95 | fileBackup, err := keysync.BackupFromConfig(config) 96 | if err != nil { 97 | logger.WithError(err).Warn("Unable to set up backups") 98 | } 99 | 100 | // Start the API server 101 | if config.APIPort != 0 { 102 | keysync.NewAPIServer(syncer, fileBackup, config.APIPort, logger, metricsHandle) 103 | } 104 | 105 | logger.Info("Starting syncer") 106 | err = syncer.Run() 107 | if err != nil { 108 | logger.WithError(err).Fatal("Failed while running syncer") 109 | } 110 | }, nil) 111 | if captured != nil { 112 | logger.Infof("Panic errorId: %s", errorId) 113 | panic(captured) 114 | } else { 115 | logger.Info("Exiting normally") 116 | } 117 | } 118 | 119 | // This is modified from raven.newTransport() 120 | func newTransport(CaFile string) (raven.Transport, error) { 121 | t := &raven.HTTPTransport{} 122 | 123 | transport := http.Transport{ 124 | Proxy: http.ProxyFromEnvironment, 125 | } 126 | 127 | if CaFile != "" { 128 | b, err := ioutil.ReadFile(CaFile) 129 | if err != nil { 130 | return t, err 131 | } 132 | rootCAs := x509.NewCertPool() 133 | ok := rootCAs.AppendCertsFromPEM(b) 134 | if !ok { 135 | return t, errors.New("failed to load root CAs") 136 | } 137 | transport.TLSClientConfig = &tls.Config{RootCAs: rootCAs} 138 | } 139 | 140 | t.Client = &http.Client{ 141 | Transport: &transport, 142 | } 143 | return t, nil 144 | } 145 | 146 | func configureLogrusSentry(DSN, CaFile string) (*logrus_sentry.SentryHook, error) { 147 | // If a custom CaFile is set, create a custom transport 148 | var transport raven.Transport 149 | var err error 150 | transport, err = newTransport(CaFile) 151 | if err != nil { 152 | return nil, err 153 | } 154 | 155 | client, err := raven.New(DSN) 156 | if err != nil { 157 | return nil, err 158 | } 159 | 160 | client.SetRelease(release) 161 | 162 | client.Transport = transport 163 | 164 | // Sentry on the configured logrus levels: 165 | hook, err := logrus_sentry.NewWithClientSentryHook(client, []logrus.Level{ 166 | logrus.PanicLevel, 167 | logrus.FatalLevel, 168 | logrus.ErrorLevel, 169 | logrus.WarnLevel, 170 | }) 171 | hook.StacktraceConfiguration.Enable = true 172 | hook.Timeout = 1 * time.Second 173 | 174 | return hook, err 175 | } 176 | -------------------------------------------------------------------------------- /cmd/keyunwrap/README.md: -------------------------------------------------------------------------------- 1 | Keyunwrap is intended to be used on an offline (or mostly offline) computer 2 | as part of restoring a Keysync backup, along with Keyrestore used on the 3 | online side. -------------------------------------------------------------------------------- /cmd/keyunwrap/keyunwrap.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "os" 10 | 11 | "github.com/square/keysync/backup" 12 | "golang.org/x/crypto/nacl/box" 13 | 14 | "gopkg.in/alecthomas/kingpin.v2" 15 | ) 16 | 17 | var b64 = base64.StdEncoding 18 | 19 | func main() { 20 | var ( 21 | app = kingpin.New("keyunwrap", "A tool to unwrap a keysync backup key") 22 | privateKeyFile = app.Flag("privatekeyfile", "The offline private key").Required().String() 23 | generateCmd = app.Command("generate", "Generate a new key pair") 24 | unwrapCmd = app.Command("unwrap", "Unwrap a backup key") 25 | publicKeyFile = generateCmd.Flag("publickeyfile", "Generate public key here").Required().String() 26 | wrappedKey = unwrapCmd.Flag("wrapped", "The wrapped backup key").Required().ExistingFile() 27 | ) 28 | 29 | switch kingpin.MustParse(app.Parse(os.Args[1:])) { 30 | case generateCmd.FullCommand(): 31 | if err := generateKey(*privateKeyFile, *publicKeyFile); err != nil { 32 | log.Fatal(err.Error()) 33 | } 34 | case unwrapCmd.FullCommand(): 35 | key, err := unwrap(*privateKeyFile, *wrappedKey) 36 | if err != nil { 37 | log.Fatal(err.Error()) 38 | } 39 | // In the success case we just print the key out 40 | fmt.Println(b64.EncodeToString(key)) 41 | } 42 | } 43 | 44 | // unwrap reads files and Unwraps. 45 | func unwrap(privateKeyFile, wrappedKey string) ([]byte, error) { 46 | privKey, err := ioutil.ReadFile(privateKeyFile) 47 | if err != nil { 48 | return nil, fmt.Errorf("error reading private key: %v", err) 49 | } 50 | 51 | wrapped, err := ioutil.ReadFile(wrappedKey) 52 | if err != nil { 53 | return nil, fmt.Errorf("error reading wrapped key: %v", err) 54 | } 55 | 56 | return backup.Unwrap(wrapped, privKey) 57 | } 58 | 59 | func generateKey(privateKeyFile, publicKeyFile string) error { 60 | if info, err := os.Stat(privateKeyFile); !os.IsNotExist(err) { 61 | return fmt.Errorf("expected private key to not exist, instead got %v: %v", info, err) 62 | } 63 | 64 | pubkey, privkey, err := box.GenerateKey(rand.Reader) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | // Base64 encode the pubkey to make it "friendlier" for putting in configuration yamls 70 | b64pubkey := make([]byte, b64.EncodedLen(len(pubkey))) 71 | b64.Encode(b64pubkey, pubkey[:]) 72 | 73 | if err := ioutil.WriteFile(publicKeyFile, b64pubkey, 0444); err != nil { 74 | return fmt.Errorf("error writing private key: %v", err) 75 | } 76 | 77 | // Don't base64 the private key to make it harder to confuse with the public key. 78 | if err := ioutil.WriteFile(privateKeyFile, privkey[:], 0400); err != nil { 79 | return fmt.Errorf("error writing private key: %v", err) 80 | } 81 | 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /cmd/monitor/checks.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "crypto/tls" 19 | "crypto/x509" 20 | "encoding/json" 21 | "fmt" 22 | "io/ioutil" 23 | "net/http" 24 | "os" 25 | "path" 26 | "syscall" 27 | "time" 28 | 29 | "github.com/square/keysync" 30 | ) 31 | 32 | func checkPaths(config *keysync.Config) []error { 33 | var errs []error 34 | errs = append(errs, directoryExists(config.SecretsDir)...) 35 | errs = append(errs, directoryExists(config.ClientsDir)...) 36 | errs = append(errs, fileExists(config.CaFile)...) 37 | return errs 38 | } 39 | 40 | func checkClientHealth(config *keysync.Config) []error { 41 | clients, err := config.LoadClients() 42 | if err != nil { 43 | return []error{fmt.Errorf("unable to load clients: %s", err)} 44 | } 45 | 46 | var errs []error 47 | for name, client := range clients { 48 | // MinCertLifetime, if not set in the config, will default to zero. 49 | // In that case this check will still work but only alert if the 50 | // certificate is *already* expired. 51 | if err := checkCertificate(name, &client, config.Monitor.MinCertLifetime); err != nil { 52 | errs = append(errs, err) 53 | } 54 | 55 | // Check that each client has at least one secret. It makes no 56 | // sense to have a client without secrets, so if there's an empty 57 | // client dir something is probably wrong. 58 | if err := checkHasSecrets(name, &client, config.SecretsDir, config.Monitor.MinSecretsCount); err != nil { 59 | errs = append(errs, err) 60 | } 61 | 62 | } 63 | 64 | return errs 65 | } 66 | 67 | func checkCertificate(name string, client *keysync.ClientConfig, minCertLifetime time.Duration) error { 68 | if client.Key == "" { 69 | return fmt.Errorf("no key specified in config for client %s", name) 70 | } 71 | 72 | keyPair, err := tls.LoadX509KeyPair(client.Cert, client.Key) 73 | if err != nil { 74 | return fmt.Errorf("unable to load certificate and key for client %s: %s", name, err) 75 | } 76 | 77 | leaf, err := x509.ParseCertificate(keyPair.Certificate[0]) 78 | if err != nil { 79 | return fmt.Errorf("invalid client certificate for client %s: %s", name, err) 80 | } 81 | 82 | now := time.Now() 83 | if leaf.NotAfter.Before(now) { 84 | return fmt.Errorf("expired client certificate for client %s: NotAfter %s", name, leaf.NotAfter.Format(time.RFC3339)) 85 | } 86 | 87 | if expiryThreshold := now.Add(minCertLifetime); leaf.NotAfter.Before(expiryThreshold) { 88 | remaining := leaf.NotAfter.Sub(now).String() 89 | return fmt.Errorf("expiring client certificate for client %s: NotAfter %s is in %s", name, leaf.NotAfter.Format(time.RFC3339), remaining) 90 | } 91 | 92 | return nil 93 | } 94 | 95 | func checkHasSecrets(name string, client *keysync.ClientConfig, secretsDir string, minSecretsCount int) error { 96 | dir := path.Join(secretsDir, client.DirName) 97 | files, err := ioutil.ReadDir(dir) 98 | if err != nil { 99 | return fmt.Errorf("unable to open secrets dir for client %s: %s", name, err) 100 | } 101 | 102 | if len(files) < minSecretsCount { 103 | return fmt.Errorf("client %s appears to have zero secrets", name) 104 | } 105 | 106 | return nil 107 | } 108 | 109 | func checkServerHealth(config *keysync.Config) []error { 110 | url := fmt.Sprintf("http://localhost:%d/status", config.APIPort) 111 | 112 | resp, err := http.Get(url) 113 | if err != nil { 114 | return []error{fmt.Errorf("unable to talk to status: %s", err)} 115 | } 116 | 117 | body, err := ioutil.ReadAll(resp.Body) 118 | if err != nil { 119 | return []error{fmt.Errorf("unable to talk to status: %s", err)} 120 | } 121 | 122 | status := &keysync.StatusResponse{} 123 | err = json.Unmarshal(body, &status) 124 | if err != nil { 125 | return []error{fmt.Errorf("invalid JSON status response: %s", err)} 126 | } 127 | 128 | if !status.Ok { 129 | return []error{fmt.Errorf("keysync unhealthy: %s", status.Message)} 130 | } 131 | 132 | return nil 133 | } 134 | 135 | func checkDiskUsage(config *keysync.Config) []error { 136 | fs := syscall.Statfs_t{} 137 | if err := syscall.Statfs(config.SecretsDir, &fs); err != nil { 138 | return []error{fmt.Errorf("could not statfs secrets dir: %v", err)} 139 | } 140 | 141 | // Relative free space is number of free blocks divided by number of total blocks 142 | freeSpace := float64(fs.Bfree) / float64(fs.Blocks) 143 | if freeSpace < 0.1 { 144 | return []error{fmt.Errorf("disk usage of '%s' is above 90%% (blocks: %d free, %d total)", config.SecretsDir, fs.Bfree, fs.Blocks)} 145 | } 146 | 147 | return nil 148 | } 149 | 150 | func fileExists(path string) []error { 151 | fi, err := os.Stat(path) 152 | if err != nil || fi.IsDir() { 153 | return []error{fmt.Errorf("expected '%s' to be a file", path)} 154 | } 155 | return nil 156 | } 157 | 158 | func directoryExists(path string) []error { 159 | fi, err := os.Stat(path) 160 | if err != nil || !fi.IsDir() { 161 | return []error{fmt.Errorf("expected '%s' to be a directory", path)} 162 | } 163 | return nil 164 | } 165 | -------------------------------------------------------------------------------- /cmd/monitor/checks_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "io/ioutil" 19 | "os" 20 | "path" 21 | "strings" 22 | "testing" 23 | "time" 24 | 25 | "github.com/square/keysync" 26 | "github.com/stretchr/testify/assert" 27 | yaml "gopkg.in/yaml.v2" 28 | ) 29 | 30 | func setupTestEnvironment(t *testing.T) *keysync.Config { 31 | secretsDir, err := ioutil.TempDir("", "keysync-test") 32 | assert.Nil(t, err) 33 | 34 | clientsDir, err := ioutil.TempDir("", "keysync-test") 35 | assert.Nil(t, err) 36 | 37 | certPath := path.Join(clientsDir, "test-client.crt") 38 | keyPath := path.Join(clientsDir, "test-client.key") 39 | 40 | assert.NoError(t, os.MkdirAll(path.Join(secretsDir, "client"), 0700)) 41 | assert.NoError(t, ioutil.WriteFile(certPath, []byte(strings.TrimSpace(testClientCert)), 0600)) 42 | assert.NoError(t, ioutil.WriteFile(keyPath, []byte(strings.TrimSpace(testClientKey)), 0600)) 43 | 44 | config := &keysync.Config{ 45 | SecretsDir: secretsDir, 46 | ClientsDir: clientsDir, 47 | YamlExt: ".yaml", 48 | Monitor: keysync.MonitorConfig{ 49 | MinCertLifetime: 10 * 365 * 24 * time.Hour, 50 | MinSecretsCount: 1, 51 | }, 52 | } 53 | 54 | client := struct { 55 | Client *keysync.ClientConfig `yaml:"client"` 56 | }{ 57 | Client: &keysync.ClientConfig{ 58 | Cert: certPath, 59 | Key: keyPath, 60 | }, 61 | } 62 | 63 | clientYAML, err := yaml.Marshal(client) 64 | assert.Nil(t, err) 65 | 66 | clientPath := path.Join(clientsDir, "client.yaml") 67 | assert.NoError(t, ioutil.WriteFile(clientPath, clientYAML, 0600)) 68 | 69 | return config 70 | } 71 | 72 | func cleanupTestEnvironment(t *testing.T, config *keysync.Config) { 73 | os.RemoveAll(config.SecretsDir) 74 | os.RemoveAll(config.ClientsDir) 75 | } 76 | 77 | func assertError(t *testing.T, errs []error, expected string) { 78 | for _, err := range errs { 79 | if strings.Contains(err.Error(), expected) { 80 | return 81 | } 82 | } 83 | 84 | t.Fatalf("expected error '%s', but was not in error list: %q", expected, errs) 85 | } 86 | 87 | func TestCheckClientHealth(t *testing.T) { 88 | config := setupTestEnvironment(t) 89 | defer cleanupTestEnvironment(t, config) 90 | 91 | errs := checkClientHealth(config) 92 | 93 | // No secrets created by default, check that we caught that. 94 | assertError(t, errs, "client appears to have zero secrets") 95 | 96 | // Client certificate has a NotAfter of 2020-12-05 23:52 UTC 97 | assertError(t, errs, "expired client certificate") 98 | } 99 | -------------------------------------------------------------------------------- /cmd/monitor/email.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import "net/smtp" 18 | 19 | type emailSender interface { 20 | sendMail(addr string, from string, to []string, msg []byte) error 21 | } 22 | 23 | type realEmailSender struct{} 24 | 25 | func (res *realEmailSender) sendMail(addr string, from string, to []string, msg []byte) error { 26 | return smtp.SendMail(addr, nil, from, to, msg) 27 | } 28 | -------------------------------------------------------------------------------- /cmd/monitor/fixtures_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const testClientCert = ` 4 | -----BEGIN CERTIFICATE----- 5 | MIIEIzCCAgugAwIBAgIQfAw6Uerc57T8oeb++YNJjTANBgkqhkiG9w0BAQsFADAS 6 | MRAwDgYDVQQDEwd0ZXN0LWNhMB4XDTE5MDYwNTIzNTIyN1oXDTIwMTIwNTIzNTIw 7 | OVowFjEUMBIGA1UEAxMLdGVzdC1jbGllbnQwggEiMA0GCSqGSIb3DQEBAQUAA4IB 8 | DwAwggEKAoIBAQDXVzsZZ1DgwU8cyryTM6hgKzvJu38rvl5iRM+LX2p/sOAIa704 9 | YFcJcdFJk8NGmgHofN0+n9v9Yd6eyPLMUSumC2ZO2zAUht+o5/Ylh0q0LwoMp8wN 10 | 0f3E/Lem1+YB2IskNn4yUH6AdRJ729fW8ZC1kCaYlOMMd4ZFLNiAMnGTc967PAKK 11 | weYiFTiPTl3EP8fgUHUq1SQUiJVoKRLM0GDyWAJ6CQmLrnQhMIIjkfbFCGtxkSOf 12 | KxMZgWgI6u4FRhDHbbrgg+kpVESHgIyIE+4xYpfKBmfdAyRH+oSsnKrgG+bWzX3d 13 | X1dbLexngOM5jKt/TgR9xF1ENz5f6v5jgLP5AgMBAAGjcTBvMA4GA1UdDwEB/wQE 14 | AwIDuDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0OBBYEFKdi 15 | 8tVEEmcv11aE5QpNXnT8XEFCMB8GA1UdIwQYMBaAFI7JTHW7SGnsZe8u6YODCcz7 16 | iFLtMA0GCSqGSIb3DQEBCwUAA4ICAQBw+A5KKrMRIM0X6fob0iNziwXafGkCIfHx 17 | a2ANL/J/7ecWZruqfFs1BA1nqRAMhyaEbhqstJhnjGiKF8j+OypjEJ1/irEcsmdO 18 | arwHoULBn4ny6O3AlJRWfRzwf9SgCM7mge259ypJ+MIjnVXAzpX8KkwJMTHskRA4 19 | 9iPDqdezixcoCJWLiZdDrREwbn+coE56cCoZTQDi6GvGivGm6TY4T5pKoFKyW1OS 20 | w8HaEw/8l/a/8mFQqXw2sw8llgh1ax+9uWPM3DRzGvHzxj5Zzori6DUi4XNy1x+e 21 | dBlygBzXTd99/lkqazozLjMvp+5sLqKDIaX3JltF7QWimH3lotN7BpXbCxzceFK3 22 | AR6rSwIR+k/CHTYu1xWj2+jCiYJi+8JlmoE3F6NUeOzjqG91ULphVv1eMPUqRUvd 23 | mDT328upB611PLCPQaSeT0Ac3nl5u5ubqr6v4P3BBgd4CQ4cYbSYljgNrXBeVQT7 24 | 3KnDlYif+O9G67NEmgS1NsHrzNpKnWbLcK3MhDpptKyzC3n2A5GTDOr9IvH4ReZv 25 | YC22u2rVUP2MOX3e57jUHZYHWibfdwsXiDSBae5edNx57SQE3XKbpknEzJ6DfTyK 26 | GyYNRiBlSzUJThduJXIrf9SMWYSHba4qiJPcbFb+usYlSxSz73EmLxZPVqFJOMeI 27 | ColmQ+Armw== 28 | -----END CERTIFICATE-----` 29 | 30 | const testClientKey = ` 31 | -----BEGIN RSA PRIVATE KEY----- 32 | MIIEpQIBAAKCAQEA11c7GWdQ4MFPHMq8kzOoYCs7ybt/K75eYkTPi19qf7DgCGu9 33 | OGBXCXHRSZPDRpoB6HzdPp/b/WHensjyzFErpgtmTtswFIbfqOf2JYdKtC8KDKfM 34 | DdH9xPy3ptfmAdiLJDZ+MlB+gHUSe9vX1vGQtZAmmJTjDHeGRSzYgDJxk3PeuzwC 35 | isHmIhU4j05dxD/H4FB1KtUkFIiVaCkSzNBg8lgCegkJi650ITCCI5H2xQhrcZEj 36 | nysTGYFoCOruBUYQx2264IPpKVREh4CMiBPuMWKXygZn3QMkR/qErJyq4Bvm1s19 37 | 3V9XWy3sZ4DjOYyrf04EfcRdRDc+X+r+Y4Cz+QIDAQABAoIBAEhyx6ZfVR2Yy+YS 38 | 62jW62IXiZDwbPOpo5WKMw1f97OoLWeH3+x0tTOvQEtx1DQPom62e7UTEW9pGv3u 39 | +4j0EixWD4CeS8nMKrln+S9dGiwO22Gwnn1T1f4NTDhs0Kx0TzPKxaBl5nmPab2U 40 | FETzls0PB300MkNCf0EMunY/Amkp55Mafnt7mFTrWXEHlLrXQSTXCyT27p8QP2ZY 41 | XvKsymPo+b3rY+uHhC9WyyPwTxFOJ+mSnVgdz4AzWVmXFf9A3WZAJyMkXTNp0QxJ 42 | ddgjGgaIjb6rnKLTvUx+couxcCcRC+3KCjUqhbV1cOtLcA5SQv+qvB7RdzjPt9UU 43 | 7YrxUSUCgYEA78J5MZr/cvCNXPG074pisSW/bM+cg8F19nxcWU5m5qqqs7dXn15g 44 | DXHbVcjMh2VDjjKASt8zcM8l1hVZR4FD9gQFFwUH5TOxa+XFZO5Bxq5JxImOi6/v 45 | 4Z4qNY4cmqTrjkSC18wWJV5XJswIGwkYmLyztl2C8m/DF26V6anwKiMCgYEA5e1S 46 | 6EYhE+ZtEXjr5xwVNtGxRnL/pY+WlJSzS09A0SFJ1TkVc+VZ1EQ53xQC+HytMxMI 47 | XEDpal/H48V4XhjVULRR/r2voby7mycnMFwfkkBvMUhe7cLJz5bDiXvHez1GxBM0 48 | EPmCv/72QVT3+4uaketS6ic0E4zvB7xqGa8d5TMCgYEAmcQAlAbTE7UhBF3j68i1 49 | 2OTbqv5PY9S8QdOqKoB00DTee5n3MTeGpLjDsXWxbphMRjMvQlV5mTzRCEby1kAa 50 | BPq5BPVuBdosTIW1HjELsE9w8gJCkGXKk8krSuOUhr2EcN6Rh7LU9SxW+oPaIvSn 51 | eLV1EF1SsQdqeGms7YnWhD8CgYEAlFx+ksIttdmJpyyPi6DjT2wfJ4Ysz3fHgjgx 52 | OPb6q/b+3UboQNBNFlqvvPH8uLo6SCqPyndYJfuHz8Er2gvMGTdBcU6UdZiCtaqd 53 | 7uaCuKw7E2HPXzvBXOG7aoskPLFdaEe74PgfFiQ+Ygmhuf5qzp058z04rSTTV/qL 54 | u+bzM2ECgYEArroABDTFVsSnxQXMuw4SgJ2XHe6KBMB/5UTEcqjRIy0131OlY963 55 | PPngrpsqmO5Lq7lD5mwEOhzHhiI8sHYL9KOYTRp91lscMXF38XsQWcIgkH1ePXuR 56 | 05Wa9LPhH9CYyzSWq1IWaWA/PmL35EpVIvJX2Y3OqBUcMxmWKfN+Xfc= 57 | -----END RSA PRIVATE KEY-----` 58 | -------------------------------------------------------------------------------- /cmd/monitor/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "bytes" 19 | "fmt" 20 | "os" 21 | 22 | "github.com/square/keysync" 23 | kingpin "gopkg.in/alecthomas/kingpin.v2" 24 | ) 25 | 26 | func main() { 27 | var ( 28 | app = kingpin.New("keysync-monitor", "Health check/monitor for keysync") 29 | configFile = app.Flag("config", "The base YAML configuration file").PlaceHolder("config.yaml").Required().String() 30 | ) 31 | kingpin.MustParse(app.Parse(os.Args[1:])) 32 | 33 | config, err := keysync.LoadConfig(*configFile) 34 | if err != nil { 35 | fmt.Fprintf(os.Stderr, "failed loading configuration: %s\n", err) 36 | os.Exit(1) 37 | } 38 | 39 | checks := []func(*keysync.Config) []error{ 40 | checkPaths, 41 | checkServerHealth, 42 | checkClientHealth, 43 | checkDiskUsage, 44 | } 45 | 46 | errs := runChecks(config, checks) 47 | if len(errs) > 0 { 48 | printErrors(errs) 49 | emailErrors(config.Monitor, errs, &realEmailSender{}) 50 | os.Exit(1) 51 | } 52 | } 53 | 54 | func runChecks(config *keysync.Config, checks []func(*keysync.Config) []error) []error { 55 | errs := []error{} 56 | for _, check := range checks { 57 | e := check(config) 58 | if len(e) > 0 { 59 | errs = append(errs, e...) 60 | return errs 61 | } 62 | } 63 | return errs 64 | } 65 | 66 | func printErrors(errs []error) { 67 | fmt.Fprintf(os.Stderr, "found the following problems:\n") 68 | for _, err := range errs { 69 | fmt.Fprintf(os.Stderr, "- %s\n", err) 70 | } 71 | } 72 | 73 | func emailErrors(config keysync.MonitorConfig, errs []error, sender emailSender) { 74 | if config.AlertEmailRecipient == "" { 75 | return 76 | } 77 | 78 | hostname, err := os.Hostname() 79 | if err != nil { 80 | panic(err) 81 | } 82 | 83 | addr := config.AlertEmailServer 84 | from := config.AlertEmailSender 85 | rcpt := config.AlertEmailRecipient 86 | 87 | if addr == "" { 88 | addr = "localhost:25" 89 | } 90 | 91 | if from == "" { 92 | from = fmt.Sprintf("%s@%s", "keysync-monitor", hostname) 93 | } 94 | 95 | message := bytes.Buffer{} 96 | writeHeader(&message, "To", rcpt) 97 | writeHeader(&message, "From", from) 98 | writeHeader(&message, "Subject", hostname) 99 | 100 | message.WriteString("Monitor found the following problems:\r\n") 101 | for _, err := range errs { 102 | message.WriteString("- ") 103 | message.WriteString(err.Error()) 104 | message.WriteString("\r\n") 105 | } 106 | 107 | if err := sender.sendMail(addr, from, []string{rcpt}, message.Bytes()); err != nil { 108 | panic(err) 109 | } 110 | } 111 | 112 | func writeHeader(buf *bytes.Buffer, header, value string) { 113 | buf.WriteString(header) 114 | buf.WriteString(": ") 115 | buf.WriteString(value) 116 | buf.WriteString("\r\n") 117 | } 118 | -------------------------------------------------------------------------------- /cmd/monitor/main_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "errors" 19 | "fmt" 20 | "os" 21 | "testing" 22 | 23 | "github.com/square/keysync" 24 | "github.com/stretchr/testify/assert" 25 | ) 26 | 27 | type fakeEmailSender struct { 28 | invocations []sendMailInvocation 29 | } 30 | 31 | type sendMailInvocation struct { 32 | addr, from string 33 | to []string 34 | msg []byte 35 | } 36 | 37 | func (fes *fakeEmailSender) sendMail(addr string, from string, to []string, msg []byte) error { 38 | fes.invocations = append(fes.invocations, sendMailInvocation{ 39 | addr: addr, 40 | from: from, 41 | to: to, 42 | msg: msg, 43 | }) 44 | return nil 45 | } 46 | 47 | func TestAlertEmail(t *testing.T) { 48 | hostname, err := os.Hostname() 49 | assert.Nil(t, err) 50 | 51 | config := keysync.MonitorConfig{ 52 | AlertEmailServer: "localhost:smtp", 53 | AlertEmailRecipient: "foo@example.org", 54 | AlertEmailSender: "bar@example.org", 55 | } 56 | 57 | mockSender := &fakeEmailSender{invocations: []sendMailInvocation{}} 58 | 59 | // Should trigger an email 60 | emailErrors(config, []error{errors.New("test failure")}, mockSender) 61 | 62 | assert.Len(t, mockSender.invocations, 1) 63 | 64 | call := mockSender.invocations[0] 65 | assert.Equal(t, call.addr, config.AlertEmailServer) 66 | assert.Equal(t, call.from, config.AlertEmailSender) 67 | 68 | assert.Len(t, call.to, 1) 69 | assert.Equal(t, call.to[0], config.AlertEmailRecipient) 70 | 71 | assert.Contains(t, string(call.msg), "To: foo@example.org\r\n") 72 | assert.Contains(t, string(call.msg), "From: bar@example.org\r\n") 73 | assert.Contains(t, string(call.msg), fmt.Sprintf("Subject: %s\r\n", hostname)) 74 | assert.Contains(t, string(call.msg), "- test failure") 75 | } 76 | 77 | func TestAlertEmailDefaults(t *testing.T) { 78 | hostname, err := os.Hostname() 79 | assert.Nil(t, err) 80 | 81 | config := keysync.MonitorConfig{ 82 | AlertEmailRecipient: "foo@example.org", 83 | } 84 | 85 | mockSender := &fakeEmailSender{invocations: []sendMailInvocation{}} 86 | 87 | // Should trigger an email 88 | emailErrors(config, []error{errors.New("test failure")}, mockSender) 89 | 90 | assert.Len(t, mockSender.invocations, 1) 91 | 92 | call := mockSender.invocations[0] 93 | expectedFrom := fmt.Sprintf("%s@%s", "keysync-monitor", hostname) 94 | 95 | assert.Equal(t, call.addr, "localhost:25") 96 | assert.Equal(t, call.from, expectedFrom) 97 | 98 | assert.Len(t, call.to, 1) 99 | assert.Equal(t, call.to[0], config.AlertEmailRecipient) 100 | 101 | assert.Contains(t, string(call.msg), "To: foo@example.org\r\n") 102 | assert.Contains(t, string(call.msg), fmt.Sprintf("From: %s\r\n", expectedFrom)) 103 | assert.Contains(t, string(call.msg), fmt.Sprintf("Subject: %s\r\n", hostname)) 104 | assert.Contains(t, string(call.msg), "- test failure") 105 | } 106 | 107 | func TestAlertEmailNoRecipient(t *testing.T) { 108 | config := keysync.MonitorConfig{} 109 | mockSender := &fakeEmailSender{invocations: []sendMailInvocation{}} 110 | 111 | // Should *not* trigger an email 112 | emailErrors(config, []error{errors.New("test failure")}, mockSender) 113 | 114 | assert.Len(t, mockSender.invocations, 0) 115 | } 116 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package keysync 16 | 17 | import ( 18 | "encoding/base64" 19 | "errors" 20 | "fmt" 21 | "io/ioutil" 22 | "path/filepath" 23 | "strings" 24 | "time" 25 | 26 | "github.com/square/keysync/backup" 27 | "github.com/square/keysync/output" 28 | 29 | yaml "gopkg.in/yaml.v2" 30 | ) 31 | 32 | // Config is the main yaml configuration file passed to the keysync binary 33 | type Config struct { 34 | ClientsDir string `yaml:"client_directory"` // A directory of configuration files 35 | SecretsDir string `yaml:"secrets_directory"` // The directory secrets will be written to 36 | CaFile string `yaml:"ca_file"` // The CA to trust (PEM) for Keywhiz communication 37 | YamlExt string `yaml:"yaml_ext"` // The filename extension of the yaml config files 38 | PollInterval string `yaml:"poll_interval"` // If specified, poll at the given interval, otherwise, exit after syncing 39 | ClientTimeout string `yaml:"client_timeout"` // If specified, timeout client connections after specified duration, otherwise use default. 40 | MinBackoff string `yaml:"min_backoff"` // If specified, wait time before first retry, otherwise, use default. 41 | MaxBackoff string `yaml:"max_backoff"` // If specified, max wait time before retries, otherwise, use default. 42 | MaxRetries uint16 `yaml:"max_retries"` // If specified, retry each HTTP call after non-200 response 43 | Server string `yaml:"server"` // The server to connect to (host:port) 44 | Debug bool `yaml:"debug"` // Enable debugging output 45 | DefaultUser string `yaml:"default_user"` // Default user to own files 46 | DefaultGroup string `yaml:"default_group"` // Default group to own files 47 | APIPort uint16 `yaml:"api_port"` // Port for API to listen on 48 | SentryDSN string `yaml:"sentry_dsn"` // Sentry DSN 49 | SentryCaFile string `yaml:"sentry_ca_file"` // The CA to trust (PEM) for Sentry communication 50 | FsType output.Filesystem `yaml:"filesystem_type"` // Enforce writing this type of filesystem. Use value from statfs. 51 | ChownFiles bool `yaml:"chown_files"` // Do we chown files? Set to false when running without CAP_CHOWN. 52 | MetricsPrefix string `yaml:"metrics_prefix"` // Prefix metric names with this 53 | Monitor MonitorConfig `yaml:"monitor"` // Config for monitoring/alerts 54 | BackupPath string `yaml:"backup_path"` // If specified, back up secrets as an encrypted tarball to this location 55 | BackupKeyPath string `yaml:"backup_key_path"` // write wrapped key encrypting the backup to this location 56 | BackupPubkey string `yaml:"backup_pubkey"` // Public key to wrap backup keys to, from keyunwrap --generate 57 | } 58 | 59 | // The MonitorConfig has extra settings for monitoring/alerts. 60 | type MonitorConfig struct { 61 | MinCertLifetime time.Duration `yaml:"min_cert_lifetime"` // If specified, warn if cert does not have given min lifetime. 62 | MinSecretsCount int `yaml:"min_secrets_count"` // If specified, warn if client has less than minimum number of secrets 63 | AlertEmailServer string `yaml:"alert_email_server"` // For alert emails: SMTP server host:port to use for sending email 64 | AlertEmailRecipient string `yaml:"alert_email_recipient"` // For alert emails: Recipient of alert emails 65 | AlertEmailSender string `yaml:"alert_email_sender"` // For alert emails: Sender (from) for alert emails 66 | } 67 | 68 | // The ClientConfig describes a single Keywhiz client. There are typically many of these per keysync instance. 69 | type ClientConfig struct { 70 | Key string `yaml:"key"` // Mandatory: Path to PEM key to use 71 | Cert string `yaml:"cert"` // Optional: PEM Certificate (If cert isn't in key file) 72 | User string `yaml:"user"` // Optional: User and Group are defaults for files without metadata 73 | DirName string `yaml:"directory"` // Optional: What directory under SecretsDir this client is in. Defaults to the client name. 74 | Group string `yaml:"group"` // Optional: If unspecified, the global defaults are used. 75 | MaxRetries uint16 76 | Timeout string 77 | MinBackoff string 78 | MaxBackoff string 79 | } 80 | 81 | // LoadConfig loads the "global" keysync configuration file. This would generally be called on startup. 82 | func LoadConfig(configFile string) (*Config, error) { 83 | var config Config 84 | data, err := ioutil.ReadFile(configFile) 85 | if err != nil { 86 | return nil, fmt.Errorf("loading config %s: %v", configFile, err) 87 | } 88 | err = yaml.Unmarshal(data, &config) 89 | if err != nil { 90 | return nil, fmt.Errorf("parsing config file: %v", err) 91 | } 92 | 93 | if config.SecretsDir == "" { 94 | return nil, fmt.Errorf("mandatory config secrets_directory not provided: %s", configFile) 95 | } 96 | 97 | // Must specify both or neither of BackupKeyPath and BackupPath 98 | if config.BackupKeyPath != "" && config.BackupPath == "" { 99 | return nil, fmt.Errorf("backup_key_path specified (%s) without backup_path", config.BackupKeyPath) 100 | } 101 | 102 | if config.BackupKeyPath == "" && config.BackupPath != "" { 103 | return nil, fmt.Errorf("backup_key specified (%s) without backup_key_path", config.BackupPath) 104 | } 105 | 106 | if config.MaxRetries < 1 { 107 | config.MaxRetries = 1 108 | } 109 | 110 | if config.ClientTimeout == "" { 111 | config.ClientTimeout = "60s" 112 | } 113 | 114 | if config.MinBackoff == "" { 115 | config.MinBackoff = "100ms" 116 | } 117 | 118 | if config.MaxBackoff == "" { 119 | config.MaxBackoff = "10s" 120 | } 121 | 122 | return &config, nil 123 | } 124 | 125 | func BackupFromConfig(cfg *Config) (backup.Backup, error) { 126 | var fileBackup backup.Backup = nil 127 | if cfg.BackupPath != "" && cfg.BackupKeyPath != "" { 128 | // Public key is base64 encoded in yaml, but the yaml decoder doesn't automatically load 129 | // []byte like json, so we do it here. 130 | pubkeyslice, err := base64.StdEncoding.DecodeString(cfg.BackupPubkey) 131 | if err != nil { 132 | return nil, err 133 | } 134 | if len(pubkeyslice) != 32 { 135 | return nil, fmt.Errorf("public key wasn't 32 bytes: %d", len(pubkeyslice)) 136 | } 137 | // Fix type to fixed-length array 138 | var pubkey [32]byte 139 | copy(pubkey[:], pubkeyslice) 140 | 141 | fileBackup = &backup.FileBackup{ 142 | SecretsDirectory: cfg.SecretsDir, 143 | BackupPath: cfg.BackupPath, 144 | BackupKeyPath: cfg.BackupKeyPath, 145 | Pubkey: &pubkey, 146 | Chown: cfg.ChownFiles, 147 | EnforceFS: cfg.FsType, 148 | } 149 | } 150 | return fileBackup, nil 151 | } 152 | 153 | // LoadClients looks in directory for files with suffix, and tries to load them 154 | // as Yaml files describing clients for Keysync to load 155 | // We filter by the yaml extension so we can keep configs and keys in the same directory 156 | func (config *Config) LoadClients() (map[string]ClientConfig, error) { 157 | files, err := ioutil.ReadDir(config.ClientsDir) 158 | if err != nil { 159 | return nil, fmt.Errorf("failed opening directory %s: %+v", config.ClientsDir, err) 160 | } 161 | configs := map[string]ClientConfig{} 162 | for _, file := range files { 163 | fileName := file.Name() 164 | if strings.HasSuffix(fileName, config.YamlExt) { 165 | // Read data into data 166 | data, err := ioutil.ReadFile(filepath.Join(config.ClientsDir, fileName)) 167 | if err != nil { 168 | return nil, fmt.Errorf("failed opening %s: %+v", fileName, err) 169 | } 170 | var newClients map[string]ClientConfig 171 | err = yaml.Unmarshal(data, &newClients) 172 | if err != nil { 173 | return nil, fmt.Errorf("failed parsing %s: %+v", fileName, err) 174 | } 175 | for name, client := range newClients { 176 | // TODO: Check if this is a duplicate. 177 | if client.DirName == "" { 178 | client.DirName = name 179 | } 180 | 181 | client.setDefaults(config) 182 | if err := client.validate(); err != nil { 183 | return nil, fmt.Errorf("failed validating %s: %+v", fileName, err) 184 | } 185 | client.resolveKeyPair(config) 186 | 187 | configs[name] = client 188 | } 189 | } 190 | } 191 | return configs, nil 192 | } 193 | 194 | func (c *ClientConfig) setDefaults(cfg *Config) { 195 | c.MinBackoff = cfg.MinBackoff 196 | c.MaxBackoff = cfg.MaxBackoff 197 | c.MaxRetries = cfg.MaxRetries 198 | c.Timeout = cfg.ClientTimeout 199 | } 200 | 201 | func (c *ClientConfig) validate() error { 202 | if c.Key == "" { 203 | return errors.New("no key in config") 204 | } 205 | 206 | return nil 207 | } 208 | 209 | func (c *ClientConfig) resolveKeyPair(cfg *Config) { 210 | c.Key = resolvePath(cfg.ClientsDir, c.Key) 211 | if c.Cert != "" { 212 | c.Cert = resolvePath(cfg.ClientsDir, c.Cert) 213 | } else { 214 | // If no cert is provided, it's in the Key file. 215 | c.Cert = c.Key 216 | } 217 | } 218 | 219 | // resolvePath returns path if it's absolute, and joins it to directory otherwise. 220 | func resolvePath(directory, path string) string { 221 | if filepath.IsAbs(path) { 222 | return path 223 | } 224 | return filepath.Join(directory, path) 225 | } 226 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package keysync 16 | 17 | import ( 18 | "fmt" 19 | "testing" 20 | 21 | "github.com/stretchr/testify/assert" 22 | "github.com/stretchr/testify/require" 23 | ) 24 | 25 | func TestConfigLoadConfigSuccess(t *testing.T) { 26 | newAssert := assert.New(t) 27 | 28 | config, err := LoadConfig("fixtures/configs/test-config.yaml") 29 | require.Nil(t, err) 30 | newAssert.Equal("fixtures/clients", config.ClientsDir) 31 | newAssert.Equal("fixtures/CA/cacert.crt", config.CaFile) 32 | newAssert.Equal("yaml", config.YamlExt) 33 | newAssert.False(config.ChownFiles) 34 | newAssert.Equal("localhost:4444", config.Server) 35 | newAssert.True(config.Debug) 36 | newAssert.Equal("keysync-test", config.DefaultUser) 37 | newAssert.Equal("keysync-test", config.DefaultGroup) 38 | newAssert.EqualValues(31738, config.APIPort) 39 | newAssert.Equal("60s", config.PollInterval) 40 | 41 | // TODO: Test loading defaults 42 | } 43 | 44 | func TestConfigLoadConfigMissingOrInvalidFiles(t *testing.T) { 45 | newAssert := assert.New(t) 46 | 47 | _, err := LoadConfig("non-existent") 48 | newAssert.NotNil(err) 49 | 50 | _, err = LoadConfig("fixtures/configs/errorconfigs/notyaml-test-config.yaml") 51 | newAssert.NotNil(err) 52 | 53 | // TODO: Uncomment if we add validation for the client dir and CA file 54 | //_, err := LoadConfig("fixtures/configs/errorconfigs/nonexistent-client-dir-config.yaml") 55 | //newAssert.Nil(err) 56 | 57 | //_, err = LoadConfig("fixtures/configs/errorconfigs/nonexistent-ca-file-config.yaml") 58 | //newAssert.NotNil(err) 59 | } 60 | 61 | func TestConfigLoadClientsSuccess(t *testing.T) { 62 | newAssert := assert.New(t) 63 | 64 | config, err := LoadConfig("fixtures/configs/test-config.yaml") 65 | newAssert.Nil(err) 66 | 67 | clients, err := config.LoadClients() 68 | newAssert.Nil(err) 69 | 70 | for _, name := range []string{"client1", "client2", "client3"} { 71 | client, ok := clients[name] 72 | newAssert.True(ok) 73 | newAssert.Equal(name, client.DirName) 74 | newAssert.Equal(fmt.Sprintf("fixtures/clients/%s.key", name), client.Key) 75 | newAssert.Equal(fmt.Sprintf("fixtures/clients/%s.crt", name), client.Cert) 76 | } 77 | 78 | assert.Equal(t, "client4_overridden", clients["client4"].DirName) 79 | 80 | client, ok := clients["missingcert"] 81 | newAssert.True(ok) 82 | newAssert.Equal("fixtures/clients/client4.key", client.Key) 83 | // With no cert specified, it's assumed to be in the key file 84 | newAssert.Equal("fixtures/clients/client4.key", client.Cert) 85 | 86 | client, ok = clients["owners"] 87 | newAssert.True(ok) 88 | newAssert.Equal("fixtures/clients/client1.key", client.Key) 89 | newAssert.Equal("fixtures/clients/client1.crt", client.Cert) 90 | newAssert.Equal("test-user", client.User) 91 | newAssert.Equal("test-group", client.Group) 92 | // defaults inherited from kesync's config 93 | newAssert.Equal("23ms", client.MinBackoff) 94 | newAssert.Equal("87ms", client.MaxBackoff) 95 | newAssert.Equal("60s", client.Timeout) 96 | newAssert.Equal(uint16(1), client.MaxRetries) 97 | } 98 | 99 | func TestConfigLoadClientsInvalidFiles(t *testing.T) { 100 | config, err := LoadConfig("fixtures/configs/errorconfigs/nonexistent-client-dir-config.yaml") 101 | require.Nil(t, err) 102 | 103 | _, err = config.LoadClients() 104 | assert.NotNil(t, err) 105 | 106 | _, err = LoadConfig("fixtures/configs/errorconfigs/missing-secrets-dir-config.yaml") 107 | assert.NotNil(t, err) 108 | 109 | config, err = LoadConfig("fixtures/configs/errorconfigs/missingkey-config.yaml") 110 | require.Nil(t, err) 111 | 112 | _, err = config.LoadClients() 113 | assert.NotNil(t, err) 114 | 115 | config, err = LoadConfig("fixtures/configs/errorconfigs/notyaml-client-config.yaml") 116 | require.Nil(t, err) 117 | 118 | _, err = config.LoadClients() 119 | assert.NotNil(t, err) 120 | } 121 | -------------------------------------------------------------------------------- /fixtures/CA/cacert.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDDjCCAfigAwIBAgIBATALBgkqhkiG9w0BAQswGjEYMBYGA1UEAxMPS2V5d2hp 3 | eiBUZXN0IENBMB4XDTE1MDQyMDE3NDQ0MloXDTQ1MDQyMDE4NDQ0M1owGjEYMBYG 4 | A1UEAxMPS2V5d2hpeiBUZXN0IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB 5 | CgKCAQEAytHUEdPnH3tu+D+SnycFuOmVRdnrPTa7yHoiBp1wuNS/as+SpfVL20GT 6 | DVnHBdvqWH3H4xguLhrv3T0aZB5MyTqwjiomzPbq/fVjudc3XhkPylK7rTgbKB49 7 | ZQwz9dSjciWyILd32zCvMSg+6r7h1UzxAG9X70UYLbgBA3zNFZWfxVZ/DJGoCkp4 8 | WZLmKjzYjRrhSkXD4M9HayGDDJkGlr75WVl6fTlZkFy1+QQ13NpMhNq29H0UeaEu 9 | GuUCR4/pkVJqzZ614UYKjg56LZC919nu9tVHSEdnJb0k8tLSn8YlCwzeVd6JGeAR 10 | mhcLpjGiT729K2c+ui7Hp+JhRLCgSQIDAQABo2MwYTAOBgNVHQ8BAf8EBAMCAAYw 11 | DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUQII6ahTU+FRbnqKa/PNeBC1ke1ww 12 | HwYDVR0jBBgwFoAUQII6ahTU+FRbnqKa/PNeBC1ke1wwCwYJKoZIhvcNAQELA4IB 13 | AQCbjkIeJlgS5MZpfqQPqRSDj0/0enctvpwRhUumKxhDgVgP0drhh3Pjfe6Wq0FR 14 | jUET2B2rwBPLZC/N/S0YM6oy2DbPEPQkvTUwGG/ci0JmD+ryHxHuOo59rFdnpblJ 15 | Gl0ouNAC524vg7bdjLUx1F8yez1bZbU42Gwajy2UliHnCqQTj/hxe8ELPcL2qFVV 16 | cQSKfiRTdGexpJ8OJjKT5iBjPu7Z3oC7OAl7HWhAFBZxNXPWePleoca8kiO9xkhf 17 | 4XG9sMoL6HgFB/CoZUIRLUMEPe9tgs152lWvZG3boJK7uOC73RNSCPlUvcXYm3Eo 18 | mBUwOdMSOYuhaWL0B8+XJgVj 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /fixtures/CA/cacert.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpQIBAAKCAQEAytHUEdPnH3tu+D+SnycFuOmVRdnrPTa7yHoiBp1wuNS/as+S 3 | pfVL20GTDVnHBdvqWH3H4xguLhrv3T0aZB5MyTqwjiomzPbq/fVjudc3XhkPylK7 4 | rTgbKB49ZQwz9dSjciWyILd32zCvMSg+6r7h1UzxAG9X70UYLbgBA3zNFZWfxVZ/ 5 | DJGoCkp4WZLmKjzYjRrhSkXD4M9HayGDDJkGlr75WVl6fTlZkFy1+QQ13NpMhNq2 6 | 9H0UeaEuGuUCR4/pkVJqzZ614UYKjg56LZC919nu9tVHSEdnJb0k8tLSn8YlCwze 7 | Vd6JGeARmhcLpjGiT729K2c+ui7Hp+JhRLCgSQIDAQABAoIBAQCE5OQuIkjoyfo2 8 | U4GBIxKOzQ7wTA/ldj2o6M3uw66ejVg3ZndSot3ndpoiP1c3MZfmD9SvqqJnt2K/ 9 | 9k+alnf9yqTxIhF2b7weV3HWzXwL/iPokDlFEORKbzYPReWuCHxoSObkpRK13rqM 10 | XTCMpDJZjybDADIAJ6fmHREc9eNzZgIsHBzKRLlzhEZ6qEkvz3JBH3Me/eBm/RmM 11 | W4Xez6265+RNsYOYUMJq03SOQx++gB17j0mih8f0eKxFCrXpJ2AI0zk/mddgrgxS 12 | licv/QboObpb/FSCViEWc/M6fcIWVzz4ZZ4ukonRQkh8TgiL8onfAzIrk2WB2mQg 13 | wQ1dJ24BAoGBAN5IMTKSYNWxrgyBu8s4ZmnOR19PSj8hu7XbcFGlK2QEqB1C0Ueg 14 | 0Ej9qX7iLwyoBadDXJPhNj8/rjEmxi/O/0ehiN+XJqmA8PfSMa+4xuhIj6w5RTkK 15 | yuC83R15o5LugbXKjVRxetpbaGXfx/v7IkNHtu/0H9kq7adyxiU7zhChAoGBAOmV 16 | 3EnaGknrynfYFgk9BUoypggfxWdEdnF90zVG6rFaDZy1HykoGItAero4fk0rJmPf 17 | KJ4iX/Cizwkg9Qm/P8Z0r6V/ifNxK80uz1Gy2L6fzLszawUIF1ZkXKy8e/58EhZt 18 | ZZ1NP5bT21lfJ2l1++nzGGGO2zlpuKF4bsoAV+apAoGBAMp1HrpdMO3yhADIOXAD 19 | 0uQUClX5NjsCUqJ1WHxE4Jyc0TK1pUCEbLHOuQ7knM3+TAfpBu16d5psOhByrJjn 20 | BQUNUEm2tnQ1CUXvoWnX9vOjA5luIGqwNdE0tIEgRaiSrHoUH14Gbktsbk474T7V 21 | ooN9UlaEGG4I96VImMlZC3uBAoGAIsEYaKiZ1rvNgS1WggNhQRvuFjFb9rR6BwLY 22 | pQmrK74hXlqYi1Aa7sUmPYTgTe0Ipj1y1qx4p94hfdM4gj3BaG6+H5qtVNpW0Q2Q 23 | 7S/2Dc7K8GODHdmJV2JRY6YbsM6XBl06jEANIQzeixqcS5WxaKqxyvotZgpz3RqF 24 | KWsJqRkCgYEAnUX0OcwXviZY2GDYLaIF9UYLKPDIpcHGTKNii/o5VugNHd+IYjyU 25 | dzephpE+3HqH8FJB5aPmNU2MCqmdffhy8mSeqJ8PLgwpf9cNrCJMi2zoAguZPjts 26 | tnW6pukaiPtmByroZLI+vJcUHeVQC+W6NegASUlZKC15z92Zp28W1TE= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /fixtures/CA/localhost.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICEzCCAXygAwIBAgIQMIMChMLGrR+QvmQvpwAU6zANBgkqhkiG9w0BAQsFADAS 3 | MRAwDgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYw 4 | MDAwWjASMRAwDgYDVQQKEwdBY21lIENvMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCB 5 | iQKBgQDuLnQAI3mDgey3VBzWnB2L39JUU4txjeVE6myuDqkM/uGlfjb9SjY1bIw4 6 | iA5sBBZzHi3z0h1YV8QPuxEbi4nW91IJm2gsvvZhIrCHS3l6afab4pZBl2+XsDul 7 | rKBxKKtD1rGxlG4LjncdabFn9gvLZad2bSysqz/qTAUStTvqJQIDAQABo2gwZjAO 8 | BgNVHQ8BAf8EBAMCAqQwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH/BAUw 9 | AwEB/zAuBgNVHREEJzAlggtleGFtcGxlLmNvbYcEfwAAAYcQAAAAAAAAAAAAAAAA 10 | AAAAATANBgkqhkiG9w0BAQsFAAOBgQCEcetwO59EWk7WiJsG4x8SY+UIAA+flUI9 11 | tyC4lNhbcF2Idq9greZwbYCqTTTr2XiRNSMLCOjKyI7ukPoPjo16ocHj+P3vZGfs 12 | h1fIw3cSS2OolhloGw/XM6RWPWtPAlGykKLciQrBru5NAPvCMsb/I1DAceTiotQM 13 | fblo6RBxUQ== 14 | -----END CERTIFICATE----- 15 | -----BEGIN RSA PRIVATE KEY----- 16 | MIICXgIBAAKBgQDuLnQAI3mDgey3VBzWnB2L39JUU4txjeVE6myuDqkM/uGlfjb9 17 | SjY1bIw4iA5sBBZzHi3z0h1YV8QPuxEbi4nW91IJm2gsvvZhIrCHS3l6afab4pZB 18 | l2+XsDulrKBxKKtD1rGxlG4LjncdabFn9gvLZad2bSysqz/qTAUStTvqJQIDAQAB 19 | AoGAGRzwwir7XvBOAy5tM/uV6e+Zf6anZzus1s1Y1ClbjbE6HXbnWWF/wbZGOpet 20 | 3Zm4vD6MXc7jpTLryzTQIvVdfQbRc6+MUVeLKwZatTXtdZrhu+Jk7hx0nTPy8Jcb 21 | uJqFk541aEw+mMogY/xEcfbWd6IOkp+4xqjlFLBEDytgbIECQQDvH/E6nk+hgN4H 22 | qzzVtxxr397vWrjrIgPbJpQvBsafG7b0dA4AFjwVbFLmQcj2PprIMmPcQrooz8vp 23 | jy4SHEg1AkEA/v13/5M47K9vCxmb8QeD/asydfsgS5TeuNi8DoUBEmiSJwma7FXY 24 | fFUtxuvL7XvjwjN5B30pNEbc6Iuyt7y4MQJBAIt21su4b3sjXNueLKH85Q+phy2U 25 | fQtuUE9txblTu14q3N7gHRZB4ZMhFYyDy8CKrN2cPg/Fvyt0Xlp/DoCzjA0CQQDU 26 | y2ptGsuSmgUtWj3NM9xuwYPm+Z/F84K6+ARYiZ6PYj013sovGKUFfYAqVXVlxtIX 27 | qyUBnu3X9ps8ZfjLZO7BAkEAlT4R5Yl6cGhaJQYZHOde3JEMhNRcVFMO8dJDaFeo 28 | f9Oeos0UUothgiDktdQHxdNEwLjQf7lJJBzV+5OtwswCWA== 29 | -----END RSA PRIVATE KEY----- 30 | -------------------------------------------------------------------------------- /fixtures/README: -------------------------------------------------------------------------------- 1 | This folder contains test fixtures. 2 | 3 | generate.sh created the client certificates. -------------------------------------------------------------------------------- /fixtures/clients/abscert.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | client1: 3 | key: client1.key 4 | cert: /nonexistent/non-dir/missing-dir/test-absolute-path/client1.crt 5 | -------------------------------------------------------------------------------- /fixtures/clients/client1.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDPTCCAiWgAwIBAgIQFPRBvaQcG7cDbo6XGepyvDANBgkqhkiG9w0BAQsFADAa 3 | MRgwFgYDVQQDEw9LZXl3aGl6IFRlc3QgQ0EwHhcNMTcwMjAzMDUyODE2WhcNNDUw 4 | NDIwMTg0NDQyWjASMRAwDgYDVQQDEwdjbGllbnQxMIIBIjANBgkqhkiG9w0BAQEF 5 | AAOCAQ8AMIIBCgKCAQEAyEa3G5e3K0M/evCu9qSM8cA2pvPs7J28RMCqjXD7FNm5 6 | 5pLTv4tE3UA0ZvE3QKqUnlSeGTi85RwsCnTYE4TgjOXobNFSlEk7W8OMe0t5Nber 7 | 6kr0MyKbCQlWnp7crysNoaaMwTs9PUPJce5uJZf0F00IgASSjCosGArlzuCxDA38 8 | h9ifwKZqBQeqJ4Zv1JlVPr/6y8Dj1h7KavhxDfIjOCsVmza7dmS/UvyKDFxlolx3 9 | OvkUFgXOJzxek0WXrBXEsOYvz3V0yGo8OvPdA3nZXN6zwBzWHNwKjfoWUvZWrJQ/ 10 | 0WNzzV/k1W7YVcqGaiUFU12JrWOVeHCtibeaM3zTJQIDAQABo4GGMIGDMA4GA1Ud 11 | DwEB/wQEAwIDuDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0O 12 | BBYEFJCB71p7aJ+tCrM1/lZvKMDfy/rPMB8GA1UdIwQYMBaAFECCOmoU1PhUW56i 13 | mvzzXgQtZHtcMBIGA1UdEQQLMAmCB2NsaWVudDEwDQYJKoZIhvcNAQELBQADggEB 14 | ACo5o3tyZAxnV5AcD3ykmgJtg/7x4nOpldXEwVXqTjbTp5MFLjs/5KzuvTrVfbpT 15 | /Pfhu2aNQRbGgi4jKmsUV/jC/B3wawTsDSnMjWEw2Qz6QHz2xnEdDJcdWsLmI1Do 16 | 4y3VbOLrraG4Gow4kMaXphkllyeJg5JeUDbsvNSnC6DSI8zEYche8aXs5iIgdAGi 17 | ygPUl/9f2DTlYQGzXP7rrWHyxyaZSjNpqF6zFjx1jcWYiu4228VmVkaKqi1FXPSt 18 | r6PykSiaY7wqQMQ7BdO75uzCIXuTSfiqNQtE1Sn3yzrUQ4+AZp7yifsLLu9P+2H4 19 | DDEQQoZoza5A71IiRntbe5k= 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /fixtures/clients/client1.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEAyEa3G5e3K0M/evCu9qSM8cA2pvPs7J28RMCqjXD7FNm55pLT 3 | v4tE3UA0ZvE3QKqUnlSeGTi85RwsCnTYE4TgjOXobNFSlEk7W8OMe0t5Nber6kr0 4 | MyKbCQlWnp7crysNoaaMwTs9PUPJce5uJZf0F00IgASSjCosGArlzuCxDA38h9if 5 | wKZqBQeqJ4Zv1JlVPr/6y8Dj1h7KavhxDfIjOCsVmza7dmS/UvyKDFxlolx3OvkU 6 | FgXOJzxek0WXrBXEsOYvz3V0yGo8OvPdA3nZXN6zwBzWHNwKjfoWUvZWrJQ/0WNz 7 | zV/k1W7YVcqGaiUFU12JrWOVeHCtibeaM3zTJQIDAQABAoIBAHiVczwuzb9Dnx4D 8 | eiTQkHgiNgWxii4xDqEKq+W7Z8F3EiIMt2d1kAHy3Vo50/2gdxkZc5NWAQ2lN7MY 9 | BN1DvPu4lYenRKQ5r36hr5ywpYs9Skon1P5Q0K3RLJEWr2LcdjmlEMxrQYT4onpx 10 | h2olIndBD2Qc3Kt93MyhCxrTWGYbl4j9U2ysB5tC6W9k0gKgjFE9+nZbr0sEPTa4 11 | jeLKPVIIXc3DIwkOLfoOBdT3S/vf7AOznIE1dDFFD9Amn1oro/y1+YV3lZfeeB37 12 | /ggIj7C/FpkKjME8CawpkdGzyxh7n8WzNdGMehhYLVCEDoqniFs8915y1Ky9Dmsx 13 | u1eLw8ECgYEA+5pgt+pPtvfg4Dcf2+/lZGG3riAjwblR7Uml5vcirB7Hz4WAjvB7 14 | EwntD5UnO+DIIGciyBNh1+Mig+nCqpwZQVUYgpdLylLrQKFmcNwtqyoCiTU66oEZ 15 | QkzsZbtomejwTUrnpptXQxrLk8G4Btm7NCTeUL4/V2ntyaE8nP0uixECgYEAy8a2 16 | I89chGIUSalOairy1wYprk1igyHYIaq/VBiJC+gngNLz/ZBEDbeDrLackK06Xik8 17 | 3EDo/tg4mVro2GMoynzsck7KlL9+rzkeK6VK0eBEFa1U28yYcZ2N3/UQZuJV9N2E 18 | T2L5sVpK/3DDdtVfAiplBVxATr73fldAUSLFPtUCgYEAmlvRAKR4+WjEBurq2dUo 19 | 59fnh6ViKoTWlXx8kuGF3REZRuDByXASIdESJmA8bMjwHqkHtrXlbjyEPWfZrTAN 20 | cn6RhfTqY5tRhxo+Lfl27y7b1W/Z0GsZowpscdFzUBGP8+uDiTx+YcX7pY/QpitI 21 | ZapE1kaRt8BeSThpZmsR9fECgYAsCMq/PkYNzWv45v8s7g7/7DMBmXNaRuv/inhB 22 | 4fNrgUVYDz3uY0hxdmCb5/I5SVW9l0exiM1QlMTWTtDWQcdEym4F3YTlU+Q6VStx 23 | 3wwmAkJ0NLqLrNCcbKGF7d0Xfn14po264fZ3Hr3qKSH0AfO/8g1WdTLoUVgGEzCw 24 | 18Sr6QKBgQCfiqNPd8Sah+tAZO7eOVijElmmnWOkFhCmNDsaZwgS0sEUvPYFtxoB 25 | Y4atsTUaryZvDH6ZlPw138HXNF6YAcPoWRfKE/+z5c7ODkX8i7ambpUhyPyDCFBg 26 | 71SgZ33GXZEeczTLrAnH3fBMbn0dZjXLuAcw4CLECzovZX0oBMK2vQ== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /fixtures/clients/client1.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | client1: 3 | key: client1.key 4 | cert: client1.crt 5 | -------------------------------------------------------------------------------- /fixtures/clients/client2-3.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | client2: 3 | key: client2.key 4 | cert: client2.crt 5 | 6 | client3: 7 | key: client3.key 8 | cert: client3.crt 9 | -------------------------------------------------------------------------------- /fixtures/clients/client2.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDPTCCAiWgAwIBAgIQa7/NZ2+V/P68NjPkhNPZhTANBgkqhkiG9w0BAQsFADAa 3 | MRgwFgYDVQQDEw9LZXl3aGl6IFRlc3QgQ0EwHhcNMTcwMjAzMDUyODE2WhcNNDUw 4 | NDIwMTg0NDQyWjASMRAwDgYDVQQDEwdjbGllbnQyMIIBIjANBgkqhkiG9w0BAQEF 5 | AAOCAQ8AMIIBCgKCAQEAt/LYPN4vHSjwixE9ZxFVcKoVFsGbUss44ddIShKXFJia 6 | 9udtFbT+7SMqDmAzac4KxA4avJeKqkgJ4pNftQGP4ajGW2CbkhrJhJWWj+Re24hl 7 | q9W3Tz9/ChoaS9uJMXa1cC2lwtG4kF8zgrCa3gCgcRWk1/B/51XErDfr+Uqaf0N5 8 | tS1Uya/BJ/6OurCdD7jQ1XuVD8UdsLRNndFqbo3INgvQIKRxgcTPX29WeWXuTKaU 9 | Tb8DkKQk04yHw0IXVhAByPoK8Tj3b65AAMLOthjRKnziV2qD1YlEi3mvZt4dzuRr 10 | 220VGCfOhRGNdZrKQFUGVJb6G/MHW/FQHzLNni838wIDAQABo4GGMIGDMA4GA1Ud 11 | DwEB/wQEAwIDuDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0O 12 | BBYEFA87oFHe1ftwcYS4hcP3N86mY/f6MB8GA1UdIwQYMBaAFECCOmoU1PhUW56i 13 | mvzzXgQtZHtcMBIGA1UdEQQLMAmCB2NsaWVudDIwDQYJKoZIhvcNAQELBQADggEB 14 | AAm/AkI67nUkPHU9kJdvWibc7O/oawJFJ/KQjj7zxlpvbZzSOFXq/VYnfm42+x6l 15 | sAmBjT8uuTQpCba8U6JqgIP05ZmmwoLV+p5Fi65U6eIDyJYy7tPt0982DQbZjRyA 16 | kk6H1EgalfySllAZP+EbgO8N4JAvhlyIdie2FOSt6NZcT88mjoiA3hO5qotPzURy 17 | K7mtwj6nQRQzVhvNEH+gpyVaNg7FT9D7B0/STyDa1vs9Vt+LZUOj1ngZb/zjfbhT 18 | 2RwMVKbFUOboTYT94VZwZhDaG9LCHcSzADVZEz+gwOUPyBrXhRa2lOHcRWG7M8eC 19 | 0/kMkwJ3ldNEVAyjOEbO4mA= 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /fixtures/clients/client2.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEAt/LYPN4vHSjwixE9ZxFVcKoVFsGbUss44ddIShKXFJia9udt 3 | FbT+7SMqDmAzac4KxA4avJeKqkgJ4pNftQGP4ajGW2CbkhrJhJWWj+Re24hlq9W3 4 | Tz9/ChoaS9uJMXa1cC2lwtG4kF8zgrCa3gCgcRWk1/B/51XErDfr+Uqaf0N5tS1U 5 | ya/BJ/6OurCdD7jQ1XuVD8UdsLRNndFqbo3INgvQIKRxgcTPX29WeWXuTKaUTb8D 6 | kKQk04yHw0IXVhAByPoK8Tj3b65AAMLOthjRKnziV2qD1YlEi3mvZt4dzuRr220V 7 | GCfOhRGNdZrKQFUGVJb6G/MHW/FQHzLNni838wIDAQABAoIBACHSYvP+HkeMSX4o 8 | c1PKGh2XCD2g54A3oYPU45PLC0BcNtIDB0mgd+b+OjNeeNWRbuVRepUGgBaDHF4u 9 | nsBXQy9IqwAOKUyZ5EeegYp/gPl4gMkxiHznveILnp4oBXe0zfOMURgbG4ZgGsaC 10 | 1lbPYrCoPCEANWRBnuHTfm3dy75uR9kX2QFKtpyxNORCmj0/8y53KfyNk4Vl7N7t 11 | 5Bn/MDLXD+ESCbMMfgfSkbksp060P8kzdKwuuZ/utiTqW3rbZ14BCfUxQxN3KWDh 12 | ZkTb7VVuY5sW1U90ubh+UdPdGA3YTCo5FnsIeAmaI2CuDM5rCjjPry8/kTDGpe9F 13 | SUQ+PdECgYEA3HJ37Eduxo2c3imWl0LBqngH+dHAMM7iWd4JTp501O37bvdBtF6o 14 | 4IuwZkVlTq/baUP2tiWEopzfZ1ZGs4wgAChz+KdGvO9ROsaUaTzpjU1xl1pQluRt 15 | vmXNPwgntZDqU+rm00DoBdXwNlVaCAQGKV6fLcTzyY4jEzYRuKXGrm0CgYEA1Z15 16 | EiVK4Yv7QY2AMxkEkUKXPuA2IHGd5YoZ4Rg3qonlaEEugLGdgNt3WlkAvZ+xPTFm 17 | HJAk84/a2AgEF35/6fkQP57Qh06lPB97MXXopKsf67zuv2MFfUiOhPk6S/dvyiFG 18 | oqEuo0quV7QWJw2zQKUG95MPt9OsMEr/1/caA98CgYEAg5re1zMqAeVHEiZ33aOf 19 | 5Lo14MGE4F6SKR5yJfpZO0k8Atof4qNkZHFghR3GxjwcW/KUFde+ICpRAOsz2Dq+ 20 | W/nKilaq115z+wfUUCNqNs5WEwp69Co5DiLObmPa+P0jt6eT1+h23A0FlBpOXlyw 21 | pP/1PajH9bsiW3S7DaYCP90CgYEAu9EHIFM1sXW4ZnyZWVQH1ggsnxXXwVLkN2vj 22 | MSxv1TwFPlMBJhoEl8Ve/UsAAbmkxl7mnvqF4rh6/DKgFmAc64UXKjjis/UMBv7O 23 | /D/lWtMy77xgVloj+3GODZBsF1rpKVl0l40MSnphK6+lQpUjJBV1OLxOt5we6x8m 24 | L6aNcxUCgYAn5R2ml9s47qRfbFOHAkzV7HZA+/0B50tWDC3Q+6SMORMdJNlKkOxw 25 | jPsDHLaYkSZ0ZfIPZNwqepnxBiPpaGwT92gHAPyRqZfS0ahyek7f+jQvAB4/Po8w 26 | tyym82FX/9H/IwCzjh3myYtHnzFDsRrznoZNi9dsSFeX1a5lBJ4jIA== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /fixtures/clients/client3.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDPTCCAiWgAwIBAgIQeuxIb0okD+LyJTTvrnAcQzANBgkqhkiG9w0BAQsFADAa 3 | MRgwFgYDVQQDEw9LZXl3aGl6IFRlc3QgQ0EwHhcNMTcwMjAzMDUyODE3WhcNNDUw 4 | NDIwMTg0NDQyWjASMRAwDgYDVQQDEwdjbGllbnQzMIIBIjANBgkqhkiG9w0BAQEF 5 | AAOCAQ8AMIIBCgKCAQEA6qE50ClXVEpMc81O9IfhJJeqOM57ptp8r+iyKeC9vau6 6 | 5ENFTq15WVeaJylCR4937aSQzvVtdxiUt0IfURBJtxJoQgfZrBFJgbiv8cpesbP/ 7 | 6PTAotKZSc9v8HumD9UtndcDQfLK7exhSJxcNXIVDqU5cTdPwz31oNDMIzB6C5oZ 8 | zSwlrz18T7aAVGNIJX3Yop31I7UWqVzGjsHk06qmqfHfcsXQdO+f9V/RM3swnUhj 9 | AQKZC5pB57C5irihGS4TPvLFWsxEvz90DHcKbsY/fKeRu5+2yBTOdW/r3GRW4eJ/ 10 | aeAOsxL5Lkk2826N5hlF/J2BENI38X7Kfb+FrX+1XwIDAQABo4GGMIGDMA4GA1Ud 11 | DwEB/wQEAwIDuDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0O 12 | BBYEFBUGrK5KwDb5imOTML4CqSK9s51AMB8GA1UdIwQYMBaAFECCOmoU1PhUW56i 13 | mvzzXgQtZHtcMBIGA1UdEQQLMAmCB2NsaWVudDMwDQYJKoZIhvcNAQELBQADggEB 14 | ABj+ufXy9EGoXnSACsj2RZ59LtoF0TlRTp1gPh8/6YqEX134OS2ylB5EGqa3pivJ 15 | CzOFhr2hA0Ntvj0chNFqvcjIFvvmkztC496uBRI6V4zn0a0eBzNcM7ly0vJAOZm1 16 | +JXdIf2s28yWFivrjYjXE6F1bzcy3Q+U3EfuQkYXDjkrJcOtFUNXq8ClPkJmXX3y 17 | tCNcGL3dfsuVhkA+U2U7QfBM7BhosLuuinUIUuYvbtvLNb3KWfDQrdACYFHgQURH 18 | hgxWP72ilUFjhwAFT2IMgQLGEHdhDutFbOtJzFNjlPFTupsWgrbcLQXR9YRSTFLX 19 | Y5PhZ5jnZ1NRtrmfHmNyRFc= 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /fixtures/clients/client3.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpQIBAAKCAQEA6qE50ClXVEpMc81O9IfhJJeqOM57ptp8r+iyKeC9vau65ENF 3 | Tq15WVeaJylCR4937aSQzvVtdxiUt0IfURBJtxJoQgfZrBFJgbiv8cpesbP/6PTA 4 | otKZSc9v8HumD9UtndcDQfLK7exhSJxcNXIVDqU5cTdPwz31oNDMIzB6C5oZzSwl 5 | rz18T7aAVGNIJX3Yop31I7UWqVzGjsHk06qmqfHfcsXQdO+f9V/RM3swnUhjAQKZ 6 | C5pB57C5irihGS4TPvLFWsxEvz90DHcKbsY/fKeRu5+2yBTOdW/r3GRW4eJ/aeAO 7 | sxL5Lkk2826N5hlF/J2BENI38X7Kfb+FrX+1XwIDAQABAoIBAQDI0bfm6At79J/d 8 | GeOzPj3AkSM7vddt52GDOnqLh3U/SyYKS12dyrKremRRkmnNUAmI2CqtSLkpj1ty 9 | QuEFBBjj3Zhos8lmEeHFausE234TQQoPPLVIZ1KWLzsTLPHkaUTC7Q43uvRfkctu 10 | V45AnGVThK1Wrs3RQU3kF/IxSEOdeuODEHHZoySRrDcQLobDRn/rnc/2eDDLvc2p 11 | 6/9Ekh7R9P9Fy8HOP7xFgr9HL1lCNhq6559j/07fZ0vPjsKvSuOoGqlERJTbBxTE 12 | 0ISeC8+1hIR9ezT6kph0dznJ0MypB9YCnh2IvG59twYeRB13iYP/3G0bTZiPp0qo 13 | m++tLQZZAoGBAPUxlNY5foxqdwyWcVla0BqONFdT3FoIDAzR7o6UIKAsCWe5GVmZ 14 | +q1+NXNrNyk3lPjCP6I/ydMDEOdRokn6PL+iBBpZ4epjyrqt/BCTrJUK/vqkFzpw 15 | d2nmgsSJUepwWzM2Ivla/7mcT43SnB4t6CYm7n9v1Sw0YGnEE4p4InabAoGBAPT4 16 | dNqNUEfzhR7X63dPoIGmNw9fdOIXLIvKPqssnZyS22L6nfwpJr7XuQdWttzJX6er 17 | gw4cKqy9Ncr4wdgXJ3Vq3+JMq+Vn5IoR3stnBXeiFaOHRpvoJjiwt/TLGmPRjd+9 18 | esUbkV1cw1HVik5U+6bfapcAFQnjpbjQBdeYokaNAoGAIbw9py/n6nfng1Lbq5ik 19 | E1NHflBqe/Spe8YSlYlp57/HV38PLtXRuLcpsYSp5UDhfUx0puUx5peAZuNDefw1 20 | CYTIHbwKKk6qoP65NKqszyDhLikPjRnWRDrT+SiPnbrxwV0MeNR9ZNNN2syEcF6O 21 | l1k57Uy8vsVCEqtIqP+YdksCgYEAuLCBWSzlc/mzSZe8nQ1Zk6W/KUXsl3ClUxc9 22 | vEw84AkQgkU0yyIzZfq6M0A7SaZBCeaTPhYaTnWwksCNVN/QDgRvmuv3RVPYyAfF 23 | E1EunA2Fhu68W1rhRihl6Jcf5FXfQupWHzkzlVIUqCP4zCniOOOENygGtJf/H6Em 24 | Zm9bGRkCgYEAtRz/0/mFF9TsQGHPqT8J1wgp9yFHviNi+dC0s54cD7RMnz1ILhv5 25 | /kGVqsqoLT1Jy21RmRogpSvXkLmvaZ5xB3RYYAcRHs7zMMuHjDmjPuZ6luu0HOj3 26 | 3be4vTmGQCYvcB4kJKdkeHjUCOzLbH1ipy29aSwv7Cg/UCnJIuGBH60= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /fixtures/clients/client4.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDPjCCAiagAwIBAgIRANr07228O78yYhqlMG7rU/kwDQYJKoZIhvcNAQELBQAw 3 | GjEYMBYGA1UEAxMPS2V5d2hpeiBUZXN0IENBMB4XDTE3MDIwMzA1MjgxN1oXDTQ1 4 | MDQyMDE4NDQ0MlowEjEQMA4GA1UEAxMHY2xpZW50NDCCASIwDQYJKoZIhvcNAQEB 5 | BQADggEPADCCAQoCggEBALIT7kQ97n2SKJzeAyczc5jn3h1S+rWeZ3wdgsPib7IM 6 | XW/vffEzDGHlQE3k+BkQz3rcIk3A7KOpdk/qF7xfAyGYb0teJqmVMyIEI5m1YU6k 7 | ghJg/Dxmv+7ACqUq2YkMjHj0y4iobNOj5F0hfatw0qsS/rWP2HoyItLlbgBoEYJM 8 | 4XCbrvhcpgf4JDIpT8YgPWTn+hbW4yJ4TEH7ryS3NgazCDAtSdixd/Uq9INeW4nK 9 | ZiYJwhjyDFNGPneAbDkajfpbdAlk7+TK+pBb97f+MwbmRGbxZMR+SwdBnl0aJIgk 10 | jtZ677nSCjcp1NdyWSM8lAK3OSbDEH57kvKJWAWQaykCAwEAAaOBhjCBgzAOBgNV 11 | HQ8BAf8EBAMCA7gwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMB0GA1Ud 12 | DgQWBBSUIyw7EqrG/H0Wz0qqndQD6d7vrzAfBgNVHSMEGDAWgBRAgjpqFNT4VFue 13 | opr8814ELWR7XDASBgNVHREECzAJggdjbGllbnQ0MA0GCSqGSIb3DQEBCwUAA4IB 14 | AQCoP3xqAcRMk37AfCpZlbQCzSin2O/iVMs8M1vvYYkqCy6O3IgSHbeC4lxSlQp9 15 | A6lJEIVT1hcvTKTyZSWdJujiPkVaHZRY2AJo+vvL2T2GkNyhY/mQWG9chJS7NZ+7 16 | N1ACEgwJfeNXgBrCLIr8CeuZ4lbhzPN40aByPM2LQ88R8kL4WUKhGenJL8DbiZn9 17 | yOCsWzgZSqkH5cNeGcaAGrZ+m3Ss5KVaDMTuNv3i5XvS67mBlRJpZb3gdmY9HnBG 18 | MfvwzQ0HqeQsMOw6B48DCBBl7yuYnHSTNUARX952r7CVVcWqaG1XSUPhB8RENxOl 19 | NItsU7O22G6nYS1cF+Kdk2SM 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /fixtures/clients/client4.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEAshPuRD3ufZIonN4DJzNzmOfeHVL6tZ5nfB2Cw+Jvsgxdb+99 3 | 8TMMYeVATeT4GRDPetwiTcDso6l2T+oXvF8DIZhvS14mqZUzIgQjmbVhTqSCEmD8 4 | PGa/7sAKpSrZiQyMePTLiKhs06PkXSF9q3DSqxL+tY/YejIi0uVuAGgRgkzhcJuu 5 | +FymB/gkMilPxiA9ZOf6FtbjInhMQfuvJLc2BrMIMC1J2LF39Sr0g15bicpmJgnC 6 | GPIMU0Y+d4BsORqN+lt0CWTv5Mr6kFv3t/4zBuZEZvFkxH5LB0GeXRokiCSO1nrv 7 | udIKNynU13JZIzyUArc5JsMQfnuS8olYBZBrKQIDAQABAoIBADY+YfbBkrMHYX2f 8 | FwDK6GxsPLlb/Gh0Tvt8lceLYxCuOYwOPKPLM/th9LuFgplICJtZEM30dWDJDvP6 9 | z64elvqVz1j63fYML54t+pYorPJipAhrKIpRlidoshVrvwXDH8r8bj87ZqL1Kmu/ 10 | 9uLRJCreR14Q6hUWzorFPkO7b5HrzJXjE8PIRgq+W+XXNEEMfVIu4AnlSbEY1XiF 11 | 9A1KUSD1B5rBZehD5mi339/10PwI5YfmtOKSL6I8rF+AJjNS5o3awgB2gZDRh5eQ 12 | iFzsNQOs5CLXSTWbWiwF+i009BFFT4GKYQfsn03UqX4XoDNFQytJbuDNoQMFc/pI 13 | dqEnkC0CgYEAwenP7rVIBUaPZzq8KhMhbIBrLx0ToKv6Anz5SH9lYmI30Z5SYHE5 14 | 0EsAmFtpS1FyovyIS2mCxZj670Au0gLTpNjmRtGQBKFtEPtg9RwA7eozUiD59ZaO 15 | 2oA9uqCvKe3yuFziOzMbz1XJMht79p3dUPMVTwRWTFxkipt8j1JBf0MCgYEA6xgo 16 | OjRiVEMDbCli4xbrznXQ877E+HB8LYOqxOJbF8jFOXh9YTu9sIFRekFnTDytw4lf 17 | ceyTyGVR+5chhGsfTLxWskzELwUA/IjCBcIU0KyYYMddhffz+2QEcD5BpQBHjRzE 18 | XNVtpRFdQkb+6KvZpVxWFfaCiw8rRLevEb/PFyMCgYAOBZ0+kqdBkmeePFYM6NM6 19 | 6FJX1s9rh+QNOAJCpsurAJUuuDcWuDlJAZNqcPm9M4eJl583bMrDBRvoHwkDsKaj 20 | Pffw2QiD/TRIzRSmxL6gdZX+c1n/00JDNJDCJQplispJYJYPV9PD+10QHYKqQ6IU 21 | T2+UBilDXk764uFv76/CxwKBgQDaKavC/7O0ABAgAnP6yt1+1YRXfVPqPPBviD0j 22 | we7Iro6fW3n2jmrkbc0/h4wlijWyPDvvS5yEncmrkL8Q1BvSqQBHK8fu3lThBMQQ 23 | dd+9Gj25qajXVpb6VgFsa8mdJhpAEE8E2yaJxQhnJd0N69PoiTx12zGffC8p8M8s 24 | yHOUQwKBgQCqdQhNz2ABI5mrxrR1W0TLbZr1na83zTJVX8bqW2cM9gRbXme2bZh5 25 | /bOvo5otKs2+KZLbC0Sq4saajYMhJGrw5eqMHAeYzTVTIMzFO4Frl/X6U221cHa7 26 | 7ZhRN03EcwzgnanWZGgQqgyF2ogIHd8ny8V7+RyJVq1AIX3A2ZMCyg== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /fixtures/clients/client4.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | client4: 3 | key: client4.key 4 | cert: client4.crt 5 | directory: client4_overridden 6 | -------------------------------------------------------------------------------- /fixtures/clients/missingcert.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | missingcert: 3 | key: client4.key 4 | -------------------------------------------------------------------------------- /fixtures/clients/owners.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | owners: 3 | key: client1.key 4 | cert: client1.crt 5 | user: test-user 6 | group: test-group 7 | -------------------------------------------------------------------------------- /fixtures/configs/errorconfigs/absolutecert-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | client_directory: 'fixtures/clients/abscert.yaml' 3 | secrets_directory: 'fixtures/secrets' 4 | ca_file: 'fixtures/CA/cacert.crt' 5 | yaml_ext: yaml 6 | chown_files: false 7 | server: 'localhost:4444' 8 | debug: true 9 | default_user: 'keysync-test' 10 | default_group: 'keysync-test' 11 | api_port: 31738 12 | poll_interval: 60s 13 | -------------------------------------------------------------------------------- /fixtures/configs/errorconfigs/missing-secrets-dir-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | client_directory: 'fixtures/clients' 3 | ca_file: 'fixtures/CA/cacert.crt' 4 | yaml_ext: yaml 5 | chown_files: false 6 | server: 'localhost:4444' 7 | debug: true 8 | default_user: 'keysync-test' 9 | default_group: 'keysync-test' 10 | api_port: 31738 11 | poll_interval: 60s 12 | -------------------------------------------------------------------------------- /fixtures/configs/errorconfigs/missingkey-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | client_directory: 'fixtures/errorclients/missingkey' 3 | secrets_directory: 'fixtures/secrets' 4 | ca_file: 'fixtures/CA/cacert.crt' 5 | yaml_ext: yaml 6 | chown_files: false 7 | server: 'localhost:4444' 8 | debug: true 9 | default_user: 'keysync-test' 10 | default_group: 'keysync-test' 11 | api_port: 31738 12 | poll_interval: 60s 13 | -------------------------------------------------------------------------------- /fixtures/configs/errorconfigs/nonexistent-ca-file-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | client_directory: 'fixtures/clients' 3 | secrets_directory: 'fixtures/secrets' 4 | ca_file: 'non-existent' 5 | yaml_ext: yaml 6 | chown_files: false 7 | server: 'localhost:4444' 8 | debug: true 9 | default_user: 'keysync-test' 10 | default_group: 'keysync-test' 11 | api_port: 31738 12 | poll_interval: 60s 13 | -------------------------------------------------------------------------------- /fixtures/configs/errorconfigs/nonexistent-client-dir-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | client_directory: 'non-existent' 3 | secrets_directory: 'fixtures/secrets' 4 | ca_file: 'fixtures/CA/cacert.crt' 5 | yaml_ext: yaml 6 | chown_files: false 7 | server: 'localhost:4444' 8 | debug: true 9 | default_user: 'keysync-test' 10 | default_group: 'keysync-test' 11 | api_port: 31738 12 | poll_interval: 60s 13 | -------------------------------------------------------------------------------- /fixtures/configs/errorconfigs/notyaml-client-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | client_directory: 'fixtures/errorclients/notyaml' 3 | secrets_directory: 'fixtures/secrets' 4 | ca_file: 'fixtures/CA/cacert.crt' 5 | yaml_ext: yaml 6 | chown_files: false 7 | server: 'localhost:4444' 8 | debug: true 9 | default_user: 'keysync-test' 10 | default_group: 'keysync-test' 11 | api_port: 31738 12 | poll_interval: 60s 13 | -------------------------------------------------------------------------------- /fixtures/configs/errorconfigs/notyaml-test-config.yaml: -------------------------------------------------------------------------------- 1 | this is not a yaml file 2 | -------------------------------------------------------------------------------- /fixtures/configs/test-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | client_directory: 'fixtures/clients' 3 | secrets_directory: 'fixtures/secrets' 4 | ca_file: 'fixtures/CA/cacert.crt' 5 | yaml_ext: yaml 6 | chown_files: false 7 | server: 'localhost:4444' 8 | debug: true 9 | default_user: 'keysync-test' 10 | default_group: 'keysync-test' 11 | api_port: 31738 12 | poll_interval: 60s 13 | min_backoff: 23ms 14 | max_backoff: 87ms 15 | -------------------------------------------------------------------------------- /fixtures/errorclients/missingkey/missingkey.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | missingkey: 3 | cert: client4.crt 4 | mountpoint: client4 5 | -------------------------------------------------------------------------------- /fixtures/errorclients/notyaml/notyaml.yaml: -------------------------------------------------------------------------------- 1 | this is not a yaml file -------------------------------------------------------------------------------- /fixtures/exportedSecretsBackupBundle.json: -------------------------------------------------------------------------------- 1 | [{"name":"Hacking_Password","secret":"MTMzNw==","secretLength":4,"checksum":"","creationDate":"2011-09-29T15:46:00.000Z","updateDate":"2017-08-04T22:40:13.000Z","mode":"0444"},{"name":"General_Password","secret":"YXNkZGFz","secretLength":6,"checksum":"","creationDate":"2011-09-29T15:46:00.000Z","updateDate":"2011-09-29T15:46:00.000Z"}] 2 | -------------------------------------------------------------------------------- /fixtures/generate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set +ex 3 | # Generate client fixtures, using certstrap. 4 | 5 | # The 'CA' folder is taken from the `keywhiz` repo, and is required to work here. 6 | 7 | mkdir -p clients 8 | 9 | # Create four client certs 10 | for client in client1 client2 client3 client4; do 11 | rm -f clients/${client}.csr clients/${client}.crt clients/${client}.key 12 | certstrap --depot-path clients request-cert --domain ${client} --passphrase '' 13 | certstrap --depot-path clients sign --years 30 --CA ../CA/cacert ${client} 14 | rm -f clients/${client}.csr 15 | git add clients/${client}.crt clients/${client}.key 16 | done -------------------------------------------------------------------------------- /fixtures/secretNormalOwner.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "hmac.key", 3 | "secret" : "SE1BQ19LRVlfMTIzNDU2Nzg=", 4 | "secretLength" : 17, 5 | "creationDate" : "2011-09-29T15:46:00.232Z", 6 | "isVersioned" : false 7 | } 8 | -------------------------------------------------------------------------------- /fixtures/secretWithoutBase64Padding.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NonexistentOwner_Pass", 3 | "secret": "MTIzNDU", 4 | "secretLength": 5, 5 | "creationDate": "2011-09-29T15:46:00.232Z", 6 | "isVersioned": false, 7 | "owner": "NonExistant", 8 | "mode": "0400" 9 | } 10 | -------------------------------------------------------------------------------- /fixtures/secret_General_Password.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "General_Password..0be68f903f8b7d86", 3 | "secret" : "YXNkZGFz", 4 | "secretLength" : 6, 5 | "creationDate" : "2011-09-29T15:46:00.312Z", 6 | "isVersioned" : true 7 | } 8 | -------------------------------------------------------------------------------- /fixtures/secret_Nobody_PgPass.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "Nobody_PgPass", 3 | "secret" : "YXNkZGFz", 4 | "secretLength" : 6, 5 | "creationDate" : "2011-09-29T15:46:00.232Z", 6 | "isVersioned" : false, 7 | "mode" : "0400", 8 | "owner" : "nobody", 9 | "group" : "nobody" 10 | } 11 | -------------------------------------------------------------------------------- /fixtures/secrets.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name" : "Nobody_PgPass", 4 | "secret" : "YXNkZGFz", 5 | "secretLength" : 6, 6 | "creationDate" : "2011-09-29T15:46:00.232Z", 7 | "isVersioned" : false, 8 | "mode" : "0400", 9 | "owner" : "nobody" 10 | }, 11 | { 12 | "name" : "General_Password..0be68f903f8b7d86", 13 | "secret" : "YXNkZGFz", 14 | "secretLength" : 6, 15 | "creationDate" : "2011-09-29T15:46:00.312Z", 16 | "isVersioned" : true 17 | } 18 | ] 19 | -------------------------------------------------------------------------------- /fixtures/secretsWithBadFilenameOverride.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name" : "Nobody_PgPass", 4 | "secret" : "YXNkZGFz", 5 | "secretLength" : 6, 6 | "creationDate" : "2011-09-29T15:46:00.232Z", 7 | "isVersioned" : false, 8 | "mode" : "0400", 9 | "owner" : "nobody", 10 | "filename": "../../bla" 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /fixtures/secretsWithoutContent.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name" : "Nobody_PgPass", 4 | "secret" : "", 5 | "secretLength" : 6, 6 | "creationDate" : "2011-09-29T15:46:00.232Z", 7 | "isVersioned" : false, 8 | "mode" : "0400", 9 | "owner" : "nobody" 10 | }, 11 | { 12 | "name" : "General_Password..0be68f903f8b7d86", 13 | "secret" : "", 14 | "secretLength" : 6, 15 | "creationDate" : "2011-09-29T15:46:00.312Z", 16 | "isVersioned" : true 17 | } 18 | ] 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/square/keysync 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc // indirect 7 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect 8 | github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448 // indirect 9 | github.com/evalphobia/logrus_sentry v0.8.2 10 | github.com/getsentry/raven-go v0.2.0 11 | github.com/gorilla/mux v1.8.0 12 | github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7 13 | github.com/kr/pretty v0.1.0 // indirect 14 | github.com/pkg/errors v0.9.1 15 | github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a 16 | github.com/sirupsen/logrus v1.7.0 17 | github.com/square/go-sq-metrics v0.0.0-20170531223841-ae72f332d0d9 18 | github.com/stretchr/testify v1.7.2 19 | golang.org/x/crypto v0.0.0-20190829043050-9756ffdc2472 20 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad 21 | gopkg.in/alecthomas/kingpin.v2 v2.2.6 22 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 23 | gopkg.in/yaml.v2 v2.4.0 24 | ) 25 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU= 2 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 3 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= 4 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 5 | github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448 h1:8tNk6SPXzLDnATTrWoI5Bgw9s/x4uf0kmBpk21NZgI4= 6 | github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/evalphobia/logrus_sentry v0.8.2 h1:dotxHq+YLZsT1Bb45bB5UQbfCh3gM/nFFetyN46VoDQ= 11 | github.com/evalphobia/logrus_sentry v0.8.2/go.mod h1:pKcp+vriitUqu9KiWj/VRFbRfFNUwz95/UkgG8a6MNc= 12 | github.com/getsentry/raven-go v0.2.0 h1:no+xWJRb5ZI7eE8TWgIq1jLulQiIoLG0IfYxv5JYMGs= 13 | github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= 14 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 15 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 16 | github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7 h1:K//n/AqR5HjG3qxbrBCL4vJPW0MVFSs9CPK1OOJdRME= 17 | github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0= 18 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 19 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 20 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 21 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 22 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 23 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 24 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 25 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 26 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 27 | github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a h1:9ZKAASQSHhDYGoxY8uLVpewe1GDZ2vu2Tr/vTdVAkFQ= 28 | github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= 29 | github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM= 30 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 31 | github.com/square/go-sq-metrics v0.0.0-20170531223841-ae72f332d0d9 h1:EjCIkN8CnRBciDeOM2c+5uBd+ek5r90N6r6zWwLUXgo= 32 | github.com/square/go-sq-metrics v0.0.0-20170531223841-ae72f332d0d9/go.mod h1:p5i0HIrAHvl2L9UETq6oc9Raa60/lQILgBMyfRis9z0= 33 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 34 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 35 | github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= 36 | github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= 37 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 38 | golang.org/x/crypto v0.0.0-20190829043050-9756ffdc2472 h1:Gv7RPwsi3eZ2Fgewe3CBsuOebPwO27PoXzRpJPsvSSM= 39 | golang.org/x/crypto v0.0.0-20190829043050-9756ffdc2472/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 40 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 41 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 42 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 43 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 44 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0= 45 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 46 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 47 | gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= 48 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 49 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 50 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 51 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 52 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 53 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 54 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 55 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 56 | -------------------------------------------------------------------------------- /keysync-sample-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | secrets_directory: './testing/secrets' 3 | client_directory: './testing/clients' 4 | ca_file: './testing/cacert.crt' 5 | yaml_ext: yaml 6 | chown_files: false 7 | server: 'localhost:4444' 8 | debug: true 9 | default_user: 'keysync-test' 10 | default_group: 'keysync-test' 11 | api_port: 31738 12 | poll_interval: 1s 13 | -------------------------------------------------------------------------------- /output/write.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "syscall" 11 | ) 12 | 13 | // FileInfo returns the filesystem properties atomicWrite wrote 14 | type FileInfo struct { 15 | Mode os.FileMode 16 | UID int 17 | GID int 18 | } 19 | 20 | // GetFileInfo from an open file 21 | func GetFileInfo(file *os.File) (*FileInfo, error) { 22 | stat, err := file.Stat() 23 | if err != nil { 24 | return nil, fmt.Errorf("failed to stat after writing: %v", err) 25 | } 26 | filemode := stat.Mode() 27 | uid := int(stat.Sys().(*syscall.Stat_t).Uid) 28 | gid := int(stat.Sys().(*syscall.Stat_t).Gid) 29 | 30 | return &FileInfo{filemode, uid, gid}, nil 31 | } 32 | 33 | // WriteFileAtomically creates a temporary file, sets perms, writes content, and renames it to filename 34 | // This sequence ensures the following: 35 | // 1. Nobody can open the file before we set owner/permissions properly 36 | // 2. Nobody observes a partially-overwritten secret file. 37 | // The returned FileInfo may not match the passed in one, especially if chownFiles is false. 38 | func WriteFileAtomically(path string, chownFiles bool, fileInfo FileInfo, enforceFilesystem Filesystem, content []byte) (*FileInfo, error) { 39 | path = filepath.Clean(path) 40 | if strings.Contains(path, "..") { 41 | return nil, fmt.Errorf("non-canonical file path: %s", path) 42 | } 43 | dir := filepath.Dir(path) 44 | 45 | if err := os.MkdirAll(dir, 0775); err != nil { 46 | return nil, fmt.Errorf("making client directory '%s': %v", dir, err) 47 | } 48 | 49 | // We can't use ioutil.TempFile because we want to open 0000. 50 | buf := make([]byte, 32) 51 | _, err := rand.Read(buf) 52 | if err != nil { 53 | return nil, err 54 | } 55 | randSuffix := hex.EncodeToString(buf) 56 | f, err := os.OpenFile(path+randSuffix, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0000) 57 | // Try to remove the file, in event we early-return with an error. 58 | defer os.Remove(path + randSuffix) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | if chownFiles { 64 | err = f.Chown(fileInfo.UID, fileInfo.GID) 65 | if err != nil { 66 | return nil, err 67 | } 68 | } 69 | 70 | // Always Chmod after the Chown, so we don't expose secret with the wrong owner. 71 | err = f.Chmod(fileInfo.Mode) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | if enforceFilesystem != 0 { 77 | good, err := isFilesystem(f, enforceFilesystem) 78 | if err != nil { 79 | return nil, fmt.Errorf("checking filesystem type: %v", err) 80 | } 81 | if !good { 82 | return nil, fmt.Errorf("unexpected filesystem writing %s", path) 83 | } 84 | } 85 | _, err = f.Write(content) 86 | if err != nil { 87 | return nil, fmt.Errorf("failed writing filesystem content: %v", err) 88 | } 89 | 90 | fileinfo, err := GetFileInfo(f) 91 | if err != nil { 92 | return nil, fmt.Errorf("failed to get file mode back from file: %v", err) 93 | } 94 | 95 | // While this is intended for use with tmpfs, you could write secrets to disk. 96 | // We ignore any errors from syncing, as it's not strictly required. 97 | _ = f.Sync() 98 | 99 | // Rename is atomic, so nobody will observe a partially updated secret 100 | err = os.Rename(path+randSuffix, path) 101 | if err != nil { 102 | return nil, err 103 | } 104 | 105 | return fileinfo, nil 106 | } 107 | 108 | // The Filesystem identification. On Mac, this is uint32, and int64 on linux 109 | // So both are safe to store as an int64. 110 | // Linux Tmpfs = 0x01021994 111 | // Get these constants with `stat --file-system --format=%t` 112 | type Filesystem int64 113 | 114 | func isFilesystem(file *os.File, fs Filesystem) (bool, error) { 115 | var statfs syscall.Statfs_t 116 | err := syscall.Fstatfs(int(file.Fd()), &statfs) 117 | return Filesystem(statfs.Type) == fs, err 118 | } 119 | -------------------------------------------------------------------------------- /ownership/lookup.go: -------------------------------------------------------------------------------- 1 | package ownership 2 | 3 | import ( 4 | "fmt" 5 | "os/user" 6 | "strconv" 7 | ) 8 | 9 | // Lookup is the interface needed by Keysync to resolve a user ID from their username (and group) 10 | // It is intended to be used with the implementation on Os. There's also one in mock.go that 11 | // uses fixed data instead of operating-system sourced data. 12 | type Lookup interface { 13 | UID(username string) (int, error) 14 | GID(groupname string) (int, error) 15 | } 16 | 17 | // Os implements Lookup using the os/user standard library package 18 | type Os struct{} 19 | 20 | var _ Lookup = Os{} 21 | 22 | func (o Os) UID(username string) (int, error) { 23 | u, err := user.Lookup(username) 24 | if err != nil { 25 | return 0, fmt.Errorf("error resolving uid for %s: %v", username, err) 26 | } 27 | id, err := strconv.ParseUint(u.Uid, 10 /* base */, 32 /* bits */) 28 | if err != nil { 29 | return 0, fmt.Errorf("error parsing uid %s for %s: %v", u.Uid, username, err) 30 | } 31 | return int(id), nil 32 | } 33 | 34 | func (o Os) GID(groupname string) (int, error) { 35 | group, err := user.LookupGroup(groupname) 36 | if err != nil { 37 | return 0, fmt.Errorf("error resolving gid for %s: %v", group, err) 38 | } 39 | id, err := strconv.ParseUint(group.Gid, 10 /* base */, 32 /* bits */) 40 | if err != nil { 41 | return 0, fmt.Errorf("error parsing gid %s for %s: %v", group.Gid, groupname, err) 42 | } 43 | return int(id), nil 44 | } 45 | -------------------------------------------------------------------------------- /ownership/lookup_test.go: -------------------------------------------------------------------------------- 1 | package ownership 2 | 3 | import ( 4 | "os/user" 5 | "strconv" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestOsLookup(t *testing.T) { 13 | lookup := Os{} 14 | 15 | current, err := user.Current() 16 | require.NoError(t, err) 17 | 18 | uid, err := lookup.UID(current.Username) 19 | require.NoError(t, err) 20 | 21 | assert.Equal(t, current.Uid, strconv.Itoa(int(uid))) 22 | 23 | currentgids, err := current.GroupIds() 24 | require.NoError(t, err) 25 | 26 | for _, gid := range currentgids { 27 | group, err := user.LookupGroupId(gid) 28 | assert.NoError(t, err) 29 | lookedupGid, err := lookup.GID(group.Name) 30 | assert.NoError(t, err) 31 | 32 | assert.Equal(t, gid, strconv.Itoa(int(lookedupGid))) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ownership/mock.go: -------------------------------------------------------------------------------- 1 | package ownership 2 | 3 | import "fmt" 4 | 5 | // Mock implements the lookup interface using a fixed set of users and groups, useful for tests 6 | type Mock struct { 7 | Users map[string]int 8 | Groups map[string]int 9 | } 10 | 11 | var _ Lookup = &Mock{} 12 | 13 | func (m *Mock) UID(username string) (int, error) { 14 | uid, ok := m.Users[username] 15 | if !ok { 16 | return 0, fmt.Errorf("unknown user %s", username) 17 | } 18 | return uid, nil 19 | } 20 | 21 | func (m *Mock) GID(username string) (int, error) { 22 | uid, ok := m.Groups[username] 23 | if !ok { 24 | return 0, fmt.Errorf("unknown group %s", username) 25 | } 26 | return uid, nil 27 | } 28 | -------------------------------------------------------------------------------- /ownership/ownership.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package ownership 16 | 17 | import ( 18 | "github.com/sirupsen/logrus" 19 | ) 20 | 21 | // Ownership indicates the default ownership of filesystem entries. 22 | type Ownership struct { 23 | UID int 24 | GID int 25 | // Where to look up users and groups 26 | Lookup 27 | } 28 | 29 | // NewOwnership initializes default file ownership struct. 30 | // Logs as error anything that goes wrong, but always returns something 31 | // Worst-case you get "0", ie root, owning things, which is safe as root can always read all files. 32 | func NewOwnership(username, groupname, fallbackUser, fallbackGroup string, lookup Lookup, logger *logrus.Entry) Ownership { 33 | var uid, gid int 34 | var err error 35 | 36 | if username != "" { 37 | uid, err = lookup.UID(username) 38 | } 39 | if err != nil { 40 | logger.WithError(err).WithField("user", username).Error("Error looking up username, using fallback") 41 | } 42 | if username == "" || err != nil { 43 | uid, err = lookup.UID(fallbackUser) 44 | if err != nil { 45 | uid = 0 46 | logger.WithError(err).WithField("user", fallbackUser).Error("Error looking up fallback username, using 0") 47 | } 48 | } 49 | 50 | if groupname != "" { 51 | gid, err = lookup.GID(groupname) 52 | } 53 | if err != nil { 54 | logger.WithError(err).WithField("group", groupname).Error("Error looking up groupname, using fallback") 55 | } 56 | if groupname == "" || err != nil { 57 | gid, err = lookup.GID(fallbackGroup) 58 | if err != nil { 59 | gid = 0 60 | logger.WithError(err).WithField("group", fallbackGroup).Error("Error looking up fallback groupname, using 0") 61 | } 62 | } 63 | 64 | return Ownership{ 65 | UID: uid, 66 | GID: gid, 67 | Lookup: lookup, 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /ownership/ownership_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package ownership 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/sirupsen/logrus" 21 | "github.com/stretchr/testify/assert" 22 | ) 23 | 24 | var testLog = logrus.New().WithField("test", "test") 25 | 26 | var data = Mock{ 27 | Users: map[string]int{"test0": 1000, "test1": 1001, "test2": 1002}, 28 | Groups: map[string]int{"group0": 2000, "group1": 2001, "group2": 2002}, 29 | } 30 | 31 | // TestNewOwnership verifies basic functionality, with no fallback or errors 32 | func TestNewOwnership(t *testing.T) { 33 | ownership := NewOwnership("test1", "group0", "", "", &data, testLog) 34 | assert.EqualValues(t, 1001, ownership.UID) 35 | assert.EqualValues(t, 2000, ownership.GID) 36 | 37 | ownership = NewOwnership("test2", "group2", "", "", &data, testLog) 38 | assert.EqualValues(t, 1002, ownership.UID) 39 | assert.EqualValues(t, 2002, ownership.GID) 40 | } 41 | 42 | func TestFallback(t *testing.T) { 43 | ownership := NewOwnership("user-doesnt-exist", "group0", "test1", "group-doesnt-exist", &data, testLog) 44 | assert.EqualValues(t, 1001, ownership.UID) 45 | assert.EqualValues(t, 2000, ownership.GID) 46 | 47 | ownership = NewOwnership("test2", "group-doesnt-exist", "user-doesnt-exist", "group2", &data, testLog) 48 | assert.EqualValues(t, 1002, ownership.UID) 49 | assert.EqualValues(t, 2002, ownership.GID) 50 | 51 | ownership = NewOwnership("test2", "group-doesnt-exist", "user-doesnt-exist", "more-nonexist", &data, testLog) 52 | assert.EqualValues(t, 1002, ownership.UID) 53 | assert.EqualValues(t, 0, ownership.GID) 54 | 55 | ownership = NewOwnership("user-doesnt-exist", "group1", "user-doesnt-exist2", "", &data, testLog) 56 | assert.EqualValues(t, 0, ownership.UID) 57 | assert.EqualValues(t, 2001, ownership.GID) 58 | 59 | ownership = NewOwnership("", "", "test2", "group2", &data, testLog) 60 | assert.EqualValues(t, 1002, ownership.UID) 61 | assert.EqualValues(t, 2002, ownership.GID) 62 | } 63 | 64 | // Verify we return an error for users and groups not present 65 | func TestLookupFailure(t *testing.T) { 66 | lookup := Os{} 67 | _, err := lookup.UID("non-existent") 68 | assert.Error(t, err) 69 | 70 | _, err = lookup.UID("non-existent") 71 | assert.Error(t, err) 72 | 73 | _, err = lookup.UID("#test2") 74 | assert.Error(t, err) 75 | } 76 | -------------------------------------------------------------------------------- /secret.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package keysync 16 | 17 | import ( 18 | "encoding/base64" 19 | "encoding/json" 20 | "fmt" 21 | "path/filepath" 22 | "strconv" 23 | "strings" 24 | "time" 25 | 26 | "github.com/square/keysync/ownership" 27 | 28 | "os" 29 | 30 | "golang.org/x/sys/unix" 31 | ) 32 | 33 | // ParseSecret deserializes raw JSON into a Secret struct. 34 | func ParseSecret(data []byte) (s *Secret, err error) { 35 | if err = json.Unmarshal(data, &s); err != nil { 36 | return nil, fmt.Errorf("failed to deserialize JSON Secret: %v", err) 37 | } 38 | return 39 | } 40 | 41 | // ParseSecretList deserializes raw JSON into a list of Secret structs. 42 | func ParseSecretList(data []byte) (secrets []Secret, err error) { 43 | if err = json.Unmarshal(data, &secrets); err != nil { 44 | return nil, fmt.Errorf("failed to deserialize JSON []Secret: %v", err) 45 | } 46 | return 47 | } 48 | 49 | // Secret represents data returned after processing a server request. 50 | // 51 | // json tags after fields indicate to json decoder the key name in JSON 52 | type Secret struct { 53 | Name string 54 | Content content `json:"secret"` 55 | Length uint64 `json:"secretLength"` 56 | Checksum string `json:"checksum"` 57 | CreatedAt time.Time `json:"creationDate"` 58 | UpdatedAt time.Time `json:"updateDate"` 59 | FilenameOverride *string `json:"filename"` 60 | Mode string 61 | Owner string 62 | Group string 63 | } 64 | 65 | // ModeValue function helps by converting a textual mode to the expected value for fuse. 66 | func (s Secret) ModeValue() (os.FileMode, error) { 67 | mode := s.Mode 68 | if mode == "" { 69 | mode = "0440" 70 | } 71 | modeValue, err := strconv.ParseUint(mode, 8 /* base */, 16 /* bits */) 72 | if err != nil { 73 | return 0, fmt.Errorf("unable to parse secret file mode (%v): %v", mode, err) 74 | } 75 | // The only acceptable bits to set in a mode are read bits, so we mask off any additional bits. 76 | modeValue = modeValue & 0444 77 | return os.FileMode(modeValue | unix.S_IFREG), nil 78 | } 79 | 80 | // OwnershipValue returns the ownership for a given secret, falling back to the values given as 81 | // an argument if they're not present in the secret 82 | func (s Secret) OwnershipValue(fallback ownership.Ownership) (ret ownership.Ownership) { 83 | ret = fallback 84 | if s.Owner != "" { 85 | uid, err := fallback.Lookup.UID(s.Owner) 86 | if err == nil { 87 | ret.UID = uid 88 | } 89 | } 90 | if s.Group != "" { 91 | gid, err := fallback.Lookup.GID(s.Group) 92 | if err == nil { 93 | ret.GID = gid 94 | } 95 | } 96 | return 97 | } 98 | 99 | // Filename returns the expected filename of a secret. The filename metadata overrides the name, 100 | // but it can't be path, so keysync can't delete or write arbitrary files outside its secrets directory. 101 | func (s Secret) Filename() (string, error) { 102 | name := s.Name 103 | if s.FilenameOverride != nil { 104 | name = *s.FilenameOverride 105 | } 106 | 107 | if strings.ContainsRune(name, filepath.Separator) { 108 | return "", fmt.Errorf("secret has invalid filename, got '%s'", name) 109 | } 110 | 111 | return name, nil 112 | } 113 | 114 | // content is a helper type used to convert base64-encoded data from the server. 115 | type content []byte 116 | 117 | func (c *content) UnmarshalJSON(data []byte) error { 118 | var s string 119 | if err := json.Unmarshal(data, &s); err != nil { 120 | return fmt.Errorf("secret should be a string, got '%s' (%v)", data, err) 121 | } 122 | 123 | // Go's base64 requires padding to be present so we add it if necessary. 124 | if m := len(s) % 4; m != 0 { 125 | s += strings.Repeat("=", 4-m) 126 | } 127 | 128 | decoded, err := base64.StdEncoding.DecodeString(s) 129 | if err != nil { 130 | return fmt.Errorf("secret not valid base64, got '%+v' (%v)", s, err) 131 | } 132 | 133 | *c = decoded 134 | return nil 135 | } 136 | -------------------------------------------------------------------------------- /secret_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package keysync 16 | 17 | import ( 18 | "encoding/json" 19 | "os" 20 | "testing" 21 | "time" 22 | 23 | "github.com/square/keysync/ownership" 24 | 25 | "github.com/stretchr/testify/assert" 26 | "github.com/stretchr/testify/require" 27 | "golang.org/x/sys/unix" 28 | ) 29 | 30 | func TestSecretDeserializeSecret(t *testing.T) { 31 | newAssert := assert.New(t) 32 | 33 | s, err := ParseSecret(fixture("secret_Nobody_PgPass.json")) 34 | require.Nil(t, err) 35 | newAssert.Equal("Nobody_PgPass", s.Name) 36 | newAssert.EqualValues(6, s.Length) 37 | newAssert.Equal("0400", s.Mode) 38 | newAssert.Equal("nobody", s.Owner) 39 | newAssert.Equal("nobody", s.Group) 40 | newAssert.EqualValues("asddas", s.Content) 41 | 42 | expectedCreatedAt := time.Date(2011, time.September, 29, 15, 46, 0, 232000000, time.UTC) 43 | newAssert.Equal(s.CreatedAt.Unix(), expectedCreatedAt.Unix()) 44 | } 45 | 46 | func TestSecretDeserializeSecretWithoutBase64Padding(t *testing.T) { 47 | newAssert := assert.New(t) 48 | 49 | s, err := ParseSecret(fixture("secretWithoutBase64Padding.json")) 50 | require.Nil(t, err) 51 | newAssert.Equal("NonexistentOwner_Pass", s.Name) 52 | newAssert.EqualValues("12345", s.Content) 53 | } 54 | 55 | func TestSecretDeserializeSecretList(t *testing.T) { 56 | newAssert := assert.New(t) 57 | 58 | fixtures := []string{"secrets.json", "secretsWithoutContent.json"} 59 | for _, f := range fixtures { 60 | secrets, err := ParseSecretList(fixture(f)) 61 | require.Nil(t, err) 62 | newAssert.Len(secrets, 2) 63 | } 64 | } 65 | 66 | func TestSecretModeValue(t *testing.T) { 67 | newAssert := assert.New(t) 68 | 69 | cases := []struct { 70 | secret Secret 71 | mode uint32 72 | }{ 73 | {Secret{Mode: "0440"}, 288}, 74 | {Secret{Mode: "0400"}, 256}, 75 | {Secret{}, 288}, 76 | } 77 | for _, c := range cases { 78 | mode, err := c.secret.ModeValue() 79 | require.Nil(t, err) 80 | newAssert.Equal(os.FileMode(c.mode|unix.S_IFREG), mode) 81 | } 82 | 83 | _, err := Secret{Mode: "9999"}.ModeValue() 84 | newAssert.NotNil(err) 85 | } 86 | 87 | func TestSecretOwnershipValue(t *testing.T) { 88 | var data = ownership.Mock{ 89 | Users: map[string]int{"test0": 1000, "test1": 1001, "test2": 1002}, 90 | Groups: map[string]int{"group0": 2000, "group1": 2001, "group2": 2002}, 91 | } 92 | defaultOwnership := ownership.Ownership{UID: 1, GID: 1, Lookup: &data} 93 | 94 | own := Secret{Owner: "test0"}.OwnershipValue(defaultOwnership) 95 | assert.EqualValues(t, 1000, own.UID) 96 | assert.EqualValues(t, 1, own.GID) 97 | 98 | own = Secret{Owner: "test1", Group: "group2"}.OwnershipValue(defaultOwnership) 99 | assert.EqualValues(t, 1001, own.UID) 100 | assert.EqualValues(t, 2002, own.GID) 101 | 102 | own = Secret{}.OwnershipValue(defaultOwnership) 103 | assert.EqualValues(t, 1, own.UID) 104 | assert.EqualValues(t, 1, own.GID) 105 | } 106 | 107 | func TestContentErrors(t *testing.T) { 108 | s, err := ParseSecret(fixture("secretWithoutBase64Padding.json")) 109 | require.Nil(t, err) 110 | originalContent := make([]byte, len(s.Content)) 111 | copy(originalContent, s.Content) // Save the original content of this secret 112 | 113 | data, err := json.Marshal(12) 114 | require.Nil(t, err) 115 | err = s.Content.UnmarshalJSON(data) 116 | assert.NotNil(t, err) 117 | assert.EqualValues(t, originalContent, s.Content) 118 | 119 | raw := json.RawMessage(`"not base64"`) 120 | data, err = json.Marshal(&raw) 121 | require.Nil(t, err) 122 | err = s.Content.UnmarshalJSON(data) 123 | assert.NotNil(t, err) 124 | assert.EqualValues(t, originalContent, s.Content) 125 | } 126 | 127 | func TestFilename(t *testing.T) { 128 | filenameOverride := "../../deleteme" 129 | s := Secret{Name: "mydbsecret", FilenameOverride: &filenameOverride} 130 | name, err := s.Filename() 131 | require.NotNil(t, err) 132 | require.Empty(t, name) 133 | 134 | s = Secret{Name: "../../mydbsecret"} 135 | name, err = s.Filename() 136 | require.NotNil(t, err) 137 | require.Empty(t, name) 138 | 139 | s = Secret{Name: "mydbsecret"} 140 | name, err = s.Filename() 141 | require.Nil(t, err) 142 | require.Equal(t, "mydbsecret", name) 143 | 144 | filenameOverride = "fileoverride" 145 | s = Secret{Name: "mydbsecret", FilenameOverride: &filenameOverride} 146 | name, err = s.Filename() 147 | require.Nil(t, err) 148 | require.Equal(t, "fileoverride", name) 149 | } 150 | -------------------------------------------------------------------------------- /syncer_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package keysync 16 | 17 | import ( 18 | "crypto/rand" 19 | "encoding/hex" 20 | "encoding/json" 21 | "fmt" 22 | "net/http" 23 | "net/http/httptest" 24 | "strings" 25 | "testing" 26 | "time" 27 | 28 | "github.com/sirupsen/logrus" 29 | "github.com/stretchr/testify/assert" 30 | "github.com/stretchr/testify/require" 31 | ) 32 | 33 | func TestSyncerLoadClients(t *testing.T) { 34 | config, err := LoadConfig("fixtures/configs/test-config.yaml") 35 | require.Nil(t, err) 36 | 37 | syncer, err := NewSyncer(config, NewInMemoryOutputCollection(), logrus.NewEntry(logrus.New()), metricsForTest()) 38 | require.Nil(t, err) 39 | 40 | _, err = syncer.LoadClients() 41 | require.Nil(t, err) 42 | 43 | // The clients should reload without error 44 | _, err = syncer.LoadClients() 45 | require.Nil(t, err) 46 | } 47 | 48 | func TestSyncerLoadClientsError(t *testing.T) { 49 | config, err := LoadConfig("fixtures/configs/errorconfigs/nonexistent-client-dir-config.yaml") 50 | require.Nil(t, err) 51 | 52 | syncer, err := NewSyncer(config, NewInMemoryOutputCollection(), logrus.NewEntry(logrus.New()), metricsForTest()) 53 | require.Nil(t, err) 54 | 55 | _, err = syncer.LoadClients() 56 | require.NotNil(t, err) 57 | } 58 | 59 | func TestSyncerBuildClient(t *testing.T) { 60 | config, err := LoadConfig("fixtures/configs/test-config.yaml") 61 | require.Nil(t, err) 62 | 63 | syncer, err := NewSyncer(config, NewInMemoryOutputCollection(), logrus.NewEntry(logrus.New()), metricsForTest()) 64 | require.Nil(t, err) 65 | 66 | clients, err := config.LoadClients() 67 | require.Nil(t, err) 68 | 69 | client1, ok := clients["client1"] 70 | require.True(t, ok) 71 | 72 | entry, err := syncer.buildClient("client1", client1, metricsForTest()) 73 | require.Nil(t, err) 74 | assert.Equal(t, entry.ClientConfig, client1) 75 | 76 | // Test misconfigured clients 77 | cfg := defaultClientConfig() 78 | cfg.DirName = "missingkey" 79 | cfg.Cert = "fixtures/clients/client4.crt" 80 | cfg.Key = "" 81 | entry, err = syncer.buildClient("missingkey", *cfg, metricsForTest()) 82 | require.Error(t, err) 83 | require.Nil(t, entry) 84 | 85 | cfg = defaultClientConfig() 86 | cfg.DirName = "missingcert" 87 | cfg.Cert = "" 88 | cfg.Key = "fixtures/clients/client4.key" 89 | entry, err = syncer.buildClient("missingcert", *cfg, metricsForTest()) 90 | require.Error(t, err) 91 | require.Nil(t, entry) 92 | 93 | // The syncer currently handles clients configured with missing mountpoints 94 | cfg = defaultClientConfig() 95 | cfg.DirName = "valid" 96 | cfg.Cert = "fixtures/clients/client4.crt" 97 | cfg.Key = "fixtures/clients/client4.key" 98 | entry, err = syncer.buildClient("missingcert", *cfg, metricsForTest()) 99 | require.NoError(t, err) 100 | require.NotNil(t, entry) 101 | } 102 | 103 | func TestSyncerRandomDuration(t *testing.T) { 104 | testData := []struct{ start, end string }{ 105 | {"100s", "125s"}, 106 | {"10s", "12.5s"}, 107 | {"1s", "1.25s"}, 108 | {"21h", "26.25h"}, 109 | } 110 | for j := 1; j <= 1024; j++ { 111 | for _, interval := range testData { 112 | start, err := time.ParseDuration(interval.start) 113 | if err != nil { 114 | t.Fatalf("Parsing test data: %v", err) 115 | } 116 | end, err := time.ParseDuration(interval.end) 117 | if err != nil { 118 | t.Fatalf("Parsing test data: %v", err) 119 | } 120 | random := randomize(start) 121 | if float64(random) < float64(start) { 122 | t.Fatalf("Random before expected range: %v < %v", random, start) 123 | } 124 | if float64(random) > float64(end) { 125 | t.Fatalf("Random beyond expected range: %v > %v", random, end) 126 | } 127 | } 128 | } 129 | } 130 | 131 | func TestSyncerRunSuccess(t *testing.T) { 132 | server := createDefaultServer() 133 | defer server.Close() 134 | 135 | // Create a new syncer with this server 136 | syncer, err := createNewSyncer("fixtures/configs/test-config.yaml", server) 137 | require.Nil(t, err) 138 | 139 | updated, errs := syncer.RunOnce() 140 | require.Nil(t, errs) 141 | 142 | // For each client, we should have added two secrets. 143 | require.Equal(t, len(syncer.clients)*2, int(updated.Added), "Expect two files added per client") 144 | 145 | for _, entry := range syncer.clients { 146 | // Check the files in the mountpoint 147 | output := entry.output.(*InMemoryOutput) 148 | require.Equal(t, 2, len(output.Secrets), "Expect two files successfully written after sync") 149 | 150 | _, present := output.Secrets["Nobody_PgPass"] 151 | assert.True(t, present, "Expect Nobody_PgPass successfully written after sync") 152 | 153 | _, present = output.Secrets["General_Password..0be68f903f8b7d86"] 154 | assert.True(t, present, "Expect General_Password..0be68f903f8b7d86 successfully written after sync") 155 | } 156 | } 157 | 158 | func TestSyncChangedSecrets(t *testing.T) { 159 | type secret struct { 160 | Name string `json:"name"` 161 | Secret string `json:"secret"` 162 | } 163 | 164 | // Create a new server that generates a random value for a secret named changing-secret every time 165 | // a hander is called. This lets us simulate secret changes. 166 | server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 167 | t.Helper() 168 | 169 | bytes := make([]byte, 8) 170 | if _, err := rand.Read(bytes); err != nil { 171 | t.Fatalf("rand.Read failed: %v", err) 172 | } 173 | s := &secret{ 174 | Name: "changing-secret", 175 | Secret: hex.EncodeToString(bytes), 176 | } 177 | 178 | encoder := json.NewEncoder(w) 179 | var err error 180 | switch r.URL.Path { 181 | case "/secrets": 182 | s.Secret = "" 183 | err = encoder.Encode([]*secret{s}) 184 | case "/secret/" + s.Name: 185 | err = encoder.Encode(s) 186 | case "/batchsecret": 187 | err = encoder.Encode([]*secret{s}) 188 | default: 189 | w.WriteHeader(404) 190 | } 191 | 192 | if err != nil { 193 | t.Fatalf("JSON encoding failed: %v", err) 194 | } 195 | })) 196 | server.TLS = testCerts(testCaFile) 197 | server.StartTLS() 198 | t.Cleanup(server.Close) 199 | 200 | // Create a new syncer with this server 201 | syncer, err := createNewSyncer("fixtures/configs/test-config.yaml", server) 202 | require.Nil(t, err) 203 | 204 | // The first time, all secrets should be added. 205 | updated, errs := syncer.RunOnce() 206 | require.Nil(t, errs) 207 | require.Equal(t, Updated{Added: uint(len(syncer.clients)), Changed: 0, Deleted: 0}, updated) 208 | 209 | // The next time, all secrets should changed. 210 | updated, errs = syncer.RunOnce() 211 | require.Nil(t, errs) 212 | require.Equal(t, Updated{Added: 0, Changed: uint(len(syncer.clients)), Deleted: 0}, updated) 213 | } 214 | 215 | func TestSyncerRunSuccessWithDeletionRace(t *testing.T) { 216 | server := createDefaultServerWithDeletionRace() 217 | defer server.Close() 218 | 219 | // Create a new syncer with this server 220 | syncer, err := createNewSyncer("fixtures/configs/test-config.yaml", server) 221 | require.Nil(t, err) 222 | 223 | // Clear the syncer's poll interval so the "Run" loop only executes once 224 | syncer.pollInterval = 0 225 | 226 | err = syncer.Run() 227 | require.Nil(t, err) 228 | 229 | // Only one secret should have been written because the other was deleted 230 | for _, entry := range syncer.clients { 231 | // Check the files in the mountpoint 232 | output := entry.output.(*InMemoryOutput) 233 | require.Equal(t, 1, len(output.Secrets), "Expect one file successfully written after sync") 234 | 235 | _, present := output.Secrets["Nobody_PgPass"] 236 | assert.True(t, present, "Expect Nobody_PgPass successfully written after sync") 237 | } 238 | } 239 | 240 | func TestSyncerRunLoadClientsFails(t *testing.T) { 241 | server := createDefaultServerWithDeletionRace() 242 | defer server.Close() 243 | 244 | // Create a new syncer with this server 245 | syncer, err := createNewSyncer("fixtures/configs/errorconfigs/nonexistent-client-dir-config.yaml", server) 246 | require.Nil(t, err) 247 | 248 | // Clear the syncer's poll interval so the "Run" loop only executes once 249 | syncer.pollInterval = 0 250 | 251 | err = syncer.Run() 252 | require.NotNil(t, err) 253 | } 254 | 255 | func TestNewSyncerFails(t *testing.T) { 256 | // Load a test config which fails on LoadClients 257 | config, err := LoadConfig("fixtures/configs/errorconfigs/nonexistent-client-dir-config.yaml") 258 | require.Nil(t, err) 259 | 260 | // Set an invalid server URL 261 | config.Server = "\\" 262 | 263 | _, err = NewSyncer(config, OutputDirCollection{}, logrus.NewEntry(logrus.New()), metricsForTest()) 264 | require.NotNil(t, err) 265 | } 266 | 267 | // Simulates a Keywhiz server outage leading to 500 errors. The secrets should not be deleted 268 | // from the mountpoint for Keywhiz-internal errors, but should be deleted when the response is 404. 269 | func TestSyncerEntrySyncKeywhizFails(t *testing.T) { 270 | server := createDefaultServerWithDeletionRace() 271 | defer server.Close() 272 | 273 | syncer, err := createNewSyncer("fixtures/configs/test-config.yaml", server) 274 | require.Nil(t, err) 275 | 276 | _, err = syncer.LoadClients() 277 | require.Nil(t, err) 278 | 279 | for name, entry := range syncer.clients { 280 | _, err = entry.Sync() 281 | require.Nil(t, err, "No error expected updating entry %s", name) 282 | 283 | // Check the files in the mountpoint 284 | output := entry.output.(*InMemoryOutput) 285 | require.Equal(t, 1, len(output.Secrets), "Expect one file successfully written after sync") 286 | _, present := output.Secrets["Nobody_PgPass"] 287 | assert.True(t, present, "Expect Nobody_PgPass successfully written after sync") 288 | } 289 | 290 | // Switch to a server which errors internally when accessing the secret; this should not cause it to be deleted 291 | internalErrorServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 292 | switch { 293 | case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/secrets"): 294 | fmt.Fprint(w, string(fixture("secrets.json"))) 295 | case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/secret/Nobody_PgPass"): 296 | w.WriteHeader(500) 297 | default: 298 | w.WriteHeader(404) 299 | } 300 | })) 301 | internalErrorServer.TLS = testCerts(testCaFile) 302 | internalErrorServer.StartTLS() 303 | defer internalErrorServer.Close() 304 | 305 | resetSyncerServer(syncer, internalErrorServer) 306 | 307 | // Clear and reload the clients to force them to pick up the new server 308 | syncer.clients = make(map[string]syncerEntry) 309 | _, err = syncer.LoadClients() 310 | require.Nil(t, err) 311 | 312 | for name, entry := range syncer.clients { 313 | _, err = entry.Sync() 314 | require.Nil(t, err, "No error expected updating entry %s", name) 315 | 316 | // Check the files in the mountpoint 317 | output := entry.output.(*InMemoryOutput) 318 | require.Equal(t, 1, len(output.Secrets), "Expect one file successfully written after sync") 319 | _, present := output.Secrets["Nobody_PgPass"] 320 | assert.True(t, present, "Expect Nobody_PgPass successfully written after sync despite internal error") 321 | } 322 | 323 | // Switch to a server in which the secret is deleted 324 | deletedServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 325 | switch { 326 | case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/secrets"): 327 | fmt.Fprint(w, string(fixture("secrets.json"))) 328 | case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/secret/Nobody_PgPass"): 329 | w.WriteHeader(404) 330 | default: 331 | w.WriteHeader(404) 332 | } 333 | })) 334 | deletedServer.TLS = testCerts(testCaFile) 335 | deletedServer.StartTLS() 336 | defer deletedServer.Close() 337 | 338 | resetSyncerServer(syncer, deletedServer) 339 | 340 | // Clear and reload the clients to force them to pick up the new server 341 | syncer.clients = make(map[string]syncerEntry) 342 | _, err = syncer.LoadClients() 343 | require.Nil(t, err) 344 | 345 | for name, entry := range syncer.clients { 346 | _, err = entry.Sync() 347 | require.Nil(t, err, "No error expected updating entry %s", name) 348 | 349 | // Check the files in the mountpoint 350 | output := entry.output.(*InMemoryOutput) 351 | require.Equal(t, 0, len(output.Secrets), "Expect all secrets to be deleted after sync") 352 | } 353 | 354 | // Switch to a server in which the secret has an override that is a filepath 355 | compromisedServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 356 | switch { 357 | case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/secrets"): 358 | fmt.Fprint(w, string(fixture("secretsWithBadFilenameOverride.json"))) 359 | case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/secret/Nobody_PgPass"): 360 | w.WriteHeader(404) 361 | default: 362 | w.WriteHeader(404) 363 | } 364 | })) 365 | compromisedServer.TLS = testCerts(testCaFile) 366 | compromisedServer.StartTLS() 367 | defer compromisedServer.Close() 368 | 369 | resetSyncerServer(syncer, compromisedServer) 370 | 371 | // Clear and reload the clients to force them to pick up the new server 372 | syncer.clients = make(map[string]syncerEntry) 373 | syncer.outputCollection = NewInMemoryOutputCollection() 374 | _, err = syncer.LoadClients() 375 | require.Nil(t, err) 376 | 377 | for _, entry := range syncer.clients { 378 | _, err = entry.Sync() 379 | require.NotNil(t, err) 380 | 381 | // Check the files in the mountpoint 382 | output := entry.output.(*InMemoryOutput) 383 | require.Equal(t, 0, output.NumWrites(), "Expect no secrets to be written during sync") 384 | require.Equal(t, 0, output.NumDeletes(), "Expect no secrets to be deleted after sync") 385 | } 386 | } 387 | -------------------------------------------------------------------------------- /testing/cacert.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDDjCCAfigAwIBAgIBATALBgkqhkiG9w0BAQswGjEYMBYGA1UEAxMPS2V5d2hp 3 | eiBUZXN0IENBMB4XDTE1MDQyMDE3NDQ0MloXDTQ1MDQyMDE4NDQ0M1owGjEYMBYG 4 | A1UEAxMPS2V5d2hpeiBUZXN0IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB 5 | CgKCAQEAytHUEdPnH3tu+D+SnycFuOmVRdnrPTa7yHoiBp1wuNS/as+SpfVL20GT 6 | DVnHBdvqWH3H4xguLhrv3T0aZB5MyTqwjiomzPbq/fVjudc3XhkPylK7rTgbKB49 7 | ZQwz9dSjciWyILd32zCvMSg+6r7h1UzxAG9X70UYLbgBA3zNFZWfxVZ/DJGoCkp4 8 | WZLmKjzYjRrhSkXD4M9HayGDDJkGlr75WVl6fTlZkFy1+QQ13NpMhNq29H0UeaEu 9 | GuUCR4/pkVJqzZ614UYKjg56LZC919nu9tVHSEdnJb0k8tLSn8YlCwzeVd6JGeAR 10 | mhcLpjGiT729K2c+ui7Hp+JhRLCgSQIDAQABo2MwYTAOBgNVHQ8BAf8EBAMCAAYw 11 | DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUQII6ahTU+FRbnqKa/PNeBC1ke1ww 12 | HwYDVR0jBBgwFoAUQII6ahTU+FRbnqKa/PNeBC1ke1wwCwYJKoZIhvcNAQELA4IB 13 | AQCbjkIeJlgS5MZpfqQPqRSDj0/0enctvpwRhUumKxhDgVgP0drhh3Pjfe6Wq0FR 14 | jUET2B2rwBPLZC/N/S0YM6oy2DbPEPQkvTUwGG/ci0JmD+ryHxHuOo59rFdnpblJ 15 | Gl0ouNAC524vg7bdjLUx1F8yez1bZbU42Gwajy2UliHnCqQTj/hxe8ELPcL2qFVV 16 | cQSKfiRTdGexpJ8OJjKT5iBjPu7Z3oC7OAl7HWhAFBZxNXPWePleoca8kiO9xkhf 17 | 4XG9sMoL6HgFB/CoZUIRLUMEPe9tgs152lWvZG3boJK7uOC73RNSCPlUvcXYm3Eo 18 | mBUwOdMSOYuhaWL0B8+XJgVj 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /testing/clients/client.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDIjCCAgygAwIBAgIQVqisMgsEeUitUbltIrNH3DALBgkqhkiG9w0BAQswGjEY 3 | MBYGA1UEAxMPS2V5d2hpeiBUZXN0IENBMB4XDTE1MDQyMDE4MzQ1MFoXDTQ1MDQy 4 | MDE5MzQ1MFowETEPMA0GA1UEAxMGY2xpZW50MIIBIjANBgkqhkiG9w0BAQEFAAOC 5 | AQ8AMIIBCgKCAQEAvTvANHENYOlIVZsTge/bKY4/ShkI/jxK/Sn84zC7Oecfomis 6 | DBDk9rOKncAta3ohVDN2AqEaSwjKea/VWsHTCbducRcvf3k70WVaOcxbAp5WBIO0 7 | 0xRB084n89hRBx8Kto/e5kcPGBvP7gnFsELbFTDWwI1pS5TDTVBj+hhO6YySwghD 8 | S1yiCB7m1bXcv441MAe9I7ztJ2Xp1SX9I7q5k5KWwLRQMB/qAV0rI9WMcLkemEjw 9 | CCZyP4KhTtel7LW3MvzAmUxmoHv3LWFCmR9d++qnzIBjsawUv9xIrMRFdNwf2ogx 10 | ne4qbJ0jWsjhAulXM6AGu4A0hP3zjnqV5Iin2wIDAQABo3EwbzAOBgNVHQ8BAf8E 11 | BAMCALgwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMB0GA1UdDgQWBBR2 12 | m/tM4lFoqPh4rA78T/Z0VxHKsjAfBgNVHSMEGDAWgBRAgjpqFNT4VFueopr8814E 13 | LWR7XDALBgkqhkiG9w0BAQsDggEBAMbBhpLVe49ztUr04X8lUtNYpTkwvbUreSRV 14 | k4f+OvB6VCClqjwVbv0SiHR2vU5ES5nephukq/l6SLixSv3IUqBJKOPT5g3OkADq 15 | 66xk8S7ot+IEV2RAwJx3hfnQCwqHOPnsYiFkmO1wJkv6gtq9shg08fhyE8b5GuL0 16 | 67FSliJMQ2Yo5YzQztsxUD2QkeC8u24pRereSQS19QToa7qE9kgExYZQW8GSjyuo 17 | Ev78lB9iL8ZZLsF1Z6Kx6UlyRnY7/MkCpP7Wg8oEgrez4sPfvuMRo9U6f67sffRp 18 | T+c7b6JlEbM7DQlS6q23Foh2dxo5aTwq88w7anPf7NnVeqdyQmg= 19 | -----END CERTIFICATE----- 20 | -----BEGIN RSA PRIVATE KEY----- 21 | MIIEpAIBAAKCAQEAvTvANHENYOlIVZsTge/bKY4/ShkI/jxK/Sn84zC7Oecfomis 22 | DBDk9rOKncAta3ohVDN2AqEaSwjKea/VWsHTCbducRcvf3k70WVaOcxbAp5WBIO0 23 | 0xRB084n89hRBx8Kto/e5kcPGBvP7gnFsELbFTDWwI1pS5TDTVBj+hhO6YySwghD 24 | S1yiCB7m1bXcv441MAe9I7ztJ2Xp1SX9I7q5k5KWwLRQMB/qAV0rI9WMcLkemEjw 25 | CCZyP4KhTtel7LW3MvzAmUxmoHv3LWFCmR9d++qnzIBjsawUv9xIrMRFdNwf2ogx 26 | ne4qbJ0jWsjhAulXM6AGu4A0hP3zjnqV5Iin2wIDAQABAoIBAE3vQM6YTOk/ypGv 27 | J46ZKUrpEbnDq8eBL2UqmMM8u68yN/4cW9cwUgwkj48+qbYc+4MBGrYkgX6rpTAO 28 | sbEKKI9U44BiCybV2EP6GPm65zSh301GrP9N1XqU6jFsQprLNw9PG379fwLv2Wfw 29 | 0GEyd6Y3kgqFcvs0zmaWGEbVIhLfI6svpO+Zp1hbHt67WvoWqhy+ujcFfwwHmVW2 30 | 7+1sf257WCxyFSKkaNbgxd8VuLDqxwxWoXazxtHCXR3mRabCFDuWSpvROq+iFRVi 31 | 0QE+O+6df5xCUAF/UQxs3jghq4ng+8c+nJJSEFrhuF7OmY1Yp6XZaKc6gSNH67v7 32 | qyNgnLECgYEA6VWRfYauyymYZLs8eogbMaiUsrw7EU7NdfUemHsKHb3+SFBx0PUT 33 | EGeUIeVaaNS++ZCwefLKTdgiBbKoOeyg2h92v0bYMxp7VOGSofSfhLL+Iua6m0UB 34 | R6VvWZx8EOhDa8kpKvkZtRiJUJcmxhikCZKqGaE+60Mjdazma0/J5akCgYEAz52B 35 | ji+adAeZV7LwCg/m1p2M1GuM6s7Pi6QB6hq9GfOoQYDiR331jRhJIOtJHBdldrXX 36 | ShcrUd48uBl21gT1i52nH6mU1UkL/89tLbdyjLUQT+F1MukOI15WG5DH8pQL5csu 37 | XxkpmuNceShjjO424IzykFif4eyRKASBw9Vdy+MCgYEAoP7EgysBwfYySxaRtS3i 38 | LZJW/zg3PUr1IvV9JdKHeVwVbonq7jWa8M+2+uhISFq6ZnH6AjqOccW2O944ircF 39 | iVr6USItnJ0iCcAWr56czi++gBBZIzcqmefA+8CoLfZERsOmnxr/LOAAJtYUD80C 40 | qgRDT6ndQvCxL8mbtuF3ufkCgYEAjV/Qv9S0lTwzdB+aCxAG/a+tHVzbSW1osMsO 41 | rq5khI6BzZEJBOvF0L1v1qXBVAqugeaTYpViX045BJf6bwRTfC3vhsUAXzhtnlVO 42 | ICpiK2SEZhC9sNw4T2dGtWCidxHPBDyWKBXHWfxmlO4m0+nGnqP77MUcokhoE9r1 43 | zje1tkcCgYBM7JH3CbhEP1rsDc2AHuQhZmCYo15beOWxRN7L0ZnYjOzvoqQ7WvI9 44 | NeBHWws0OMAvgrsc1Oc4AynIHXlB/ziBbp77X/284gA0v7QZoVIgKYSjdpBnwt7o 45 | rPaqKIjAQjOPhL0kW9+yPVhmAej75zKqfpo4oarg4YdX94Cfr26a/w== 46 | -----END RSA PRIVATE KEY----- 47 | -------------------------------------------------------------------------------- /testing/clients/client.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | client1: 3 | key: client.pem 4 | cert: client.pem 5 | user: 'keysync-test' 6 | group: 'keysync-test' 7 | -------------------------------------------------------------------------------- /testing/expected/content/Database_Password: -------------------------------------------------------------------------------- 1 | 12345 -------------------------------------------------------------------------------- /testing/expected/content/General_Password: -------------------------------------------------------------------------------- 1 | asddas -------------------------------------------------------------------------------- /testing/expected/content/Nobody_PgPass: -------------------------------------------------------------------------------- 1 | somehost.someplace.com:5432:somedatabase:misterawesome:hell0McFly 2 | -------------------------------------------------------------------------------- /testing/expected/content/NonexistentOwner_Pass: -------------------------------------------------------------------------------- 1 | 12345 -------------------------------------------------------------------------------- /testing/expected/ownership/Database_Password: -------------------------------------------------------------------------------- 1 | keysync-test:keysync-test:440 2 | -------------------------------------------------------------------------------- /testing/expected/ownership/General_Password: -------------------------------------------------------------------------------- 1 | keysync-test:keysync-test:440 2 | -------------------------------------------------------------------------------- /testing/expected/ownership/Nobody_PgPass: -------------------------------------------------------------------------------- 1 | nobody:keysync-test:400 2 | -------------------------------------------------------------------------------- /testing/expected/ownership/NonexistentOwner_Pass: -------------------------------------------------------------------------------- 1 | keysync-test:keysync-test:400 2 | -------------------------------------------------------------------------------- /testing/keysync-backup.key: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/keysync/b8ca463a888a9019db8ef1e7f09bc3b9497cddb4/testing/keysync-backup.key -------------------------------------------------------------------------------- /testing/keysync-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | secrets_directory: '/secrets' 3 | client_directory: '/opt/keysync/testing/clients' 4 | ca_file: '/opt/keysync/testing/cacert.crt' 5 | yaml_ext: yaml 6 | chown_files: true 7 | server: 'localhost:4444' 8 | debug: true 9 | default_user: 'keysync-test' 10 | default_group: 'keysync-test' 11 | api_port: 31738 12 | poll_interval: 1s 13 | backup_key_path: /tmp/keysync-backup.key.wrapped 14 | backup_path: /tmp/keysync-backup.tar.enc 15 | backup_pubkey: 'mHpEJsGAPmANxhlpFEE0DI1eQRTOsdKGvR3oVX6PKUs=' 16 | -------------------------------------------------------------------------------- /testing/keywhiz-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Keywhiz configuration file for integration test server 3 | # Don't use this for production deployments, it's set up for testing. 4 | 5 | server: 6 | applicationConnectors: 7 | - type: https 8 | port: 4444 9 | keyStorePath: /opt/keysync/testing/resources/dev_and_test_keystore.p12 10 | keyStorePassword: ponies 11 | keyStoreType: PKCS12 12 | trustStorePath: /opt/keysync/testing/resources/dev_and_test_truststore.p12 13 | trustStorePassword: ponies 14 | trustStoreType: PKCS12 15 | wantClientAuth: true 16 | validateCerts: false 17 | enableCRLDP: false 18 | enableOCSP: false 19 | crlPath: /opt/keysync/testing/resources/dev_and_test.crl 20 | supportedProtocols: [TLSv1.2] 21 | supportedCipherSuites: 22 | - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 23 | - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 24 | adminConnectors: 25 | - type: http 26 | bindHost: localhost 27 | port: 8085 28 | 29 | logging: 30 | appenders: 31 | - type: console 32 | threshold: INFO 33 | 34 | environment: development 35 | 36 | database: 37 | driverClass: org.h2.Driver 38 | url: jdbc:h2:/opt/keysync/testing/keywhizdb_testing 39 | user: root 40 | properties: 41 | charSet: UTF-8 42 | initialSize: 10 43 | minSize: 10 44 | maxSize: 10 45 | 46 | readonlyDatabase: 47 | driverClass: org.h2.Driver 48 | url: jdbc:h2:/opt/keysync/testing/keywhizdb_testing 49 | user: root 50 | properties: 51 | charSet: UTF-8 52 | readOnlyByDefault: true 53 | initialSize: 32 54 | minSize: 32 55 | maxSize: 32 56 | 57 | migrationsDir: db/h2/migration 58 | 59 | statusCacheExpiry: PT1S 60 | 61 | userAuth: 62 | type: bcrypt 63 | 64 | cookieKey: 'QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE=' 65 | 66 | sessionCookie: 67 | name: session 68 | path: /admin 69 | 70 | xsrfCookie: 71 | name: XSRF-TOKEN 72 | path: / 73 | httpOnly: false 74 | 75 | contentKeyStore: 76 | path: /opt/keysync/testing/resources/derivation.jceks 77 | type: JCEKS 78 | password: CHANGE 79 | alias: basekey 80 | -------------------------------------------------------------------------------- /testing/keywhiz-server.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/keysync/b8ca463a888a9019db8ef1e7f09bc3b9497cddb4/testing/keywhiz-server.jar -------------------------------------------------------------------------------- /testing/lib-signed/bcprov-jdk15on.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/keysync/b8ca463a888a9019db8ef1e7f09bc3b9497cddb4/testing/lib-signed/bcprov-jdk15on.jar -------------------------------------------------------------------------------- /testing/resources/derivation.jceks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/keysync/b8ca463a888a9019db8ef1e7f09bc3b9497cddb4/testing/resources/derivation.jceks -------------------------------------------------------------------------------- /testing/resources/dev_and_test.crl: -------------------------------------------------------------------------------- 1 | -----BEGIN X509 CRL----- 2 | MIIBYjBMAgEBMA0GCSqGSIb3DQEBDQUAMBoxGDAWBgNVBAMTD0tleXdoaXogVGVz 3 | dCBDQRcNMTUwNDIyMDcyODM4WhcNNDUwNDIyMDcyODM4WjANBgkqhkiG9w0BAQ0F 4 | AAOCAQEASSuiDs5T65X7fw/qr55xgoir4U2BD+Si+SuM1V9luQuWzKp5pkeJIIAg 5 | D4hau+p+zL9BiXu5t2LklBmU0k0R45dBqKkVDtDbUR3H3C/blAY4McNerfrxW6Ni 6 | FD+JDK0C0Go2inxGfHFjdVMKV5LQT39yQC/6y1EPvv2AbkQ0MIK+UKYIn26APX2U 7 | Ak/y02a+pdaH71FUnAW6r+uaBEZ4CewGprAs3cbMOyYfg88oJ1hXTz21pcxs8ptN 8 | R7VzgTdo5W6fuGiqNcarZTSUfTtC4FjkcZfEHVoNRuLyBGKD9x+1kpMT0pUkVvB+ 9 | lo0lijfP+rseUtp65FR/6CFbUqb3gg== 10 | -----END X509 CRL----- 11 | -------------------------------------------------------------------------------- /testing/resources/dev_and_test_keystore.p12: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/keysync/b8ca463a888a9019db8ef1e7f09bc3b9497cddb4/testing/resources/dev_and_test_keystore.p12 -------------------------------------------------------------------------------- /testing/resources/dev_and_test_truststore.p12: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/keysync/b8ca463a888a9019db8ef1e7f09bc3b9497cddb4/testing/resources/dev_and_test_truststore.p12 -------------------------------------------------------------------------------- /testing/run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | set -o pipefail 5 | set -x 6 | 7 | # Start keywhiz (server) 8 | java -jar /opt/keysync/testing/keywhiz-server.jar server /opt/keysync/testing/keywhiz-config.yaml & 9 | keywhiz_pid=$! 10 | 11 | # Start keysync (client) 12 | keysync --config /opt/keysync/testing/keysync-config.yaml & 13 | keysync_pid=$! 14 | 15 | # Call the /sync endpoint which blocks until synced 16 | sleep 5 17 | curl --retry 20 -X POST http://localhost:31738/sync 18 | 19 | # Diff content & permissions 20 | function verify { 21 | pushd "$1" 22 | for file in *; do 23 | perms_actual="$(stat -c '%U:%G:%a' "$file")" 24 | perms_expected="$(cat /opt/keysync/testing/expected/ownership/"$file")" 25 | content_actual="$(cat "$file")" 26 | content_expected="$(cat /opt/keysync/testing/expected/content/"$file")" 27 | 28 | if [ "$perms_actual" != "$perms_expected" ]; then 29 | echo "ERROR: Incorrect ownership on file $file (expecting $perms_expected, got $perms_actual)" 30 | exit 1 31 | fi 32 | if [ "$content_actual" != "$content_expected" ]; then 33 | echo "ERROR: Incorrect content in file $file (expecting $content_expected, got $content_actual)" 34 | exit 1 35 | fi 36 | done 37 | echo "Verified $1" 38 | popd 39 | } 40 | 41 | verify /secrets/client1 42 | 43 | # Make a second client 44 | sed 's/client1/client2/g' /opt/keysync/testing/clients/client.yaml > /opt/keysync/testing/clients/client2.yaml 45 | 46 | curl --fail -X POST http://localhost:31738/sync/client2 47 | 48 | verify /secrets/client2 49 | 50 | # Create a backup 51 | curl --fail -X POST http://localhost:31738/backup 52 | 53 | rm /opt/keysync/testing/clients/client2.yaml 54 | 55 | curl --fail -X POST http://localhost:31738/sync/client2 56 | 57 | if [ -d /secrets/client2 ]; then 58 | echo "ERROR: Client 2 was not removed" 59 | exit 1 60 | fi 61 | 62 | # Subsequent try should 404 63 | curl -X POST http://localhost:31738/sync/client2 64 | 65 | # Make sure client 1 still works 66 | curl --fail -X POST http://localhost:31738/sync/client1 67 | verify /secrets/client1 68 | 69 | 70 | # Stop keysync & keywhiz 71 | kill $keywhiz_pid 72 | kill $keysync_pid 73 | 74 | sleep 1 75 | 76 | # Keysync should have written a backup to this location 77 | backup_file="/tmp/keysync-backup.tar.enc" 78 | if [[ ! -f "$backup_file" ]]; then 79 | echo "Backup file $backup_file is missing" 80 | fi 81 | 82 | rm -rf /secrets 83 | 84 | # Unwrap the backup key 85 | cat /tmp/keysync-backup.key.wrapped 86 | keyunwrap unwrap --wrapped /tmp/keysync-backup.key.wrapped --privatekeyfile /opt/keysync/testing/keysync-backup.key > /tmp/restorekey 87 | 88 | # Restore the backup 89 | keyrestore --config /opt/keysync/testing/keysync-config.yaml --keyfile /tmp/restorekey 90 | 91 | # Make sure both clients are present in backup. Client 2 was removed after backup was run. 92 | verify /secrets/client1 93 | verify /secrets/client2 94 | 95 | echo "Keysync test passed" 96 | -------------------------------------------------------------------------------- /util_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package keysync 16 | 17 | import ( 18 | "crypto/tls" 19 | "encoding/json" 20 | "fmt" 21 | "io/ioutil" 22 | "log" 23 | "net/http" 24 | "net/http/httptest" 25 | "net/url" 26 | "strings" 27 | "time" 28 | 29 | "github.com/rcrowley/go-metrics" 30 | "github.com/sirupsen/logrus" 31 | sqmetrics "github.com/square/go-sq-metrics" 32 | ) 33 | 34 | // Create metrics for testing purposes 35 | func metricsForTest() *sqmetrics.SquareMetrics { 36 | return sqmetrics.NewMetrics("", "test", nil, 1*time.Second, metrics.DefaultRegistry, &log.Logger{}) 37 | } 38 | 39 | // Create a new server with two secrets present 40 | // Users should call defer server.close immediately after getting this server. 41 | func createDefaultServer() *httptest.Server { 42 | server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 43 | switch { 44 | case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/secrets"): 45 | fmt.Fprint(w, string(fixture("secretsWithoutContent.json"))) 46 | case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/secret/Nobody_PgPass"): 47 | fmt.Fprint(w, string(fixture("secret_Nobody_PgPass.json"))) 48 | case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/secret/General_Password..0be68f903f8b7d86"): 49 | fmt.Fprint(w, string(fixture("secret_General_Password.json"))) 50 | case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/batchsecret"): 51 | if requestContainsExpectedSecrets(r) { 52 | // one of the secrets is missing, so this returns an error 53 | fmt.Fprint(w, string(fixture("secrets.json"))) 54 | } else { 55 | // The "secrets.json" file is only a valid response if the two secrets in it were requested 56 | w.WriteHeader(400) 57 | } 58 | default: 59 | w.WriteHeader(404) 60 | } 61 | })) 62 | server.TLS = testCerts(testCaFile) 63 | server.StartTLS() 64 | return server 65 | } 66 | 67 | // Create a new server that returns "secret_Nobody_PgPass.json", and "secrets.json" for its endpoints; this represents 68 | // the case where a secret is deleted between listing secrets and retrieving its contents. 69 | // Users should call defer server.close immediately after getting this server. 70 | func createDefaultServerWithDeletionRace() *httptest.Server { 71 | server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 72 | switch { 73 | case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/secrets"): 74 | fmt.Fprint(w, string(fixture("secretsWithoutContent.json"))) 75 | case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/secret/Nobody_PgPass"): 76 | fmt.Fprint(w, string(fixture("secret_Nobody_PgPass.json"))) 77 | case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/batchsecret"): 78 | if requestContainsExpectedSecrets(r) { 79 | // one of the secrets is missing, so this returns an error 80 | w.WriteHeader(404) 81 | } else { 82 | // The "secrets.json" file is only a valid response if the two secrets in it were requested 83 | w.WriteHeader(400) 84 | } 85 | default: 86 | w.WriteHeader(404) 87 | } 88 | })) 89 | server.TLS = testCerts(testCaFile) 90 | server.StartTLS() 91 | return server 92 | } 93 | 94 | func requestContainsExpectedSecrets(r *http.Request) bool { 95 | body, err := ioutil.ReadAll(r.Body) 96 | panicOnError(err) 97 | var req = map[string][]string{} 98 | err = json.Unmarshal(body, &req) 99 | panicOnError(err) 100 | secrets, ok := req["secrets"] 101 | return ok && contains(secrets, "Nobody_PgPass") && contains(secrets, "General_Password..0be68f903f8b7d86") 102 | } 103 | 104 | func contains(slice []string, target string) bool { 105 | for _, item := range slice { 106 | if item == target { 107 | return true 108 | } 109 | } 110 | return false 111 | } 112 | 113 | // Create a new syncer with the given config and server, failing for any 114 | func createNewSyncer(configFile string, server *httptest.Server) (*Syncer, error) { 115 | // Load a config with the server's URL 116 | config, err := LoadConfig(configFile) 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | syncer, err := NewSyncer(config, NewInMemoryOutputCollection(), logrus.NewEntry(logrus.New()), metricsForTest()) 122 | if err != nil { 123 | return nil, err 124 | } 125 | syncer.config.CaFile = "fixtures/CA/localhost.crt" 126 | 127 | return resetSyncerServer(syncer, server), nil 128 | } 129 | 130 | // Reset the given syncer's server URL to point to the given server 131 | func resetSyncerServer(syncer *Syncer, server *httptest.Server) *Syncer { 132 | serverURL, _ := url.Parse(server.URL) 133 | syncer.server = serverURL 134 | return syncer 135 | } 136 | 137 | // fixture fully reads test data from a file in the fixtures/ subdirectory. 138 | func fixture(file string) (content []byte) { 139 | content, err := ioutil.ReadFile("fixtures/" + file) 140 | panicOnError(err) 141 | return 142 | } 143 | 144 | // Load the file with cert & private key into a tls.Config 145 | func testCerts(file string) (config *tls.Config) { 146 | config = new(tls.Config) 147 | cert, err := tls.LoadX509KeyPair(file, file) 148 | panicOnError(err) 149 | 150 | config.Certificates = []tls.Certificate{cert} 151 | 152 | return config 153 | } 154 | 155 | // Helper function to panic on error 156 | func panicOnError(err error) { 157 | if err != nil { 158 | panic(err) 159 | } 160 | } 161 | 162 | // Pass this to syncer to get an "in memory output", which records how secrets are written, making this useful 163 | // for testing behaviour without ever writing secrets to disk anywhere. 164 | type InMemoryOutputCollection struct { 165 | Outputs map[string]*InMemoryOutput 166 | } 167 | 168 | var _ OutputCollection = InMemoryOutputCollection{} 169 | 170 | func NewInMemoryOutputCollection() InMemoryOutputCollection { 171 | return InMemoryOutputCollection{Outputs: map[string]*InMemoryOutput{}} 172 | } 173 | 174 | func (c InMemoryOutputCollection) NewOutput(clientConfig ClientConfig, logger *logrus.Entry) (Output, error) { 175 | name := clientConfig.DirName 176 | if previous, present := c.Outputs[name]; present { 177 | return previous, nil 178 | } 179 | output := &InMemoryOutput{Secrets: map[string]Secret{}, logger: logger} 180 | 181 | logger.Warn("Making new client for ", name) 182 | c.Outputs[name] = output 183 | logger.Warnf("clients: %v", c.Outputs) 184 | return output, nil 185 | } 186 | 187 | func (c InMemoryOutputCollection) Cleanup(_ map[string]struct{}, _ *logrus.Entry) (uint, []error) { 188 | return 0, nil 189 | } 190 | 191 | type InMemoryOutput struct { 192 | logger *logrus.Entry 193 | Secrets map[string]Secret 194 | writesCounter int 195 | deletesCounter int 196 | } 197 | 198 | func (out *InMemoryOutput) Validate(secret *Secret, state secretState) bool { 199 | _, present := out.Secrets[secret.Name] 200 | // If it's in the map, it's valid - delete from the map to test on-disk invalidation behavior. 201 | return present 202 | } 203 | 204 | func (out *InMemoryOutput) Write(secret *Secret) (*secretState, error) { 205 | out.Secrets[secret.Name] = *secret 206 | out.writesCounter++ 207 | out.logger.WithField("muhname", secret.Name).Warn("writing secret") 208 | return &secretState{}, nil 209 | } 210 | 211 | func (out *InMemoryOutput) Remove(name string) error { 212 | delete(out.Secrets, name) 213 | out.deletesCounter++ 214 | out.logger.WithField("mahnuum", name).Warn("deleting secret") 215 | return nil 216 | } 217 | 218 | func (out *InMemoryOutput) RemoveAll() (uint, error) { 219 | deleted := uint(len(out.Secrets)) 220 | out.deletesCounter += len(out.Secrets) 221 | out.Secrets = map[string]Secret{} 222 | return deleted, nil 223 | } 224 | 225 | func (out *InMemoryOutput) Cleanup(_ map[string]Secret) (uint, error) { 226 | return 0, nil 227 | } 228 | 229 | func (out *InMemoryOutput) Logger() *logrus.Entry { 230 | return nil 231 | } 232 | 233 | func (out *InMemoryOutput) NumWrites() int { 234 | return out.writesCounter 235 | } 236 | 237 | func (out *InMemoryOutput) NumDeletes() int { 238 | return out.deletesCounter 239 | } 240 | -------------------------------------------------------------------------------- /write.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 Square Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package keysync 16 | 17 | import ( 18 | "bytes" 19 | "crypto/sha256" 20 | "fmt" 21 | "io/ioutil" 22 | "os" 23 | "path/filepath" 24 | 25 | "github.com/square/keysync/output" 26 | "github.com/square/keysync/ownership" 27 | 28 | "github.com/sirupsen/logrus" 29 | ) 30 | 31 | // OutputCollection handles a collection of outputs. 32 | type OutputCollection interface { 33 | NewOutput(clientConfig ClientConfig, logger *logrus.Entry) (Output, error) 34 | // Cleanup unknown clients (eg, ones deleted while keysync was not running) 35 | // Returns a count of deleted clients 36 | Cleanup(map[string]struct{}, *logrus.Entry) (uint, []error) 37 | } 38 | 39 | // Output is an interface that encapsulates what it means to store secrets 40 | type Output interface { 41 | // Validate returns true if the secret is persisted already 42 | Validate(secret *Secret, state secretState) bool 43 | // Write a secret 44 | Write(secret *Secret) (*secretState, error) 45 | // Remove a secret 46 | Remove(name string) error 47 | // Remove all secrets and the containing directory (eg, when the client config is removed) 48 | // Returns a count of deleted files 49 | RemoveAll() (uint, error) 50 | // Cleanup unknown files (eg, ones deleted in Keywhiz while keysync was not running) 51 | // Returns a count of deleted files 52 | Cleanup(map[string]Secret) (uint, error) 53 | } 54 | 55 | type OutputDirCollection struct { 56 | Config *Config 57 | } 58 | 59 | func (c OutputDirCollection) NewOutput(clientConfig ClientConfig, logger *logrus.Entry) (Output, error) { 60 | defaultOwnership := ownership.NewOwnership( 61 | clientConfig.User, 62 | clientConfig.Group, 63 | c.Config.DefaultUser, 64 | c.Config.DefaultGroup, 65 | ownership.Os{}, 66 | logger, 67 | ) 68 | 69 | writeDirectory := filepath.Join(c.Config.SecretsDir, clientConfig.DirName) 70 | if err := os.MkdirAll(writeDirectory, 0775); err != nil { 71 | return nil, fmt.Errorf("failed to mkdir client directory '%s': %v", writeDirectory, err) 72 | } 73 | 74 | return &OutputDir{ 75 | WriteDirectory: writeDirectory, 76 | EnforceFilesystem: c.Config.FsType, 77 | ChownFiles: c.Config.ChownFiles, 78 | DefaultOwnership: defaultOwnership, 79 | Logger: logger, 80 | }, nil 81 | } 82 | 83 | func (c OutputDirCollection) Cleanup(known map[string]struct{}, logger *logrus.Entry) (uint, []error) { 84 | var errors []error 85 | var deleted uint = 0 86 | 87 | fileInfos, err := ioutil.ReadDir(c.Config.SecretsDir) 88 | if err != nil { 89 | errors = append(errors, err) 90 | logger.WithError(err).WithField("SecretsDir", c.Config.SecretsDir).Warn("Couldn't read secrets dir") 91 | } 92 | for _, fileInfo := range fileInfos { 93 | logger := logger.WithField("name", fileInfo.Name()) 94 | if !fileInfo.IsDir() { 95 | // Keysync won't have written a file here, so safest to not touch it 96 | logger.Warn("Found unknown file, ignoring") 97 | continue 98 | } 99 | if _, present := known[fileInfo.Name()]; !present { 100 | logger.Info("Deleting unknown directory") 101 | if err := os.RemoveAll(filepath.Join(c.Config.SecretsDir, fileInfo.Name())); err != nil { 102 | logger.WithError(err).Warn("Error removing unknown directory") 103 | errors = append(errors, err) 104 | } 105 | // os.RemoveAll may have returned an error but partially removed files, so increment 106 | // deleted despite the error, so we know changes may have been made. 107 | deleted++ 108 | } 109 | } 110 | 111 | return deleted, errors 112 | } 113 | 114 | // OutputDir implements Output to files, which is the typical keysync usage to a tmpfs. 115 | type OutputDir struct { 116 | WriteDirectory string 117 | DefaultOwnership ownership.Ownership 118 | EnforceFilesystem output.Filesystem // What filesystem type do we expect to write to? 119 | ChownFiles bool // Do we chown the file? (Needs root or CAP_CHOWN). 120 | Logger *logrus.Entry 121 | } 122 | 123 | // Validate verifies the secret is written to disk with the correct content, permissions, and ownership 124 | func (out *OutputDir) Validate(secret *Secret, state secretState) bool { 125 | if state.Checksum != secret.Checksum { 126 | return false 127 | } 128 | 129 | filename, err := secret.Filename() 130 | if err != nil { 131 | return false 132 | } 133 | path := filepath.Join(out.WriteDirectory, filename) 134 | 135 | // Check if new permissions match state 136 | if state.Owner != secret.Owner || state.Group != secret.Group || state.Mode != secret.Mode { 137 | return false 138 | } 139 | 140 | // Check on-disk permissions, and ownership against what's configured. 141 | f, err := os.Open(path) 142 | if err != nil { 143 | return false 144 | } 145 | fileinfo, err := output.GetFileInfo(f) 146 | if err != nil { 147 | return false 148 | } 149 | if state.FileInfo != *fileinfo { 150 | out.Logger.WithFields(logrus.Fields{ 151 | "secret": filename, 152 | "expected": state.FileInfo, 153 | "seen": *fileinfo, 154 | }).Warn("Secret permissions changed unexpectedly") 155 | return false 156 | } 157 | 158 | // Check the content of what's on disk 159 | var b bytes.Buffer 160 | _, err = b.ReadFrom(f) 161 | if err != nil { 162 | return false 163 | } 164 | hash := sha256.Sum256(b.Bytes()) 165 | 166 | if state.ContentHash != hash { 167 | // As tempting as it is, we shouldn't log hashes as they'd leak information about the secret. 168 | out.Logger.WithField("secret", filename).Warn("Secret modified on disk") 169 | return false 170 | } 171 | 172 | // OK, the file is unchanged 173 | return true 174 | 175 | } 176 | 177 | func (out *OutputDir) Remove(name string) error { 178 | return os.Remove(filepath.Join(out.WriteDirectory, name)) 179 | } 180 | 181 | func (out *OutputDir) RemoveAll() (uint, error) { 182 | // TODO: This count isn't accurate, but it also isn't worth reimplementing os.RemoveAll to count 183 | return 1, os.RemoveAll(out.WriteDirectory) 184 | } 185 | 186 | func (out *OutputDir) Cleanup(secrets map[string]Secret) (uint, error) { 187 | var deleted uint 188 | 189 | fileInfos, err := ioutil.ReadDir(out.WriteDirectory) 190 | if err != nil { 191 | return deleted, fmt.Errorf("couldn't read directory: %s", out.WriteDirectory) 192 | } 193 | for _, fileInfo := range fileInfos { 194 | existingFile := fileInfo.Name() 195 | if _, present := secrets[existingFile]; !present { 196 | // This file wasn't written in the loop above, so we remove it. 197 | out.Logger.WithField("file", existingFile).Info("Removing unknown file") 198 | err := os.Remove(filepath.Join(out.WriteDirectory, existingFile)) 199 | if err != nil { 200 | // Not fatal, so log and continue. 201 | out.Logger.WithError(err).Warnf("Unable to delete file") 202 | } else { 203 | deleted++ 204 | } 205 | } 206 | } 207 | return deleted, nil 208 | } 209 | 210 | // Write puts a Secret into OutputDir 211 | func (out *OutputDir) Write(secret *Secret) (*secretState, error) { 212 | 213 | filename, err := secret.Filename() 214 | if err != nil { 215 | return nil, err 216 | } 217 | 218 | mode, err := secret.ModeValue() 219 | if err != nil { 220 | return nil, err 221 | } 222 | fileInfo := output.FileInfo{Mode: mode} 223 | if out.ChownFiles { 224 | owner := secret.OwnershipValue(out.DefaultOwnership) 225 | fileInfo.UID = owner.UID 226 | fileInfo.GID = owner.GID 227 | } 228 | path := filepath.Join(out.WriteDirectory, filename) 229 | fileinfo, err := output.WriteFileAtomically(path, out.ChownFiles, fileInfo, out.EnforceFilesystem, secret.Content) 230 | if err != nil { 231 | return nil, err 232 | } 233 | 234 | state := secretState{ 235 | ContentHash: sha256.Sum256(secret.Content), 236 | Checksum: secret.Checksum, 237 | FileInfo: *fileinfo, 238 | Owner: secret.Owner, 239 | Group: secret.Group, 240 | Mode: secret.Mode, 241 | } 242 | return &state, err 243 | } 244 | -------------------------------------------------------------------------------- /write_test.go: -------------------------------------------------------------------------------- 1 | package keysync 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/sirupsen/logrus" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func testConfig(t *testing.T) Config { 15 | dir, err := ioutil.TempDir("", "keysyncWriteTest") 16 | assert.NoError(t, err, "Error making tempdir") 17 | 18 | return Config{ 19 | SecretsDir: dir, 20 | ChownFiles: false, 21 | } 22 | } 23 | 24 | func testClientConfig(name string) ClientConfig { 25 | return ClientConfig{ 26 | Key: fmt.Sprintf("fixtures/clients/%s.key", name), 27 | Cert: fmt.Sprintf("fixtures/clients/%s.crt", name), 28 | DirName: name, 29 | } 30 | } 31 | 32 | func testSecret(name string) Secret { 33 | return Secret{ 34 | Name: name, 35 | Content: []byte("my secret content"), 36 | Checksum: "0ABC", 37 | } 38 | } 39 | 40 | func testFixture(t *testing.T) (Config, ClientConfig, OutputDirCollection, Output) { 41 | c := testConfig(t) 42 | 43 | testlogger := testLogger() 44 | 45 | odc := OutputDirCollection{Config: &c} 46 | cc := testClientConfig("client 1") 47 | out, err := odc.NewOutput(cc, testlogger) 48 | assert.NoError(t, err) 49 | 50 | return c, cc, odc, out 51 | } 52 | 53 | func testLogger() *logrus.Entry { 54 | return logrus.NewEntry(logrus.New()) 55 | } 56 | 57 | // Test the basic secret lifecycle: 58 | // Calls all methods in the OutputCollection and Output interface 59 | func TestBasicLifecycle(t *testing.T) { 60 | c, _, odc, out := testFixture(t) 61 | defer os.RemoveAll(c.SecretsDir) 62 | 63 | odc.Cleanup(map[string]struct{}{"client 1": {}}, testLogger()) 64 | 65 | name := "secret 1" 66 | s := testSecret(name) 67 | 68 | state, err := out.Write(&s) 69 | assert.NoError(t, err) 70 | 71 | deleted, err := out.Cleanup(map[string]Secret{name: {}}) 72 | assert.NoError(t, err) 73 | assert.Zero(t, deleted) 74 | 75 | assert.True(t, out.Validate(&s, *state), "Expected just-written secret to be valid") 76 | 77 | filecontents, err := ioutil.ReadFile(filepath.Join(c.SecretsDir, "client 1", name)) 78 | assert.NoError(t, err) 79 | 80 | assert.Equal(t, s.Content, content(filecontents)) 81 | 82 | assert.NoError(t, out.Remove(name)) 83 | 84 | assert.False(t, out.Validate(&s, *state), "Expected secret invalid after deletion") 85 | 86 | deleted, err = out.RemoveAll() 87 | assert.NoError(t, err) 88 | assert.EqualValues(t, 1, deleted) 89 | } 90 | 91 | // Test that if ChownFiles is set, we fail to write out files (since we're not root) 92 | // While this isn't a super-great test, it makes sure ChownFiles = true does something. 93 | func TestChownFiles(t *testing.T) { 94 | c, _, _, out := testFixture(t) 95 | defer os.RemoveAll(c.SecretsDir) 96 | 97 | // Easier to modify this after-the-fact so we can share test fixture setup 98 | out.(*OutputDir).ChownFiles = true 99 | 100 | secret := testSecret("secret") 101 | _, err := out.Write(&secret) 102 | assert.Error(t, err, "Expected error writing file. Maybe you're testing as root?") 103 | } 104 | 105 | // This tests enforcing filesystems. We set an EnforceFilesystem value that won't correspond with any filesystem 106 | // we might run tests on, and thus we should never succeed in writing files. 107 | func TestEnforceFS(t *testing.T) { 108 | c, _, _, out := testFixture(t) 109 | defer os.RemoveAll(c.SecretsDir) 110 | 111 | // Easier to modify this after-the-fact so we can share test fixture setup 112 | // This value is Linux's /proc filesystem, arbitrarily chosen as a filesystem we're not going to be writing to, 113 | // so that out.Write is guaranteed to fail. 114 | out.(*OutputDir).EnforceFilesystem = 0x9fa0 115 | 116 | secret := testSecret("secret") 117 | _, err := out.Write(&secret) 118 | assert.Contains(t, err.Error(), "unexpected filesystem") 119 | } 120 | 121 | // Make sure any stray files and directories are cleaned up by Keysync. 122 | func TestCleanup(t *testing.T) { 123 | c, cc, odc, out := testFixture(t) 124 | defer os.RemoveAll(c.SecretsDir) 125 | 126 | junkdir := filepath.Join(c.SecretsDir, "junk client") 127 | assert.NoError(t, os.MkdirAll(junkdir, 0400)) 128 | 129 | _, err := os.Stat(junkdir) 130 | assert.NoError(t, err, "Expected junkdir to exist before cleanup") 131 | 132 | _, errs := odc.Cleanup(map[string]struct{}{cc.DirName: {}}, testLogger()) 133 | assert.Equal(t, 0, len(errs), "Expected no errors cleaning up") 134 | 135 | _, err = os.Stat(junkdir) 136 | assert.Error(t, err, "Expected junkdir to be gone after cleanup") 137 | 138 | junkfile := filepath.Join(c.SecretsDir, cc.DirName, "junk file") 139 | assert.NoError(t, ioutil.WriteFile(junkfile, []byte("my data"), 0400)) 140 | 141 | deleted, err := out.Cleanup(map[string]Secret{"secret 1": {}}) 142 | assert.NoError(t, err) 143 | assert.EqualValues(t, 1, deleted) 144 | 145 | _, err = os.Stat(junkfile) 146 | assert.Error(t, err, "Expected file to be gone after cleanup") 147 | } 148 | 149 | // TestCustomFilename makes sure we honor the "filename" attribute when writing out files. 150 | func TestCustomFilename(t *testing.T) { 151 | c, _, _, out := testFixture(t) 152 | defer os.RemoveAll(c.SecretsDir) 153 | 154 | secret := testSecret("secret_name") 155 | filename := "override_filename" 156 | secret.FilenameOverride = &filename 157 | 158 | state, err := out.Write(&secret) 159 | assert.NoError(t, err) 160 | 161 | deleted, err := out.Cleanup(map[string]Secret{filename: {}}) 162 | assert.NoError(t, err) 163 | assert.Zero(t, deleted) 164 | 165 | assert.True(t, out.Validate(&secret, *state), "Expected override_filename secret to be valid after cleanup") 166 | 167 | assert.NoError(t, out.Remove(filename)) 168 | 169 | assert.False(t, out.Validate(&secret, *state), "Expected secret to be removed") 170 | } 171 | 172 | // TestCustomFilenameAsFilepath makes sure we fail to write a file if the "filename" is actually a filepath 173 | func TestCustomFilenameAsFilepath(t *testing.T) { 174 | c, _, _, out := testFixture(t) 175 | defer os.RemoveAll(c.SecretsDir) 176 | 177 | secret := testSecret("secret_name") 178 | filename := "../override_filename" 179 | secret.FilenameOverride = &filename 180 | 181 | state, err := out.Write(&secret) 182 | assert.Error(t, err) 183 | assert.Nil(t, state) 184 | } 185 | --------------------------------------------------------------------------------