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