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>
96 lines
2.8 KiB
Go
96 lines
2.8 KiB
Go
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)
|
|
}
|