Compare commits
2 Commits
e7dda4bcda
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| eb35aa7a43 | |||
| 71cc398197 |
@@ -0,0 +1,5 @@
|
|||||||
|
/dist/
|
||||||
|
/vault-plugin-secrets-gpg
|
||||||
|
*.out
|
||||||
|
*.test
|
||||||
|
.env
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v5.0.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-added-large-files
|
||||||
|
|
||||||
|
- repo: https://github.com/dnephin/pre-commit-golang
|
||||||
|
rev: v0.5.1
|
||||||
|
hooks:
|
||||||
|
- id: go-fmt
|
||||||
|
- id: go-vet
|
||||||
|
- id: go-mod-tidy
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build
|
||||||
|
image: golang:1.25
|
||||||
|
commands:
|
||||||
|
- make build
|
||||||
|
backend_options:
|
||||||
|
kubernetes:
|
||||||
|
serviceAccountName: default
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 512Mi
|
||||||
|
cpu: 1
|
||||||
|
limits:
|
||||||
|
memory: 2Gi
|
||||||
|
cpu: 2
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: pre-commit
|
||||||
|
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
|
||||||
|
commands:
|
||||||
|
- uvx pre-commit run --all-files
|
||||||
|
backend_options:
|
||||||
|
kubernetes:
|
||||||
|
serviceAccountName: default
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 512Mi
|
||||||
|
cpu: 1
|
||||||
|
limits:
|
||||||
|
memory: 2Gi
|
||||||
|
cpu: 2
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
when:
|
||||||
|
- event: tag
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build
|
||||||
|
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
|
||||||
|
commands:
|
||||||
|
- make build VERSION=${CI_COMMIT_TAG}
|
||||||
|
backend_options:
|
||||||
|
kubernetes:
|
||||||
|
serviceAccountName: default
|
||||||
|
resources:
|
||||||
|
requests: {memory: 512Mi, cpu: 1}
|
||||||
|
limits: {memory: 2Gi, cpu: 2}
|
||||||
|
|
||||||
|
- name: package
|
||||||
|
image: git.unkin.net/unkin/almalinux9-rpmbuilder:latest
|
||||||
|
commands:
|
||||||
|
- ./scripts/build-rpm.sh ${CI_COMMIT_TAG}
|
||||||
|
depends_on: [build]
|
||||||
|
backend_options:
|
||||||
|
kubernetes:
|
||||||
|
serviceAccountName: default
|
||||||
|
resources:
|
||||||
|
requests: {memory: 512Mi, cpu: 1}
|
||||||
|
limits: {memory: 2Gi, cpu: 2}
|
||||||
|
|
||||||
|
- name: upload
|
||||||
|
image: git.unkin.net/unkin/almalinux9-base:20260606
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
HOST="https://artifactapi.k8s.syd1.au.unkin.net"
|
||||||
|
REPO="rpm-internal"
|
||||||
|
for rpm in dist/*.rpm; do
|
||||||
|
FILE=$$(basename "$$rpm")
|
||||||
|
code=$$(curl -s -o /dev/null -w '%{http_code}' "$$HOST/api/v2/remotes/$$REPO/files/Packages/$$FILE" || true)
|
||||||
|
if [ "$$code" = "200" ]; then echo "$$FILE exists; skipping"; continue; fi
|
||||||
|
echo "Uploading $$FILE (probe $$code)"
|
||||||
|
curl -f -X PUT "$$HOST/api/v2/remotes/$$REPO/files/$$FILE" -H "Content-Type: application/x-rpm" --data-binary @"$$rpm"
|
||||||
|
done
|
||||||
|
depends_on: [package]
|
||||||
|
backend_options:
|
||||||
|
kubernetes:
|
||||||
|
serviceAccountName: default
|
||||||
|
resources:
|
||||||
|
requests: {memory: 128Mi, cpu: 100m}
|
||||||
|
limits: {memory: 512Mi, cpu: 500m}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: lint
|
||||||
|
image: golang:1.25
|
||||||
|
commands:
|
||||||
|
- make lint
|
||||||
|
backend_options:
|
||||||
|
kubernetes:
|
||||||
|
serviceAccountName: default
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 512Mi
|
||||||
|
cpu: 1
|
||||||
|
limits:
|
||||||
|
memory: 2Gi
|
||||||
|
cpu: 2
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
image: golang:1.25
|
||||||
|
commands:
|
||||||
|
- make test
|
||||||
|
backend_options:
|
||||||
|
kubernetes:
|
||||||
|
serviceAccountName: default
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 512Mi
|
||||||
|
cpu: 1
|
||||||
|
limits:
|
||||||
|
memory: 2Gi
|
||||||
|
cpu: 2
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
.PHONY: build install test lint fmt clean tidy rpm rpm-package patch minor major check-go e2e e2e-vault e2e-openbao e2e-up e2e-down
|
||||||
|
|
||||||
|
BINARY := vault-plugin-secrets-gpg
|
||||||
|
PKG := ./cmd/vault-plugin-secrets-gpg
|
||||||
|
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev")
|
||||||
|
OS ?= $(shell go env GOOS)
|
||||||
|
ARCH ?= $(shell go env GOARCH)
|
||||||
|
PLUGIN_DIR ?= ./dist
|
||||||
|
|
||||||
|
GO_VERSION_REQUIRED := 1.25
|
||||||
|
GO_VERSION_ACTUAL := $(shell go version | sed 's/go version go\([0-9]*\.[0-9]*\).*/\1/')
|
||||||
|
|
||||||
|
check-go:
|
||||||
|
@if [ "$$(printf '%s\n%s' "$(GO_VERSION_REQUIRED)" "$(GO_VERSION_ACTUAL)" | sort -V | head -1)" != "$(GO_VERSION_REQUIRED)" ]; then \
|
||||||
|
echo "ERROR: Go >= $(GO_VERSION_REQUIRED) required, found $(GO_VERSION_ACTUAL)"; exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
build: check-go tidy
|
||||||
|
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(PLUGIN_DIR)/$(BINARY) $(PKG)
|
||||||
|
|
||||||
|
install: build
|
||||||
|
@echo "Built $(PLUGIN_DIR)/$(BINARY) (register it with: vault plugin register -sha256=<sha> secret $(BINARY))"
|
||||||
|
|
||||||
|
test: check-go
|
||||||
|
go test -race -count=1 ./...
|
||||||
|
|
||||||
|
lint: check-go
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
fmt: check-go
|
||||||
|
gofmt -w .
|
||||||
|
|
||||||
|
tidy:
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf $(PLUGIN_DIR)
|
||||||
|
|
||||||
|
rpm: build rpm-package
|
||||||
|
|
||||||
|
rpm-package:
|
||||||
|
./scripts/build-rpm.sh $(VERSION)
|
||||||
|
|
||||||
|
# End-to-end tests bring up Vault and OpenBao in Docker and drive the full key
|
||||||
|
# lifecycle (generate/sign/verify/encrypt/decrypt/rotate) against each with the
|
||||||
|
# same plugin binary.
|
||||||
|
e2e:
|
||||||
|
./scripts/e2e.sh
|
||||||
|
|
||||||
|
e2e-vault:
|
||||||
|
ENGINES=vault ./scripts/e2e.sh
|
||||||
|
|
||||||
|
e2e-openbao:
|
||||||
|
ENGINES=openbao ./scripts/e2e.sh
|
||||||
|
|
||||||
|
e2e-up:
|
||||||
|
docker compose -f test/docker-compose.yml up -d --build
|
||||||
|
|
||||||
|
e2e-down:
|
||||||
|
docker compose -f test/docker-compose.yml down -v
|
||||||
|
|
||||||
|
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
|
||||||
|
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
|
||||||
|
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
|
||||||
|
_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
|
||||||
|
_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
|
||||||
|
|
||||||
|
patch:
|
||||||
|
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
|
||||||
|
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
|
||||||
|
|
||||||
|
minor:
|
||||||
|
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
|
||||||
|
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
|
||||||
|
|
||||||
|
major:
|
||||||
|
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
|
||||||
|
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
|
||||||
@@ -1,3 +1,100 @@
|
|||||||
# vault-plugin-secrets-gpg
|
# vault-plugin-secrets-gpg
|
||||||
|
|
||||||
HashiCorp Vault / OpenBao secrets engine for GPG/OpenPGP keys (sign/verify/encrypt/decrypt, transit-style versioned keys, pass-compatible)
|
A HashiCorp Vault / OpenBao secrets engine that manages **GPG / OpenPGP keys**
|
||||||
|
and performs **signing, verification, encryption and decryption** with them —
|
||||||
|
the transit engine, but the key material is OpenPGP.
|
||||||
|
|
||||||
|
Keys are generated and stored inside Vault's own barrier storage (seal-wrapped
|
||||||
|
under `key/`) and the private key never leaves Vault unless a key is explicitly
|
||||||
|
marked `exportable`. Each named key is **versioned**: rotating adds a new
|
||||||
|
OpenPGP entity, while older versions are retained so archived signatures still
|
||||||
|
verify and old ciphertext still decrypts.
|
||||||
|
|
||||||
|
The armored public key the engine hands back is a standard, importable OpenPGP
|
||||||
|
key, so `gpg`, [`pass`](https://www.passwordstore.org/) and friends can encrypt
|
||||||
|
to it while the private key stays in Vault and decryption is delegated back to
|
||||||
|
`decrypt/<name>`.
|
||||||
|
|
||||||
|
```
|
||||||
|
gpg / pass ──encrypt to public key──▶ binary .gpg
|
||||||
|
│ │
|
||||||
|
│ import public key │ decrypt/<name>
|
||||||
|
▼ ▼
|
||||||
|
export/public-key/<name> ◀── Vault ──▶ private key (never leaves the barrier)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Paths
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `keys/<name>` (write/read/delete, list `keys/`) | generate a key (`rsa-2048`, `rsa-3072`, `rsa-4096`, `ed25519`); read metadata + armored public key |
|
||||||
|
| `keys/<name>/config` | set `min_decryption_version`, `deletion_allowed`, `exportable` |
|
||||||
|
| `keys/<name>/rotate` | add a new version (older versions kept for decrypt/verify) |
|
||||||
|
| `keys/<name>/import` | import an existing armored private key |
|
||||||
|
| `export/<public-key\|private-key>/<name>[/<version>]` | export armored key material |
|
||||||
|
| `sign/<name>` / `verify/<name>` | detached OpenPGP signature over base64 input |
|
||||||
|
| `encrypt/<name>` / `decrypt/<name>` | OpenPGP encrypt to / decrypt with the key |
|
||||||
|
|
||||||
|
Creating a key is **idempotent** — writing `keys/<name>` again returns the
|
||||||
|
existing key rather than regenerating it. Deletion is refused unless
|
||||||
|
`deletion_allowed=true` is set via `keys/<name>/config`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# register + enable (the plugin_directory must contain the binary)
|
||||||
|
sha=$(sha256sum /opt/vault-plugins/vault-plugin-secrets-gpg | cut -d' ' -f1)
|
||||||
|
vault plugin register -sha256=$sha secret vault-plugin-secrets-gpg
|
||||||
|
vault secrets enable -path=gpg vault-plugin-secrets-gpg
|
||||||
|
|
||||||
|
# generate a key
|
||||||
|
vault write gpg/keys/app algorithm=rsa-4096 identity="App <app@unkin.net>"
|
||||||
|
vault read gpg/keys/app # metadata + armored public key
|
||||||
|
|
||||||
|
# sign / verify (input and output are base64 / armored)
|
||||||
|
sig=$(vault write -field=signature gpg/sign/app input=$(base64 <<<"ship it"))
|
||||||
|
vault write -field=valid gpg/verify/app input=$(base64 <<<"ship it") signature="$sig"
|
||||||
|
|
||||||
|
# encrypt / decrypt
|
||||||
|
ct=$(vault write -field=ciphertext gpg/encrypt/app plaintext=$(base64 <<<"secret"))
|
||||||
|
vault write -field=plaintext gpg/decrypt/app ciphertext="$ct" | base64 -d
|
||||||
|
|
||||||
|
# rotate; old ciphertext still decrypts
|
||||||
|
vault write gpg/keys/app/rotate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using it with `pass`
|
||||||
|
|
||||||
|
The engine holds the private key; `pass`/`gpg` only need the **public** key to
|
||||||
|
encrypt, and delegate decryption back to Vault.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# import the engine's public key into your gpg keyring
|
||||||
|
vault read -field=public_key gpg/keys/app | gpg --import
|
||||||
|
fpr=$(vault read -field=fingerprint gpg/keys/app)
|
||||||
|
|
||||||
|
# initialise a password store against it
|
||||||
|
pass init "$fpr"
|
||||||
|
pass insert email/example # writes ~/.password-store/email/example.gpg (binary OpenPGP)
|
||||||
|
|
||||||
|
# decrypt an entry through Vault (decrypt accepts binary ciphertext directly)
|
||||||
|
vault write -field=plaintext gpg/decrypt/app \
|
||||||
|
ciphertext=$(base64 -w0 ~/.password-store/email/example.gpg) | base64 -d
|
||||||
|
```
|
||||||
|
|
||||||
|
`decrypt/<name>` auto-detects ASCII-armored vs raw-binary input, so the binary
|
||||||
|
`.gpg` files `pass` writes work without conversion. A thin `gpg`/`pass`
|
||||||
|
wrapper that routes decryption to Vault automatically can be layered on top.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make build # -> dist/vault-plugin-secrets-gpg
|
||||||
|
make test lint # go test -race / go vet
|
||||||
|
make e2e # full lifecycle against Vault + OpenBao in Docker
|
||||||
|
make rpm # vault + openbao RPM flavours
|
||||||
|
```
|
||||||
|
|
||||||
|
CI (Woodpecker) runs pre-commit/build/lint/test on PRs and builds+publishes the
|
||||||
|
RPMs on a `v*` tag. The unit tests include real-`gpg` and real-`pass` interop
|
||||||
|
checks (skipped automatically when those binaries are absent).
|
||||||
|
|||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
// Package gpg implements a Vault / OpenBao secrets engine that manages GPG /
|
||||||
|
// OpenPGP keys and performs signing, verification, encryption and decryption
|
||||||
|
// with them — a transit-style engine whose key material happens to be OpenPGP.
|
||||||
|
//
|
||||||
|
// Keys are generated and stored inside Vault's own barrier storage (seal-wrapped
|
||||||
|
// under key/) and never leave it unless explicitly marked exportable. Each named
|
||||||
|
// key is versioned: rotating adds a new OpenPGP entity while older versions are
|
||||||
|
// retained so archived signatures still verify and old ciphertext still decrypts.
|
||||||
|
//
|
||||||
|
// The exported public key is a standard, importable OpenPGP key, so tools like
|
||||||
|
// `gpg` and `pass` can encrypt to it while the private key stays in Vault and
|
||||||
|
// decryption is delegated back to the decrypt/<name> path.
|
||||||
|
package gpg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/hashicorp/vault/sdk/framework"
|
||||||
|
"github.com/hashicorp/vault/sdk/helper/locksutil"
|
||||||
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyPrefix = "key/"
|
||||||
|
|
||||||
|
type gpgBackend struct {
|
||||||
|
*framework.Backend
|
||||||
|
|
||||||
|
// locks serializes read-modify-write cycles per key name (rotate, config,
|
||||||
|
// delete), following the transit engine's pattern.
|
||||||
|
locks []*locksutil.LockEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factory returns a configured gpg secrets backend.
|
||||||
|
func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) {
|
||||||
|
b := backend()
|
||||||
|
if err := b.Setup(ctx, conf); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func backend() *gpgBackend {
|
||||||
|
b := &gpgBackend{
|
||||||
|
locks: locksutil.CreateLocks(),
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Backend = &framework.Backend{
|
||||||
|
Help: strings.TrimSpace(backendHelp),
|
||||||
|
BackendType: logical.TypeLogical,
|
||||||
|
PathsSpecial: &logical.Paths{
|
||||||
|
SealWrapStorage: []string{keyPrefix},
|
||||||
|
},
|
||||||
|
Paths: framework.PathAppend(
|
||||||
|
[]*framework.Path{
|
||||||
|
pathKeys(b),
|
||||||
|
pathKeysList(b),
|
||||||
|
pathKeyConfig(b),
|
||||||
|
pathRotate(b),
|
||||||
|
pathImport(b),
|
||||||
|
pathExport(b),
|
||||||
|
pathSign(b),
|
||||||
|
pathVerify(b),
|
||||||
|
pathEncrypt(b),
|
||||||
|
pathDecrypt(b),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *gpgBackend) lockForKey(name string) *locksutil.LockEntry {
|
||||||
|
return locksutil.LockForKey(b.locks, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getKey loads a named key from storage, returning (nil, nil) when absent.
|
||||||
|
func (b *gpgBackend) getKey(ctx context.Context, s logical.Storage, name string) (*gpgKey, error) {
|
||||||
|
entry, err := s.Get(ctx, keyPrefix+name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if entry == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
key := &gpgKey{}
|
||||||
|
if err := entry.DecodeJSON(key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if key.Versions == nil {
|
||||||
|
key.Versions = map[int]keyVersion{}
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *gpgBackend) putKey(ctx context.Context, s logical.Storage, key *gpgKey) error {
|
||||||
|
entry, err := logical.StorageEntryJSON(keyPrefix+key.Name, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.Put(ctx, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendHelp = `
|
||||||
|
The gpg secrets engine manages OpenPGP keys and performs cryptographic
|
||||||
|
operations with them without the private key ever leaving Vault.
|
||||||
|
|
||||||
|
Generate or import a key under keys/<name>, then use sign, verify, encrypt and
|
||||||
|
decrypt just as you would with transit. Keys are versioned: keys/<name>/rotate
|
||||||
|
adds a new generation while older versions keep working for decrypt and verify.
|
||||||
|
|
||||||
|
The armored public key returned by keys/<name> (or export/public-key/<name>) is
|
||||||
|
a standard OpenPGP key that gpg, pass and other tooling can import and encrypt
|
||||||
|
to; decryption is delegated back to decrypt/<name>.
|
||||||
|
`
|
||||||
+228
@@ -0,0 +1,228 @@
|
|||||||
|
package gpg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestBackend(t *testing.T) (logical.Backend, logical.Storage) {
|
||||||
|
t.Helper()
|
||||||
|
config := logical.TestBackendConfig()
|
||||||
|
config.StorageView = &logical.InmemStorage{}
|
||||||
|
b, err := Factory(context.Background(), config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Factory: %v", err)
|
||||||
|
}
|
||||||
|
return b, config.StorageView
|
||||||
|
}
|
||||||
|
|
||||||
|
func do(t *testing.T, b logical.Backend, s logical.Storage, op logical.Operation, path string, data map[string]interface{}) *logical.Response {
|
||||||
|
t.Helper()
|
||||||
|
resp, err := b.HandleRequest(context.Background(), &logical.Request{
|
||||||
|
Operation: op,
|
||||||
|
Path: path,
|
||||||
|
Data: data,
|
||||||
|
Storage: s,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %s: err %v", op, path, err)
|
||||||
|
}
|
||||||
|
if resp != nil && resp.IsError() {
|
||||||
|
t.Fatalf("%s %s: %v", op, path, resp.Error())
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func createKey(t *testing.T, b logical.Backend, s logical.Storage, name, algorithm string) *logical.Response {
|
||||||
|
t.Helper()
|
||||||
|
return do(t, b, s, logical.CreateOperation, "keys/"+name, map[string]interface{}{
|
||||||
|
"algorithm": algorithm,
|
||||||
|
"identity": "Test Key <test@unkin.net>",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
|
||||||
|
|
||||||
|
func TestKeyLifecycle(t *testing.T) {
|
||||||
|
b, s := newTestBackend(t)
|
||||||
|
|
||||||
|
resp := createKey(t, b, s, "app", "rsa-2048")
|
||||||
|
if resp.Data["latest_version"].(int) != 1 {
|
||||||
|
t.Fatalf("expected latest_version 1, got %v", resp.Data["latest_version"])
|
||||||
|
}
|
||||||
|
pub, _ := resp.Data["public_key"].(string)
|
||||||
|
if !strings.HasPrefix(pub, "-----BEGIN PGP PUBLIC KEY BLOCK-----") {
|
||||||
|
t.Fatalf("public_key not armored: %q", pub[:min(40, len(pub))])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creation is idempotent — same fingerprint, no regeneration.
|
||||||
|
fp := resp.Data["fingerprint"].(string)
|
||||||
|
resp2 := createKey(t, b, s, "app", "rsa-2048")
|
||||||
|
if resp2.Data["fingerprint"].(string) != fp {
|
||||||
|
t.Fatal("re-create regenerated the key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// List.
|
||||||
|
list := do(t, b, s, logical.ListOperation, "keys/", nil)
|
||||||
|
if keys, _ := list.Data["keys"].([]string); len(keys) != 1 || keys[0] != "app" {
|
||||||
|
t.Fatalf("unexpected list: %v", list.Data["keys"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignVerify(t *testing.T) {
|
||||||
|
b, s := newTestBackend(t)
|
||||||
|
createKey(t, b, s, "signer", "ed25519")
|
||||||
|
|
||||||
|
sign := do(t, b, s, logical.UpdateOperation, "sign/signer", map[string]interface{}{
|
||||||
|
"input": b64("hello world"),
|
||||||
|
})
|
||||||
|
sig := sign.Data["signature"].(string)
|
||||||
|
if !strings.HasPrefix(sig, "-----BEGIN PGP SIGNATURE-----") {
|
||||||
|
t.Fatalf("signature not armored: %q", sig[:min(40, len(sig))])
|
||||||
|
}
|
||||||
|
|
||||||
|
ok := do(t, b, s, logical.UpdateOperation, "verify/signer", map[string]interface{}{
|
||||||
|
"input": b64("hello world"),
|
||||||
|
"signature": sig,
|
||||||
|
})
|
||||||
|
if !ok.Data["valid"].(bool) {
|
||||||
|
t.Fatal("valid signature reported invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
bad := do(t, b, s, logical.UpdateOperation, "verify/signer", map[string]interface{}{
|
||||||
|
"input": b64("tampered"),
|
||||||
|
"signature": sig,
|
||||||
|
})
|
||||||
|
if bad.Data["valid"].(bool) {
|
||||||
|
t.Fatal("tampered message reported valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptDecrypt(t *testing.T) {
|
||||||
|
b, s := newTestBackend(t)
|
||||||
|
createKey(t, b, s, "box", "rsa-2048")
|
||||||
|
secret := "correct horse battery staple"
|
||||||
|
|
||||||
|
for _, format := range []string{"base64", "ascii-armor"} {
|
||||||
|
enc := do(t, b, s, logical.UpdateOperation, "encrypt/box", map[string]interface{}{
|
||||||
|
"plaintext": b64(secret),
|
||||||
|
"format": format,
|
||||||
|
})
|
||||||
|
ct := enc.Data["ciphertext"].(string)
|
||||||
|
|
||||||
|
dec := do(t, b, s, logical.UpdateOperation, "decrypt/box", map[string]interface{}{
|
||||||
|
"ciphertext": ct,
|
||||||
|
})
|
||||||
|
got, _ := base64.StdEncoding.DecodeString(dec.Data["plaintext"].(string))
|
||||||
|
if string(got) != secret {
|
||||||
|
t.Fatalf("[%s] roundtrip mismatch: %q", format, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRotateRetainsOldCiphertext(t *testing.T) {
|
||||||
|
b, s := newTestBackend(t)
|
||||||
|
createKey(t, b, s, "rot", "rsa-2048")
|
||||||
|
|
||||||
|
// Encrypt against version 1.
|
||||||
|
enc := do(t, b, s, logical.UpdateOperation, "encrypt/rot", map[string]interface{}{
|
||||||
|
"plaintext": b64("v1 secret"),
|
||||||
|
})
|
||||||
|
oldCt := enc.Data["ciphertext"].(string)
|
||||||
|
|
||||||
|
// Rotate to version 2.
|
||||||
|
r := do(t, b, s, logical.UpdateOperation, "keys/rot/rotate", nil)
|
||||||
|
if r.Data["latest_version"].(int) != 2 {
|
||||||
|
t.Fatalf("expected latest_version 2, got %v", r.Data["latest_version"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old ciphertext must still decrypt.
|
||||||
|
dec := do(t, b, s, logical.UpdateOperation, "decrypt/rot", map[string]interface{}{
|
||||||
|
"ciphertext": oldCt,
|
||||||
|
})
|
||||||
|
got, _ := base64.StdEncoding.DecodeString(dec.Data["plaintext"].(string))
|
||||||
|
if string(got) != "v1 secret" {
|
||||||
|
t.Fatalf("post-rotation decrypt of v1 ciphertext failed: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raising min_decryption_version past v1 locks the old ciphertext out.
|
||||||
|
do(t, b, s, logical.UpdateOperation, "keys/rot/config", map[string]interface{}{
|
||||||
|
"min_decryption_version": 2,
|
||||||
|
})
|
||||||
|
resp, err := b.HandleRequest(context.Background(), &logical.Request{
|
||||||
|
Operation: logical.UpdateOperation,
|
||||||
|
Path: "decrypt/rot",
|
||||||
|
Data: map[string]interface{}{"ciphertext": oldCt},
|
||||||
|
Storage: s,
|
||||||
|
})
|
||||||
|
if err == nil && (resp == nil || !resp.IsError()) {
|
||||||
|
t.Fatal("expected decrypt to fail once v1 is below min_decryption_version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportImportRoundtrip(t *testing.T) {
|
||||||
|
b, s := newTestBackend(t)
|
||||||
|
|
||||||
|
do(t, b, s, logical.CreateOperation, "keys/src", map[string]interface{}{
|
||||||
|
"algorithm": "ed25519",
|
||||||
|
"identity": "Exportable <e@unkin.net>",
|
||||||
|
"exportable": true,
|
||||||
|
})
|
||||||
|
|
||||||
|
exp := do(t, b, s, logical.ReadOperation, "export/private-key/src", nil)
|
||||||
|
keys := exp.Data["keys"].(map[string]interface{})
|
||||||
|
priv := keys["1"].(string)
|
||||||
|
if !strings.HasPrefix(priv, "-----BEGIN PGP PRIVATE KEY BLOCK-----") {
|
||||||
|
t.Fatalf("private key not armored: %q", priv[:min(40, len(priv))])
|
||||||
|
}
|
||||||
|
|
||||||
|
do(t, b, s, logical.CreateOperation, "keys/dst/import", map[string]interface{}{
|
||||||
|
"private_key": priv,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The imported key must sign and verify like any other.
|
||||||
|
sign := do(t, b, s, logical.UpdateOperation, "sign/dst", map[string]interface{}{
|
||||||
|
"input": b64("imported"),
|
||||||
|
})
|
||||||
|
ok := do(t, b, s, logical.UpdateOperation, "verify/dst", map[string]interface{}{
|
||||||
|
"input": b64("imported"),
|
||||||
|
"signature": sign.Data["signature"].(string),
|
||||||
|
})
|
||||||
|
if !ok.Data["valid"].(bool) {
|
||||||
|
t.Fatal("imported key failed to verify its own signature")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeletionGuard(t *testing.T) {
|
||||||
|
b, s := newTestBackend(t)
|
||||||
|
createKey(t, b, s, "keep", "rsa-2048")
|
||||||
|
|
||||||
|
resp, err := b.HandleRequest(context.Background(), &logical.Request{
|
||||||
|
Operation: logical.DeleteOperation,
|
||||||
|
Path: "keys/keep",
|
||||||
|
Storage: s,
|
||||||
|
})
|
||||||
|
if err == nil && (resp == nil || !resp.IsError()) {
|
||||||
|
t.Fatal("delete should be refused when deletion_allowed is false")
|
||||||
|
}
|
||||||
|
|
||||||
|
do(t, b, s, logical.UpdateOperation, "keys/keep/config", map[string]interface{}{
|
||||||
|
"deletion_allowed": true,
|
||||||
|
})
|
||||||
|
do(t, b, s, logical.DeleteOperation, "keys/keep", nil)
|
||||||
|
|
||||||
|
if got := do(t, b, s, logical.ReadOperation, "keys/keep", nil); got != nil {
|
||||||
|
t.Fatal("key still present after allowed delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
hclog "github.com/hashicorp/go-hclog"
|
||||||
|
"github.com/hashicorp/vault/api"
|
||||||
|
"github.com/hashicorp/vault/sdk/plugin"
|
||||||
|
|
||||||
|
gpg "git.unkin.net/unkin/vault-plugin-secrets-gpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
apiClientMeta := &api.PluginAPIClientMeta{}
|
||||||
|
flags := apiClientMeta.FlagSet()
|
||||||
|
if err := flags.Parse(os.Args[1:]); err != nil {
|
||||||
|
logger := hclog.New(&hclog.LoggerOptions{})
|
||||||
|
logger.Error("failed to parse flags", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsConfig := apiClientMeta.GetTLSConfig()
|
||||||
|
tlsProviderFunc := api.VaultPluginTLSProvider(tlsConfig)
|
||||||
|
|
||||||
|
err := plugin.ServeMultiplex(&plugin.ServeOpts{
|
||||||
|
BackendFactoryFunc: gpg.Factory,
|
||||||
|
TLSProviderFunc: tlsProviderFunc,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger := hclog.New(&hclog.LoggerOptions{})
|
||||||
|
logger.Error("plugin shutting down", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
module git.unkin.net/unkin/vault-plugin-secrets-gpg
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/ProtonMail/go-crypto v1.4.1
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3
|
||||||
|
github.com/hashicorp/vault/api v1.15.0
|
||||||
|
github.com/hashicorp/vault/sdk v0.14.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/Microsoft/go-winio v0.6.1 // indirect
|
||||||
|
github.com/armon/go-metrics v0.4.1 // indirect
|
||||||
|
github.com/armon/go-radix v1.0.0 // indirect
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/cloudflare/circl v1.6.2 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
|
github.com/docker/docker v26.1.5+incompatible // indirect
|
||||||
|
github.com/docker/go-connections v0.4.0 // indirect
|
||||||
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
|
github.com/evanphx/json-patch/v5 v5.6.0 // indirect
|
||||||
|
github.com/fatih/color v1.16.0 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||||
|
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||||
|
github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.0 // indirect
|
||||||
|
github.com/hashicorp/go-kms-wrapping/v2 v2.0.8 // indirect
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
|
github.com/hashicorp/go-plugin v1.6.1 // indirect
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
|
||||||
|
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
|
||||||
|
github.com/hashicorp/go-secure-stdlib/mlock v0.1.2 // indirect
|
||||||
|
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 // indirect
|
||||||
|
github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.0 // indirect
|
||||||
|
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
|
||||||
|
github.com/hashicorp/go-sockaddr v1.0.6 // indirect
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
||||||
|
github.com/hashicorp/go-version v1.6.0 // indirect
|
||||||
|
github.com/hashicorp/golang-lru v0.5.4 // indirect
|
||||||
|
github.com/hashicorp/hcl v1.0.1-vault-5 // indirect
|
||||||
|
github.com/hashicorp/yamux v0.1.1 // indirect
|
||||||
|
github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||||
|
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
|
github.com/oklog/run v1.1.0 // indirect
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
|
github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b // indirect
|
||||||
|
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect
|
||||||
|
github.com/pierrec/lz4 v2.6.1+incompatible // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
|
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||||
|
github.com/sasha-s/go-deadlock v0.2.0 // indirect
|
||||||
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||||
|
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||||
|
go.uber.org/atomic v1.9.0 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/mod v0.32.0 // indirect
|
||||||
|
golang.org/x/net v0.50.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
golang.org/x/time v0.5.0 // indirect
|
||||||
|
golang.org/x/tools v0.41.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||||
|
google.golang.org/grpc v1.79.2 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
|
||||||
|
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
|
||||||
|
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
|
||||||
|
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
||||||
|
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||||
|
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
|
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
|
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
|
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
|
github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA=
|
||||||
|
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
|
||||||
|
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
|
||||||
|
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||||
|
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||||
|
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA=
|
||||||
|
github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
|
||||||
|
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
|
||||||
|
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
|
||||||
|
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||||
|
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||||
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/docker v26.1.5+incompatible h1:NEAxTwEjxV6VbBMBoGG3zPqbiJosIApZjxlbrG9q3/g=
|
||||||
|
github.com/docker/docker v26.1.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
|
||||||
|
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww=
|
||||||
|
github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4=
|
||||||
|
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||||
|
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||||
|
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||||
|
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||||
|
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/frankban/quicktest v1.14.0 h1:+cqqvzZV87b4adx/5ayVOaYZ2CrvM4ejQvUdBzPPUss=
|
||||||
|
github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
|
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||||
|
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||||
|
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||||
|
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
|
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||||
|
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||||
|
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||||
|
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||||
|
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
|
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||||
|
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||||
|
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
|
||||||
|
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||||
|
github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.0 h1:pSjQfW3vPtrOTcasTUKgCTQT7OGPPTTMVRrOfU6FJD8=
|
||||||
|
github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.0/go.mod h1:xvb32K2keAc+R8DSFG2IwDcydK9DBQE+fGA5fsw6hSk=
|
||||||
|
github.com/hashicorp/go-kms-wrapping/v2 v2.0.8 h1:9Q2lu1YbbmiAgvYZ7Pr31RdlVonUpX+mmDL7Z7qTA2U=
|
||||||
|
github.com/hashicorp/go-kms-wrapping/v2 v2.0.8/go.mod h1:qTCjxGig/kjuj3hk1z8pOUrzbse/GxB1tGfbrq8tGJg=
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||||
|
github.com/hashicorp/go-plugin v1.6.1 h1:P7MR2UP6gNKGPp+y7EZw2kOiq4IR9WiqLvp0XOsVdwI=
|
||||||
|
github.com/hashicorp/go-plugin v1.6.1/go.mod h1:XPHFku2tFo3o3QKFgSYo+cghcUhw1NA1hZyMK0PWAw0=
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
|
||||||
|
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
|
||||||
|
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
|
||||||
|
github.com/hashicorp/go-secure-stdlib/mlock v0.1.2 h1:p4AKXPPS24tO8Wc8i1gLvSKdmkiSY5xuju57czJ/IJQ=
|
||||||
|
github.com/hashicorp/go-secure-stdlib/mlock v0.1.2/go.mod h1:zq93CJChV6L9QTfGKtfBxKqD7BqqXx5O04A/ns2p5+I=
|
||||||
|
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 h1:iBt4Ew4XEGLfh6/bPk4rSYmuZJGizr6/x/AEizP0CQc=
|
||||||
|
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8/go.mod h1:aiJI+PIApBRQG7FZTEBx5GiiX+HbOHilUdNxUZi4eV0=
|
||||||
|
github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.0 h1:7Yran48kl6X7jfUg3sfYDrFot1gD3LvzdC3oPu5l/qo=
|
||||||
|
github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.0/go.mod h1:9WJFu7L3d+Z4ViZmwUf+6/73/Uy7YMY1NXrB9wdElYE=
|
||||||
|
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
|
||||||
|
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
|
||||||
|
github.com/hashicorp/go-sockaddr v1.0.6 h1:RSG8rKU28VTUTvEKghe5gIhIQpv8evvNpnDEyqO4u9I=
|
||||||
|
github.com/hashicorp/go-sockaddr v1.0.6/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
|
||||||
|
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||||
|
github.com/hashicorp/hcl v1.0.1-vault-5 h1:kI3hhbbyzr4dldA8UdTb7ZlVVlI2DACdCfz31RPDgJM=
|
||||||
|
github.com/hashicorp/hcl v1.0.1-vault-5/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
|
||||||
|
github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA=
|
||||||
|
github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8=
|
||||||
|
github.com/hashicorp/vault/sdk v0.14.0 h1:8vagjlpLurkFTnKT9aFSGs4U1XnK2IFytnWSxgFrDo0=
|
||||||
|
github.com/hashicorp/vault/sdk v0.14.0/go.mod h1:3hnGK5yjx3CW2hFyk+Dw1jDgKxdBvUvjyxMHhq0oUFc=
|
||||||
|
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
|
||||||
|
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
|
||||||
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
|
github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c=
|
||||||
|
github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo=
|
||||||
|
github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 h1:hgVxRoDDPtQE68PT4LFvNlPz2nBKd3OMlGKIQ69OmR4=
|
||||||
|
github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531/go.mod h1:fqTUQpVYBvhCNIsMXGl2GE9q6z94DIP6NtFKXCSTVbg=
|
||||||
|
github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d h1:J8tJzRyiddAFF65YVgxli+TyWBi0f79Sld6rJP6CBcY=
|
||||||
|
github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d/go.mod h1:b+Q3v8Yrg5o15d71PSUraUzYb+jWl6wQMSBXSGS/hv0=
|
||||||
|
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||||
|
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||||
|
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||||
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
|
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||||
|
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||||
|
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||||
|
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||||
|
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||||
|
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
|
||||||
|
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||||
|
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
|
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||||
|
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
|
||||||
|
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b h1:YWuSjZCQAPM8UUBLkYUk1e+rZcvWHJmFb6i6rM44Xs8=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b/go.mod h1:3OVijpioIKYWTqjiG0zfF6wvoJ4fAXGbjdZuI2NgsRQ=
|
||||||
|
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
|
||||||
|
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||||
|
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ=
|
||||||
|
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o=
|
||||||
|
github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM=
|
||||||
|
github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
|
||||||
|
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||||
|
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||||
|
github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||||
|
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
|
||||||
|
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||||
|
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||||
|
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
|
||||||
|
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
|
||||||
|
github.com/sasha-s/go-deadlock v0.2.0 h1:lMqc+fUb7RrFS3gQLtoQsJ7/6TV/pAIFvBsqX73DK8Y=
|
||||||
|
github.com/sasha-s/go-deadlock v0.2.0/go.mod h1:StQn567HiB1fF2yJ44N9au7wOhrPS3iZqiDbRupzT10=
|
||||||
|
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||||
|
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||||
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc=
|
||||||
|
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||||
|
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 h1:j9+03ymgYhPKmeXGk5Zu+cIZOlVzd9Zv7QIiyItjFBU=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0/go.mod h1:Y5+XiUG4Emn1hTfciPzGPJaSI+RpDts6BnCIir0SLqk=
|
||||||
|
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||||
|
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||||
|
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||||
|
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||||
|
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||||
|
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
|
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
|
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||||
|
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
|
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
||||||
|
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY=
|
||||||
|
gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
package gpg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGPGInterop proves the pass/gpg integration contract end to end using the
|
||||||
|
// real gpg binary: our exported public key imports into a gpg keyring, gpg
|
||||||
|
// encrypts to it producing a *binary* OpenPGP message (exactly what `pass`
|
||||||
|
// writes), and our engine's decrypt path opens it. It also checks the reverse:
|
||||||
|
// gpg verifies a detached signature the engine produced.
|
||||||
|
func TestGPGInterop(t *testing.T) {
|
||||||
|
gpgBin, err := exec.LookPath("gpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("gpg not installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
b, s := newTestBackend(t)
|
||||||
|
createKey(t, b, s, "pass", "rsa-2048")
|
||||||
|
|
||||||
|
read := do(t, b, s, logical.ReadOperation, "keys/pass", nil)
|
||||||
|
pubArmored := read.Data["public_key"].(string)
|
||||||
|
fingerprint := read.Data["fingerprint"].(string)
|
||||||
|
|
||||||
|
// Isolated gpg home so we never touch the user's real keyring.
|
||||||
|
gnupg := t.TempDir()
|
||||||
|
run := func(stdin string, args ...string) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command(gpgBin, append([]string{"--homedir", gnupg, "--batch", "--yes", "--trust-model", "always"}, args...)...)
|
||||||
|
cmd.Stdin = strings.NewReader(stdin)
|
||||||
|
var out, errb strings.Builder
|
||||||
|
cmd.Stdout = &out
|
||||||
|
cmd.Stderr = &errb
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("gpg %v: %v\n%s", args, err, errb.String())
|
||||||
|
}
|
||||||
|
return out.String(), errb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Import the engine's public key into gpg.
|
||||||
|
run(pubArmored, "--import")
|
||||||
|
|
||||||
|
// 2. Encrypt with gpg the way pass does: binary (no --armor) OpenPGP.
|
||||||
|
secret := "hunter2\n"
|
||||||
|
binCipher := filepath.Join(gnupg, "msg.gpg")
|
||||||
|
cmd := exec.Command(gpgBin, "--homedir", gnupg, "--batch", "--yes", "--trust-model", "always",
|
||||||
|
"-r", fingerprint, "--output", binCipher, "--encrypt")
|
||||||
|
cmd.Stdin = strings.NewReader(secret)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("gpg encrypt: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
binBytes, err := os.ReadFile(binCipher)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. The engine decrypts gpg's binary ciphertext (base64-wrapped for the API).
|
||||||
|
dec := do(t, b, s, logical.UpdateOperation, "decrypt/pass", map[string]interface{}{
|
||||||
|
"ciphertext": base64.StdEncoding.EncodeToString(binBytes),
|
||||||
|
})
|
||||||
|
got, _ := base64.StdEncoding.DecodeString(dec.Data["plaintext"].(string))
|
||||||
|
if string(got) != secret {
|
||||||
|
t.Fatalf("engine failed to decrypt gpg's binary ciphertext: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Reverse direction: engine signs, gpg verifies.
|
||||||
|
sign := do(t, b, s, logical.UpdateOperation, "sign/pass", map[string]interface{}{
|
||||||
|
"input": base64.StdEncoding.EncodeToString([]byte(secret)),
|
||||||
|
})
|
||||||
|
sigFile := filepath.Join(gnupg, "msg.sig")
|
||||||
|
msgFile := filepath.Join(gnupg, "msg.txt")
|
||||||
|
if err := os.WriteFile(sigFile, []byte(sign.Data["signature"].(string)), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(msgFile, []byte(secret), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
run("", "--verify", sigFile, msgFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPassInterop drives the actual `pass` binary end to end: it initialises a
|
||||||
|
// password store against the engine's exported public key, inserts a secret
|
||||||
|
// (pass shells out to gpg to write a binary .gpg file), and the engine then
|
||||||
|
// decrypts that file — the real-world flow the engine is built for.
|
||||||
|
func TestPassInterop(t *testing.T) {
|
||||||
|
passBin, err := exec.LookPath("pass")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("pass not installed")
|
||||||
|
}
|
||||||
|
gpgBin, err := exec.LookPath("gpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("gpg not installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
b, s := newTestBackend(t)
|
||||||
|
createKey(t, b, s, "store", "rsa-2048")
|
||||||
|
read := do(t, b, s, logical.ReadOperation, "keys/store", nil)
|
||||||
|
pubArmored := read.Data["public_key"].(string)
|
||||||
|
fingerprint := read.Data["fingerprint"].(string)
|
||||||
|
|
||||||
|
gnupg := t.TempDir()
|
||||||
|
storeDir := t.TempDir()
|
||||||
|
env := append(os.Environ(),
|
||||||
|
"GNUPGHOME="+gnupg,
|
||||||
|
"PASSWORD_STORE_DIR="+storeDir,
|
||||||
|
// The engine's key is imported without an ownertrust ultimatum, so tell
|
||||||
|
// pass's gpg invocations to trust it anyway (as a real deployment would
|
||||||
|
// via ownertrust or a signed key).
|
||||||
|
"PASSWORD_STORE_GPG_OPTS=--trust-model always",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Import the public key so pass/gpg can encrypt to it.
|
||||||
|
imp := exec.Command(gpgBin, "--homedir", gnupg, "--batch", "--yes", "--import")
|
||||||
|
imp.Stdin = strings.NewReader(pubArmored)
|
||||||
|
if out, err := imp.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("gpg import: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
passRun := func(stdin string, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command(passBin, args...)
|
||||||
|
cmd.Env = env
|
||||||
|
cmd.Stdin = strings.NewReader(stdin)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("pass %v: %v\n%s", args, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
passRun("", "init", fingerprint)
|
||||||
|
passRun("s3cr3t\n", "insert", "--echo", "email/test")
|
||||||
|
|
||||||
|
// pass wrote email/test.gpg as a binary OpenPGP message; decrypt it via the engine.
|
||||||
|
gpgFile := filepath.Join(storeDir, "email", "test.gpg")
|
||||||
|
binBytes, err := os.ReadFile(gpgFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading pass-written file: %v", err)
|
||||||
|
}
|
||||||
|
dec := do(t, b, s, logical.UpdateOperation, "decrypt/store", map[string]interface{}{
|
||||||
|
"ciphertext": base64.StdEncoding.EncodeToString(binBytes),
|
||||||
|
})
|
||||||
|
got, _ := base64.StdEncoding.DecodeString(dec.Data["plaintext"].(string))
|
||||||
|
if strings.TrimSpace(string(got)) != "s3cr3t" {
|
||||||
|
t.Fatalf("engine failed to decrypt pass entry: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
package gpg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-crypto/openpgp"
|
||||||
|
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||||
|
"github.com/ProtonMail/go-crypto/openpgp/packet"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
pgpMessageType = "PGP MESSAGE"
|
||||||
|
armorPrefix = "-----BEGIN PGP"
|
||||||
|
)
|
||||||
|
|
||||||
|
// keyVersion is one generation of a named GPG key. Each version is a complete,
|
||||||
|
// self-contained OpenPGP entity (its own fingerprint), so rotating a key never
|
||||||
|
// invalidates data that was signed or encrypted against an earlier version.
|
||||||
|
type keyVersion struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
CreationTime time.Time `json:"creation_time"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
KeyID string `json:"key_id"`
|
||||||
|
// PrivateKey is the ASCII-armored OpenPGP private key for this version.
|
||||||
|
PrivateKey string `json:"private_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// gpgKey is the transit-style, versioned key stored (barrier-encrypted and
|
||||||
|
// seal-wrapped) under key/<name>.
|
||||||
|
type gpgKey struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Algorithm string `json:"algorithm"`
|
||||||
|
Identity string `json:"identity"`
|
||||||
|
Versions map[int]keyVersion `json:"versions"`
|
||||||
|
LatestVersion int `json:"latest_version"`
|
||||||
|
MinDecryptionVersion int `json:"min_decryption_version"`
|
||||||
|
DeletionAllowed bool `json:"deletion_allowed"`
|
||||||
|
Exportable bool `json:"exportable"`
|
||||||
|
Imported bool `json:"imported"`
|
||||||
|
CreationTime time.Time `json:"creation_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// packetConfig maps our algorithm labels onto an OpenPGP generation config.
|
||||||
|
func packetConfig(algorithm string) (*packet.Config, error) {
|
||||||
|
cfg := &packet.Config{DefaultHash: crypto.SHA256}
|
||||||
|
switch algorithm {
|
||||||
|
case "rsa-2048":
|
||||||
|
cfg.Algorithm = packet.PubKeyAlgoRSA
|
||||||
|
cfg.RSABits = 2048
|
||||||
|
case "", "rsa-3072":
|
||||||
|
cfg.Algorithm = packet.PubKeyAlgoRSA
|
||||||
|
cfg.RSABits = 3072
|
||||||
|
case "rsa-4096":
|
||||||
|
cfg.Algorithm = packet.PubKeyAlgoRSA
|
||||||
|
cfg.RSABits = 4096
|
||||||
|
case "ed25519":
|
||||||
|
cfg.Algorithm = packet.PubKeyAlgoEdDSA
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported algorithm %q (supported: rsa-2048, rsa-3072, rsa-4096, ed25519)", algorithm)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseIdentity splits "Name (comment) <email>" into its parts. Any part may be
|
||||||
|
// absent; a bare string is treated as the name.
|
||||||
|
func parseIdentity(identity string) (name, comment, email string) {
|
||||||
|
s := strings.TrimSpace(identity)
|
||||||
|
if i := strings.LastIndex(s, "<"); i >= 0 {
|
||||||
|
if j := strings.Index(s[i:], ">"); j > 0 {
|
||||||
|
email = strings.TrimSpace(s[i+1 : i+j])
|
||||||
|
s = strings.TrimSpace(s[:i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i := strings.LastIndex(s, "("); i >= 0 {
|
||||||
|
if j := strings.Index(s[i:], ")"); j > 0 {
|
||||||
|
comment = strings.TrimSpace(s[i+1 : i+j])
|
||||||
|
s = strings.TrimSpace(s[:i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name = strings.TrimSpace(s)
|
||||||
|
return name, comment, email
|
||||||
|
}
|
||||||
|
|
||||||
|
// newKey generates version 1 of a fresh named key.
|
||||||
|
func newKey(name, algorithm, identity string, exportable bool, now time.Time) (*gpgKey, error) {
|
||||||
|
kv, err := generateVersion(algorithm, identity, 1, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &gpgKey{
|
||||||
|
Name: name,
|
||||||
|
Algorithm: algorithm,
|
||||||
|
Identity: identity,
|
||||||
|
Versions: map[int]keyVersion{1: kv},
|
||||||
|
LatestVersion: 1,
|
||||||
|
MinDecryptionVersion: 1,
|
||||||
|
Exportable: exportable,
|
||||||
|
CreationTime: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newImportedKey wraps an externally-generated armored private key as version 1.
|
||||||
|
func newImportedKey(name, armoredPrivate string, exportable bool, now time.Time) (*gpgKey, error) {
|
||||||
|
e, err := readArmoredEntity(armoredPrivate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing imported key: %w", err)
|
||||||
|
}
|
||||||
|
if e.PrivateKey == nil {
|
||||||
|
return nil, errors.New("imported material contains no private key")
|
||||||
|
}
|
||||||
|
kv := keyVersion{
|
||||||
|
Version: 1,
|
||||||
|
CreationTime: now,
|
||||||
|
Fingerprint: fmt.Sprintf("%X", e.PrimaryKey.Fingerprint),
|
||||||
|
KeyID: e.PrimaryKey.KeyIdString(),
|
||||||
|
PrivateKey: armoredPrivate,
|
||||||
|
}
|
||||||
|
return &gpgKey{
|
||||||
|
Name: name,
|
||||||
|
Algorithm: algoLabel(e),
|
||||||
|
Identity: entityIdentity(e),
|
||||||
|
Versions: map[int]keyVersion{1: kv},
|
||||||
|
LatestVersion: 1,
|
||||||
|
MinDecryptionVersion: 1,
|
||||||
|
Exportable: exportable,
|
||||||
|
Imported: true,
|
||||||
|
CreationTime: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotate appends a new key version and makes it the latest.
|
||||||
|
func (k *gpgKey) rotate(now time.Time) error {
|
||||||
|
if k.Imported {
|
||||||
|
return errors.New("cannot rotate an imported key")
|
||||||
|
}
|
||||||
|
v := k.LatestVersion + 1
|
||||||
|
kv, err := generateVersion(k.Algorithm, k.Identity, v, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
k.Versions[v] = kv
|
||||||
|
k.LatestVersion = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateVersion(algorithm, identity string, version int, now time.Time) (keyVersion, error) {
|
||||||
|
name, comment, email := parseIdentity(identity)
|
||||||
|
cfg, err := packetConfig(algorithm)
|
||||||
|
if err != nil {
|
||||||
|
return keyVersion{}, err
|
||||||
|
}
|
||||||
|
cfg.Time = func() time.Time { return now }
|
||||||
|
|
||||||
|
e, err := openpgp.NewEntity(name, comment, email, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return keyVersion{}, fmt.Errorf("generating openpgp entity: %w", err)
|
||||||
|
}
|
||||||
|
armored, err := armorPrivateKey(e)
|
||||||
|
if err != nil {
|
||||||
|
return keyVersion{}, err
|
||||||
|
}
|
||||||
|
return keyVersion{
|
||||||
|
Version: version,
|
||||||
|
CreationTime: now,
|
||||||
|
Fingerprint: fmt.Sprintf("%X", e.PrimaryKey.Fingerprint),
|
||||||
|
KeyID: e.PrimaryKey.KeyIdString(),
|
||||||
|
PrivateKey: armored,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// entityForVersion re-parses the stored armored private key for a version.
|
||||||
|
func (k *gpgKey) entityForVersion(v int) (*openpgp.Entity, error) {
|
||||||
|
kv, ok := k.Versions[v]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("key version %d does not exist", v)
|
||||||
|
}
|
||||||
|
return readArmoredEntity(kv.PrivateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyKeyring returns the public keys of every version (for verifying
|
||||||
|
// signatures made under any generation of the key).
|
||||||
|
func (k *gpgKey) verifyKeyring() (openpgp.EntityList, error) {
|
||||||
|
var list openpgp.EntityList
|
||||||
|
for v := 1; v <= k.LatestVersion; v++ {
|
||||||
|
if _, ok := k.Versions[v]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e, err := k.entityForVersion(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list = append(list, e)
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptionKeyring returns the private keys of every version at or above
|
||||||
|
// min_decryption_version, so archived ciphertext still opens after rotation.
|
||||||
|
func (k *gpgKey) decryptionKeyring() (openpgp.EntityList, error) {
|
||||||
|
var list openpgp.EntityList
|
||||||
|
for v := k.MinDecryptionVersion; v <= k.LatestVersion; v++ {
|
||||||
|
if _, ok := k.Versions[v]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e, err := k.entityForVersion(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list = append(list, e)
|
||||||
|
}
|
||||||
|
if len(list) == 0 {
|
||||||
|
return nil, errors.New("no decryption-eligible key versions")
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicKeyArmored returns the ASCII-armored public key for a version. This is
|
||||||
|
// the value you feed to `gpg --import` / `pass init <fingerprint>`.
|
||||||
|
func (k *gpgKey) publicKeyArmored(v int) (string, error) {
|
||||||
|
e, err := k.entityForVersion(v)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return armorPublicKey(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encrypt OpenPGP-encrypts plaintext to the latest version's public key.
|
||||||
|
func (k *gpgKey) encrypt(plaintext []byte, asciiArmor bool) ([]byte, error) {
|
||||||
|
e, err := k.entityForVersion(k.LatestVersion)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
var sink io.Writer = &buf
|
||||||
|
var armorWriter io.WriteCloser
|
||||||
|
if asciiArmor {
|
||||||
|
armorWriter, err = armor.Encode(&buf, pgpMessageType, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sink = armorWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := openpgp.Encrypt(sink, []*openpgp.Entity{e}, nil, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := w.Write(plaintext); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if armorWriter != nil {
|
||||||
|
if err := armorWriter.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decrypt opens ciphertext with any eligible private version. It accepts both
|
||||||
|
// ASCII-armored input and raw binary OpenPGP messages (what `pass`/`gpg` write
|
||||||
|
// by default), auto-detecting which it was given.
|
||||||
|
func (k *gpgKey) decrypt(ciphertext []byte) ([]byte, error) {
|
||||||
|
keyring, err := k.decryptionKeyring()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := dearmorIfNeeded(ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
md, err := openpgp.ReadMessage(r, keyring, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decrypting message: %w", err)
|
||||||
|
}
|
||||||
|
return io.ReadAll(md.UnverifiedBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign produces a detached signature over message using the given version
|
||||||
|
// (0 = latest).
|
||||||
|
func (k *gpgKey) sign(message []byte, version int, asciiArmor bool) ([]byte, error) {
|
||||||
|
if version == 0 {
|
||||||
|
version = k.LatestVersion
|
||||||
|
}
|
||||||
|
e, err := k.entityForVersion(version)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if asciiArmor {
|
||||||
|
err = openpgp.ArmoredDetachSign(&buf, e, bytes.NewReader(message), nil)
|
||||||
|
} else {
|
||||||
|
err = openpgp.DetachSign(&buf, e, bytes.NewReader(message), nil)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("signing: %w", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify checks a detached signature against every public version. A malformed
|
||||||
|
// or non-matching signature returns (false, nil); only unexpected failures
|
||||||
|
// return an error.
|
||||||
|
func (k *gpgKey) verify(message, signature []byte) (bool, error) {
|
||||||
|
keyring, err := k.verifyKeyring()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
sigReader, err := dearmorIfNeeded(signature)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if _, err := openpgp.CheckDetachedSignature(keyring, bytes.NewReader(message), sigReader, nil); err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OpenPGP (de)serialization helpers ---
|
||||||
|
|
||||||
|
func armorPrivateKey(e *openpgp.Entity) (string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := e.SerializePrivate(w, nil); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func armorPublicKey(e *openpgp.Entity) (string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := e.Serialize(w); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readArmoredEntity(a string) (*openpgp.Entity, error) {
|
||||||
|
block, err := armor.Decode(strings.NewReader(a))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return openpgp.ReadEntity(packet.NewReader(block.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// dearmorIfNeeded returns a reader over the raw OpenPGP packets, transparently
|
||||||
|
// stripping ASCII armor when present.
|
||||||
|
func dearmorIfNeeded(data []byte) (io.Reader, error) {
|
||||||
|
if bytes.HasPrefix(bytes.TrimSpace(data), []byte(armorPrefix)) {
|
||||||
|
block, err := armor.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return block.Body, nil
|
||||||
|
}
|
||||||
|
return bytes.NewReader(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func entityIdentity(e *openpgp.Entity) string {
|
||||||
|
for _, id := range e.Identities {
|
||||||
|
return id.Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func algoLabel(e *openpgp.Entity) string {
|
||||||
|
switch e.PrimaryKey.PubKeyAlgo {
|
||||||
|
case packet.PubKeyAlgoRSA, packet.PubKeyAlgoRSAEncryptOnly, packet.PubKeyAlgoRSASignOnly:
|
||||||
|
if bl, err := e.PrimaryKey.BitLength(); err == nil {
|
||||||
|
return fmt.Sprintf("rsa-%d", bl)
|
||||||
|
}
|
||||||
|
return "rsa"
|
||||||
|
case packet.PubKeyAlgoEdDSA, packet.PubKeyAlgoEd25519:
|
||||||
|
return "ed25519"
|
||||||
|
case packet.PubKeyAlgoECDSA:
|
||||||
|
return "ecdsa"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
# nfpm config for the vault-plugin-secrets-gpg RPM. Rendered through
|
||||||
|
# envsubst (see scripts/build-rpm.sh) then fed to `nfpm pkg`. Built once per
|
||||||
|
# target server (Vault, OpenBao); PACKAGE_NAME and PACKAGE_PLUGIN_DIR vary.
|
||||||
|
|
||||||
|
name: ${PACKAGE_NAME}
|
||||||
|
version: ${PACKAGE_VERSION}
|
||||||
|
release: ${PACKAGE_RELEASE}
|
||||||
|
arch: ${PACKAGE_ARCH}
|
||||||
|
platform: ${PACKAGE_PLATFORM}
|
||||||
|
section: default
|
||||||
|
priority: extra
|
||||||
|
description: "${PACKAGE_DESCRIPTION}"
|
||||||
|
|
||||||
|
maintainer: ${PACKAGE_MAINTAINER}
|
||||||
|
homepage: ${PACKAGE_HOMEPAGE}
|
||||||
|
license: ${PACKAGE_LICENSE}
|
||||||
|
|
||||||
|
disable_globbing: false
|
||||||
|
|
||||||
|
replaces:
|
||||||
|
- ${PACKAGE_NAME}
|
||||||
|
provides:
|
||||||
|
- ${PACKAGE_NAME}
|
||||||
|
|
||||||
|
contents:
|
||||||
|
- src: dist/vault-plugin-secrets-gpg
|
||||||
|
dst: ${PACKAGE_PLUGIN_DIR}/vault-plugin-secrets-gpg
|
||||||
|
file_info:
|
||||||
|
mode: 0755
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
|
||||||
|
scripts:
|
||||||
|
preinstall: ${PACKAGE_PREINSTALL}
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Ensure the plugin directory exists before the binary is laid down.
|
||||||
|
# Rendered per flavour via envsubst (see scripts/build-rpm.sh).
|
||||||
|
mkdir -p ${PACKAGE_PLUGIN_DIR}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package gpg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/hashicorp/vault/sdk/framework"
|
||||||
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pathKeyConfig(b *gpgBackend) *framework.Path {
|
||||||
|
return &framework.Path{
|
||||||
|
Pattern: "keys/" + framework.GenericNameRegex("name") + "/config",
|
||||||
|
DisplayAttrs: &framework.DisplayAttributes{
|
||||||
|
OperationPrefix: "gpg",
|
||||||
|
OperationSuffix: "key-config",
|
||||||
|
},
|
||||||
|
Fields: map[string]*framework.FieldSchema{
|
||||||
|
"name": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "Name of the key.",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
"min_decryption_version": {
|
||||||
|
Type: framework.TypeInt,
|
||||||
|
Description: "Minimum key version usable for decryption and verification. Versions below this can no longer open data.",
|
||||||
|
},
|
||||||
|
"deletion_allowed": {
|
||||||
|
Type: framework.TypeBool,
|
||||||
|
Description: "Whether the key may be deleted.",
|
||||||
|
},
|
||||||
|
"exportable": {
|
||||||
|
Type: framework.TypeBool,
|
||||||
|
Description: "Enable export of the private key. Once enabled it cannot be disabled.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Operations: map[logical.Operation]framework.OperationHandler{
|
||||||
|
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathKeyConfigWrite},
|
||||||
|
},
|
||||||
|
HelpSynopsis: "Tune deletion, exportability and the minimum decryption version of a key.",
|
||||||
|
HelpDescription: "Update mutable policy on a key without touching its material.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *gpgBackend) pathKeyConfigWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||||
|
name := data.Get("name").(string)
|
||||||
|
|
||||||
|
lock := b.lockForKey(name)
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
|
||||||
|
key, err := b.getKey(ctx, req.Storage, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if key == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := data.GetOk("min_decryption_version"); ok {
|
||||||
|
mdv := v.(int)
|
||||||
|
if mdv < 1 || mdv > key.LatestVersion {
|
||||||
|
return logical.ErrorResponse("min_decryption_version must be between 1 and the latest version (%d)", key.LatestVersion), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
key.MinDecryptionVersion = mdv
|
||||||
|
}
|
||||||
|
if v, ok := data.GetOk("deletion_allowed"); ok {
|
||||||
|
key.DeletionAllowed = v.(bool)
|
||||||
|
}
|
||||||
|
if v, ok := data.GetOk("exportable"); ok {
|
||||||
|
exportable := v.(bool)
|
||||||
|
if key.Exportable && !exportable {
|
||||||
|
return logical.ErrorResponse("exportable cannot be disabled once enabled"), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
key.Exportable = exportable
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.putKey(ctx, req.Storage, key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b.keyResponse(key)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package gpg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
|
||||||
|
"github.com/hashicorp/vault/sdk/framework"
|
||||||
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pathEncrypt(b *gpgBackend) *framework.Path {
|
||||||
|
return &framework.Path{
|
||||||
|
Pattern: "encrypt/" + framework.GenericNameRegex("name"),
|
||||||
|
DisplayAttrs: &framework.DisplayAttributes{
|
||||||
|
OperationPrefix: "gpg",
|
||||||
|
OperationSuffix: "encrypt",
|
||||||
|
},
|
||||||
|
Fields: map[string]*framework.FieldSchema{
|
||||||
|
"name": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "Name of the key to encrypt to.",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
"plaintext": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "Base64-encoded plaintext to encrypt.",
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "Ciphertext encoding: base64 (default, raw binary OpenPGP) or ascii-armor.",
|
||||||
|
Default: "base64",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Operations: map[logical.Operation]framework.OperationHandler{
|
||||||
|
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathEncryptWrite},
|
||||||
|
},
|
||||||
|
HelpSynopsis: "Encrypt plaintext to the key's latest public version.",
|
||||||
|
HelpDescription: "Produces an OpenPGP message decryptable via decrypt/<name> or with the exported private key.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *gpgBackend) pathEncryptWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||||
|
name := data.Get("name").(string)
|
||||||
|
|
||||||
|
plaintext, err := base64.StdEncoding.DecodeString(data.Get("plaintext").(string))
|
||||||
|
if err != nil {
|
||||||
|
return logical.ErrorResponse("plaintext must be valid base64: %s", err), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
asciiArmor, errResp := parseEncFormat(data.Get("format").(string))
|
||||||
|
if errResp != nil {
|
||||||
|
return errResp, logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := b.getKey(ctx, req.Storage, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if key == nil {
|
||||||
|
return logical.ErrorResponse("key %q not found", name), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext, err := key.encrypt(plaintext, asciiArmor)
|
||||||
|
if err != nil {
|
||||||
|
return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
out := base64.StdEncoding.EncodeToString(ciphertext)
|
||||||
|
if asciiArmor {
|
||||||
|
out = string(ciphertext)
|
||||||
|
}
|
||||||
|
return &logical.Response{
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"ciphertext": out,
|
||||||
|
"key_version": key.LatestVersion,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathDecrypt(b *gpgBackend) *framework.Path {
|
||||||
|
return &framework.Path{
|
||||||
|
Pattern: "decrypt/" + framework.GenericNameRegex("name"),
|
||||||
|
DisplayAttrs: &framework.DisplayAttributes{
|
||||||
|
OperationPrefix: "gpg",
|
||||||
|
OperationSuffix: "decrypt",
|
||||||
|
},
|
||||||
|
Fields: map[string]*framework.FieldSchema{
|
||||||
|
"name": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "Name of the key to decrypt with.",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
"ciphertext": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "The OpenPGP message: ASCII-armored, or base64-encoded raw binary (as `pass`/`gpg` write by default).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Operations: map[logical.Operation]framework.OperationHandler{
|
||||||
|
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathDecryptWrite},
|
||||||
|
},
|
||||||
|
HelpSynopsis: "Decrypt an OpenPGP message with the key's private material.",
|
||||||
|
HelpDescription: "Tries every key version at or above min_decryption_version. Accepts armored or binary ciphertext.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *gpgBackend) pathDecryptWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||||
|
name := data.Get("name").(string)
|
||||||
|
|
||||||
|
ciphertext := decodeCiphertext(data.Get("ciphertext").(string))
|
||||||
|
|
||||||
|
key, err := b.getKey(ctx, req.Storage, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if key == nil {
|
||||||
|
return logical.ErrorResponse("key %q not found", name), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext, err := key.decrypt(ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
return &logical.Response{
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"plaintext": base64.StdEncoding.EncodeToString(plaintext),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeCiphertext passes armored input through untouched and base64-decodes
|
||||||
|
// everything else back to raw binary OpenPGP.
|
||||||
|
func decodeCiphertext(ciphertext string) []byte {
|
||||||
|
if len(ciphertext) > len(armorPrefix) && ciphertext[:len(armorPrefix)] == armorPrefix {
|
||||||
|
return []byte(ciphertext)
|
||||||
|
}
|
||||||
|
if raw, err := base64.StdEncoding.DecodeString(ciphertext); err == nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return []byte(ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseEncFormat resolves the base64 / ascii-armor selector for ciphertext.
|
||||||
|
func parseEncFormat(format string) (asciiArmor bool, errResp *logical.Response) {
|
||||||
|
switch format {
|
||||||
|
case "", "base64":
|
||||||
|
return false, nil
|
||||||
|
case "ascii-armor":
|
||||||
|
return true, nil
|
||||||
|
default:
|
||||||
|
return false, logical.ErrorResponse("invalid format %q (must be base64 or ascii-armor)", format)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package gpg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/vault/sdk/framework"
|
||||||
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pathImport(b *gpgBackend) *framework.Path {
|
||||||
|
return &framework.Path{
|
||||||
|
Pattern: "keys/" + framework.GenericNameRegex("name") + "/import",
|
||||||
|
DisplayAttrs: &framework.DisplayAttributes{
|
||||||
|
OperationPrefix: "gpg",
|
||||||
|
OperationSuffix: "key-import",
|
||||||
|
},
|
||||||
|
Fields: map[string]*framework.FieldSchema{
|
||||||
|
"name": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "Name of the key.",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
"private_key": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "ASCII-armored OpenPGP private key to import.",
|
||||||
|
DisplayAttrs: &framework.DisplayAttributes{
|
||||||
|
Sensitive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"exportable": {
|
||||||
|
Type: framework.TypeBool,
|
||||||
|
Description: "Whether the imported private key may later be exported.",
|
||||||
|
Default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Operations: map[logical.Operation]framework.OperationHandler{
|
||||||
|
logical.CreateOperation: &framework.PathOperation{Callback: b.pathImportWrite},
|
||||||
|
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathImportWrite},
|
||||||
|
},
|
||||||
|
ExistenceCheck: b.pathKeysExistenceCheck,
|
||||||
|
HelpSynopsis: "Import an existing armored OpenPGP private key.",
|
||||||
|
HelpDescription: "Register an externally-generated private key as version 1 of a new named key.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *gpgBackend) pathImportWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||||
|
name := data.Get("name").(string)
|
||||||
|
armored := data.Get("private_key").(string)
|
||||||
|
if armored == "" {
|
||||||
|
return logical.ErrorResponse("private_key is required"), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
lock := b.lockForKey(name)
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
|
||||||
|
existing, err := b.getKey(ctx, req.Storage, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
return logical.ErrorResponse("key %q already exists", name), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := newImportedKey(name, armored, data.Get("exportable").(bool), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
if err := b.putKey(ctx, req.Storage, key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b.keyResponse(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathExport(b *gpgBackend) *framework.Path {
|
||||||
|
return &framework.Path{
|
||||||
|
Pattern: "export/" + framework.GenericNameRegex("type") + "/" + framework.GenericNameRegex("name") + "(/" + framework.GenericNameRegex("version") + ")?",
|
||||||
|
DisplayAttrs: &framework.DisplayAttributes{
|
||||||
|
OperationPrefix: "gpg",
|
||||||
|
OperationSuffix: "export",
|
||||||
|
},
|
||||||
|
Fields: map[string]*framework.FieldSchema{
|
||||||
|
"type": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "Material to export: public-key or private-key.",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "Name of the key.",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "Specific version to export. Omit to export all versions.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Operations: map[logical.Operation]framework.OperationHandler{
|
||||||
|
logical.ReadOperation: &framework.PathOperation{Callback: b.pathExportRead},
|
||||||
|
},
|
||||||
|
HelpSynopsis: "Export the public (or, if exportable, private) armored key.",
|
||||||
|
HelpDescription: "public-key is always exportable and is what gpg/pass import to encrypt to this key. private-key requires the key to be marked exportable.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *gpgBackend) pathExportRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||||
|
exportType := data.Get("type").(string)
|
||||||
|
name := data.Get("name").(string)
|
||||||
|
versionArg := data.Get("version").(string)
|
||||||
|
|
||||||
|
switch exportType {
|
||||||
|
case "public-key", "private-key":
|
||||||
|
default:
|
||||||
|
return logical.ErrorResponse("invalid export type %q (must be public-key or private-key)", exportType), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := b.getKey(ctx, req.Storage, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if key == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if exportType == "private-key" && !key.Exportable {
|
||||||
|
return logical.ErrorResponse("key %q is not exportable", name), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
render := func(v int) (string, error) {
|
||||||
|
if exportType == "public-key" {
|
||||||
|
return key.publicKeyArmored(v)
|
||||||
|
}
|
||||||
|
kv, ok := key.Versions[v]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("version %d does not exist", v)
|
||||||
|
}
|
||||||
|
return kv.PrivateKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := map[string]interface{}{}
|
||||||
|
if versionArg != "" {
|
||||||
|
v, err := strconv.Atoi(versionArg)
|
||||||
|
if err != nil {
|
||||||
|
return logical.ErrorResponse("invalid version %q", versionArg), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
if v == 0 {
|
||||||
|
v = key.LatestVersion
|
||||||
|
}
|
||||||
|
material, err := render(v)
|
||||||
|
if err != nil {
|
||||||
|
return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
keys[strconv.Itoa(v)] = material
|
||||||
|
} else {
|
||||||
|
for v := range key.Versions {
|
||||||
|
material, err := render(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keys[strconv.Itoa(v)] = material
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &logical.Response{
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"name": key.Name,
|
||||||
|
"type": exportType,
|
||||||
|
"keys": keys,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
package gpg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/vault/sdk/framework"
|
||||||
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pathKeys(b *gpgBackend) *framework.Path {
|
||||||
|
return &framework.Path{
|
||||||
|
Pattern: "keys/" + framework.GenericNameRegex("name"),
|
||||||
|
DisplayAttrs: &framework.DisplayAttributes{
|
||||||
|
OperationPrefix: "gpg",
|
||||||
|
OperationSuffix: "key",
|
||||||
|
},
|
||||||
|
Fields: map[string]*framework.FieldSchema{
|
||||||
|
"name": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "Name of the key.",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
"algorithm": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "Key algorithm: rsa-2048, rsa-3072 (default), rsa-4096 or ed25519.",
|
||||||
|
Default: "rsa-3072",
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: `OpenPGP User ID for the key, e.g. "Ben Vincent <ben@unkin.net>". Defaults to the key name.`,
|
||||||
|
},
|
||||||
|
"exportable": {
|
||||||
|
Type: framework.TypeBool,
|
||||||
|
Description: "Whether the private key may be exported via export/private-key. Cannot be unset once enabled.",
|
||||||
|
Default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Operations: map[logical.Operation]framework.OperationHandler{
|
||||||
|
logical.CreateOperation: &framework.PathOperation{Callback: b.pathKeysWrite},
|
||||||
|
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathKeysWrite},
|
||||||
|
logical.ReadOperation: &framework.PathOperation{Callback: b.pathKeysRead},
|
||||||
|
logical.DeleteOperation: &framework.PathOperation{Callback: b.pathKeysDelete},
|
||||||
|
},
|
||||||
|
ExistenceCheck: b.pathKeysExistenceCheck,
|
||||||
|
HelpSynopsis: "Generate and manage a named GPG key.",
|
||||||
|
HelpDescription: "Create a versioned OpenPGP key, read its metadata and armored public key, or delete it.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathKeysList(b *gpgBackend) *framework.Path {
|
||||||
|
return &framework.Path{
|
||||||
|
Pattern: "keys/?$",
|
||||||
|
DisplayAttrs: &framework.DisplayAttributes{
|
||||||
|
OperationPrefix: "gpg",
|
||||||
|
OperationSuffix: "keys",
|
||||||
|
},
|
||||||
|
Operations: map[logical.Operation]framework.OperationHandler{
|
||||||
|
logical.ListOperation: &framework.PathOperation{Callback: b.pathKeysList},
|
||||||
|
},
|
||||||
|
HelpSynopsis: "List named GPG keys.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *gpgBackend) pathKeysExistenceCheck(ctx context.Context, req *logical.Request, data *framework.FieldData) (bool, error) {
|
||||||
|
key, err := b.getKey(ctx, req.Storage, data.Get("name").(string))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return key != nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *gpgBackend) pathKeysWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||||
|
name := data.Get("name").(string)
|
||||||
|
if name == "" {
|
||||||
|
return logical.ErrorResponse("missing key name"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lock := b.lockForKey(name)
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
|
||||||
|
existing, err := b.getKey(ctx, req.Storage, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
// Creation is idempotent: return the existing key rather than
|
||||||
|
// regenerating and destroying its material.
|
||||||
|
return b.keyResponse(existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
algorithm := data.Get("algorithm").(string)
|
||||||
|
identity := data.Get("identity").(string)
|
||||||
|
if identity == "" {
|
||||||
|
identity = name
|
||||||
|
}
|
||||||
|
exportable := data.Get("exportable").(bool)
|
||||||
|
|
||||||
|
key, err := newKey(name, algorithm, identity, exportable, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return logical.ErrorResponse(err.Error()), nil
|
||||||
|
}
|
||||||
|
if err := b.putKey(ctx, req.Storage, key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b.keyResponse(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *gpgBackend) pathKeysRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||||
|
key, err := b.getKey(ctx, req.Storage, data.Get("name").(string))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if key == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return b.keyResponse(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *gpgBackend) pathKeysDelete(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||||
|
name := data.Get("name").(string)
|
||||||
|
|
||||||
|
lock := b.lockForKey(name)
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
|
||||||
|
key, err := b.getKey(ctx, req.Storage, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if key == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if !key.DeletionAllowed {
|
||||||
|
return logical.ErrorResponse("deletion is not allowed for this key; enable it via keys/%s/config deletion_allowed=true", name), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
if err := req.Storage.Delete(ctx, keyPrefix+name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *gpgBackend) pathKeysList(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
|
||||||
|
names, err := req.Storage.List(ctx, keyPrefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return logical.ListResponse(names), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyResponse renders a key's metadata plus the armored public key of every
|
||||||
|
// version. The top-level public_key is the latest version, for convenience.
|
||||||
|
func (b *gpgBackend) keyResponse(key *gpgKey) (*logical.Response, error) {
|
||||||
|
versions := map[string]interface{}{}
|
||||||
|
for v, kv := range key.Versions {
|
||||||
|
pub, err := key.publicKeyArmored(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rendering public key for version %d: %w", v, err)
|
||||||
|
}
|
||||||
|
versions[fmt.Sprintf("%d", v)] = map[string]interface{}{
|
||||||
|
"creation_time": kv.CreationTime.Format(time.RFC3339),
|
||||||
|
"fingerprint": kv.Fingerprint,
|
||||||
|
"key_id": kv.KeyID,
|
||||||
|
"public_key": pub,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
latestPub, err := key.publicKeyArmored(key.LatestVersion)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &logical.Response{
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"name": key.Name,
|
||||||
|
"algorithm": key.Algorithm,
|
||||||
|
"identity": key.Identity,
|
||||||
|
"latest_version": key.LatestVersion,
|
||||||
|
"min_decryption_version": key.MinDecryptionVersion,
|
||||||
|
"deletion_allowed": key.DeletionAllowed,
|
||||||
|
"exportable": key.Exportable,
|
||||||
|
"imported": key.Imported,
|
||||||
|
"creation_time": key.CreationTime.Format(time.RFC3339),
|
||||||
|
"fingerprint": key.Versions[key.LatestVersion].Fingerprint,
|
||||||
|
"public_key": latestPub,
|
||||||
|
"keys": versions,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package gpg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/vault/sdk/framework"
|
||||||
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pathRotate(b *gpgBackend) *framework.Path {
|
||||||
|
return &framework.Path{
|
||||||
|
Pattern: "keys/" + framework.GenericNameRegex("name") + "/rotate",
|
||||||
|
DisplayAttrs: &framework.DisplayAttributes{
|
||||||
|
OperationPrefix: "gpg",
|
||||||
|
OperationSuffix: "key-rotate",
|
||||||
|
},
|
||||||
|
Fields: map[string]*framework.FieldSchema{
|
||||||
|
"name": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "Name of the key.",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Operations: map[logical.Operation]framework.OperationHandler{
|
||||||
|
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathRotateWrite},
|
||||||
|
},
|
||||||
|
HelpSynopsis: "Add a new version to a key.",
|
||||||
|
HelpDescription: "Generate a fresh OpenPGP entity as the new latest version. Older versions are retained for decryption and verification.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *gpgBackend) pathRotateWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||||
|
name := data.Get("name").(string)
|
||||||
|
|
||||||
|
lock := b.lockForKey(name)
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
|
||||||
|
key, err := b.getKey(ctx, req.Storage, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if key == nil {
|
||||||
|
return logical.ErrorResponse("key %q not found", name), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
if err := key.rotate(time.Now()); err != nil {
|
||||||
|
return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
if err := b.putKey(ctx, req.Storage, key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b.keyResponse(key)
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package gpg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
|
||||||
|
"github.com/hashicorp/vault/sdk/framework"
|
||||||
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pathSign(b *gpgBackend) *framework.Path {
|
||||||
|
return &framework.Path{
|
||||||
|
Pattern: "sign/" + framework.GenericNameRegex("name"),
|
||||||
|
DisplayAttrs: &framework.DisplayAttributes{
|
||||||
|
OperationPrefix: "gpg",
|
||||||
|
OperationSuffix: "sign",
|
||||||
|
},
|
||||||
|
Fields: map[string]*framework.FieldSchema{
|
||||||
|
"name": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "Name of the key to sign with.",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "Base64-encoded message to sign.",
|
||||||
|
},
|
||||||
|
"key_version": {
|
||||||
|
Type: framework.TypeInt,
|
||||||
|
Description: "Key version to sign with (0 or omitted = latest).",
|
||||||
|
Default: 0,
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "Signature encoding: ascii-armor (default) or base64 (raw binary detached signature).",
|
||||||
|
Default: "ascii-armor",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Operations: map[logical.Operation]framework.OperationHandler{
|
||||||
|
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathSignWrite},
|
||||||
|
},
|
||||||
|
HelpSynopsis: "Produce a detached OpenPGP signature over the input.",
|
||||||
|
HelpDescription: "Signs with the key's primary signing key. Verify elsewhere with gpg or the verify/<name> path.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *gpgBackend) pathSignWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||||
|
name := data.Get("name").(string)
|
||||||
|
|
||||||
|
message, err := base64.StdEncoding.DecodeString(data.Get("input").(string))
|
||||||
|
if err != nil {
|
||||||
|
return logical.ErrorResponse("input must be valid base64: %s", err), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
asciiArmor, errResp := parseFormat(data.Get("format").(string))
|
||||||
|
if errResp != nil {
|
||||||
|
return errResp, logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := b.getKey(ctx, req.Storage, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if key == nil {
|
||||||
|
return logical.ErrorResponse("key %q not found", name), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
sig, err := key.sign(message, data.Get("key_version").(int), asciiArmor)
|
||||||
|
if err != nil {
|
||||||
|
return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
signature := string(sig)
|
||||||
|
if !asciiArmor {
|
||||||
|
signature = base64.StdEncoding.EncodeToString(sig)
|
||||||
|
}
|
||||||
|
return &logical.Response{
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"signature": signature,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathVerify(b *gpgBackend) *framework.Path {
|
||||||
|
return &framework.Path{
|
||||||
|
Pattern: "verify/" + framework.GenericNameRegex("name"),
|
||||||
|
DisplayAttrs: &framework.DisplayAttributes{
|
||||||
|
OperationPrefix: "gpg",
|
||||||
|
OperationSuffix: "verify",
|
||||||
|
},
|
||||||
|
Fields: map[string]*framework.FieldSchema{
|
||||||
|
"name": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "Name of the key to verify against.",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "Base64-encoded message that was signed.",
|
||||||
|
},
|
||||||
|
"signature": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "The detached signature: ASCII-armored, or base64-encoded raw binary.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Operations: map[logical.Operation]framework.OperationHandler{
|
||||||
|
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathVerifyWrite},
|
||||||
|
},
|
||||||
|
HelpSynopsis: "Verify a detached OpenPGP signature over the input.",
|
||||||
|
HelpDescription: "Checks the signature against every version of the key; returns valid=true/false.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *gpgBackend) pathVerifyWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||||
|
name := data.Get("name").(string)
|
||||||
|
|
||||||
|
message, err := base64.StdEncoding.DecodeString(data.Get("input").(string))
|
||||||
|
if err != nil {
|
||||||
|
return logical.ErrorResponse("input must be valid base64: %s", err), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
signature := decodeSignature(data.Get("signature").(string))
|
||||||
|
|
||||||
|
key, err := b.getKey(ctx, req.Storage, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if key == nil {
|
||||||
|
return logical.ErrorResponse("key %q not found", name), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, err := key.verify(message, signature)
|
||||||
|
if err != nil {
|
||||||
|
return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest
|
||||||
|
}
|
||||||
|
return &logical.Response{
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"valid": valid,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeSignature accepts an armored signature verbatim, or decodes a base64
|
||||||
|
// raw-binary one. If it is neither, the original bytes are returned and
|
||||||
|
// verification will simply fail.
|
||||||
|
func decodeSignature(sig string) []byte {
|
||||||
|
if len(sig) > len(armorPrefix) && sig[:len(armorPrefix)] == armorPrefix {
|
||||||
|
return []byte(sig)
|
||||||
|
}
|
||||||
|
if raw, err := base64.StdEncoding.DecodeString(sig); err == nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return []byte(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFormat resolves the ascii-armor / base64 format selector.
|
||||||
|
func parseFormat(format string) (asciiArmor bool, errResp *logical.Response) {
|
||||||
|
switch format {
|
||||||
|
case "", "ascii-armor":
|
||||||
|
return true, nil
|
||||||
|
case "base64":
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, logical.ErrorResponse("invalid format %q (must be ascii-armor or base64)", format)
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Package the (already built) plugin binary into RPMs with nfpm. Builds one RPM
|
||||||
|
# per target server: Vault (/opt/vault-plugins) and OpenBao (/opt/openbao-plugins).
|
||||||
|
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${ROOT_DIR}"
|
||||||
|
|
||||||
|
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
BINARY="vault-plugin-secrets-gpg"
|
||||||
|
DIST="dist"
|
||||||
|
|
||||||
|
if [ ! -f "${DIST}/${BINARY}" ]; then
|
||||||
|
echo "ERROR: ${DIST}/${BINARY} not found; run 'make build' first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PACKAGE_VERSION="${VERSION}"
|
||||||
|
export PACKAGE_RELEASE="1"
|
||||||
|
export PACKAGE_ARCH="amd64"
|
||||||
|
export PACKAGE_PLATFORM="linux"
|
||||||
|
export PACKAGE_DESCRIPTION="Vault/OpenBao secrets engine for GPG/OpenPGP keys (sign/verify/encrypt/decrypt)"
|
||||||
|
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
|
||||||
|
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/vault-plugin-secrets-gpg"
|
||||||
|
export PACKAGE_LICENSE="MIT"
|
||||||
|
|
||||||
|
build_flavor() {
|
||||||
|
export PACKAGE_NAME="$1"
|
||||||
|
export PACKAGE_PLUGIN_DIR="$2"
|
||||||
|
export PACKAGE_PREINSTALL="${DIST}/preinstall-${PACKAGE_NAME}.sh"
|
||||||
|
envsubst '${PACKAGE_PLUGIN_DIR}' < packaging/scripts/preinstall.sh.tmpl > "${PACKAGE_PREINSTALL}"
|
||||||
|
envsubst < packaging/nfpm.yaml > "${DIST}/nfpm-${PACKAGE_NAME}.yaml"
|
||||||
|
nfpm pkg --config "${DIST}/nfpm-${PACKAGE_NAME}.yaml" --target "${DIST}" --packager rpm
|
||||||
|
}
|
||||||
|
|
||||||
|
build_flavor "vault-plugin-secrets-gpg" "/opt/vault-plugins"
|
||||||
|
build_flavor "openbao-plugin-secrets-gpg" "/opt/openbao-plugins"
|
||||||
|
|
||||||
|
echo "Built:"
|
||||||
|
ls -1 "${DIST}"/*.rpm
|
||||||
Executable
+100
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# End-to-end test for vault-plugin-secrets-gpg.
|
||||||
|
#
|
||||||
|
# Builds the plugin, brings up both Vault and OpenBao, then drives the identical
|
||||||
|
# lifecycle against each to prove the same binary works on both:
|
||||||
|
# generate key -> sign/verify -> encrypt/decrypt -> rotate -> decrypt old
|
||||||
|
# ciphertext -> export public key.
|
||||||
|
#
|
||||||
|
# Select engines with ENGINES (default "vault openbao"), e.g. ENGINES=openbao.
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
COMPOSE_FILE="${ROOT_DIR}/test/docker-compose.yml"
|
||||||
|
COMPOSE="docker compose -f ${COMPOSE_FILE}"
|
||||||
|
BINARY="vault-plugin-secrets-gpg"
|
||||||
|
|
||||||
|
MOUNT="gpg"
|
||||||
|
ENGINES="${ENGINES:-vault openbao}"
|
||||||
|
|
||||||
|
red() { printf '\033[31m%s\033[0m\n' "$*"; }
|
||||||
|
green() { printf '\033[32m%s\033[0m\n' "$*"; }
|
||||||
|
blue() { printf '\033[34m==> %s\033[0m\n' "$*"; }
|
||||||
|
|
||||||
|
cleanup() { blue "Tearing down containers"; ${COMPOSE} down -v >/dev/null 2>&1 || true; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
fail() { red "FAIL: $*"; exit 1; }
|
||||||
|
|
||||||
|
wait_for() {
|
||||||
|
local desc="$1"; shift
|
||||||
|
local i=0
|
||||||
|
until "$@" >/dev/null 2>&1; do
|
||||||
|
i=$((i + 1))
|
||||||
|
[ "$i" -ge "${WAIT_RETRIES:-90}" ] && fail "timed out waiting for ${desc}"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
green "ready: ${desc}"
|
||||||
|
}
|
||||||
|
|
||||||
|
b64() { printf '%s' "$1" | base64 | tr -d '\n'; }
|
||||||
|
|
||||||
|
run_engine() {
|
||||||
|
local engine="$1" container="$2" cli="$3"
|
||||||
|
blue "[${engine}] exercising the plugin"
|
||||||
|
ex() { ${COMPOSE} exec -T "${container}" "${cli}" "$@"; }
|
||||||
|
|
||||||
|
local sha; sha="$(sha256sum "${ROOT_DIR}/dist/${BINARY}" | awk '{print $1}')"
|
||||||
|
ex plugin register -sha256="${sha}" secret "${BINARY}" >/dev/null || true
|
||||||
|
ex secrets disable "${MOUNT}" >/dev/null 2>&1 || true
|
||||||
|
ex secrets enable -path="${MOUNT}" "${BINARY}" >/dev/null
|
||||||
|
green "[${engine}] plugin registered and mounted"
|
||||||
|
|
||||||
|
# --- generate a key ---
|
||||||
|
local pub
|
||||||
|
pub="$(ex write -field=public_key "${MOUNT}/keys/app" algorithm=rsa-2048 identity='App <app@unkin.net>')"
|
||||||
|
printf '%s' "${pub}" | grep -q 'BEGIN PGP PUBLIC KEY BLOCK' || fail "[${engine}] no armored public key returned"
|
||||||
|
green "[${engine}] key generated; public key exported"
|
||||||
|
|
||||||
|
# --- sign / verify ---
|
||||||
|
local msg sig valid
|
||||||
|
msg="$(b64 'ship it')"
|
||||||
|
sig="$(ex write -field=signature "${MOUNT}/sign/app" input="${msg}")"
|
||||||
|
valid="$(ex write -field=valid "${MOUNT}/verify/app" input="${msg}" signature="${sig}")"
|
||||||
|
[ "${valid}" = "true" ] || fail "[${engine}] signature did not verify"
|
||||||
|
green "[${engine}] sign/verify ok"
|
||||||
|
|
||||||
|
# --- encrypt / decrypt roundtrip ---
|
||||||
|
local ct pt secret="correct horse battery staple"
|
||||||
|
ct="$(ex write -field=ciphertext "${MOUNT}/encrypt/app" plaintext="$(b64 "${secret}")")"
|
||||||
|
pt="$(ex write -field=plaintext "${MOUNT}/decrypt/app" ciphertext="${ct}" | base64 -d)"
|
||||||
|
[ "${pt}" = "${secret}" ] || fail "[${engine}] encrypt/decrypt roundtrip mismatch"
|
||||||
|
green "[${engine}] encrypt/decrypt ok"
|
||||||
|
|
||||||
|
# --- rotate, then decrypt the pre-rotation ciphertext ---
|
||||||
|
local ver
|
||||||
|
ver="$(ex write -field=latest_version "${MOUNT}/keys/app/rotate")"
|
||||||
|
[ "${ver}" = "2" ] || fail "[${engine}] rotate did not bump to version 2"
|
||||||
|
pt="$(ex write -field=plaintext "${MOUNT}/decrypt/app" ciphertext="${ct}" | base64 -d)"
|
||||||
|
[ "${pt}" = "${secret}" ] || fail "[${engine}] old ciphertext failed to decrypt after rotation"
|
||||||
|
green "[${engine}] rotation retains old versions"
|
||||||
|
|
||||||
|
green "[${engine}] PASSED"
|
||||||
|
}
|
||||||
|
|
||||||
|
blue "Building plugin for linux/amd64"
|
||||||
|
OS=linux ARCH=amd64 PLUGIN_DIR="${ROOT_DIR}/dist" make -C "${ROOT_DIR}" build
|
||||||
|
|
||||||
|
blue "Starting Docker stack (vault + openbao)"
|
||||||
|
${COMPOSE} up -d --build
|
||||||
|
|
||||||
|
for engine in ${ENGINES}; do
|
||||||
|
case "${engine}" in
|
||||||
|
vault) wait_for "vault" ${COMPOSE} exec -T vault vault status -address=http://127.0.0.1:8200; run_engine vault vault vault ;;
|
||||||
|
openbao) wait_for "openbao" ${COMPOSE} exec -T openbao bao status -address=http://127.0.0.1:8200; run_engine openbao openbao bao ;;
|
||||||
|
*) fail "unknown engine: ${engine}" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
green "ALL END-TO-END CHECKS PASSED (${ENGINES})"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# End-to-end test stack: two secrets-engine hosts running the exact same plugin
|
||||||
|
# binary — HashiCorp Vault and OpenBao. The gpg engine is self-contained (it
|
||||||
|
# stores key material in the host's own barrier storage), so there is no
|
||||||
|
# external service to stand up. Bind mounts use ":z" so they work under SELinux.
|
||||||
|
services:
|
||||||
|
vault:
|
||||||
|
image: hashicorp/vault:1.18
|
||||||
|
cap_add: [IPC_LOCK]
|
||||||
|
environment:
|
||||||
|
VAULT_DEV_ROOT_TOKEN_ID: root
|
||||||
|
VAULT_ADDR: http://127.0.0.1:8200
|
||||||
|
VAULT_TOKEN: root
|
||||||
|
command: ["server", "-dev", "-dev-listen-address=0.0.0.0:8200", "-config=/vault/vault.hcl"]
|
||||||
|
volumes:
|
||||||
|
- ../dist:/vault/plugins:ro,z
|
||||||
|
- ./vault/vault.hcl:/vault/vault.hcl:ro,z
|
||||||
|
ports: ["8200:8200"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "vault", "status", "-address=http://127.0.0.1:8200"]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
openbao:
|
||||||
|
image: openbao/openbao:latest
|
||||||
|
cap_add: [IPC_LOCK]
|
||||||
|
environment:
|
||||||
|
BAO_DEV_ROOT_TOKEN_ID: root
|
||||||
|
BAO_ADDR: http://127.0.0.1:8200
|
||||||
|
BAO_TOKEN: root
|
||||||
|
command: ["server", "-dev", "-dev-listen-address=0.0.0.0:8200", "-config=/openbao/bao.hcl"]
|
||||||
|
volumes:
|
||||||
|
- ../dist:/openbao/plugins:ro,z
|
||||||
|
- ./openbao/bao.hcl:/openbao/bao.hcl:ro,z
|
||||||
|
ports: ["8300:8200"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "bao", "status", "-address=http://127.0.0.1:8200"]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# OpenBao is plugin-protocol compatible with Vault, so the very same plugin
|
||||||
|
# binary registers and runs here unchanged. Combined with `-dev` at runtime.
|
||||||
|
plugin_directory = "/openbao/plugins"
|
||||||
|
api_addr = "http://127.0.0.1:8200"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Combined with `-dev` at runtime; supplies the plugin_directory the dev server
|
||||||
|
# would otherwise leave unset, so the plugin binary in ../dist can be registered.
|
||||||
|
plugin_directory = "/vault/plugins"
|
||||||
|
api_addr = "http://127.0.0.1:8200"
|
||||||
Reference in New Issue
Block a user