Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
503c239
chore(store): export common secret factory type
Benehiko Sep 11, 2025
4f60a61
chore: add filippo.io/age package
Benehiko Sep 11, 2025
27589b8
feat(store): posix compliant store using age encryption
Benehiko Sep 11, 2025
b3fb5d0
test: posixage package
Benehiko Sep 11, 2025
32065de
posixage: add README
Benehiko Sep 11, 2025
d5674db
posixage: test helper funcs
Benehiko Sep 12, 2025
aea04e8
posixage: more store tests
Benehiko Sep 12, 2025
af36409
posixage: options should return error
Benehiko Sep 12, 2025
20f6e59
posixage: lock recovery pattern
Benehiko Sep 15, 2025
4b381fa
chore: fix lint
Benehiko Sep 15, 2025
099a56f
test: properly close os.Root
Benehiko Sep 16, 2025
77e1aaa
posixage: simplify locking the store
Benehiko Sep 17, 2025
ce8c991
posixage: use flock
Benehiko Sep 17, 2025
2c98617
posixage: deduplicate code
Benehiko Sep 17, 2025
b3501a1
posixage: refactor and move helpers
Benehiko Sep 18, 2025
2270568
posixage: refactor store
Benehiko Sep 18, 2025
285ca9d
posixage: group encryption/decryption prompt funcs under one type
Benehiko Sep 18, 2025
0abb842
chore: update godoc
Benehiko Sep 18, 2025
6e0deaa
chore: update mod
Benehiko Sep 18, 2025
fea1dea
chore: fix lint
Benehiko Sep 18, 2025
ae46908
posixage: always unlock mutex on flock error
Benehiko Sep 19, 2025
dcf0f86
chore: better code comments
Benehiko Sep 19, 2025
0538de1
posixage(test): add testLogger
Benehiko Sep 19, 2025
030c3de
chore: fix spelling errors
Benehiko Sep 19, 2025
180b7a1
posixage: set the error correctly for the defer
Benehiko Sep 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.work.sum
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwm
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=
github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU=
github.com/opencontainers/runtime-tools v0.9.0/go.mod h1:r3f7wjNzSs2extwzU3Y+6pKfobzPh+kKFJ3ofN+3nfs=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
Expand Down
1 change: 1 addition & 0 deletions store/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Supported stores include:
- Linux keychain (gnome-keyring and kdewallet)
- macOS keychain
- windows credential management API
- file encryption via [age](https://github.com/filoSottile/age)

## Local Testing

Expand Down
2 changes: 2 additions & 0 deletions store/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ go 1.25
replace github.com/docker/secrets-engine/x => ../x

require (
filippo.io/age v1.2.1
github.com/cenkalti/backoff/v5 v5.0.3
github.com/danieljoos/wincred v1.2.2
github.com/docker/secrets-engine/x v0.0.3-do.not.use
Expand All @@ -21,6 +22,7 @@ require (
)

require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
Expand Down
8 changes: 8 additions & 0 deletions store/go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0=
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
Expand Down Expand Up @@ -38,6 +44,8 @@ golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
4 changes: 1 addition & 3 deletions store/keychain/keychain.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ type keychainStore[T store.Secret] struct {

var _ store.Store = &keychainStore[store.Secret]{}

type Factory[T store.Secret] func() T

// New creates a new keychain store.
//
// It takes ServiceGroup and ServiceName and a [Factory] as input.
Expand All @@ -42,7 +40,7 @@ type Factory[T store.Secret] func() T
// Changing the service name can be done, but would require migrating existing credentials.
//
// [Factory] is a function used to instantiate new secrets of type T.
func New[T store.Secret](serviceGroup, serviceName string, factory Factory[T]) (store.Store, error) {
func New[T store.Secret](serviceGroup, serviceName string, factory store.Factory[T]) (store.Store, error) {
if serviceGroup == "" || serviceName == "" {
return nil, errors.New("serviceGroup and serviceName are required")
}
Expand Down
78 changes: 78 additions & 0 deletions store/posixage/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Store posixage

The posixage store is a POSIX compliant encrypted file store. It uses [age](https://github.com/filoSottile/age)
to encrypt/decrypt its files and has support for password, ssh and age keys.

## Quickstart

```go
import "github.com/docker/secrets-engine/store/posixage"

func main() {
root, err := os.OpenRoot("my/secrets/path")
if err != nil {
panic(err)
}

s, err := posixage.New(root,
func() *mocks.MockCredential {
return &mocks.MockCredential{}
},
WithEncryptionCallbackFunc[EncryptionPassword](func(_ context.Context) ([]byte, error) {
return []byte(masterKey), nil
}),
WithDecryptionCallbackFunc[DecryptionPassword](func(_ context.Context) ([]byte, error) {
return []byte(masterKey), nil
}),
)
}
```

The store allows you to register multiple encryption and decryption callback
functions. Each callback gives your application control over how to retrieve
the required data — for example, from environment variables, a configuration
file, or via an interactive user prompt.

### Features

- Support for multiple encryption functions
- Support for multiple decryption functions

Callbacks are invoked in the order they are registered. For decryption, the
store tries each callback in sequence, and the first one that successfully
provides a valid key will return the decrypted secret.

Here's an example of accepting multiple passwords for encryption:

```go
import "github.com/docker/secrets-engine/store/posixage"

func main() {
root, err := os.OpenRoot("my/secrets/path")
if err != nil {
panic(err)
}

s, err := posixage.New(root,
func() *mocks.MockCredential {
return &mocks.MockCredential{}
},
WithEncryptionCallbackFunc[EncryptionPassword](func(_ context.Context) ([]byte, error) {
return []byte(masterKey), nil
}),
WithEncryptionCallbackFunc[EncryptionPassword](func(_ context.Context) ([]byte, error) {
return []byte(bobPassword), nil
}),
WithEncryptionCallbackFunc[EncryptionAgeX25519](func(_ context.Context) ([]byte, error) {
return []byte(identity.Recipient().String()), nil
}),
WithDecryptionCallbackFunc[DecryptionPassword](func(_ context.Context) ([]byte, error) {
return []byte(masterKey), nil
}),
)
}
```

### Secrets

Any secret format is supported as long as it conforms to the `store.Secret` interface.
102 changes: 102 additions & 0 deletions store/posixage/internal/secretfile/keys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package secretfile

import (
"context"
"fmt"

"filippo.io/age"
"filippo.io/age/agessh"
)

type (
// PromptFunc is a callback invoked by the store when encrypting or
// decrypting a file. The function is expected to return the key material
// (as a byte slice) or an error if the key cannot be obtained.
PromptFunc func(context.Context) ([]byte, error)

// KeyType identifies the type of encryption or decryption key associated
// with a secret (e.g., password, age, or SSH).
KeyType string
)

const (
PasswordKeyType KeyType = "pass"
AgeKeyType KeyType = "age"
SSHKeyType KeyType = "ssh"
)

func getRecipient(k KeyType, encryptionKey string) (age.Recipient, error) {
var recipient age.Recipient
var err error

switch k {
case PasswordKeyType:
recipient, err = age.NewScryptRecipient(encryptionKey)
case AgeKeyType:
recipient, err = age.ParseX25519Recipient(encryptionKey)
case SSHKeyType:
recipient, err = agessh.ParseRecipient(encryptionKey)
default:
return nil, fmt.Errorf("unsupported encryption type %T", k)
}

if err != nil {
return nil, err
}

return recipient, nil
}

// GetRecipients returns a slice of [age.Recipient] for the given key type and
// encryption keys.
//
// The recipient implementation depends on the provided [KeyType]:
// - passwordKeyType → [age.NewScryptRecipient]
// - ageKeyType → [age.ParseX25519Recipient]
// - sshKeyType → [agessh.ParseRecipient]
//
// An error is returned if the key cannot be parsed or the key type is
// unsupported.
func GetRecipients(k KeyType, encryptionKeys []string) ([]age.Recipient, error) {
var recipients []age.Recipient
for _, encryptionKey := range encryptionKeys {
recipient, err := getRecipient(k, encryptionKey)
if err != nil {
return nil, err
}
recipients = append(recipients, recipient)
}
return recipients, nil
}

// GetIdentity returns an [age.Identity] for the given key type and
// decryption key.
//
// The identity implementation depends on the provided [KeyType]:
// - PasswordKeyType → [age.NewScryptIdentity]
// - AgeKeyType → [age.ParseX25519Identity]
// - SSHKeyType → [agessh.ParseIdentity]
//
// An error is returned if the key cannot be parsed or the key type is
// unsupported.
func GetIdentity(k KeyType, decryptionKey string) (age.Identity, error) {
var identity age.Identity
var err error

switch k {
case PasswordKeyType:
identity, err = age.NewScryptIdentity(decryptionKey)
case AgeKeyType:
identity, err = age.ParseX25519Identity(decryptionKey)
case SSHKeyType:
identity, err = agessh.ParseIdentity([]byte(decryptionKey))
default:
return nil, fmt.Errorf("unsupported decryption type %T", k)
}

if err != nil {
return nil, err
}

return identity, nil
}
Loading
Loading