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