8af44dfcd2
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>
104 lines
2.6 KiB
Go
104 lines
2.6 KiB
Go
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")
|
|
}
|
|
}
|