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>
162 lines
5.1 KiB
Go
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)
|
|
}
|