@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,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,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.
|