# Font fingerprint randomization: glyphs, not lists

> Font fingerprint randomization can hide fonts or change how wide glyphs measure. Hiding read as masking; per-profile glyph advances did not.

Source: https://mosaicbrowser.com/blog/glyph-advances-per-profile

## Same fonts, different measurements: per-profile glyph advances

Font fingerprint randomization can hide fonts or change how wide glyphs measure. Hiding read as masking; per-profile glyph advances did not.

![1/20 px: Up to a twentieth of a pixel per glyph](https://mosaicbrowser.com/covers/glyph-advances-per-profile.webp)

Font fingerprint randomization comes in two kinds. You can change which fonts a page finds, by hiding or swapping families, or you can leave the font list alone and change how wide the text measures. We shipped both in our engine at first. Measured, the first kind was what a detector called masking, and the second was not. So we removed the font hiding and kept per-profile glyph advances, which is how Tesseract Chrome handles fonts today.

## How a page fingerprints fonts

There is no API that lists installed fonts, so a page measures. It draws a test string in a fallback font such as `monospace` and records the width, then draws the same string as `"Some Font", monospace`. If the width changes, Some Font is installed. Run that over a few hundred family names and the page has a font list, built entirely from measurements.

The same widths are a fingerprint of their own. How wide a string comes out depends on the font files, the version of the operating system that drew them and the way it rasterises text. Two browsers on one machine produce identical numbers for every string, because they are using the same files on the same system. So on a computer running several profiles, text measurement is shared entropy: every profile answers the same question the same way.

That gives a randomizer two different things to change, and they behave very differently.

Mosaic is a desktop app

Email yourself the link and open it on your computer.

[How it works: Machines that exist](https://mosaicbrowser.com/features/real-fingerprints)

## Hiding fonts: what we started with

The font patch our engine started from filtered font lookups in `font_cache.cc`. For a profile claiming the machine's own OS, it hid a seeded 2% or so of families, so each profile was missing a different handful. For a profile claiming another OS, it hid 95% of the families that platform would not have and substituted the host's fonts for the claimed platform's names.

Each profile got a different list, which sounds like the goal. What it actually produced was a Mac missing two families every Mac ships with. A macOS font list with random gaps is not a rare Mac. It is a Mac nobody owns, the same mistake as a fast laptop chip paired with the wrong screen, made two per cent at a time.

Pixelscan said so. On the first detector run of our finished engine it read one fresh profile as "Masking detected". We bisected that profile in place, one surface at a time, with the unchanged profile run first and last as a control, and only one surface flipped the verdict: fonts. Canvas, WebGL, audio and client-rect noise were all still on in the row that passed. The full bisect, with every row, is in [Pixelscan masking, bisected](https://mosaicbrowser.com/blog/pixelscan-masking-bisected).

The cross-OS branch went too, and it was already dead code: the engine made every profile claim the host's own OS at that point, so the substitution never ran. It would not have helped anyway. A Windows font name answered by a Mac's font still measures like the Mac's font, which is why [a Mac makes the best Mac profiles](https://mosaicbrowser.com/blog/a-mac-makes-mac-profiles).

## Font fingerprint randomization by glyph advance

The second patch, `0115-font-metric-jitter`, touches one function in `simple_font_data.cc`: the one that returns how far the pen moves after a glyph, its advance width. It adds a small amount to that width, and nothing else changes. The same fonts exist, the same fallback rules apply, and each font draws the same shapes.

The rules it follows are the ones that make it look like a font rather than a filter:

- Advances only, never the x or y offsets. Offsets are how combining marks sit on their base letters, and moving them breaks Thai, Arabic and Devanagari text.
- A glyph with zero advance stays at zero. Those are combining marks and joiners, and giving one a width pushes the mark off its letter.
- The amount is a pure function of the profile's seed and the glyph id. A glyph measures the same every time within a profile, so measuring one string twice gives one answer. Noise that re-rolled per call would be caught by exactly that test, which is a stronger signal than any width.
- At most a twentieth of a pixel per glyph. That is enough to move the width of a measured string, and small enough to stay inside what two machines drawing one font could plausibly differ by. More noise is not safer. Past a point it stops looking like a different machine and starts looking like a randomizer, which is the lesson canvas noise taught us first.

The seed comes from the profile, through `--fingerprint-font-spacing-seed`, so it holds across launches. A profile measures text the same way next week as today, and a second profile on the same Mac measures it slightly differently.

## How we knew the jitter was innocent

Both font patches sat behind one `--disable-spoofing=font` token, so the first bisect turned them off together, and we nearly deleted both. The jitter has its own off switch by accident: a seed that is not a number fails to parse, and the function returns the real width. That let us hold the two apart. With the jitter off and the hiding on, Pixelscan still failed, twice. With both off it passed. With both on it failed. So the hiding was the signal, and the jitter stayed.

Because this happens in C++ inside Blink's own font code, there is no script on the page for a detector to find and no wrapped function whose source gives it away. A JavaScript wrapper around one measuring API would leave the others reporting the real value, and why that split is worse than no change at all is in [C++ versus JavaScript spoofing](https://mosaicbrowser.com/blog/cpp-not-javascript).

## The cost, stated

Two Mosaic profiles on one computer now report the same font list. Profiles drawn for this computer's own system already share its operating system and its GPU vendor, because those are true of the machine. A shared truth is ordinary; real offices are full of identical laptops. A private contradiction, a list that no real install of that system would have, is what a scanner is looking for. Where text measurement fits among the other values a page reads is on [browser fingerprint](https://mosaicbrowser.com/glossary/browser-fingerprint).

## Questions

### What is font fingerprint randomization?

Changing what a page learns when it probes fonts, so two browsers on one machine do not give the same answer. It can be done by hiding or swapping which fonts exist, or by changing the measured widths of the glyphs a font draws.

### Does hiding fonts protect against font fingerprinting?

In our measurements it did the opposite. Our engine hid about 2% of installed families per profile, and Pixelscan read that as masking. With the hiding removed and every other surface still changed, the verdict cleared.

### How much does Mosaic change a glyph's width?

Up to a twentieth of a pixel per glyph, added to the advance width. The amount is a fixed function of the profile's seed and the glyph, so a string measures the same every time within one profile and differently in another.

### Do two Mosaic profiles on one computer have the same font list?

Yes. The installed fonts come from the real machine, and we stopped hiding any. Two profiles report the same list, because it is the list this computer really has, while their glyph advances differ.

## Related

[Pixelscan's masking verdict, bisected to one surface](https://mosaicbrowser.com/blog/pixelscan-masking-bisected): We bisected Pixelscan's masking detected verdict on one profile. Canvas, WebGL, audio and client rects were fine. Hiding about 2% of font families caused it.

[Why OS spoofing fails on fonts: a Mac makes Mac profiles](https://mosaicbrowser.com/blog/a-mac-makes-mac-profiles): Why OS spoofing fails on fonts: they come from the computer, not the claim. A Linux profile made on our Mac showed 1 font where macOS showed 159.

[Browser fingerprint: what a site can read about your machine](https://mosaicbrowser.com/glossary/browser-fingerprint): A browser fingerprint is the set of values a site reads about your browser and computer, which together recognize you without cookies. What goes into one.

[Why we change fingerprints in C++, not JavaScript](https://mosaicbrowser.com/blog/cpp-not-javascript): A JavaScript spoof is one realm deep. An iframe read the real source of 33 of our patched functions; the same values changed in C++ agree everywhere.



[Every profile on one Mac drew the same WebGL image](https://mosaicbrowser.com/blog/webgl-hash-same-every-profile): A WebGL fingerprint is the same in every browser profile on one machine because the GPU draws it. Ours was too, until per-profile noise moved the hash.

[readPixels and toDataURL read a canvas from opposite ends](https://mosaicbrowser.com/blog/webgl-readpixels-vs-todataurl): readPixels starts at the bottom row and toDataURL at the top, so position-keyed WebGL noise disagreed with itself. Keyed on colour, 4096 of 4096 pixels agree.

[The four-line probe that catches position-keyed canvas noise](https://mosaicbrowser.com/blog/canvas-offset-probe): To detect canvas noise injection, draw one shape twice at two offsets and diff them. A real browser gives 0 bytes; our old noise gave 76.

## Mosaic, on your own computer

The browser this blog is about: its own Chromium, fingerprints from real machines and a proxy exit per profile. Free holds 3 profiles. Pro is $49 a month for any number.

Mosaic is a desktop app

Email yourself the link and open it on your computer.
