Files
dial/internal/yubikey/yubikey_test.go
T
bsncubed 8af44dfcd2 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>
2026-07-22 23:18:23 +10:00

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")
}
}