@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.
- package/README.md +285 -0
- package/bin/eldlock +11 -0
- package/docs/architecture.md +164 -0
- package/docs/threat-model.md +47 -0
- package/eldlock-cli/README.md +56 -0
- package/eldlock-cli/bin/eldlock +3 -0
- package/eldlock-cli/package-lock.json +805 -0
- package/eldlock-cli/package.json +71 -0
- package/eldlock-cli/src/api.ts +250 -0
- package/eldlock-cli/src/cli.ts +490 -0
- package/eldlock-cli/src/main.ts +10 -0
- package/eldlock-cli/src/tui.ts +676 -0
- package/eldlock-cli/tsconfig.json +13 -0
- package/eldlock-cli/vendor/npm/ansi-regex-6.2.2.tgz +0 -0
- package/eldlock-cli/vendor/npm/bun-ffi-structs-0.2.2.tgz +0 -0
- package/eldlock-cli/vendor/npm/diff-9.0.0.tgz +0 -0
- package/eldlock-cli/vendor/npm/emoji-regex-10.6.0.tgz +0 -0
- package/eldlock-cli/vendor/npm/esbuild-0.28.0.tgz +0 -0
- package/eldlock-cli/vendor/npm/esbuild-darwin-arm64-0.28.0.tgz +0 -0
- package/eldlock-cli/vendor/npm/esbuild-darwin-x64-0.28.0.tgz +0 -0
- package/eldlock-cli/vendor/npm/esbuild-linux-arm64-0.28.0.tgz +0 -0
- package/eldlock-cli/vendor/npm/esbuild-linux-x64-0.28.0.tgz +0 -0
- package/eldlock-cli/vendor/npm/fsevents-2.3.3.tgz +0 -0
- package/eldlock-cli/vendor/npm/get-east-asian-width-1.6.0.tgz +0 -0
- package/eldlock-cli/vendor/npm/marked-17.0.1.tgz +0 -0
- package/eldlock-cli/vendor/npm/opentui-core-0.3.1.tgz +0 -0
- package/eldlock-cli/vendor/npm/opentui-core-darwin-arm64-0.3.1.tgz +0 -0
- package/eldlock-cli/vendor/npm/opentui-core-darwin-x64-0.3.1.tgz +0 -0
- package/eldlock-cli/vendor/npm/opentui-core-linux-arm64-0.3.1.tgz +0 -0
- package/eldlock-cli/vendor/npm/opentui-core-linux-x64-0.3.1.tgz +0 -0
- package/eldlock-cli/vendor/npm/string-width-7.2.0.tgz +0 -0
- package/eldlock-cli/vendor/npm/strip-ansi-7.1.2.tgz +0 -0
- package/eldlock-cli/vendor/npm/tsx-4.22.4.tgz +0 -0
- package/eldlock-cli/vendor/npm/types-node-22.19.19.tgz +0 -0
- package/eldlock-cli/vendor/npm/typescript-5.9.3.tgz +0 -0
- package/eldlock-cli/vendor/npm/undici-types-6.21.0.tgz +0 -0
- package/eldlock-cli/vendor/npm/web-tree-sitter-0.25.10.tgz +0 -0
- package/eldlock-cli/vendor/npm/yoga-layout-3.2.1.tgz +0 -0
- package/eldlock-server/cmd/eldlock-server/main.go +132 -0
- package/eldlock-server/go.mod +10 -0
- package/eldlock-server/go.sum +11 -0
- package/eldlock-server/internal/api/README.md +14 -0
- package/eldlock-server/internal/api/core.go +126 -0
- package/eldlock-server/internal/api/exec.go +97 -0
- package/eldlock-server/internal/api/secrets.go +358 -0
- package/eldlock-server/internal/api/server.go +72 -0
- package/eldlock-server/internal/api/service_test.go +416 -0
- package/eldlock-server/internal/api/types.go +48 -0
- package/eldlock-server/internal/api/vault.go +69 -0
- package/eldlock-server/internal/api/vendor.go +44 -0
- package/eldlock-server/internal/libfido2/LICENSE +21 -0
- package/eldlock-server/internal/libfido2/README.md +127 -0
- package/eldlock-server/internal/libfido2/examples_test.go +614 -0
- package/eldlock-server/internal/libfido2/fido2.go +1234 -0
- package/eldlock-server/internal/libfido2/fido2_darwin.go +7 -0
- package/eldlock-server/internal/libfido2/fido2_other.go +9 -0
- package/eldlock-server/internal/libfido2/fido2_test.go +101 -0
- package/eldlock-server/internal/libfido2/go.mod +10 -0
- package/eldlock-server/internal/libfido2/go.sum +16 -0
- package/eldlock-server/internal/libfido2/log.go +87 -0
- package/eldlock-server/internal/store/README.md +7 -0
- package/eldlock-server/internal/store/store.go +434 -0
- package/eldlock-server/internal/store/store_test.go +125 -0
- package/eldlock-server/internal/yubikey/README.md +25 -0
- package/eldlock-server/internal/yubikey/default_fido2.go +7 -0
- package/eldlock-server/internal/yubikey/default_stub.go +7 -0
- package/eldlock-server/internal/yubikey/fido2_disabled.go +9 -0
- package/eldlock-server/internal/yubikey/fido2_libfido2.go +225 -0
- package/eldlock-server/internal/yubikey/fido2_libfido2_test.go +66 -0
- package/eldlock-server/internal/yubikey/passkey.go +139 -0
- package/eldlock-server/internal/yubikey/passkey_test.go +36 -0
- package/eldlock-server/vendor/github.com/keys-pub/go-libfido2/LICENSE +21 -0
- package/eldlock-server/vendor/github.com/keys-pub/go-libfido2/README.md +127 -0
- package/eldlock-server/vendor/github.com/keys-pub/go-libfido2/fido2.go +1234 -0
- package/eldlock-server/vendor/github.com/keys-pub/go-libfido2/fido2_darwin.go +7 -0
- package/eldlock-server/vendor/github.com/keys-pub/go-libfido2/fido2_other.go +9 -0
- package/eldlock-server/vendor/github.com/keys-pub/go-libfido2/log.go +87 -0
- package/eldlock-server/vendor/github.com/pkg/errors/.travis.yml +10 -0
- package/eldlock-server/vendor/github.com/pkg/errors/LICENSE +23 -0
- package/eldlock-server/vendor/github.com/pkg/errors/Makefile +44 -0
- package/eldlock-server/vendor/github.com/pkg/errors/README.md +59 -0
- package/eldlock-server/vendor/github.com/pkg/errors/appveyor.yml +32 -0
- package/eldlock-server/vendor/github.com/pkg/errors/errors.go +288 -0
- package/eldlock-server/vendor/github.com/pkg/errors/go113.go +38 -0
- package/eldlock-server/vendor/github.com/pkg/errors/stack.go +177 -0
- package/eldlock-server/vendor/modules.txt +7 -0
- package/examples/eldlock.toml +17 -0
- package/install.sh +66 -0
- package/package.json +66 -0
- package/scripts/build-production.mjs +177 -0
- package/scripts/postinstall-production.mjs +23 -0
|
@@ -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,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
|
+
}
|