ITADN
ithiria894/nocap · 文件
文件最后提交记录最后更新时间
README.md

nocap

Anti-screenshot, anti-AI display for secrets on screen.

Live demo & playground

Splits content into frames that alternate at your display's refresh rate. Each frame is noise; their mean is the content. Your visual system does the averaging, so you read it — a single screenshot does not.

The text never enters the DOM, so page-reading AI agents and scrapers get nothing at all.

import 'nocap';
<nocap-secret></nocap-secret>
document.querySelector('nocap-secret').secret = await fetchAccountNumber();

What this defeats, and what defeats it

The claims are narrow on purpose. Read this before building on it.

ThreatResult
Reflexive Print Screen / Win+Shift+S / Cmd+Shift+4Blocked — the capture lands on one plane
DOM-reading AI agents, LLM scrapers, accessibility-tree readersBlocked absolutely — the secret is never a DOM node
Single-frame OCR / vision-model ingestionBlocked — one frame is noise
View Source, curl, Save Page As, Select-All + CopyBlocked — verified live in the demo
Quick phone photoUsually blocked — short exposure lands on one plane
Screenshot + a box blurWeakened. Denoising recovers a lot; see the block-size table
Screen recording + temporal averagingDefeated. ffmpeg -i cap.mp4 -vf tmix=frames=2 out.mp4
Burst screenshotsDefeated. Average them, or keep the readable one
Casual DevTools poke — heap search, one-line fillText hookSlowed by scramble — yields the glyphs without their order
Anyone with DevTools and intentDefeated. The canvas must hold the arranged image, so averaging a run of frames recovers it

The averaging limit is information-theoretic, not an implementation gap: anything your eye can integrate, software can integrate better. No tuning fixes it. averageFrames() and denoisedLeak() ship so you can run both attacks against your own settings — if you can't, you don't know what you're shipping.

For protection that holds against a determined attacker the mechanism is the compositor, not the content: SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE) on Windows, NSWindow.sharingType = .none on macOS, FLAG_SECURE on Android. Layer nocap on top of those, never instead. For documents, per-user forensic watermarking changes behaviour more than any technical speed bump.

How DevTools defeats it

scramble is an optional mode that stores glyphs shuffled with a separate slot map and paints each back into place. It moves several of these attacks. It does not move the one that matters.

AttackDefaultWith scramble
Average a run of frames off the canvasplaintextplaintext
Breakpoint on the secret setterplaintextplaintext
Read the element's private fieldsplaintextreconstructable
Hook fillText on both 2D prototypeswhole secret, one callglyphs, no order
Heap snapshot, search for the stringfoundnot found

The first row settles it, and no mode changes it: the canvas must hold the correctly-arranged image, or you could not read it either. A short run of frames averaged together is the plaintext — verified live in the demo, which recovers it identically with and without scramble. Scrambling protects how the value sits in JS memory and does blunt the one-line fillText dump, but it is obfuscation, not encryption — both the shuffled glyphs and their position map are live fields on the element, and the setter still receives the plaintext before any of it happens.

A fillText hook has to patch OffscreenCanvasRenderingContext2D as well; text is rasterised off-screen, so patching only CanvasRenderingContext2D catches nothing and makes the default look safe when it is not.

The real line is automated pipeline vs. targeted attacker. nocap defeats the pipeline and never the person who has decided to come after the value.

Scope

This library is the display effect and nothing else: split the content, show it, and tell you honestly how well it is hidden. It deliberately does not ship storage, delivery, encryption, expiry or accounts.

That is not an oversight — those belong a layer up, and they are where the real protection lives. The flicker cannot beat a screen recording or DevTools; what does beat them is making the captured value worthless. A one-time value that dies on first read turns an unwinnable fight into an economic one.

The value is unreadable to assistive technology, by construction. It is a <canvas> inside a closed shadow root: no text node, no accessible name, no alternative. That is the same property that blocks scrapers and DOM-reading agents, and it cannot be had selectively — a screen reader is a DOM-reading agent. Any integration has to provide an accessible route to the value itself. A copy-to-clipboard control is the usual answer, and it is what people want to do with an account number anyway.

If you are building that layer, the pieces that pair with this are: encryption at rest with a key that never touches your server (a passphrase, or a key in the URL fragment), single-use or short-lived values, per-recipient watermarking, and on native, WDA_EXCLUDEFROMCAPTURE / FLAG_SECURE — which is enforcement rather than friction. Use nocap for the moment the value is on screen.

Choosing colours

color and background are the perceived colours. The split runs in linear light, so what you author is what you see — verified to within 0.3 code levels across the range. There is no band, no compression, and no contrast pre-emphasis; those existed only to buy uniform noise headroom and linear light removed the need.

What you cannot escape is that a colour can only carry so much noise. The predictor is the masking ratio:

checkPalette({ color: '#9ea6b4', background: '#6b7280' })
// { ratio: 1.42, grade: 'good', warnings: [] }
ratiomeasured leakverdict
≥ 1.00.08 – 0.22good
0.5 – 1.0~0.3fair
< 0.50.47 – 0.79do not ship

Correlated at −0.73 against denoised leak over 28 palettes. Light headroom, the obvious metric, correlates −0.06 — no better than chance, because light is expansive near white: #f0f0f0 keeps 26% of its light headroom and still leaks 0.76.

Two hard limits fall out:

  • Saturation is capped. A channel at 0 or 255 has zero swing, so #ff3131 scores 0.00 at any lightness. Fully saturated colours cannot be masked.
  • Both ends are bad. Usable region is roughly channels in [40, 214], both colours mid-tone, separation under ~90.

Put the secret on a mid-tone panel. It then matches its surroundings exactly and has room to be protected. suggestConfig() derives such a pair from a page's palette, and the palette demo lets you check one interactively.

amplitude is now a fraction of whatever headroom the colours allow, so choosing mid-tone colours buys more protection than raising amplitude ever does. noise-scale trades blur resistance against flicker fusion: coarse noise resists a blur far better but strobes below 120Hz, so 3 is the default.

Fake values — experimental

Experimental, and not recommended for production yet. It works, but the useful range is narrow enough that it may not be worth the complexity. Read the trade below before enabling it.

Noise tells an attacker the capture failed, so they take another. A value in the right shape does not.

<nocap-secret fake="auto"></nocap-secret>

Each cycle carries a different decoy, added on one of its two frames and subtracted on the other. Two consequences:

  • The viewer never resolves any of them. The pair cancels inside a single cycle — about 16ms at 60Hz — so it is gone well within the 50-100ms your eye integrates over.
  • A capture freezes one. A screenshot cannot average, so it catches a single frame and a single decoy, in the right format, at full contrast. The rotation holds eight, so a burst lands on different ones.

Why it is marked experimental

The decoy's push is bounded by the headroom the noise leaves, because anything larger clips — and a clipped push is no longer equal and opposite, so the decoy stops cancelling and ghosts into the mean where the viewer sees it. That bound also makes the decoy subtle in a capture: it shares the noise's grain and contrast rather than standing out from it.

So the two things you want are in direct tension. A decoy that blends and never reaches the eye is hard to read in a screenshot; one that is boldly legible leaks. There is no setting that gives both, and the honest summary is that fake mode currently buys less than its complexity costs. It is kept because the mechanism is sound and a larger decoy font may widen the window later.

The pair has to sit inside one cycle, and this is the subtle part. An earlier version split each pair one cycle apart, which cancels only over the full 16-frame rotation — 267ms, far longer than the eye's window. The result inverted the whole effect: the decoys stayed visible and the noise averaged away. Two other failures cost a rebuild each, both visible only in a rendered frame: alternating signs over different strings does not cancel, it leaves the difference of their glyph coverage as a smear; and flipping the line and the sign together re-correlates them and makes it worse.

detectFormat() classifies ISO and d/m/y dates, card expiries, card numbers, grouped numbers, phone numbers, alphanumerics and free text; fakeLike() generates the decoy in auto / number / text / random. The shape always matches the source exactly — same length, separators, digit/letter/case pattern — because a wrong-shaped decoy reveals the mechanism and is worse than noise. Auto mode is semantic too: fake dates have real months, fake card numbers pass Luhn.

4471-0092-8834   grouped number, 12 digits   2206-9276-8289  6943-2162-7081 ...
2026-09-01       ISO date                    2024-02-15
4539578763621486 16-digit card number        4638875219028443

Decoys are driven from the mean of the plane pair at full headroom, so they replace the noise where their ink falls rather than competing with it, and they never clip or shift the perceived value. Each gets its own random position and size, scattered across the field rather than stacked on fixed lines — the spot belongs to the decoy rather than to the appearance, so the added and subtracted copies still land on exactly the same pixels and cancel.

<nocap-secret>

AttributeDefaultMeaning
scrambleoffStore glyphs shuffled; see the DevTools table.
fakeoffExperimental. auto / number / text / random. Needs masking ratio 1.0+.
amplitude110Fraction of the headroom the colours allow.
noise-scale3 × dprNoise block in device px. Higher resists blur, strobes below 120Hz.
gamma2.4Display EOTF. Measure yours with the calibration demo.
frames2Planes per cycle. 2 is almost always right.
contrast1Pre-emphasis. Not needed under linear light, which does not compress.
chroma00 = grey noise, 1 = independent per channel.
hardness11 slams every pixel to ±amplitude; lower keeps noise near the background.
color / background#9ea6b4 / #6b7280Authored palette. Must be maskable — see below.
adaptiveoffExact colours, amplitude capped to their headroom.
width / height260 / 56CSS pixels.

Properties: .secret (write-only), .revealed, .refreshHz, .render(), .stop(), .measureLeak(). Events: render, stop.

It renders as soon as it has a value. Hold-to-reveal, click-to-toggle, auto-hide on blur — those are product decisions, not part of the effect, so they are deliberately absent. Wire them up around the element with render() and stop().

Set .secret from JS. Putting the text in markup works — it is read once and erased from the DOM — but it was in the HTML source on the way there, which defeats the point.

Lower-level API

import {
  Flicker, splitFrames, averageFrames, boxBlur, denoisedLeak,
  leakScore, planeRange, suggestConfig, checkPalette, codeSwing,
  detectFormat, fakeLike,
} from 'nocap';

Flicker drives a canvas. Everything in splitter.js and palette.js is pure and DOM-free, so it runs in Node, a worker, or a native port.

Split modes: amplitude (the one that works), plus interleave, channels and decoy — included so leakScore can show you why they do not:

SplitSingle-plane leak
channels — RGB split across planes1.000
decoy — second image as modulator0.929
interleave ×2 — pixels split across planes0.693
amplitude 640.386
amplitude 960.137
amplitude 1270.004

Measured on a structured test image with leakScore — raw, with no denoising. See the block-size table above for numbers with a blur attack allowed; they are considerably worse, and that is the honest number to design against.

Splitting where pixels are does nothing; only randomizing what they say works. Recognition survives losing colour, losing 90% of pixels, blur and quantization, so channel splitting and pixel interleaving both leak badly. Interleave only becomes safe once stacked noise carries it — at which point the interleaving contributed nothing. Noise amplitude is the only lever; frame count is not a substitute.

Render at exact device pixels. resize(cssW, cssH) handles devicePixelRatio; if CSS rescales the canvas the noise blurs toward its mean and the content becomes readable in a single frame — a silent, total failure.

Safety

Noise is per-pixel and zero-mean, so every frame carries the same local mean luminance as the source. The alternation is a high-spatial-frequency contrast reversal, not a full-field flash — the mechanism 6-bit panels use for FRC dithering. That keeps it out of the large-area-flash regime WCAG 2.3.1 targets.

It is still moving high-contrast content, and the element starts as soon as it has a value and does not stop on its own. There is no hold or auto-hide here — those are product decisions (see Scope) — so gating the reveal, bounding how long it runs, and offering an opt-out are your responsibility. stop() is the hook.

prefers-reduced-motion: reduce is honoured: the element shows the perceived mean statically instead of alternating, and warns to the console that there is no masking in that mode. Below ~120Hz the shimmer is clearly visible, and the library warns below 100Hz measured refresh.

This argument has not had a real accessibility review. It reads plausibly — contrast reversal rather than full-field flash, small area, zero-mean per frame — but plausible is not assessed. Get one before shipping this anywhere public.

Demo

Live: https://acieshk.github.io/nocap/ — or npm run demo, then http://127.0.0.1:8787/.

One page: the live/screenshot/denoise comparison, a playground with presets and every parameter, checks that search each page surface for the secret on screen, and both DevTools attacks running for real.

Test

npm test

45 tests: the planes average back to the source at every amplitude and mode, no plane leaks, clipping never breaks the zero-sum property, the leak ordering holds, adaptive colour is exact, and coarse blocks resist a blur that fine noise does not.

License

MIT