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