Files
dial/internal/yubikey/yubikey.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

162 lines
5.1 KiB
Go

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