Repository navigation
fix(cli): take turns with the security key across commands #190
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| // Package filelock takes exclusive locks that several rack-gateway commands share: a lock file that one process | ||
| // at a time holds. The operating system releases a lock when its process exits, so a command that crashes or is | ||
| // killed never leaves one behind. | ||
| package filelock | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| ) | ||
|
|
||
| // Lock takes the exclusive lock on the file at path, creating it (and its directory, private to the user) when it | ||
| // doesn't exist. When another process holds the lock, it calls waiting, then waits until it can take it. The | ||
| // returned func releases the lock. | ||
| func Lock(path string, waiting func()) (func(), error) { | ||
| if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | ||
| return nil, err | ||
| } | ||
| file, err := os.OpenFile(filepath.Clean(path), os.O_CREATE|os.O_RDWR, 0o600) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| locked, err := tryLockFile(file) | ||
| if err == nil && !locked { | ||
| if waiting != nil { | ||
| waiting() | ||
| } | ||
| err = lockFile(file) | ||
| } | ||
| if err != nil { | ||
| _ = file.Close() | ||
| return nil, err | ||
| } | ||
| return func() { | ||
| _ = unlockFile(file) | ||
| _ = file.Close() | ||
| }, nil | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| package filelock | ||
|
|
||
| import ( | ||
| "path/filepath" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // A second Lock on the same file waits, saying so, until the first is released. (Each Lock opens the file again, | ||
| // so two in one process conflict as two processes do.) | ||
| func TestLockWaitsForTheHolder(t *testing.T) { | ||
| path := filepath.Join(t.TempDir(), "locks", "key.lock") | ||
| unlockFirst, err := Lock(path, func() { t.Error("nothing holds the lock yet") }) | ||
| require.NoError(t, err) | ||
|
|
||
| type result struct { | ||
| unlock func() | ||
| err error | ||
| } | ||
| waiting := make(chan struct{}) | ||
| acquired := make(chan result, 1) | ||
| go func() { | ||
| unlock, err := Lock(path, func() { close(waiting) }) | ||
| acquired <- result{unlock, err} | ||
| }() | ||
|
|
||
| select { | ||
| case second := <-acquired: | ||
| require.NoError(t, second.err) | ||
| t.Fatal("the second Lock took the lock while the first held it") | ||
| case <-waiting: | ||
| } | ||
| select { | ||
| case <-acquired: | ||
| t.Fatal("the second Lock took the lock while the first held it") | ||
| case <-time.After(200 * time.Millisecond): | ||
| } | ||
| unlockFirst() | ||
| second := <-acquired | ||
| require.NoError(t, second.err) | ||
| second.unlock() | ||
|
|
||
| unlockThird, err := Lock(path, func() { t.Error("the lock was released") }) | ||
| require.NoError(t, err) | ||
| unlockThird() | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| //go:build !windows | ||
|
|
||
| package filelock | ||
|
|
||
| import ( | ||
| "errors" | ||
| "os" | ||
|
|
||
| "golang.org/x/sys/unix" | ||
| ) | ||
|
|
||
| func lockFile(file *os.File) error { | ||
| return unix.Flock(int(file.Fd()), unix.LOCK_EX) | ||
| } | ||
|
|
||
| // tryLockFile takes the lock if no other process holds it, and reports whether it did. | ||
| func tryLockFile(file *os.File) (bool, error) { | ||
| err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB) | ||
| if errors.Is(err, unix.EWOULDBLOCK) { | ||
| return false, nil | ||
| } | ||
| return err == nil, err | ||
| } | ||
|
|
||
| func unlockFile(file *os.File) error { | ||
| return unix.Flock(int(file.Fd()), unix.LOCK_UN) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| //go:build windows | ||
|
|
||
| package filelock | ||
|
|
||
| import ( | ||
| "errors" | ||
| "math" | ||
| "os" | ||
|
|
||
| "golang.org/x/sys/windows" | ||
| ) | ||
|
|
||
| func lockFile(file *os.File) error { | ||
| return lockFileEx(file, windows.LOCKFILE_EXCLUSIVE_LOCK) | ||
| } | ||
|
|
||
| // tryLockFile takes the lock if no other process holds it, and reports whether it did. | ||
| func tryLockFile(file *os.File) (bool, error) { | ||
| err := lockFileEx(file, windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY) | ||
| if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { | ||
| return false, nil | ||
| } | ||
| return err == nil, err | ||
| } | ||
|
|
||
| func lockFileEx(file *os.File, flags uint32) error { | ||
| return windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, math.MaxUint32, math.MaxUint32, | ||
| &windows.Overlapped{}) | ||
| } | ||
|
|
||
| func unlockFile(file *os.File) error { | ||
| return windows.UnlockFileEx(windows.Handle(file.Fd()), 0, math.MaxUint32, math.MaxUint32, &windows.Overlapped{}) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| //go:build !nofido | ||
|
|
||
| package webauthn | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "os" | ||
| "path/filepath" | ||
|
|
||
| "github.com/DocSpring/rack-gateway/internal/cli/filelock" | ||
| ) | ||
|
|
||
| // keyLockPath is the lock every rack-gateway command holds while it uses a security key. It's in the user's cache | ||
| // directory, which every command shares. A temporary directory isn't: each agent session can have a TMPDIR of its | ||
| // own, so commands there would use the key at once. | ||
| var keyLockPath = func() (string, error) { | ||
| dir, err := os.UserCacheDir() | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| return filepath.Join(dir, "rack-gateway", "security-key.lock"), nil | ||
| } | ||
|
|
||
| // withKeyLock runs use while holding the security key lock, waiting for any other rack-gateway command that holds | ||
| // it. A key answers one command at a time: two commands asking for a PIN and a touch at once (two agents running | ||
| // commands, say) both fail, with libfido2's "rx error" or "unsupported option", and the person can't tell which | ||
| // touch is for which. A command holds the lock from its PIN dialog to its touch, so the next one asks only then. | ||
| func withKeyLock[T any](use func() (T, error)) (T, error) { | ||
| var zero T | ||
| path, err := keyLockPath() | ||
| if err != nil { | ||
| return zero, fmt.Errorf("failed to find where to lock the security key: %w", err) | ||
| } | ||
| unlock, err := filelock.Lock(path, func() { | ||
| fmt.Fprintln(os.Stderr, "Waiting for another rack-gateway command to finish with the security key...") | ||
| }) | ||
| if err != nil { | ||
| return zero, fmt.Errorf("failed to lock the security key for this command: %w", err) | ||
| } | ||
| defer unlock() | ||
| return use() | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| //go:build !nofido | ||
|
|
||
| package webauthn | ||
|
|
||
| import ( | ||
| "errors" | ||
| "path/filepath" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/keys-pub/go-libfido2" | ||
| "github.com/stretchr/testify/require" | ||
|
|
||
| "github.com/DocSpring/rack-gateway/internal/cli/filelock" | ||
| ) | ||
|
|
||
| // Each way a command uses the security key holds the lock, so a command waits for another that's using the key | ||
| // rather than both failing. (No key is opened: the test replaces opening one.) | ||
| func TestSecurityKeyUsersWaitForTheLock(t *testing.T) { | ||
| path := filepath.Join(t.TempDir(), "security-key.lock") | ||
| lockPath, open := keyLockPath, openDevices | ||
| t.Cleanup(func() { keyLockPath, openDevices = lockPath, open }) | ||
| keyLockPath = func() (string, error) { return path, nil } | ||
| errNoKey := errors.New("no security key in tests") | ||
| openDevices = func() ([]*libfido2.Device, error) { return nil, errNoKey } | ||
|
|
||
| for name, use := range map[string]func() error{ | ||
| "assertion": func() error { | ||
| _, _, err := GetAssertionWithCachedPIN(AssertionOptions{}, "1234") | ||
| return err | ||
| }, | ||
| "PIN check": func() error { return CheckPIN("1234") }, | ||
| "PIN query": func() error { | ||
| if SingleKeyHasPIN() { | ||
| return errors.New("SingleKeyHasPIN found a PIN without a key") | ||
| } | ||
| return errNoKey | ||
| }, | ||
|
ndbroadbent marked this conversation as resolved.
|
||
| } { | ||
| unlock, err := filelock.Lock(path, nil) | ||
| require.NoError(t, err) | ||
| done := make(chan error, 1) | ||
| go func() { done <- use() }() | ||
| select { | ||
| case <-done: | ||
| t.Fatalf("the %s used the key while another command held the lock", name) | ||
| case <-time.After(200 * time.Millisecond): | ||
| } | ||
| unlock() | ||
| require.ErrorIs(t, <-done, errNoKey, name) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.