Initial commit: dial — interactive SSH host picker
A fast, read-only ~/.ssh/config picker that exec's the system ssh client. Includes: static high-contrast picker with recency/frequency/name sort and tag grouping, `dial keygen` (native ed25519/RSA + optional config entry), and an optional offline YubiKey launch gate with one-time recovery codes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
package yubikey
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.apointless.space/bsncubed/dial/internal/store"
|
||||
)
|
||||
|
||||
// fakeKey simulates a YubiKey slot programmed with a fixed secret by computing
|
||||
// HMAC-SHA1 over the challenge, mirroring ykman's challenge-response output.
|
||||
func fakeKey(t *testing.T, secret []byte) func(args ...string) ([]byte, error) {
|
||||
t.Helper()
|
||||
return func(args ...string) ([]byte, error) {
|
||||
// args: otp calculate <slot> <hexchallenge>
|
||||
if len(args) < 4 {
|
||||
t.Fatalf("unexpected ykman args: %v", args)
|
||||
}
|
||||
chal, err := hex.DecodeString(args[3])
|
||||
if err != nil {
|
||||
t.Fatalf("bad challenge hex: %v", err)
|
||||
}
|
||||
m := hmac.New(sha1.New, secret)
|
||||
m.Write(chal)
|
||||
return []byte(hex.EncodeToString(m.Sum(nil)) + "\n"), nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrollThenVerifySucceeds(t *testing.T) {
|
||||
orig := runYkman
|
||||
defer func() { runYkman = orig }()
|
||||
runYkman = fakeKey(t, []byte("secret-slot-2"))
|
||||
|
||||
cfg, err := Enroll(2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !cfg.IsEnrolled() {
|
||||
t.Fatal("config should be enrolled")
|
||||
}
|
||||
if err := Verify(cfg, 2); err != nil {
|
||||
t.Errorf("verify with same key should succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyWrongKeyFails(t *testing.T) {
|
||||
orig := runYkman
|
||||
defer func() { runYkman = orig }()
|
||||
|
||||
runYkman = fakeKey(t, []byte("correct-secret"))
|
||||
cfg, err := Enroll(2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Now a different physical key (different secret) is present.
|
||||
runYkman = fakeKey(t, []byte("attacker-secret"))
|
||||
if err := Verify(cfg, 2); err != ErrKeyMismatch {
|
||||
t.Errorf("expected ErrKeyMismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidSlot(t *testing.T) {
|
||||
if _, err := Enroll(3); err == nil {
|
||||
t.Error("slot 3 should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryCodeRoundTrip(t *testing.T) {
|
||||
code, h, err := GenerateRecoveryCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Format: 6 groups of 5 chars separated by dashes.
|
||||
parts := strings.Split(code, "-")
|
||||
if len(parts) != recoveryGroups {
|
||||
t.Fatalf("expected %d groups, got %d (%q)", recoveryGroups, len(parts), code)
|
||||
}
|
||||
for _, p := range parts {
|
||||
if len(p) != recoveryGroupLen {
|
||||
t.Errorf("group %q wrong length", p)
|
||||
}
|
||||
}
|
||||
if !VerifyRecoveryCode(code, h) {
|
||||
t.Error("exact code should verify")
|
||||
}
|
||||
// With separators stripped / lowercased it should still verify.
|
||||
if !VerifyRecoveryCode(strings.ToLower(strings.ReplaceAll(code, "-", " ")), h) {
|
||||
t.Error("normalised code should verify")
|
||||
}
|
||||
if VerifyRecoveryCode("WRONG-WRONG-WRONG-WRONG-WRONG-WRONG", h) {
|
||||
t.Error("wrong code should not verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRecoveryUnsetHash(t *testing.T) {
|
||||
if VerifyRecoveryCode("anything", store.RecoveryHash{}) {
|
||||
t.Error("empty hash should never verify")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user