@reegaviljoen/eldlock 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +285 -0
  2. package/bin/eldlock +11 -0
  3. package/docs/architecture.md +164 -0
  4. package/docs/threat-model.md +47 -0
  5. package/eldlock-cli/README.md +56 -0
  6. package/eldlock-cli/bin/eldlock +3 -0
  7. package/eldlock-cli/package-lock.json +805 -0
  8. package/eldlock-cli/package.json +71 -0
  9. package/eldlock-cli/src/api.ts +250 -0
  10. package/eldlock-cli/src/cli.ts +490 -0
  11. package/eldlock-cli/src/main.ts +10 -0
  12. package/eldlock-cli/src/tui.ts +676 -0
  13. package/eldlock-cli/tsconfig.json +13 -0
  14. package/eldlock-cli/vendor/npm/ansi-regex-6.2.2.tgz +0 -0
  15. package/eldlock-cli/vendor/npm/bun-ffi-structs-0.2.2.tgz +0 -0
  16. package/eldlock-cli/vendor/npm/diff-9.0.0.tgz +0 -0
  17. package/eldlock-cli/vendor/npm/emoji-regex-10.6.0.tgz +0 -0
  18. package/eldlock-cli/vendor/npm/esbuild-0.28.0.tgz +0 -0
  19. package/eldlock-cli/vendor/npm/esbuild-darwin-arm64-0.28.0.tgz +0 -0
  20. package/eldlock-cli/vendor/npm/esbuild-darwin-x64-0.28.0.tgz +0 -0
  21. package/eldlock-cli/vendor/npm/esbuild-linux-arm64-0.28.0.tgz +0 -0
  22. package/eldlock-cli/vendor/npm/esbuild-linux-x64-0.28.0.tgz +0 -0
  23. package/eldlock-cli/vendor/npm/fsevents-2.3.3.tgz +0 -0
  24. package/eldlock-cli/vendor/npm/get-east-asian-width-1.6.0.tgz +0 -0
  25. package/eldlock-cli/vendor/npm/marked-17.0.1.tgz +0 -0
  26. package/eldlock-cli/vendor/npm/opentui-core-0.3.1.tgz +0 -0
  27. package/eldlock-cli/vendor/npm/opentui-core-darwin-arm64-0.3.1.tgz +0 -0
  28. package/eldlock-cli/vendor/npm/opentui-core-darwin-x64-0.3.1.tgz +0 -0
  29. package/eldlock-cli/vendor/npm/opentui-core-linux-arm64-0.3.1.tgz +0 -0
  30. package/eldlock-cli/vendor/npm/opentui-core-linux-x64-0.3.1.tgz +0 -0
  31. package/eldlock-cli/vendor/npm/string-width-7.2.0.tgz +0 -0
  32. package/eldlock-cli/vendor/npm/strip-ansi-7.1.2.tgz +0 -0
  33. package/eldlock-cli/vendor/npm/tsx-4.22.4.tgz +0 -0
  34. package/eldlock-cli/vendor/npm/types-node-22.19.19.tgz +0 -0
  35. package/eldlock-cli/vendor/npm/typescript-5.9.3.tgz +0 -0
  36. package/eldlock-cli/vendor/npm/undici-types-6.21.0.tgz +0 -0
  37. package/eldlock-cli/vendor/npm/web-tree-sitter-0.25.10.tgz +0 -0
  38. package/eldlock-cli/vendor/npm/yoga-layout-3.2.1.tgz +0 -0
  39. package/eldlock-server/cmd/eldlock-server/main.go +132 -0
  40. package/eldlock-server/go.mod +10 -0
  41. package/eldlock-server/go.sum +11 -0
  42. package/eldlock-server/internal/api/README.md +14 -0
  43. package/eldlock-server/internal/api/core.go +126 -0
  44. package/eldlock-server/internal/api/exec.go +97 -0
  45. package/eldlock-server/internal/api/secrets.go +358 -0
  46. package/eldlock-server/internal/api/server.go +72 -0
  47. package/eldlock-server/internal/api/service_test.go +416 -0
  48. package/eldlock-server/internal/api/types.go +48 -0
  49. package/eldlock-server/internal/api/vault.go +69 -0
  50. package/eldlock-server/internal/api/vendor.go +44 -0
  51. package/eldlock-server/internal/libfido2/LICENSE +21 -0
  52. package/eldlock-server/internal/libfido2/README.md +127 -0
  53. package/eldlock-server/internal/libfido2/examples_test.go +614 -0
  54. package/eldlock-server/internal/libfido2/fido2.go +1234 -0
  55. package/eldlock-server/internal/libfido2/fido2_darwin.go +7 -0
  56. package/eldlock-server/internal/libfido2/fido2_other.go +9 -0
  57. package/eldlock-server/internal/libfido2/fido2_test.go +101 -0
  58. package/eldlock-server/internal/libfido2/go.mod +10 -0
  59. package/eldlock-server/internal/libfido2/go.sum +16 -0
  60. package/eldlock-server/internal/libfido2/log.go +87 -0
  61. package/eldlock-server/internal/store/README.md +7 -0
  62. package/eldlock-server/internal/store/store.go +434 -0
  63. package/eldlock-server/internal/store/store_test.go +125 -0
  64. package/eldlock-server/internal/yubikey/README.md +25 -0
  65. package/eldlock-server/internal/yubikey/default_fido2.go +7 -0
  66. package/eldlock-server/internal/yubikey/default_stub.go +7 -0
  67. package/eldlock-server/internal/yubikey/fido2_disabled.go +9 -0
  68. package/eldlock-server/internal/yubikey/fido2_libfido2.go +225 -0
  69. package/eldlock-server/internal/yubikey/fido2_libfido2_test.go +66 -0
  70. package/eldlock-server/internal/yubikey/passkey.go +139 -0
  71. package/eldlock-server/internal/yubikey/passkey_test.go +36 -0
  72. package/eldlock-server/vendor/github.com/keys-pub/go-libfido2/LICENSE +21 -0
  73. package/eldlock-server/vendor/github.com/keys-pub/go-libfido2/README.md +127 -0
  74. package/eldlock-server/vendor/github.com/keys-pub/go-libfido2/fido2.go +1234 -0
  75. package/eldlock-server/vendor/github.com/keys-pub/go-libfido2/fido2_darwin.go +7 -0
  76. package/eldlock-server/vendor/github.com/keys-pub/go-libfido2/fido2_other.go +9 -0
  77. package/eldlock-server/vendor/github.com/keys-pub/go-libfido2/log.go +87 -0
  78. package/eldlock-server/vendor/github.com/pkg/errors/.travis.yml +10 -0
  79. package/eldlock-server/vendor/github.com/pkg/errors/LICENSE +23 -0
  80. package/eldlock-server/vendor/github.com/pkg/errors/Makefile +44 -0
  81. package/eldlock-server/vendor/github.com/pkg/errors/README.md +59 -0
  82. package/eldlock-server/vendor/github.com/pkg/errors/appveyor.yml +32 -0
  83. package/eldlock-server/vendor/github.com/pkg/errors/errors.go +288 -0
  84. package/eldlock-server/vendor/github.com/pkg/errors/go113.go +38 -0
  85. package/eldlock-server/vendor/github.com/pkg/errors/stack.go +177 -0
  86. package/eldlock-server/vendor/modules.txt +7 -0
  87. package/examples/eldlock.toml +17 -0
  88. package/install.sh +66 -0
  89. package/package.json +66 -0
  90. package/scripts/build-production.mjs +177 -0
  91. package/scripts/postinstall-production.mjs +23 -0
@@ -0,0 +1,7 @@
1
+ package libfido2
2
+
3
+ /*
4
+ #cgo darwin pkg-config: libfido2
5
+ #cgo darwin LDFLAGS: -framework CoreFoundation -framework IOKit
6
+ */
7
+ import "C"
@@ -0,0 +1,9 @@
1
+ package libfido2
2
+
3
+ /*
4
+ #cgo linux LDFLAGS: -L/usr/lib/x86_64-linux-gnu -lfido2
5
+ #cgo linux CFLAGS: -I/usr/include/fido
6
+ #cgo windows LDFLAGS: -L${SRCDIR}/windows/lib -lfido2
7
+ #cgo windows CFLAGS: -I${SRCDIR}/windows/include
8
+ */
9
+ import "C"
@@ -0,0 +1,101 @@
1
+ package libfido2_test
2
+
3
+ import (
4
+ "log"
5
+ "testing"
6
+ "time"
7
+
8
+ "github.com/keys-pub/go-libfido2"
9
+ "github.com/pkg/errors"
10
+ "github.com/stretchr/testify/require"
11
+ )
12
+
13
+ // TODO: It's important tests are run serially (a device can't handle concurrent requests).
14
+
15
+ func TestDevices(t *testing.T) {
16
+ locs, err := libfido2.DeviceLocations()
17
+ require.NoError(t, err)
18
+ t.Logf("Found %d devices", len(locs))
19
+
20
+ for _, loc := range locs {
21
+ device, err := libfido2.NewDevice(loc.Path)
22
+ require.NoError(t, err)
23
+
24
+ isFIDO2, err := device.IsFIDO2()
25
+ require.NoError(t, err)
26
+ if !isFIDO2 {
27
+ continue
28
+ }
29
+
30
+ typ, err := device.Type()
31
+ require.NoError(t, err)
32
+ require.Equal(t, libfido2.FIDO2, typ)
33
+
34
+ // Testing info twice (hid_osx issues in the past caused a delayed 2nd request to fail).
35
+ info, err := device.Info()
36
+ require.NoError(t, err)
37
+ time.Sleep(time.Millisecond * 100)
38
+
39
+ info, err = device.Info()
40
+ require.NoError(t, err)
41
+ t.Logf("Info: %+v", info)
42
+ }
43
+ }
44
+
45
+ func TestDeviceAssertionCancel(t *testing.T) {
46
+ locs, err := libfido2.DeviceLocations()
47
+ require.NoError(t, err)
48
+ if len(locs) == 0 {
49
+ t.Skip("No devices")
50
+ }
51
+
52
+ t.Logf("Using device: %+v\n", locs[0])
53
+ path := locs[0].Path
54
+ device, err := libfido2.NewDevice(path)
55
+ if err != nil {
56
+ log.Fatal(err)
57
+ }
58
+
59
+ cdh := libfido2.RandBytes(32)
60
+ userID := libfido2.RandBytes(32)
61
+ salt := libfido2.RandBytes(32)
62
+ pin := "12345"
63
+
64
+ t.Logf("Make credential\n")
65
+ attest, err := device.MakeCredential(
66
+ cdh,
67
+ libfido2.RelyingParty{
68
+ ID: "keys.pub",
69
+ },
70
+ libfido2.User{
71
+ ID: userID,
72
+ Name: "gabriel",
73
+ },
74
+ libfido2.ES256, // Algorithm
75
+ pin,
76
+ &libfido2.MakeCredentialOpts{
77
+ Extensions: []libfido2.Extension{libfido2.HMACSecretExtension},
78
+ RK: libfido2.True,
79
+ },
80
+ )
81
+ require.NoError(t, err)
82
+
83
+ go func() {
84
+ time.Sleep(time.Second * 2)
85
+ t.Logf("Cancel")
86
+ device.Cancel()
87
+ }()
88
+
89
+ _, err = device.Assertion(
90
+ "keys.pub",
91
+ cdh,
92
+ [][]byte{attest.CredentialID},
93
+ pin,
94
+ &libfido2.AssertionOpts{
95
+ Extensions: []libfido2.Extension{libfido2.HMACSecretExtension},
96
+ UP: libfido2.True,
97
+ HMACSalt: salt,
98
+ },
99
+ )
100
+ require.EqualError(t, errors.Cause(err), "keep alive cancel")
101
+ }
@@ -0,0 +1,10 @@
1
+ module github.com/keys-pub/go-libfido2
2
+
3
+ go 1.13
4
+
5
+ require (
6
+ github.com/davecgh/go-spew v1.1.0
7
+ github.com/pkg/errors v0.9.1
8
+ github.com/stretchr/testify v1.5.1
9
+ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a
10
+ )
@@ -0,0 +1,16 @@
1
+ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
2
+ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3
+ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
4
+ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
5
+ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
6
+ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
7
+ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
8
+ github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
9
+ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
10
+ github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
11
+ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
12
+ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a h1:WXEvlFVvvGxCJLG6REjsT03iWnKLEWinaScsxF2Vm2o=
13
+ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
14
+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
15
+ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
16
+ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -0,0 +1,87 @@
1
+ package libfido2
2
+
3
+ import (
4
+ pkglog "log"
5
+ )
6
+
7
+ var logger = NewLogger(ErrLevel)
8
+
9
+ // SetLogger sets logger for the package.
10
+ func SetLogger(l Logger) {
11
+ logger = l
12
+ }
13
+
14
+ // Logger interface used in this package.
15
+ type Logger interface {
16
+ Debugf(format string, args ...interface{})
17
+ Infof(format string, args ...interface{})
18
+ Warningf(format string, args ...interface{})
19
+ Errorf(format string, args ...interface{})
20
+ Fatalf(format string, args ...interface{})
21
+ }
22
+
23
+ // LogLevel ...
24
+ type LogLevel int
25
+
26
+ const (
27
+ // DebugLevel ...
28
+ DebugLevel LogLevel = 3
29
+ // InfoLevel ...
30
+ InfoLevel LogLevel = 2
31
+ // WarnLevel ...
32
+ WarnLevel LogLevel = 1
33
+ // ErrLevel ...
34
+ ErrLevel LogLevel = 0
35
+ )
36
+
37
+ // NewLogger ...
38
+ func NewLogger(lev LogLevel) Logger {
39
+ return &defaultLog{Level: lev}
40
+ }
41
+
42
+ func (l LogLevel) String() string {
43
+ switch l {
44
+ case DebugLevel:
45
+ return "debug"
46
+ case InfoLevel:
47
+ return "info"
48
+ case WarnLevel:
49
+ return "warn"
50
+ case ErrLevel:
51
+ return "err"
52
+ default:
53
+ return ""
54
+ }
55
+ }
56
+
57
+ type defaultLog struct {
58
+ Level LogLevel
59
+ }
60
+
61
+ func (l defaultLog) Debugf(format string, args ...interface{}) {
62
+ if l.Level >= 3 {
63
+ pkglog.Printf("[DEBG] "+format+"\n", args...)
64
+ }
65
+ }
66
+
67
+ func (l defaultLog) Infof(format string, args ...interface{}) {
68
+ if l.Level >= 2 {
69
+ pkglog.Printf("[INFO] "+format+"\n", args...)
70
+ }
71
+ }
72
+
73
+ func (l defaultLog) Warningf(format string, args ...interface{}) {
74
+ if l.Level >= 1 {
75
+ pkglog.Printf("[WARN] "+format+"\n", args...)
76
+ }
77
+ }
78
+
79
+ func (l defaultLog) Errorf(format string, args ...interface{}) {
80
+ if l.Level >= 0 {
81
+ pkglog.Printf("[ERR] "+format+"\n", args...)
82
+ }
83
+ }
84
+
85
+ func (l defaultLog) Fatalf(format string, args ...interface{}) {
86
+ pkglog.Fatalf(format, args...)
87
+ }
@@ -0,0 +1,7 @@
1
+ # Store
2
+
3
+ Local encrypted vault.
4
+
5
+ The vault file lives on the user's machine and should not require a hosted Eldlock service to function.
6
+
7
+ Current implementation note: the 0.1 preview uses an encrypted JSON envelope. Eldlock should avoid SQL/SQLite for vault storage so SQL injection is not part of the storage threat model. The envelope uses HKDF-SHA256 plus AES-256-GCM, with key material requested from the configured passkey provider for every vault create, read, add, list, and remove operation.
@@ -0,0 +1,434 @@
1
+ package store
2
+
3
+ import (
4
+ "crypto/aes"
5
+ "crypto/cipher"
6
+ "crypto/hkdf"
7
+ "crypto/rand"
8
+ "crypto/sha256"
9
+ "encoding/base64"
10
+ "encoding/json"
11
+ "errors"
12
+ "fmt"
13
+ "os"
14
+ "path/filepath"
15
+ "sort"
16
+ "sync"
17
+ "time"
18
+
19
+ "github.com/eldlock/eldlock-server/internal/yubikey"
20
+ )
21
+
22
+ var (
23
+ ErrNotFound = errors.New("secret not found")
24
+ )
25
+
26
+ type SecretType string
27
+
28
+ const (
29
+ SecretTypeEnv SecretType = "env"
30
+ SecretTypeSSH SecretType = "ssh"
31
+ )
32
+
33
+ type Secret struct {
34
+ Name string `json:"name"`
35
+ Type SecretType `json:"type"`
36
+ Value string `json:"value"`
37
+ CreatedAt time.Time `json:"created_at"`
38
+ UpdatedAt time.Time `json:"updated_at"`
39
+ }
40
+
41
+ type SecretSummary struct {
42
+ Name string `json:"name"`
43
+ Type SecretType `json:"type"`
44
+ CreatedAt time.Time `json:"created_at"`
45
+ UpdatedAt time.Time `json:"updated_at"`
46
+ }
47
+
48
+ type KeyProvider interface {
49
+ CreateKeyMaterial(operation string, salt []byte) ([]byte, map[string]string, error)
50
+ OpenKeyMaterial(operation string, salt []byte, metadata map[string]string) ([]byte, error)
51
+ }
52
+
53
+ type FileStore struct {
54
+ path string
55
+ keyProvider KeyProvider
56
+ mu sync.Mutex
57
+ }
58
+
59
+ func NewFileStore(path string, keyProvider KeyProvider) *FileStore {
60
+ return &FileStore{path: path, keyProvider: keyProvider}
61
+ }
62
+
63
+ func (s *FileStore) Init() error {
64
+ return s.InitWithPIN("")
65
+ }
66
+
67
+ func (s *FileStore) InitWithPIN(pin string) error {
68
+ s.mu.Lock()
69
+ defer s.mu.Unlock()
70
+
71
+ keyProvider := s.keyProviderWithPIN(pin)
72
+ if err := ensureProvider(keyProvider); err != nil {
73
+ return err
74
+ }
75
+ if _, err := os.Stat(s.path); err == nil {
76
+ return nil
77
+ } else if !errors.Is(err, os.ErrNotExist) {
78
+ return fmt.Errorf("stat store: %w", err)
79
+ }
80
+
81
+ salt, err := randomBytes(32)
82
+ if err != nil {
83
+ return fmt.Errorf("generate vault salt: %w", err)
84
+ }
85
+ material, metadata, err := keyProvider.CreateKeyMaterial("init", salt)
86
+ if err != nil {
87
+ return fmt.Errorf("passkey init: %w", err)
88
+ }
89
+ key, err := deriveKey(material, salt)
90
+ if err != nil {
91
+ return err
92
+ }
93
+
94
+ return s.saveEncrypted(diskData{Secrets: map[string]Secret{}}, vaultEnvelope{Version: 1, Salt: encode(salt), Passkey: metadata}, key)
95
+ }
96
+
97
+ func (s *FileStore) IsInitialized() (bool, error) {
98
+ s.mu.Lock()
99
+ defer s.mu.Unlock()
100
+
101
+ if _, err := os.Stat(s.path); err == nil {
102
+ return true, nil
103
+ } else if errors.Is(err, os.ErrNotExist) {
104
+ return false, nil
105
+ } else {
106
+ return false, fmt.Errorf("stat store: %w", err)
107
+ }
108
+ }
109
+
110
+ func (s *FileStore) Put(secret Secret) error {
111
+ return s.PutWithPIN(secret, "")
112
+ }
113
+
114
+ func (s *FileStore) PutWithPIN(secret Secret, pin string) error {
115
+ s.mu.Lock()
116
+ defer s.mu.Unlock()
117
+
118
+ data, envelope, key, err := s.loadUnlocked("put", pin)
119
+ if err != nil {
120
+ return err
121
+ }
122
+
123
+ now := time.Now().UTC()
124
+ if existing, ok := data.Secrets[secret.Name]; ok {
125
+ secret.CreatedAt = existing.CreatedAt
126
+ } else {
127
+ secret.CreatedAt = now
128
+ }
129
+ secret.UpdatedAt = now
130
+ data.Secrets[secret.Name] = secret
131
+
132
+ return s.saveEncrypted(data, envelope, key)
133
+ }
134
+
135
+ func (s *FileStore) PutManyWithPIN(secrets []Secret, pin string) error {
136
+ s.mu.Lock()
137
+ defer s.mu.Unlock()
138
+
139
+ data, envelope, key, err := s.loadUnlocked("import", pin)
140
+ if err != nil {
141
+ return err
142
+ }
143
+
144
+ now := time.Now().UTC()
145
+ for _, secret := range secrets {
146
+ if existing, ok := data.Secrets[secret.Name]; ok {
147
+ secret.CreatedAt = existing.CreatedAt
148
+ } else {
149
+ secret.CreatedAt = now
150
+ }
151
+ secret.UpdatedAt = now
152
+ data.Secrets[secret.Name] = secret
153
+ }
154
+
155
+ return s.saveEncrypted(data, envelope, key)
156
+ }
157
+
158
+ func (s *FileStore) Get(name string) (Secret, error) {
159
+ return s.GetWithPIN(name, "")
160
+ }
161
+
162
+ func (s *FileStore) GetWithPIN(name string, pin string) (Secret, error) {
163
+ s.mu.Lock()
164
+ defer s.mu.Unlock()
165
+
166
+ data, _, _, err := s.loadUnlocked("get", pin)
167
+ if err != nil {
168
+ return Secret{}, err
169
+ }
170
+ secret, ok := data.Secrets[name]
171
+ if !ok {
172
+ return Secret{}, ErrNotFound
173
+ }
174
+ return secret, nil
175
+ }
176
+
177
+ func (s *FileStore) Delete(name string) error {
178
+ return s.DeleteWithPIN(name, "")
179
+ }
180
+
181
+ func (s *FileStore) DeleteWithPIN(name string, pin string) error {
182
+ s.mu.Lock()
183
+ defer s.mu.Unlock()
184
+
185
+ data, envelope, key, err := s.loadUnlocked("delete", pin)
186
+ if err != nil {
187
+ return err
188
+ }
189
+ if _, ok := data.Secrets[name]; !ok {
190
+ return ErrNotFound
191
+ }
192
+ delete(data.Secrets, name)
193
+
194
+ return s.saveEncrypted(data, envelope, key)
195
+ }
196
+
197
+ func (s *FileStore) List() ([]SecretSummary, error) {
198
+ return s.ListWithPIN("")
199
+ }
200
+
201
+ func (s *FileStore) ListWithPIN(pin string) ([]SecretSummary, error) {
202
+ s.mu.Lock()
203
+ defer s.mu.Unlock()
204
+
205
+ data, _, _, err := s.loadUnlocked("list", pin)
206
+ if err != nil {
207
+ return nil, err
208
+ }
209
+
210
+ summaries := make([]SecretSummary, 0, len(data.Secrets))
211
+ for _, secret := range data.Secrets {
212
+ summaries = append(summaries, SecretSummary{
213
+ Name: secret.Name,
214
+ Type: secret.Type,
215
+ CreatedAt: secret.CreatedAt,
216
+ UpdatedAt: secret.UpdatedAt,
217
+ })
218
+ }
219
+ sort.Slice(summaries, func(i, j int) bool {
220
+ return summaries[i].Name < summaries[j].Name
221
+ })
222
+
223
+ return summaries, nil
224
+ }
225
+
226
+ func (s *FileStore) Env() (map[string]string, error) {
227
+ return s.EnvWithPIN("")
228
+ }
229
+
230
+ func (s *FileStore) EnvWithPIN(pin string) (map[string]string, error) {
231
+ s.mu.Lock()
232
+ defer s.mu.Unlock()
233
+
234
+ data, _, _, err := s.loadUnlocked("exec", pin)
235
+ if err != nil {
236
+ return nil, err
237
+ }
238
+
239
+ env := make(map[string]string)
240
+ for _, secret := range data.Secrets {
241
+ if secret.Type == SecretTypeEnv {
242
+ env[secret.Name] = secret.Value
243
+ }
244
+ }
245
+ return env, nil
246
+ }
247
+
248
+ type diskData struct {
249
+ Secrets map[string]Secret `json:"secrets"`
250
+ }
251
+
252
+ type vaultEnvelope struct {
253
+ Version int `json:"version"`
254
+ KDF string `json:"kdf"`
255
+ AEAD string `json:"aead"`
256
+ Salt string `json:"salt"`
257
+ Passkey map[string]string `json:"passkey"`
258
+ Nonce string `json:"nonce"`
259
+ Ciphertext string `json:"ciphertext"`
260
+ }
261
+
262
+ func (s *FileStore) loadUnlocked(operation string, pin string) (diskData, vaultEnvelope, []byte, error) {
263
+ keyProvider := s.keyProviderWithPIN(pin)
264
+ if err := ensureProvider(keyProvider); err != nil {
265
+ return diskData{}, vaultEnvelope{}, nil, err
266
+ }
267
+ envelope, err := s.loadEnvelope()
268
+ if err != nil {
269
+ return diskData{}, vaultEnvelope{}, nil, err
270
+ }
271
+ salt, err := decode(envelope.Salt)
272
+ if err != nil {
273
+ return diskData{}, vaultEnvelope{}, nil, fmt.Errorf("decode vault salt: %w", err)
274
+ }
275
+ material, err := keyProvider.OpenKeyMaterial(operation, salt, envelope.Passkey)
276
+ if err != nil {
277
+ return diskData{}, vaultEnvelope{}, nil, err
278
+ }
279
+ key, err := deriveKey(material, salt)
280
+ if err != nil {
281
+ return diskData{}, vaultEnvelope{}, nil, err
282
+ }
283
+ data, err := decryptData(envelope, key)
284
+ if err != nil {
285
+ return diskData{}, vaultEnvelope{}, nil, err
286
+ }
287
+ return data, envelope, key, nil
288
+ }
289
+
290
+ func (s *FileStore) loadEnvelope() (vaultEnvelope, error) {
291
+ raw, err := os.ReadFile(s.path)
292
+ if err != nil {
293
+ return vaultEnvelope{}, fmt.Errorf("read store: %w", err)
294
+ }
295
+
296
+ var envelope vaultEnvelope
297
+ if err := json.Unmarshal(raw, &envelope); err != nil {
298
+ return vaultEnvelope{}, fmt.Errorf("decode encrypted store envelope: %w", err)
299
+ }
300
+ if envelope.Version != 1 {
301
+ return vaultEnvelope{}, fmt.Errorf("unsupported vault version %d", envelope.Version)
302
+ }
303
+ if envelope.KDF != "hkdf-sha256" || envelope.AEAD != "aes-256-gcm" {
304
+ return vaultEnvelope{}, errors.New("unsupported vault crypto parameters")
305
+ }
306
+ if envelope.Salt == "" || envelope.Nonce == "" || envelope.Ciphertext == "" || envelope.Passkey == nil {
307
+ return vaultEnvelope{}, errors.New("invalid encrypted vault envelope")
308
+ }
309
+
310
+ return envelope, nil
311
+ }
312
+
313
+ func (s *FileStore) saveEncrypted(data diskData, envelope vaultEnvelope, key []byte) error {
314
+ if data.Secrets == nil {
315
+ data.Secrets = map[string]Secret{}
316
+ }
317
+ if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
318
+ return fmt.Errorf("create store directory: %w", err)
319
+ }
320
+
321
+ next, err := encryptData(data, envelope, key)
322
+ if err != nil {
323
+ return err
324
+ }
325
+ raw, err := json.MarshalIndent(next, "", " ")
326
+ if err != nil {
327
+ return fmt.Errorf("encode encrypted store: %w", err)
328
+ }
329
+ raw = append(raw, '\n')
330
+
331
+ return os.WriteFile(s.path, raw, 0o600)
332
+ }
333
+
334
+ func deriveKey(material []byte, salt []byte) ([]byte, error) {
335
+ if len(material) < 32 {
336
+ return nil, errors.New("passkey material must be at least 32 bytes")
337
+ }
338
+ key, err := hkdf.Key(sha256.New, material, salt, "eldlock vault encryption v1", 32)
339
+ if err != nil {
340
+ return nil, fmt.Errorf("derive vault key: %w", err)
341
+ }
342
+ return key, nil
343
+ }
344
+
345
+ func (s *FileStore) keyProviderWithPIN(pin string) KeyProvider {
346
+ if pin == "" {
347
+ return s.keyProvider
348
+ }
349
+ if provider, ok := s.keyProvider.(yubikey.PINProvider); ok {
350
+ return provider.WithPIN(pin)
351
+ }
352
+ return s.keyProvider
353
+ }
354
+
355
+ func ensureProvider(keyProvider KeyProvider) error {
356
+ if keyProvider == nil {
357
+ return errors.New("passkey provider is not configured")
358
+ }
359
+ return nil
360
+ }
361
+
362
+ func encryptData(data diskData, envelope vaultEnvelope, key []byte) (vaultEnvelope, error) {
363
+ plaintext, err := json.Marshal(data)
364
+ if err != nil {
365
+ return vaultEnvelope{}, fmt.Errorf("encode vault plaintext: %w", err)
366
+ }
367
+ block, err := aes.NewCipher(key)
368
+ if err != nil {
369
+ return vaultEnvelope{}, fmt.Errorf("create AES cipher: %w", err)
370
+ }
371
+ aead, err := cipher.NewGCM(block)
372
+ if err != nil {
373
+ return vaultEnvelope{}, fmt.Errorf("create GCM: %w", err)
374
+ }
375
+ nonce, err := randomBytes(aead.NonceSize())
376
+ if err != nil {
377
+ return vaultEnvelope{}, fmt.Errorf("generate nonce: %w", err)
378
+ }
379
+
380
+ envelope.Version = 1
381
+ envelope.KDF = "hkdf-sha256"
382
+ envelope.AEAD = "aes-256-gcm"
383
+ envelope.Nonce = encode(nonce)
384
+ envelope.Ciphertext = encode(aead.Seal(nil, nonce, plaintext, []byte("eldlock-vault-v1")))
385
+ return envelope, nil
386
+ }
387
+
388
+ func decryptData(envelope vaultEnvelope, key []byte) (diskData, error) {
389
+ nonce, err := decode(envelope.Nonce)
390
+ if err != nil {
391
+ return diskData{}, fmt.Errorf("decode vault nonce: %w", err)
392
+ }
393
+ ciphertext, err := decode(envelope.Ciphertext)
394
+ if err != nil {
395
+ return diskData{}, fmt.Errorf("decode vault ciphertext: %w", err)
396
+ }
397
+ block, err := aes.NewCipher(key)
398
+ if err != nil {
399
+ return diskData{}, fmt.Errorf("create AES cipher: %w", err)
400
+ }
401
+ aead, err := cipher.NewGCM(block)
402
+ if err != nil {
403
+ return diskData{}, fmt.Errorf("create GCM: %w", err)
404
+ }
405
+ plaintext, err := aead.Open(nil, nonce, ciphertext, []byte("eldlock-vault-v1"))
406
+ if err != nil {
407
+ return diskData{}, errors.New("decrypt vault: passkey rejected or vault data changed")
408
+ }
409
+
410
+ var data diskData
411
+ if err := json.Unmarshal(plaintext, &data); err != nil {
412
+ return diskData{}, fmt.Errorf("decode vault plaintext: %w", err)
413
+ }
414
+ if data.Secrets == nil {
415
+ data.Secrets = map[string]Secret{}
416
+ }
417
+ return data, nil
418
+ }
419
+
420
+ func randomBytes(size int) ([]byte, error) {
421
+ buf := make([]byte, size)
422
+ if _, err := rand.Read(buf); err != nil {
423
+ return nil, err
424
+ }
425
+ return buf, nil
426
+ }
427
+
428
+ func encode(value []byte) string {
429
+ return base64.RawStdEncoding.EncodeToString(value)
430
+ }
431
+
432
+ func decode(value string) ([]byte, error) {
433
+ return base64.RawStdEncoding.DecodeString(value)
434
+ }