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:
2026-07-22 23:18:23 +10:00
commit 8af44dfcd2
31 changed files with 4219 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
package yubikey
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base32"
"encoding/hex"
"strings"
"gitea.apointless.space/bsncubed/dial/internal/store"
)
// Recovery code format: 6 dash-separated groups of 5 base32 characters
// (XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX), i.e. 30 chars ≈ 150 bits of entropy.
// It is shown to the user exactly once at enrollment and stored only as a
// salted hash. It is single-use: after a successful recovery unlock the caller
// invalidates it and issues a fresh one.
const (
recoveryGroups = 6
recoveryGroupLen = 5
recoveryChars = recoveryGroups * recoveryGroupLen // 30
recoveryRawBytes = 19 // 19 bytes -> 31 base32 chars, sliced to 30
)
// base32NoPad uses the standard RFC 4648 alphabet without padding.
var base32NoPad = base32.StdEncoding.WithPadding(base32.NoPadding)
// GenerateRecoveryCode returns a fresh human-formatted recovery code together
// with its salted hash for storage. The plaintext is returned only here and is
// expected to be displayed once and then discarded by the caller.
func GenerateRecoveryCode() (code string, h store.RecoveryHash, err error) {
raw := make([]byte, recoveryRawBytes)
if _, err = rand.Read(raw); err != nil {
return "", store.RecoveryHash{}, err
}
enc := base32NoPad.EncodeToString(raw)[:recoveryChars]
var b strings.Builder
for i := 0; i < recoveryGroups; i++ {
if i > 0 {
b.WriteByte('-')
}
b.WriteString(enc[i*recoveryGroupLen : (i+1)*recoveryGroupLen])
}
code = b.String()
salt := make([]byte, 16)
if _, err = rand.Read(salt); err != nil {
return "", store.RecoveryHash{}, err
}
h = store.RecoveryHash{
Salt: hex.EncodeToString(salt),
Hash: hex.EncodeToString(hashCode(salt, normalizeCode(code))),
}
return code, h, nil
}
// VerifyRecoveryCode reports whether input matches the stored recovery hash.
// Input is normalised (uppercased, separators/whitespace removed) before
// comparison so the user can type it with or without dashes.
func VerifyRecoveryCode(input string, h store.RecoveryHash) bool {
if !h.IsSet() {
return false
}
salt, err := hex.DecodeString(h.Salt)
if err != nil {
return false
}
want, err := hex.DecodeString(h.Hash)
if err != nil {
return false
}
got := hashCode(salt, normalizeCode(input))
return subtle.ConstantTimeCompare(got, want) == 1
}
// normalizeCode strips separators/whitespace and uppercases so that "abc de"
// and "ABCDE" compare equal.
func normalizeCode(s string) string {
var b strings.Builder
for _, r := range strings.ToUpper(s) {
if (r >= 'A' && r <= 'Z') || (r >= '2' && r <= '7') {
b.WriteRune(r)
}
}
return b.String()
}
func hashCode(salt []byte, normalized string) []byte {
h := sha256.New()
h.Write(salt)
h.Write([]byte(normalized))
return h.Sum(nil)
}
+161
View File
@@ -0,0 +1,161 @@
// Package yubikey implements dial's optional launch gate. It performs an
// offline HMAC-SHA1 challenge-response against a YubiKey by shelling out to
// Yubico's official `ykman` CLI (slot configurable, default 2), and manages a
// single-use backup recovery code so the user can't be permanently locked out.
//
// Per the brief, this feature intentionally depends on `ykman` being installed
// separately — it trades the "single static binary" goal for leaning on
// Yubico's own well-tested tool. dial checks for `ykman` on PATH and reports a
// clear error if it is missing.
//
// Nothing secret is persisted: enrollment stores only a random challenge and a
// salted hash of the key's response. The YubiKey secret never leaves the key.
package yubikey
import (
"bufio"
"bytes"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"os/exec"
"strings"
"gitea.apointless.space/bsncubed/dial/internal/store"
)
// ErrYkmanMissing is returned when the gate is enabled but `ykman` is not on
// PATH. Callers surface this as an actionable message.
var ErrYkmanMissing = errors.New("ykman not found on PATH: install Yubico's ykman CLI to use the YubiKey gate (https://developers.yubico.com/yubikey-manager/)")
// ErrKeyMismatch means the connected key produced a response that does not
// match the enrolled one (wrong key, wrong slot, or no key present).
var ErrKeyMismatch = errors.New("YubiKey response did not match the enrolled key")
// runYkman executes ykman and returns stdout. It is a package variable so tests
// can stub the external call without a physical key.
var runYkman = func(args ...string) ([]byte, error) {
cmd := exec.Command("ykman", args...)
var out, errBuf bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errBuf
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(errBuf.String())
if msg != "" {
return nil, fmt.Errorf("ykman: %s", msg)
}
return nil, err
}
return out.Bytes(), nil
}
// Available reports whether the ykman CLI is installed and on PATH.
func Available() error {
if _, err := exec.LookPath("ykman"); err != nil {
return ErrYkmanMissing
}
return nil
}
// challenge runs an HMAC-SHA1 challenge-response against the given slot. The
// challenge is passed as hex; ykman blocks for a physical touch if the slot was
// programmed to require one. The response is returned as raw bytes.
func challenge(slot int, chal []byte) ([]byte, error) {
if slot != 1 && slot != 2 {
return nil, fmt.Errorf("invalid slot %d (must be 1 or 2)", slot)
}
out, err := runYkman("otp", "calculate", fmt.Sprintf("%d", slot), hex.EncodeToString(chal))
if err != nil {
return nil, err
}
resp, err := parseResponse(out)
if err != nil {
return nil, err
}
return resp, nil
}
// parseResponse extracts the hex response from ykman's stdout, tolerating extra
// whitespace or trailing lines.
func parseResponse(out []byte) ([]byte, error) {
var last string
sc := bufio.NewScanner(bytes.NewReader(out))
for sc.Scan() {
if t := strings.TrimSpace(sc.Text()); t != "" {
last = t
}
}
last = strings.TrimSpace(last)
if last == "" {
return nil, errors.New("ykman returned an empty response")
}
b, err := hex.DecodeString(last)
if err != nil {
return nil, fmt.Errorf("unexpected ykman output %q: %w", last, err)
}
return b, nil
}
// Enroll performs a challenge-response now (prompting a touch) and returns a
// YubiKeyConfig that records the challenge and a salted hash of the response.
// This is called when the user turns the gate on.
func Enroll(slot int) (store.YubiKeyConfig, error) {
chal := make([]byte, 20) // 160-bit challenge, matching HMAC-SHA1's block use
if _, err := rand.Read(chal); err != nil {
return store.YubiKeyConfig{}, err
}
resp, err := challenge(slot, chal)
if err != nil {
return store.YubiKeyConfig{}, err
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return store.YubiKeyConfig{}, err
}
return store.YubiKeyConfig{
Challenge: hex.EncodeToString(chal),
ResponseSalt: hex.EncodeToString(salt),
ResponseHash: hex.EncodeToString(hashResponse(salt, resp)),
}, nil
}
// Verify re-runs the enrolled challenge and checks the response hash matches.
// It returns ErrKeyMismatch if the physical key does not produce the expected
// response. A touch is required if the slot demands one.
func Verify(cfg store.YubiKeyConfig, slot int) error {
if !cfg.IsEnrolled() {
return errors.New("no YubiKey enrolled")
}
chal, err := hex.DecodeString(cfg.Challenge)
if err != nil {
return fmt.Errorf("corrupt stored challenge: %w", err)
}
salt, err := hex.DecodeString(cfg.ResponseSalt)
if err != nil {
return fmt.Errorf("corrupt stored salt: %w", err)
}
want, err := hex.DecodeString(cfg.ResponseHash)
if err != nil {
return fmt.Errorf("corrupt stored hash: %w", err)
}
resp, err := challenge(slot, chal)
if err != nil {
return err
}
got := hashResponse(salt, resp)
if subtle.ConstantTimeCompare(got, want) != 1 {
return ErrKeyMismatch
}
return nil
}
// hashResponse computes sha256(salt || response).
func hashResponse(salt, resp []byte) []byte {
h := sha256.New()
h.Write(salt)
h.Write(resp)
return h.Sum(nil)
}
+103
View File
@@ -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")
}
}