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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user