Form K-SEC · Security model
Security
What Keeyo protects, how, and — just as important — what it deliberately doesn't.
Data model: metadata only
Keeyo stores names and notes: which key is registered where, what it's called, when it was tested. It never stores your services' TOTP seeds, private keys, or recovery codes. The exceptions are opt-in and about Keeyo itself: the per-key secret note explained below, and the seed/codes for Keeyo's own sign-in second factor covered under Authentication.
Authentication
- Passwords hashed with scrypt (per-user salt); sessions are random 256-bit server-side tokens in
httpOnly SameSite=Laxcookies. - Optional security-key second factor: WebAuthn assertions verified fully server-side — challenge, origin, RP ID hash, user-presence flag, and signature against the enrolled public key.
- Optional authenticator-app second factor (RFC 6238 TOTP, ±1 step window, codes strictly single-use). Honesty note: unlike WebAuthn, this seed must live in the server's database — it's a convenience factor, one notch weaker than a hardware key.
- Recovery codes: ten single-use fallback codes, generated server-side with ~49 bits of entropy each and stored only as SHA-256 hashes. They are a second factor only — useless without the scrypt-protected password — and are wiped automatically when the last real factor is removed.
- Login rate limiting that ignores spoofable
X-Forwarded-Forheaders unlessTRUST_PROXY=1is deliberately set. - Changing your password signs out every other session.
Hardening
- Strict Content-Security-Policy with no inline scripts, plus
frame-ancestors 'none',nosniffandReferrer-Policy: no-referrer. - All SQL parameterized; all user content HTML-escaped on render.
- File attachments are always served as downloads with
nosniff, so an uploaded HTML file can never execute in Keeyo's origin. - One npm dependency (Express), no build chain, Docker image runs as a non-root user.
Tap-to-reveal secret notes
When a key is scanned, Keeyo keeps the public half of a throwaway WebAuthn credential as a pairing. Revealing the key's secret note runs a challenge–response against that pairing: single-use challenge, and the server verifies origin, RP ID, user presence and the signature before releasing the note. Swapping a key's pairing while a note exists is refused — the note must be cleared or replaced first, so a hijacked session can destroy the note but never read it.
End-to-end encryption (PRF)
If the authenticator supports the WebAuthn PRF extension (hmac-secret — YubiKey 5 series and most modern FIDO2 keys do), notes go further: they are encrypted in your browser with an AES-256-GCM key derived (HKDF-SHA256) from a secret only that physical key can reproduce. The server stores ciphertext only — a stolen database, a leaked JSON export, even a malicious server admin gets nothing without the physical key in hand. Saving such a note costs one extra tap (deriving the key); revealing costs none, because the possession-proof tap doubles as the decryption ceremony. Pairings made before PRF support store notes server-side in plaintext — the key page labels each note honestly (E2E encrypted vs server-stored), and re-pairing upgrades it.
Honest limitations
- Secret notes on non-PRF pairings are stored unencrypted in the SQLite file and JSON exports. PRF-capable pairings store ciphertext only — check the note's label on the key page.
- TLS is delegated to your reverse proxy. Over plain HTTP on a LAN, session cookies travel unencrypted and all WebAuthn features disable themselves.
- The FIDO metadata blob's JWT signature is not verified — it only supplies display names, never security decisions.
KEEYO_DISABLE_MFAand the password-reset script are deliberate escape hatches: anyone with shell access to your server owns your Keeyo. That's the nature of self-hosting.
Outbound traffic
The server makes exactly one kind of outbound request: refreshing the device registry (FIDO Alliance MDS + a community list) every few days. Set KEEYO_OFFLINE=1 and it makes none. In the browser, the optional favicon icon mode loads icons from DuckDuckGo — skip it and the app makes zero external requests.
Reporting vulnerabilities
Please use GitHub's private Report a Vulnerability form — details, scope and expectations are in the security policy. Verified reports about authentication, cross-user access, injection, or bypasses of the WebAuthn possession checks are very welcome. Never report security issues in public threads.