Security
A narrow promise, held at the wire.
The useful security review is not “is it private?” but “what exactly does the guard stop, and what is still the integrator’s job?” This page is specific about both, including the parts the library cannot fix.
The promise
The whole design is stated plainly in SECURITY.md: raw private text never crosses the wire. Detection, the vault and rehydration run on the device; only an approved, labelled payload is sendable. The documented limit sits on top of that promise: values outside the fixed ruleset are not detected, so the review step is what catches the rest.
- Only placeholders for recognized values leave. The provider call receives the text the reviewer approved, with detected values replaced by labels like
[CARD_1]. - The send is type-enforced. Provider adapters accept only a branded
RedactedPayload; the redaction pipeline is its only legitimate public constructor. - The send is also runtime-enforced. Approved payloads are registered by object identity, and every provider calls
assertApprovedbefore making a request. - The restore is local and bound.
rehydrateruns after the reply, checks the payload’s vault reference, and fails closed on a wrong vault or an unresolvable token. - The escape hatch is audited.
unsafeBypassis the only way to make raw text sendable, and it always emits anAuditEntry. - Receipts are opt-in and hash-only. They are Ed25519-signed through
@gainratio/avowand contain a SHA-256 digest, a decision and a provider name, never the text.
The egress guard
This is the load-bearing boundary, and it has two independent halves:
- Compile time.
RedactedPayloadis a branded type. A plainstringis not assignable to it, so handing raw text to a provider is aTS2345compile error. The brand factorymintPendingRedactionis deliberately not exported from the public barrel.pnpm buildre-proves the compile-time half on every build. - Runtime. TypeScript’s brand disappears at runtime, so every approved payload is registered in a module-private
WeakSetby identity.assertApprovedrejects a hand-built or spread-cloned look-alike withUnapprovedPayloadErrorbefore any network call.
An unapproved payload is refused, not warned about. A redaction with no detections is still not sendable; it passes through approve() like any other. The audit sink is required, so an approval nobody can observe is not an approval this boundary grants.
The one exception is explicit by name: unsafeBypass(raw, reason, audit) always writes an audit entry recording the human-supplied reason. A bypass that leaves no audit trail is a vulnerability, not a feature.
What the tests prove
These are the repository’s own claims, each tied to a test you can run with pnpm gate:
| Claim | Backed by |
|---|---|
| Only labels cross the network | pnpm test:e2e drives the browser demo in real Chromium, intercepts the outbound request and fails if any real value is in it. |
| Every format in the recognized table is redacted at the wire | test/detector-completeness.test.ts drives each documented format through the real send path. |
| Nothing that v0.2.2 redacted stops being redacted | test/v022-recall-floor.test.ts carries a frozen copy of the v0.2.2 recognizers. |
| A raw string cannot reach a provider | test/brand-compile-proof.test.ts and pnpm build. |
| Hostile input stays fast: 512 KiB of card-, IBAN- or email-shaped text in under 1.5 seconds (100–250 ms measured on a CI-class Linux container, Node 22) | test/detection-regressions.test.ts, timed outside coverage instrumentation. |
| Every source line and branch is exercised | Vitest coverage thresholds pinned at 100% in pnpm gate. |
What the tests do not prove is below. A green suite is not a claim that the library catches everything, anonymises text, or survives a compromised host.
Boundaries
Over-claiming privacy is worse than claiming none. These are the limits, stated as boundaries rather than caveats:
| Not provided | Why |
|---|---|
| Detection beyond the ruleset | The detector is regex, checksums and two small dictionaries. It misses oddly formatted account numbers, unusual names, and kinds of private data nobody wrote a rule for. The built-in name list is three demo names; general name detection is not shipped. |
| Anonymity | Even with names and numbers stripped, the shape of the text can identify a person. “$482.10, insurance, early January” can point at one person with no identifier left in it. This reduces direct identifier leakage; it is not anonymisation. |
| Protection from a compromised device | A malicious browser extension, a cross-site scripting bug or a compromised dependency can read the in-memory vault. There is no secure element involved. |
| Hardware-backed browser key custody | Signing keys held in a browser are protected by the browser’s same-origin rules and nothing stronger. Anything that can run code on your origin can reach them. |
| Persistence | The vault is ordinary process or tab memory and clears on reload. An encrypted, stored vault is planned, not shipped. |
| Control over the provider | What the AI company does with the labelled text it receives is outside this library. |
| Host attestation from receipts | A signature proves a record has not changed since it was signed. It does not prove the signing host was uncompromised, and it is not a replay-prevention token. |
If any of these limits is unacceptable for what you are doing, this is the wrong tool. The repository says so directly rather than working around it.
Receipts: evidence, not host attestation
Receipts are opt-in. Hand guardedProvider a governance context — provider name, signing seed and an onReceipt sink — and every decision it makes is signed with Ed25519 through @gainratio/avow. Refusals are recorded too, so a blocked send cannot simply vanish. A receipt contains a SHA-256 hash of the redacted text, not the text itself.
- What a signature proves. The record has not been altered since it was signed. It says nothing about whether the machine that signed it was already compromised; an attacker controlling the host can make it sign a true-looking record of a decision you never wanted.
- Replay and counting. The signed envelope is deterministic, so two identical decisions produce identical receipts. A downstream audit store must track receipt occurrences if it needs to count sends rather than only verify content.
- Legacy receipts. Receipts stored by 0.2.x have no
schemafield.verifyEgressReceiptlabels them{ legacy: true, compat: "pre-v1-schema" }, and avow 0.5.3’sverifySignatureaccepts them as they are. The legacy flag is advisory only:schemais not part of the signed payload, so anyone can add or remove it without breaking the signature. Do not base a trust decision on it. - Key handling. Your signing seed is passed in by the host. Browser-held keys are same-origin, not hardware-backed. The bundled demo uses a fixed, documented non-secret seed only to make its receipts reproducible; a real deployment keeps the seed server-side.
There is a runnable allow-and-deny receipts example if you want the shape without the theory.
What counts as a security report
The parts worth probing are the boundary itself. In-scope reports include:
- Any way to construct or forge a
RedactedPayloadoutsideredactForEgressandapprove. - Any path that puts raw, un-redacted text on the wire.
- A bypass that skips the audit sink.
- Vault contents escaping the local device.
Known, documented limits are not vulnerabilities: detection is deterministic (regex + checksums + dictionaries), so recall is bounded and the human-visible preview is the backstop for anything the ruleset misses. The contextual NER tier that would widen recall is deliberately deferred.
Report a vulnerability
Please report security issues privately — do not open a public issue.
- Preferred: open a GitHub private security advisory (Security → Report a vulnerability).
- Or email harish.seshadri@gmail.com with
SECURITYin the subject.
Useful things to include: what you found, how to reproduce it, the affected file or API, and the impact you think it has. A proof of concept helps but is not required.
What to expect: an acknowledgement within a few days, and an honest assessment of severity and fix timeline. This is a solo-maintained open-source reference project, not a funded product. There is no bug-bounty payout, but credit in the fix or release notes is offered unless you would rather stay anonymous. Please give a reasonable window to ship a fix before any public disclosure.
Supported versions
Fixes land on the latest release on main. Older tagged releases are not patched — upgrade to the current release. 0.3.2 is the current release; 0.3.1 was the first under @gainratio/privacy-core, and 0.3.0 and older shipped as @edgeproc/privacy-core.
Supply chain
Releases are published from CI with npm provenance — an SLSA build attestation linking the tarball to this repository’s workflow. You can check the attestation for the current release:
npm view @gainratio/privacy-core@0.3.2 dist.attestations
And in a project that installed it:
npm audit signatures
The only runtime dependency is @gainratio/avow, used to sign the optional receipts. The library itself reads no environment variables and ships no server component.
Read next
- Deployment guidance, including where to run the boundary and how to keep a provider key server-side.
- The checks you can run, including the Playwright wire proof.
- SECURITY.md and ARCHITECTURE.md in the repository.