├── .git-blame-ignore-revs ├── .github ├── dependabot.yaml └── workflows │ ├── cli.yml │ ├── codeql.yml │ ├── docs.yml │ ├── linters.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── .release ├── Dockerfile └── alpine.Dockerfile ├── .sops.yaml ├── CHANGELOG.md ├── CHANGELOG.rst ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DCO ├── LICENSE ├── Makefile ├── README.rst ├── aes ├── cipher.go └── cipher_test.go ├── age ├── encrypted_keys.go ├── keysource.go ├── keysource_test.go ├── ssh_parse.go └── tui.go ├── audit ├── audit.go └── schema.sql ├── azkv ├── keysource.go ├── keysource_integration_test.go └── keysource_test.go ├── cmd └── sops │ ├── codes │ └── codes.go │ ├── common │ └── common.go │ ├── decrypt.go │ ├── edit.go │ ├── encrypt.go │ ├── formats │ ├── formats.go │ └── formats_test.go │ ├── main.go │ ├── rotate.go │ ├── set.go │ ├── subcommand │ ├── exec │ │ ├── exec.go │ │ ├── exec_unix.go │ │ └── exec_windows.go │ ├── filestatus │ │ ├── filestatus.go │ │ └── filestatus_internal_test.go │ ├── groups │ │ ├── add.go │ │ └── delete.go │ ├── keyservice │ │ └── keyservice.go │ ├── publish │ │ └── publish.go │ └── updatekeys │ │ └── updatekeys.go │ └── unset.go ├── config ├── config.go ├── config_test.go └── test_resources │ └── example.yaml ├── decrypt ├── decrypt.go └── example_test.go ├── docs ├── images │ └── cncf-color-bg.svg └── release.md ├── example.ini ├── example.json ├── example.txt ├── example.yaml ├── examples ├── all_in_one │ ├── .gitignore │ ├── README.rst │ ├── bin │ │ ├── decrypt-config.sh │ │ └── edit-config-file.sh │ ├── config │ │ ├── __init__.py │ │ ├── secret.enc.json │ │ └── static.py │ └── main.py └── per_file │ ├── .gitignore │ ├── README.rst │ ├── bin │ ├── decrypt-config.sh │ └── edit-config-file.sh │ ├── config.enc │ ├── __init__.py │ ├── static.py │ └── static_github.json │ └── main.py ├── functional-tests ├── .sops.yaml ├── Cargo.lock ├── Cargo.toml ├── bin │ └── editor.rs ├── res │ ├── comments.enc.yaml │ ├── comments.yaml │ ├── comments_list.yaml │ ├── comments_unencrypted_comments.yaml │ ├── multiple_keys.yaml │ ├── no_mac.yaml │ └── plainfile.yaml └── src │ └── lib.rs ├── gcpkms ├── keysource.go ├── keysource_test.go └── mock_kms_server_test.go ├── go.mod ├── go.sum ├── hcvault ├── keysource.go └── keysource_test.go ├── keys └── keys.go ├── keyservice ├── client.go ├── keyservice.go ├── keyservice.pb.go ├── keyservice.proto ├── keyservice_grpc.pb.go ├── server.go └── server_test.go ├── kms ├── keysource.go └── keysource_test.go ├── logging └── logging.go ├── pgp ├── keysource.go ├── keysource_test.go ├── sops_functional_tests_key.asc └── testdata │ ├── private.gpg │ ├── public.gpg │ └── ring │ ├── pubring.gpg │ └── secring.gpg ├── publish ├── gcs.go ├── publish.go ├── s3.go └── vault.go ├── rust-toolchain.toml ├── shamir ├── LICENSE ├── README.md ├── shamir.go └── shamir_test.go ├── sops.go ├── sops_test.go ├── stores ├── dotenv │ ├── store.go │ └── store_test.go ├── flatten.go ├── flatten_test.go ├── ini │ ├── store.go │ ├── store_test.go │ └── test_resources │ │ └── example.json ├── json │ ├── store.go │ └── store_test.go ├── stores.go └── yaml │ ├── store.go │ └── store_test.go ├── usererrors.go └── version ├── version.go └── version_test.go /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Update formatting. 2 | 72cebfd8a13ff59dd72712b711f08787c9cc6b0a 3 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "docker" 5 | directory: "/.release" 6 | labels: ["dependencies"] 7 | schedule: 8 | # By default, this will be on a Monday. 9 | interval: "weekly" 10 | groups: 11 | # Group all updates together, so that they are all applied in a single PR. 12 | # xref: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#groups 13 | docker: 14 | patterns: 15 | - "*" 16 | 17 | - package-ecosystem: "github-actions" 18 | directory: "/" 19 | labels: ["area/CI", "dependencies"] 20 | schedule: 21 | # By default, this will be on a Monday. 22 | interval: "weekly" 23 | groups: 24 | # Group all updates together, so that they are all applied in a single PR. 25 | # xref: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#groups 26 | ci: 27 | patterns: 28 | - "*" 29 | 30 | - package-ecosystem: "gomod" 31 | directory: "/" 32 | labels: ["dependencies"] 33 | schedule: 34 | # By default, this will be on a Monday. 35 | interval: "weekly" 36 | groups: 37 | # Group all updates together, so that they are all applied in a single PR. 38 | # xref: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#groups 39 | go: 40 | patterns: 41 | - "*" 42 | 43 | - package-ecosystem: "cargo" 44 | directory: "/functional-tests" 45 | labels: ["area/CI", "dependencies"] 46 | schedule: 47 | # By default, this will be on a Monday. 48 | interval: "weekly" 49 | groups: 50 | # Group all updates together, so that they are all applied in a single PR. 51 | # xref: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#groups 52 | rust: 53 | patterns: 54 | - "*" 55 | -------------------------------------------------------------------------------- /.github/workflows/cli.yml: -------------------------------------------------------------------------------- 1 | name: CLI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | build: 16 | name: Build and test ${{ matrix.os }} ${{ matrix.arch }} ${{ matrix.go-version }} 17 | runs-on: ubuntu-latest 18 | strategy: 19 | matrix: 20 | os: [linux, darwin, windows] 21 | arch: [amd64, arm64] 22 | go-version: ['1.23', '1.24'] 23 | exclude: 24 | - os: windows 25 | arch: arm64 26 | env: 27 | VAULT_VERSION: "1.14.0" 28 | VAULT_TOKEN: "root" 29 | VAULT_ADDR: "http://127.0.0.1:8200" 30 | steps: 31 | - name: Set up Go ${{ matrix.go-version }} 32 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 33 | with: 34 | go-version: ${{ matrix.go-version }} 35 | id: go 36 | 37 | - name: Check out code into the Go module directory 38 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 39 | with: 40 | persist-credentials: false 41 | 42 | - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 43 | with: 44 | path: ~/go/pkg/mod 45 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 46 | restore-keys: | 47 | ${{ runner.os }}-go- 48 | 49 | - name: Vendor Go Modules 50 | run: make vendor 51 | 52 | - name: Ensure clean working tree 53 | run: git diff --exit-code 54 | 55 | - name: Build ${{ matrix.os }} 56 | if: matrix.os != 'windows' 57 | run: GOOS=${{ matrix.os }} GOARCH=${{ matrix.arch }} go build -o sops-${{ matrix.go-version }}-${{ matrix.os }}-${{ matrix.arch }}-${{ github.sha }} -v ./cmd/sops 58 | 59 | - name: Build ${{ matrix.os }} 60 | if: matrix.os == 'windows' 61 | run: GOOS=${{ matrix.os }} go build -o sops-${{ matrix.go-version }}-${{ matrix.os }}-${{ github.sha }} -v ./cmd/sops 62 | 63 | - name: Import test GPG keys 64 | run: for i in 1 2 3 4 5; do gpg --import pgp/sops_functional_tests_key.asc && break || sleep 15; done 65 | 66 | - name: Test 67 | run: make test 68 | 69 | - name: Upload artifact for ${{ matrix.os }} 70 | if: matrix.os != 'windows' 71 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 72 | with: 73 | name: sops-${{ matrix.go-version }}-${{ matrix.os }}-${{ matrix.arch }}-${{ github.sha }} 74 | path: sops-${{ matrix.go-version }}-${{ matrix.os }}-${{ matrix.arch }}-${{ github.sha }} 75 | 76 | - name: Upload artifact for ${{ matrix.os }} 77 | if: matrix.os == 'windows' 78 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 79 | with: 80 | name: sops-${{ matrix.go-version }}-${{ matrix.os }}-${{ github.sha }} 81 | path: sops-${{ matrix.go-version }}-${{ matrix.os }}-${{ github.sha }} 82 | test: 83 | name: Functional tests 84 | runs-on: ubuntu-latest 85 | needs: [build] 86 | strategy: 87 | matrix: 88 | go-version: ['1.24'] 89 | env: 90 | VAULT_VERSION: "1.14.0" 91 | VAULT_TOKEN: "root" 92 | VAULT_ADDR: "http://127.0.0.1:8200" 93 | steps: 94 | - name: Check out code 95 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 96 | with: 97 | persist-credentials: false 98 | 99 | # Rustup will detect toolchain version and profile from rust-toolchain.toml 100 | # It will download and install the toolchain and components automatically 101 | # and make them available for subsequent commands 102 | - name: Install Rust toolchain 103 | run: rustup show 104 | 105 | - name: Show Rust version 106 | run: cargo --version 107 | 108 | - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 109 | with: 110 | name: sops-${{ matrix.go-version }}-linux-amd64-${{ github.sha }} 111 | 112 | - name: Move SOPS binary 113 | run: mv sops-${{ matrix.go-version }}-linux-amd64-${{ github.sha }} ./functional-tests/sops 114 | 115 | - name: Make SOPS binary executable 116 | run: chmod +x ./functional-tests/sops 117 | 118 | - name: Download Vault 119 | run: curl -O "https://releases.hashicorp.com/vault/${VAULT_VERSION}/vault_${VAULT_VERSION}_linux_amd64.zip" && sudo unzip vault_${VAULT_VERSION}_linux_amd64.zip -d /usr/local/bin/ 120 | 121 | - name: Start Vault server 122 | run: vault server -dev -dev-root-token-id="$VAULT_TOKEN" & 123 | 124 | - name: Enable Vault KV 125 | run: vault secrets enable -version=1 kv 126 | 127 | - name: Import test GPG keys 128 | run: for i in 1 2 3 4 5; do gpg --import pgp/sops_functional_tests_key.asc && break || sleep 15; done 129 | 130 | - name: Run tests 131 | run: cargo test 132 | working-directory: ./functional-tests 133 | 134 | # The 'check' job should depend on all other jobs so it's possible to configure branch protection only for 'check' 135 | # instead of having to explicitly list all individual jobs that need to pass. 136 | check: 137 | if: always() 138 | 139 | needs: 140 | - build 141 | - test 142 | 143 | runs-on: ubuntu-latest 144 | 145 | steps: 146 | - name: Decide whether the needed jobs succeeded or failed 147 | uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2 148 | with: 149 | allowed-failures: docs, linters 150 | allowed-skips: non-voting-flaky-job 151 | jobs: ${{ toJSON(needs) }} 152 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | # Ignore changes to common non-code files. 9 | paths-ignore: 10 | - '**/*.md' 11 | - '**/*.rst' 12 | - '**/*.txt' 13 | - '**/*.yml' 14 | - '**/*.yaml' 15 | - '**/*.json' 16 | - '**/*.ini' 17 | - '**/*.env' 18 | schedule: 19 | - cron: '25 6 * * 3' 20 | 21 | jobs: 22 | analyze: 23 | name: Analyze 24 | runs-on: ubuntu-latest 25 | permissions: 26 | actions: read 27 | contents: read 28 | security-events: write 29 | 30 | steps: 31 | - name: Checkout code 32 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 33 | with: 34 | persist-credentials: false 35 | 36 | # Initializes the CodeQL tools for scanning. 37 | - name: Initialize CodeQL 38 | uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 39 | with: 40 | languages: go 41 | # xref: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 42 | # xref: https://codeql.github.com/codeql-query-help/go/ 43 | queries: security-and-quality 44 | 45 | # Build the project, and run CodeQL analysis. 46 | # We do not make use of autobuild as this would run the first Make 47 | # target, which includes a lot more than just the Go files we want to 48 | # scan. 49 | - name: Build 50 | run: | 51 | make vendor 52 | make install 53 | 54 | - name: Perform CodeQL Analysis 55 | uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 56 | with: 57 | category: "/language:go" 58 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: "Docs" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | # Only consider changes to documentation 9 | paths: 10 | - '**/*.md' 11 | - '**/*.rst' 12 | - '**/*.txt' 13 | schedule: 14 | - cron: '25 6 * * 3' 15 | 16 | permissions: 17 | contents: read 18 | 19 | jobs: 20 | documentation: 21 | name: Lint RST and MD files 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 27 | with: 28 | persist-credentials: false 29 | 30 | - name: Install rstcheck and markdownlint 31 | run: | 32 | pip install rstcheck 33 | sudo gem install mdl 34 | 35 | - name: Run rstcheck on all RST files 36 | run: make checkrst 37 | 38 | - name: Run mdl on all MD files 39 | run: make checkmd 40 | -------------------------------------------------------------------------------- /.github/workflows/linters.yml: -------------------------------------------------------------------------------- 1 | name: Linters 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | # Only run when Rust version or linted files change 11 | paths: 12 | - 'rust-toolchain.toml' 13 | - 'functional-tests/**/*.rs' 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | lint: 20 | name: Lint Rust source files 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Check out code 24 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 25 | with: 26 | persist-credentials: false 27 | 28 | # Rustup will detect toolchain version and profile from rust-toolchain.toml 29 | # It will download and install the toolchain and components automatically 30 | # and make them available for subsequent commands 31 | - name: Install Rust toolchain and additional components 32 | run: rustup component add rustfmt 33 | 34 | - name: Show Rust version 35 | run: cargo --version 36 | 37 | - name: Run Formatting Check 38 | run: cargo fmt --check 39 | working-directory: ./functional-tests 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | dist/ 3 | functional-tests/sops 4 | vendor/ 5 | profile.out 6 | -------------------------------------------------------------------------------- /.release/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bookworm-slim 2 | 3 | RUN apt-get update && apt-get install --no-install-recommends -y \ 4 | awscli \ 5 | azure-cli \ 6 | curl \ 7 | gnupg \ 8 | vim \ 9 | && rm -rf /var/lib/apt/lists/* 10 | 11 | ENV EDITOR vim 12 | 13 | # Glob pattern to match the binary for the current architecture 14 | COPY sops* /usr/local/bin/sops 15 | 16 | ENTRYPOINT ["sops"] 17 | -------------------------------------------------------------------------------- /.release/alpine.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.21 2 | 3 | RUN apk --no-cache add \ 4 | ca-certificates \ 5 | vim \ 6 | && update-ca-certificates 7 | 8 | ENV EDITOR vim 9 | 10 | # Glob pattern to match the binary for the current architecture 11 | COPY sops* /usr/local/bin/sops 12 | 13 | ENTRYPOINT ["sops"] 14 | -------------------------------------------------------------------------------- /.sops.yaml: -------------------------------------------------------------------------------- 1 | creation_rules: 2 | - pgp: >- 3 | FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4, 4 | D7229043384BCC60326C6FB9D8720D957C3D3074 5 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | The changelog can be found in `CHANGELOG.md `_. 5 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | This project adheres to the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/main/code-of-conduct.md). 4 | 5 | By participating, you are expected to honor this code. 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to SOPS 2 | 3 | The SOPS project welcomes contributions from everyone. Here are a few guidelines 4 | and instructions if you are thinking of helping with the development of SOPS. 5 | 6 | ## Getting started 7 | 8 | - Make sure you have Go 1.19 or greater installed. You can find information on 9 | how to install Go [here](https://go.dev/doc/install) 10 | - Clone the Git repository and switch into SOPS's directory. 11 | - Run the tests with `make test`. They should all pass. 12 | - If you modify documentation (RST or MD files), run `make checkdocs` to run 13 | [rstcheck](https://pypi.org/project/rstcheck/) and 14 | [markdownlint](https://github.com/markdownlint/markdownlint). These should also 15 | pass. If you need help in fixing issues, create a pull request (see below) and 16 | ask for help. 17 | - Fork the project on GitHub. 18 | - Add your fork to Git's remotes: 19 | - If you use SSH authentication: 20 | `git remote add git@github.com:/sops.git`. 21 | - Otherwise: `git remote add https://github.com//sops.git`. 22 | - Make any changes you want to SOPS, commit them, and push them to your fork. 23 | - **Create a pull request against `main`**, and a maintainer will come by and 24 | review your code. They may ask for some changes, and hopefully your 25 | contribution will be merged! 26 | 27 | ## Guidelines 28 | 29 | - Unless it's particularly hard, changes that fix a bug should have a regression 30 | test to make sure that the bug is not introduced again. 31 | - New features and changes to existing features should be documented, and, if 32 | possible, tested. 33 | 34 | ## Communication 35 | 36 | If you need any help contributing to SOPS, several maintainers are on the 37 | [`#sops-dev` channel](https://cloud-native.slack.com/archives/C059800AJBT) on 38 | the [CNCF Slack](https://slack.cncf.io). 39 | -------------------------------------------------------------------------------- /DCO: -------------------------------------------------------------------------------- 1 | Developer Certificate of Origin 2 | Version 1.1 3 | 4 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 5 | 660 York Street, Suite 102, 6 | San Francisco, CA 94110 USA 7 | 8 | Everyone is permitted to copy and distribute verbatim copies of this 9 | license document, but changing it is not allowed. 10 | 11 | 12 | Developer's Certificate of Origin 1.1 13 | 14 | By making a contribution to this project, I certify that: 15 | 16 | (a) The contribution was created in whole or in part by me and I 17 | have the right to submit it under the open source license 18 | indicated in the file; or 19 | 20 | (b) The contribution is based upon previous work that, to the best 21 | of my knowledge, is covered under an appropriate open source 22 | license and I have the right under that license to submit that 23 | work with modifications, whether created in whole or in part 24 | by me, under the same open source license (unless I am 25 | permitted to submit under a different license), as indicated 26 | in the file; or 27 | 28 | (c) The contribution was provided directly to me by some other 29 | person who certified (a), (b) or (c) and I have not modified 30 | it. 31 | 32 | (d) I understand and agree that this project and the contribution 33 | are public and that a record of the contribution (including all 34 | personal information I submit with it, including my sign-off) is 35 | maintained indefinitely and may be redistributed consistent with 36 | this project or the open source license(s) involved. 37 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | PROJECT := github.com/getsops/sops/v3 6 | PROJECT_DIR := $(shell dirname $(abspath $(lastword $(MAKEFILE_LIST)))) 7 | BIN_DIR := $(PROJECT_DIR)/bin 8 | 9 | GO := GOPROXY=https://proxy.golang.org go 10 | GO_TEST_FLAGS ?= -race -coverprofile=profile.out -covermode=atomic 11 | 12 | GITHUB_REPOSITORY ?= github.com/getsops/sops 13 | 14 | STATICCHECK := $(BIN_DIR)/staticcheck 15 | STATICCHECK_VERSION := latest 16 | 17 | SYFT := $(BIN_DIR)/syft 18 | SYFT_VERSION ?= v0.87.0 19 | 20 | GORELEASER := $(BIN_DIR)/goreleaser 21 | GORELEASER_VERSION ?= v1.20.0 22 | 23 | PROTOC_GO := $(BIN_DIR)/protoc-gen-go 24 | PROTOC_GO_VERSION ?= v1.35.2 25 | 26 | PROTOC_GO_GRPC := $(BIN_DIR)/protoc-gen-go-grpc 27 | PROTOC_GO_GRPC_VERSION ?= v1.5.1 28 | 29 | RSTCHECK := $(shell command -v rstcheck) 30 | MARKDOWNLINT := $(shell command -v mdl) 31 | 32 | export PATH := $(BIN_DIR):$(PATH) 33 | 34 | .PHONY: all 35 | all: test vet generate install functional-tests 36 | 37 | .PHONY: origin-build 38 | origin-build: test vet generate install functional-tests-all 39 | 40 | .PHONY: install 41 | install: 42 | $(GO) install github.com/getsops/sops/v3/cmd/sops 43 | 44 | .PHONY: staticcheck 45 | staticcheck: install-staticcheck 46 | $(STATICCHECK) ./... 47 | 48 | .PHONY: vendor 49 | vendor: 50 | $(GO) mod tidy 51 | $(GO) mod vendor 52 | 53 | .PHONY: vet 54 | vet: 55 | $(GO) vet ./... 56 | 57 | 58 | .PHONY: checkdocs 59 | checkdocs: checkrst checkmd 60 | 61 | .PHONY: checkrst 62 | RST_FILES=$(shell find . -name '*.rst' | grep -v /vendor/ | sort) 63 | checkrst: $(RST_FILES) 64 | @if [ "$(RSTCHECK)" == "" ]; then echo "Need rstcheck to lint RST files. Install rstcheck from your system package repository or from PyPI (https://pypi.org/project/rstcheck/)."; exit 1; fi 65 | $(RSTCHECK) --report-level warning $^ 66 | 67 | .PHONY: checkmd 68 | MD_FILES=$(shell find . -name '*.md' | grep -v /vendor/ | sort) 69 | checkmd: $(MD_FILES) 70 | @if [ "$(MARKDOWNLINT)" == "" ]; then echo "Need markdownlint to lint RST files. Install markdownlint from your system package repository or from https://github.com/markdownlint/markdownlint."; exit 1; fi 71 | $(MARKDOWNLINT) $^ 72 | 73 | .PHONY: test 74 | test: vendor 75 | gpg --import pgp/sops_functional_tests_key.asc 2>&1 1>/dev/null || exit 0 76 | unset SOPS_AGE_KEY_FILE; unset SOPS_AGE_KEY_CMD; LANG=en_US.UTF-8 $(GO) test $(GO_TEST_FLAGS) ./... 77 | 78 | .PHONY: showcoverage 79 | showcoverage: test 80 | $(GO) tool cover -html=profile.out 81 | 82 | .PHONY: generate 83 | generate: install-protoc-go install-protoc-go-grpc keyservice/keyservice.pb.go 84 | $(GO) generate 85 | 86 | %.pb.go: %.proto 87 | protoc --plugin gen-go=$(PROTOC_GO) --plugin gen-go-grpc=$(PLUGIN_GO_GRPC) --go-grpc_opt=require_unimplemented_servers=false --go-grpc_out=. --go_out=. $< 88 | 89 | .PHONY: functional-tests 90 | functional-tests: 91 | $(GO) build -o functional-tests/sops github.com/getsops/sops/v3/cmd/sops 92 | cd functional-tests && cargo test 93 | 94 | .PHONY: functional-tests-all 95 | functional-tests-all: 96 | $(GO) build -o functional-tests/sops github.com/getsops/sops/v3/cmd/sops 97 | # Ignored tests are ones that require external services (e.g. AWS KMS) 98 | # TODO: Once `--include-ignored` lands in rust stable, switch to that. 99 | cd functional-tests && cargo test && cargo test -- --ignored 100 | 101 | .PHONY: release-snapshot 102 | release-snapshot: install-goreleaser install-syft 103 | GITHUB_REPOSITORY=$(GITHUB_REPOSITORY) $(GORELEASER) release --clean --snapshot --skip=sign 104 | 105 | .PHONY: clean 106 | clean: 107 | rm -rf $(BIN_DIR) profile.out functional-tests/sops 108 | 109 | .PHONY: install-staticcheck 110 | install-staticcheck: 111 | $(call go-install-tool,$(STATICCHECK),honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VERSION),$(STATICCHECK_VERSION)) 112 | 113 | .PHONY: install-goreleaser 114 | install-goreleaser: 115 | $(call go-install-tool,$(GORELEASER),github.com/goreleaser/goreleaser@$(GORELEASER_VERSION),$(GORELEASER_VERSION)) 116 | 117 | .PHONY: install-syft 118 | install-syft: 119 | $(call go-install-tool,$(SYFT),github.com/anchore/syft/cmd/syft@$(SYFT_VERSION),$(SYFT_VERSION)) 120 | 121 | .PHONY: install-protoc-go 122 | install-protoc-go: 123 | $(call go-install-tool,$(PROTOC_GO),google.golang.org/protobuf/cmd/protoc-gen-go@$(PROTOC_GO_VERSION),$(PROTOC_GO_VERSION)) 124 | 125 | .PHONY: install-protoc-go-grpc 126 | install-protoc-go-grpc: 127 | $(call go-install-tool,$(PROTOC_GO_GRPC),google.golang.org/grpc/cmd/protoc-gen-go-grpc@$(PROTOC_GO_GRPC_VERSION),$(PROTOC_GO_GRPC_VERSION)) 128 | 129 | # go-install-tool will 'go install' any package $2 and install it to $1. 130 | define go-install-tool 131 | @[ -f $(1)-$(3) ] || { \ 132 | set -e ;\ 133 | GOBIN=$$(dirname $(1)) go install $(2) ;\ 134 | touch $(1)-$(3) ;\ 135 | } 136 | endef 137 | -------------------------------------------------------------------------------- /aes/cipher_test.go: -------------------------------------------------------------------------------- 1 | package aes 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "reflect" 7 | "strings" 8 | "testing" 9 | "testing/quick" 10 | "time" 11 | 12 | "github.com/getsops/sops/v3" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestDecrypt(t *testing.T) { 17 | expected := "foo" 18 | key := []byte(strings.Repeat("f", 32)) 19 | message := `ENC[AES256_GCM,data:oYyi,iv:MyIDYbT718JRr11QtBkcj3Dwm4k1aCGZBVeZf0EyV8o=,tag:t5z2Z023Up0kxwCgw1gNxg==,type:str]` 20 | decryption, err := NewCipher().Decrypt(message, key, "bar:") 21 | if err != nil { 22 | t.Errorf("%s", err) 23 | } 24 | if decryption != expected { 25 | t.Errorf("Decrypt(\"%s\", \"%s\") == \"%s\", expected %s", message, key, decryption, expected) 26 | } 27 | } 28 | 29 | func TestDecryptInvalidAad(t *testing.T) { 30 | message := `ENC[AES256_GCM,data:oYyi,iv:MyIDYbT718JRr11QtBkcj3Dwm4k1aCGZBVeZf0EyV8o=,tag:t5z2Z023Up0kxwCgw1gNxg==,type:str]` 31 | _, err := NewCipher().Decrypt(message, []byte(strings.Repeat("f", 32)), "") 32 | if err == nil { 33 | t.Errorf("Decrypting with an invalid AAC should fail") 34 | } 35 | } 36 | 37 | func TestRoundtripString(t *testing.T) { 38 | f := func(x, aad string) bool { 39 | key := make([]byte, 32) 40 | rand.Read(key) 41 | s, err := NewCipher().Encrypt(x, key, aad) 42 | if err != nil { 43 | log.Println(err) 44 | return false 45 | } 46 | d, err := NewCipher().Decrypt(s, key, aad) 47 | if err != nil { 48 | return false 49 | } 50 | return x == d 51 | } 52 | if err := quick.Check(f, nil); err != nil { 53 | t.Error(err) 54 | } 55 | } 56 | 57 | func TestRoundtripFloat(t *testing.T) { 58 | key := []byte(strings.Repeat("f", 32)) 59 | f := func(x float64) bool { 60 | s, err := NewCipher().Encrypt(x, key, "") 61 | if err != nil { 62 | log.Println(err) 63 | return false 64 | } 65 | d, err := NewCipher().Decrypt(s, key, "") 66 | if err != nil { 67 | return false 68 | } 69 | return x == d 70 | } 71 | if err := quick.Check(f, nil); err != nil { 72 | t.Error(err) 73 | } 74 | } 75 | 76 | func TestRoundtripInt(t *testing.T) { 77 | key := []byte(strings.Repeat("f", 32)) 78 | f := func(x int) bool { 79 | s, err := NewCipher().Encrypt(x, key, "") 80 | if err != nil { 81 | log.Println(err) 82 | return false 83 | } 84 | d, err := NewCipher().Decrypt(s, key, "") 85 | if err != nil { 86 | return false 87 | } 88 | return x == d 89 | } 90 | if err := quick.Check(f, nil); err != nil { 91 | t.Error(err) 92 | } 93 | } 94 | 95 | func TestRoundtripBool(t *testing.T) { 96 | key := []byte(strings.Repeat("f", 32)) 97 | f := func(x bool) bool { 98 | s, err := NewCipher().Encrypt(x, key, "") 99 | if err != nil { 100 | log.Println(err) 101 | return false 102 | } 103 | d, err := NewCipher().Decrypt(s, key, "") 104 | if err != nil { 105 | return false 106 | } 107 | return x == d 108 | } 109 | if err := quick.Check(f, nil); err != nil { 110 | t.Error(err) 111 | } 112 | } 113 | 114 | func TestRoundtripTime(t *testing.T) { 115 | key := []byte(strings.Repeat("f", 32)) 116 | parsedTime, err := time.Parse(time.RFC3339, "2006-01-02T15:04:05+07:00") 117 | assert.Nil(t, err) 118 | loc := time.FixedZone("", 12300) // offset must be divisible by 60, otherwise won't survive a round-trip 119 | values := []time.Time{ 120 | time.UnixMilli(0).In(time.UTC), 121 | time.UnixMilli(123456).In(time.UTC), 122 | time.UnixMilli(123456).In(loc), 123 | time.UnixMilli(123456789).In(time.UTC), 124 | time.UnixMilli(123456789).In(loc), 125 | time.UnixMilli(1234567890).In(time.UTC), 126 | time.UnixMilli(1234567890).In(loc), 127 | parsedTime, 128 | } 129 | for _, value := range values { 130 | s, err := NewCipher().Encrypt(value, key, "foo") 131 | assert.Nil(t, err) 132 | if err != nil { 133 | continue 134 | } 135 | d, err := NewCipher().Decrypt(s, key, "foo") 136 | assert.Nil(t, err) 137 | if err != nil { 138 | continue 139 | } 140 | assert.Equal(t, value, d) 141 | } 142 | } 143 | 144 | func TestEncryptEmptyComment(t *testing.T) { 145 | key := []byte(strings.Repeat("f", 32)) 146 | s, err := NewCipher().Encrypt(sops.Comment{}, key, "") 147 | assert.Nil(t, err) 148 | assert.Equal(t, "", s) 149 | } 150 | 151 | func TestDecryptEmptyValue(t *testing.T) { 152 | key := []byte(strings.Repeat("f", 32)) 153 | s, err := NewCipher().Decrypt("", key, "") 154 | assert.Nil(t, err) 155 | assert.Equal(t, "", s) 156 | } 157 | 158 | // This test would belong more in sops_test.go, but from there we cannot access 159 | // the aes package to get a cipher which can actually handle time.Time objects. 160 | func TestTimestamps(t *testing.T) { 161 | unixTime := time.UnixMilli(123456789).In(time.UTC) 162 | parsedTime, err := time.Parse(time.RFC3339, "2006-01-02T15:04:05+07:00") 163 | assert.Nil(t, err) 164 | branches := sops.TreeBranches{ 165 | sops.TreeBranch{ 166 | sops.TreeItem{ 167 | Key: "foo", 168 | Value: unixTime, 169 | }, 170 | sops.TreeItem{ 171 | Key: "bar", 172 | Value: sops.TreeBranch{ 173 | sops.TreeItem{ 174 | Key: "foo", 175 | Value: parsedTime, 176 | }, 177 | }, 178 | }, 179 | }, 180 | } 181 | tree := sops.Tree{Branches: branches, Metadata: sops.Metadata{UnencryptedSuffix: "_unencrypted"}} 182 | expected := sops.TreeBranch{ 183 | sops.TreeItem{ 184 | Key: "foo", 185 | Value: unixTime, 186 | }, 187 | sops.TreeItem{ 188 | Key: "bar", 189 | Value: sops.TreeBranch{ 190 | sops.TreeItem{ 191 | Key: "foo", 192 | Value: parsedTime, 193 | }, 194 | }, 195 | }, 196 | } 197 | cipher := NewCipher() 198 | _, err = tree.Encrypt(bytes.Repeat([]byte("f"), 32), cipher) 199 | if err != nil { 200 | t.Errorf("Encrypting the tree failed: %s", err) 201 | } 202 | if reflect.DeepEqual(tree.Branches[0], expected) { 203 | t.Errorf("Trees do match: \ngot \t\t%+v,\n not expected \t\t%+v", tree.Branches[0], expected) 204 | } 205 | _, err = tree.Decrypt(bytes.Repeat([]byte("f"), 32), cipher) 206 | if err != nil { 207 | t.Errorf("Decrypting the tree failed: %s", err) 208 | } 209 | assert.Equal(t, tree.Branches[0][0].Value, unixTime) 210 | assert.Equal(t, tree.Branches[0], expected) 211 | if !reflect.DeepEqual(tree.Branches[0], expected) { 212 | t.Errorf("Trees don't match: \ngot\t\t\t%+v,\nexpected\t\t%+v", tree.Branches[0], expected) 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /age/encrypted_keys.go: -------------------------------------------------------------------------------- 1 | // These functions have been copied from the age project 2 | // https://github.com/FiloSottile/age/blob/101cc8676386b0503571a929a88618cae2f0b1cd/cmd/age/encrypted_keys.go 3 | // https://github.com/FiloSottile/age/blob/101cc8676386b0503571a929a88618cae2f0b1cd/cmd/age/parse.go 4 | // 5 | // Copyright 2021 The age Authors. All rights reserved. 6 | // Use of this source code is governed by a BSD-style 7 | // license that can be found in age's LICENSE file at 8 | // https://github.com/FiloSottile/age/blob/v1.0.0/LICENSE 9 | // 10 | // SPDX-License-Identifier: BSD-3-Clause 11 | 12 | package age 13 | 14 | import ( 15 | "bufio" 16 | "bytes" 17 | "errors" 18 | "fmt" 19 | "io" 20 | 21 | "filippo.io/age" 22 | "filippo.io/age/armor" 23 | 24 | gpgagent "github.com/getsops/gopgagent" 25 | ) 26 | 27 | type EncryptedIdentity struct { 28 | Contents []byte 29 | Passphrase func() (string, error) 30 | NoMatchWarning func() 31 | IncorrectPassphrase func() 32 | 33 | identities []age.Identity 34 | } 35 | 36 | var _ age.Identity = &EncryptedIdentity{} 37 | 38 | func (i *EncryptedIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) { 39 | if i.identities == nil { 40 | if err := i.decrypt(); err != nil { 41 | return nil, err 42 | } 43 | } 44 | 45 | for _, id := range i.identities { 46 | fileKey, err = id.Unwrap(stanzas) 47 | if errors.Is(err, age.ErrIncorrectIdentity) { 48 | continue 49 | } 50 | if err != nil { 51 | return nil, err 52 | } 53 | return fileKey, nil 54 | } 55 | i.NoMatchWarning() 56 | return nil, age.ErrIncorrectIdentity 57 | } 58 | 59 | func (i *EncryptedIdentity) decrypt() error { 60 | d, err := age.Decrypt(bytes.NewReader(i.Contents), &LazyScryptIdentity{i.Passphrase}) 61 | if e := new(age.NoIdentityMatchError); errors.As(err, &e) { 62 | // ScryptIdentity returns ErrIncorrectIdentity for an incorrect 63 | // passphrase, which would lead Decrypt to returning "no identity 64 | // matched any recipient". That makes sense in the API, where there 65 | // might be multiple configured ScryptIdentity. Since in cmd/age there 66 | // can be only one, return a better error message. 67 | i.IncorrectPassphrase() 68 | return fmt.Errorf("incorrect passphrase") 69 | } 70 | if err != nil { 71 | return fmt.Errorf("failed to decrypt identity file: %v", err) 72 | } 73 | i.identities, err = age.ParseIdentities(d) 74 | return err 75 | } 76 | 77 | // LazyScryptIdentity is an age.Identity that requests a passphrase only if it 78 | // encounters an scrypt stanza. After obtaining a passphrase, it delegates to 79 | // ScryptIdentity. 80 | type LazyScryptIdentity struct { 81 | Passphrase func() (string, error) 82 | } 83 | 84 | var _ age.Identity = &LazyScryptIdentity{} 85 | 86 | func (i *LazyScryptIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) { 87 | for _, s := range stanzas { 88 | if s.Type == "scrypt" && len(stanzas) != 1 { 89 | return nil, errors.New("an scrypt recipient must be the only one") 90 | } 91 | } 92 | if len(stanzas) != 1 || stanzas[0].Type != "scrypt" { 93 | return nil, age.ErrIncorrectIdentity 94 | } 95 | pass, err := i.Passphrase() 96 | if err != nil { 97 | return nil, fmt.Errorf("could not read passphrase: %v", err) 98 | } 99 | ii, err := age.NewScryptIdentity(pass) 100 | if err != nil { 101 | return nil, err 102 | } 103 | fileKey, err = ii.Unwrap(stanzas) 104 | return fileKey, err 105 | } 106 | 107 | func unwrapIdentities(key string, reader io.Reader) (ParsedIdentities, error) { 108 | b := bufio.NewReader(reader) 109 | p, _ := b.Peek(14) // length of "age-encryption" and "-----BEGIN AGE" 110 | peeked := string(p) 111 | 112 | switch { 113 | // An age encrypted file, plain or armored. 114 | case peeked == "age-encryption" || peeked == "-----BEGIN AGE": 115 | var r io.Reader = b 116 | if peeked == "-----BEGIN AGE" { 117 | r = armor.NewReader(r) 118 | } 119 | const privateKeySizeLimit = 1 << 24 // 16 MiB 120 | contents, err := io.ReadAll(io.LimitReader(r, privateKeySizeLimit)) 121 | if err != nil { 122 | return nil, fmt.Errorf("failed to read '%s': %w", key, err) 123 | } 124 | if len(contents) == privateKeySizeLimit { 125 | return nil, fmt.Errorf("failed to read '%s': file too long", key) 126 | } 127 | IncorrectPassphrase := func() { 128 | conn, err := gpgagent.NewConn() 129 | if err != nil { 130 | return 131 | } 132 | defer func(conn *gpgagent.Conn) { 133 | if err := conn.Close(); err != nil { 134 | log.Errorf("failed to close connection with gpg-agent: %s", err) 135 | } 136 | }(conn) 137 | err = conn.RemoveFromCache(key) 138 | if err != nil { 139 | log.Warnf("gpg-agent remove cache request errored: %s", err) 140 | return 141 | } 142 | } 143 | ids := []age.Identity{&EncryptedIdentity{ 144 | Contents: contents, 145 | Passphrase: func() (string, error) { 146 | conn, err := gpgagent.NewConn() 147 | if err != nil { 148 | passphrase, err := readSecret("Enter passphrase for identity " + key + ":") 149 | if err != nil { 150 | return "", err 151 | } 152 | return string(passphrase), nil 153 | } 154 | defer func(conn *gpgagent.Conn) { 155 | if err := conn.Close(); err != nil { 156 | log.Errorf("failed to close connection with gpg-agent: %s", err) 157 | } 158 | }(conn) 159 | 160 | req := gpgagent.PassphraseRequest{ 161 | // TODO is the cachekey good enough? 162 | CacheKey: key, 163 | Prompt: "Passphrase", 164 | Desc: fmt.Sprintf("Enter passphrase for identity '%s':", key), 165 | } 166 | pass, err := conn.GetPassphrase(&req) 167 | if err != nil { 168 | return "", fmt.Errorf("gpg-agent passphrase request errored: %s", err) 169 | } 170 | //make sure that we won't store empty pass 171 | if len(pass) == 0 { 172 | IncorrectPassphrase() 173 | } 174 | return pass, nil 175 | }, 176 | IncorrectPassphrase: IncorrectPassphrase, 177 | NoMatchWarning: func() { 178 | log.Warnf("encrypted identity '%s' didn't match file's recipients", key) 179 | }, 180 | }} 181 | return ids, nil 182 | // An unencrypted age identity file. 183 | default: 184 | ids, err := parseIdentities(b) 185 | if err != nil { 186 | return nil, fmt.Errorf("failed to parse '%s' age identities: %w", key, err) 187 | } 188 | return ids, nil 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /age/ssh_parse.go: -------------------------------------------------------------------------------- 1 | // These functions are similar to those in the age project 2 | // https://github.com/FiloSottile/age/blob/v1.0.0/cmd/age/parse.go 3 | // 4 | // Copyright 2021 The age Authors. All rights reserved. 5 | // Use of this source code is governed by a BSD-style 6 | // license that can be found in age's LICENSE file at 7 | // https://github.com/FiloSottile/age/blob/v1.0.0/LICENSE 8 | // 9 | // SPDX-License-Identifier: BSD-3-Clause 10 | 11 | package age 12 | 13 | import ( 14 | "fmt" 15 | "io" 16 | "os" 17 | 18 | "filippo.io/age" 19 | "filippo.io/age/agessh" 20 | "golang.org/x/crypto/ssh" 21 | ) 22 | 23 | // readPublicKeyFile attempts to read a public key based on the given private 24 | // key path. It assumes the public key is in the same directory, with the same 25 | // name, but with a ".pub" extension. If the public key cannot be read, an 26 | // error is returned. 27 | func readPublicKeyFile(privateKeyPath string) (ssh.PublicKey, error) { 28 | publicKeyPath := privateKeyPath + ".pub" 29 | f, err := os.Open(publicKeyPath) 30 | if err != nil { 31 | return nil, fmt.Errorf("failed to obtain public %q key for %q SSH key: %w", publicKeyPath, privateKeyPath, err) 32 | } 33 | defer f.Close() 34 | contents, err := io.ReadAll(f) 35 | if err != nil { 36 | return nil, fmt.Errorf("failed to read %q: %w", publicKeyPath, err) 37 | } 38 | pubKey, _, _, _, err := ssh.ParseAuthorizedKey(contents) 39 | if err != nil { 40 | return nil, fmt.Errorf("failed to parse %q: %w", publicKeyPath, err) 41 | } 42 | return pubKey, nil 43 | } 44 | 45 | // parseSSHIdentityFromPrivateKeyFile returns an age.Identity from the given 46 | // private key file. If the private key file is encrypted, it will configure 47 | // the identity to prompt for a passphrase. 48 | func parseSSHIdentityFromPrivateKeyFile(keyPath string) (age.Identity, error) { 49 | keyFile, err := os.Open(keyPath) 50 | if err != nil { 51 | return nil, fmt.Errorf("failed to open file: %w", err) 52 | } 53 | defer keyFile.Close() 54 | contents, err := io.ReadAll(keyFile) 55 | if err != nil { 56 | return nil, fmt.Errorf("failed to read file: %w", err) 57 | } 58 | id, err := agessh.ParseIdentity(contents) 59 | if sshErr, ok := err.(*ssh.PassphraseMissingError); ok { 60 | pubKey := sshErr.PublicKey 61 | if pubKey == nil { 62 | pubKey, err = readPublicKeyFile(keyPath) 63 | if err != nil { 64 | return nil, err 65 | } 66 | } 67 | passphrasePrompt := func() ([]byte, error) { 68 | pass, err := readSecret(fmt.Sprintf("Enter passphrase for %q:", keyPath)) 69 | if err != nil { 70 | return nil, fmt.Errorf("could not read passphrase for %q: %v", keyPath, err) 71 | } 72 | return pass, nil 73 | } 74 | i, err := agessh.NewEncryptedSSHIdentity(pubKey, contents, passphrasePrompt) 75 | if err != nil { 76 | return nil, fmt.Errorf("could not create encrypted SSH identity: %w", err) 77 | } 78 | return i, nil 79 | } 80 | if err != nil { 81 | return nil, fmt.Errorf("malformed SSH identity in %q: %w", keyPath, err) 82 | } 83 | return id, nil 84 | } 85 | -------------------------------------------------------------------------------- /age/tui.go: -------------------------------------------------------------------------------- 1 | // These functions have been copied from the age project 2 | // https://github.com/FiloSottile/age/blob/3d91014ea095e8d70f7c6c4833f89b53a96e0832/cmd/age/tui.go 3 | // 4 | // Copyright 2021 The age Authors. All rights reserved. 5 | // Use of this source code is governed by a BSD-style 6 | // license that can be found in age's LICENSE file at 7 | // https://github.com/FiloSottile/age/blob/v1.0.0/LICENSE 8 | // 9 | // SPDX-License-Identifier: BSD-3-Clause 10 | 11 | package age 12 | 13 | import ( 14 | "errors" 15 | "filippo.io/age/plugin" 16 | "fmt" 17 | "io" 18 | "os" 19 | "runtime" 20 | "testing" 21 | 22 | "golang.org/x/term" 23 | ) 24 | 25 | var testOnlyAgePassword string 26 | 27 | func printf(format string, v ...interface{}) { 28 | log.Printf("age: "+format, v...) 29 | } 30 | 31 | func warningf(format string, v ...interface{}) { 32 | log.Printf("age: warning: "+format, v...) 33 | } 34 | 35 | // clearLine clears the current line on the terminal, or opens a new line if 36 | // terminal escape codes don't work. 37 | func clearLine(out io.Writer) { 38 | const ( 39 | CUI = "\033[" // Control Sequence Introducer 40 | CPL = CUI + "F" // Cursor Previous Line 41 | EL = CUI + "K" // Erase in Line 42 | ) 43 | 44 | // First, open a new line, which is guaranteed to work everywhere. Then, try 45 | // to erase the line above with escape codes. 46 | // 47 | // (We use CRLF instead of LF to work around an apparent bug in WSL2's 48 | // handling of CONOUT$. Only when running a Windows binary from WSL2, the 49 | // cursor would not go back to the start of the line with a simple LF. 50 | // Honestly, it's impressive CONIN$ and CONOUT$ work at all inside WSL2.) 51 | fmt.Fprintf(out, "\r\n"+CPL+EL) 52 | } 53 | 54 | // withTerminal runs f with the terminal input and output files, if available. 55 | // withTerminal does not open a non-terminal stdin, so the caller does not need 56 | // to check stdinInUse. 57 | func withTerminal(f func(in, out *os.File) error) error { 58 | if runtime.GOOS == "windows" { 59 | in, err := os.OpenFile("CONIN$", os.O_RDWR, 0) 60 | if err != nil { 61 | return err 62 | } 63 | defer in.Close() 64 | out, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0) 65 | if err != nil { 66 | return err 67 | } 68 | defer out.Close() 69 | return f(in, out) 70 | } else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil { 71 | defer tty.Close() 72 | return f(tty, tty) 73 | } else if term.IsTerminal(int(os.Stdin.Fd())) { 74 | return f(os.Stdin, os.Stdin) 75 | } else { 76 | return fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err) 77 | } 78 | } 79 | 80 | // readSecret reads a value from the terminal with no echo. The prompt is ephemeral. 81 | func readSecret(prompt string) (s []byte, err error) { 82 | if testing.Testing() { 83 | if testOnlyAgePassword != "" { 84 | return []byte(testOnlyAgePassword), nil 85 | } 86 | } 87 | 88 | err = withTerminal(func(in, out *os.File) error { 89 | fmt.Fprintf(out, "%s ", prompt) 90 | defer clearLine(out) 91 | s, err = term.ReadPassword(int(in.Fd())) 92 | return err 93 | }) 94 | return 95 | } 96 | 97 | // readCharacter reads a single character from the terminal with no echo. The 98 | // prompt is ephemeral. 99 | func readCharacter(prompt string) (c byte, err error) { 100 | err = withTerminal(func(in, out *os.File) error { 101 | fmt.Fprintf(out, "%s ", prompt) 102 | defer clearLine(out) 103 | 104 | oldState, err := term.MakeRaw(int(in.Fd())) 105 | if err != nil { 106 | return err 107 | } 108 | defer term.Restore(int(in.Fd()), oldState) 109 | 110 | b := make([]byte, 1) 111 | if _, err := in.Read(b); err != nil { 112 | return err 113 | } 114 | 115 | c = b[0] 116 | return nil 117 | }) 118 | return 119 | } 120 | 121 | var pluginTerminalUI = &plugin.ClientUI{ 122 | DisplayMessage: func(name, message string) error { 123 | printf("%s plugin: %s", name, message) 124 | return nil 125 | }, 126 | RequestValue: func(name, message string, _ bool) (s string, err error) { 127 | defer func() { 128 | if err != nil { 129 | warningf("could not read value for age-plugin-%s: %v", name, err) 130 | } 131 | }() 132 | secret, err := readSecret(message) 133 | if err != nil { 134 | return "", err 135 | } 136 | return string(secret), nil 137 | }, 138 | Confirm: func(name, message, yes, no string) (choseYes bool, err error) { 139 | defer func() { 140 | if err != nil { 141 | warningf("could not read value for age-plugin-%s: %v", name, err) 142 | } 143 | }() 144 | if no == "" { 145 | message += fmt.Sprintf(" (press enter for %q)", yes) 146 | _, err := readSecret(message) 147 | if err != nil { 148 | return false, err 149 | } 150 | return true, nil 151 | } 152 | message += fmt.Sprintf(" (press [1] for %q or [2] for %q)", yes, no) 153 | for { 154 | selection, err := readCharacter(message) 155 | if err != nil { 156 | return false, err 157 | } 158 | switch selection { 159 | case '1': 160 | return true, nil 161 | case '2': 162 | return false, nil 163 | case '\x03': // CTRL-C 164 | return false, errors.New("user cancelled prompt") 165 | default: 166 | warningf("reading value for age-plugin-%s: invalid selection %q", name, selection) 167 | } 168 | } 169 | }, 170 | WaitTimer: func(name string) { 171 | printf("waiting on %s plugin...", name) 172 | }, 173 | } 174 | -------------------------------------------------------------------------------- /audit/audit.go: -------------------------------------------------------------------------------- 1 | package audit 2 | 3 | import ( 4 | "database/sql" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "os/user" 9 | 10 | "github.com/pkg/errors" 11 | 12 | // empty import as per https://godoc.org/github.com/lib/pq 13 | _ "github.com/lib/pq" 14 | 15 | "github.com/getsops/sops/v3/logging" 16 | "github.com/sirupsen/logrus" 17 | "gopkg.in/yaml.v3" 18 | ) 19 | 20 | var log *logrus.Logger 21 | 22 | func init() { 23 | log = logging.NewLogger("AUDIT") 24 | confBytes, err := os.ReadFile(configFile) 25 | if err != nil { 26 | log.WithField("error", err).Debugf("Error reading config") 27 | return 28 | } 29 | var conf config 30 | err = yaml.Unmarshal(confBytes, &conf) 31 | if err != nil { 32 | log.WithField("error", err).Panicf("Error unmarshalling config") 33 | } 34 | // If we are running test, then don't create auditors. 35 | // This is pretty hacky, but doing it The Right Way would require 36 | // restructuring SOPS to use dependency injection instead of just using 37 | // globals everywhere. 38 | if flag.Lookup("test.v") != nil { 39 | return 40 | } 41 | var auditErrors []error 42 | 43 | for _, pgConf := range conf.Backends.Postgres { 44 | auditDb, err := NewPostgresAuditor(pgConf.ConnStr) 45 | if err != nil { 46 | auditErrors = append(auditErrors, errors.Wrap(err, fmt.Sprintf("connectStr: %s, err", pgConf.ConnStr))) 47 | } 48 | auditors = append(auditors, auditDb) 49 | } 50 | if len(auditErrors) > 0 { 51 | log.Errorf("connecting to audit database, defined in %s", configFile) 52 | for _, err := range auditErrors { 53 | log.Error(err) 54 | } 55 | log.Fatal("one or more audit backends reported errors, exiting") 56 | } 57 | } 58 | 59 | // TODO: Make platform agnostic 60 | const configFile = "/etc/sops/audit.yaml" 61 | 62 | type config struct { 63 | Backends struct { 64 | Postgres []struct { 65 | ConnStr string `yaml:"connection_string"` 66 | } `yaml:"postgres"` 67 | } `yaml:"backends"` 68 | } 69 | 70 | var auditors []Auditor 71 | 72 | // SubmitEvent handles an event for all auditors 73 | func SubmitEvent(event interface{}) { 74 | for _, auditor := range auditors { 75 | auditor.Handle(event) 76 | } 77 | } 78 | 79 | // Register registers a new Auditor in the global auditor list 80 | func Register(auditor Auditor) { 81 | auditors = append(auditors, auditor) 82 | } 83 | 84 | // Auditor is notified when noteworthy events happen, 85 | // for example when a file is encrypted or decrypted. 86 | type Auditor interface { 87 | // Handle() takes an audit event and attempts to persists it; 88 | // how it is persisted and how errors are handled is up to the 89 | // implementation of this interface. 90 | Handle(event interface{}) 91 | } 92 | 93 | // DecryptEvent contains fields relevant to a decryption event 94 | type DecryptEvent struct { 95 | File string 96 | } 97 | 98 | // EncryptEvent contains fields relevant to an encryption event 99 | type EncryptEvent struct { 100 | File string 101 | } 102 | 103 | // RotateEvent contains fields relevant to a key rotation event 104 | type RotateEvent struct { 105 | File string 106 | } 107 | 108 | // PostgresAuditor is a Postgres SQL DB implementation of the Auditor interface. 109 | // It persists the audit event by writing a row to the 'audit_event' table. 110 | // Errors with writing to the database will output a log message and the 111 | // process will exit with status set to 1 112 | type PostgresAuditor struct { 113 | DB *sql.DB 114 | } 115 | 116 | // NewPostgresAuditor is the constructor for a new PostgresAuditor struct 117 | // initialized with the given db connection string 118 | func NewPostgresAuditor(connStr string) (*PostgresAuditor, error) { 119 | db, err := sql.Open("postgres", connStr) 120 | pg := &PostgresAuditor{DB: db} 121 | if err != nil { 122 | return pg, err 123 | } 124 | var result int 125 | err = pg.DB.QueryRow("SELECT 1").Scan(&result) 126 | if err != nil { 127 | return pg, fmt.Errorf("Pinging audit database failed: %s", err) 128 | } else if result != 1 { 129 | return pg, fmt.Errorf("Database malfunction: SELECT 1 should return 1, but returned %d", result) 130 | } 131 | return pg, nil 132 | } 133 | 134 | // Handle persists the audit event by writing a row to the 135 | // 'audit_event' postgres table 136 | func (p *PostgresAuditor) Handle(event interface{}) { 137 | u, err := user.Current() 138 | if err != nil { 139 | log.Fatalf("Error getting current user for auditing: %s", err) 140 | } 141 | switch event := event.(type) { 142 | case DecryptEvent: 143 | // Save the event to the database 144 | log.WithField("file", event.File). 145 | Debug("Saving decrypt event to database") 146 | _, err = p.DB.Exec("INSERT INTO audit_event (action, username, file) VALUES ($1, $2, $3)", "decrypt", u.Username, event.File) 147 | if err != nil { 148 | log.Fatalf("Failed to insert audit record: %s", err) 149 | } 150 | case EncryptEvent: 151 | // Save the event to the database 152 | log.WithField("file", event.File). 153 | Debug("Saving encrypt event to database") 154 | _, err = p.DB.Exec("INSERT INTO audit_event (action, username, file) VALUES ($1, $2, $3)", "encrypt", u.Username, event.File) 155 | if err != nil { 156 | log.Fatalf("Failed to insert audit record: %s", err) 157 | } 158 | case RotateEvent: 159 | // Save the event to the database 160 | log.WithField("file", event.File). 161 | Debug("Saving rotate event to database") 162 | _, err = p.DB.Exec("INSERT INTO audit_event (action, username, file) VALUES ($1, $2, $3)", "rotate", u.Username, event.File) 163 | if err != nil { 164 | log.Fatalf("Failed to insert audit record: %s", err) 165 | } 166 | default: 167 | log.WithField("type", fmt.Sprintf("%T", event)). 168 | Info("Received unknown event") 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /audit/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE audit_event ( 2 | id SERIAL PRIMARY KEY, 3 | timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 4 | action TEXT, 5 | username TEXT, 6 | file TEXT 7 | ); 8 | 9 | CREATE ROLE sops WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB LOGIN PASSWORD 'sops'; 10 | 11 | GRANT INSERT ON audit_event TO sops; 12 | GRANT USAGE ON audit_event_id_seq TO sops; 13 | -------------------------------------------------------------------------------- /azkv/keysource_integration_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | 3 | package azkv 4 | 5 | import ( 6 | "context" 7 | "encoding/base64" 8 | "os" 9 | "testing" 10 | 11 | "github.com/Azure/azure-sdk-for-go/sdk/azcore" 12 | "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" 13 | "github.com/Azure/azure-sdk-for-go/sdk/azidentity" 14 | "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys" 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | // The following values should be created based on the instructions in: 19 | // https://github.com/mozilla/sops#encrypting-using-azure-key-vault 20 | // 21 | // Additionally required permissions for auto key creation: 22 | // - KeyManagementOperations/Get 23 | // - KeyManagementOperations/Create 24 | var ( 25 | testVaultTenantID = os.Getenv("SOPS_TEST_AZURE_TENANT_ID") 26 | testVaultClientID = os.Getenv("SOPS_TEST_AZURE_CLIENT_ID") 27 | testVaultClientSecret = os.Getenv("SOPS_TEST_AZURE_CLIENT_SECRET") 28 | 29 | testVaultURL = os.Getenv("SOPS_TEST_AZURE_VAULT_URL") 30 | testVaultKeyName = os.Getenv("SOPS_TEST_AZURE_VAULT_KEY_NAME") 31 | testVaultKeyVersion = os.Getenv("SOPS_TEST_AZURE_VAULT_KEY_VERSION") 32 | ) 33 | 34 | func TestMasterKey_Encrypt(t *testing.T) { 35 | key, err := createTestKMSKeyIfNotExists() 36 | assert.NoError(t, err) 37 | 38 | data := []byte("to be or not to be static bytes") 39 | assert.NoError(t, key.Encrypt(data)) 40 | assert.NotEmpty(t, key.EncryptedDataKey()) 41 | assert.NotEqual(t, data, key.EncryptedKey) 42 | } 43 | 44 | func TestMasterKey_Decrypt(t *testing.T) { 45 | key, err := createTestKMSKeyIfNotExists() 46 | assert.NoError(t, err) 47 | 48 | data := []byte("this is super secret data") 49 | 50 | c, err := azkeys.NewClient(key.VaultURL, key.tokenCredential, nil) 51 | assert.NoError(t, err) 52 | 53 | resp, err := c.Encrypt(context.Background(), key.Name, key.Version, azkeys.KeyOperationParameters{ 54 | Algorithm: to.Ptr(azkeys.EncryptionAlgorithmRSAOAEP256), 55 | Value: data, 56 | }, nil) 57 | assert.NoError(t, err) 58 | key.EncryptedKey = base64.RawURLEncoding.EncodeToString(resp.KeyOperationResult.Result) 59 | 60 | got, err := key.Decrypt() 61 | assert.NoError(t, err) 62 | assert.Equal(t, data, got) 63 | } 64 | 65 | func TestMasterKey_EncryptDecrypt_RoundTrip(t *testing.T) { 66 | key, err := createTestKMSKeyIfNotExists() 67 | assert.NoError(t, err) 68 | 69 | data := []byte("the earth is round") 70 | assert.NoError(t, key.Encrypt(data)) 71 | assert.NotNil(t, key.EncryptedDataKey()) 72 | 73 | got, err := key.Decrypt() 74 | assert.NoError(t, err) 75 | assert.Equal(t, data, got) 76 | } 77 | 78 | func createTestKMSKeyIfNotExists() (*MasterKey, error) { 79 | token, err := testTokenCredential() 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | key := &MasterKey{ 85 | VaultURL: testVaultURL, 86 | Name: testVaultKeyName, 87 | Version: testVaultKeyVersion, 88 | } 89 | NewTokenCredential(token).ApplyToMasterKey(key) 90 | 91 | // If we have been given a version, assume it exists. 92 | if key.Version == "" { 93 | c, err := azkeys.NewClient(key.VaultURL, token, nil) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | getResp, err := c.GetKey(context.TODO(), key.Name, key.Version, nil) 99 | if err == nil { 100 | key.Version = getResp.KeyBundle.Key.KID.Version() 101 | } 102 | if err != nil { 103 | createResp, err := c.CreateKey(context.TODO(), key.Name, azkeys.CreateKeyParameters{ 104 | Kty: to.Ptr(azkeys.KeyTypeRSA), 105 | KeyOps: to.SliceOfPtrs(azkeys.KeyOperationEncrypt, azkeys.KeyOperationDecrypt), 106 | }, nil) 107 | if err != nil { 108 | return nil, err 109 | } 110 | key.Version = createResp.Key.KID.Version() 111 | } 112 | } 113 | 114 | return key, nil 115 | } 116 | 117 | func testTokenCredential() (azcore.TokenCredential, error) { 118 | return azidentity.NewClientSecretCredential(testVaultTenantID, testVaultClientID, testVaultClientSecret, nil) 119 | } 120 | -------------------------------------------------------------------------------- /cmd/sops/codes/codes.go: -------------------------------------------------------------------------------- 1 | // Package codes the exit statuses returned by the sops binary 2 | package codes 3 | 4 | // Exit statuses returned by the binary 5 | const ( 6 | ErrorGeneric int = 1 7 | CouldNotReadInputFile int = 2 8 | CouldNotWriteOutputFile int = 3 9 | ErrorDumpingTree int = 4 10 | ErrorReadingConfig int = 5 11 | ErrorInvalidKMSEncryptionContextFormat int = 6 12 | ErrorInvalidSetFormat int = 7 13 | ErrorConflictingParameters int = 8 14 | ErrorEncryptingMac int = 21 15 | ErrorEncryptingTree int = 23 16 | ErrorDecryptingMac int = 24 17 | ErrorDecryptingTree int = 25 18 | CannotChangeKeysFromNonExistentFile int = 49 19 | MacMismatch int = 51 20 | MacNotFound int = 52 21 | ConfigFileNotFound int = 61 22 | KeyboardInterrupt int = 85 23 | InvalidTreePathFormat int = 91 24 | NeedAtLeastOneDocument int = 92 25 | NoFileSpecified int = 100 26 | CouldNotRetrieveKey int = 128 27 | NoEncryptionKeyFound int = 111 28 | DuplicateDecryptionKeyType int = 112 29 | FileHasNotBeenModified int = 200 30 | NoEditorFound int = 201 31 | FailedToCompareVersions int = 202 32 | FileAlreadyEncrypted int = 203 33 | ) 34 | -------------------------------------------------------------------------------- /cmd/sops/decrypt.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/getsops/sops/v3" 8 | "github.com/getsops/sops/v3/cmd/sops/codes" 9 | "github.com/getsops/sops/v3/cmd/sops/common" 10 | "github.com/getsops/sops/v3/keyservice" 11 | "github.com/getsops/sops/v3/stores/json" 12 | ) 13 | 14 | const notBinaryHint = ("This is likely not an encrypted binary file?" + 15 | " If not, use --output-type to select the correct output type.") 16 | 17 | type decryptOpts struct { 18 | Cipher sops.Cipher 19 | InputStore sops.Store 20 | OutputStore sops.Store 21 | InputPath string 22 | ReadFromStdin bool 23 | IgnoreMAC bool 24 | Extract []interface{} 25 | KeyServices []keyservice.KeyServiceClient 26 | DecryptionOrder []string 27 | } 28 | 29 | func decryptTree(opts decryptOpts) (tree *sops.Tree, err error) { 30 | tree, err = common.LoadEncryptedFileWithBugFixes(common.GenericDecryptOpts{ 31 | Cipher: opts.Cipher, 32 | InputStore: opts.InputStore, 33 | InputPath: opts.InputPath, 34 | ReadFromStdin: opts.ReadFromStdin, 35 | IgnoreMAC: opts.IgnoreMAC, 36 | KeyServices: opts.KeyServices, 37 | }) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | _, err = common.DecryptTree(common.DecryptTreeOpts{ 43 | Cipher: opts.Cipher, 44 | IgnoreMac: opts.IgnoreMAC, 45 | Tree: tree, 46 | KeyServices: opts.KeyServices, 47 | DecryptionOrder: opts.DecryptionOrder, 48 | }) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | return tree, nil 54 | } 55 | 56 | func decrypt(opts decryptOpts) (decryptedFile []byte, err error) { 57 | tree, err := decryptTree(opts) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | if len(opts.Extract) > 0 { 63 | return extract(tree, opts.Extract, opts.OutputStore) 64 | } 65 | decryptedFile, err = opts.OutputStore.EmitPlainFile(tree.Branches) 66 | if errors.Is(err, json.BinaryStoreEmitPlainError) { 67 | err = fmt.Errorf("%s\n\n%s", err.Error(), notBinaryHint) 68 | } 69 | if err != nil { 70 | return nil, common.NewExitError(fmt.Sprintf("Error dumping file: %s", err), codes.ErrorDumpingTree) 71 | } 72 | return decryptedFile, err 73 | } 74 | 75 | func extract(tree *sops.Tree, path []interface{}, outputStore sops.Store) (output []byte, err error) { 76 | v, err := tree.Branches[0].Truncate(path) 77 | if err != nil { 78 | return nil, fmt.Errorf("error truncating tree: %s", err) 79 | } 80 | if newBranch, ok := v.(sops.TreeBranch); ok { 81 | tree.Branches[0] = newBranch 82 | decrypted, err := outputStore.EmitPlainFile(tree.Branches) 83 | if errors.Is(err, json.BinaryStoreEmitPlainError) { 84 | err = fmt.Errorf("%s\n\n%s", err.Error(), notBinaryHint) 85 | } 86 | if err != nil { 87 | return nil, common.NewExitError(fmt.Sprintf("Error dumping file: %s", err), codes.ErrorDumpingTree) 88 | } 89 | return decrypted, err 90 | } else if str, ok := v.(string); ok { 91 | return []byte(str), nil 92 | } 93 | bytes, err := outputStore.EmitValue(v) 94 | if err != nil { 95 | return nil, common.NewExitError(fmt.Sprintf("Error dumping tree: %s", err), codes.ErrorDumpingTree) 96 | } 97 | return bytes, nil 98 | } 99 | -------------------------------------------------------------------------------- /cmd/sops/encrypt.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/getsops/sops/v3" 10 | "github.com/getsops/sops/v3/cmd/sops/codes" 11 | "github.com/getsops/sops/v3/cmd/sops/common" 12 | "github.com/getsops/sops/v3/keyservice" 13 | "github.com/getsops/sops/v3/stores" 14 | "github.com/getsops/sops/v3/version" 15 | "github.com/mitchellh/go-wordwrap" 16 | ) 17 | 18 | type encryptConfig struct { 19 | UnencryptedSuffix string 20 | EncryptedSuffix string 21 | UnencryptedRegex string 22 | EncryptedRegex string 23 | UnencryptedCommentRegex string 24 | EncryptedCommentRegex string 25 | MACOnlyEncrypted bool 26 | KeyGroups []sops.KeyGroup 27 | GroupThreshold int 28 | } 29 | 30 | type encryptOpts struct { 31 | Cipher sops.Cipher 32 | InputStore sops.Store 33 | OutputStore sops.Store 34 | InputPath string 35 | ReadFromStdin bool 36 | KeyServices []keyservice.KeyServiceClient 37 | encryptConfig 38 | } 39 | 40 | type fileAlreadyEncryptedError struct{} 41 | 42 | func (err *fileAlreadyEncryptedError) Error() string { 43 | return "File already encrypted" 44 | } 45 | 46 | func (err *fileAlreadyEncryptedError) UserError() string { 47 | message := "The file you have provided contains a top-level entry called " + 48 | "'" + stores.SopsMetadataKey + "', or for flat file formats top-level entries starting with " + 49 | "'" + stores.SopsMetadataKey + "_'. This is generally due to the file already being encrypted. " + 50 | "SOPS uses a top-level entry called '" + stores.SopsMetadataKey + "' to store the metadata " + 51 | "required to decrypt the file. For this reason, SOPS can not " + 52 | "encrypt files that already contain such an entry.\n\n" + 53 | "If this is an unencrypted file, rename the '" + stores.SopsMetadataKey + "' entry.\n\n" + 54 | "If this is an encrypted file and you want to edit it, use the " + 55 | "editor mode, for example: `sops my_file.yaml`" 56 | return wordwrap.WrapString(message, 75) 57 | } 58 | 59 | func ensureNoMetadata(opts encryptOpts, branch sops.TreeBranch) error { 60 | if opts.OutputStore.HasSopsTopLevelKey(branch) { 61 | return &fileAlreadyEncryptedError{} 62 | } 63 | return nil 64 | } 65 | 66 | func metadataFromEncryptionConfig(config encryptConfig) sops.Metadata { 67 | return sops.Metadata{ 68 | KeyGroups: config.KeyGroups, 69 | UnencryptedSuffix: config.UnencryptedSuffix, 70 | EncryptedSuffix: config.EncryptedSuffix, 71 | UnencryptedRegex: config.UnencryptedRegex, 72 | EncryptedRegex: config.EncryptedRegex, 73 | UnencryptedCommentRegex: config.UnencryptedCommentRegex, 74 | EncryptedCommentRegex: config.EncryptedCommentRegex, 75 | MACOnlyEncrypted: config.MACOnlyEncrypted, 76 | Version: version.Version, 77 | ShamirThreshold: config.GroupThreshold, 78 | } 79 | } 80 | 81 | func encrypt(opts encryptOpts) (encryptedFile []byte, err error) { 82 | // Load the file 83 | var fileBytes []byte 84 | if opts.ReadFromStdin { 85 | fileBytes, err = io.ReadAll(os.Stdin) 86 | if err != nil { 87 | return nil, common.NewExitError(fmt.Sprintf("Error reading from stdin: %s", err), codes.CouldNotReadInputFile) 88 | } 89 | } else { 90 | fileBytes, err = os.ReadFile(opts.InputPath) 91 | if err != nil { 92 | return nil, common.NewExitError(fmt.Sprintf("Error reading file: %s", err), codes.CouldNotReadInputFile) 93 | } 94 | } 95 | branches, err := opts.InputStore.LoadPlainFile(fileBytes) 96 | if err != nil { 97 | return nil, common.NewExitError(fmt.Sprintf("Error unmarshalling file: %s", err), codes.CouldNotReadInputFile) 98 | } 99 | if len(branches) < 1 { 100 | return nil, common.NewExitError("File cannot be completely empty, it must contain at least one document", codes.NeedAtLeastOneDocument) 101 | } 102 | if err := ensureNoMetadata(opts, branches[0]); err != nil { 103 | return nil, common.NewExitError(err, codes.FileAlreadyEncrypted) 104 | } 105 | path, err := filepath.Abs(opts.InputPath) 106 | if err != nil { 107 | return nil, err 108 | } 109 | tree := sops.Tree{ 110 | Branches: branches, 111 | Metadata: metadataFromEncryptionConfig(opts.encryptConfig), 112 | FilePath: path, 113 | } 114 | dataKey, errs := tree.GenerateDataKeyWithKeyServices(opts.KeyServices) 115 | if len(errs) > 0 { 116 | err = fmt.Errorf("Could not generate data key: %s", errs) 117 | return nil, err 118 | } 119 | 120 | err = common.EncryptTree(common.EncryptTreeOpts{ 121 | DataKey: dataKey, 122 | Tree: &tree, 123 | Cipher: opts.Cipher, 124 | }) 125 | if err != nil { 126 | return nil, err 127 | } 128 | 129 | encryptedFile, err = opts.OutputStore.EmitEncryptedFile(tree) 130 | if err != nil { 131 | return nil, common.NewExitError(fmt.Sprintf("Could not marshal tree: %s", err), codes.ErrorDumpingTree) 132 | } 133 | return 134 | } 135 | -------------------------------------------------------------------------------- /cmd/sops/formats/formats.go: -------------------------------------------------------------------------------- 1 | package formats 2 | 3 | import "strings" 4 | 5 | // Format is an enum type 6 | type Format int 7 | 8 | const ( 9 | Binary Format = iota 10 | Dotenv 11 | Ini 12 | Json 13 | Yaml 14 | ) 15 | 16 | var stringToFormat = map[string]Format{ 17 | "binary": Binary, 18 | "dotenv": Dotenv, 19 | "ini": Ini, 20 | "json": Json, 21 | "yaml": Yaml, 22 | } 23 | 24 | // FormatFromString returns a Format from a string. 25 | // This is used for converting string cli options. 26 | func FormatFromString(formatString string) Format { 27 | format, found := stringToFormat[formatString] 28 | if !found { 29 | return Binary 30 | } 31 | return format 32 | } 33 | 34 | // IsYAMLFile returns true if a given file path corresponds to a YAML file 35 | func IsYAMLFile(path string) bool { 36 | return strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml") 37 | } 38 | 39 | // IsJSONFile returns true if a given file path corresponds to a JSON file 40 | func IsJSONFile(path string) bool { 41 | return strings.HasSuffix(path, ".json") 42 | } 43 | 44 | // IsEnvFile returns true if a given file path corresponds to a .env file 45 | func IsEnvFile(path string) bool { 46 | return strings.HasSuffix(path, ".env") 47 | } 48 | 49 | // IsIniFile returns true if a given file path corresponds to a INI file 50 | func IsIniFile(path string) bool { 51 | return strings.HasSuffix(path, ".ini") 52 | } 53 | 54 | // FormatForPath returns the correct format given the path to a file 55 | func FormatForPath(path string) Format { 56 | format := Binary // default 57 | if IsYAMLFile(path) { 58 | format = Yaml 59 | } else if IsJSONFile(path) { 60 | format = Json 61 | } else if IsEnvFile(path) { 62 | format = Dotenv 63 | } else if IsIniFile(path) { 64 | format = Ini 65 | } 66 | return format 67 | } 68 | 69 | // FormatForPathOrString returns the correct format-specific implementation 70 | // of the Store interface given the formatString if specified, or the path to a file. 71 | // This is to support the cli, where both are provided. 72 | func FormatForPathOrString(path, format string) Format { 73 | formatFmt, found := stringToFormat[format] 74 | if !found { 75 | formatFmt = FormatForPath(path) 76 | } 77 | return formatFmt 78 | } 79 | -------------------------------------------------------------------------------- /cmd/sops/formats/formats_test.go: -------------------------------------------------------------------------------- 1 | package formats 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestFormatFromString(t *testing.T) { 10 | assert.Equal(t, Binary, FormatFromString("foobar")) 11 | assert.Equal(t, Dotenv, FormatFromString("dotenv")) 12 | assert.Equal(t, Ini, FormatFromString("ini")) 13 | assert.Equal(t, Yaml, FormatFromString("yaml")) 14 | assert.Equal(t, Json, FormatFromString("json")) 15 | } 16 | 17 | func TestFormatForPath(t *testing.T) { 18 | assert.Equal(t, Binary, FormatForPath("/path/to/foobar")) 19 | assert.Equal(t, Dotenv, FormatForPath("/path/to/foobar.env")) 20 | assert.Equal(t, Ini, FormatForPath("/path/to/foobar.ini")) 21 | assert.Equal(t, Json, FormatForPath("/path/to/foobar.json")) 22 | assert.Equal(t, Yaml, FormatForPath("/path/to/foobar.yml")) 23 | assert.Equal(t, Yaml, FormatForPath("/path/to/foobar.yaml")) 24 | } 25 | 26 | func TestFormatForPathOrString(t *testing.T) { 27 | assert.Equal(t, Binary, FormatForPathOrString("/path/to/foobar", "")) 28 | assert.Equal(t, Dotenv, FormatForPathOrString("/path/to/foobar", "dotenv")) 29 | assert.Equal(t, Dotenv, FormatForPathOrString("/path/to/foobar.env", "")) 30 | assert.Equal(t, Ini, FormatForPathOrString("/path/to/foobar", "ini")) 31 | assert.Equal(t, Ini, FormatForPathOrString("/path/to/foobar.ini", "")) 32 | assert.Equal(t, Json, FormatForPathOrString("/path/to/foobar", "json")) 33 | assert.Equal(t, Json, FormatForPathOrString("/path/to/foobar.json", "")) 34 | assert.Equal(t, Yaml, FormatForPathOrString("/path/to/foobar", "yaml")) 35 | assert.Equal(t, Yaml, FormatForPathOrString("/path/to/foobar.yml", "")) 36 | 37 | assert.Equal(t, Ini, FormatForPathOrString("/path/to/foobar.yml", "ini")) 38 | assert.Equal(t, Binary, FormatForPathOrString("/path/to/foobar.yml", "binary")) 39 | } 40 | -------------------------------------------------------------------------------- /cmd/sops/rotate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/getsops/sops/v3" 7 | "github.com/getsops/sops/v3/audit" 8 | "github.com/getsops/sops/v3/cmd/sops/codes" 9 | "github.com/getsops/sops/v3/cmd/sops/common" 10 | "github.com/getsops/sops/v3/keys" 11 | "github.com/getsops/sops/v3/keyservice" 12 | ) 13 | 14 | type rotateOpts struct { 15 | Cipher sops.Cipher 16 | InputStore sops.Store 17 | OutputStore sops.Store 18 | InputPath string 19 | IgnoreMAC bool 20 | AddMasterKeys []keys.MasterKey 21 | RemoveMasterKeys []keys.MasterKey 22 | KeyServices []keyservice.KeyServiceClient 23 | DecryptionOrder []string 24 | } 25 | 26 | func rotate(opts rotateOpts) ([]byte, error) { 27 | tree, err := common.LoadEncryptedFileWithBugFixes(common.GenericDecryptOpts{ 28 | Cipher: opts.Cipher, 29 | InputStore: opts.InputStore, 30 | InputPath: opts.InputPath, 31 | IgnoreMAC: opts.IgnoreMAC, 32 | KeyServices: opts.KeyServices, 33 | DecryptionOrder: opts.DecryptionOrder, 34 | }) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | audit.SubmitEvent(audit.RotateEvent{ 40 | File: tree.FilePath, 41 | }) 42 | 43 | _, err = common.DecryptTree(common.DecryptTreeOpts{ 44 | Cipher: opts.Cipher, 45 | IgnoreMac: opts.IgnoreMAC, 46 | Tree: tree, 47 | KeyServices: opts.KeyServices, 48 | DecryptionOrder: opts.DecryptionOrder, 49 | }) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | // Add new master keys 55 | for _, key := range opts.AddMasterKeys { 56 | tree.Metadata.KeyGroups[0] = append(tree.Metadata.KeyGroups[0], key) 57 | } 58 | // Remove master keys 59 | for _, rmKey := range opts.RemoveMasterKeys { 60 | for i := range tree.Metadata.KeyGroups { 61 | for j, groupKey := range tree.Metadata.KeyGroups[i] { 62 | if rmKey.ToString() == groupKey.ToString() { 63 | tree.Metadata.KeyGroups[i] = append(tree.Metadata.KeyGroups[i][:j], tree.Metadata.KeyGroups[i][j+1:]...) 64 | } 65 | } 66 | } 67 | } 68 | 69 | // Create a new data key 70 | dataKey, errs := tree.GenerateDataKeyWithKeyServices(opts.KeyServices) 71 | if len(errs) > 0 { 72 | err = fmt.Errorf("Could not generate data key: %s", errs) 73 | return nil, err 74 | } 75 | 76 | // Reencrypt the file with the new key 77 | err = common.EncryptTree(common.EncryptTreeOpts{ 78 | DataKey: dataKey, Tree: tree, Cipher: opts.Cipher, 79 | }) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | encryptedFile, err := opts.OutputStore.EmitEncryptedFile(*tree) 85 | if err != nil { 86 | return nil, common.NewExitError(fmt.Sprintf("Could not marshal tree: %s", err), codes.ErrorDumpingTree) 87 | } 88 | return encryptedFile, nil 89 | } 90 | -------------------------------------------------------------------------------- /cmd/sops/set.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/getsops/sops/v3" 7 | "github.com/getsops/sops/v3/cmd/sops/codes" 8 | "github.com/getsops/sops/v3/cmd/sops/common" 9 | "github.com/getsops/sops/v3/keyservice" 10 | ) 11 | 12 | type setOpts struct { 13 | Cipher sops.Cipher 14 | InputStore sops.Store 15 | OutputStore sops.Store 16 | InputPath string 17 | IgnoreMAC bool 18 | TreePath []interface{} 19 | Value interface{} 20 | KeyServices []keyservice.KeyServiceClient 21 | DecryptionOrder []string 22 | } 23 | 24 | func set(opts setOpts) ([]byte, bool, error) { 25 | // Load the file 26 | // TODO: Issue #173: if the file does not exist, create it with the contents passed in as opts.Value 27 | tree, err := common.LoadEncryptedFileWithBugFixes(common.GenericDecryptOpts{ 28 | Cipher: opts.Cipher, 29 | InputStore: opts.InputStore, 30 | InputPath: opts.InputPath, 31 | IgnoreMAC: opts.IgnoreMAC, 32 | KeyServices: opts.KeyServices, 33 | }) 34 | if err != nil { 35 | return nil, false, err 36 | } 37 | 38 | // Decrypt the file 39 | dataKey, err := common.DecryptTree(common.DecryptTreeOpts{ 40 | Cipher: opts.Cipher, 41 | IgnoreMac: opts.IgnoreMAC, 42 | Tree: tree, 43 | KeyServices: opts.KeyServices, 44 | DecryptionOrder: opts.DecryptionOrder, 45 | }) 46 | if err != nil { 47 | return nil, false, err 48 | } 49 | 50 | // Set the value 51 | var changed bool 52 | tree.Branches[0], changed = tree.Branches[0].Set(opts.TreePath, opts.Value) 53 | 54 | err = common.EncryptTree(common.EncryptTreeOpts{ 55 | DataKey: dataKey, Tree: tree, Cipher: opts.Cipher, 56 | }) 57 | if err != nil { 58 | return nil, false, err 59 | } 60 | 61 | encryptedFile, err := opts.OutputStore.EmitEncryptedFile(*tree) 62 | if err != nil { 63 | return nil, false, common.NewExitError(fmt.Sprintf("Could not marshal tree: %s", err), codes.ErrorDumpingTree) 64 | } 65 | return encryptedFile, changed, err 66 | } 67 | -------------------------------------------------------------------------------- /cmd/sops/subcommand/exec/exec.go: -------------------------------------------------------------------------------- 1 | package exec 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | "strings" 10 | 11 | "github.com/getsops/sops/v3/logging" 12 | 13 | "github.com/sirupsen/logrus" 14 | ) 15 | 16 | const ( 17 | FallbackFilename = "tmp-file" 18 | ) 19 | 20 | var log *logrus.Logger 21 | 22 | func init() { 23 | log = logging.NewLogger("EXEC") 24 | } 25 | 26 | type ExecOpts struct { 27 | Command string 28 | Plaintext []byte 29 | Background bool 30 | SameProcess bool 31 | Pristine bool 32 | Fifo bool 33 | User string 34 | Filename string 35 | Env []string 36 | } 37 | 38 | func GetFile(dir, filename string) *os.File { 39 | // If no filename is provided, create a random one based on FallbackFilename 40 | if filename == "" { 41 | handle, err := os.CreateTemp(dir, FallbackFilename) 42 | if err != nil { 43 | log.Fatal(err) 44 | } 45 | return handle 46 | } 47 | // If a filename is provided, use that one 48 | handle, err := os.Create(filepath.Join(dir, filename)) 49 | if err != nil { 50 | log.Fatal(err) 51 | } 52 | // read+write for owner only 53 | if err = handle.Chmod(0600); err != nil { 54 | log.Fatal(err) 55 | } 56 | return handle 57 | } 58 | 59 | func ExecWithFile(opts ExecOpts) error { 60 | if opts.User != "" { 61 | SwitchUser(opts.User) 62 | } 63 | 64 | if runtime.GOOS == "windows" && opts.Fifo { 65 | log.Warn("no fifos on windows, use --no-fifo next time") 66 | opts.Fifo = false 67 | } 68 | 69 | dir, err := os.MkdirTemp("", ".sops") 70 | if err != nil { 71 | log.Fatal(err) 72 | } 73 | defer os.RemoveAll(dir) 74 | 75 | var filename string 76 | if opts.Fifo { 77 | // fifo handling needs to be async, even opening to write 78 | // will block if there is no reader present 79 | filename = opts.Filename 80 | if filename == "" { 81 | filename = FallbackFilename 82 | } 83 | filename = GetPipe(dir, filename) 84 | go WritePipe(filename, opts.Plaintext) 85 | } else { 86 | // GetFile handles opts.Filename == "" specially, that's why we have 87 | // to pass in opts.Filename without handling the fallback here 88 | handle := GetFile(dir, opts.Filename) 89 | handle.Write(opts.Plaintext) 90 | handle.Close() 91 | filename = handle.Name() 92 | } 93 | 94 | var env []string 95 | if !opts.Pristine { 96 | env = os.Environ() 97 | } 98 | env = append(env, opts.Env...) 99 | 100 | placeholdered := strings.Replace(opts.Command, "{}", filename, -1) 101 | cmd := BuildCommand(placeholdered) 102 | cmd.Env = env 103 | 104 | if opts.Background { 105 | return cmd.Start() 106 | } 107 | 108 | cmd.Stdin = os.Stdin 109 | cmd.Stdout = os.Stdout 110 | cmd.Stderr = os.Stderr 111 | 112 | return cmd.Run() 113 | } 114 | 115 | func ExecWithEnv(opts ExecOpts) error { 116 | if opts.User != "" { 117 | SwitchUser(opts.User) 118 | } 119 | 120 | if runtime.GOOS == "windows" && opts.SameProcess { 121 | return fmt.Errorf("The --same-process flag is not supported on Windows") 122 | } 123 | 124 | var env []string 125 | 126 | if !opts.Pristine { 127 | env = os.Environ() 128 | } 129 | 130 | lines := bytes.Split(opts.Plaintext, []byte("\n")) 131 | for _, line := range lines { 132 | if len(line) == 0 { 133 | continue 134 | } 135 | if line[0] == '#' { 136 | continue 137 | } 138 | env = append(env, string(line)) 139 | } 140 | 141 | env = append(env, opts.Env...) 142 | 143 | if opts.SameProcess { 144 | if opts.Background { 145 | log.Fatal("background is not supported for same-process") 146 | } 147 | 148 | // Note that the call does NOT return, unless an error happens. 149 | return ExecSyscall(opts.Command, env) 150 | } 151 | 152 | cmd := BuildCommand(opts.Command) 153 | cmd.Env = env 154 | 155 | if opts.Background { 156 | return cmd.Start() 157 | } 158 | 159 | cmd.Stdin = os.Stdin 160 | cmd.Stdout = os.Stdout 161 | cmd.Stderr = os.Stderr 162 | 163 | return cmd.Run() 164 | } 165 | -------------------------------------------------------------------------------- /cmd/sops/subcommand/exec/exec_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package exec 5 | 6 | import ( 7 | "os" 8 | "os/exec" 9 | "os/user" 10 | "path/filepath" 11 | "strconv" 12 | "syscall" 13 | ) 14 | 15 | func ExecSyscall(command string, env []string) error { 16 | return syscall.Exec("/bin/sh", []string{"/bin/sh", "-c", command}, env) 17 | } 18 | 19 | func BuildCommand(command string) *exec.Cmd { 20 | return exec.Command("/bin/sh", "-c", command) 21 | } 22 | 23 | func WritePipe(pipe string, contents []byte) { 24 | handle, err := os.OpenFile(pipe, os.O_WRONLY, 0600) 25 | 26 | if err != nil { 27 | os.Remove(pipe) 28 | log.Fatal(err) 29 | } 30 | 31 | handle.Write(contents) 32 | handle.Close() 33 | } 34 | 35 | func GetPipe(dir, filename string) string { 36 | tmpfn := filepath.Join(dir, filename) 37 | err := syscall.Mkfifo(tmpfn, 0600) 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | 42 | return tmpfn 43 | } 44 | 45 | func SwitchUser(username string) { 46 | user, err := user.Lookup(username) 47 | if err != nil { 48 | log.Fatal(err) 49 | } 50 | 51 | uid, _ := strconv.Atoi(user.Uid) 52 | 53 | err = syscall.Setgid(uid) 54 | if err != nil { 55 | log.Fatal(err) 56 | } 57 | 58 | err = syscall.Setuid(uid) 59 | if err != nil { 60 | log.Fatal(err) 61 | } 62 | 63 | err = syscall.Setreuid(uid, uid) 64 | if err != nil { 65 | log.Fatal(err) 66 | } 67 | 68 | err = syscall.Setregid(uid, uid) 69 | if err != nil { 70 | log.Fatal(err) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /cmd/sops/subcommand/exec/exec_windows.go: -------------------------------------------------------------------------------- 1 | package exec 2 | 3 | import ( 4 | "os/exec" 5 | ) 6 | 7 | func ExecSyscall(command string, env []string) error { 8 | log.Fatal("same-process not available on windows") 9 | return nil 10 | } 11 | 12 | func BuildCommand(command string) *exec.Cmd { 13 | return exec.Command("cmd.exe", "/C", command) 14 | } 15 | 16 | func WritePipe(pipe string, contents []byte) { 17 | log.Fatal("fifos are not available on windows") 18 | } 19 | 20 | func GetPipe(dir, filename string) string { 21 | log.Fatal("fifos are not available on windows") 22 | return "" 23 | } 24 | 25 | func SwitchUser(username string) { 26 | log.Fatal("user switching not available on windows") 27 | } 28 | -------------------------------------------------------------------------------- /cmd/sops/subcommand/filestatus/filestatus.go: -------------------------------------------------------------------------------- 1 | package filestatus 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/getsops/sops/v3" 7 | "github.com/getsops/sops/v3/cmd/sops/common" 8 | ) 9 | 10 | // Opts represent the input options for FileStatus 11 | type Opts struct { 12 | InputStore sops.Store 13 | InputPath string 14 | } 15 | 16 | // Status represents the status of a file 17 | type Status struct { 18 | // Encrypted represents whether the file provided is encrypted by SOPS 19 | Encrypted bool `json:"encrypted"` 20 | } 21 | 22 | // FileStatus checks encryption status of a file 23 | func FileStatus(opts Opts) (Status, error) { 24 | encrypted, err := cfs(opts.InputStore, opts.InputPath) 25 | if err != nil { 26 | return Status{}, fmt.Errorf("cannot check file status: %w", err) 27 | } 28 | return Status{Encrypted: encrypted}, nil 29 | } 30 | 31 | // cfs checks and reports on file encryption status. 32 | // 33 | // It tries to decrypt the input file with the provided store. 34 | // It returns true if the file contains sops metadata, false 35 | // if it doesn't or Version or MessageAuthenticationCode are 36 | // not found. 37 | // It reports any error encountered different from 38 | // sops.MetadataNotFound, as that is used to detect a sops 39 | // encrypted file. 40 | func cfs(s sops.Store, inputpath string) (bool, error) { 41 | tree, err := common.LoadEncryptedFile(s, inputpath) 42 | if err != nil && err == sops.MetadataNotFound { 43 | return false, nil 44 | } 45 | if err != nil { 46 | return false, fmt.Errorf("cannot load encrypted file: %w", err) 47 | } 48 | 49 | // NOTE: even if it's a file that sops recognize as containing 50 | // valid metadata, we want to ensure some metadata are present 51 | // to report the file as encrypted. 52 | if tree.Metadata.Version == "" { 53 | return false, nil 54 | } 55 | if tree.Metadata.MessageAuthenticationCode == "" { 56 | return false, nil 57 | } 58 | 59 | return true, nil 60 | } 61 | -------------------------------------------------------------------------------- /cmd/sops/subcommand/filestatus/filestatus_internal_test.go: -------------------------------------------------------------------------------- 1 | package filestatus 2 | 3 | import ( 4 | "path" 5 | "testing" 6 | 7 | "github.com/getsops/sops/v3/cmd/sops/common" 8 | "github.com/getsops/sops/v3/config" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | const repoRoot = "../../../../" 13 | 14 | func fromRepoRoot(p string) string { 15 | return path.Join(repoRoot, p) 16 | } 17 | 18 | func TestFileStatus(t *testing.T) { 19 | tests := []struct { 20 | name string 21 | file string 22 | expectedEncrypted bool 23 | }{ 24 | { 25 | name: "encrypted file should be reported as such", 26 | file: "example.yaml", 27 | expectedEncrypted: true, 28 | }, 29 | { 30 | name: "plain text file should be reported as cleartext", 31 | file: "functional-tests/res/plainfile.yaml", 32 | }, 33 | { 34 | name: "file without mac should be reported as cleartext", 35 | file: "functional-tests/res/plainfile.yaml", 36 | }, 37 | } 38 | 39 | for _, tt := range tests { 40 | t.Run(tt.name, func(t *testing.T) { 41 | f := fromRepoRoot(tt.file) 42 | s := common.DefaultStoreForPath(config.NewStoresConfig(), f) 43 | encrypted, err := cfs(s, f) 44 | require.Nil(t, err, "should not error") 45 | if tt.expectedEncrypted { 46 | require.True(t, encrypted, "file should have been reported as encrypted") 47 | } else { 48 | require.False(t, encrypted, "file should have been reported as cleartext") 49 | } 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /cmd/sops/subcommand/groups/add.go: -------------------------------------------------------------------------------- 1 | package groups 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/getsops/sops/v3" 7 | "github.com/getsops/sops/v3/cmd/sops/common" 8 | "github.com/getsops/sops/v3/keyservice" 9 | ) 10 | 11 | // AddOpts are the options for adding a key group to a SOPS file 12 | type AddOpts struct { 13 | InputPath string 14 | InputStore sops.Store 15 | OutputStore sops.Store 16 | Group sops.KeyGroup 17 | GroupThreshold int 18 | InPlace bool 19 | KeyServices []keyservice.KeyServiceClient 20 | DecryptionOrder []string 21 | } 22 | 23 | // Add adds a key group to a SOPS file 24 | func Add(opts AddOpts) error { 25 | tree, err := common.LoadEncryptedFile(opts.InputStore, opts.InputPath) 26 | if err != nil { 27 | return err 28 | } 29 | dataKey, err := tree.Metadata.GetDataKeyWithKeyServices(opts.KeyServices, opts.DecryptionOrder) 30 | if err != nil { 31 | return err 32 | } 33 | tree.Metadata.KeyGroups = append(tree.Metadata.KeyGroups, opts.Group) 34 | 35 | if opts.GroupThreshold != 0 { 36 | tree.Metadata.ShamirThreshold = opts.GroupThreshold 37 | } 38 | tree.Metadata.UpdateMasterKeysWithKeyServices(dataKey, opts.KeyServices) 39 | output, err := opts.OutputStore.EmitEncryptedFile(*tree) 40 | if err != nil { 41 | return err 42 | } 43 | var outputFile = os.Stdout 44 | if opts.InPlace { 45 | var err error 46 | outputFile, err = os.Create(opts.InputPath) 47 | if err != nil { 48 | return err 49 | } 50 | defer outputFile.Close() 51 | } 52 | outputFile.Write(output) 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /cmd/sops/subcommand/groups/delete.go: -------------------------------------------------------------------------------- 1 | package groups 2 | 3 | import ( 4 | "os" 5 | 6 | "fmt" 7 | 8 | "github.com/getsops/sops/v3" 9 | "github.com/getsops/sops/v3/cmd/sops/common" 10 | "github.com/getsops/sops/v3/keyservice" 11 | ) 12 | 13 | // DeleteOpts are the options for deleting a key group from a SOPS file 14 | type DeleteOpts struct { 15 | InputPath string 16 | InputStore sops.Store 17 | OutputStore sops.Store 18 | Group uint 19 | GroupThreshold int 20 | InPlace bool 21 | KeyServices []keyservice.KeyServiceClient 22 | DecryptionOrder []string 23 | } 24 | 25 | // Delete deletes a key group from a SOPS file 26 | func Delete(opts DeleteOpts) error { 27 | tree, err := common.LoadEncryptedFile(opts.InputStore, opts.InputPath) 28 | if err != nil { 29 | return err 30 | } 31 | dataKey, err := tree.Metadata.GetDataKeyWithKeyServices(opts.KeyServices, opts.DecryptionOrder) 32 | if err != nil { 33 | return err 34 | } 35 | tree.Metadata.KeyGroups = append(tree.Metadata.KeyGroups[:opts.Group], tree.Metadata.KeyGroups[opts.Group+1:]...) 36 | 37 | if opts.GroupThreshold != 0 { 38 | tree.Metadata.ShamirThreshold = opts.GroupThreshold 39 | } 40 | 41 | if len(tree.Metadata.KeyGroups) < tree.Metadata.ShamirThreshold { 42 | return fmt.Errorf("removing this key group will make the Shamir threshold impossible to satisfy: "+ 43 | "Shamir threshold is %d, but we only have %d key groups", tree.Metadata.ShamirThreshold, 44 | len(tree.Metadata.KeyGroups)) 45 | } 46 | 47 | tree.Metadata.UpdateMasterKeysWithKeyServices(dataKey, opts.KeyServices) 48 | output, err := opts.OutputStore.EmitEncryptedFile(*tree) 49 | if err != nil { 50 | return err 51 | } 52 | var outputFile = os.Stdout 53 | if opts.InPlace { 54 | var err error 55 | outputFile, err = os.Create(opts.InputPath) 56 | if err != nil { 57 | return err 58 | } 59 | defer outputFile.Close() 60 | } 61 | outputFile.Write(output) 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /cmd/sops/subcommand/keyservice/keyservice.go: -------------------------------------------------------------------------------- 1 | package keyservice 2 | 3 | import ( 4 | "net" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | 9 | "github.com/getsops/sops/v3/keyservice" 10 | "github.com/getsops/sops/v3/logging" 11 | 12 | "github.com/sirupsen/logrus" 13 | "google.golang.org/grpc" 14 | ) 15 | 16 | var log *logrus.Logger 17 | 18 | func init() { 19 | log = logging.NewLogger("KEYSERVICE") 20 | } 21 | 22 | // Opts are the options the key service server can take 23 | type Opts struct { 24 | Network string 25 | Address string 26 | Prompt bool 27 | } 28 | 29 | // Run runs a SOPS key service server 30 | func Run(opts Opts) error { 31 | lis, err := net.Listen(opts.Network, opts.Address) 32 | if err != nil { 33 | return err 34 | } 35 | defer lis.Close() 36 | grpcServer := grpc.NewServer() 37 | keyservice.RegisterKeyServiceServer(grpcServer, keyservice.Server{ 38 | Prompt: opts.Prompt, 39 | }) 40 | log.Infof("Listening on %s://%s", opts.Network, opts.Address) 41 | 42 | // Close socket if we get killed 43 | sigc := make(chan os.Signal, 1) 44 | signal.Notify(sigc, os.Interrupt, os.Kill, syscall.SIGTERM) 45 | go func(c chan os.Signal) { 46 | sig := <-c 47 | log.Infof("Caught signal %s: shutting down.", sig) 48 | lis.Close() 49 | os.Exit(0) 50 | }(sigc) 51 | return grpcServer.Serve(lis) 52 | } 53 | -------------------------------------------------------------------------------- /cmd/sops/subcommand/publish/publish.go: -------------------------------------------------------------------------------- 1 | package publish 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/getsops/sops/v3" 11 | "github.com/getsops/sops/v3/cmd/sops/codes" 12 | "github.com/getsops/sops/v3/cmd/sops/common" 13 | "github.com/getsops/sops/v3/config" 14 | "github.com/getsops/sops/v3/keyservice" 15 | "github.com/getsops/sops/v3/logging" 16 | "github.com/getsops/sops/v3/publish" 17 | "github.com/getsops/sops/v3/version" 18 | 19 | "github.com/sirupsen/logrus" 20 | ) 21 | 22 | var log *logrus.Logger 23 | 24 | func init() { 25 | log = logging.NewLogger("PUBLISH") 26 | } 27 | 28 | // Opts represents publish options and config 29 | type Opts struct { 30 | Interactive bool 31 | Cipher sops.Cipher 32 | ConfigPath string 33 | InputPath string 34 | KeyServices []keyservice.KeyServiceClient 35 | DecryptionOrder []string 36 | InputStore sops.Store 37 | OmitExtensions bool 38 | Recursive bool 39 | RootPath string 40 | } 41 | 42 | // Run publish operation 43 | func Run(opts Opts) error { 44 | var fileContents []byte 45 | path, err := filepath.Abs(opts.InputPath) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | conf, err := config.LoadDestinationRuleForFile(opts.ConfigPath, opts.InputPath, make(map[string]*string)) 51 | if err != nil { 52 | return err 53 | } 54 | if conf.Destination == nil { 55 | return errors.New("no destination configured for this file") 56 | } 57 | 58 | var destinationPath string 59 | if opts.Recursive { 60 | destinationPath, err = filepath.Rel(opts.RootPath, opts.InputPath) 61 | if err != nil { 62 | return err 63 | } 64 | } else { 65 | _, destinationPath = filepath.Split(path) 66 | } 67 | if opts.OmitExtensions || conf.OmitExtensions { 68 | destinationPath = strings.TrimSuffix(destinationPath, filepath.Ext(path)) 69 | } 70 | 71 | // Check that this is a sops-encrypted file 72 | tree, err := common.LoadEncryptedFile(opts.InputStore, opts.InputPath) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | data := map[string]interface{}{} 78 | 79 | switch conf.Destination.(type) { 80 | case *publish.S3Destination, *publish.GCSDestination: 81 | // Re-encrypt if settings exist to do so 82 | if len(conf.KeyGroups[0]) != 0 { 83 | log.Debug("Re-encrypting tree before publishing") 84 | _, err = common.DecryptTree(common.DecryptTreeOpts{ 85 | Cipher: opts.Cipher, 86 | IgnoreMac: false, 87 | Tree: tree, 88 | KeyServices: opts.KeyServices, 89 | DecryptionOrder: opts.DecryptionOrder, 90 | }) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | diffs := common.DiffKeyGroups(tree.Metadata.KeyGroups, conf.KeyGroups) 96 | keysWillChange := false 97 | for _, diff := range diffs { 98 | if len(diff.Added) > 0 || len(diff.Removed) > 0 { 99 | keysWillChange = true 100 | } 101 | } 102 | if keysWillChange { 103 | fmt.Printf("The following changes will be made to the file's key groups:\n") 104 | common.PrettyPrintDiffs(diffs) 105 | } 106 | 107 | tree.Metadata = sops.Metadata{ 108 | KeyGroups: conf.KeyGroups, 109 | UnencryptedSuffix: conf.UnencryptedSuffix, 110 | EncryptedSuffix: conf.EncryptedSuffix, 111 | Version: version.Version, 112 | ShamirThreshold: conf.ShamirThreshold, 113 | } 114 | 115 | dataKey, errs := tree.GenerateDataKeyWithKeyServices(opts.KeyServices) 116 | if len(errs) > 0 { 117 | err = fmt.Errorf("Could not generate data key: %s", errs) 118 | return err 119 | } 120 | 121 | err = common.EncryptTree(common.EncryptTreeOpts{ 122 | DataKey: dataKey, 123 | Tree: tree, 124 | Cipher: opts.Cipher, 125 | }) 126 | if err != nil { 127 | return err 128 | } 129 | 130 | fileContents, err = opts.InputStore.EmitEncryptedFile(*tree) 131 | if err != nil { 132 | return common.NewExitError(fmt.Sprintf("Could not marshal tree: %s", err), codes.ErrorDumpingTree) 133 | } 134 | } else { 135 | fileContents, err = os.ReadFile(path) 136 | if err != nil { 137 | return fmt.Errorf("could not read file: %s", err) 138 | } 139 | } 140 | case *publish.VaultDestination: 141 | _, err = common.DecryptTree(common.DecryptTreeOpts{ 142 | Cipher: opts.Cipher, 143 | IgnoreMac: false, 144 | Tree: tree, 145 | KeyServices: opts.KeyServices, 146 | DecryptionOrder: opts.DecryptionOrder, 147 | }) 148 | if err != nil { 149 | return err 150 | } 151 | data, err = sops.EmitAsMap(tree.Branches) 152 | if err != nil { 153 | return err 154 | } 155 | } 156 | 157 | if opts.Interactive { 158 | var response string 159 | for response != "y" && response != "n" { 160 | fmt.Printf("uploading %s to %s ? (y/n): ", path, conf.Destination.Path(destinationPath)) 161 | _, err := fmt.Scanln(&response) 162 | if err != nil { 163 | return err 164 | } 165 | } 166 | if response == "n" { 167 | msg := fmt.Sprintf("Publication of %s canceled", path) 168 | if opts.Recursive { 169 | fmt.Println(msg) 170 | return nil 171 | } else { 172 | return errors.New(msg) 173 | } 174 | } 175 | } 176 | 177 | switch dest := conf.Destination.(type) { 178 | case *publish.S3Destination, *publish.GCSDestination: 179 | err = dest.Upload(fileContents, destinationPath) 180 | case *publish.VaultDestination: 181 | err = dest.UploadUnencrypted(data, destinationPath) 182 | } 183 | 184 | if err != nil { 185 | return err 186 | } 187 | 188 | return nil 189 | } 190 | -------------------------------------------------------------------------------- /cmd/sops/subcommand/updatekeys/updatekeys.go: -------------------------------------------------------------------------------- 1 | package updatekeys 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/getsops/sops/v3/cmd/sops/codes" 10 | "github.com/getsops/sops/v3/cmd/sops/common" 11 | "github.com/getsops/sops/v3/config" 12 | "github.com/getsops/sops/v3/keyservice" 13 | ) 14 | 15 | // Opts represents key operation options and config 16 | type Opts struct { 17 | InputPath string 18 | ShamirThreshold int 19 | KeyServices []keyservice.KeyServiceClient 20 | DecryptionOrder []string 21 | Interactive bool 22 | ConfigPath string 23 | InputType string 24 | } 25 | 26 | // UpdateKeys update the keys for a given file 27 | func UpdateKeys(opts Opts) error { 28 | path, err := filepath.Abs(opts.InputPath) 29 | if err != nil { 30 | return err 31 | } 32 | info, err := os.Stat(path) 33 | if err != nil { 34 | return err 35 | } 36 | if info.IsDir() { 37 | return fmt.Errorf("can't operate on a directory") 38 | } 39 | opts.InputPath = path 40 | return updateFile(opts) 41 | } 42 | 43 | func updateFile(opts Opts) error { 44 | sc, err := config.LoadStoresConfig(opts.ConfigPath) 45 | if err != nil { 46 | return err 47 | } 48 | store := common.DefaultStoreForPathOrFormat(sc, opts.InputPath, opts.InputType) 49 | log.Printf("Syncing keys for file %s", opts.InputPath) 50 | tree, err := common.LoadEncryptedFile(store, opts.InputPath) 51 | if err != nil { 52 | return err 53 | } 54 | conf, err := config.LoadCreationRuleForFile(opts.ConfigPath, opts.InputPath, make(map[string]*string)) 55 | if err != nil { 56 | return err 57 | } 58 | if conf == nil { 59 | return fmt.Errorf("The config file %s does not contain any creation rule", opts.ConfigPath) 60 | } 61 | 62 | diffs := common.DiffKeyGroups(tree.Metadata.KeyGroups, conf.KeyGroups) 63 | keysWillChange := false 64 | for _, diff := range diffs { 65 | if len(diff.Added) > 0 || len(diff.Removed) > 0 { 66 | keysWillChange = true 67 | } 68 | } 69 | 70 | // TODO: use conf.ShamirThreshold instead of tree.Metadata.ShamirThreshold in the next line? 71 | // Or make this configurable? 72 | var shamirThreshold = tree.Metadata.ShamirThreshold 73 | if opts.ShamirThreshold != 0 { 74 | shamirThreshold = opts.ShamirThreshold 75 | } 76 | shamirThreshold = min(shamirThreshold, len(conf.KeyGroups)) 77 | var shamirThresholdWillChange = tree.Metadata.ShamirThreshold != shamirThreshold 78 | 79 | if !keysWillChange && !shamirThresholdWillChange { 80 | log.Printf("File %s already up to date", opts.InputPath) 81 | return nil 82 | } 83 | fmt.Printf("The following changes will be made to the file's groups:\n") 84 | common.PrettyPrintShamirDiff(tree.Metadata.ShamirThreshold, shamirThreshold) 85 | common.PrettyPrintDiffs(diffs) 86 | 87 | if opts.Interactive { 88 | var response string 89 | for response != "y" && response != "n" { 90 | fmt.Printf("Is this okay? (y/n):") 91 | _, err = fmt.Scanln(&response) 92 | if err != nil { 93 | return err 94 | } 95 | } 96 | if response == "n" { 97 | log.Printf("File %s left unchanged", opts.InputPath) 98 | return nil 99 | } 100 | } 101 | key, err := tree.Metadata.GetDataKeyWithKeyServices(opts.KeyServices, opts.DecryptionOrder) 102 | if err != nil { 103 | return common.NewExitError(err, codes.CouldNotRetrieveKey) 104 | } 105 | tree.Metadata.KeyGroups = conf.KeyGroups 106 | tree.Metadata.ShamirThreshold = shamirThreshold 107 | errs := tree.Metadata.UpdateMasterKeysWithKeyServices(key, opts.KeyServices) 108 | if len(errs) > 0 { 109 | return fmt.Errorf("error updating one or more master keys: %s", errs) 110 | } 111 | output, err := store.EmitEncryptedFile(*tree) 112 | if err != nil { 113 | return common.NewExitError(fmt.Sprintf("Could not marshal tree: %s", err), codes.ErrorDumpingTree) 114 | } 115 | outputFile, err := os.Create(opts.InputPath) 116 | if err != nil { 117 | return fmt.Errorf("could not open file for writing: %s", err) 118 | } 119 | defer outputFile.Close() 120 | _, err = outputFile.Write(output) 121 | if err != nil { 122 | return fmt.Errorf("error writing to file: %s", err) 123 | } 124 | log.Printf("File %s synced with new keys", opts.InputPath) 125 | return nil 126 | } 127 | 128 | func min(a, b int) int { 129 | if a < b { 130 | return a 131 | } 132 | return b 133 | } 134 | -------------------------------------------------------------------------------- /cmd/sops/unset.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/getsops/sops/v3" 7 | "github.com/getsops/sops/v3/cmd/sops/codes" 8 | "github.com/getsops/sops/v3/cmd/sops/common" 9 | "github.com/getsops/sops/v3/keyservice" 10 | ) 11 | 12 | type unsetOpts struct { 13 | Cipher sops.Cipher 14 | InputStore sops.Store 15 | OutputStore sops.Store 16 | InputPath string 17 | IgnoreMAC bool 18 | TreePath []interface{} 19 | KeyServices []keyservice.KeyServiceClient 20 | DecryptionOrder []string 21 | } 22 | 23 | func unset(opts unsetOpts) ([]byte, error) { 24 | // Load the file 25 | tree, err := common.LoadEncryptedFileWithBugFixes(common.GenericDecryptOpts{ 26 | Cipher: opts.Cipher, 27 | InputStore: opts.InputStore, 28 | InputPath: opts.InputPath, 29 | IgnoreMAC: opts.IgnoreMAC, 30 | KeyServices: opts.KeyServices, 31 | }) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | // Decrypt the file 37 | dataKey, err := common.DecryptTree(common.DecryptTreeOpts{ 38 | Cipher: opts.Cipher, 39 | IgnoreMac: opts.IgnoreMAC, 40 | Tree: tree, 41 | KeyServices: opts.KeyServices, 42 | DecryptionOrder: opts.DecryptionOrder, 43 | }) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | // Unset the value 49 | newBranch, err := tree.Branches[0].Unset(opts.TreePath) 50 | if err != nil { 51 | return nil, err 52 | } 53 | tree.Branches[0] = newBranch 54 | 55 | err = common.EncryptTree(common.EncryptTreeOpts{ 56 | DataKey: dataKey, Tree: tree, Cipher: opts.Cipher, 57 | }) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | encryptedFile, err := opts.OutputStore.EmitEncryptedFile(*tree) 63 | if err != nil { 64 | return nil, common.NewExitError(fmt.Sprintf("Could not marshal tree: %s", err), codes.ErrorDumpingTree) 65 | } 66 | return encryptedFile, err 67 | } 68 | -------------------------------------------------------------------------------- /config/test_resources/example.yaml: -------------------------------------------------------------------------------- 1 | example_key: ENC[AES256_GCM,data:mOtEkS9nmmncpxRBYA==,iv:ecPCSny/r62lJr2DuwvucwK/SGiAWLSbariWob7r4Ek=,tag:3m8Dul5yxqEmunMJom+Cag==,type:str] 2 | example_array: 3 | - ENC[AES256_GCM,data:vYoTCRm2gudQ/XFkIm8=,iv:DOM9XZRFFdty4kjMWvqT4UCiwHr5sd7Z3CiS6Xg4q8Q=,tag:07gBVF4mSeG/7Xs+qBeMew==,type:str] 4 | - ENC[AES256_GCM,data:Wf3LVG1677UaZSzzIYc=,iv:8sQd09YT7BkJSntvKUudfXtjPyqzsPqayu0Qo8v+SHI=,tag:zVz8aWLVox40iEKOSUGW+Q==,type:str] 5 | example_multiline: |- 6 | ENC[AES256_GCM,data:Jw34myS8s3NPrOnPnBdJ67bVyNxKmq7pmp0=,iv:8pjw5k5XpV2x4bM2jpISMHy97b6ffkCFvbtKOiZ22VU=,tag:V3NFD/WtgBDXujNTxst1HQ==,type:str] 7 | example_number: ENC[AES256_GCM,data:/KVkicl+xk6x,iv:M+qJNRywTlnkk28nLV3aO6pszNoLcNlrSs05aPIwgwE=,tag:Zku+tQMIeF/Sqnuy8QW4zg==,type:float] 8 | example: 9 | nested: 10 | values: ENC[AES256_GCM,data:y/Gbd4dFl8X4,iv:zOKYeVFBLEoliQ/WuxgNwui+9Vy1CcR+Eg9Zrs9d2qs=,tag:UUC/23KmH4ZGPT+dhHBYGg==,type:str] 11 | example_booleans: 12 | - ENC[AES256_GCM,data:RYzb/A==,iv:7cQpPBZ/0Woa5X3kdlx+4pOW15EmJMXopelmOGfYd1w=,tag:RT0L/KZJTrHQ9E3fgFfqhA==,type:bool] 13 | - ENC[AES256_GCM,data:H/wjpX4=,iv:EDRADHc4Z65yJzXHpVsWgaqcNPhwEdmSNeRtmi14p0g=,tag:ED3o+9Yv4NkoMg2BeS03Cw==,type:bool] 14 | sops: 15 | lastmodified: '2016-08-02T18:34:06Z' 16 | attention: This section contains key material that should only be modified with 17 | extra care. See `sops -h`. 18 | unencrypted_suffix: _unencrypted 19 | mac: ENC[AES256_GCM,data:n6bRU4NrPaMvQj0nI7j7x+Ihkdvr61SDYKw7atVeTDGj+puvd+/0DuFyeWJADNtlAZq7pasSobUMuA66MrNpavMLc5/tBX+NZbT1cbGD0nBt4y7ztog1MFSay03CDrhbepmWNSs8ihgYtHfxfGiqzTU5sRTM+iEHAiwJ1h7eU4k=,iv:oqO1KMNkWUC+aAxgNV4Kng4CBWzO7wIIswDwbRvlv8g=,tag:uUmgKKFeNGvAzZZLI0RViA==,type:str] 20 | version: '1.13' 21 | pgp: 22 | - fp: E5297818703249D0C60E19E6824612478D1A4CCD 23 | created_at: '2016-08-02T18:34:06Z' 24 | enc: | 25 | -----BEGIN PGP MESSAGE----- 26 | Version: GnuPG v1 27 | 28 | hQIMA2X8rvoeiASBAQ/+MYitjFEMfLz5N6JeZ0IoYAbpJZPWkUavhXF9H4SU9mYi 29 | 8Tp4KgJVRhv7IOsqmuMrxIWZL9++HqEpe+HWWy6luzPrWU7HIPpwGcYRCyWrdt2u 30 | UjBVYMVgLwGMfrWe6sr0yWrVXrLQ2ltPBn8J5vyHDZUYXY33B1vj7F2BuF2oL32J 31 | f9ZTAikiHJI/4Q62P5x7RQrqHqV/Ncczf+w/ni+ZnOwH5kay306DKNwTX64AWoc6 32 | iW/nPCRzIfhWx89BPu+SwGaEwQbHvS7zMLZ/UtarNWAFQ5QmlBW6dyH9Edj5bA7u 33 | BuhxjBT/6ZaXX6iSAl98021eplf6s945vB7gPuzrgW1LM4t6X8M9P0KMMrDujz/S 34 | cm7Vrhm//syT2IJqFR9x/PylbpHhVTwTWZIFvLRRJlM66NYsvxMvJdknovN/889D 35 | I+mQkA8oxtjyof8fgIOvU7ph6yBzRbb838a0Fyu5U5wwrNzBc5N23WNjzNcT1XRy 36 | kl7W7MElHnjzq2jNJIB+XipaV6uoMbaoiYZNi+bpHj6pASCaBko7c8aPYVLruPbZ 37 | G3fCMY1mxwRBYyn6d1sTqzVGR4WBJLc+4ikm+H4O4aa4sv137/cnRRbVNhpgefob 38 | H9SSjSr2gr9YiHgMsG0kdilyTnccYaeljBqOJKUKiWOPk8OBiycNtU+rESagw5rS 39 | XgHpMul9uyxksAQf7VpXJJQYYgk+DlZhkkd/hVu59vxJo2oxxp5aGyBijGSy7mcI 40 | 3FajmsEFKdPaHpbsSDP0/83RHIwVwbQK5N+IcNGHk/rnh7XEJ73vtrsA9muAmoo= 41 | =OxaE 42 | -----END PGP MESSAGE----- 43 | -------------------------------------------------------------------------------- /decrypt/decrypt.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package decrypt is the external API other Go programs can use to decrypt SOPS files. It is the only package in SOPS with 3 | a stable API. 4 | */ 5 | package decrypt // import "github.com/getsops/sops/v3/decrypt" 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "time" 11 | 12 | "github.com/getsops/sops/v3/aes" 13 | "github.com/getsops/sops/v3/cmd/sops/common" 14 | . "github.com/getsops/sops/v3/cmd/sops/formats" // Re-export 15 | "github.com/getsops/sops/v3/config" 16 | ) 17 | 18 | // File is a wrapper around Data that reads a local encrypted 19 | // file and returns its cleartext data in an []byte 20 | func File(path, format string) (cleartext []byte, err error) { 21 | // Read the file into an []byte 22 | encryptedData, err := os.ReadFile(path) 23 | if err != nil { 24 | return nil, fmt.Errorf("Failed to read %q: %w", path, err) 25 | } 26 | 27 | // uses same logic as cli. 28 | formatFmt := FormatForPathOrString(path, format) 29 | return DataWithFormat(encryptedData, formatFmt) 30 | } 31 | 32 | // DataWithFormat is a helper that takes encrypted data, and a format enum value, 33 | // decrypts the data and returns its cleartext in an []byte. 34 | func DataWithFormat(data []byte, format Format) (cleartext []byte, err error) { 35 | 36 | store := common.StoreForFormat(format, config.NewStoresConfig()) 37 | 38 | // Load SOPS file and access the data key 39 | tree, err := store.LoadEncryptedFile(data) 40 | if err != nil { 41 | return nil, err 42 | } 43 | key, err := tree.Metadata.GetDataKey() 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | // Decrypt the tree 49 | cipher := aes.NewCipher() 50 | mac, err := tree.Decrypt(key, cipher) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | // Compute the hash of the cleartext tree and compare it with 56 | // the one that was stored in the document. If they match, 57 | // integrity was preserved 58 | originalMac, err := cipher.Decrypt( 59 | tree.Metadata.MessageAuthenticationCode, 60 | key, 61 | tree.Metadata.LastModified.Format(time.RFC3339), 62 | ) 63 | if err != nil { 64 | return nil, fmt.Errorf("Failed to decrypt original mac: %w", err) 65 | } 66 | if originalMac != mac { 67 | return nil, fmt.Errorf("Failed to verify data integrity. expected mac %q, got %q", originalMac, mac) 68 | } 69 | 70 | return store.EmitPlainFile(tree.Branches) 71 | } 72 | 73 | // Data is a helper that takes encrypted data and a format string, 74 | // decrypts the data and returns its cleartext in an []byte. 75 | // The format string can be `json`, `yaml`, `ini`, `dotenv` or `binary`. 76 | // If the format string is empty, binary format is assumed. 77 | func Data(data []byte, format string) (cleartext []byte, err error) { 78 | formatFmt := FormatFromString(format) 79 | return DataWithFormat(data, formatFmt) 80 | } 81 | -------------------------------------------------------------------------------- /decrypt/example_test.go: -------------------------------------------------------------------------------- 1 | package decrypt 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/getsops/sops/v3/logging" 7 | 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | var log *logrus.Logger 12 | 13 | func init() { 14 | log = logging.NewLogger("DECRYPT") 15 | } 16 | 17 | type configuration struct { 18 | FirstName string `json:"firstName"` 19 | LastName string `json:"lastName"` 20 | Age float64 `json:"age"` 21 | Address struct { 22 | City string `json:"city"` 23 | PostalCode string `json:"postalCode"` 24 | State string `json:"state"` 25 | StreetAddress string `json:"streetAddress"` 26 | } `json:"address"` 27 | PhoneNumbers []struct { 28 | Number string `json:"number"` 29 | Type string `json:"type"` 30 | } `json:"phoneNumbers"` 31 | AnEmptyValue string `json:"anEmptyValue"` 32 | } 33 | 34 | func ExampleFile() { 35 | var ( 36 | confPath string = "./example.json" 37 | cfg configuration 38 | err error 39 | ) 40 | confData, err := File(confPath, "json") 41 | if err != nil { 42 | log.Fatalf("cleartext configuration marshalling failed with error: %v", err) 43 | } 44 | err = json.Unmarshal(confData, &cfg) 45 | if err != nil { 46 | log.Fatalf("cleartext configuration unmarshalling failed with error: %v", err) 47 | } 48 | if cfg.FirstName != "John" || 49 | cfg.LastName != "Smith" || 50 | cfg.Age != 25.4 || 51 | cfg.PhoneNumbers[1].Number != "646 555-4567" { 52 | log.Fatalf("configuration does not contain expected values: %+v", cfg) 53 | } 54 | log.Printf("%+v", cfg) 55 | } 56 | -------------------------------------------------------------------------------- /docs/images/cncf-color-bg.svg: -------------------------------------------------------------------------------- 1 | cncf-color-bg.svg -------------------------------------------------------------------------------- /docs/release.md: -------------------------------------------------------------------------------- 1 | # Release procedure 2 | 3 | This document describes the procedure for releasing a new version of SOPS. It 4 | is intended for maintainers of the project, but may be useful for anyone 5 | interested in the release process. 6 | 7 | ## Overview 8 | 9 | The release is performed by creating a signed tag for the release, and pushing 10 | it to GitHub. This will automatically trigger a GitHub Actions workflow that 11 | builds the binaries, packages, SBOMs, and other artifacts for the release 12 | using [GoReleaser](https://goreleaser.com), and uploads them to GitHub. 13 | 14 | The configuration for GoReleaser is in the file 15 | [`.goreleaser.yaml`](../.goreleaser.yaml). The configuration for the GitHub 16 | Actions workflow is in the file 17 | [`release.yml`](../.github/workflows/release.yml). 18 | 19 | This configuration is quite sophisticated, and ensures at least the following: 20 | 21 | - The release is built for multiple platforms and architectures, including 22 | Linux, macOS, and Windows, and for both AMD64 and ARM64. 23 | - The release includes multiple packages in Debian and RPM formats. 24 | - For every binary, a corresponding SBOM is generated and published. 25 | - For all binaries, a checksum file is generated and signed using 26 | [Cosign](https://docs.sigstore.dev/cosign/overview/) with GitHub OIDC. 27 | - Both Debian and Alpine Docker multi-arch images are built and pushed to GitHub 28 | Container Registry and Quay.io. 29 | - The container images are signed using 30 | [Cosign](https://docs.sigstore.dev/cosign/overview/) with GitHub OIDC. 31 | - [SLSA provenance](https://slsa.dev/provenance/v0.2) metadata is generated for 32 | release artifacts and container images. 33 | 34 | ## Preparation 35 | 36 | - [ ] Ensure that all changes intended for the release are merged into the 37 | `main` branch. At present, this means that all pull requests attached to the 38 | milestone for the release are merged. If there are any pull requests that 39 | should not be included in the release, move them to a different milestone. 40 | - [ ] Create a pull request to update the [`CHANGELOG.md`](../CHANGELOG.md) 41 | file. This should include a summary of all changes since the last release, 42 | including references to any relevant pull requests. 43 | - [ ] In this same pull request, update the version number in `version/version.go` 44 | to the new version number. 45 | - [ ] Get approval for the pull request from at least one other maintainer, and 46 | merge it into `main`. 47 | - [ ] Ensure CI passes on the `main` branch. 48 | 49 | ## Release 50 | 51 | - [ ] Ensure your local copy of the `main` branch is up-to-date: 52 | 53 | ```sh 54 | git checkout main 55 | git pull 56 | ``` 57 | 58 | - [ ] Create a **signed tag** for the release, using the following command: 59 | 60 | ```sh 61 | git tag -s -m 62 | ``` 63 | 64 | where `` is the version number of the release. The version number 65 | should be in the form `vX.Y.Z`, where `X`, `Y`, and `Z` are integers. The 66 | version number should be incremented according to 67 | [semantic versioning](https://semver.org/). 68 | - [ ] Push the tag to GitHub: 69 | 70 | ```sh 71 | git push origin 72 | ``` 73 | 74 | - [ ] Ensure the release is built successfully on GitHub Actions. This will 75 | automatically create a release on GitHub. 76 | -------------------------------------------------------------------------------- /example.ini: -------------------------------------------------------------------------------- 1 | ; ENC[AES256_GCM,data:EexYkXJHDv1E9WwU8wUrBakxAZcnlIkAJQgRFts3alF+5w==,iv:/wd7bCxSkJ85eG69NMq2RH70Dlw5q/diL42GcdTbRYs=,tag:8W4B3JjLYAtJgG3Zi6Hu+A==,type:comment] 2 | [name] 3 | firstName = ENC[AES256_GCM,data:7cWtsg==,iv:rh/1l0yiOE3JmmSJB9CXp4Ctb5en0FcNUZZkLyWsX3c=,tag:d7We6RtSs3AYhA6jan/CoA==,type:str] 4 | lastName = ENC[AES256_GCM,data:Iq50v00=,iv:o+bfCsNBsv/+XdXS4M7TCOTPzG38ft14/Q9/vZvnNvM=,tag:cRivFOZyIpMDXdKA9R+6mA==,type:str] 5 | age = ENC[AES256_GCM,data:aSt9Fg==,iv:z1NjXdRRFs8+dAFkrngS7PyhUSCOTzAmxtzCcfafkAM=,tag:6kpo65TxMu1oKF9d7L1wJg==,type:str] 6 | 7 | [address] 8 | city = ENC[AES256_GCM,data:3hQ6RXAc8ik=,iv:ajAjhQIJiNWy9PGj8ZCI5k+3uw7igweqZ+eiZ9tuvG0=,tag:bGX7PK+/hTQE/1uYmXexyA==,type:str] 9 | postalCode = ENC[AES256_GCM,data:1Ha0avZTe6IflQ==,iv:l8rbQbhSwxr3AJzqe/H8ALrHcXsntcBfkyUGt9/6k/U=,tag:f6s6ObY5QaqmG4CUpV+UKg==,type:str] 10 | state = ENC[AES256_GCM,data:ba0=,iv:P/VsGYbhT8ihRvluWc8HcUDPPigh/IcHXBnj0Xb86AE=,tag:JN7Y77eOaF1+tJjN2cxtnQ==,type:str] 11 | streetAddress = ENC[AES256_GCM,data:LYYluDyJzryoYT2Nkg==,iv:KTmXPVV5tSY8/piTb/uPcfJL2mUqkS1aUI9pukQm1Dc=,tag:AzdSsRjEWx8kzclSZIdIcA==,type:str] 12 | 13 | [phoneNumbers] 14 | home = ENC[AES256_GCM,data:wv3pG8J7wmiotVY6,iv:YdX9RKlN9t0PohEu+Dxws0POUf8hjDMi4PJre+4/lsg=,tag:2XadWng1DxnfMituylFC5g==,type:str] 15 | office = ENC[AES256_GCM,data:Hu3X626TUgV3Ix7i,iv:P1SBjvZPogJih4KLFLcbxeWeZT2aTNXPBtwlC9yZZ70=,tag:4SOkQZw4ynKfrFwrmre6Lw==,type:str] 16 | 17 | [not private] 18 | notsecret_unencrypted = hi there! 19 | 20 | [sops] 21 | pgp__list_0__map_fp = FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4 22 | pgp__list_1__map_created_at = 2019-12-10T22:45:52Z 23 | pgp__list_1__map_fp = D7229043384BCC60326C6FB9D8720D957C3D3074 24 | lastmodified = 2019-12-10T22:45:52Z 25 | pgp__list_0__map_created_at = 2019-12-10T22:45:52Z 26 | pgp__list_0__map_enc = -----BEGIN PGP MESSAGE-----\n\nwcBMAyUpShfNkFB/AQgAGb6tDB1gDtwLcVuENgd/AU1ZjxDbUY46OJzi1fkQ4jyo\nvxfCviqfslYyncVrKab0S9U+alIu8Po8BrHCTwmDkcnPG7HWMcqRo+Nq3JShir3y\ntVMbHuRGykPmGjs7GOUn1WibOisiJjHzLdFOsyNexveBV9AVln7kZazCRlXwbLqc\nyJ9IQsWi1ET6DjtViejiBKblDzZ87WgvL2Z0E8QfYXmrB3GpFa+K4Aqgb3pHkQQo\nGQArNAe8BmrhEXotj0IS/K3v2wjX5Sqli+0dh4qhaXiIeYb16nfc4M1TUo4Vvj7Q\nyWdjufTi+2RIxxUEkIdRg6lDVabm98hLn6/jsQ5c5tLgAeT99N9te4j1eeO62xwy\nz4S+4Xok4Cng9OFHFODz4hh6ah3gYuXRf4qFh++GUaxGwG/iGNSiY7A9eWg1gM+N\nPB6MtiOnhuB15N5mbD2V1xrzlF0fu0K98Vviv75h4eGzYQA=\n=RaGg\n-----END PGP MESSAGE----- 27 | mac = ENC[AES256_GCM,data:5NouZe0uY8581eqXLe31TWYqThPlfePdpIyYDBgj3FwGvPfhiWB+dn/diSt4NpZ9zUNI4VhF2cmNesk8NgZdUK9/N9WbGgmJjjdBXvo0xhvr8RR22AiVCB9ipDlSFqnOMyJzo+CAvpSDbwF1bMU0qxwKW+zpDZV2uLX+px7b2qM=,iv:em3/lIV7eYA46u2kM0bR1/3O6Ga2vRfS5/r2qh0jges=,tag:ow9277u7mFNcs4IdsU4YUQ==,type:str] 28 | pgp__list_1__map_enc = -----BEGIN PGP MESSAGE-----\n\nwYwDXFUltYFwV4MBBAAQYkLcQ1MORGYYprtFwUZPO2J3CBOU4u76qJQod5JXZuAa\n3aCynkyrOHIpgh2cKoyUle4u/FK68mFl9+TxixlFDRxt1CsvMR8dHP0EFJOMSq5U\nNwpcnEh20++3DNiq7bCtS2W68FRh8bVxmhEEXPxW4HLJV4WZSE4pu9B7w9kd5NLg\nAeQyj0ugpILq3IonJdLsgLkg4RGV4GDgFOEqxOCT4uKeSX/gdOXvr5WJzlWJRLjT\nA51CtgnUVXlNO5zfCiFRMXOFyE1FCeBm5Ivkgh4KxkQvwI6jSZpU1rjiz4QYTOGO\n4gA=\n=XdTd\n-----END PGP MESSAGE----- 29 | unencrypted_suffix = _unencrypted 30 | version = 3.5.0 31 | 32 | -------------------------------------------------------------------------------- /example.json: -------------------------------------------------------------------------------- 1 | { 2 | "firstName": "ENC[AES256_GCM,data:f8++3g==,iv:rYuVzzb+C40QlYgO4Dl2V7atZUx0ITBcyb5fUsftKMo=,tag:krquPqa1HQltZqidzNamrA==,type:str]", 3 | "lastName": "ENC[AES256_GCM,data:94a2Q8c=,iv:c3NC7L80UTtbz7gdvPV5oSUwg30lC3Kg82uvRVs5CZw=,tag:kUXRNerUWmSe44mwD4w5uA==,type:str]", 4 | "age": "ENC[AES256_GCM,data:gjwWkw==,iv:XEWFpsyvEsPwr3qqsOJlfZ+vSZdiA+D6DAc6aoq/BS0=,tag:pcnUyMtYFa9v5DB6sNV15w==,type:float]", 5 | "address": { 6 | "city": "ENC[AES256_GCM,data:vSeyQwN1Z9k=,iv:DBmuX4w6w14Z/1b820OE3SM3MPx3oLGAeSoR4CWxdhg=,tag:ClpJZLb4ObIOdDD441clrw==,type:str]", 7 | "postalCode": "ENC[AES256_GCM,data:SZadC4tZh106eg==,iv:z/yWCZTd19j+3cFY5mwVkxY8a7i6veTBnwh4fsw5Kbw=,tag:iel9Pqh0jS0KhjxklXeqIg==,type:str]", 8 | "state": "ENC[AES256_GCM,data:b0Y=,iv:7Ar/Tb7XCDo5ABZNdSNBqGquaGEQF7dNxd1VvW7Nwak=,tag:uEjmOKwPlkYSq4IV1tQjwQ==,type:str]", 9 | "streetAddress": "ENC[AES256_GCM,data:dVFPTRPKOFSJ1plV9w==,iv:08Ks4C1FzFozezKBBYSPEAIkC5DkthDFmMW0R3zVbkI=,tag:waInfXMCAcx5C7avXHahOw==,type:str]" 10 | }, 11 | "phoneNumbers": [ 12 | { 13 | "number": "ENC[AES256_GCM,data:lkUEC7s3qU9AY6W+,iv:KjF9i0K9u7THbb3Bn1adQrIKpv1ZqA3PiJkctgFm3Bw=,tag:LBSqqr+gn15x0Pz5JKMJJQ==,type:str]", 14 | "type": "ENC[AES256_GCM,data:aXBHGg==,iv:ulcjNwVfGFvUtVN8q0h1LMYM5zRDmOsqtoFC1JOHREY=,tag:7vdMoCOD+7IeUHWQqQJ8XA==,type:str]" 15 | }, 16 | { 17 | "number": "ENC[AES256_GCM,data:z9Ujp3n2yXBqPNM1,iv:1nbZrIKozuS2p2AgD5/gHgjMN/VSd8SFeCbWdjy9Cf0=,tag:tfwaYihQDMAsmy/yt6ScLg==,type:str]", 18 | "type": "ENC[AES256_GCM,data:ddkB7Iu6,iv:4t31C5r1zhCpLQ64idoJ8OBC7ocME15zCUXCmgf2ItY=,tag:RmBsmbhQW10oOTDuzfxEaA==,type:str]" 19 | } 20 | ], 21 | "anEmptyValue": "", 22 | "sops": { 23 | "kms": null, 24 | "gcp_kms": null, 25 | "azure_kv": null, 26 | "lastmodified": "2019-12-10T22:45:55Z", 27 | "mac": "ENC[AES256_GCM,data:VoXDgYpIYCvFSLyKGx4c8yk56Mk5GkeIwM8IyUi4RgkKBY/xEIzNUOuMqBzWEOvTTsivF/JtUOrBIsDRxGY0u0qNJK1R5WFuSYr3TA5sdu3ytMcu+mKY4THSJN8uuri/tXVcoF+ywLOS6NRFbHDJPtJGcy1XQwJJAgvdw+sIvQA=,iv:dvk3FYXHr2N6gYIw2OqbYiHn6FfXzuyHqZvJIo6IVGM=,tag:rjnNkJSGpXJ762EyjDltXg==,type:str]", 28 | "pgp": [ 29 | { 30 | "created_at": "2019-12-10T22:45:55Z", 31 | "enc": "-----BEGIN PGP MESSAGE-----\n\nwcBMAyUpShfNkFB/AQgAMSeWf3F8kIm8EFiVgGVQgWGIHUVoolToi8d8lAC8/UdK\ncx9dIqlR43IFvvmCKyNZ6Q+/a1ERc07xLpVp3wmN80sE4NZCGZioThZjp2qNS42e\n/HtLfDu+Rie1eKcXEik40rMDn7d8gaFVOpD3FbzoZUFVm8hN5ChzqQqL1nLy9ZgY\nbthH3Rzt58Z1/sxARLNF2/yUqAEX/YEoL0MxM68Z55kwiMqSZ1rdmLKKfXdJbdoL\nSRrFyi+XaAwr1bTD+BnqHqgmYEEWEfHPDW7e1St/4IS4PU98kKuVLjhBKbfTRUpF\nkzxt+XQV6uDfPzdeOzf+JrFMRaoxTRMpcUi4Jn0vstLgAeSAjSzqkUr6DEVsuS8V\nAqRD4Z9I4HHgy+GYruDQ4kDtTvPgbeUWFma5yz25JOoORZIHaGiEB3T8ZhrFD8VI\ndFLxtxLtCeCg5BhRcrxFgWPQCMK/uCt/GFniNf4Y2OGOHQA=\n=r/lV\n-----END PGP MESSAGE-----", 32 | "fp": "FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4" 33 | }, 34 | { 35 | "created_at": "2019-12-10T22:45:55Z", 36 | "enc": "-----BEGIN PGP MESSAGE-----\n\nwYwDXFUltYFwV4MBBABs0ZEghsU+mdsRFROvlT8ACqk4Ru0Bssw3N+lkSrpR+QyY\nuRgAFNmZgPKz4DhZLHbhOD0zAAHuMCGriXVqtxMYMveX1nbXom+ayBmU0jja+6X2\njwk0MghHS1bgIsGrrhPoD7c3iirdaXSgHKAxwl4bpw5OxW6t91moOtJ4DAyGXdLg\nAeTkQxUyLFEeUbx4xX7kNCm+4Yho4CngDuH4oOD44gSWJsjgU+XiRBuJa50tiloC\nA10yQkWZtuyPOWppO5qOdXqV3XDIeOBl5JSNFuv5B80w8UIYhPPXWpriV1nBveE/\nwAA=\n=Vg44\n-----END PGP MESSAGE-----", 37 | "fp": "D7229043384BCC60326C6FB9D8720D957C3D3074" 38 | } 39 | ], 40 | "unencrypted_suffix": "_unencrypted", 41 | "version": "3.5.0" 42 | } 43 | } -------------------------------------------------------------------------------- /example.txt: -------------------------------------------------------------------------------- 1 | { 2 | "data": "ENC[AES256_GCM,data:p6kOd9e7KOYw47VlNlKa52wPFfbY3xaJYQrO5QDT1LyNvUIVBSRTrJxvn5MCC7vdnTOkcBzWmlr6Z/Q23/sx22++3Y7nXTSgFPQxPVIA8X33OoIsCamNHS8+8JWOReALCf2Cd3rzedu0GWR+/f2YBSHNA3C4nffEDbWbXRyAvcvCv3G4umH+Jh9auWUlfbk3Bx/8LvX6DodcxhQ=,iv:ESrDyOG6qetEWGBNHWRpT6ra1NhpaFH3SnjBSdMj2r0=,tag:aP5vOboB64cJDUls9WKsTA==,type:str]", 3 | "sops": { 4 | "kms": null, 5 | "gcp_kms": null, 6 | "azure_kv": null, 7 | "lastmodified": "2019-12-10T22:44:49Z", 8 | "mac": "ENC[AES256_GCM,data:wN+npCzfJVz6nwZQ40FTPD23Ly1CEiU1N6aDua+Mgj9cH7NwJOklW8QKTs3+q3f4HEkbeuFE6VQN+Jm05Zsj1inGjAdG2MfDurspJl6Jpe5DBKgk3zudAcc66gm4T4Dn3h7zFvNovOl+VEa4+ntaxIoVNugVDq3ZLTj/wMd3XwU=,iv:RadNg2jPeQEkE1F/GzrdcPIZHbxXoZpo+iOHpRGlLhc=,tag:ID8N4xhN7p3N5EYGTkYKxg==,type:str]", 9 | "pgp": [ 10 | { 11 | "created_at": "2019-12-10T22:44:49Z", 12 | "enc": "-----BEGIN PGP MESSAGE-----\n\nwcBMAyUpShfNkFB/AQgANOTnicDHAmqwi76yIm2eAgzm32k34hsPS40vKeCKtbIP\niR91/hDmklYXgR9yL9xgBI0SRTMGySSk9YJ9daZd61JVh1IVuxr93Y8GSxhDldAn\n1Wc2dXJ24x7zxfUs4sfZYCtzXZBUb/eAPLDIkeKPzkVKN4kLdVdccOig/2lOuuVo\nw3Xy+m7cx0VPdsFFzVWok15oHj8n0+J8v6Vnyiyx7yI7xgsynNwpZDUN+K15NyGs\nkaO21AeQnxDWmwo4H93+r10esFYns0kyLOCNwN5/XLskT31f9MCo8H4bBDyeO1lE\nrfLKAn0mh81qKedQLTssjElCLBgY4CpcL9B688P/otLgAeSR+v/JrgslAw+QhiBC\nPxqj4ZUC4KbgFeERieC34sjLWuPgxOUoC769iqiM3ArscWLYG6jYb9Acigwtf5/r\nNkFoXHoZPOD15Ne/ElmCDPowh0aAFCwVp6/ipRc0teELTQA=\n=FyYT\n-----END PGP MESSAGE-----", 13 | "fp": "FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4" 14 | }, 15 | { 16 | "created_at": "2019-12-10T22:44:49Z", 17 | "enc": "-----BEGIN PGP MESSAGE-----\n\nwYwDXFUltYFwV4MBBACebuDSGC4caG1iSviC+IQKXw/mQXmWDDWLA1RDH7/ZEa/2\nqsA0Eb5bsd4Hf5pW6UpK/TDZpFn3eKeCn8wP2G792Ez19UmhwHB+0Zid9Zq76UAZ\n1bcwX2YJeCgd8/OgIxfh7a6MDDz6TDNGL916BIE6kFJwT3Vvm9EzF7vDglE5mtLg\nAeTK87HB76QLJNDI03Q8JYrN4e1S4KzgxOFhkeAv4lmhR+zgCeU3u0ripltx+Hys\ngtiGJSnuoJrYrwhSIO8JOoc2iR0bkuCn5Gf4VirHEUwrIUqgQVsmLQnick1iv+Hk\n3QA=\n=exjM\n-----END PGP MESSAGE-----", 18 | "fp": "D7229043384BCC60326C6FB9D8720D957C3D3074" 19 | } 20 | ], 21 | "unencrypted_suffix": "_unencrypted", 22 | "version": "3.5.0" 23 | } 24 | } -------------------------------------------------------------------------------- /example.yaml: -------------------------------------------------------------------------------- 1 | myapp1: ENC[AES256_GCM,data:zlGNmhTYX5xol4ZZFsiaoGkD73nn,iv:ql9mkhoU1I64E/FJi3iA0HaAe2U3kQVFee2ZLwPnBik=,tag:SqVSfu/JkRrwqidAT/i0pg==,type:str] 2 | app2: 3 | db: 4 | user: ENC[AES256_GCM,data:tQ1l,iv:o9MiMveNYO7T82yDab+4pAt17DO6B4wl8yGy3oFbDb8=,tag:0RjvUUtSQwaUc9SF3RSTZQ==,type:str] 5 | password: ENC[AES256_GCM,data:8Ll+TDCgzQ==,iv:aao0OSVdFwaB9EGZ0O+Wn5bRJ6do7hgiQioqiwDi1w0=,tag:WKj0nuSSPkm2dBOBwinYvg==,type:str] 6 | #ENC[AES256_GCM,data:uPuUQAahmq1xL5L2B0+a2gHSl7zXucjqa4Kr0AgNKTpgHw3IPx2lCoK+,iv:qYxjRmWMir47YKhmrrHwV0atmGjcJ3Dts+xvQ+6skHQ=,tag:dsj+0DsfiDyJNGUmvZwnHg==,type:comment] 7 | key: ENC[AES256_GCM,data:xRjmLiX4BCoSUElToUs5twDq1WNWQNvNMi8yitXly43iGQiwltIs0FsY5u+7fzLepS66oLTGdL0NfWwCGxksYYzUKz5OXRLEatacZP40D71861zu+njmGdXepY0q0q5VOG7ObgIAMMMElVKRIFdjpVgmgUa+/h6R77mEbDztk6lqb28r15XyR6GmdubierTE7aialFzNoC+XO/yk7bnMi0XA/aomj1H5RdZ37LBR2k+rhqXmhkPdGTdJ39t4Ou1Q2Oc4RHRGvgs/EeFqQHcq0AilqXsFIv/PE0bQP564LaOcXK33B+PnoV1D+lXZ7mLOjKlq04c+UojwXjpZeXBr6Ip4H3dAGkPAoQKMtyGwHYzuLCNesxwPv2tPqbxkbVev0AKezGhnPjvCNvRN3S1Y0LPf1atfwzOCBQBhdUTpmXtxCdTNG0cUUZjeIKAyJXDWJooYHlstzDti/dSGMEedOnKq6648Yp7tLNDjAg5CGbDEWjTWehtqgvixUoRdYc2/r/ie3t09XB9h70BzLFDnbNdTNHhg0/aivHeDf8LJ2Co8YvIjLTwlm7GV0mSwzJIoY/2YxXFtw+XFqt+BCt1G8wq3R6OdXZK/+6fUKH/D4EVym5nrGkZIuOCiTB+wpzy09QmZ80Fo+ba0x/1g9ZTMKHk=,iv:ZtzvrO7QSHEOCnKCrIYcaesKnyScV8KaHZr22tUMLlU=,tag:2A3nJBIPF2Q3FlwMYLvG2w==,type:str] 8 | number: ENC[AES256_GCM,data:DX0qiTOWhQvG/w==,iv:ouWsby8JoFwCRj/mLVCnNcYhP2sdyf4h6nwZuGksE7Q=,tag:lPU6AId2JrlquHnYRw+E8Q==,type:float] 9 | an_array: 10 | - ENC[AES256_GCM,data:vyczE8EQr9qHkaM=,iv:sT5jKk3LZ61Zq/neTli5tcnDFxCxY5RuGr2k5oGQWJQ=,tag:1HgaHWfyh6EJLkI1V2kOrw==,type:str] 11 | - ENC[AES256_GCM,data:XtBinnYXR7bx1GY=,iv:KvT9smKVmgMNrab+RzfuWscyvJav2r8j1P08ucNmhgQ=,tag:IllAEvIPPeKOfqi1XbmT8w==,type:str] 12 | - ENC[AES256_GCM,data:gpZ7nwWTGaI+Ti+lk+CPQOoM0ypwK7UMMBUiZAniQHDNJelipqc8hyhNeV+tpJLNaRt74OHs04EX8g==,iv:mKcwVelqLvwVDPjR8NeyMZ7AhsjRgmnYmyEuwPNPrQ8=,tag:vrkoccUfJs105yLCmXYYCw==,type:str] 13 | - ENC[AES256_GCM,data:L9jPh+7+XsdqEpUnFcD4nA==,iv:xyfKjOXVrBDCIQG5786pSu5yvHdl/PK8eVxkIUWoCIw=,tag:Q0wlTV2e2vZeU6eTF5Oacg==,type:str] 14 | somebooleans: 15 | - ENC[AES256_GCM,data:ExiXxg==,iv:K7FUwomqdA7o9lzvNoAMH/wbXs08FextTGGeJKnaatU=,tag:A9UntgvPIcappmeM3jsbdA==,type:bool] 16 | - ENC[AES256_GCM,data:3I0AVdM=,iv:q4YKnRIKufREPmwT4sz8plcsOD6iem/tY3NMUV0STBE=,tag:0w4OMKClWTzjKhqsJZT8JA==,type:bool] 17 | this: 18 | is: 19 | a: 20 | nested: 21 | value: ENC[AES256_GCM,data:oFn5fJS5+slb2sCdLY5SxZ+iWeowWtf4wn9g,iv:MZ7i4tZnfCQhQRUwXV2fYQPIJ0tTUFLiD9xuB+765e8=,tag:ZEjE5jvxE5HkN3mma84pKw==,type:str] 22 | #ENC[AES256_GCM,data:WwWiKtMsD1shPe5kPHOh2bJqQPGHwxa6GYrR1y14wiid,iv:AZPaRyVDOl100PvBPMeq0lt6/O5ZUhzWX5UmWNABWvM=,tag:PBReK6Ap/VHxncn0G4qtEA==,type:comment] 23 | #ENC[AES256_GCM,data:eYRaxgs3vGeS96+ZDV8GYrwbvsrMtnWHOtsT2045tD2mlfOD,iv:/RVNEWuBlxhhY8OlJPbS/81QJukXZu1EWnPUQwrcin4=,tag:DybgrXKGWxoRyIQOlc+UMA==,type:comment] 24 | #ENC[AES256_GCM,data:JXKEWGBg4eeCdeQ=,iv:K5keuEjyekf7a3q7WBOKwljsHGXRdQteJcXeeKvHo28=,tag:60VtIdsy13qSKPIEWHUUNg==,type:comment] 25 | somelist_unencrypted: 26 | - all elements of this list 27 | - remain in clear text 28 | - because of the _unencrypted suffix in the key 29 | nested_unencrypted: 30 | this: 31 | is: 32 | all: going to remain in clear text 33 | sops: 34 | kms: [] 35 | gcp_kms: [] 36 | azure_kv: [] 37 | lastmodified: '2019-12-10T22:45:53Z' 38 | mac: ENC[AES256_GCM,data:WDjMv0eWcyPQzZlr3MppeAMQavN88xv5LzI/9wOlg+WPhRoTdrvgFpWowyWvTdUC/i0ybRQRg2u/Wam0kaqzMDpl/E806Gp9hgJcSneqydDJqPiMh+HpXkXWpc70xbYg8/gc1l7eIfSG7rS1dC2t2je60OAIfC/5zAXrL9KH4Ho=,iv:h0hWhb+46upix6K7hZfNNQoiX7WCapiMTv5I/keZsm4=,tag:v7mVbTN3HWmekol5iaO8FA==,type:str] 39 | pgp: 40 | - created_at: '2019-12-10T22:45:53Z' 41 | enc: |- 42 | -----BEGIN PGP MESSAGE----- 43 | 44 | wcBMAyUpShfNkFB/AQgAE0MaWAQGbTKY7Xg3fDNtzlvnVBkkQRHsLt5kUTu2nAy4 45 | sPX0NRXPVF/tAMxr9mI2fRjKnNBXKpOAecNis85D/QEkfflG8/syGkqiJqy9Nqon 46 | WSm1bNriPfD4PL850688EJe49Xrsz6rVbW5FYZCHMbnPxvmoheMJRxLonW3/eWPy 47 | IjJ9i6Z7W175mv1y7FELOimdQeelynp4r8bOuuq1BhePB4+wJXihw9n0ovuLklpl 48 | Kr2iCmIUibSywlEO/LGQT/VXo6R7xgSN2Xg0RwWfflajYHNhVkHlnNYzkACjvdhj 49 | ph1M5fLGDqPu+ySSe93EyahNhdwKgQ7R9yF8/13+QdLgAeRmrBgh0N54mQQAQyL7 50 | mH/i4SWi4BjgmeHgUeBL4qcm2avg6OUasVJQHlTeA8D+c3TwKSTRVDijN4GBadYJ 51 | Z5/vXKOseeBk5JWCnIHC/MtjOkuPt53nvGzi3lYvW+F0FQA= 52 | =RDyn 53 | -----END PGP MESSAGE----- 54 | fp: FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4 55 | - created_at: '2019-12-10T22:45:53Z' 56 | enc: |- 57 | -----BEGIN PGP MESSAGE----- 58 | 59 | wYwDXFUltYFwV4MBBABpm+tFhFhv3A7A/L/p6nL3HXKKhONrgguYgXA/hhSg4/bD 60 | 1Po5pQhCM4yb3gqWewxgVpGNKFr/Gl+kN9eZ3LXp5nEdhei/aQn7BbWkhph5PKt6 61 | faiEZAL5PNHvktvEQwPsfNJvxe8QT2Z9oFmlueP0n3mCZz3UV9LZHNwOP7XfzdLg 62 | AeRzUGrZK43KavmIjdgPXcd/4ZW14NPgNeGm8+BO4nD26Fvgh+UiCb0TpBiI7WsX 63 | HQGUlFjuR6Vd7Q+vg8B/1Ovm3fUw7uCG5Gc2WeB0M+pXaLYS9bCDL9jisGOkD+Ef 64 | ZAA= 65 | =mqGc 66 | -----END PGP MESSAGE----- 67 | fp: D7229043384BCC60326C6FB9D8720D957C3D3074 68 | unencrypted_suffix: _unencrypted 69 | version: 3.5.0 70 | -------------------------------------------------------------------------------- /examples/all_in_one/.gitignore: -------------------------------------------------------------------------------- 1 | config/secret.json 2 | -------------------------------------------------------------------------------- /examples/all_in_one/README.rst: -------------------------------------------------------------------------------- 1 | All-in-one example 2 | ================== 3 | This directory is an example configuration for SOPS inside of a project. We will cover the files used and relevant scripts for developers. 4 | 5 | This example is optimized for saving developer time by storing all secrets in a single file (e.g. ``secret.enc.json``). 6 | 7 | One downside is any configurations which should be stored side by side might not be. 8 | 9 | Getting started 10 | --------------- 11 | To use this example, run the following: 12 | 13 | .. code:: bash 14 | 15 | # From the `sops` root directory 16 | # Import the test key 17 | gpg --import tests/sops_functional_tests_key.asc 18 | 19 | # Navigate to our example directory 20 | cd examples/all_in_one 21 | 22 | # Decrypt our secrets 23 | bin/decrypt-config.sh 24 | 25 | # Optionally edit a secret 26 | # bin/edit-secret.sh config/secret.enc.json 27 | 28 | # Run a script that uses our decrypted secrets 29 | python main.py 30 | 31 | Storage 32 | ------- 33 | In both development and production, we will be storing the secrets file unencrypted on disk. This is for a few reasons: 34 | 35 | - Can't store file in an encrypted manner because we would need to know the secret to decode it 36 | - Loading it into memory at boot is impractical 37 | 38 | - Requires reimplementing SOPS' decryption logic to multiple languages which increases chance of human error which is bad for security 39 | - If someone uses an automatic process reloader during development, then it could get expensive with AWS 40 | 41 | - We could cache the results from AWS but those secrets would wind up being stored on disk 42 | 43 | As peace of mind, think about this: 44 | 45 | - Unencrypted on disk is fine because if the attacker ever gains access to the server, then they can run ``sops decrypt`` as well. 46 | 47 | Files 48 | ----- 49 | - ``bin/decrypt-config.sh`` - Script to decrypt secret file 50 | - ``bin/edit-config-file.sh`` - Script to edit a secret file and then decrypt it 51 | - ``config/secret.enc.json`` - Catch-all file containing our secrets 52 | - ``config/secret.json`` - Decrypted catch-all secrets file 53 | - ``config/static.py`` - Configuration file which imports secrets 54 | - ``.gitignore`` - Ignore file for decrypted secret file 55 | - ``main.py`` - Example script 56 | 57 | Usage 58 | ----- 59 | Development 60 | ~~~~~~~~~~~ 61 | For development, each developer must have access to the PGP/KMS keys. This means: 62 | 63 | - If we are using PGP, then each developer must have the private key installed on their local machine 64 | - If we are using KMS, then each developer must have AWS access to the appropriate key 65 | 66 | Testing 67 | ~~~~~~~ 68 | For testing in a public CI, we can copy ``secret.enc.json`` to ``secret.json``. This will represent the same structure as ``secret.enc.json`` with an additional ``sops`` key but not reveal any secret information. 69 | 70 | .. 71 | 72 | For convenience, we can run ``CONFIG_COPY_ONLY=TRUE bin/decrypt-config.sh`` which will use ``cp`` rather than ``sops decrypt``. 73 | 74 | For testing in a private CI where we need private information, see the `Production instructions <#production>`_. 75 | 76 | Production 77 | ~~~~~~~~~~ 78 | For production, we have a few options: 79 | 80 | - Build an archive (e.g. ``.tar.gz``) in a private CI which contains the secrets and deploy our service via the archive 81 | - Install PGP private key/KMS credentials on production machine, decrypt secrets during deployment process on production machine 82 | -------------------------------------------------------------------------------- /examples/all_in_one/bin/decrypt-config.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Exit on first error 3 | set -e 4 | 5 | # Define our secret files 6 | secret_files="secret.enc.json" 7 | 8 | # For each of our files in our encrypted config 9 | for file in $secret_files; do 10 | # Determine src and target for our file 11 | src_file="config/$file" 12 | target_file="$(echo "config/$file" | sed -E "s/.enc.json/.json/")" 13 | 14 | # If we only want to copy, then perform a copy 15 | # DEV: We allow `CONFIG_COPY_ONLY` to handle tests in Travis CI 16 | if test "$CONFIG_COPY_ONLY" = "TRUE"; then 17 | cp "$src_file" "$target_file" 18 | # Otherwise, decrypt it 19 | else 20 | sops decrypt "$src_file" > "$target_file" 21 | fi 22 | done 23 | -------------------------------------------------------------------------------- /examples/all_in_one/bin/edit-config-file.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Exit on first error 3 | set -e 4 | 5 | # Define our secret files 6 | secret_files="secret.enc.json" 7 | 8 | # Look up our file 9 | filepath="$1" 10 | if test "$filepath" = ""; then 11 | echo "Expected \`filepath\` but received nothing" 1>&2 12 | echo "Usage: $0 " 1>&2 13 | exit 1 14 | fi 15 | 16 | # If our file is a secret 17 | filename="$(basename "$filepath")" 18 | if echo "$secret_files" | grep "$filename"; then 19 | # Load it into SOPS and run our sync script 20 | sops "$filepath" 21 | bin/decrypt-config.sh 22 | # Otherwise (it's a normal file) 23 | else 24 | # Resolve our editor via `sops` logic 25 | editor="$EDITOR" 26 | if test "$editor" = ""; then 27 | editor="$(which vim nano | head -n 1)" 28 | fi 29 | if test "$editor" = ""; then 30 | echo "Expected \`EDITOR\` environment variable to be defined but it was not" 1>&2 31 | exit 1 32 | fi 33 | 34 | # Edit our file 35 | "$editor" "$filepath" 36 | fi 37 | -------------------------------------------------------------------------------- /examples/all_in_one/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsops/sops/bb75c13f0755ad7eebd8b7a4be3440cdc2883c95/examples/all_in_one/config/__init__.py -------------------------------------------------------------------------------- /examples/all_in_one/config/secret.enc.json: -------------------------------------------------------------------------------- 1 | { 2 | "github_oauth_token": "ENC[AES256_GCM,data:lVe1fLoILE8e/jiah90=,iv:6g350p9Id8+ssrV5lVEF4TWEnR+JfY84iUsh92/9XIQ=,tag:2qojKddpx5lO1iR2dqLZJg==,type:str]", 3 | "sops": { 4 | "kms": null, 5 | "gcp_kms": null, 6 | "azure_kv": null, 7 | "lastmodified": "2019-12-10T22:46:40Z", 8 | "mac": "ENC[AES256_GCM,data:XUVZm8UI26QEbhlIkQDfEWcyhooh/bGQ2eHOTu/ojbE4SBLRpcLtKHt2Bn8Z/72pLV77FmmSXAIwy8+xvzeTmEAZNRMfjrm0ZulBY1VgJLTrvA4Hh9F7H+Pb95xtc8B9SbFAo65L+xBJJ/e6t8QsV0N4crOoq0Su3OuvqXIVa90=,iv:1Vnq304ic+vPZ0as0xLpaO8dMduMrF3Xh9aAHT+VLiA=,tag:37+ami7Z3tQd1EwZKC7sPg==,type:str]", 9 | "pgp": [ 10 | { 11 | "created_at": "2019-12-10T22:46:40Z", 12 | "enc": "-----BEGIN PGP MESSAGE-----\n\nwcBMAyUpShfNkFB/AQgATTwZYaZdIVFnJrzujQvk5x/SqVc5VY367UtStph9pxn5\nkSh2lvPrYHtkbpPkvv5Ug5CUwUbofq4rj3YSO3kVSDu5Z3IrAT+EtnK5PECg2JDT\nIXskkjFegQYp4YtC5UUhjIVIALti/9b3g2skVES/IHHlL+BE9AWj93yF24weu54D\nbwF6PQyGRE7HhZ8uUWEKIGTbJbpDzySk06LJSF33uNbKUViRtHuLu4JNFb22sjzy\nMKzITct/4KVlHAkC4ZHpDlsXmtooBJ8Yk7DK2vEM1POk2pE6ZBQbEgtY0hAOMFGB\nfg+j6cvNsLYWyQrjaB9Vsf4+QPDeTWuv5+cJjvgildLgAeQssqFSnZXumtz/VgB1\nm7Ma4Rov4DLgAeHkV+DJ4vTaGavgHuVj+a53Oa9HuX4p1rNV3ZLUyJi6HmNOWjvw\np8QFzrcRyOCN5N3VkrUyEK9l03NczoI9nt7i6r8jBeHWTQA=\n=Uxc1\n-----END PGP MESSAGE-----", 13 | "fp": "FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4" 14 | }, 15 | { 16 | "created_at": "2019-12-10T22:46:40Z", 17 | "enc": "-----BEGIN PGP MESSAGE-----\n\nwYwDXFUltYFwV4MBBAA5g2pnnh43zql0b1bOa6zw0S6uMSLioFy5Or4Z+aEhywpe\n94unRNqABQdqCtowoUpjJ4vEHxQ+dkHPXpYJejolBKPaKpHJHLrdImkC9nb62jJ8\nthWFitj8ELQnHdmSk+t5kvaNj4nWXSuTTiXd2UodXZcHxH4UI4P7A4aZXKTYytLg\nAeRHhnSBOBYD0rMvFIWKLcR94SZE4LrgluHLYODT4l6Ln4fg5uX2CzKsP/KD4+jI\nPDvITr4Sdnh6m6XEctb4whn0OOKXo+Ca5OJ75/+Kb9SzSbA24uXJ1qriELpTt+Hk\nawA=\n=SIw4\n-----END PGP MESSAGE-----", 18 | "fp": "D7229043384BCC60326C6FB9D8720D957C3D3074" 19 | } 20 | ], 21 | "unencrypted_suffix": "_unencrypted", 22 | "version": "3.5.0" 23 | } 24 | } -------------------------------------------------------------------------------- /examples/all_in_one/config/static.py: -------------------------------------------------------------------------------- 1 | # Load in our dependencies 2 | import json 3 | 4 | # Load in our secrets 5 | with open('config/secret.json', 'r') as file: 6 | secret = json.loads(file.read()) 7 | 8 | # Define our configuration 9 | common = { 10 | 'github_oauth_token': secret['github_oauth_token'], 11 | 'port': 8080, 12 | } 13 | -------------------------------------------------------------------------------- /examples/all_in_one/main.py: -------------------------------------------------------------------------------- 1 | # Load in our dependencies 2 | from __future__ import absolute_import 3 | from config.static import common 4 | 5 | 6 | # Define our main function 7 | def main(): 8 | # Output our configuration 9 | print('Configuration') 10 | print('-------------') 11 | for key in common: 12 | # Example: `port: "8080"` 13 | print('{key}: "{val}"'.format(key=key, val=common[key])) 14 | 15 | 16 | # If this script is being invoked directly, then run our main function 17 | if __name__ == '__main__': 18 | main() 19 | -------------------------------------------------------------------------------- /examples/per_file/.gitignore: -------------------------------------------------------------------------------- 1 | config 2 | config.bak 3 | -------------------------------------------------------------------------------- /examples/per_file/README.rst: -------------------------------------------------------------------------------- 1 | Per-file example 2 | ================ 3 | This directory is an example configuration for SOPS inside of a project. We will cover the files used and relevant scripts for developers. 4 | 5 | This example is optimized for storing sensitive information next to related non-sensitive information (e.g. password next to username). 6 | 7 | The downsides include: 8 | 9 | - Slowing down developers by requiring usage of SOPS for non-sensitive information 10 | - Losing dynamic configurations that rely on reusing variables (e.g. ``test = {'foo': {'bar': common['foo']['bar'], 'baz': false}}``) 11 | 12 | - There might be work arounds via YAML 13 | 14 | Getting started 15 | --------------- 16 | To use this example, run the following 17 | 18 | .. code:: bash 19 | 20 | # From the `sops` root directory 21 | # Import the test key 22 | gpg --import pgp/sops_functional_tests_key.asc 23 | 24 | # Navigate to our example directory 25 | cd examples/per_file 26 | 27 | # Decrypt our secrets 28 | bin/decrypt-config.sh 29 | 30 | # Optionally edit a secret 31 | # bin/edit-secret.sh config.enc/static_github.json 32 | 33 | # Run our script 34 | python main.py 35 | 36 | Storage 37 | ------- 38 | In both development and production, we will be storing the secrets file unencrypted on disk. This is for a few reasons: 39 | 40 | - Can't store file in an encrypted manner because we would need to know the secret to decode it 41 | - Loading it into memory at boot is impractical 42 | 43 | - Requires reimplementing SOPS' decryption logic to multiple languages which increases chance of human error which is bad for security 44 | - If someone uses an automatic process reloader during development, then it could get expensive with AWS 45 | 46 | - We could cache the results from AWS but those secrets would wind up being stored on disk 47 | 48 | As peace of mind, think about this: 49 | 50 | - Unencrypted on disk is fine because if the attacker ever gains access to the server, then they can run ``sops decrypt`` as well. 51 | 52 | Files 53 | ----- 54 | - ``bin/decrypt-config.sh`` - Script to decrypt secret file 55 | - ``bin/edit-config-file.sh`` - Script to edit a secret file and then decrypt it 56 | - ``config`` - Directory containing decrypted secrets 57 | - ``config.bak`` - Backup of ``config`` to prevent accidental data loss 58 | - ``config.enc`` - Directory containing encrypted secrets 59 | 60 | - ``static.py`` - Python script to merge together secrets 61 | - ``static_github.json`` - File containing secrets 62 | 63 | - ``.gitignore`` - Ignore file for ``config`` and ``config.bak`` 64 | - ``main.py`` - Example script 65 | 66 | Usage 67 | ----- 68 | Development 69 | ~~~~~~~~~~~ 70 | For development, each developer must have access to the PGP/KMS keys. This means: 71 | 72 | - If we are using PGP, then each developer must have the private key installed on their local machine 73 | - If we are using KMS, then each developer must have AWS access to the appropriate key 74 | 75 | Testing 76 | ~~~~~~~ 77 | For testing in a public CI, we can copy ``config.enc`` to ``config``. The secret files will have structure with an additional ``sops`` key but not reveal any secret information. 78 | 79 | .. 80 | 81 | For convenience, we can run ``CONFIG_COPY_ONLY=TRUE bin/decrypt-config.sh`` which will use ``ln -s`` rather than ``sops decrypt``. 82 | 83 | For testing in a private CI where we need private information, see the `Production instructions <#production>`_. 84 | 85 | Production 86 | ~~~~~~~~~~ 87 | For production, we have a few options: 88 | 89 | - Build an archive (e.g. ``.tar.gz``) in a private CI which contains the secrets and deploy our service via the archive 90 | - Install PGP private key/KMS credentials on production machine, decrypt secrets during deployment process on production machine 91 | -------------------------------------------------------------------------------- /examples/per_file/bin/decrypt-config.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Exit on first error 3 | set -e 4 | 5 | # Define our secret extenssion 6 | secret_ext=".json" 7 | 8 | # If there is a config directory, then move it to a backup 9 | if test -d config; then 10 | if test -d config.bak; then 11 | rm -r config.bak 12 | fi 13 | mv config/ config.bak/ 14 | fi 15 | 16 | # Create our new config directory 17 | mkdir config 18 | 19 | # For each of our files in our encrypted config 20 | for src_file in config.enc/*; do 21 | # Determine target for our file 22 | src_filename="$(basename "$src_file")" 23 | target_file="config/$src_filename" 24 | 25 | # If the file is our secret, then decrypt it 26 | if echo "$src_filename" | grep -E "${secret_ext}$" && 27 | test "$CONFIG_COPY_ONLY" != "TRUE"; then 28 | sops decrypt "$src_file" > "$target_file" 29 | # Otherwise, symlink to the original file 30 | else 31 | ln -s "../$src_file" "$target_file" 32 | fi 33 | done 34 | -------------------------------------------------------------------------------- /examples/per_file/bin/edit-config-file.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Exit on first error 3 | set -e 4 | 5 | # Localize our filepath 6 | filepath="$1" 7 | if test "$filepath" = ""; then 8 | echo "Expected \`filepath\` but received nothing" 1>&2 9 | echo "Usage: $0 " 1>&2 10 | exit 1 11 | fi 12 | 13 | # Load our file into SOPS and run our sync script 14 | sops "$filepath" 15 | bin/decrypt-config.sh 16 | -------------------------------------------------------------------------------- /examples/per_file/config.enc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsops/sops/bb75c13f0755ad7eebd8b7a4be3440cdc2883c95/examples/per_file/config.enc/__init__.py -------------------------------------------------------------------------------- /examples/per_file/config.enc/static.py: -------------------------------------------------------------------------------- 1 | # Load in our dependencies 2 | import json 3 | 4 | # Define our configuration 5 | # DEV: THE FOLLOWING CONFIGURATIONS SHOULD NOT CONTAIN ANY SECRETS 6 | # THIS FILE IS NOT ENCRYPTED!! 7 | common = { 8 | 'port': 8080, 9 | } 10 | 11 | development = { 12 | 13 | } 14 | 15 | test = { 16 | 17 | } 18 | 19 | production = { 20 | 21 | } 22 | 23 | config = { 24 | 'common': common, 25 | 'development': development, 26 | 'test': test, 27 | 'production': production, 28 | } 29 | 30 | 31 | def walk(item, fn): 32 | """Traverse dicts and lists to update keys via `fn`""" 33 | # If we are looking at a dict, then traverse each of its branches 34 | if isinstance(item, dict): 35 | for key in item: 36 | # Walk our value 37 | walk(item[key], fn) 38 | 39 | # If we are changing our key, then update it 40 | new_key = fn(key) 41 | if new_key != key: 42 | item[new_key] = item[key] 43 | del item[key] 44 | # Otherwise, if we are looking at a list, walk each of its items 45 | elif isinstance(item, list): 46 | for val in item: 47 | walk(val, fn) 48 | 49 | 50 | # Merge all of our static secrets onto our config 51 | # For each of our secrets 52 | secret_files = [ 53 | 'config/static_github.json', 54 | ] 55 | for secret_file in secret_files: 56 | with open(secret_file, 'r') as file: 57 | # Load and parse our JSON 58 | data = json.loads(file.read()) 59 | 60 | # Strip off `_unencrypted` from all keys 61 | walk(data, lambda key: key.replace('_unencrypted', '')) 62 | 63 | # For each of the environments 64 | for env_key in data: 65 | # Load in the respective source and target 66 | env_src = data[env_key] 67 | env_target = config[env_key] 68 | 69 | # Merge info between configs 70 | for key in env_src: 71 | if key in env_target: 72 | raise AssertionError( 73 | 'Expected "{env_key}.{key}" to not be defined already ' 74 | 'but it was. ' 75 | 'Please verify no configs are using the same key' 76 | .format(env_key=env_key, key=key)) 77 | env_target.update(env_src) 78 | -------------------------------------------------------------------------------- /examples/per_file/config.enc/static_github.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "github_username_unencrypted": "twolfson-dev", 4 | "github_password": "ENC[AES256_GCM,data:wtyRyrcYh1AaoA==,iv:lJKpAdaRjC4jAl+wIT6ozSZanSIEJwEyJkLi0aFHlGg=,tag:NrrARDWsoWqOyQ+oyALHAA==,type:str]" 5 | }, 6 | "development": {}, 7 | "test": { 8 | "github_username_unencrypted": "test-user", 9 | "github_password": "ENC[AES256_GCM,data:To6eP3uCm+IQo6KCBalF2Q+7zc4=,iv:GZz08Kz9fjCsIk1edHVZRYgSzfF9smzGbllOU6op+z8=,tag:Zrp5RNck7DXCVffX/zmWIQ==,type:str]" 10 | }, 11 | "production": { 12 | "github_username_unencrypted": "twolfson", 13 | "github_password": "ENC[AES256_GCM,data:AZ+Yef+hheVBQWtQ6Ys=,iv:RMd4iwMCiNkt2zPyLBakdA9+HaJA9hh2qUbbdNcemLo=,tag:ejPV/E3dwubZl6s1NyZnSA==,type:str]" 14 | }, 15 | "sops": { 16 | "kms": null, 17 | "gcp_kms": null, 18 | "azure_kv": null, 19 | "lastmodified": "2019-12-10T22:47:04Z", 20 | "mac": "ENC[AES256_GCM,data:rBKvMg8FroPuKB03JEzRvlnONsca7ByBqoTB18kjXHwr7mmnBYLQP4mWiYPRRRaBDaGtDXx3FH+Igyo84vPwNO2eEN94gv1NpeGqPI1vRUwAkCB3UE3hIQN22zXBEwWO4sUPKzERuG8t5dFF7xO4EyzweZlC+nwtyQUQX78ysGo=,iv:vymJO0wSb+/W4LUiZi9+ov9YVy3la+BopXiqd2X/mbA=,tag:Pm7NolfYB7ELKjHaGdbauQ==,type:str]", 21 | "pgp": [ 22 | { 23 | "created_at": "2019-12-10T22:47:04Z", 24 | "enc": "-----BEGIN PGP MESSAGE-----\n\nwcBMAyUpShfNkFB/AQgAJAeWCsS3nqQ+uDy5gj7Fm7evXn/gY5DW7AWJjkXmHqwx\n+IHVERj9JsfFApoUCPu4Om/KB67uegLWk/GFmAtHcwV2GrsGt1AAIURdNSwYf8P7\nLix9fpRwAUlamXS0wDbGQQyNX3X1rIMu7PnL3KLzvfBQUHaVs3+4GpNXjzUVRGgb\nmjtCTsH3u1VYiHrRz0uoWisPDlvn60fPv0mcYdu17sVpgPnC0qmHh6UJd85dGzfy\nSOisauKYkirOfLSJyVCItFD0GwPVd3mbEcxmIokHmaIznck/Tto3sXcQO4Amc54+\nCjXhk0f/KJbKQQg4mbZMaEMISE+/s3H4mBmk2VADJNLgAeS5csP9GjYrFOAHsgGR\nrDIC4SMd4DTgmuEYSOCh4qLAvJfgmOXA0EoByddCKyrCIHDdyq+lFOQq1Hk+kfl/\nruUZSgCy9OCo5FerieGdW/fWfWsKx2CGKczi0Q/ivuFb2QA=\n=rw7+\n-----END PGP MESSAGE-----", 25 | "fp": "FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4" 26 | }, 27 | { 28 | "created_at": "2019-12-10T22:47:04Z", 29 | "enc": "-----BEGIN PGP MESSAGE-----\n\nwYwDXFUltYFwV4MBBACeF7HYThUNm0osc9SEASAnYLB0/qp6IdA6dc0aNHBZxM3Z\nxiOmbGOfImgwWMDGFP3GYFe3NNt/imzy0V+umVWMYxLYb9Hm3yCdVSb6ks9xJwkp\nXWD7vLXXEuVRsLp+IsmMXSRRJ/shBLIORiOMIAiSv92yluNT8PZF3Nv1hA56Y9Lg\nAeR2GEqpdbw6/tIUPMJUHWQf4XZ14DHgkOF9beAN4qqnf1zgsuVA+YmakiS7L5iY\nA/4OMVnCDJBKTD+l91ID5d4k2bQoU+Dm5AmJqm+I6sX6nViQUgyEyjvi+aeOuuG0\nDQA=\n=0KIM\n-----END PGP MESSAGE-----", 30 | "fp": "D7229043384BCC60326C6FB9D8720D957C3D3074" 31 | } 32 | ], 33 | "unencrypted_suffix": "_unencrypted", 34 | "version": "3.5.0" 35 | } 36 | } -------------------------------------------------------------------------------- /examples/per_file/main.py: -------------------------------------------------------------------------------- 1 | # Load in our dependencies 2 | from __future__ import absolute_import 3 | from config.static import config 4 | 5 | 6 | # Define our main function 7 | def main(): 8 | # Output our configuration 9 | # DEV: We use custom keys for a custom sort 10 | print('Configuration') 11 | print('=============') 12 | for env_key in ('common', 'development', 'test', 'production'): 13 | # Example: `Environment: common` 14 | # Example: `-------------------` 15 | env_str = 'Environment: {env_key}'.format(env_key=env_key) 16 | print(env_str) 17 | print(''.join(['-' for char in env_str])) 18 | 19 | env_config = config[env_key] 20 | for key in sorted(env_config.keys()): 21 | # Example: `port: "8080"` 22 | print('{key}: "{val}"'.format(key=key, val=env_config[key])) 23 | print('') 24 | 25 | 26 | # If this script is being invoked directly, then run our main function 27 | if __name__ == '__main__': 28 | main() 29 | -------------------------------------------------------------------------------- /functional-tests/.sops.yaml: -------------------------------------------------------------------------------- 1 | creation_rules: 2 | - path_regex: test_roundtrip_keygroups.yaml 3 | key_groups: 4 | - pgp: 5 | - FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4 6 | - pgp: 7 | - D7229043384BCC60326C6FB9D8720D957C3D3074 8 | - path_regex: test_roundtrip_keygroups_missing_decryption_key.yaml 9 | key_groups: 10 | - pgp: 11 | - FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4 12 | - pgp: 13 | - B611A2F9F11D0FF82568805119F9B5DAEA91FF86 14 | - path_regex: test_no_keygroups.yaml 15 | - path_regex: test_zero_keygroups.yaml 16 | key_groups: [] 17 | - path_regex: test_empty_keygroup.yaml 18 | key_groups: 19 | - {} 20 | - pgp: FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4 21 | destination_rules: 22 | - s3_bucket: "sops-publish-functional-tests" 23 | s3_prefix: "functional-test/" 24 | path_regex: test_encrypt_publish_s3.json 25 | reencryption_rule: 26 | pgp: B611A2F9F11D0FF82568805119F9B5DAEA91FF86 27 | - vault_path: "functional-test/" 28 | vault_kv_mount_name: "secret/" 29 | vault_kv_version: 2 30 | path_regex: test_encrypt_publish_vault.json 31 | - vault_path: "functional-test-version-1/" 32 | vault_kv_mount_name: "kv/" 33 | vault_kv_version: 1 34 | path_regex: test_encrypt_publish_vault_version_1.json 35 | -------------------------------------------------------------------------------- /functional-tests/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "functional-tests" 3 | version = "0.1.0" 4 | edition = "2021" 5 | authors = ["Adrian Utrilla "] 6 | 7 | [dependencies] 8 | tempfile = "3.20.0" 9 | serde = "1.0" 10 | serde_json = "1.0.140" 11 | serde_yaml = "0.9.34" 12 | serde_derive = "1.0" 13 | lazy_static = "1.5.0" 14 | -------------------------------------------------------------------------------- /functional-tests/bin/editor.rs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsops/sops/bb75c13f0755ad7eebd8b7a4be3440cdc2883c95/functional-tests/bin/editor.rs -------------------------------------------------------------------------------- /functional-tests/res/comments.enc.yaml: -------------------------------------------------------------------------------- 1 | #ENC[AES256_GCM,data:IYA+b4ORDq8u9CBQolipWD4HRqoZyA==,iv:F8ldQqGng+WptHuBkFtjrGM+7sRZCsvd0FHq98lrpAE=,tag:ZHbLU9+CELinf5PhhuIzSQ==,type:comment] 2 | lorem: ENC[AES256_GCM,data:PhmSdTs=,iv:J5ugEWq6RfyNx+5zDXvcTdoQ18YYZkqesDED7LNzou4=,tag:0Qrom6J6aUnZMZzGz5XCxw==,type:str] 3 | #ENC[AES256_GCM,data:HiHCasVRzWUiFxKb3X/AcEeM,iv:bmNg+T91dqGk/CEtVH+FDC53osDCEPmWmJKpLyAU5OM=,tag:bTLDYxQSAfYDCBYccoUokQ==,type:comment] 4 | dolor: ENC[AES256_GCM,data:IgvT,iv:wtPNYbDTARFE810PH6ldOLzCDcAjkB/dzPsZjpgHcko=,tag:zwE8P+AwO1hrHkgF6pTbZw==,type:str] 5 | sops: 6 | kms: [] 7 | gcp_kms: [] 8 | azure_kv: [] 9 | hc_vault: [] 10 | age: [] 11 | lastmodified: '2020-10-07T15:49:13Z' 12 | mac: ENC[AES256_GCM,data:2dhyKdHYSynjXPwYrn9356wA7vRKw+T5qwBenI2vZrgthpQBOCQG4M6f7eeH3VLTxB4mN4CAchb25dsNRoGr6A38VruaSSAhPco3Rh4AlvKSvXuhgRnzZvNxE/bnHX1D4K5cdTb4FsJg/Ue1l7UcWrlrv1s3H3SwLHP/nf+suD0=,iv:6xBYURjjaQzlUOKOrs2NWOChiNFZVAGPJZQZ59MwX3o=,tag:uXD5VYme+c8eHcCc5TD2YA==,type:str] 13 | pgp: 14 | - created_at: '2019-08-29T21:52:32Z' 15 | enc: | 16 | -----BEGIN PGP MESSAGE----- 17 | 18 | hQEMAyUpShfNkFB/AQgAlvpTj0NYqF4mQyIeM7wX2SHLb4U07/flpqDpp2W/30Pz 19 | AHA7sYrgP0l8BrjT2kwtgCN0cdfoIHJudezrNjANp2P5TbP2b9kYYNxpehzB9PFj 20 | FixnCS7Zp8WIt1yXr1TX+ANZoXLopVcRbMaQ5OdH7CN1pNQtMR+R3FR3X/IqKxiU 21 | Do1YLaooRJICUC8LJw2Tb4K+lYnTSqd/HalLGym++ivFvdDB1Ya1GhT1FswXidXK 22 | IRjsOVbxV0q5VeNOR0zxsheOvuHyCje16c7NXJtATJVWtTFABJB8u7CY5HhZSgq+ 23 | rXJHyLHqVLzJ8E4WqHQkMNUlVcrqAz7glZ6xbAhfI9JeAYk5SuBOQOQ4yvASqH4K 24 | b0N3+/abluBY7YPqKuRZBiEtmcYlZ+zIHuOTP1rD/7L5VY8CwE5U8SFlEqwM7nQJ 25 | 6/vtl6qngOFjwt34WrhZzUfLPB/wRV/m1Qv2kr0RNA== 26 | =Ykiw 27 | -----END PGP MESSAGE----- 28 | fp: FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4 29 | unencrypted_suffix: _unencrypted 30 | version: 3.6.1 31 | -------------------------------------------------------------------------------- /functional-tests/res/comments.yaml: -------------------------------------------------------------------------------- 1 | # first comment in file 2 | lorem: ipsum 3 | # this-is-a-comment 4 | dolor: sit -------------------------------------------------------------------------------- /functional-tests/res/comments_list.yaml: -------------------------------------------------------------------------------- 1 | lorem: 2 | - foo 3 | #this-is-a-comment 4 | - bar -------------------------------------------------------------------------------- /functional-tests/res/comments_unencrypted_comments.yaml: -------------------------------------------------------------------------------- 1 | # first comment in file 2 | lorem: ENC[AES256_GCM,data:qVz4paM=,iv:0oGsaw71i3wZKmlyDl8uDhQT9XLvJt3oIyx514X44K8=,tag:acbMS613StWo1IVnKK+5uQ==,type:str] 3 | # this-is-a-comment 4 | dolor: ENC[AES256_GCM,data:21fI,iv:01LXdHZYwLTeyUB1YWIAM6KF8cPPVsw/RuQO+Ab4pgM=,tag:o1xnCIIoccWzdWxB2kZYKg==,type:str] 5 | sops: 6 | kms: [] 7 | gcp_kms: [] 8 | azure_kv: [] 9 | lastmodified: '2019-08-29T22:42:03Z' 10 | mac: ENC[AES256_GCM,data:xKkcsqrAHxyqwgv+IVqx52AmrJdC607Dc/Ughna2e2UnnHteXTw7LGt4d0sSlw8LgjaXpa+T6lQ0MgnMPjgTEa20lbtVtauCDdRCnR7Z/Vdk7t7uLl94+STD7C1H6obnOe4fG6c2cUfDNHoeABLetti2ZBZOSZDkWQeCucLys/s=,iv:k9ODJLNYsBedQKMHcgn0KUXPlunOq5jDFH9BeJOyYRE=,tag:d+Ga71V+gijiWLFZ7BhQgg==,type:str] 11 | pgp: 12 | - created_at: '2019-08-29T22:42:03Z' 13 | enc: |- 14 | -----BEGIN PGP MESSAGE----- 15 | 16 | wcBMAyUpShfNkFB/AQgAMgUtep3vurVXoOI1h4Ovr3YbYz+gRHAPGMAUdXQdX7az 17 | fmh7Eq+6Eye2wpnAaogJ2RtIoYO1F/jkoQO74mgaLXNu/gtr8b6Ejc61sQSyjnjg 18 | N9I50+Bh075TqzToZTo7gOwOlltMjA/UVGD0z+gPHP1MpcVpUvm7C0Ol5L0Co37O 19 | UvLrQJjw1x6ktAawWokw910iX9usXiTj87fYvaqutNKRBfh1LI0Os2H8C9xpJu7q 20 | I60NcqF7JQbAaaumRroQpF2K7RI1nt+qTshPqWzDLfjzlrCVPnJlxludqvHUKzR3 21 | TnJdb4Dsx3o/XEQkZxpl7RZa9SZZuKg3EejTxOj0ZtLgAeRLzLl0gSeMGvkntASq 22 | NQkM4e1p4AjgJOEqrOCw4h0xtdrg9+UczwVpI4rnibRqJZtEhFHRaupX6leAEi1l 23 | FMikSjpGSOB55PVMt6+e4ruokoulnq32VRTi07691uEeowA= 24 | =ft04 25 | -----END PGP MESSAGE----- 26 | fp: FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4 27 | unencrypted_suffix: _unencrypted 28 | version: 3.3.1 29 | -------------------------------------------------------------------------------- /functional-tests/res/multiple_keys.yaml: -------------------------------------------------------------------------------- 1 | message: ENC[AES256_GCM,data:4soxy1Q=,iv:GGOFFLu8aqMEyoOBdMGdevug7E0R8H0iDyXIKQ5ufzM=,tag:954eipkbSu2U64tx2usiBA==,type:str] 2 | sops: 3 | kms: [] 4 | gcp_kms: [] 5 | azure_kv: [] 6 | lastmodified: '2019-08-29T22:22:04Z' 7 | mac: ENC[AES256_GCM,data:Rcyh6DKgpd8WNvLqnPhMRYDGztK/WjNJ+SisCD4qL50wxiYUgwRGs5S9I8kOFm4+8pruOcQyCVIDzyjQHZfM1bKs+CnMJTrp1HDz2vhIKkE8O6EdE5+JbNxwc6ZFQE6YThmx6rz93gMHnDOcrO0XG2guGxTumM5xPkZsEBfpIyQ=,iv:YI0Yc2khjy+q1BxiEBDejgymyAQmBkdyl3fQUm76/1Y=,tag:pf4ZbMy9TrpTA/B2qVEkmw==,type:str] 8 | pgp: 9 | - created_at: '2019-08-29T22:22:04Z' 10 | enc: |- 11 | -----BEGIN PGP MESSAGE----- 12 | 13 | wcBMAyUpShfNkFB/AQgAQH8KFrXipAGaHsKjQecpZ4R5PsWg4nqetgsy6FTcAI3j 14 | KwvBoXapI+2m5aXjAdO4k76ZxGyyXXdwe6ebbuikxCuDl28m86o9xNxbBv5SxbaO 15 | gTxtUR+swcnYRyfVmkd07HhATXQw7b2q4ERNJAVH9zxJcmzNzP6q9eRjePRWXSjn 16 | xxzpnS9kQkevuzJXg5dhVZ9HnsuVEthK88c9H3ZZQt/kg3GLaj2XDhFUt9Xy3POl 17 | 3Q2dE8xzVXzg5rF+0n8H0WpiRJ7hbvW6kvAanKNUDeNLY2shmkKiIzh4W0f2Vl5Q 18 | W1nw23Tga6IQ6mSqLItZB5bYX/C9K33kceV1DYyW+tLgAeTofEdQuueh2myAYmmV 19 | Wjap4eAq4F/gi+EpuOAS4jDaEhPgY+UkojNLl9ow2EkyrIMMJCKDSzuZcmZhwTYl 20 | 7/RSqGHDHuDX5GvXVgnRiqnoDiPsImCDN5/iNmoSVuG9cgA= 21 | =wVy4 22 | -----END PGP MESSAGE----- 23 | fp: FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4 24 | - created_at: '2019-08-29T22:22:04Z' 25 | enc: | 26 | -----BEGIN PGP MESSAGE----- 27 | 28 | hIwDXFUltYFwV4MBA/9/qqKDEbT9oZJqB7Z7R1RAz+TStVUzlE9+M0ScFuguBUpd 29 | 7doLhhgBYa9ygti5pnAN4E1SI97xPhflGZLTp4OtdTg5UGOUwh6BM0BZI1ai6dlK 30 | u4ra0BKOmDzv9SkbTngStXfPPLC9msc6sZe9TER88puqNAZ4DBrTyTtEAmg5wtJe 31 | AecV7hxDzAGleAdmaTC+WHkvg/EgwY/9PaU1IMf6tCx8O/bS8JQhxmptO1aAm5Vr 32 | dO/SV2m9QStMafO6OG1aGpy3zkVytlxGavkUK+dPp5fYbRYGQVfUiGpgRRZEZg== 33 | =NlDf 34 | -----END PGP MESSAGE----- 35 | fp: D7229043384BCC60326C6FB9D8720D957C3D3074 36 | unencrypted_suffix: _unencrypted 37 | version: 3.3.1 38 | -------------------------------------------------------------------------------- /functional-tests/res/no_mac.yaml: -------------------------------------------------------------------------------- 1 | myapp1: ENC[AES256_GCM,data:NjYyOQ1GyFUhy25XX7cz7CZo4tLt,iv:WeWmnZCG7/pm71VMd37ua3LsZ6NnqQzLAyN8ylfYHb8=,tag:zh2hOpxHZ8ON+sJgB+OnTg==,type:str] 2 | sops: 3 | kms: [] 4 | gcp_kms: [] 5 | azure_kv: [] 6 | lastmodified: '2019-08-29T22:23:01Z' 7 | pgp: 8 | - created_at: '2019-08-29T22:23:00Z' 9 | enc: |- 10 | -----BEGIN PGP MESSAGE----- 11 | 12 | wcBMAyUpShfNkFB/AQgABuzcPMJSK+DvnPKZVyYlmDLCM9BobLz/7/F8zyX4O00T 13 | UN7/0Lc+MwZWUOe7lmTVgorDLcfxLONbLg9nxXO+apbb2gRABRYdggosMFaRUn3+ 14 | m+maKQ4yDTIjIvblVa/olN8reD4Bt037mT52IxTZBLDAnyVw45dRTe9mzJ73eTi7 15 | 1Mk5s6s1ZH52SQHtbY4TSqCnOKodn3UkTjnFmDXMSAzCwBDmK/wv+fDufXSkLpeR 16 | AGufxo697as2bQ8mpqgPPTvB04MgXwnTngUQ8BfagyOndgTCrU9YMA1gewJnJJj4 17 | azRP9SOukqtn0c/LpewiOHHk/w00dMLEk0E8DpxMItLgAeQQGaX95PJfl3vuPeRT 18 | 2CA24Qbb4DbgseGimeBG4oS0oCLg4OVDd1tvVtu4+6VGKJr8YvgDk7WpDAK33LoO 19 | QBWfQ5mPIeAN5GVWJ2Mk1qcSQTlO9LmM1vPiWojLjOEvZAA= 20 | =5Gax 21 | -----END PGP MESSAGE----- 22 | fp: FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4 23 | unencrypted_suffix: _unencrypted 24 | version: 3.3.1 25 | -------------------------------------------------------------------------------- /functional-tests/res/plainfile.yaml: -------------------------------------------------------------------------------- 1 | hello: world 2 | -------------------------------------------------------------------------------- /keys/keys.go: -------------------------------------------------------------------------------- 1 | package keys 2 | 3 | // MasterKey provides a way of securing the key used to encrypt the Tree by encrypting and decrypting said key. 4 | type MasterKey interface { 5 | Encrypt(dataKey []byte) error 6 | EncryptIfNeeded(dataKey []byte) error 7 | EncryptedDataKey() []byte 8 | SetEncryptedDataKey([]byte) 9 | Decrypt() ([]byte, error) 10 | NeedsRotation() bool 11 | ToString() string 12 | ToMap() map[string]interface{} 13 | TypeToIdentifier() string 14 | } 15 | -------------------------------------------------------------------------------- /keyservice/client.go: -------------------------------------------------------------------------------- 1 | package keyservice 2 | 3 | import ( 4 | "golang.org/x/net/context" 5 | 6 | "google.golang.org/grpc" 7 | ) 8 | 9 | // LocalClient is a key service client that performs all operations locally 10 | type LocalClient struct { 11 | Server KeyServiceServer 12 | } 13 | 14 | // NewLocalClient creates a new local client 15 | func NewLocalClient() LocalClient { 16 | return LocalClient{Server{}} 17 | } 18 | 19 | // NewCustomLocalClient creates a new local client with a non-default backing 20 | // KeyServiceServer implementation 21 | func NewCustomLocalClient(server KeyServiceServer) LocalClient { 22 | return LocalClient{Server: server} 23 | } 24 | 25 | // Decrypt processes a decrypt request locally 26 | // See keyservice/server.go for more details 27 | func (c LocalClient) Decrypt(ctx context.Context, 28 | req *DecryptRequest, opts ...grpc.CallOption) (*DecryptResponse, error) { 29 | return c.Server.Decrypt(ctx, req) 30 | } 31 | 32 | // Encrypt processes an encrypt request locally 33 | // See keyservice/server.go for more details 34 | func (c LocalClient) Encrypt(ctx context.Context, 35 | req *EncryptRequest, opts ...grpc.CallOption) (*EncryptResponse, error) { 36 | return c.Server.Encrypt(ctx, req) 37 | } 38 | -------------------------------------------------------------------------------- /keyservice/keyservice.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package keyservice implements a gRPC API that can be used by SOPS to encrypt and decrypt the data key using remote 3 | master keys. 4 | */ 5 | package keyservice 6 | 7 | import ( 8 | "fmt" 9 | 10 | "github.com/getsops/sops/v3/age" 11 | "github.com/getsops/sops/v3/azkv" 12 | "github.com/getsops/sops/v3/gcpkms" 13 | "github.com/getsops/sops/v3/hcvault" 14 | "github.com/getsops/sops/v3/keys" 15 | "github.com/getsops/sops/v3/kms" 16 | "github.com/getsops/sops/v3/pgp" 17 | ) 18 | 19 | // KeyFromMasterKey converts a SOPS internal MasterKey to an RPC Key that can be serialized with Protocol Buffers 20 | func KeyFromMasterKey(mk keys.MasterKey) Key { 21 | switch mk := mk.(type) { 22 | case *pgp.MasterKey: 23 | return Key{ 24 | KeyType: &Key_PgpKey{ 25 | PgpKey: &PgpKey{ 26 | Fingerprint: mk.Fingerprint, 27 | }, 28 | }, 29 | } 30 | case *gcpkms.MasterKey: 31 | return Key{ 32 | KeyType: &Key_GcpKmsKey{ 33 | GcpKmsKey: &GcpKmsKey{ 34 | ResourceId: mk.ResourceID, 35 | }, 36 | }, 37 | } 38 | case *hcvault.MasterKey: 39 | return Key{ 40 | KeyType: &Key_VaultKey{ 41 | VaultKey: &VaultKey{ 42 | VaultAddress: mk.VaultAddress, 43 | EnginePath: mk.EnginePath, 44 | KeyName: mk.KeyName, 45 | }, 46 | }, 47 | } 48 | case *kms.MasterKey: 49 | ctx := make(map[string]string) 50 | for k, v := range mk.EncryptionContext { 51 | ctx[k] = *v 52 | } 53 | return Key{ 54 | KeyType: &Key_KmsKey{ 55 | KmsKey: &KmsKey{ 56 | Arn: mk.Arn, 57 | Role: mk.Role, 58 | Context: ctx, 59 | AwsProfile: mk.AwsProfile, 60 | }, 61 | }, 62 | } 63 | case *azkv.MasterKey: 64 | return Key{ 65 | KeyType: &Key_AzureKeyvaultKey{ 66 | AzureKeyvaultKey: &AzureKeyVaultKey{ 67 | VaultUrl: mk.VaultURL, 68 | Name: mk.Name, 69 | Version: mk.Version, 70 | }, 71 | }, 72 | } 73 | case *age.MasterKey: 74 | return Key{ 75 | KeyType: &Key_AgeKey{ 76 | AgeKey: &AgeKey{ 77 | Recipient: mk.Recipient, 78 | }, 79 | }, 80 | } 81 | default: 82 | panic(fmt.Sprintf("Tried to convert unknown MasterKey type %T to keyservice.Key", mk)) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /keyservice/keyservice.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option go_package = "./keyservice"; 4 | 5 | message Key { 6 | oneof key_type { 7 | KmsKey kms_key = 1; 8 | PgpKey pgp_key = 2; 9 | GcpKmsKey gcp_kms_key = 3; 10 | AzureKeyVaultKey azure_keyvault_key = 4; 11 | VaultKey vault_key = 5; 12 | AgeKey age_key = 6; 13 | } 14 | } 15 | 16 | message PgpKey { 17 | string fingerprint = 1; 18 | } 19 | 20 | message KmsKey { 21 | string arn = 1; 22 | string role = 2; 23 | map context = 3; 24 | string aws_profile = 4; 25 | } 26 | 27 | message GcpKmsKey { 28 | string resource_id = 1; 29 | } 30 | 31 | message VaultKey { 32 | string vault_address = 1; 33 | string engine_path = 2; 34 | string key_name = 3; 35 | } 36 | 37 | message AzureKeyVaultKey { 38 | string vault_url = 1; 39 | string name = 2; 40 | string version = 3; 41 | } 42 | 43 | message AgeKey { 44 | string recipient = 1; 45 | } 46 | 47 | message EncryptRequest { 48 | Key key = 1; 49 | bytes plaintext = 2; 50 | } 51 | 52 | message EncryptResponse { 53 | bytes ciphertext = 1; 54 | } 55 | 56 | message DecryptRequest { 57 | Key key = 1; 58 | bytes ciphertext = 2; 59 | } 60 | 61 | message DecryptResponse { 62 | bytes plaintext = 1; 63 | } 64 | 65 | service KeyService { 66 | rpc Encrypt (EncryptRequest) returns (EncryptResponse) {} 67 | rpc Decrypt (DecryptRequest) returns (DecryptResponse) {} 68 | } 69 | -------------------------------------------------------------------------------- /keyservice/keyservice_grpc.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go-grpc. DO NOT EDIT. 2 | // versions: 3 | // - protoc-gen-go-grpc v1.5.1 4 | // - protoc v5.28.3 5 | // source: keyservice/keyservice.proto 6 | 7 | package keyservice 8 | 9 | import ( 10 | context "context" 11 | grpc "google.golang.org/grpc" 12 | codes "google.golang.org/grpc/codes" 13 | status "google.golang.org/grpc/status" 14 | ) 15 | 16 | // This is a compile-time assertion to ensure that this generated file 17 | // is compatible with the grpc package it is being compiled against. 18 | // Requires gRPC-Go v1.64.0 or later. 19 | const _ = grpc.SupportPackageIsVersion9 20 | 21 | const ( 22 | KeyService_Encrypt_FullMethodName = "/KeyService/Encrypt" 23 | KeyService_Decrypt_FullMethodName = "/KeyService/Decrypt" 24 | ) 25 | 26 | // KeyServiceClient is the client API for KeyService service. 27 | // 28 | // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. 29 | type KeyServiceClient interface { 30 | Encrypt(ctx context.Context, in *EncryptRequest, opts ...grpc.CallOption) (*EncryptResponse, error) 31 | Decrypt(ctx context.Context, in *DecryptRequest, opts ...grpc.CallOption) (*DecryptResponse, error) 32 | } 33 | 34 | type keyServiceClient struct { 35 | cc grpc.ClientConnInterface 36 | } 37 | 38 | func NewKeyServiceClient(cc grpc.ClientConnInterface) KeyServiceClient { 39 | return &keyServiceClient{cc} 40 | } 41 | 42 | func (c *keyServiceClient) Encrypt(ctx context.Context, in *EncryptRequest, opts ...grpc.CallOption) (*EncryptResponse, error) { 43 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 44 | out := new(EncryptResponse) 45 | err := c.cc.Invoke(ctx, KeyService_Encrypt_FullMethodName, in, out, cOpts...) 46 | if err != nil { 47 | return nil, err 48 | } 49 | return out, nil 50 | } 51 | 52 | func (c *keyServiceClient) Decrypt(ctx context.Context, in *DecryptRequest, opts ...grpc.CallOption) (*DecryptResponse, error) { 53 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 54 | out := new(DecryptResponse) 55 | err := c.cc.Invoke(ctx, KeyService_Decrypt_FullMethodName, in, out, cOpts...) 56 | if err != nil { 57 | return nil, err 58 | } 59 | return out, nil 60 | } 61 | 62 | // KeyServiceServer is the server API for KeyService service. 63 | // All implementations should embed UnimplementedKeyServiceServer 64 | // for forward compatibility. 65 | type KeyServiceServer interface { 66 | Encrypt(context.Context, *EncryptRequest) (*EncryptResponse, error) 67 | Decrypt(context.Context, *DecryptRequest) (*DecryptResponse, error) 68 | } 69 | 70 | // UnimplementedKeyServiceServer should be embedded to have 71 | // forward compatible implementations. 72 | // 73 | // NOTE: this should be embedded by value instead of pointer to avoid a nil 74 | // pointer dereference when methods are called. 75 | type UnimplementedKeyServiceServer struct{} 76 | 77 | func (UnimplementedKeyServiceServer) Encrypt(context.Context, *EncryptRequest) (*EncryptResponse, error) { 78 | return nil, status.Errorf(codes.Unimplemented, "method Encrypt not implemented") 79 | } 80 | func (UnimplementedKeyServiceServer) Decrypt(context.Context, *DecryptRequest) (*DecryptResponse, error) { 81 | return nil, status.Errorf(codes.Unimplemented, "method Decrypt not implemented") 82 | } 83 | func (UnimplementedKeyServiceServer) testEmbeddedByValue() {} 84 | 85 | // UnsafeKeyServiceServer may be embedded to opt out of forward compatibility for this service. 86 | // Use of this interface is not recommended, as added methods to KeyServiceServer will 87 | // result in compilation errors. 88 | type UnsafeKeyServiceServer interface { 89 | mustEmbedUnimplementedKeyServiceServer() 90 | } 91 | 92 | func RegisterKeyServiceServer(s grpc.ServiceRegistrar, srv KeyServiceServer) { 93 | // If the following call pancis, it indicates UnimplementedKeyServiceServer was 94 | // embedded by pointer and is nil. This will cause panics if an 95 | // unimplemented method is ever invoked, so we test this at initialization 96 | // time to prevent it from happening at runtime later due to I/O. 97 | if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { 98 | t.testEmbeddedByValue() 99 | } 100 | s.RegisterService(&KeyService_ServiceDesc, srv) 101 | } 102 | 103 | func _KeyService_Encrypt_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 104 | in := new(EncryptRequest) 105 | if err := dec(in); err != nil { 106 | return nil, err 107 | } 108 | if interceptor == nil { 109 | return srv.(KeyServiceServer).Encrypt(ctx, in) 110 | } 111 | info := &grpc.UnaryServerInfo{ 112 | Server: srv, 113 | FullMethod: KeyService_Encrypt_FullMethodName, 114 | } 115 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 116 | return srv.(KeyServiceServer).Encrypt(ctx, req.(*EncryptRequest)) 117 | } 118 | return interceptor(ctx, in, info, handler) 119 | } 120 | 121 | func _KeyService_Decrypt_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 122 | in := new(DecryptRequest) 123 | if err := dec(in); err != nil { 124 | return nil, err 125 | } 126 | if interceptor == nil { 127 | return srv.(KeyServiceServer).Decrypt(ctx, in) 128 | } 129 | info := &grpc.UnaryServerInfo{ 130 | Server: srv, 131 | FullMethod: KeyService_Decrypt_FullMethodName, 132 | } 133 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 134 | return srv.(KeyServiceServer).Decrypt(ctx, req.(*DecryptRequest)) 135 | } 136 | return interceptor(ctx, in, info, handler) 137 | } 138 | 139 | // KeyService_ServiceDesc is the grpc.ServiceDesc for KeyService service. 140 | // It's only intended for direct use with grpc.RegisterService, 141 | // and not to be introspected or modified (even as a copy) 142 | var KeyService_ServiceDesc = grpc.ServiceDesc{ 143 | ServiceName: "KeyService", 144 | HandlerType: (*KeyServiceServer)(nil), 145 | Methods: []grpc.MethodDesc{ 146 | { 147 | MethodName: "Encrypt", 148 | Handler: _KeyService_Encrypt_Handler, 149 | }, 150 | { 151 | MethodName: "Decrypt", 152 | Handler: _KeyService_Decrypt_Handler, 153 | }, 154 | }, 155 | Streams: []grpc.StreamDesc{}, 156 | Metadata: "keyservice/keyservice.proto", 157 | } 158 | -------------------------------------------------------------------------------- /keyservice/server_test.go: -------------------------------------------------------------------------------- 1 | package keyservice 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "github.com/stretchr/testify/require" 6 | "testing" 7 | ) 8 | 9 | func TestKmsKeyToMasterKey(t *testing.T) { 10 | 11 | cases := []struct { 12 | description string 13 | expectedArn string 14 | expectedRole string 15 | expectedCtx map[string]string 16 | expectedAwsProfile string 17 | }{ 18 | { 19 | description: "empty context", 20 | expectedArn: "arn:aws:kms:eu-west-1:123456789012:key/d5c90a06-f824-4628-922b-12424571ed4d", 21 | expectedRole: "ExampleRole", 22 | expectedCtx: map[string]string{}, 23 | expectedAwsProfile: "", 24 | }, 25 | { 26 | description: "context with one key-value pair", 27 | expectedArn: "arn:aws:kms:eu-west-1:123456789012:key/d5c90a06-f824-4628-922b-12424571ed4d", 28 | expectedRole: "", 29 | expectedCtx: map[string]string{ 30 | "firstKey": "first value", 31 | }, 32 | expectedAwsProfile: "ExampleProfile", 33 | }, 34 | { 35 | description: "context with three key-value pairs", 36 | expectedArn: "arn:aws:kms:eu-west-1:123456789012:key/d5c90a06-f824-4628-922b-12424571ed4d", 37 | expectedRole: "", 38 | expectedCtx: map[string]string{ 39 | "firstKey": "first value", 40 | "secondKey": "second value", 41 | "thirdKey": "third value", 42 | }, 43 | expectedAwsProfile: "", 44 | }, 45 | } 46 | 47 | for _, c := range cases { 48 | 49 | t.Run(c.description, func(t *testing.T) { 50 | 51 | inputCtx := make(map[string]string) 52 | for k, v := range c.expectedCtx { 53 | inputCtx[k] = v 54 | } 55 | 56 | key := &KmsKey{ 57 | Arn: c.expectedArn, 58 | Role: c.expectedRole, 59 | Context: inputCtx, 60 | AwsProfile: c.expectedAwsProfile, 61 | } 62 | 63 | masterKey := kmsKeyToMasterKey(key) 64 | foundCtx := masterKey.EncryptionContext 65 | 66 | for k := range c.expectedCtx { 67 | require.Containsf(t, foundCtx, k, "Context does not contain expected key '%s'", k) 68 | } 69 | for k := range foundCtx { 70 | require.Containsf(t, c.expectedCtx, k, "Context contains an unexpected key '%s' which cannot be found from expected map", k) 71 | } 72 | for k, expected := range c.expectedCtx { 73 | foundVal := *foundCtx[k] 74 | assert.Equalf(t, expected, foundVal, "Context key '%s' value '%s' does not match expected value '%s'", k, foundVal, expected) 75 | } 76 | assert.Equalf(t, c.expectedArn, masterKey.Arn, "Expected ARN to be '%s', but found '%s'", c.expectedArn, masterKey.Arn) 77 | assert.Equalf(t, c.expectedRole, masterKey.Role, "Expected Role to be '%s', but found '%s'", c.expectedRole, masterKey.Role) 78 | assert.Equalf(t, c.expectedAwsProfile, masterKey.AwsProfile, "Expected AWS profile to be '%s', but found '%s'", c.expectedAwsProfile, masterKey.AwsProfile) 79 | }) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /logging/logging.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fatih/color" 7 | "github.com/sirupsen/logrus" 8 | ) 9 | 10 | func init() { 11 | Loggers = make(map[string]*logrus.Logger) 12 | } 13 | 14 | // TextFormatter extends the standard logrus TextFormatter and adds a field to specify the logger's name 15 | type TextFormatter struct { 16 | LoggerName string 17 | logrus.TextFormatter 18 | } 19 | 20 | // Format formats a log entry onto bytes 21 | func (f *TextFormatter) Format(entry *logrus.Entry) ([]byte, error) { 22 | bytes, err := f.TextFormatter.Format(entry) 23 | name := color.New(color.Bold).Sprintf("[%s]", f.LoggerName) 24 | return []byte(fmt.Sprintf("%s\t %s", name, bytes)), err 25 | } 26 | 27 | // NewLogger is the constructor for a new Logger object with the given name 28 | func NewLogger(name string) *logrus.Logger { 29 | log := logrus.New() 30 | log.SetLevel(logrus.WarnLevel) 31 | log.Formatter = &TextFormatter{ 32 | LoggerName: name, 33 | } 34 | Loggers[name] = log 35 | return log 36 | } 37 | 38 | // SetLevel sets the given level for all current Loggers 39 | func SetLevel(level logrus.Level) { 40 | for k := range Loggers { 41 | Loggers[k].SetLevel(level) 42 | } 43 | } 44 | 45 | // Loggers is the runtime map of logger name to logger object 46 | var Loggers map[string]*logrus.Logger 47 | -------------------------------------------------------------------------------- /pgp/testdata/private.gpg: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PRIVATE KEY BLOCK----- 2 | 3 | lQVYBGJGqrsBDAC7OxFP6Z2E+AkVZpQySjLFAeYJWdnadx0GOHnckOOFkQvVJauz 4 | 9KibgzLUkO9h0oIoP7dLyPEiRPhKgmbrktyCDfysvNeKCgI5XemJCJqCmwA/vWwp 5 | GnVltcgsVVjVZ3vvD8VMfhKF77pkmMDj7mnCPw9x39R8SVpe2K9RO0QLk/Dt+o8t 6 | MO+sXTz4ba4aMdjJvMoaoQKw6RXAouZa4H09i6tiAgXrRLxQDxJ58sGg/ZCWa5G4 7 | aI6PdObY41fzQlcobtifCbktbICVb1Ms1s0iZWttFmr0oTSkJTv3FPWhf6n126w4 8 | LEkF9d6YW+/0H9cqXa4GMfxXg4XBmJNJfYkLDVUlbp3xi+I+Lg1Sit6QlqkW93EW 9 | etpYPK1KmDcW3IA6ausYnkyrcQbt1m5/hh9KJoQb6He/RytXEBxp90+v9y7THZGr 10 | 2U49ZEHQg6DAIj4j1p9NAGgqjKr9am6yk2pvpK3ZWmHQ6CZfCiBrEPCvdmEhrx4U 11 | lj6wyd00YJknpDkAEQEAAQAL/iU3C+1g55TvAks1LP7D/cxn4LP6HpHMfEHoxtwf 12 | FoJNftcamkL2Pe9PSDK1Lke44nMimwnewoNHxzx0KAXqFpdpNVCWZpdC/wctEgbR 13 | ZXjRW17QBWg0IKKbW9LoEfS1EY7GiTZ3lrH1oQxuymRj1rSr+SNu1Jrxr5tLoalZ 14 | SOCuQsTiuUPHxtPxYnWUw3bkco1Cz780QscsRU0ZdAUbOvmZQfMEqO2HJ5EYNdl0 15 | daVM0Uj8z6Wibre4CkyQ/8HT7Qy+Z7fgGzcSfwSzqqVJ5GtJimHK8CfX3HnHovHm 16 | o7MtZGnr9fiug8m3XN8b1zM/ADXbVMXAmQY+QsI44bQPMxocLij3/HL5/oJvXGJ8 17 | rBc5xFbzvclteD2MPTHx7hpZP+ys84LpieMw9Fojk9/k1tfNNJEESVp1OTI42YFA 18 | bmQ52Qgehn6skcs1oAygsSjaoCkhFMnhHVNtOnoSUG+2O7xpaT6cSNV4ySo/0stQ 19 | 85RrEu0skjzChI0D1Tb4o2qI2wYAyUQtUxwN46uqfo/NpzzBHujpizdQ9VJxfof5 20 | 2xCrLZ2AzLAhhpiqUiNTu7PPC+fXh3T1ru4pOg6JZYc6Ok0R3Huu16S51wpK66LE 21 | xgSkzGRET1kZFYg4G99g7jgmhhOyKOeh7J1LL5raOETX7hoPfO9M99tmRSVBuHFr 22 | 48e0SQpb/mf6yYEr5ndsX3uvONo+86rerWDigZhOyvIYY8OyMzof0h7UP1jOg0uG 23 | JGj80rQDdqb48mTZW+d4ZC+1EstzBgDuJcG+Z/ufe2cymUwZUS6gpWuFddhab09a 24 | ZeS4ojlg40Jv8LcBtX29tuFSk228YWWa10u7KOzVSc9MIIVYyfTNhUXyKr+tyAFg 25 | jwVXWqKsDo53uwWClCwB7cvmep0sArR2hx/JdO2zAOrc0Myhx0XhwFdvV3lnNzZr 26 | 8hbXcwLtT/HGJVl3ivmiXyfWo2MZDlY7mAw5+84WaBqcmKe0+ugRFIOhvO9GR5cU 27 | NysXNfEur7qRuEKqFU6BPePg8olc/qMF/3undrcOcPfyME1VG1mKkBJDFek15VxZ 28 | URiLhKyQtDSR1BJKeicGiVPVVeLoqyQvslXihm3c7EPPnz+Q8fQiR4sUWbh2BgPv 29 | Ckv0CP5a4RqiuV9B9pXqew2voJtRB1fU95JWIV08CLlcqLVArZFlxvUAVmQDF64Z 30 | EW+2dvXr42+KwBWIteyblvlirVztknqoO3gyk3AvYe16uX9K1Mu+7xLUByg6Rv83 31 | duS2YUjm6xj/ogYD6wU0zUXQ5Lx0JhKzA+jktBVGbHV4IDxzb3BzQGZsdXhjZC5p 32 | bz6JAc4EEwEIADgWIQS1na9GnoyUgTiQGmSXMgdeoiGn6gUCYkaquwIbAwULCQgH 33 | AgYVCgkICwIEFgIDAQIeAQIXgAAKCRCXMgdeoiGn6ppqDACakBksOfI9xkcV2J4o 34 | KkElDPzPMVlWeuulegHbS8R6srEJDxdR4jUpFVIlDp88xwMurpAZkwJdxzWLWj8N 35 | GuW5Z0s3WuM1h17BYE/ZKGc4pBf7/A2OzDhS8IrqNuE7kNfupPgorVwbuNs5C8ho 36 | w6MP7yqrxVOkWRcz1lx29FJIes+I46P+H/5rAv+4fiGWLj33wHhHpxTo4JWViYyl 37 | 8S3aKN0yrpNqzU/b/cQoEydsNks8cuHBh4QMjH+1sh3s0ery2Z5VPBBccxGe94Sp 38 | vbh1fkhe2LIZKdfrh47WVPJVyUaUJC/JzCcINCNpGjpOxAIM8NxfUIF2k+SlgREN 39 | TQztXAnHYWHcTSP8ojbN8vODka6LMgEtOo0WB/H7/NvBDuc09izP1HwdNM2dTkPu 40 | plbmEdg9NkFgp+H8QgAxHGWVN22gzYaxJVO9PFrpF2D83Zch4zAYt/wYEWC/SK9A 41 | cdbevVdetFPCCV5dSJCWq+e9llnJaxR4bDbTf3EQW7aulg2dBVgEYkaquwEMAMQV 42 | snEOeBV1iX6hCiMWwgjnyS+ggIZN2F15NgM76VMLYMyOt7Y3nkBAFCZgEA9IymrC 43 | UiPSk+YzDtebWgprqAgNgqovSl2c1xuacjuHgpG7DQiV20sb752BMMDDEUY05YyQ 44 | a5NmCUqJIB2F0mxtIqbUpPgdHLSidgRX/5VjugKSlkD+JqURIpW6lmAaJ5RgbWbX 45 | Puuy8yFsHtd75jp5fQFDSMxSG30ZBQAky+4vw8zTxbOLdXS3FrZr6YvLfmAMafoG 46 | aZKAKEOxAZCp152JxUm6yvgXGIlgDDPLHyt5tpWwi98vw633NVE3MkKwic0HuSHE 47 | PXemwZJi3z4yaBsofv7HCo9KtGx4I+t/cqEk8qri3dPqsEkiWKfN3FdzKndgd894 48 | GtFlO22y9/8pcjpeG3ErTq4rTmo3lkLnxbGxgENDoPEcJ8Q/xu+ZAdjqs5kRnivQ 49 | 57Qk0KCRU8HrzBDBWs13Sac8qbgR+Hvyq29UQJOQo0phKVOfb6oqym9ZsB8q7wAR 50 | AQABAAv/VMgu2exQJrMl6o8V03slFXWmywWCXM+u1CezH23ZojMCvR+eNlbRAWXT 51 | cI5Lk1g9UTDJFD0Z/sgnzDibE3Nd+XFiBFSjOlu0tHYwmyWp4nn2ljY5Vb3z+m2g 52 | F1CgmPMJJ6BQKzDMpqIotSsmAwSjHXBHDhKEVWQDVDh6RW0TwcYA2oQpUGjaw9Oj 53 | 7lSQtXqGAxfhWEcNEe/uW+xx7OmXj6K4iMOdqBbXzyqZ1FhpuBf+3PVZKUh6tRBu 54 | sCeh8kSbEOh4xpPFcs17EAcZfXTfdo9vtqjQkRUASuEzctR/91qI3c7IPMFilSHo 55 | HdVQLyUiJTuY0k/00Je1QtPgh7lZtffkq6Bd11I53cfD+44l7g7Lcc2zFzuHjpyp 56 | F+SDBs3tD8uKqbam8Hnop49/BRe1mgNdzobUEem39zKNSXWqUFemr15sAW2J4SKM 57 | m657y0hdpGDE/QlD0ruE6sFFa8zk+92UElnzgyLl69YO139Sbjm+jSZfMVxm+nO6 58 | vrW16U75BgDIIH6PgZshXxMO1LzWxMP+d+6MWKpgeWYes/tkI6bfmM+LBh5/zzd2 59 | lV+9Zae/YJYMn30BrpWfE1uVcWVxlAilHYM92wtH8CjXIYkLhez5uTQQ7UKcLqyS 60 | 3nmO+u5ADqwPeaygS+gTTWhRFX0F5dTYhXFQABiK94lfyMJ8tzmjAuwWxPINLL0m 61 | IdilK8crZayDarDBe2amiP/gG6W7zzIV8DvJn2ZZtlraFxTV8+mqq9Lh0cKJHT6/ 62 | 1/Lt90eubhcGAPrUTKv/jvMEZWlsw1HNzPfP+VyAtebmgDY0o2Rkpikaie/bsnAR 63 | umPuRdGNMct5UAG8WLrgO/RIFb0dsJy1CA+zvZdluAHFaq89ikwFrvcvegc0325w 64 | Iom8xiH0C9pLPiPcyFoFedQ3ZRaQB4oLhhikNDvD2ANA9HIY+k7dpfXP8LWY5jSs 65 | UM3NdXC8RIdBW7DllfDNjWi/xaAyBDxcXRBPuWjIYWlLcHjmqablB/xdmZgIEJoU 66 | VMPCRf7JAl3I6QX9Htkv4ocJyzRDxmhTuFdc7YOc3zmTqwx7/q+kuJDGROA1VeFT 67 | HCtWrSF7Ax5WNIIRRFH1AEk8j//2yoycVCWKNMXV0d8xeHblyGVX+Huhq8rsokNU 68 | MFbDY4wFDTzTK8F8fCa6Z0Q1nts6HOf5ZXv2xYMjyh93gJKF2/NhobX7noDMe/oR 69 | CUzbd6Ogg3JLqnlrfIhR/Kh3yk+w/FhGRiQsV1rIqx4FrWvA3CTkr2zHTAH/gQvt 70 | 1CKrnh3iKiqJ9uV26yiJAbYEGAEIACAWIQS1na9GnoyUgTiQGmSXMgdeoiGn6gUC 71 | YkaquwIbDAAKCRCXMgdeoiGn6jSVDACYkZWrhX/TM6bBVCGvhzl3EmwHqMuMT/Qx 72 | N5Sc5QVawRD36+L/yuFYzK+MK9s9p5Z/9VmTsO/KQxcaPiuYub5vsJ38AxsaSiPE 73 | VCtXY1QH0R3AYMh7tCGW+qhyf8IZyynkiOIZmo8PdrSwRnBCWGPvHYqJEr7c5LJD 74 | 0RYZFwR+ujPhr5mavERVziF2EfUor33la5vpax+CD+XLeMQaWorGegFN6wEpoGoQ 75 | 1rP10xtM+txU7/w0fkYHaEzvQfnRN4QVNg/EgQx7U+HyklAM36tGYgj2CRF5qm+K 76 | Whv+ipymfmAngrjNMqcM15uXi1MF3UGFG7QkbKUBqpeK9UfG4lnZKHcSwhffcgL6 77 | clz1mGfriCEJvw9CfvlLm7RDM2m/MRxFr2yNQgpIJFoXgDVCthBCuH1dIMvhgCYA 78 | frIIYzTK2ZKLJlTv3O8SCTf1Zhjru2f3z85YAqOXmQUGYKrQZL2T9NE2mQnXL0n+ 79 | sgS+XwT2h+fdCBJHJYmboxXpxC02xHY= 80 | =G8JF 81 | -----END PGP PRIVATE KEY BLOCK----- 82 | -------------------------------------------------------------------------------- /pgp/testdata/public.gpg: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | mQGNBGJGqrsBDAC7OxFP6Z2E+AkVZpQySjLFAeYJWdnadx0GOHnckOOFkQvVJauz 4 | 9KibgzLUkO9h0oIoP7dLyPEiRPhKgmbrktyCDfysvNeKCgI5XemJCJqCmwA/vWwp 5 | GnVltcgsVVjVZ3vvD8VMfhKF77pkmMDj7mnCPw9x39R8SVpe2K9RO0QLk/Dt+o8t 6 | MO+sXTz4ba4aMdjJvMoaoQKw6RXAouZa4H09i6tiAgXrRLxQDxJ58sGg/ZCWa5G4 7 | aI6PdObY41fzQlcobtifCbktbICVb1Ms1s0iZWttFmr0oTSkJTv3FPWhf6n126w4 8 | LEkF9d6YW+/0H9cqXa4GMfxXg4XBmJNJfYkLDVUlbp3xi+I+Lg1Sit6QlqkW93EW 9 | etpYPK1KmDcW3IA6ausYnkyrcQbt1m5/hh9KJoQb6He/RytXEBxp90+v9y7THZGr 10 | 2U49ZEHQg6DAIj4j1p9NAGgqjKr9am6yk2pvpK3ZWmHQ6CZfCiBrEPCvdmEhrx4U 11 | lj6wyd00YJknpDkAEQEAAbQVRmx1eCA8c29wc0BmbHV4Y2QuaW8+iQHOBBMBCAA4 12 | FiEEtZ2vRp6MlIE4kBpklzIHXqIhp+oFAmJGqrsCGwMFCwkIBwIGFQoJCAsCBBYC 13 | AwECHgECF4AACgkQlzIHXqIhp+qaagwAmpAZLDnyPcZHFdieKCpBJQz8zzFZVnrr 14 | pXoB20vEerKxCQ8XUeI1KRVSJQ6fPMcDLq6QGZMCXcc1i1o/DRrluWdLN1rjNYde 15 | wWBP2ShnOKQX+/wNjsw4UvCK6jbhO5DX7qT4KK1cG7jbOQvIaMOjD+8qq8VTpFkX 16 | M9ZcdvRSSHrPiOOj/h/+awL/uH4hli4998B4R6cU6OCVlYmMpfEt2ijdMq6Tas1P 17 | 2/3EKBMnbDZLPHLhwYeEDIx/tbId7NHq8tmeVTwQXHMRnveEqb24dX5IXtiyGSnX 18 | 64eO1lTyVclGlCQvycwnCDQjaRo6TsQCDPDcX1CBdpPkpYERDU0M7VwJx2Fh3E0j 19 | /KI2zfLzg5GuizIBLTqNFgfx+/zbwQ7nNPYsz9R8HTTNnU5D7qZW5hHYPTZBYKfh 20 | /EIAMRxllTdtoM2GsSVTvTxa6Rdg/N2XIeMwGLf8GBFgv0ivQHHW3r1XXrRTwgle 21 | XUiQlqvnvZZZyWsUeGw2039xEFu2rpYNuQGNBGJGqrsBDADEFbJxDngVdYl+oQoj 22 | FsII58kvoICGTdhdeTYDO+lTC2DMjre2N55AQBQmYBAPSMpqwlIj0pPmMw7Xm1oK 23 | a6gIDYKqL0pdnNcbmnI7h4KRuw0IldtLG++dgTDAwxFGNOWMkGuTZglKiSAdhdJs 24 | bSKm1KT4HRy0onYEV/+VY7oCkpZA/ialESKVupZgGieUYG1m1z7rsvMhbB7Xe+Y6 25 | eX0BQ0jMUht9GQUAJMvuL8PM08Wzi3V0txa2a+mLy35gDGn6BmmSgChDsQGQqded 26 | icVJusr4FxiJYAwzyx8rebaVsIvfL8Ot9zVRNzJCsInNB7khxD13psGSYt8+Mmgb 27 | KH7+xwqPSrRseCPrf3KhJPKq4t3T6rBJIlinzdxXcyp3YHfPeBrRZTttsvf/KXI6 28 | XhtxK06uK05qN5ZC58WxsYBDQ6DxHCfEP8bvmQHY6rOZEZ4r0Oe0JNCgkVPB68wQ 29 | wVrNd0mnPKm4Efh78qtvVECTkKNKYSlTn2+qKspvWbAfKu8AEQEAAYkBtgQYAQgA 30 | IBYhBLWdr0aejJSBOJAaZJcyB16iIafqBQJiRqq7AhsMAAoJEJcyB16iIafqNJUM 31 | AJiRlauFf9MzpsFUIa+HOXcSbAeoy4xP9DE3lJzlBVrBEPfr4v/K4VjMr4wr2z2n 32 | ln/1WZOw78pDFxo+K5i5vm+wnfwDGxpKI8RUK1djVAfRHcBgyHu0IZb6qHJ/whnL 33 | KeSI4hmajw92tLBGcEJYY+8diokSvtzkskPRFhkXBH66M+GvmZq8RFXOIXYR9Siv 34 | feVrm+lrH4IP5ct4xBpaisZ6AU3rASmgahDWs/XTG0z63FTv/DR+RgdoTO9B+dE3 35 | hBU2D8SBDHtT4fKSUAzfq0ZiCPYJEXmqb4paG/6KnKZ+YCeCuM0ypwzXm5eLUwXd 36 | QYUbtCRspQGql4r1R8biWdkodxLCF99yAvpyXPWYZ+uIIQm/D0J++UubtEMzab8x 37 | HEWvbI1CCkgkWheANUK2EEK4fV0gy+GAJgB+sghjNMrZkosmVO/c7xIJN/VmGOu7 38 | Z/fPzlgCo5eZBQZgqtBkvZP00TaZCdcvSf6yBL5fBPaH590IEkcliZujFenELTbE 39 | dg== 40 | =05GI 41 | -----END PGP PUBLIC KEY BLOCK----- 42 | -------------------------------------------------------------------------------- /pgp/testdata/ring/pubring.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsops/sops/bb75c13f0755ad7eebd8b7a4be3440cdc2883c95/pgp/testdata/ring/pubring.gpg -------------------------------------------------------------------------------- /pgp/testdata/ring/secring.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsops/sops/bb75c13f0755ad7eebd8b7a4be3440cdc2883c95/pgp/testdata/ring/secring.gpg -------------------------------------------------------------------------------- /publish/gcs.go: -------------------------------------------------------------------------------- 1 | package publish 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "cloud.google.com/go/storage" 8 | ) 9 | 10 | // GCSDestination represents the Google Cloud Storage destination 11 | type GCSDestination struct { 12 | gcsBucket string 13 | gcsPrefix string 14 | } 15 | 16 | // NewGCSDestination is the constructor for a Google Cloud Storage destination 17 | func NewGCSDestination(gcsBucket, gcsPrefix string) *GCSDestination { 18 | return &GCSDestination{gcsBucket, gcsPrefix} 19 | } 20 | 21 | // Path returns a the GCS path for a file within this GCS Destination 22 | func (gcsd *GCSDestination) Path(fileName string) string { 23 | return fmt.Sprintf("gcs://%s/%s%s", gcsd.gcsBucket, gcsd.gcsPrefix, fileName) 24 | } 25 | 26 | // Upload uploads contents to a file in GCS 27 | func (gcsd *GCSDestination) Upload(fileContents []byte, fileName string) error { 28 | ctx := context.Background() 29 | client, err := storage.NewClient(ctx) 30 | if err != nil { 31 | return err 32 | } 33 | wc := client.Bucket(gcsd.gcsBucket).Object(gcsd.gcsPrefix + fileName).NewWriter(ctx) 34 | defer wc.Close() 35 | _, err = wc.Write(fileContents) 36 | if err != nil { 37 | return err 38 | } 39 | return nil 40 | } 41 | 42 | // Returns NotImplementedError 43 | func (gcsd *GCSDestination) UploadUnencrypted(data map[string]interface{}, fileName string) error { 44 | return &NotImplementedError{"GCS does not support uploading the unencrypted file contents."} 45 | } 46 | -------------------------------------------------------------------------------- /publish/publish.go: -------------------------------------------------------------------------------- 1 | package publish 2 | 3 | import "fmt" 4 | 5 | // Destination represents actions which all destination types 6 | // must implement in order to be used by SOPS 7 | type Destination interface { 8 | Upload(fileContents []byte, fileName string) error 9 | UploadUnencrypted(data map[string]interface{}, fileName string) error 10 | Path(fileName string) string 11 | } 12 | 13 | type NotImplementedError struct { 14 | message string 15 | } 16 | 17 | func (e *NotImplementedError) Error() string { 18 | return fmt.Sprintf("NotImplementedError: %s", e.message) 19 | } 20 | -------------------------------------------------------------------------------- /publish/s3.go: -------------------------------------------------------------------------------- 1 | package publish 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | 8 | "github.com/aws/aws-sdk-go-v2/aws" 9 | "github.com/aws/aws-sdk-go-v2/config" 10 | "github.com/aws/aws-sdk-go-v2/feature/s3/manager" 11 | "github.com/aws/aws-sdk-go-v2/service/s3" 12 | ) 13 | 14 | // S3Destination is the AWS S3 implementation of the Destination interface 15 | type S3Destination struct { 16 | s3Bucket string 17 | s3Prefix string 18 | } 19 | 20 | // NewS3Destination is the constructor for an S3 Destination 21 | func NewS3Destination(s3Bucket, s3Prefix string) *S3Destination { 22 | return &S3Destination{s3Bucket, s3Prefix} 23 | } 24 | 25 | // Path returns the S3 path of a file in an S3 Destination (bucket) 26 | func (s3d *S3Destination) Path(fileName string) string { 27 | return fmt.Sprintf("s3://%s/%s%s", s3d.s3Bucket, s3d.s3Prefix, fileName) 28 | } 29 | 30 | // Upload uploads contents to a file in an S3 Destination (bucket) 31 | func (s3d *S3Destination) Upload(fileContents []byte, fileName string) error { 32 | cfg, err := config.LoadDefaultConfig(context.TODO()) 33 | if err != nil { 34 | return fmt.Errorf("unable to load SDK config: %w", err) 35 | } 36 | svc := s3.NewFromConfig(cfg) 37 | input := &s3.PutObjectInput{ 38 | Body: manager.ReadSeekCloser(bytes.NewReader(fileContents)), 39 | Bucket: aws.String(s3d.s3Bucket), 40 | Key: aws.String(s3d.s3Prefix + fileName), 41 | } 42 | if _, err = svc.PutObject(context.TODO(), input); err != nil { 43 | return err 44 | } 45 | return nil 46 | } 47 | 48 | // Returns NotImplementedError 49 | func (s3d *S3Destination) UploadUnencrypted(data map[string]interface{}, fileName string) error { 50 | return &NotImplementedError{"S3 does not support uploading the unencrypted file contents."} 51 | } 52 | -------------------------------------------------------------------------------- /publish/vault.go: -------------------------------------------------------------------------------- 1 | package publish 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/getsops/sops/v3/logging" 8 | "github.com/google/go-cmp/cmp" 9 | vault "github.com/hashicorp/vault/api" 10 | 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | var log *logrus.Logger 15 | 16 | func init() { 17 | log = logging.NewLogger("PUBLISH") 18 | } 19 | 20 | type VaultDestination struct { 21 | vaultAddress string 22 | vaultPath string 23 | kvMountName string 24 | kvVersion int 25 | } 26 | 27 | func NewVaultDestination(vaultAddress, vaultPath, kvMountName string, kvVersion int) *VaultDestination { 28 | if !strings.HasSuffix(vaultPath, "/") { 29 | vaultPath = vaultPath + "/" 30 | } 31 | if kvMountName == "" { 32 | kvMountName = "secret/" 33 | } 34 | if !strings.HasSuffix(kvMountName, "/") { 35 | kvMountName = kvMountName + "/" 36 | } 37 | if kvVersion != 1 && kvVersion != 2 { 38 | kvVersion = 2 39 | } 40 | return &VaultDestination{vaultAddress, vaultPath, kvMountName, kvVersion} 41 | } 42 | 43 | func (vaultd *VaultDestination) getAddress() string { 44 | if vaultd.vaultAddress != "" { 45 | return vaultd.vaultAddress 46 | } 47 | return vault.DefaultConfig().Address 48 | } 49 | 50 | func (vaultd *VaultDestination) Path(fileName string) string { 51 | return fmt.Sprintf("%s/v1/%s", vaultd.getAddress(), vaultd.secretsPath(fileName)) 52 | } 53 | 54 | func (vaultd *VaultDestination) secretsPath(fileName string) string { 55 | if vaultd.kvVersion == 1 { 56 | return fmt.Sprintf("%s%s%s", vaultd.kvMountName, vaultd.vaultPath, fileName) 57 | } 58 | return fmt.Sprintf("%sdata/%s%s", vaultd.kvMountName, vaultd.vaultPath, fileName) 59 | } 60 | 61 | // Returns NotImplementedError 62 | func (vaultd *VaultDestination) Upload(fileContents []byte, fileName string) error { 63 | return &NotImplementedError{"Vault does not support uploading encrypted sops files directly."} 64 | } 65 | 66 | func (vaultd *VaultDestination) UploadUnencrypted(data map[string]interface{}, fileName string) error { 67 | client, err := vault.NewClient(nil) 68 | if err != nil { 69 | return err 70 | } 71 | if vaultd.vaultAddress != "" { 72 | err = client.SetAddress(vaultd.vaultAddress) 73 | if err != nil { 74 | return err 75 | } 76 | } 77 | 78 | secretsPath := vaultd.secretsPath(fileName) 79 | existingSecret, err := client.Logical().Read(secretsPath) 80 | if err != nil { 81 | log.Warnf("Cannot check if destination secret already exists in %s. New version will be created even if the data has not been changed.", secretsPath) 82 | } 83 | if existingSecret != nil && cmp.Equal(data, existingSecret.Data["data"]) { 84 | log.Infof("Secret in %s is already up-to-date.\n", secretsPath) 85 | return nil 86 | } 87 | 88 | secretsData := make(map[string]interface{}) 89 | 90 | if vaultd.kvVersion == 1 { 91 | secretsData = data 92 | } else if vaultd.kvVersion == 2 { 93 | secretsData["data"] = data 94 | } 95 | 96 | _, err = client.Logical().Write(secretsPath, secretsData) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | return nil 102 | } 103 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.85.0" 3 | profile = "minimal" 4 | -------------------------------------------------------------------------------- /shamir/README.md: -------------------------------------------------------------------------------- 1 | # Shamir's secret sharing 2 | 3 | Forked from [Vault](https://github.com/hashicorp/vault/tree/master/shamir) 4 | 5 | ## How it works 6 | 7 | We want to split a secret into parts. 8 | 9 | Any two points on the cartesian plane define a line. Three points define a 10 | parabola. Four points define a cubic curve, and so on. In general, `n` points 11 | define an function of degree `(n - 1)`. If our secret was somehow an function of 12 | degree `(n - 1)`, we could just compute `n` different points of that function 13 | and give `n` different people one point each. In order to recover the secret, 14 | then we'd need all the `n` points. If we wanted to, we could compute more than n 15 | points, but even then still only `n` points out of our whole set of computed 16 | points would be required to recover the function. 17 | 18 | A concrete example: our secret is the function `y = 2x + 1`. This function is of 19 | degree 1, so we need at least 2 points to define it. For example, let's set 20 | `x = 1`, `x = 2` and `x = 3`. From this follows that `y = 3`, `y = 5` and 21 | `y = 7`, respectively. Now, with the information that our secret is of degree 1, 22 | we can use any 2 of the 3 points we computed to recover our original function. 23 | For example, let's use the points `x = 1; y = 3` and `x = 2; y = 5`. 24 | We know that first degree functions are lines, defined by their slope and their 25 | intersection point with the y axis. We can easily compute the slope given our 26 | two points: it's the change in `y` divided by the change in `x`: 27 | `(5 - 3)/(2 - 1) = 2`. Now, knowing the slope we can compute the intersection 28 | point with the `y` axis by "working our way back". We know that at `x = 1`, 29 | `y` equals `3`, so naturally because the slope is `2`, at `x = 0`, `y` must be 30 | `1`. 31 | 32 | ## Lagrange interpolation 33 | 34 | The method we've used for this isn't very general: it only works for polynomials 35 | of degree 1. Lagrange interpolation is a more general way that lets us obtain 36 | the function of degree `(n - 1)` that passes through `n` arbitrary points. 37 | 38 | Understanding how to perform Lagrange interpolation isn't really necessary to 39 | understand Shamir's Secret Sharing: it's enough to know that there's only one 40 | function of degree `(n - 1)` that passes through `n` given points and that 41 | computing this function given the points is computationally efficient. 42 | 43 | But for those interested, here's an explanation: 44 | 45 | Let's say our points are `(x_0, y_0),...,(x_j, y_j),...,(x_(n-1), y_(n-1))`. 46 | Then, the Lagrange polynomial `L(x)`, the polynomial we're looking for, is 47 | defined as follows: 48 | 49 | `L(x) = sum from j=0 to j=(n-1) of {y_j * l_j(x)}` 50 | 51 | and `l_j(x) = product from m=0 to m=(n-1) except when m=j of {(x - x_m)/(x_j - x_m)}` 52 | 53 | A concrete example, with 3 points: 54 | 55 | ``` 56 | x_0 = 1 y_0 = 1 57 | x_1 = 2 y_1 = 4 58 | x_2 = 3 y_2 = 9 59 | ``` 60 | 61 | Let's apply the formula: 62 | 63 | ``` 64 | L(x) = 65 | y_0 * l_0(x) + 66 | y_1 * l_1(x) + 67 | y_2 * l_2(x) 68 | ``` 69 | 70 | Substitute `y_j` for the actual value: 71 | 72 | ``` 73 | L(x) = 74 | 1 * l_0(x) + 75 | 4 * l_1(x) + 76 | 9 * l_2(x) 77 | ``` 78 | 79 | Replace `l_j(x)`: 80 | 81 | ``` 82 | l_0(x) = (x - 2)/(1 - 2) * (x - 3)/(1 - 3) = 0.5x^2 - 2.5x + 3 83 | l_1(x) = (x - 1)/(2 - 1) * (x - 3)/(2 - 3) = - x^2 + 4x - 3 84 | l_2(x) = (x - 1)/(3 - 1) * (x - 2)/(3 - 2) = 0.5x^2 - 1.5x + 1 85 | ``` 86 | 87 | ``` 88 | 89 | L(x) = 90 | 1 * ( 0.5x^2 - 2.5x + 3) + 91 | 4 * ( -x^2 + 4x - 3) + 92 | 9 * ( 0.5x^2 - 1.5x + 1) 93 | ``` 94 | 95 | ``` 96 | 97 | L(x) = 98 | ( 0.5x^2 - 2.5x + 3) + 99 | ( -4x^2 + 16x - 12) + 100 | ( 4.5x^2 - 13.5x + 9) 101 | = x^2 + 0x + 0 102 | = x^2 103 | ``` 104 | 105 | So the polynomial we were looking for is `y = x^2`. 106 | 107 | ## Splitting a secret 108 | 109 | So we have the ability of splitting a function into parts, but in the context 110 | of computing we generally want to split a number, not a function. For this, 111 | let's define a function of degree `threshold`. `threshold` is the amount of 112 | parts we want to require in order to recover the secret. Let's set the parameter 113 | of degree zero to our secret `S` and make the rest of the parameters random: 114 | 115 | `y = ax^(threshold) + bx^(threshold-1) + ... + zx^1 + S` 116 | 117 | With `a, b, ...` random. 118 | 119 | Then, we want to generate our parts. For this, we evaluate our function at as 120 | many points as we want parts. For example, say our secret is 123, we want 5 121 | parts and a threshold of 2. Because the threshold is 2, we're going to need a 122 | polynomial of degree 2: 123 | 124 | `y = ax^2 + bx + 123` 125 | 126 | We randomly set `a = 7` and `b = 1`: 127 | 128 | `y = 7x^2 + x + 123` 129 | 130 | Because we want 5 parts, we need to compute 5 points: 131 | 132 | ``` 133 | x = 0 -> y = 123 # woops! This is the secret itself. Let's not use that one. 134 | x = 1 -> y = 131 135 | x = 2 -> y = 153 136 | x = 3 -> y = 189 137 | x = 4 -> y = 239 138 | x = 5 -> y = 303 139 | ``` 140 | 141 | And that's it. Each of the computed points is one part of the secret. 142 | 143 | ## Combining a secret 144 | 145 | Now that we have our parts, we have to define a way to recover them. Using 146 | the example from the previous section, we only need any two points out of the 147 | five we created to recover the secret, because we set the threshold to two. 148 | So with any two of the five points we created, we can recover the original 149 | polynomial, and because the secret is the free term in the polynomial, we can 150 | recover the secret. 151 | 152 | ## Finite fields 153 | 154 | In the previous examples we've only used integers, and this unfortunately has 155 | a flaw. First of all, it's impossible to uniformly sample integers to get 156 | random coefficients for our generated polynomial. Additionally, if we don't 157 | operate in a finite field, information about the secret is leaked for every part 158 | someone recovers. 159 | 160 | For these reasons, Vault's implementation of Shamir's Secret Sharing uses finite 161 | field arithmetic, specifically in GF(2^8), with 229 as the generator. GF(2^8) 162 | has 256 elements, so using this we can only split one byte at a time. This is 163 | not a problem, though, as we can just split each byte in our secret 164 | independently. This implementation uses tables to speed up the execution of 165 | finite field arithmetic. 166 | -------------------------------------------------------------------------------- /shamir/shamir_test.go: -------------------------------------------------------------------------------- 1 | package shamir 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestSplit_invalid(t *testing.T) { 9 | secret := []byte("test") 10 | 11 | if _, err := Split(secret, 0, 0); err == nil { 12 | t.Fatalf("expect error") 13 | } 14 | 15 | if _, err := Split(secret, 2, 3); err == nil { 16 | t.Fatalf("expect error") 17 | } 18 | 19 | if _, err := Split(secret, 1000, 3); err == nil { 20 | t.Fatalf("expect error") 21 | } 22 | 23 | if _, err := Split(secret, 10, 1); err == nil { 24 | t.Fatalf("expect error") 25 | } 26 | 27 | if _, err := Split(nil, 3, 2); err == nil { 28 | t.Fatalf("expect error") 29 | } 30 | } 31 | 32 | func TestSplit(t *testing.T) { 33 | secret := []byte("test") 34 | 35 | out, err := Split(secret, 5, 3) 36 | if err != nil { 37 | t.Fatalf("err: %v", err) 38 | } 39 | 40 | if len(out) != 5 { 41 | t.Fatalf("bad: %v", out) 42 | } 43 | 44 | for _, share := range out { 45 | if len(share) != len(secret)+1 { 46 | t.Fatalf("bad: %v", out) 47 | } 48 | } 49 | } 50 | 51 | func TestCombine_invalid(t *testing.T) { 52 | // Not enough parts 53 | if _, err := Combine(nil); err == nil { 54 | t.Fatalf("should err") 55 | } 56 | 57 | // Mismatch in length 58 | parts := [][]byte{ 59 | []byte("foo"), 60 | []byte("ba"), 61 | } 62 | if _, err := Combine(parts); err == nil { 63 | t.Fatalf("should err") 64 | } 65 | 66 | //Too short 67 | parts = [][]byte{ 68 | []byte("f"), 69 | []byte("b"), 70 | } 71 | if _, err := Combine(parts); err == nil { 72 | t.Fatalf("should err") 73 | } 74 | 75 | parts = [][]byte{ 76 | []byte("foo"), 77 | []byte("foo"), 78 | } 79 | if _, err := Combine(parts); err == nil { 80 | t.Fatalf("should err") 81 | } 82 | } 83 | 84 | func TestCombine(t *testing.T) { 85 | secret := []byte("test") 86 | 87 | out, err := Split(secret, 5, 3) 88 | if err != nil { 89 | t.Fatalf("err: %v", err) 90 | } 91 | 92 | // There is 5*4*3 possible choices, 93 | // we will just brute force try them all 94 | for i := 0; i < 5; i++ { 95 | for j := 0; j < 5; j++ { 96 | if j == i { 97 | continue 98 | } 99 | for k := 0; k < 5; k++ { 100 | if k == i || k == j { 101 | continue 102 | } 103 | parts := [][]byte{out[i], out[j], out[k]} 104 | recomb, err := Combine(parts) 105 | if err != nil { 106 | t.Fatalf("err: %v", err) 107 | } 108 | 109 | if !bytes.Equal(recomb, secret) { 110 | t.Errorf("parts: (i:%d, j:%d, k:%d) %v", i, j, k, parts) 111 | t.Fatalf("bad: %v %v", recomb, secret) 112 | } 113 | } 114 | } 115 | } 116 | } 117 | 118 | func TestField_MulDivSmoke(t *testing.T) { 119 | for a := range 256 { 120 | for b := range 256 { 121 | if b == 0 { 122 | if out := mult(uint8(a), uint8(b)); out != 0 { 123 | t.Fatalf("Bad: %v * %v = %v 0", a, b, out) 124 | } 125 | } else { 126 | if out := div(mult(uint8(a), uint8(b)), uint8(b)); out != uint8(a) { 127 | t.Fatalf("Bad: (%v * %v) / %v = %v %v", a, b, b, out, a) 128 | } 129 | } 130 | } 131 | } 132 | } 133 | 134 | func TestField_Add(t *testing.T) { 135 | if out := add(16, 16); out != 0 { 136 | t.Fatalf("Bad: %v 16", out) 137 | } 138 | 139 | if out := add(3, 4); out != 7 { 140 | t.Fatalf("Bad: %v 7", out) 141 | } 142 | } 143 | 144 | func TestField_Mult(t *testing.T) { 145 | if out := mult(3, 7); out != 9 { 146 | t.Fatalf("Bad: %v 9", out) 147 | } 148 | 149 | if out := mult(3, 0); out != 0 { 150 | t.Fatalf("Bad: %v 0", out) 151 | } 152 | 153 | if out := mult(0, 3); out != 0 { 154 | t.Fatalf("Bad: %v 0", out) 155 | } 156 | } 157 | 158 | func TestField_Divide(t *testing.T) { 159 | if out := div(0, 7); out != 0 { 160 | t.Fatalf("Bad: %v 0", out) 161 | } 162 | 163 | if out := div(3, 3); out != 1 { 164 | t.Fatalf("Bad: %v 1", out) 165 | } 166 | 167 | if out := div(6, 3); out != 2 { 168 | t.Fatalf("Bad: %v 2", out) 169 | } 170 | } 171 | 172 | func TestPolynomial_Random(t *testing.T) { 173 | p, err := makePolynomial(42, 2) 174 | if err != nil { 175 | t.Fatalf("err: %v", err) 176 | } 177 | 178 | if p.coefficients[0] != 42 { 179 | t.Fatalf("bad: %v", p.coefficients) 180 | } 181 | } 182 | 183 | func TestPolynomial_Eval(t *testing.T) { 184 | p, err := makePolynomial(42, 1) 185 | if err != nil { 186 | t.Fatalf("err: %v", err) 187 | } 188 | 189 | if out := p.evaluate(0); out != 42 { 190 | t.Fatalf("bad: %v", out) 191 | } 192 | 193 | out := p.evaluate(1) 194 | exp := add(42, mult(1, p.coefficients[1])) 195 | if out != exp { 196 | t.Fatalf("bad: %v %v %v", out, exp, p.coefficients) 197 | } 198 | } 199 | 200 | func TestInterpolate_Rand(t *testing.T) { 201 | for i := 0; i < 256; i++ { 202 | p, err := makePolynomial(uint8(i), 2) 203 | if err != nil { 204 | t.Fatalf("err: %v", err) 205 | } 206 | 207 | xVals := []uint8{1, 2, 3} 208 | yVals := []uint8{p.evaluate(1), p.evaluate(2), p.evaluate(3)} 209 | out := interpolatePolynomial(xVals, yVals, 0) 210 | if out != uint8(i) { 211 | t.Fatalf("Bad: %v %d", out, i) 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /stores/dotenv/store.go: -------------------------------------------------------------------------------- 1 | package dotenv //import "github.com/getsops/sops/v3/stores/dotenv" 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | 9 | "github.com/getsops/sops/v3" 10 | "github.com/getsops/sops/v3/config" 11 | "github.com/getsops/sops/v3/stores" 12 | ) 13 | 14 | // SopsPrefix is the prefix for all metadatada entry keys 15 | const SopsPrefix = stores.SopsMetadataKey + "_" 16 | 17 | // Store handles storage of dotenv data 18 | type Store struct { 19 | config config.DotenvStoreConfig 20 | } 21 | 22 | func NewStore(c *config.DotenvStoreConfig) *Store { 23 | return &Store{config: *c} 24 | } 25 | 26 | // LoadEncryptedFile loads an encrypted file's bytes onto a sops.Tree runtime object 27 | func (store *Store) LoadEncryptedFile(in []byte) (sops.Tree, error) { 28 | branches, err := store.LoadPlainFile(in) 29 | if err != nil { 30 | return sops.Tree{}, err 31 | } 32 | 33 | var resultBranch sops.TreeBranch 34 | mdMap := make(map[string]interface{}) 35 | for _, item := range branches[0] { 36 | switch key := item.Key.(type) { 37 | case string: 38 | if strings.HasPrefix(key, SopsPrefix) { 39 | key = key[len(SopsPrefix):] 40 | mdMap[key] = item.Value 41 | } else { 42 | resultBranch = append(resultBranch, item) 43 | } 44 | case sops.Comment: 45 | resultBranch = append(resultBranch, item) 46 | default: 47 | panic(fmt.Sprintf("Unexpected type: %T (value %#v)", key, key)) 48 | } 49 | } 50 | 51 | stores.DecodeNewLines(mdMap) 52 | err = stores.DecodeNonStrings(mdMap) 53 | if err != nil { 54 | return sops.Tree{}, err 55 | } 56 | metadata, err := stores.UnflattenMetadata(mdMap) 57 | if err != nil { 58 | return sops.Tree{}, err 59 | } 60 | internalMetadata, err := metadata.ToInternal() 61 | if err != nil { 62 | return sops.Tree{}, err 63 | } 64 | 65 | return sops.Tree{ 66 | Branches: sops.TreeBranches{ 67 | resultBranch, 68 | }, 69 | Metadata: internalMetadata, 70 | }, nil 71 | } 72 | 73 | // LoadPlainFile returns the contents of a plaintext file loaded onto a 74 | // sops runtime object 75 | func (store *Store) LoadPlainFile(in []byte) (sops.TreeBranches, error) { 76 | var branches sops.TreeBranches 77 | var branch sops.TreeBranch 78 | 79 | for _, line := range bytes.Split(in, []byte("\n")) { 80 | if len(line) == 0 { 81 | continue 82 | } 83 | if line[0] == '#' { 84 | branch = append(branch, sops.TreeItem{ 85 | Key: sops.Comment{Value: string(line[1:])}, 86 | Value: nil, 87 | }) 88 | } else { 89 | pos := bytes.Index(line, []byte("=")) 90 | if pos == -1 { 91 | return nil, fmt.Errorf("invalid dotenv input line: %s", line) 92 | } 93 | branch = append(branch, sops.TreeItem{ 94 | Key: string(line[:pos]), 95 | Value: strings.Replace(string(line[pos+1:]), "\\n", "\n", -1), 96 | }) 97 | } 98 | } 99 | 100 | branches = append(branches, branch) 101 | return branches, nil 102 | } 103 | 104 | // EmitEncryptedFile returns the encrypted file's bytes corresponding to a sops 105 | // runtime object 106 | func (store *Store) EmitEncryptedFile(in sops.Tree) ([]byte, error) { 107 | metadata := stores.MetadataFromInternal(in.Metadata) 108 | mdItems, err := stores.FlattenMetadata(metadata) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | stores.EncodeNonStrings(mdItems) 114 | stores.EncodeNewLines(mdItems) 115 | 116 | var keys []string 117 | for k := range mdItems { 118 | keys = append(keys, k) 119 | } 120 | sort.Strings(keys) 121 | 122 | for _, key := range keys { 123 | var value = mdItems[key] 124 | if value == nil { 125 | continue 126 | } 127 | in.Branches[0] = append(in.Branches[0], sops.TreeItem{Key: SopsPrefix + key, Value: value}) 128 | } 129 | return store.EmitPlainFile(in.Branches) 130 | } 131 | 132 | // EmitPlainFile returns the plaintext file's bytes corresponding to a sops 133 | // runtime object 134 | func (store *Store) EmitPlainFile(in sops.TreeBranches) ([]byte, error) { 135 | buffer := bytes.Buffer{} 136 | for _, item := range in[0] { 137 | if IsComplexValue(item.Value) { 138 | return nil, fmt.Errorf("cannot use complex value in dotenv file: %s", item.Value) 139 | } 140 | var line string 141 | if comment, ok := item.Key.(sops.Comment); ok { 142 | line = fmt.Sprintf("#%s\n", comment.Value) 143 | } else { 144 | value := strings.Replace(item.Value.(string), "\n", "\\n", -1) 145 | line = fmt.Sprintf("%s=%s\n", item.Key, value) 146 | } 147 | buffer.WriteString(line) 148 | } 149 | return buffer.Bytes(), nil 150 | } 151 | 152 | // EmitValue returns a single value as bytes 153 | func (Store) EmitValue(v interface{}) ([]byte, error) { 154 | if s, ok := v.(string); ok { 155 | return []byte(s), nil 156 | } 157 | return nil, fmt.Errorf("the dotenv store only supports emitting strings, got %T", v) 158 | } 159 | 160 | // EmitExample returns the bytes corresponding to an example Flat Tree runtime object 161 | func (store *Store) EmitExample() []byte { 162 | bytes, err := store.EmitPlainFile(stores.ExampleFlatTree.Branches) 163 | if err != nil { 164 | panic(err) 165 | } 166 | return bytes 167 | } 168 | 169 | func IsComplexValue(v interface{}) bool { 170 | switch v.(type) { 171 | case []interface{}: 172 | return true 173 | case sops.TreeBranch: 174 | return true 175 | } 176 | return false 177 | } 178 | 179 | // HasSopsTopLevelKey checks whether a top-level "sops" key exists. 180 | func (store *Store) HasSopsTopLevelKey(branch sops.TreeBranch) bool { 181 | for _, b := range branch { 182 | if key, ok := b.Key.(string); ok { 183 | if strings.HasPrefix(key, SopsPrefix) { 184 | return true 185 | } 186 | } 187 | } 188 | return false 189 | } 190 | -------------------------------------------------------------------------------- /stores/dotenv/store_test.go: -------------------------------------------------------------------------------- 1 | package dotenv 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/getsops/sops/v3" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | var PLAIN = []byte(strings.TrimLeft(` 12 | VAR1=val1 13 | VAR2=val2 14 | #comment 15 | VAR3_unencrypted=val3 16 | VAR4=val4\nval4 17 | `, "\n")) 18 | 19 | var BRANCH = sops.TreeBranch{ 20 | sops.TreeItem{ 21 | Key: "VAR1", 22 | Value: "val1", 23 | }, 24 | sops.TreeItem{ 25 | Key: "VAR2", 26 | Value: "val2", 27 | }, 28 | sops.TreeItem{ 29 | Key: sops.Comment{Value: "comment"}, 30 | Value: nil, 31 | }, 32 | sops.TreeItem{ 33 | Key: "VAR3_unencrypted", 34 | Value: "val3", 35 | }, 36 | sops.TreeItem{ 37 | Key: "VAR4", 38 | Value: "val4\nval4", 39 | }, 40 | } 41 | 42 | func TestLoadPlainFile(t *testing.T) { 43 | branches, err := (&Store{}).LoadPlainFile(PLAIN) 44 | assert.Nil(t, err) 45 | assert.Equal(t, BRANCH, branches[0]) 46 | } 47 | func TestEmitPlainFile(t *testing.T) { 48 | branches := sops.TreeBranches{ 49 | BRANCH, 50 | } 51 | bytes, err := (&Store{}).EmitPlainFile(branches) 52 | assert.Nil(t, err) 53 | assert.Equal(t, PLAIN, bytes) 54 | } 55 | 56 | func TestEmitValueString(t *testing.T) { 57 | bytes, err := (&Store{}).EmitValue("hello") 58 | assert.Nil(t, err) 59 | assert.Equal(t, []byte("hello"), bytes) 60 | } 61 | 62 | func TestEmitValueNonstring(t *testing.T) { 63 | _, err := (&Store{}).EmitValue(BRANCH) 64 | assert.NotNil(t, err) 65 | } 66 | 67 | func TestEmitEncryptedFileStability(t *testing.T) { 68 | // emit the same tree multiple times to ensure the output is stable 69 | // i.e. emitting the same tree always yields exactly the same output 70 | var previous []byte 71 | for i := 0; i < 10; i += 1 { 72 | bytes, err := (&Store{}).EmitEncryptedFile(sops.Tree{ 73 | Branches: []sops.TreeBranch{{}}, 74 | }) 75 | assert.Nil(t, err) 76 | assert.NotEmpty(t, bytes) 77 | if previous != nil { 78 | assert.Equal(t, previous, bytes) 79 | } 80 | previous = bytes 81 | } 82 | } 83 | 84 | func TestHasSopsTopLevelKey(t *testing.T) { 85 | ok := (&Store{}).HasSopsTopLevelKey(sops.TreeBranch{ 86 | sops.TreeItem{ 87 | Key: "sops", 88 | Value: "value", 89 | }, 90 | }) 91 | assert.Equal(t, ok, false) 92 | ok = (&Store{}).HasSopsTopLevelKey(sops.TreeBranch{ 93 | sops.TreeItem{ 94 | Key: "sops_", 95 | Value: "value", 96 | }, 97 | }) 98 | assert.Equal(t, ok, true) 99 | } 100 | -------------------------------------------------------------------------------- /stores/ini/store_test.go: -------------------------------------------------------------------------------- 1 | package ini 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/getsops/sops/v3" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestDecodeIni(t *testing.T) { 11 | in := ` 12 | ; last modified 1 April 2001 by John Doe 13 | [owner] 14 | name=John Doe 15 | organization=Acme Widgets Inc. 16 | 17 | [database] 18 | ; use IP address in case network name resolution is not working 19 | server=192.0.2.62 20 | port=143 21 | file="payroll.dat" 22 | ` 23 | expected := sops.TreeBranches{ 24 | sops.TreeBranch{ 25 | sops.TreeItem{ 26 | Key: "DEFAULT", 27 | Value: sops.TreeBranch(nil), 28 | }, 29 | sops.TreeItem{ 30 | Key: "owner", 31 | Value: sops.TreeBranch{ 32 | sops.TreeItem{ 33 | Key: sops.Comment{Value: "last modified 1 April 2001 by John Doe"}, 34 | Value: nil, 35 | }, 36 | sops.TreeItem{ 37 | Key: "name", 38 | Value: "John Doe", 39 | }, 40 | sops.TreeItem{ 41 | Key: "organization", 42 | Value: "Acme Widgets Inc.", 43 | }, 44 | }, 45 | }, 46 | sops.TreeItem{ 47 | Key: "database", 48 | Value: sops.TreeBranch{ 49 | sops.TreeItem{ 50 | Key: "server", 51 | Value: "192.0.2.62", 52 | }, 53 | sops.TreeItem{ 54 | Key: sops.Comment{Value: "use IP address in case network name resolution is not working"}, 55 | Value: nil, 56 | }, 57 | sops.TreeItem{ 58 | Key: "port", 59 | Value: "143", 60 | }, 61 | sops.TreeItem{ 62 | Key: "file", 63 | Value: "payroll.dat", 64 | }, 65 | }, 66 | }, 67 | }, 68 | } 69 | branch, err := Store{}.treeBranchesFromIni([]byte(in)) 70 | assert.Nil(t, err) 71 | assert.Equal(t, expected, branch) 72 | } 73 | 74 | func TestEncodeSimpleIni(t *testing.T) { 75 | branches := sops.TreeBranches{ 76 | sops.TreeBranch{ 77 | sops.TreeItem{ 78 | Key: "DEFAULT", 79 | Value: sops.TreeBranch{ 80 | sops.TreeItem{ 81 | Key: "foo", 82 | Value: "bar", 83 | }, 84 | sops.TreeItem{ 85 | Key: "baz", 86 | Value: "3.0", 87 | }, 88 | sops.TreeItem{ 89 | Key: "qux", 90 | Value: "false", 91 | }, 92 | }, 93 | }, 94 | }, 95 | } 96 | out, err := Store{}.iniFromTreeBranches(branches) 97 | assert.Nil(t, err) 98 | expected, _ := Store{}.treeBranchesFromIni(out) 99 | assert.Equal(t, expected, branches) 100 | } 101 | 102 | func TestEncodeIniWithEscaping(t *testing.T) { 103 | branches := sops.TreeBranches{ 104 | sops.TreeBranch{ 105 | sops.TreeItem{ 106 | Key: "DEFAULT", 107 | Value: sops.TreeBranch{ 108 | sops.TreeItem{ 109 | Key: "foo\\bar", 110 | Value: "value", 111 | }, 112 | sops.TreeItem{ 113 | Key: "a_key_with\"quotes\"", 114 | Value: "4.0", 115 | }, 116 | sops.TreeItem{ 117 | Key: "baz\\\\foo", 118 | Value: "2.0", 119 | }, 120 | }, 121 | }, 122 | }, 123 | } 124 | out, err := Store{}.iniFromTreeBranches(branches) 125 | assert.Nil(t, err) 126 | expected, _ := Store{}.treeBranchesFromIni(out) 127 | assert.Equal(t, expected, branches) 128 | } 129 | 130 | func TestEncodeIniWithDuplicateSections(t *testing.T) { 131 | branches := sops.TreeBranches{ 132 | sops.TreeBranch{ 133 | sops.TreeItem{ 134 | Key: "DEFAULT", 135 | Value: interface{}(sops.TreeBranch(nil)), 136 | }, 137 | sops.TreeItem{ 138 | Key: "foo", 139 | Value: sops.TreeBranch{ 140 | sops.TreeItem{ 141 | Key: "foo", 142 | Value: "bar", 143 | }, 144 | sops.TreeItem{ 145 | Key: "baz", 146 | Value: "3.0", 147 | }, 148 | sops.TreeItem{ 149 | Key: "qux", 150 | Value: "false", 151 | }, 152 | }, 153 | }, 154 | sops.TreeItem{ 155 | Key: "foo", 156 | Value: sops.TreeBranch{ 157 | sops.TreeItem{ 158 | Key: "foo", 159 | Value: "bar", 160 | }, 161 | sops.TreeItem{ 162 | Key: "baz", 163 | Value: "3.0", 164 | }, 165 | sops.TreeItem{ 166 | Key: "qux", 167 | Value: "false", 168 | }, 169 | }, 170 | }, 171 | }, 172 | } 173 | out, err := Store{}.iniFromTreeBranches(branches) 174 | assert.Nil(t, err) 175 | expected, _ := Store{}.treeBranchesFromIni(out) 176 | assert.Equal(t, expected, branches) 177 | } 178 | 179 | func TestUnmarshalMetadataFromNonSOPSFile(t *testing.T) { 180 | data := []byte(`hello=2`) 181 | store := Store{} 182 | _, err := store.LoadEncryptedFile(data) 183 | assert.Equal(t, sops.MetadataNotFound, err) 184 | } 185 | -------------------------------------------------------------------------------- /stores/ini/test_resources/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "example_key": "ENC[AES256_GCM,data:Xjen3YMQYCBfTU8VjA==,iv:1NUKversqeQiuTmAkZuyd6UY2AWiBS4owa4QHnwKOBM=,tag:ZO+Aln5DQ5Qm/QV2uBdahA==,type:str]", 3 | "example_array": [ 4 | "ENC[AES256_GCM,data:XJR0qvZuifm8j1TIQB8=,iv:QUkwy1dp0RU0PKEAw/VxVe1ZsQ972c8gPMJoVKgMfuw=,tag:LGdwC5nTua4rSe0dLbbA1Q==,type:str]", 5 | "ENC[AES256_GCM,data:7bhghzi5GN/mMqh1vHU=,iv:X5vrd9X7ItIG/RVCn0T7RFhUrTb2YItr3i97EVk9nOY=,tag:vrM058PPOGmWOGPThOP5Ew==,type:str]" 6 | ], 7 | "example_number": "ENC[AES256_GCM,data:w9etQN5r8iCz,iv:YF+1uUlMa4I1C7A0ELpVuMa1yK042uEMhp8y6HiCTDE=,tag:Dmh+AV9sh+ir0M1Txe+v2A==,type:float]", 8 | "example_booleans": [ 9 | "ENC[AES256_GCM,data:/hltsg==,iv:pbAtZ9i8rxFpaFlbwE1KOA+k/TVx5dm0tDtH94GCEVc=,tag:yz3d1pQu9zy5Ra9z2kDcoA==,type:bool]", 10 | "ENC[AES256_GCM,data:HEei0+s=,iv:hgKT5eiYdHn5AqWdNji7vRKfabln90VbLmJqt7A480E=,tag:6pfezzTX/eULebF+32Z2+w==,type:bool]" 11 | ], 12 | "sops": { 13 | "lastmodified": "2016-08-04T23:30:35Z", 14 | "attention": "This section contains key material that should only be modified with extra care. See `sops -h`.", 15 | "unencrypted_suffix": "_unencrypted", 16 | "mac": "ENC[AES256_GCM,data:EK1LkVgW5CBEsGgGc7RkfZlzqWrP2fZe3kG7HbkJ5JFd591oUkbQ6I2uPImkcxf7HjiEHzKPPF5QvNg3+rUxgw6S8pQtumhDbFrfDi8GDS2VVvPR+0fnc2fR5PMGm36bOaQFDNSmgyJzKhMmNL+MtRhH+fMUnHhrnxuN3wfLr4w=,iv:xbNK6wRDVT4xhrP+vP2RIy+uNjZSSzqEJZPOdShn96o=,tag:vT4akR5X6qx5/wJ4dncxtg==,type:str]", 17 | "version": "1.13", 18 | "kms": [ 19 | { 20 | "created_at": "2016-08-04T23:30:35Z", 21 | "enc": "AQECAHgFEiO2dNygC3Rz8PhERCc8Sfhak4g81FUPqQJ0OBcAKgAAAH4wfAYJKoZIhvcNAQcGoG8wbQIBADBoBgkqhkiG9w0BBwEwHgYJYIZIAWUDBAEuMBEEDPKe5R67LMN3+xAkygIBEIA7F8noZukawV3VLQ/yH3Ep7Ptx8weLFUgVf/ZI6xqSMNvEHIr4+vf2xjBiAyrEF8u/n9nm9PWAdKHszFM=", 22 | "arn": "arn:aws:kms:us-east-1:927034868273:key/e9fc75db-05e9-44c1-9c35-633922bac347" 23 | } 24 | ], 25 | "pgp": [ 26 | { 27 | "fp": "E5297818703249D0C60E19E6824612478D1A4CCD", 28 | "created_at": "2016-08-04T23:30:35Z", 29 | "enc": "-----BEGIN PGP MESSAGE-----\nVersion: GnuPG v1\n\nhQIMA2X8rvoeiASBARAAurTEVS82kqadk68f5ZlwR176S148WYTYxFp5oMC7cVD7\n42+Eo9RzaxbHeO5n7XKX0SDOUUeCucFl8fwuDUV1iDIx4/u5HgWXxDuvWoNe5cAL\n4LBS1Er2ZBVAdU0WHZ/8USuZLhSu7ucAHOvqNpHzPT6gkuBUYLQKOu0c+onWHqVO\n1DhfkTtvphotZ0ZBBR099t5N8ofD0W2+SM268A9/bB5yQcK9Ig/KxZBrfmMQm7zx\n9hLVQhcBmj0OQG37K4/SXGwjrQFarh6lm+FuZM0Q+GI+OARoAKdpZOnPEhXKE6un\nSEa69rh5FKVM/XRp2/QVZEakzRtq3gi9CtYL2sNr7KEnCvxt/v2pEc6evfIvxWTc\nT8MWdk48FkVjdsJ34sNiIM8msstnYorse8RZny9gcLE+A5lsRavo2QPL4GADyHF8\n7kwijSVDd08nByTBMMEPpMozUFhzF8QuVZPD+siuUvi+Bned9MmqgGMfvhS0Kf38\nMZFy5C6e38VGEX3IrWChvzbBm/M3fjs1fPVDShHfk1MYsCU9sXNQMQVewWE0s/em\nklycIL3hywd4N9z1MVW2hBpRrC247PtGQRKGoB9qbKtSgjTtgM7bo1vYekeY1tjr\nBGTHNFV+FBqFih16u/rGVzIaBsf5lLL/RtpaFZx1OWHMd9XjQpRrHhjOMpQ8tjvS\nXgGH59vv/9GNZ+Rix1QF+iMD84sfkyyguGKwg+TC3m275v+HIO1NvNdU6oS3O/Xq\nBCBV3yYAUwcrWUPWCuSUHJbuHKJEI1ymXUu8+RUElPyi/5JEhW+J1WlVPvnG1Xk=\n=Q/XA\n-----END PGP MESSAGE-----\n" 30 | } 31 | ] 32 | } 33 | } -------------------------------------------------------------------------------- /usererrors.go: -------------------------------------------------------------------------------- 1 | package sops 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "github.com/fatih/color" 9 | "github.com/goware/prefixer" 10 | "github.com/mitchellh/go-wordwrap" 11 | ) 12 | 13 | // UserError is a well-formatted error for the purpose of being displayed to 14 | // the end user. 15 | type UserError interface { 16 | error 17 | UserError() string 18 | } 19 | 20 | var statusSuccess = color.New(color.FgGreen).Sprint("SUCCESS") 21 | var statusFailed = color.New(color.FgRed).Sprint("FAILED") 22 | 23 | type getDataKeyError struct { 24 | RequiredSuccessfulKeyGroups int 25 | GroupResults []error 26 | } 27 | 28 | func (err *getDataKeyError) successfulKeyGroups() int { 29 | n := 0 30 | for _, r := range err.GroupResults { 31 | if r == nil { 32 | n++ 33 | } 34 | } 35 | return n 36 | } 37 | 38 | func (err *getDataKeyError) Error() string { 39 | return fmt.Sprintf("Error getting data key: %d successful groups "+ 40 | "required, got %d", err.RequiredSuccessfulKeyGroups, 41 | err.successfulKeyGroups()) 42 | } 43 | 44 | func (err *getDataKeyError) UserError() string { 45 | var groupErrs []string 46 | for i, res := range err.GroupResults { 47 | groupErr := decryptGroupError{ 48 | err: res, 49 | groupName: fmt.Sprintf("%d", i), 50 | } 51 | groupErrs = append(groupErrs, groupErr.UserError()) 52 | } 53 | var trailer string 54 | if err.RequiredSuccessfulKeyGroups == 0 { 55 | trailer = "Recovery failed because no master key was able to decrypt " + 56 | "the file. In order for SOPS to recover the file, at least one key " + 57 | "has to be successful, but none were." 58 | } else { 59 | trailer = fmt.Sprintf("Recovery failed because the file was "+ 60 | "encrypted with a Shamir threshold of %d, but only %d part(s) "+ 61 | "were successfully recovered, one for each successful key group. "+ 62 | "In order for SOPS to recover the file, at least %d groups have "+ 63 | "to be successful. In order for a group to be successful, "+ 64 | "decryption has to succeed with any of the keys in that key group.", 65 | err.RequiredSuccessfulKeyGroups, err.successfulKeyGroups(), 66 | err.RequiredSuccessfulKeyGroups) 67 | } 68 | trailer = wordwrap.WrapString(trailer, 75) 69 | return fmt.Sprintf("Failed to get the data key required to "+ 70 | "decrypt the SOPS file.\n\n%s\n\n%s", 71 | strings.Join(groupErrs, "\n\n"), trailer) 72 | } 73 | 74 | type decryptGroupError struct { 75 | groupName string 76 | err error 77 | } 78 | 79 | func (r *decryptGroupError) Error() string { 80 | return fmt.Sprintf("could not decrypt group %s: %s", r.groupName, r.err) 81 | } 82 | 83 | func (r *decryptGroupError) UserError() string { 84 | var status string 85 | if r.err == nil { 86 | status = statusSuccess 87 | } else { 88 | status = statusFailed 89 | } 90 | header := fmt.Sprintf(`Group %s: %s`, r.groupName, status) 91 | if r.err == nil { 92 | return header 93 | } 94 | message := r.err.Error() 95 | if userError, ok := r.err.(UserError); ok { 96 | message = userError.UserError() 97 | } 98 | reader := prefixer.New(strings.NewReader(message), " ") 99 | // Safe to ignore this error, as reading from a strings.Reader can't fail 100 | errMsg, _ := io.ReadAll(reader) 101 | return fmt.Sprintf("%s\n%s", header, string(errMsg)) 102 | } 103 | 104 | type decryptKeyErrors []error 105 | 106 | func (e decryptKeyErrors) Error() string { 107 | return fmt.Sprintf("error decrypting key: %s", []error(e)) 108 | } 109 | 110 | func (e decryptKeyErrors) UserError() string { 111 | var errStrs []string 112 | for _, err := range []error(e) { 113 | if userErr, ok := err.(UserError); ok { 114 | errStrs = append(errStrs, userErr.UserError()) 115 | } else { 116 | errStrs = append(errStrs, err.Error()) 117 | } 118 | } 119 | return strings.Join(errStrs, "\n\n") 120 | } 121 | 122 | type decryptKeyError struct { 123 | keyName string 124 | errs []error 125 | } 126 | 127 | func (e *decryptKeyError) isSuccessful() bool { 128 | for _, err := range e.errs { 129 | if err == nil { 130 | return true 131 | } 132 | } 133 | return false 134 | } 135 | 136 | func (e *decryptKeyError) Error() string { 137 | return fmt.Sprintf("error decrypting key %s: %s", e.keyName, e.errs) 138 | } 139 | 140 | func (e *decryptKeyError) UserError() string { 141 | var status string 142 | if e.isSuccessful() { 143 | status = statusSuccess 144 | } else { 145 | status = statusFailed 146 | } 147 | header := fmt.Sprintf("%s: %s", e.keyName, status) 148 | if e.isSuccessful() { 149 | return header 150 | } 151 | var errMessages []string 152 | for _, err := range e.errs { 153 | wrappedErr := wordwrap.WrapString(err.Error(), 60) 154 | reader := prefixer.New(strings.NewReader(wrappedErr), " | ") 155 | // Safe to ignore this error, as reading from a strings.Reader can't fail 156 | errMsg, _ := io.ReadAll(reader) 157 | errMsg[0] = '-' 158 | errMessages = append(errMessages, string(errMsg)) 159 | } 160 | joinedMsgs := strings.Join(errMessages, "\n\n") 161 | reader := prefixer.New(strings.NewReader(joinedMsgs), " ") 162 | errMsg, _ := io.ReadAll(reader) 163 | return fmt.Sprintf("%s\n%s", header, string(errMsg)) 164 | } 165 | --------------------------------------------------------------------------------