Skip to content

Let YubiKeys be added by tapping them, checked with YubiCloud - #174

Merged
haileyok merged 1 commit into
mainfrom
hailey/yubicloud-otp
Sep 28, 2026
Merged

haileyok merged 1 commit into
mainfrom
hailey/yubicloud-otp

Conversation

@haileyok

@haileyok haileyok commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Adding a YubiKey no longer needs a slot reprogrammed with ykman. With a Yubico API key configured, you tap the key into a box on /account/2fa, and sign in by tapping it into the Bluesky app's code box or the OAuth signin page.
  • Why: a factory YubiKey's OTP secret is known only to Yubico, so a factory key's OTPs can only be checked with Yubico's YubiCloud service (this is how sites like Vultr do it).
  • Without the new settings, the existing ykman setup still works, and keys already added that way are unaffected.

Changes

  • New internal/yubicloud package: a client for Yubico's validation protocol v2.0.
    • Requests are signed (HMAC-SHA1 with the API key) and carry a random 32-character nonce.
    • A reply is acted on only if it's signed; OK, REPLAYED_OTP and REPLAYED_REQUEST must also echo this request's otp and nonce.
    • Yubico's reference server sends BAD_OTP without those echoes, so an echo-less BAD_OTP must instead carry a signed timestamp within 5 minutes.
    • Any other status, a failed check, a network or HTTP error, or a timeout (10 s) is an error, never a valid OTP.
  • New internal/yubicloud/yubicloudtest: a fake YubiCloud server for tests. It checks request signatures and nonces, rejects repeated OTPs, signs replies, and has switches for tampered or failing replies.
  • New settings COCOON_YUBICO_CLIENT_ID / COCOON_YUBICO_API_KEY (--yubico-client-id / --yubico-api-key), added to the Docker Compose files and the README. Setting only one of them, or a malformed API key, stops startup rather than silently turning the feature off.
  • Registration: when Yubico is configured and no AES key is submitted, /account/2fa/yubikey asks only for a name, the password (plus a current code if 2FA is already on), and a tap. The YubiKey box comes last, because the key presses Enter and submits the form.
    • Checks run in this order: password, then the tap with Yubico, then the current code. A tap is cheaper to redo than a current code, which might be a single-use backup code.
    • Only the key's public ID is stored (Secret empty; TwoFactorCredential.UsesYubiCloud()). Adding the same key to one account twice is refused.
  • Sign-in: an OTP is matched to one of the account's registered keys by public ID, and is only sent to Yubico if it matches. This check is also repeated inside the YubiCloud path itself. Yubico rejects replayed OTPs.
  • Outages: if Yubico can't be reached or gives a reply that can't be trusted, or the settings were removed after keys were added:
    • createSession returns 503 YubiKeyCheckUnavailable with an explanation, and the signin page shows a message.
    • It does not count toward the wrong-code lockout.

Validation

  • CGO_ENABLED=1 go test -race ./...: all packages pass. go vet ./... and gofmt -l . are clean.
  • Client tests: a valid OTP, a replay, BAD_OTP with and without echoes, and signed-timestamp handling (including Yubico's documented example value 2008-11-21T06:11:55Z0711). Plus 18 cases of untrustworthy replies, all of which must produce an error rather than a valid OTP: bad or missing signature, wrong otp or nonce echo, forged or unsigned rejections, a stale echo-less BAD_OTP, HTTP 500, error statuses, an unknown status, a wrong API key, and a timeout.
  • Server tests:
    • registering by tapping, then signing in through both the Bluesky app path and the signin page
    • replay rejection
    • an unregistered key's OTP is never sent to Yubico
    • an outage, and settings removed after keys were added, don't count toward the lockout
    • a mistyped password doesn't spend the tap
    • duplicate keys and OTPs without a public ID are refused
    • the fallback to the ykman flow when Yubico isn't configured, and the settings validation
  • Mutation checks: removing the nonce check, the reply signature check, the public-ID match, or the outage handling each makes a test fail.
  • Roast cross-model review: one standard pass and four deeper interactive passes. The first three deeper passes found three issues, now fixed and tested:
    • removed settings counted as wrong codes (medium, pass 1)
    • unsigned rejections were trusted (medium, pass 2)
    • an old signed BAD_OTP could be played back (pass 3; reported twice, rated low by the verifying model)
  • One more finding (low, pass 2) was partly accepted. It suggested running all account checks before the tap; that would burn a current code, possibly a single-use backup code, whenever Yubico rejects the tap. Instead the password, which uses nothing up, is now checked first. The standard pass and the final deeper pass had no findings.
  • Not run: against the real api.yubico.com with a real YubiKey (no Yubico API key or hardware here). The signing follows the spec and matches Yubico's reference server (yubikey-val) and the yubigo Go client, but a live check before relying on this is worthwhile.

Review notes

  • YubiCloud is now an outside dependency for these keys: if api.yubico.com is down, YubiKey sign-in fails until it's back. Authenticator codes and backup codes still work.
  • Each YubiKey sign-in sends the OTP to Yubico. The OTP contains the key's public ID, but no account information.
  • Keys added via ykman keep being checked locally, even when Yubico is configured.
  • On iOS, tapping a YubiKey over NFC opens a URL instead of typing the OTP (unchanged from before).

A factory YubiKey's OTP secret is known only to Yubico, so the server can't
check its OTPs itself. That's why adding a YubiKey needed a slot
reprogrammed with ykman. With a Yubico client ID and API key configured
(COCOON_YUBICO_CLIENT_ID / COCOON_YUBICO_API_KEY), adding one is now: tap
the key into a box on /account/2fa. Signing in is the same tap, in the
Bluesky app's code box or on the signin page used by OAuth.

- internal/yubicloud: a client for Yubico's validation protocol v2.0. It
  signs requests. It trusts a reply that decides the result (OK, BAD_OTP,
  REPLAYED_*) only if the reply is signed and echoes this request's otp and
  nonce. BAD_OTP replies, which Yubico sends without the echoes, must
  instead carry a current signed timestamp.
- The key's public ID ties an OTP to an account. OTPs from other keys are
  rejected without being sent to Yubico.
- If Yubico can't be reached, or the settings are later removed, sign-in
  reports that the YubiKey couldn't be checked, without counting it as a
  wrong code.
- Without the settings, the existing ykman setup still works, and keys
  already added that way are unaffected. Setting only one of the two
  settings, or a malformed API key, stops startup.
@haileyok
haileyok marked this pull request as ready for review September 28, 2026 23:12
@haileyok
haileyok merged commit d373512 into main Sep 28, 2026
1 check passed
@haileyok
haileyok deleted the hailey/yubicloud-otp branch September 28, 2026 23:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant