@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,125 @@
1
+ package store
2
+
3
+ import (
4
+ "errors"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+ "testing"
9
+ )
10
+
11
+ func TestFileStorePutGetList(t *testing.T) {
12
+ path := filepath.Join(t.TempDir(), "vault.json")
13
+ store := NewFileStore(path, staticKeyProvider{})
14
+
15
+ if initialized, err := store.IsInitialized(); err != nil {
16
+ t.Fatalf("IsInitialized() error = %v", err)
17
+ } else if initialized {
18
+ t.Fatalf("IsInitialized() = true before Init")
19
+ }
20
+
21
+ if err := store.Init(); err != nil {
22
+ t.Fatalf("Init() error = %v", err)
23
+ }
24
+ raw, err := os.ReadFile(path)
25
+ if err != nil {
26
+ t.Fatalf("ReadFile() error = %v", err)
27
+ }
28
+ if strings.Contains(string(raw), "secret-value") {
29
+ t.Fatalf("encrypted vault contains plaintext secret")
30
+ }
31
+ if initialized, err := store.IsInitialized(); err != nil {
32
+ t.Fatalf("IsInitialized() error = %v", err)
33
+ } else if !initialized {
34
+ t.Fatalf("IsInitialized() = false after Init")
35
+ }
36
+
37
+ if err := store.Put(Secret{Name: "app/API_KEY", Type: SecretTypeEnv, Value: "secret-value"}); err != nil {
38
+ t.Fatalf("Put() error = %v", err)
39
+ }
40
+ if err := store.Put(Secret{Name: "ssh/personal", Type: SecretTypeSSH, Value: "private-key"}); err != nil {
41
+ t.Fatalf("Put() error = %v", err)
42
+ }
43
+
44
+ got, err := store.Get("app/API_KEY")
45
+ if err != nil {
46
+ t.Fatalf("Get() error = %v", err)
47
+ }
48
+ if got.Value != "secret-value" {
49
+ t.Fatalf("Get().Value = %q, want secret value", got.Value)
50
+ }
51
+
52
+ summaries, err := store.List()
53
+ if err != nil {
54
+ t.Fatalf("List() error = %v", err)
55
+ }
56
+ if len(summaries) != 2 {
57
+ t.Fatalf("List() length = %d, want 2", len(summaries))
58
+ }
59
+ if summaries[0].Name != "app/API_KEY" || summaries[1].Name != "ssh/personal" {
60
+ t.Fatalf("List() names = %#v, want sorted names", summaries)
61
+ }
62
+
63
+ if err := store.Delete("app/API_KEY"); err != nil {
64
+ t.Fatalf("Delete() error = %v", err)
65
+ }
66
+ if _, err := store.Get("app/API_KEY"); !errors.Is(err, ErrNotFound) {
67
+ t.Fatalf("Get() after Delete() error = %v, want ErrNotFound", err)
68
+ }
69
+ }
70
+
71
+ func TestFileStoreGetMissing(t *testing.T) {
72
+ store := NewFileStore(filepath.Join(t.TempDir(), "vault.json"), staticKeyProvider{})
73
+ if err := store.Init(); err != nil {
74
+ t.Fatalf("Init() error = %v", err)
75
+ }
76
+
77
+ _, err := store.Get("missing")
78
+ if !errors.Is(err, ErrNotFound) {
79
+ t.Fatalf("Get() error = %v, want ErrNotFound", err)
80
+ }
81
+ }
82
+
83
+ func TestFileStoreDeleteMissing(t *testing.T) {
84
+ store := NewFileStore(filepath.Join(t.TempDir(), "vault.json"), staticKeyProvider{})
85
+ if err := store.Init(); err != nil {
86
+ t.Fatalf("Init() error = %v", err)
87
+ }
88
+
89
+ if err := store.Delete("missing"); !errors.Is(err, ErrNotFound) {
90
+ t.Fatalf("Delete() error = %v, want ErrNotFound", err)
91
+ }
92
+ }
93
+
94
+ func TestFileStoreRejectsWrongPasskey(t *testing.T) {
95
+ path := filepath.Join(t.TempDir(), "vault.json")
96
+ store := NewFileStore(path, staticKeyProvider{secret: "correct passkey material that is long enough"})
97
+ if err := store.Init(); err != nil {
98
+ t.Fatalf("Init() error = %v", err)
99
+ }
100
+ if err := store.Put(Secret{Name: "app/API_KEY", Type: SecretTypeEnv, Value: "secret-value"}); err != nil {
101
+ t.Fatalf("Put() error = %v", err)
102
+ }
103
+
104
+ wrong := NewFileStore(path, staticKeyProvider{secret: "wrong passkey material that is also long enough"})
105
+ if _, err := wrong.Get("app/API_KEY"); err == nil {
106
+ t.Fatalf("Get() with wrong passkey succeeded")
107
+ }
108
+ }
109
+
110
+ type staticKeyProvider struct {
111
+ secret string
112
+ }
113
+
114
+ func (p staticKeyProvider) CreateKeyMaterial(operation string, salt []byte) ([]byte, map[string]string, error) {
115
+ material, err := p.OpenKeyMaterial(operation, salt, nil)
116
+ return material, map[string]string{"provider": "static"}, err
117
+ }
118
+
119
+ func (p staticKeyProvider) OpenKeyMaterial(_ string, _ []byte, _ map[string]string) ([]byte, error) {
120
+ secret := p.secret
121
+ if secret == "" {
122
+ secret = "test passkey material that is deliberately longer than thirty two bytes"
123
+ }
124
+ return []byte(secret), nil
125
+ }
@@ -0,0 +1,25 @@
1
+ # YubiKey
2
+
3
+ YubiKey-backed unlock or wrapping provider.
4
+
5
+ The YubiKey should participate in unwrapping key material. It should not be only a cosmetic approval check.
6
+
7
+ Current implementation note: automated tests use the stub provider (`ELDLOCK_STUB_PASSKEY`). Manual YubiKey Bio tests can use the build-tagged FIDO2 hmac-secret provider:
8
+
9
+ ```bash
10
+ brew install libfido2
11
+ export GOFLAGS="-tags=fido2"
12
+ export ELDLOCK_FIDO2_UV=true
13
+ ```
14
+
15
+ Optional settings:
16
+
17
+ - `ELDLOCK_FIDO2_DEVICE` to pin a specific authenticator path.
18
+ - `ELDLOCK_FIDO2_RP_ID` to override the relying party ID, default `eldlock.local`.
19
+ - `ELDLOCK_FIDO2_UV=true` to require biometric/PIN user verification, default true.
20
+ - `ELDLOCK_FIDO2_UP=true` to require touch/user presence, default true.
21
+ - `ELDLOCK_FIDO2_PIN` if your authenticator requires PIN auth.
22
+ - `ELDLOCK_FIDO2_RK=true` to request a resident credential, default false.
23
+ - `ELDLOCK_FIDO2_CRED_PROTECT=true` to request UV-required credential protection, default false.
24
+
25
+ The fallback command provider remains available with `ELDLOCK_PASSKEY_COMMAND`; it should print key material as `hex:<value>`, `base64:<value>`, or `raw:<value>`.
@@ -0,0 +1,7 @@
1
+ //go:build fido2
2
+
3
+ package yubikey
4
+
5
+ func defaultPasskeyProvider() string {
6
+ return "fido"
7
+ }
@@ -0,0 +1,7 @@
1
+ //go:build !fido2
2
+
3
+ package yubikey
4
+
5
+ func defaultPasskeyProvider() string {
6
+ return "stub"
7
+ }
@@ -0,0 +1,9 @@
1
+ //go:build !fido2
2
+
3
+ package yubikey
4
+
5
+ import "errors"
6
+
7
+ func newFIDO2ProviderFromEnv() (Provider, error) {
8
+ return nil, errors.New("FIDO2 provider requires building with GOFLAGS=-tags=fido2 and local libfido2 development libraries")
9
+ }
@@ -0,0 +1,225 @@
1
+ //go:build fido2
2
+
3
+ package yubikey
4
+
5
+ import (
6
+ "crypto/rand"
7
+ "crypto/sha256"
8
+ "encoding/base64"
9
+ "encoding/hex"
10
+ "errors"
11
+ "fmt"
12
+ "os"
13
+
14
+ libfido2 "github.com/keys-pub/go-libfido2"
15
+ )
16
+
17
+ const (
18
+ libfido2ErrUVBlocked = 0x3c
19
+ libfido2ErrUVInvalid = 0x3f
20
+ )
21
+
22
+ type FIDO2Provider struct {
23
+ RPID string
24
+ UserName string
25
+ DevicePath string
26
+ PIN string
27
+ RequireUV bool
28
+ RequireUP bool
29
+ }
30
+
31
+ func (p FIDO2Provider) Name() string {
32
+ return "fido2-hmac-secret"
33
+ }
34
+
35
+ func (p FIDO2Provider) WithPIN(pin string) Provider {
36
+ p.PIN = pin
37
+ return p
38
+ }
39
+
40
+ func newFIDO2ProviderFromEnv() (Provider, error) {
41
+ return FIDO2Provider{
42
+ RPID: envDefault("ELDLOCK_FIDO2_RP_ID", "eldlock.local"),
43
+ UserName: envDefault("ELDLOCK_FIDO2_USER", "eldlock"),
44
+ DevicePath: os.Getenv("ELDLOCK_FIDO2_DEVICE"),
45
+ PIN: os.Getenv("ELDLOCK_FIDO2_PIN"),
46
+ RequireUV: envBool("ELDLOCK_FIDO2_UV", true),
47
+ RequireUP: envBool("ELDLOCK_FIDO2_UP", true),
48
+ }, nil
49
+ }
50
+
51
+ func (p FIDO2Provider) CreateKeyMaterial(operation string, salt []byte) ([]byte, map[string]string, error) {
52
+ device, path, err := p.openDevice()
53
+ if err != nil {
54
+ return nil, nil, err
55
+ }
56
+
57
+ clientDataHash := clientHash(operation, salt)
58
+ userID, err := randomBytes(32)
59
+ if err != nil {
60
+ return nil, nil, fmt.Errorf("generate FIDO2 user ID: %w", err)
61
+ }
62
+
63
+ opts := &libfido2.MakeCredentialOpts{
64
+ Extensions: []libfido2.Extension{libfido2.HMACSecretExtension},
65
+ }
66
+ if envBool("ELDLOCK_FIDO2_RK", false) {
67
+ opts.RK = libfido2.True
68
+ }
69
+ if p.RequireUV {
70
+ opts.UV = libfido2.True
71
+ }
72
+ if envBool("ELDLOCK_FIDO2_CRED_PROTECT", false) {
73
+ opts.CredProtect = libfido2.CredProtectUVRequired
74
+ }
75
+
76
+ attest, err := device.MakeCredential(
77
+ clientDataHash,
78
+ libfido2.RelyingParty{ID: p.RPID, Name: "Eldlock"},
79
+ libfido2.User{ID: userID, Name: p.UserName},
80
+ libfido2.ES256,
81
+ p.PIN,
82
+ opts,
83
+ )
84
+ if err != nil {
85
+ return nil, nil, fmt.Errorf("create FIDO2 hmac-secret credential: %w", mapFIDO2PINError(err))
86
+ }
87
+
88
+ metadata := map[string]string{
89
+ "provider": "fido2-hmac-secret",
90
+ "rp_id": p.RPID,
91
+ "credential_id": base64.RawStdEncoding.EncodeToString(attest.CredentialID),
92
+ "user_id": base64.RawStdEncoding.EncodeToString(userID),
93
+ "device_path": path,
94
+ "uv": boolString(p.RequireUV),
95
+ "up": boolString(p.RequireUP),
96
+ }
97
+
98
+ material, err := p.OpenKeyMaterial(operation, salt, metadata)
99
+ if err != nil {
100
+ return nil, nil, fmt.Errorf("verify new FIDO2 hmac-secret credential: %w", err)
101
+ }
102
+ return material, metadata, nil
103
+ }
104
+
105
+ func (p FIDO2Provider) OpenKeyMaterial(operation string, salt []byte, metadata map[string]string) ([]byte, error) {
106
+ if metadata["provider"] != "fido2-hmac-secret" {
107
+ return nil, fmt.Errorf("vault was created with passkey provider %q, not fido2-hmac-secret", metadata["provider"])
108
+ }
109
+ credentialID, err := base64.RawStdEncoding.DecodeString(metadata["credential_id"])
110
+ if err != nil {
111
+ return nil, fmt.Errorf("decode FIDO2 credential ID: %w", err)
112
+ }
113
+
114
+ rpID := metadata["rp_id"]
115
+ if rpID == "" {
116
+ rpID = p.RPID
117
+ }
118
+ device, _, err := p.openDevice()
119
+ if err != nil {
120
+ return nil, err
121
+ }
122
+
123
+ opts := &libfido2.AssertionOpts{
124
+ Extensions: []libfido2.Extension{libfido2.HMACSecretExtension},
125
+ HMACSalt: salt,
126
+ }
127
+ if metadata["uv"] == "true" || p.RequireUV {
128
+ opts.UV = libfido2.True
129
+ }
130
+ if metadata["up"] == "true" || p.RequireUP {
131
+ opts.UP = libfido2.True
132
+ }
133
+
134
+ assertion, err := device.Assertion(
135
+ rpID,
136
+ clientHash(operation, salt),
137
+ [][]byte{credentialID},
138
+ p.PIN,
139
+ opts,
140
+ )
141
+ if err != nil {
142
+ return nil, fmt.Errorf("assert FIDO2 hmac-secret credential: %w", mapFIDO2PINError(err))
143
+ }
144
+ if len(assertion.HMACSecret) < 32 {
145
+ return nil, errors.New("FIDO2 hmac-secret returned insufficient key material")
146
+ }
147
+ return assertion.HMACSecret, nil
148
+ }
149
+
150
+ func mapFIDO2PINError(err error) error {
151
+ switch {
152
+ case errors.Is(err, libfido2.ErrPinRequired), errors.Is(err, libfido2.ErrPinNotSet):
153
+ return ErrPINRequired
154
+ case errors.Is(err, libfido2.ErrPinInvalid):
155
+ return ErrPINInvalid
156
+ case errors.Is(err, libfido2.ErrPinAuthBlocked):
157
+ return ErrPINAuthBlocked
158
+ case isLibfido2ErrorCode(err, libfido2ErrUVBlocked), isLibfido2ErrorCode(err, libfido2ErrUVInvalid):
159
+ return ErrPINRequired
160
+ default:
161
+ return err
162
+ }
163
+ }
164
+
165
+ func isLibfido2ErrorCode(err error, code int) bool {
166
+ var fidoErr libfido2.Error
167
+ return errors.As(err, &fidoErr) && fidoErr.Code == code
168
+ }
169
+
170
+ func (p FIDO2Provider) openDevice() (*libfido2.Device, string, error) {
171
+ path := p.DevicePath
172
+ if path == "" {
173
+ locs, err := libfido2.DeviceLocations()
174
+ if err != nil {
175
+ return nil, "", fmt.Errorf("list FIDO2 devices: %w", err)
176
+ }
177
+ if len(locs) == 0 {
178
+ return nil, "", errors.New("no FIDO2 devices found")
179
+ }
180
+ path = locs[0].Path
181
+ }
182
+ device, err := libfido2.NewDevice(path)
183
+ if err != nil {
184
+ return nil, "", fmt.Errorf("open FIDO2 device %s: %w", path, err)
185
+ }
186
+ return device, path, nil
187
+ }
188
+
189
+ func clientHash(operation string, salt []byte) []byte {
190
+ sum := sha256.Sum256([]byte("eldlock:vault:" + operation + ":" + hex.EncodeToString(salt)))
191
+ return sum[:]
192
+ }
193
+
194
+ func randomBytes(size int) ([]byte, error) {
195
+ buf := make([]byte, size)
196
+ if _, err := rand.Read(buf); err != nil {
197
+ return nil, err
198
+ }
199
+ return buf, nil
200
+ }
201
+
202
+ func envDefault(key, fallback string) string {
203
+ if value := os.Getenv(key); value != "" {
204
+ return value
205
+ }
206
+ return fallback
207
+ }
208
+
209
+ func envBool(key string, fallback bool) bool {
210
+ switch os.Getenv(key) {
211
+ case "1", "true", "yes", "on":
212
+ return true
213
+ case "0", "false", "no", "off":
214
+ return false
215
+ default:
216
+ return fallback
217
+ }
218
+ }
219
+
220
+ func boolString(value bool) string {
221
+ if value {
222
+ return "true"
223
+ }
224
+ return "false"
225
+ }
@@ -0,0 +1,66 @@
1
+ //go:build fido2
2
+
3
+ package yubikey
4
+
5
+ import (
6
+ "errors"
7
+ "fmt"
8
+ "testing"
9
+
10
+ libfido2 "github.com/keys-pub/go-libfido2"
11
+ )
12
+
13
+ func TestMapFIDO2PINErrorMapsPINAndBioFallbackErrors(t *testing.T) {
14
+ tests := []struct {
15
+ name string
16
+ err error
17
+ want error
18
+ }{
19
+ {
20
+ name: "pin required",
21
+ err: libfido2.ErrPinRequired,
22
+ want: ErrPINRequired,
23
+ },
24
+ {
25
+ name: "pin not set",
26
+ err: libfido2.ErrPinNotSet,
27
+ want: ErrPINRequired,
28
+ },
29
+ {
30
+ name: "uv blocked",
31
+ err: fmt.Errorf("wrapped: %w", libfido2.Error{Code: libfido2ErrUVBlocked}),
32
+ want: ErrPINRequired,
33
+ },
34
+ {
35
+ name: "uv invalid",
36
+ err: fmt.Errorf("wrapped: %w", libfido2.Error{Code: libfido2ErrUVInvalid}),
37
+ want: ErrPINRequired,
38
+ },
39
+ {
40
+ name: "pin invalid",
41
+ err: libfido2.ErrPinInvalid,
42
+ want: ErrPINInvalid,
43
+ },
44
+ {
45
+ name: "pin auth blocked",
46
+ err: libfido2.ErrPinAuthBlocked,
47
+ want: ErrPINAuthBlocked,
48
+ },
49
+ }
50
+
51
+ for _, tt := range tests {
52
+ t.Run(tt.name, func(t *testing.T) {
53
+ got := mapFIDO2PINError(tt.err)
54
+ if !errors.Is(got, tt.want) {
55
+ t.Fatalf("mapFIDO2PINError() = %v, want %v", got, tt.want)
56
+ }
57
+ })
58
+ }
59
+ }
60
+
61
+ func TestMapFIDO2PINErrorLeavesUnrelatedErrorsAlone(t *testing.T) {
62
+ err := libfido2.ErrNotAllowed
63
+ if got := mapFIDO2PINError(err); !errors.Is(got, err) {
64
+ t.Fatalf("mapFIDO2PINError() = %v, want %v", got, err)
65
+ }
66
+ }
@@ -0,0 +1,139 @@
1
+ package yubikey
2
+
3
+ import (
4
+ "crypto/sha256"
5
+ "encoding/base64"
6
+ "encoding/hex"
7
+ "errors"
8
+ "fmt"
9
+ "os"
10
+ "os/exec"
11
+ "strings"
12
+ )
13
+
14
+ var (
15
+ ErrPINRequired = errors.New("pin required")
16
+ ErrPINInvalid = errors.New("pin invalid")
17
+ ErrPINAuthBlocked = errors.New("pin auth blocked")
18
+ )
19
+
20
+ type Provider interface {
21
+ CreateKeyMaterial(operation string, salt []byte) ([]byte, map[string]string, error)
22
+ OpenKeyMaterial(operation string, salt []byte, metadata map[string]string) ([]byte, error)
23
+ Name() string
24
+ }
25
+
26
+ type PINProvider interface {
27
+ WithPIN(pin string) Provider
28
+ }
29
+
30
+ type StubProvider struct {
31
+ Secret string
32
+ }
33
+
34
+ func (p StubProvider) Name() string {
35
+ return "stub"
36
+ }
37
+
38
+ func (p StubProvider) CreateKeyMaterial(operation string, salt []byte) ([]byte, map[string]string, error) {
39
+ material, err := p.OpenKeyMaterial(operation, salt, nil)
40
+ if err != nil {
41
+ return nil, nil, err
42
+ }
43
+ return material, map[string]string{"provider": "stub"}, nil
44
+ }
45
+
46
+ func (p StubProvider) OpenKeyMaterial(_ string, _ []byte, _ map[string]string) ([]byte, error) {
47
+ secret := p.Secret
48
+ if secret == "" {
49
+ secret = os.Getenv("ELDLOCK_STUB_PASSKEY")
50
+ }
51
+ if secret == "" {
52
+ return nil, errors.New("ELDLOCK_STUB_PASSKEY is required for the stub passkey provider")
53
+ }
54
+ sum := sha256.Sum256([]byte(secret))
55
+ return sum[:], nil
56
+ }
57
+
58
+ type CommandProvider struct {
59
+ Command string
60
+ }
61
+
62
+ func (p CommandProvider) Name() string {
63
+ return "command"
64
+ }
65
+
66
+ func (p CommandProvider) CreateKeyMaterial(operation string, salt []byte) ([]byte, map[string]string, error) {
67
+ material, err := p.OpenKeyMaterial(operation, salt, nil)
68
+ if err != nil {
69
+ return nil, nil, err
70
+ }
71
+ return material, map[string]string{"provider": "command"}, nil
72
+ }
73
+
74
+ func (p CommandProvider) OpenKeyMaterial(operation string, salt []byte, metadata map[string]string) ([]byte, error) {
75
+ if strings.TrimSpace(p.Command) == "" {
76
+ return nil, errors.New("passkey command is empty")
77
+ }
78
+ cmd := exec.Command("/bin/sh", "-c", p.Command)
79
+ cmd.Env = append(os.Environ(),
80
+ "ELDLOCK_PASSKEY_OPERATION="+operation,
81
+ "ELDLOCK_PASSKEY_SALT_HEX="+hex.EncodeToString(salt),
82
+ "ELDLOCK_PASSKEY_METADATA="+encodeMetadata(metadata),
83
+ )
84
+ out, err := cmd.Output()
85
+ if err != nil {
86
+ return nil, fmt.Errorf("run passkey command: %w", err)
87
+ }
88
+
89
+ return parseCommandOutput(strings.TrimSpace(string(out)))
90
+ }
91
+
92
+ func NewProviderFromEnv() (Provider, error) {
93
+ if command := os.Getenv("ELDLOCK_PASSKEY_COMMAND"); command != "" {
94
+ return CommandProvider{Command: command}, nil
95
+ }
96
+
97
+ provider := os.Getenv("ELDLOCK_PASSKEY_PROVIDER")
98
+ if provider == "" {
99
+ provider = defaultPasskeyProvider()
100
+ }
101
+
102
+ switch provider {
103
+ case "stub":
104
+ return StubProvider{}, nil
105
+ case "command":
106
+ return nil, errors.New("ELDLOCK_PASSKEY_COMMAND is required when ELDLOCK_PASSKEY_PROVIDER=command")
107
+ case "fido":
108
+ return newFIDO2ProviderFromEnv()
109
+ default:
110
+ return nil, fmt.Errorf("unknown ELDLOCK_PASSKEY_PROVIDER %q", os.Getenv("ELDLOCK_PASSKEY_PROVIDER"))
111
+ }
112
+ }
113
+
114
+ func parseCommandOutput(value string) ([]byte, error) {
115
+ if value == "" {
116
+ return nil, errors.New("passkey command produced no key material")
117
+ }
118
+ if strings.HasPrefix(value, "hex:") {
119
+ return hex.DecodeString(strings.TrimPrefix(value, "hex:"))
120
+ }
121
+ if strings.HasPrefix(value, "base64:") {
122
+ return base64.StdEncoding.DecodeString(strings.TrimPrefix(value, "base64:"))
123
+ }
124
+ if strings.HasPrefix(value, "raw:") {
125
+ return []byte(strings.TrimPrefix(value, "raw:")), nil
126
+ }
127
+ return []byte(value), nil
128
+ }
129
+
130
+ func encodeMetadata(metadata map[string]string) string {
131
+ if len(metadata) == 0 {
132
+ return ""
133
+ }
134
+ var pairs []string
135
+ for key, value := range metadata {
136
+ pairs = append(pairs, key+"="+value)
137
+ }
138
+ return strings.Join(pairs, "\n")
139
+ }
@@ -0,0 +1,36 @@
1
+ package yubikey
2
+
3
+ import "testing"
4
+
5
+ func TestStubProviderReturnsStableHighEntropyMaterial(t *testing.T) {
6
+ provider := StubProvider{Secret: "test stub passkey secret with enough entropy"}
7
+
8
+ first, metadata, err := provider.CreateKeyMaterial("init", []byte("salt"))
9
+ if err != nil {
10
+ t.Fatalf("CreateKeyMaterial() error = %v", err)
11
+ }
12
+ if metadata["provider"] != "stub" {
13
+ t.Fatalf("metadata provider = %q, want stub", metadata["provider"])
14
+ }
15
+ second, err := provider.OpenKeyMaterial("put", []byte("salt"), metadata)
16
+ if err != nil {
17
+ t.Fatalf("OpenKeyMaterial() error = %v", err)
18
+ }
19
+
20
+ if len(first) != 32 {
21
+ t.Fatalf("len(KeyMaterial()) = %d, want 32", len(first))
22
+ }
23
+ if string(first) != string(second) {
24
+ t.Fatalf("stub material changed across operations")
25
+ }
26
+ }
27
+
28
+ func TestParseCommandOutput(t *testing.T) {
29
+ got, err := parseCommandOutput("hex:414243")
30
+ if err != nil {
31
+ t.Fatalf("parseCommandOutput() error = %v", err)
32
+ }
33
+ if string(got) != "ABC" {
34
+ t.Fatalf("parseCommandOutput() = %q, want ABC", got)
35
+ }
36
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2019 Gabriel Handford
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.