Repository navigation
Security Design
Mirror of SECURITY-DESIGN.md (the repository copy is authoritative). Author: Louis Wright.
Status: v2 design. Author: Louis Wright. v1's single-glyph design is kept as badge mode (Appendix B).
Inline mode (default, v2): the masking dots inside a password field and its confirm field are drawn as Egyptian hieroglyphs. Same password → same glyph sequence in both fields. A typo changes the glyph at the first wrong position and (almost certainly) every glyph after it. The user spots the mistake without the password being shown.
Badge mode (v1, mode: 'badge'): one small glyph beside each field.
Prior art (Arc90's HashMask and Chroma-Hash, both 2009, and the HashMask Chrome extension, 2013) showed a fast hash of the password, unsalted or with a static site-wide salt. That's a stable fingerprint that can be brute-forced offline from a screenshot and recognised across sites. passglyph is designed so that this doesn't happen.
Assets: the password being typed, and the user's other passwords (reuse).
| # | Adversary | Capability |
|---|---|---|
| A1 | Shoulder surfer / camera / screen recording / screen-share / screenshot | Sees the glyphs, possibly keystroke by keystroke and across sessions; not page memory |
| A2 | Cross-site / cross-session correlator | Collects glyph sequences from several sites or sessions |
| A3 | DOM observer without script execution (session replay, DOM snapshots, accessibility-tree dumps, bug-report HTML) | Sees DOM and a11y tree, not pixels or memory |
| A4 | Timing observer | Sees when glyphs change |
Out of scope: keyloggers, malware, extensions with page access, XSS, compromised third-party scripts. Anything that runs in the page can read input.value.
The obvious design, character c → glyph f(c), is a substitution cipher. Even with a secret per-session f:
- repeated characters show repeated glyphs, so
balloonreveals the patternab cc dd e(positions 3=4 and 5=6); - the password's structure (which positions are equal) is visible to A1 in every session;
- A1 can learn f outright from a few sessions or from known text typed into the same field;
- dictionary words are recognisable from their letter pattern alone.
passglyph never does this. (The demo has a toggle that shows the pattern leak.)
Each attach() (each <pass-glyph>, each hook instance) creates a 128-bit random salt with crypto.getRandomValues, prefixed with the label passglyph/v1. It's imported straight away as a non-extractable WebCrypto HMAC-SHA-256 key, and the raw salt bytes are zero-filled. From then on the secret exists only inside a CryptoKey: script can't export it, it isn't in the DOM, storage, the network or a global, and the handle passglyph returns doesn't expose it. Both fields of a pair share the key, which is the only thing that makes them comparable. Every page load, tab, form and site gets a different key.
If crypto.getRandomValues or crypto.subtle is missing, passglyph falls back to normal dots. It never uses Math.random.
g[i] = first b bits of HMAC-SHA-256(key, UTF-8(password[0..i])) (positions = Unicode code points)
-
Identical characters don't give identical glyphs.
aaaahashesa,aa,aaa,aaaa: four independent values (tested). - A mismatch shows from the first wrong position on. The two fields' sequences agree exactly up to the first difference. After it, every later glyph differs with probability 1 − 2^-b, independently.
- No PBKDF2 here. Inline glyphs must update on every keystroke (like dots), and the key is 128 random bits, not a password, so offline brute force of the key is infeasible and slowness buys nothing. Badge mode still uses PBKDF2 (Appendix B).
- The HMAC outputs are zero-filled after truncation. The only derived state kept is the current sequence of b-bit indices, needed to redraw on scroll and resize. It's discarded on the next input and on
detach().
The alphabet is the first 2^b glyphs of a curated set of 64 hieroglyphs: 16, 32 (default) or 64. bits is clamped to 4–6, so asking for more gives 6.
Typo detection. A typo at position i is missed at that position with probability 2^-b, and at each of the following k positions independently with probability 2^-b, so it's missed entirely with probability 2^-b(k+1):
| b | alphabet | missed at that one position | missed at it and the next 9 positions |
|---|---|---|---|
| 4 | 16 | 6.25 % | 2^-40 ≈ 10^-12 |
| 5 | 32 | 3.1 % | 2^-50 ≈ 10^-15 |
| 6 | 64 | 1.6 % | 2^-60 ≈ 10^-18 |
(A typo in the last character is the weakest case: it's caught only by that position, so 1 − 2^-b. The match announcement, which compares the values exactly, covers it.)
What an observer learns: the honest numbers.
- A1/A2 without the key: 0 bits. HMAC with an unknown 128-bit key is a PRF. Glyph sequences from different sessions are independent, and nothing can be compared across sessions or sites.
-
Under that session's key, a recording carries about b bits per position: b·n bits for an n-character password (5 × 12 = 60 bits at the default). That sounds like a lot, and it is. If an attacker also obtained the key (i.e. ran code in that page, or dumped its memory), prefix hashing lets them recover the password one character at a time: for each position, try the ~95 printable characters, keep those whose HMAC matches. About 95/2^b candidates survive per position (≈ 3 at b = 5, ≈ 1.5 at b = 6, ≈ 6 at b = 4). That leaves about n·log2(95/2^b) bits: about 19 bits for 12 characters at b = 5, which is trivial to finish offline. So inline mode's protection rests entirely on the key never leaving memory. It doesn't: it's non-extractable, never serialised and dies with the page. And an attacker who can reach it can read
input.valuedirectly, so the realistic marginal risk is small. But it's very different from badge mode, where even a stolen key yields at most 12 bits behind PBKDF2. If your threat model includes memory disclosure without script execution (e.g. crash dumps sent to a third party), use badge mode. - The typo position is revealed to anyone watching: the sequences diverge exactly at the first wrong character. That's acceptable UX (it's the feature) but it's a leak: an observer learns "the confirm field differs from position 7 on". On its own it says nothing about the characters.
- The match/mismatch bit is visible, as with any "passwords don't match" message.
- Length: one glyph per character, exactly where the dots were. The dots already reveal length, so nothing is added. (Positions are code points. A browser that draws two dots for one astral character, e.g. some emoji, would misalign, but reveals nothing extra.)
-
No per-keystroke flourish: no animation, fade or "reveal last character". The overlay repaints in a single frame per input. Mobile browsers' "show the last typed character briefly" is suppressed too, because the native text is transparent.
prefers-reduced-motionneeds no special handling because nothing moves. - Timing: each input recomputes the HMAC of every prefix (n HMACs, microseconds each). The work depends only on the length, which is public, never on content or on whether the fields match. The match announcement is debounced (300 ms) and only made at ≥ minLength.
- Stability while typing: earlier glyphs never change when you append characters (their prefixes didn't change), so watching someone type shows only "one more glyph", the same as dots.
- The
<input type="password">is not replaced, wrapped, cloned or retyped. Its type, name, id and autocomplete are unchanged, so autofill, password managers, mobile secure keyboards, screen readers, form submission and:invalidstyling all keep working. - passglyph hides the native dots with CSSOM properties on the input (
color: transparent,-webkit-text-fill-color: transparent), keeps the caret visible (caret-color), and setsletter-spacingso each native mask cell is exactly one glyph cell wide. The caret, selection and click-to-position therefore line up with the glyphs. All four properties are restored exactly ondetach(). - Glyphs are painted on an absolutely positioned
<canvas aria-hidden="true">overlay (pointer-events: none) above the input's padding box. It followsscrollLeft(long passwords),direction: rtl, font-size and zoom changes (ResizeObserver+resize), and selection changes (the native selection highlight shows through). -
IME composition: native dots are shown during composition and glyphs return on
compositionend. -
Fallback to normal dots if canvas,
Path2D,measureTextor WebCrypto is unavailable (onFallback(reason)is called). The input is then left completely untouched.
Hieroglyphs come from a bundled sprite: 64 curated signs from U+13000–U+1342F, rasterised from Noto Sans Egyptian Hieroglyphs (OFL-1.1) to 32×32 bitmaps and expanded at runtime into SVG path data for Path2D. No font is loaded and no hieroglyph code point is ever put in the DOM. Text nodes or attributes containing 𓂀 would expose each position's glyph (the derived bits) to A3, and would depend on system fonts that often lack the block. The DOM snapshot is byte-for-byte identical for any two passwords with the same match state, and no character in U+13000–U+1342F, no path data and no data-* attribute appears anywhere (tested).
-
Accessibility: overlays and badges are
aria-hidden. A visually hiddenrole="status"/aria-live="polite"region announces "Passwords match" or "Passwords don't match" once both fields have ≥ minLength (8) characters, after a 300 ms debounce. Screen readers otherwise keep reading the password field as a password field. Glyphs differ by shape, never only by colour (inline glyphs are monochrome in the input's text colour; badge glyphs use the Okabe–Ito palette plus shape and mark). -
No network, no storage, no globals: passglyph never calls
fetch,XMLHttpRequest,sendBeacon,WebSocket,localStorage,sessionStorage,indexedDBor cookies (trapped in tests). The only global is the optionalcustomElements.define('pass-glyph'). -
CSP: no
<style>elements, nostyle="..."in markup (nosetAttribute('style')), noevalornew Function, no fonts, and no network. Styles are set through the CSSOM, whichstyle-srcwithout'unsafe-inline'doesn't block, so no nonce is needed. Works underdefault-src 'self'. -
Password managers and autofill: listened to via
inputandchange, plus a check at attach time for values filled earlier.
| Threat | Mitigation | Residual risk |
|---|---|---|
| Substitution-cipher pattern leak (repeated characters, structure) | Prefix HMAC, not per-character mapping | None |
| Screenshot or recording used offline (A1) | Unknown non-extractable 128-bit key | None without the key; with the key ≈ full recovery in inline mode (4.3) |
| Linking sessions or sites (A2) | Fresh key per instance | None |
| DOM / a11y-tree / session-replay disclosure (A3) | Canvas-only rendering from a sprite; no code points; snapshot-identical DOM | Match bit visible |
| Length / rhythm / timing (A1, A4) | One glyph per dot, single-frame repaint, work ∝ public length | Length (already visible as dots) |
| Typo position | — (it's the feature) | Position of first mismatch visible |
| Weak RNG |
getRandomValues only; else fall back to dots |
None |
| Unsupported browser features | Fall back to native dots | Plain dots UX |
- Not a strength meter. Matching says nothing about strength.
- Not protection against keyloggers, malicious extensions, XSS or compromised scripts.
- Not verification: keep comparing on submit.
- Not memorable, deliberately: the sequence changes every page load.
| Option | Inline default | Badge default | Allowed |
|---|---|---|---|
mode |
'inline' |
'badge' |
|
bits |
5 per position | 10 total | inline 4–6, badge 4–12 (clamped) |
minLength |
8 (announcement) | 8 (glyph + announcement) | ≥ 4 |
iterations |
— | 60 000 | ≥ 10 000 |
debounce |
300 ms (announcement) | 300 ms | ≥ 100 |
size |
1.1 em cell | 20 px |
Inline: identical repeated characters give different glyphs; sequences agree up to the first difference and diverge after; same salt gives the same sequence in both fields; a fresh salt gives a different sequence; bits clamped to 4–6; the key is non-extractable and the salt is wiped; no code points, path data or derived data in the DOM (snapshot equality across passwords); type=password, name and autocomplete preserved; one repaint per input; paste; scroll and RTL; IME; letter-spacing alignment; fallback without canvas or WebCrypto; detach restores the input exactly; no network, storage or style injection.
Badge (unchanged from v1): salt uniqueness, entropy cap, DOM independence, neutral glyph for short input, debounce, fixed-deadline timing, no network or storage, CSP, a11y, detach.
Each field gets one glyph: PBKDF2-HMAC-SHA-256(password, salt, 60 000 iterations) → 4–12 bits (default 10, hard cap 12), drawn from an alphabet of shape × Okabe–Ito colour × mark × pips × frame. A fixed neutral glyph is shown below minLength, and the repaint is debounced to a fixed deadline. It leaks far less than inline mode (≤ 12 bits in total even with the salt) but doesn't show where a typo is.
bits = PBKDF2-HMAC-SHA-256(password, salt = "passglyph/v1" ‖ random128, iterations = 60 000, 32 bits)
- It uses WebCrypto (
crypto.subtle), never hand-rolled crypto. - The default is 60,000 iterations, which takes roughly 15–60 ms on current laptops and phones. That's fast enough for a field the user has stopped typing in. The minimum is 10,000, enforced; lower values are raised to it. Even an attacker who somehow has the salt (an A1 attacker who also dumped memory) pays 60,000 HMACs per guess, not one SHA-256.
- Input is debounced (300 ms default): there's no derivation, and no glyph change, while the user is typing.
- The
PBKDF2key is imported as non-extractable, used once and dropped. Only 32 bits are derived, and theArrayBufferis zero-filled right after the glyph index is taken. JS strings (the password itself) can't be wiped; that's a platform limitation shared with the input element.
The glyph shows b bits: 10 by default, configurable from 4 to 12. The cap is hard: values above 12 are clamped to 12. b is a trade-off between two numbers:
| b | Glyphs | Typo goes unnoticed (false match) = 2^-b | Guess-space reduction if the salt were known | Example: 40-bit password |
|---|---|---|---|---|
| 4 | 16 | 6.25 % | ÷16 | 36 bits left |
| 8 | 256 | 0.39 % | ÷256 | 32 bits left |
| 10 (default) | 1 024 | 0.098 % | ÷1 024 | 30 bits left |
| 12 (cap) | 4 096 | 0.024 % | ÷4 096 | 28 bits left |
How to read this:
- Without the salt (A1, A2, A3), the reduction is zero. The 2^b reduction only applies to an attacker who also has page memory, and that attacker can read the password anyway.
- Even in that worst case, b bits are at most b bits. A screenshot never narrows the password by more than a factor of 2^b, and every remaining guess still costs a PBKDF2 evaluation. A strong password (60+ bits) stays strong. A weak password was weak before passglyph.
- 12 bits is the cap because beyond it the typo-detection benefit is negligible (0.024 % → 0.006 %), while the worst-case leak keeps growing. Typo detection needs "probably different", not "certainly different".
- Repeated observations. n screenshots of independent sessions use n independent salts, so without the salts they're worthless. With the salts they could leak up to n·b bits. That's another reason the salt never leaves memory.
- Prefixes. Debouncing means a user who pauses mid-password shows a glyph of the prefix. With the salt this would leak b bits about a shorter string, which is why B.3 exists and why the default minimum length is 8.
- Below
minLength(8 by default, never below 4) a fixed neutral glyph is shown. It's identical for every short value, including empty, so short passwords never get a glyph worth recording. - There's no per-keystroke animation, no blanking and no colour flash while typing. The glyph changes only after the debounce, and only at most once per pause. So the display doesn't reveal how many characters were typed, or their rhythm.
- The neutral glyph reveals only "at least minLength characters", which is already visible from the masked dots in any password field.
Each settled input runs one derivation regardless of length (short values still derive, the result is discarded). The repaint is then scheduled at a fixed deadline: last input + debounce + budget (budget is 250 ms by default, or the actual derivation time if that's longer). PBKDF2 cost doesn't depend on password length below the HMAC block size (64 bytes). So when the glyph changes reveals nothing about the length or content of the password, or about whether it matched (A4).