feat: run TreatVault as a service image
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 6m2s
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 6m2s
This commit is contained in:
50
.gitea/workflows/main-image.yml
Normal file
50
.gitea/workflows/main-image.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
name: Build and Push Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY_HOST: registry.campbellwireless.net
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
docker-build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git init
|
||||
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
|
||||
git config http.extraheader "Authorization: Basic $(printf '%s' '${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}' | base64)"
|
||||
git fetch --depth=1 origin "${{ github.sha }}"
|
||||
git checkout FETCH_HEAD
|
||||
|
||||
- name: Compute image tags
|
||||
id: tags
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_HOST }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:${{ steps.tags.outputs.sha_short }}
|
||||
@@ -13,4 +13,4 @@ RUN apk add --no-cache ca-certificates docker-cli age
|
||||
COPY --from=build /treatvault /usr/local/bin/treatvault
|
||||
EXPOSE 9090
|
||||
ENTRYPOINT ["/usr/local/bin/treatvault"]
|
||||
CMD ["serve", "--file", "/var/lib/treatvault/secrets.age", "--identity", "/run/secrets/barkstack_treatvault_identity", "--listen", ":9090"]
|
||||
CMD ["--file", "/var/lib/treatvault/secrets.age", "--identity", "/run/secrets/barkstack_treatvault_identity", "--listen", ":9090"]
|
||||
|
||||
23
README.md
23
README.md
@@ -10,17 +10,18 @@ TreatVault manages Barkstack's namespaced secrets. It keeps an age-encrypted sou
|
||||
- Consumers mount secrets at the stable target path `barkstack_<name>`; rotations swap the mounted source and update the service, leaving a rollback path.
|
||||
- Obsolete Barkstack-managed secret objects are deleted; objects still mounted by a managed service are reported in the sync status and refused for deletion via the API until their consumers are updated.
|
||||
|
||||
## CLI
|
||||
## Service image
|
||||
|
||||
TreatVault is a service, not a user CLI. `registry.campbellwireless.net/barkstack/treatvault:latest` runs its HTTP API on port `9090` inside the Barkstack overlay network. It accepts only service flags:
|
||||
|
||||
```sh
|
||||
treatvault keygen --identity ./treatvault-identity.txt
|
||||
treatvault init --file ./secrets.age --identity ./treatvault-identity.txt
|
||||
treatvault set --name database_password --file ./secrets.age --identity ./treatvault-identity.txt # value on stdin
|
||||
treatvault list --file ./secrets.age --identity ./treatvault-identity.txt
|
||||
treatvault delete --name database_password --file ./secrets.age --identity ./treatvault-identity.txt
|
||||
treatvault serve --file ./secrets.age --identity ./treatvault-identity.txt --listen :9090
|
||||
treatvault --file /var/lib/treatvault/secrets.age \
|
||||
--identity /run/secrets/barkstack_treatvault_identity \
|
||||
--listen :9090
|
||||
```
|
||||
|
||||
On startup TreatVault creates the encrypted vault file when it is absent, using the mounted age identity. It then loads the file, reconciles Docker Swarm secrets, and exposes the API. The identity remains a Docker secret; TreatVault never exposes it through the API or UI.
|
||||
|
||||
## HTTP API
|
||||
|
||||
- `GET /barkstack/ui/manifest.json` - Barkstack plugin manifest (id `treatvault`, mount `/treatvault`).
|
||||
@@ -31,7 +32,7 @@ treatvault serve --file ./secrets.age --identity ./treatvault-identity.txt --lis
|
||||
|
||||
## Barkstack deployment
|
||||
|
||||
Add a `treatvault` block to the Barkfile and run `barkstack init` on a Swarm manager:
|
||||
Configure TreatVault in the Barkfile and run `barkstack init` on a Swarm manager:
|
||||
|
||||
```text
|
||||
treatvault {
|
||||
@@ -40,12 +41,12 @@ treatvault {
|
||||
}
|
||||
```
|
||||
|
||||
`barkstack init` provisions the `barkstack-treatvault` service (manager-constrained) with the encrypted file bind-mounted read-write at its host path, the Docker socket, the `barkstack_treatvault_identity` secret mounted at `/run/secrets/barkstack_treatvault_identity`, and no published ports; the console reaches it over the overlay network at `:9090`.
|
||||
`barkstack init` creates `barkstack_treatvault_identity` if absent, provisions the manager-constrained `barkstack-treatvault` service, and bind-mounts the encrypted file read-write at its configured host path. The service receives only that file, the Docker socket, and its Docker-secret identity; it has no published ports. The console reaches it over the overlay network at `:9090`.
|
||||
|
||||
The encrypted file is safe to commit and back up. Keep its mounted Docker-secret identity outside source control. Use the TreatVault page in the Barkstack Console to create, rotate, and delete secret values; values are write-only and never returned.
|
||||
|
||||
Services that consume vault secrets carry the `io.barkstack.treatvault.secrets=true` label plus an `io.barkstack.treatvault.names` label listing the logical names they need; `barkstack init` labels the PawSQL service automatically. Each sync mounts the current revision at the stable `/run/secrets/barkstack_<name>` target, so consumers read the same path across rotations.
|
||||
|
||||
The Barkstack console automatically gains a TreatVault page (plugin id `treatvault`) for creating, rotating, and deleting secrets. Deletion is refused with `409 Conflict` while a labeled service still mounts the secret.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// TreatVault is the Barkstack secret-management service. It is launched by its
|
||||
// container image; operator workflows use Barkstack and the console plugin.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -11,7 +13,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -21,143 +22,57 @@ import (
|
||||
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault"
|
||||
)
|
||||
|
||||
type options struct {
|
||||
filePath string
|
||||
identityPath string
|
||||
listen string
|
||||
dockerPath string
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err := run(ctx, os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil {
|
||||
if err := run(ctx, os.Args[1:], os.Stderr); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "treatvault:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
|
||||
if len(args) == 0 {
|
||||
return usage(stderr)
|
||||
}
|
||||
switch args[0] {
|
||||
case "keygen":
|
||||
return runKeygen(args[1:], stdout, stderr)
|
||||
case "init":
|
||||
return runInit(args[1:], stderr)
|
||||
case "set":
|
||||
return runSet(args[1:], stdin, stderr)
|
||||
case "delete":
|
||||
return runDelete(args[1:], stderr)
|
||||
case "list":
|
||||
return runList(args[1:], stdout, stderr)
|
||||
case "serve":
|
||||
return runServe(ctx, args[1:], stderr)
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q", args[0])
|
||||
func run(ctx context.Context, args []string, stderr io.Writer) error {
|
||||
options, err := parseOptions(args, stderr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runServer(ctx, options, stderr)
|
||||
}
|
||||
|
||||
func runKeygen(args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("keygen", flag.ContinueOnError)
|
||||
func parseOptions(args []string, stderr io.Writer) (options, error) {
|
||||
flags := flag.NewFlagSet("treatvault", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
identityPath := flags.String("identity", "treatvault-identity.txt", "age identity output path")
|
||||
if err := parseNoArguments(flags, args); err != nil {
|
||||
return err
|
||||
options := options{}
|
||||
flags.StringVar(&options.filePath, "file", "/var/lib/treatvault/secrets.age", "encrypted secret file")
|
||||
flags.StringVar(&options.identityPath, "identity", "/run/secrets/barkstack_treatvault_identity", "age identity file")
|
||||
flags.StringVar(&options.listen, "listen", ":9090", "HTTP listen address")
|
||||
flags.StringVar(&options.dockerPath, "docker", "docker", "Docker CLI path")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return options, err
|
||||
}
|
||||
recipient, err := vault.GenerateIdentity(*identityPath)
|
||||
if err != nil {
|
||||
return err
|
||||
if flags.NArg() != 0 {
|
||||
return options, fmt.Errorf("unexpected arguments: %v", flags.Args())
|
||||
}
|
||||
_, _ = fmt.Fprintf(stdout, "Identity written to %s\nRecipient: %s\n", *identityPath, recipient)
|
||||
return nil
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func runInit(args []string, stderr io.Writer) error {
|
||||
flags, filePath, identityPath := fileFlags("init", stderr)
|
||||
if err := parseNoArguments(flags, args); err != nil {
|
||||
return err
|
||||
}
|
||||
identity, err := vault.ReadIdentity(*identityPath)
|
||||
func runServer(ctx context.Context, options options, stderr io.Writer) error {
|
||||
store, err := vault.OpenOrInitialize(options.filePath, options.identityPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return vault.Initialize(*filePath, identity)
|
||||
}
|
||||
|
||||
func runSet(args []string, stdin io.Reader, stderr io.Writer) error {
|
||||
flags, filePath, identityPath := fileFlags("set", stderr)
|
||||
name := flags.String("name", "", "short secret name")
|
||||
valuePath := flags.String("value-file", "-", "secret value file, or - for stdin")
|
||||
if err := parseNoArguments(flags, args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *name == "" {
|
||||
return errors.New("--name is required")
|
||||
}
|
||||
value, err := readValue(*valuePath, stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store, err := vault.Open(*filePath, *identityPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = store.Set(*name, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func runDelete(args []string, stderr io.Writer) error {
|
||||
flags, filePath, identityPath := fileFlags("delete", stderr)
|
||||
name := flags.String("name", "", "short secret name")
|
||||
if err := parseNoArguments(flags, args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *name == "" {
|
||||
return errors.New("--name is required")
|
||||
}
|
||||
store, err := vault.Open(*filePath, *identityPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = store.Delete(*name)
|
||||
return err
|
||||
}
|
||||
|
||||
func runList(args []string, stdout, stderr io.Writer) error {
|
||||
flags, filePath, identityPath := fileFlags("list", stderr)
|
||||
if err := parseNoArguments(flags, args); err != nil {
|
||||
return err
|
||||
}
|
||||
store, err := vault.Open(*filePath, *identityPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot, err := store.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names := make([]string, 0, len(snapshot.Secrets))
|
||||
for name := range snapshot.Secrets {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
_, _ = fmt.Fprintln(stdout, name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runServe(ctx context.Context, args []string, stderr io.Writer) error {
|
||||
flags, filePath, identityPath := fileFlags("serve", stderr)
|
||||
listen := flags.String("listen", ":9090", "HTTP listen address")
|
||||
dockerPath := flags.String("docker", "docker", "Docker CLI path")
|
||||
if err := parseNoArguments(flags, args); err != nil {
|
||||
return err
|
||||
}
|
||||
store, err := vault.Open(*filePath, *identityPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secretManager := manager.New(store, provider.Docker{Runner: provider.ExecRunner{Path: *dockerPath}}, 500*time.Millisecond)
|
||||
secretManager := manager.New(store, provider.Docker{Runner: provider.ExecRunner{Path: options.dockerPath}}, 500*time.Millisecond)
|
||||
go secretManager.Run(ctx)
|
||||
listener, err := net.Listen("tcp", *listen)
|
||||
listener, err := net.Listen("tcp", options.listen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on %s: %w", *listen, err)
|
||||
return fmt.Errorf("listen on %s: %w", options.listen, err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
logger.Info("TreatVault listening", "address", listener.Addr().String())
|
||||
@@ -183,39 +98,3 @@ func runServe(ctx context.Context, args []string, stderr io.Writer) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func fileFlags(name string, stderr io.Writer) (*flag.FlagSet, *string, *string) {
|
||||
flags := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
filePath := flags.String("file", "secrets.age", "encrypted secret file")
|
||||
identityPath := flags.String("identity", "treatvault-identity.txt", "age identity file")
|
||||
return flags, filePath, identityPath
|
||||
}
|
||||
|
||||
func parseNoArguments(flags *flag.FlagSet, args []string) error {
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return fmt.Errorf("unexpected arguments: %v", flags.Args())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readValue(path string, stdin io.Reader) ([]byte, error) {
|
||||
if path == "-" {
|
||||
return io.ReadAll(io.LimitReader(stdin, 500*1024+1))
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func usage(stderr io.Writer) error {
|
||||
_, _ = fmt.Fprintln(stderr, "usage:")
|
||||
_, _ = fmt.Fprintln(stderr, " treatvault keygen [--identity FILE]")
|
||||
_, _ = fmt.Fprintln(stderr, " treatvault init [--file FILE] [--identity FILE]")
|
||||
_, _ = fmt.Fprintln(stderr, " treatvault set --name NAME [--value-file FILE|-] [--file FILE] [--identity FILE]")
|
||||
_, _ = fmt.Fprintln(stderr, " treatvault delete --name NAME [--file FILE] [--identity FILE]")
|
||||
_, _ = fmt.Fprintln(stderr, " treatvault list [--file FILE] [--identity FILE]")
|
||||
_, _ = fmt.Fprintln(stderr, " treatvault serve [--file FILE] [--identity FILE] [--listen :9090] [--docker docker]")
|
||||
return errors.New("a command is required")
|
||||
}
|
||||
|
||||
@@ -2,85 +2,35 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault"
|
||||
"filippo.io/age"
|
||||
)
|
||||
|
||||
func TestCLIEndToEndWithoutDocker(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
filePath := filepath.Join(directory, "secrets.age")
|
||||
identityPath := filepath.Join(directory, "identity.txt")
|
||||
|
||||
var stdout, stderr strings.Builder
|
||||
mustRun(t, []string{"keygen", "--identity", identityPath}, nil, &stdout, &stderr)
|
||||
if !strings.Contains(stdout.String(), "Recipient: age1") {
|
||||
t.Fatalf("keygen output = %q", stdout.String())
|
||||
}
|
||||
mustRun(t, []string{"init", "--file", filePath, "--identity", identityPath}, nil, &strings.Builder{}, &strings.Builder{})
|
||||
mustRun(t, []string{"set", "--name", "database_password", "--value-file", "-", "--file", filePath, "--identity", identityPath}, strings.NewReader("correct horse battery staple"), &strings.Builder{}, &strings.Builder{})
|
||||
var list strings.Builder
|
||||
mustRun(t, []string{"list", "--file", filePath, "--identity", identityPath}, nil, &list, &strings.Builder{})
|
||||
if got := strings.TrimSpace(list.String()); got != "database_password" {
|
||||
t.Fatalf("list = %q", got)
|
||||
}
|
||||
|
||||
snapshot, err := vault.Open(filePath, identityPath)
|
||||
func TestParseOptionsUsesContainerDefaults(t *testing.T) {
|
||||
options, err := parseOptions(nil, &bytes.Buffer{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := snapshot.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(loaded.Secrets["database_password"].Value) != "correct horse battery staple" {
|
||||
t.Fatalf("stored value = %q", loaded.Secrets["database_password"].Value)
|
||||
}
|
||||
|
||||
mustRun(t, []string{"delete", "--name", "database_password", "--file", filePath, "--identity", identityPath}, nil, &strings.Builder{}, &strings.Builder{})
|
||||
loaded, err = snapshot.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(loaded.Secrets) != 0 {
|
||||
t.Fatalf("secrets after delete = %#v", loaded.Secrets)
|
||||
if options.filePath != "/var/lib/treatvault/secrets.age" || options.identityPath != "/run/secrets/barkstack_treatvault_identity" || options.listen != ":9090" || options.dockerPath != "docker" {
|
||||
t.Fatalf("options = %#v", options)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRejectsEmptyValue(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
filePath := filepath.Join(directory, "secrets.age")
|
||||
identityPath := filepath.Join(directory, "identity.txt")
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
func TestParseOptionsAcceptsServiceOverrides(t *testing.T) {
|
||||
options, err := parseOptions([]string{"--file", "/vault/secrets.age", "--identity", "/identity", "--listen", "127.0.0.1:9090", "--docker", "/usr/bin/docker"}, &bytes.Buffer{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(identityPath, []byte(identity.String()), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := vault.Initialize(filePath, identity); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stderr strings.Builder
|
||||
err = run(context.Background(), []string{"set", "--name", "database_password", "--value-file", "-", "--file", filePath, "--identity", identityPath}, strings.NewReader(""), &strings.Builder{}, &stderr)
|
||||
if err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
t.Fatalf("set empty value error = %v", err)
|
||||
if options.filePath != "/vault/secrets.age" || options.identityPath != "/identity" || options.listen != "127.0.0.1:9090" || options.dockerPath != "/usr/bin/docker" {
|
||||
t.Fatalf("options = %#v", options)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRun(t *testing.T, args []string, stdin *strings.Reader, stdout, stderr *strings.Builder) {
|
||||
t.Helper()
|
||||
var reader io.Reader = bytes.NewReader(nil)
|
||||
if stdin != nil {
|
||||
reader = stdin
|
||||
}
|
||||
if err := run(context.Background(), args, reader, stdout, stderr); err != nil {
|
||||
t.Fatalf("run(%v) error = %v", args, err)
|
||||
func TestParseOptionsRejectsLegacyCommands(t *testing.T) {
|
||||
for _, command := range []string{"keygen", "init", "set", "delete", "list", "serve"} {
|
||||
_, err := parseOptions([]string{command}, &bytes.Buffer{})
|
||||
if err == nil || !strings.Contains(err.Error(), "unexpected arguments") {
|
||||
t.Errorf("parseOptions(%q) error = %v", command, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ const (
|
||||
maxDocumentBytes = 16 * 1024 * 1024
|
||||
)
|
||||
|
||||
var ErrSecretNotFound = errors.New("secret not found")
|
||||
var (
|
||||
ErrSecretNotFound = errors.New("secret not found")
|
||||
ErrAlreadyInitialized = errors.New("encrypted file already exists")
|
||||
)
|
||||
|
||||
type Record struct {
|
||||
Revision string `json:"revision"`
|
||||
@@ -49,12 +52,26 @@ type Store struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func Open(path, identityPath string) (*Store, error) {
|
||||
// OpenOrInitialize opens an encrypted file, creating an empty vault when it
|
||||
// does not exist. A TreatVault service owns this bootstrap path; operators
|
||||
// never need a separate vault-management executable.
|
||||
func OpenOrInitialize(path, identityPath string) (*Store, error) {
|
||||
identity, err := ReadIdentity(identityPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store := &Store{path: path, identity: identity, recipient: identity.Recipient()}
|
||||
store := New(path, identity)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
if _, err := store.Load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("stat encrypted file: %w", err)
|
||||
}
|
||||
if err := Initialize(path, identity); err != nil && !errors.Is(err, ErrAlreadyInitialized) {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := store.Load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -77,25 +94,9 @@ func ReadIdentity(path string) (*age.X25519Identity, error) {
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func GenerateIdentity(path string) (string, error) {
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate age identity: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return "", fmt.Errorf("identity file %q already exists", path)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
if err := writeFileAtomic(path, []byte(identity.String()+"\n"), 0o600); err != nil {
|
||||
return "", fmt.Errorf("write age identity: %w", err)
|
||||
}
|
||||
return identity.Recipient().String(), nil
|
||||
}
|
||||
|
||||
func Initialize(path string, identity *age.X25519Identity) error {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return fmt.Errorf("encrypted file %q already exists", path)
|
||||
return fmt.Errorf("%w: %q", ErrAlreadyInitialized, path)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -86,23 +86,29 @@ func TestStoreRejectsWrongIdentityAndInvalidValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateIdentityDoesNotOverwrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "identity.txt")
|
||||
recipient, err := GenerateIdentity(path)
|
||||
func TestOpenOrInitializeCreatesReusableEmptyVault(t *testing.T) {
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recipient == "" {
|
||||
t.Fatal("recipient is empty")
|
||||
directory := t.TempDir()
|
||||
identityPath := filepath.Join(directory, "identity.txt")
|
||||
if err := os.WriteFile(identityPath, []byte(identity.String()), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := GenerateIdentity(path); err == nil {
|
||||
t.Fatal("GenerateIdentity() overwrote existing identity")
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
path := filepath.Join(directory, "state", "secrets.age")
|
||||
store, err := OpenOrInitialize(path, identityPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("identity permissions = %o", info.Mode().Perm())
|
||||
snapshot, err := store.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snapshot.Secrets) != 0 {
|
||||
t.Fatalf("initial secrets = %#v", snapshot.Secrets)
|
||||
}
|
||||
if _, err := OpenOrInitialize(path, identityPath); err != nil {
|
||||
t.Fatalf("OpenOrInitialize existing vault: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user