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