npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mrtinkz/tessera

v0.1.6

Published

One passcode. Your browser storage, encrypted. Zero dependencies.

Downloads

696

Readme

A zero-dependency TypeScript/JavaScript library (~10 KB gzip) that encrypts everything you write to browser storage — and plants decoy tripwires to catch anyone who goes looking.

import { Tessera } from '@mrtinkz/tessera';

const vault = await Tessera.unlock('my-passcode');
await vault.local.setItem('cart', JSON.stringify(cartData));
const cart = await vault.local.getItem('cart'); // decrypted, plaintext
vault.lock(); // zeroes the in-memory key

Honey keys — tessera's defining feature. After every write, the vault plants N decoy entries alongside your real data. They look byte-for-byte identical to real encrypted keys. Any code that touches one — an XSS payload enumerating storage, a malicious extension, an automated scraper — triggers the suspicion engine and can lock the vault and wipe sensitive data before anything useful is read. No other browser storage library does this.

const vault = await Tessera.unlock(passcode, { honeyKeys: { count: 5 } });

vault.on('honey-triggered', ({ backend, score }) => {
  // something just touched a decoy — suspicion score climbing
});

Everything else you'd expect, done properly:

  • AES-256-GCM encryption on localStorage, sessionStorage, IndexedDB, and cookies — key derived from the user passcode via PBKDF2-SHA-256 (310,000 rounds), never leaves the browser
  • Suspicion engine — scores HMAC failures, honey hits, brute-force attempts, and rate anomalies; locks down and wipes on threshold breach
  • Per-key TTL, max-reads, and half-life — keys self-destruct by time or access count; soft half-life prompts re-authentication, hard half-life deletes unconditionally
  • Sensitivity levelslow · medium · high · critical; targeted or full wipes respect the tier
  • Storage modesdirect (in-place), claim (cookie pointer → IDB payload), split (XOR-split across two backends)
  • PIN pad — canvas-rendered, digit-shuffled on every render to defeat shoulder-surfing and input-event sniffing
  • Framework integrations — React, Vue 3, Svelte, Angular
  • Zero dependencies — ~10 KB gzip

Contents


What is tessera?

When you store data in localStorage or sessionStorage, any JavaScript on the page can read it. That means an XSS attack, a malicious browser extension, or a curious developer opening DevTools can see everything.

tessera solves this by encrypting every value before it touches storage. The only way to read the data back is to supply the same passcode that encrypted it. Without the passcode, all an attacker sees is random-looking base64.


When to use tessera

Use tessera when the alternative is doing nothing.

Most web apps store user data in browser storage with no protection at all — plain text, readable by any script on the page. tessera closes that gap without requiring a backend, a paid auth service, or a complex integration.

It is a good fit for:

  • SaaS tools that store tokens, preferences, or user state in localStorage with no encryption today
  • Apps where a full IAM integration is more than the threat model actually needs
  • Teams that want real encryption without a server dependency or a monthly bill

Do not use tessera as a substitute for:

  • Server-verified identity — if you need to know a user is who they say they are, that check has to happen on a server
  • Session revocation across devices — tessera is local to one browser; it cannot reach other sessions
  • Compliance-grade access control (HIPAA, PCI, SOC 2) — those require IAM, audit logs, and server-side enforcement

tessera is not trying to replace any of those. It fills the gap between "no protection" and "full auth stack" — which is exactly where most apps live. A stolen storage dump is worthless. An attacker who tries to enumerate storage trips the honey key system. Key rotation, HMAC integrity, TTL, sensitivity tiers — all the things most teams never get around to building themselves, bundled in 10 KB with zero dependencies.

Libraries that know their scope are more trustworthy than ones that claim to solve everything. tessera knows its scope.


Installation

npm install @mrtinkz/tessera

CDN (no bundler needed):

<script src="https://cdn.jsdelivr.net/npm/@mrtinkz/tessera/dist/index.global.global.js"></script>
<script>
  const { Tessera, renderPinPad } = TesseraLib;
</script>

Quick Start

The simplest possible example

import { Tessera } from '@mrtinkz/tessera';

// 1. Unlock — derives the encryption key from the passcode
const vault = await Tessera.unlock('my-passcode');

// 2. Write encrypted data
await vault.local.setItem('username', 'alice');
await vault.session.setItem('token', 'eyJ...');
await vault.cookie.set('theme', 'dark');
await vault.idb.put('orders', 'order-42', { items: [...] });

// 3. Read it back (automatically decrypted)
const username = await vault.local.getItem('username'); // 'alice'
const token    = await vault.session.getItem('token');  // 'eyJ...'
const theme    = await vault.cookie.get('theme');       // 'dark'
const order    = await vault.idb.get('orders', 'order-42');

// 4. Lock — the in-memory key is gone; data is inaccessible until unlock
vault.lock();

Unlock with all options

const vault = await Tessera.unlock('my-passcode', {
  // --- Key derivation ---
  iterations: 310_000, // PBKDF2-SHA-256 rounds. Minimum 310 000 (OWASP 2024).
  // Higher = slower brute-force. Default: 310 000.

  // --- Session ---
  idleTimeout: 900_000, // Auto-lock after 15 min of no reads/writes. Default: 15 min.

  // --- Lockout ---
  lockoutAttempts: 5, // Wrong passcodes before lockout. Default: 5.
  lockoutAction: 'wipe', // 'wipe' clears all storage on lockout.
  // 'delay' applies exponential backoff (default).
  // 'throw' permanently locks (no wipe).
  lockoutDelay: 30_000, // Initial backoff delay for 'delay' action. Doubles each time.

  // --- Defaults applied to every stored key ---
  defaultSensitivity: 'medium',
  defaults: {
    ttl: 3_600_000, // Keys expire after 1 hour.
    maxReads: 50, // Keys self-destruct after 50 reads.
    onSuspicion: 'wipe', // What to do on HMAC failure: 'wipe' | 'lock' | 'throw'.
  },

  // --- Honey keys (decoy tripwires) ---
  honeyKeys: { count: 3 }, // Add 3 decoy entries to localStorage. Default: 3.

  // --- Half-life (time-based re-authentication) ---
  halfLife: {
    soft: 300_000, // After 5 min: require vault.reconfirm() before access.
    hard: 900_000, // After 15 min: key is deleted regardless.
  },

  // --- Suspicion engine ---
  suspicion: {
    platform: 'desktop', // 'auto' | 'desktop' | 'mobile'
    thresholds: { lockdown: 100 },
  },
});

Framework Integrations

React

// 'use client' is required for Next.js App Router
'use client';
import { useTessera } from '@mrtinkz/tessera/react';
import { renderPinPad } from '@mrtinkz/tessera';

function App() {
  const { vault, isLocked, unlock, lock } = useTessera({ idleTimeout: 600_000 });

  if (isLocked) {
    return (
      <div
        ref={(el) => {
          if (el) renderPinPad(el, { onUnlock: unlock, randomize: true, length: 6 });
        }}
      />
    );
  }

  return <Dashboard vault={vault} onLock={lock} />;
}

Vue 3

<script setup lang="ts">
import { useTessera } from '@mrtinkz/tessera/vue';
import { renderPinPad } from '@mrtinkz/tessera';
import { ref, onMounted } from 'vue';

const { vault, isLocked, unlock, lock } = useTessera({ idleTimeout: 600_000 });
const pinRef = ref<HTMLDivElement | null>(null);

onMounted(() => {
  if (pinRef.value) {
    renderPinPad(pinRef.value, { onUnlock: unlock, randomize: true, length: 6 });
  }
});
</script>

<template>
  <div v-if="isLocked" ref="pinRef" />
  <Dashboard v-else :vault="vault" @lock="lock" />
</template>

Svelte / SvelteKit

<script lang="ts">
  import { onMount } from 'svelte';
  import { tesseraStore } from '@mrtinkz/tessera/svelte';
  import { renderPinPad } from '@mrtinkz/tessera';

  const { vault, isLocked, unlock, lock } = tesseraStore({ idleTimeout: 600_000 });
  let pinEl: HTMLDivElement;

  onMount(() => {
    renderPinPad(pinEl, { onUnlock: unlock, randomize: true, length: 6 });
  });
</script>

{#if $isLocked}
  <div bind:this={pinEl} />
{:else}
  <Dashboard vault={$vault} on:lock={lock} />
{/if}

Angular

// app.module.ts
import { TesseraModule } from '@mrtinkz/tessera/angular';

@NgModule({
  imports: [TesseraModule.forRoot({ idleTimeout: 600_000 })],
})
export class AppModule {}

// component
import { TesseraService } from '@mrtinkz/tessera/angular';

@Component({ ... })
export class MyComponent {
  constructor(private tessera: TesseraService) {}

  async save(key: string, value: string): Promise<void> {
    await this.tessera.vault?.local.setItem(key, value);
  }
}

Core Concepts

The passcode

The passcode is the secret that unlocks the vault. tessera runs it through PBKDF2-SHA-256 (≥ 310 000 iterations) with a random salt to derive the AES-256-GCM encryption key. The raw passcode is never stored anywhere — only the derived key lives in memory.

  • Minimum length: 6 characters
  • No maximum length: passphrases, GUIDs, API keys, and PIN numbers all work
  • First unlock stores an encrypted sentinel so wrong passcodes are detected on all future unlocks
  • The key is non-extractable — the Web Crypto API prevents JavaScript from ever reading the raw key bytes

The vault

Tessera.unlock() returns a vault object with four storage adapters:

| Adapter | Usage | | --------------- | --------------------------------------------------------------------- | | vault.local | localStorage — persists across sessions | | vault.session | sessionStorage — cleared when the tab closes | | vault.cookie | Cookies — survives page reloads; name stays plain, value is encrypted | | vault.idb | IndexedDB — best for large objects; named object stores |

Key rotation

Developer-facing key names (e.g. 'cart') are never written to storage as-is. tessera runs the developer name through HMAC-SHA256 (keyed with a separate PBKDF2-derived HMAC key) to produce a deterministic, random-looking storage key: t_ + 32 hex chars. This prevents key name enumeration — an attacker cannot tell which keys are in storage, or even how many real keys there are.

Locking

Calling vault.lock() immediately discards the in-memory key. Any subsequent getItem or setItem call returns null / throws LOCKED. The encrypted data remains in storage; it becomes accessible again on the next Tessera.unlock() with the correct passcode.


Configuration Reference

All options are optional; defaults are shown.

| Option | Type | Default | Description | | ------------------------------- | ------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | iterations | number | 310_000 | PBKDF2-SHA-256 iteration count. Must be ≥ 310 000 (OWASP 2024). Increase for higher security on fast hardware. | | idleTimeout | number (ms) | 900_000 | Auto-lock after this many milliseconds of inactivity. Resets on every read/write. Values below 1 000 ms emit console.warn — the vault can lock between async adapter operations, causing silent null returns from getItem. | | lockoutAttempts | number | 5 | Failed Tessera.unlock() calls before lockout fires. Clamped to [3, 20] — values outside this range are silently corrected. | | lockoutAction | 'wipe' \| 'delay' \| 'throw' | 'delay' | wipe — clears all storage and throws LOCKOUT. delay — exponential backoff (no data loss). throw — throws LOCKOUT immediately, permanently. | | lockoutDelay | number (ms) | 30_000 | Starting backoff delay for 'delay' action. Doubles on each lockout trigger. | | defaultSensitivity | 'low' \| 'medium' \| 'high' \| 'critical' | 'medium' | Sensitivity preset applied to every key that does not specify its own. | | defaults.ttl | number (ms) | — | Default time-to-live for all keys. Keys silently expire and self-delete after this duration. | | defaults.maxReads | number | — | Default read limit. Keys self-delete after this many reads. | | defaults.onSuspicion | 'wipe' \| 'lock' \| 'throw' | 'wipe' | What to do when an HMAC integrity check fails on a stored value. | | honeyKeys.count | number | 3 | Number of decoy entries planted in the same backend after each write. Set to 0 to disable. | | halfLife.soft | number (ms) | — | After this duration, reads require vault.reconfirm(passcode) before succeeding. | | halfLife.hard | number (ms) | — | After this duration, the key is deleted unconditionally. | | suspicion.platform | 'auto' \| 'desktop' \| 'mobile' | 'auto' | Tunes visibility-change sensitivity for mobile vs desktop usage patterns. | | suspicion.thresholds.lockdown | number | 100 | Suspicion score that triggers vault lockdown and a full wipe of all encrypted entries across every backend. | | vaultId | string | 'default' | Namespace for this vault. Change this to run multiple independent vaults on the same origin without collision. See Multiple Vaults. | | cspCheck | 'warn' \| 'require' \| false | 'warn' | warn — emits csp-warning if no CSP is detected. require — throws UNSUPPORTED_ENV if no CSP is found. false — disables the check (use when CSP is set via HTTP header). | | debug | boolean | false | Enables vault.local.getRawKey() / vault.session.getRawKey() for storage key inspection. Keep false in production — it exposes the alias→key mapping. | | selectiveKeys | string[] | — | When set, only keys in this list can be written or read. Acts as an allowlist for the whole vault session. | | maxValueBytes | number | — | Maximum plaintext value size in bytes. Writes exceeding this limit throw VALIDATION_ERROR before encryption. Applied across all four adapters. | | onBeforeWrite | (key: string, value: string) => boolean | — | Write-time validation hook. Return false to abort the write and throw VALIDATION_ERROR. Receives the developer alias (pre-rotation) and plaintext value. | | maxUnlockDurationMs | number (ms) | — | Absolute vault-open duration ceiling, independent of idle-timeout resets. The vault locks once it has been open for this long, even with ongoing reads/writes. | | honeyKeys.maxPerBackend | number | 500 | FIFO eviction cap on the in-memory honey key registry per backend. Prevents unbounded Set growth in long-lived sessions with high write volume. | | suspicion.persistScore | boolean | false | Persist the suspicion score across page reloads as an HMAC-signed snapshot. Decay-adjusted on unlock() — idle time naturally reduces the score. | | contextBinding.webauthn | boolean | false | Require a WebAuthn platform authenticator (TouchID / FaceID / Windows Hello) as a second factor. Enrolled on first unlock; asserted on every subsequent unlock. Origin-bound and hardware-backed. | | contextBinding.onMismatch | 'throw' \| 'lock' \| 'wipe' | 'throw' | Action when the WebAuthn assertion fails. Only applies when contextBinding.webauthn is true. |


Per-Key Options

Every setItem / put call accepts an options object that overrides the vault-level defaults for that key only.

await vault.local.setItem('session-token', token, {
  sensitivity: 'critical', // overrides defaultSensitivity
  ttl: 900_000, // self-delete after 15 min
  maxReads: 1, // one-time read (burn-after-reading)
  onSuspicion: 'lock', // lock vault on HMAC failure instead of wiping
  halfLife: {
    soft: 300_000, // require reconfirm after 5 min
    hard: 600_000, // auto-wipe after 10 min
  },
});

| Option | Type | Description | | --------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------- | | sensitivity | SensitivityLevel | 'low' / 'medium' / 'high' / 'critical'. Controls default TTL, maxReads, and half-life profiles. | | ttl | number (ms) | Key expires and self-deletes after this duration from write time. | | maxReads | number | Key self-deletes after this many successful reads. Useful for one-time tokens. | | onSuspicion | 'wipe' \| 'lock' \| 'throw' | Action on HMAC failure: delete the key, lock the vault, or silently return null. | | halfLife.soft | number (ms) | Read returns null and emits reconfirmation-required after this duration; resumes after vault.reconfirm(). | | halfLife.hard | number (ms) | Key is deleted unconditionally after this duration from write time. | | mode | 'direct' \| 'claim' \| 'split' | sessionStorage and cookie adapters only — see Storage Modes. |


Sensitivity Levels

Sensitivity presets apply a bundled set of defaults. Per-key options always override the preset.

| Level | TTL | Max reads | Soft half-life | Notes | | ------------ | ------ | --------- | -------------- | ------------------------------------------------- | | 'low' | none | none | none | Suitable for preferences, theme settings | | 'medium' | 1 hour | 50 | none | Default. Suitable for shopping carts, form drafts | | 'high' | 15 min | 10 | 5 min | Suitable for session tokens, user IDs | | 'critical' | 5 min | 3 | 1 min | Suitable for OTPs, private keys, PII |

When the vault goes on suspicion lockdown, all encrypted entries are wiped across every backend — including honey keys — to prevent an attacker from identifying real keys by elimination.


Storage Modes

vault.session and vault.cookie support three storage modes, set via options.mode.

'direct' (default)

The encrypted value lives directly in sessionStorage / the cookie. Simple and fast.

await vault.session.setItem('draft', content, { mode: 'direct' });

'claim'

A short, opaque claim token lives in sessionStorage / the cookie. The actual encrypted value lives in IndexedDB. Useful when the value is large (cookies have a 4 KB limit) or when you want the session-side to be just a reference.

await vault.session.setItem('large-blob', data, { mode: 'claim' });
// sessionStorage gets a tiny ref: pointer → IDB has the real ciphertext

'split'

The value is XOR-split into two shares. Share A lives in sessionStorage / the cookie; Share B lives in IndexedDB. Neither share alone can reconstruct the value.

await vault.session.setItem('secret', value, { mode: 'split' });
// Requires both sessionStorage AND IndexedDB to read back

Events

Subscribe to vault events to react to security incidents, expirations, and state changes.

vault.on('vault-locked', ({ reason }) => showLoginScreen(reason));
vault.on('auto-locked', ({ reason }) => showLoginScreen(reason));
vault.on('key-expired', ({ keyAlias, backend }) =>
  console.log(`${keyAlias} expired in ${backend}`),
);
vault.on('max-reads-reached', ({ keyAlias }) => console.log(`${keyAlias} burned after max reads`));
vault.on('hmac-failure', ({ keyAlias }) => console.warn(`Integrity failure on ${keyAlias}`));
vault.on('honey-triggered', ({ backend, score }) =>
  console.warn('Honey key accessed', { backend, score }),
);
vault.on('suspicion-lockdown', ({ reason, score, keysWiped }) => {
  console.error('Vault locked down!', { reason, score, keysWiped });
});
vault.on('reconfirmation-required', ({ keyAlias }) => {
  // Prompt the user to re-enter their passcode
  promptReconfirm().then((p) => vault.reconfirm(p));
});
vault.on('rate-limit-warning', ({ callsPerSecond }) => {
  console.warn(`High read rate: ${callsPerSecond}/s`);
});

// Remove a listener
vault.off('vault-locked', myHandler);

All events

| Event | Payload | When | | ------------------------- | ------------------------------------------ | -------------------------------------------------- | | vault-unlocked | { mode: 'normal' \| 'reconfirm' } | After successful unlock() or reconfirm() | | vault-locked | { reason: string } | On lock(), idle timeout, or lockdown | | auto-locked | { reason: 'idle-timeout' } | On idle timeout specifically | | key-expired | { keyAlias, backend, expiredAt } | TTL or hard half-life elapsed | | max-reads-reached | { keyAlias, backend, reads } | Read limit exhausted | | hmac-failure | { keyAlias, backend } | Decryption integrity check failed | | honey-triggered | { backend, score } | A decoy honey key was accessed | | suspicion-lockdown | { reason, score, keysWiped } | Suspicion score crossed the lockdown threshold | | reconfirmation-required | { keyAlias, softThresholdMs, elapsedMs } | Soft half-life elapsed; vault.reconfirm() needed | | rate-limit-warning | { callsPerSecond, threshold } | Read rate exceeded soft limit | | storage-quota-warning | { backend, usedBytes, quotaBytes } | Storage near quota (IndexedDB only) |

Re-authentication (vault.reconfirm)

When a reconfirmation-required event fires, the key is still in storage but tessera requires the user to re-verify their identity before returning the value. Call vault.reconfirm(passcode) with the correct passcode to resume access.

vault.on('reconfirmation-required', async ({ keyAlias }) => {
  const passcode = await promptUser(`Re-enter passcode to access ${keyAlias}`);
  try {
    await vault.reconfirm(passcode);
    // Retry the original read — it will succeed now
  } catch {
    // Wrong passcode — handle gracefully
  }
});

Scoped Vault

vault.scope(keys, ops?) returns a lightweight proxy that restricts which key names can be used and which operations (read / write) are permitted. Pass it to a sub-component that should only touch a subset of the vault's data.

// This component can only read 'theme' and 'locale' — it cannot write them
// and cannot access any other key.
const readOnly = vault.scope(['theme', 'locale'], ['read']);
await readOnly.local.getItem('theme'); // ✓ allowed
await readOnly.local.setItem('theme', 'dark'); // ✗ throws PERMISSION_DENIED
await readOnly.local.getItem('token'); // ✗ throws PERMISSION_DENIED

// Default: all ops allowed, key list enforced
const limited = vault.scope(['cart', 'draft']);
await limited.local.setItem('cart', '...'); // ✓
await limited.local.getItem('token'); // ✗ PERMISSION_DENIED

Note: vault.scope() is a JavaScript-only guard, not a cryptographic boundary. Code that holds a reference to the original vault can still access every key. Use it for developer ergonomics and component isolation, not for security between mutually distrusting modules.


Multiple Vaults

By default every Tessera.unlock() call connects to the same vault namespace (vaultId: 'default'). Use a different vaultId to run independent vaults on the same origin — for example, one vault per tenant in a multi-tenant app.

const adminVault = await Tessera.unlock(adminPasscode, { vaultId: 'admin' });
const userVault = await Tessera.unlock(userPasscode, { vaultId: 'user' });

// Storage keys, IDB database name, and lockout counters are fully isolated.
// Honey keys from one vault never trip the other.
await adminVault.local.setItem('config', JSON.stringify(cfg));
await userVault.local.setItem('config', JSON.stringify(userCfg));

Each vaultId gets its own localStorage key prefix, its own IDB database (tessera_vault_<id>), and its own lockout record. There is no sharing between vaults.


Developer Introspection

exportItem(key)

vault.local.exportItem(key) and vault.session.exportItem(key) return the decrypted value and its full metadata snapshot without incrementing the readCount. This is useful during development for inspecting what tessera has stored.

const snapshot = await vault.local.exportItem('session-token');
console.log(snapshot);
// {
//   value: 'eyJ...',
//   writeTime: 1716000000000,
//   readCount: 3,
//   ttl: 900000,
//   sensitivity: 'high',
//   ...
// }

exportItem does not expose the raw storage key (t_abc123...). If you need that for debugging, unlock with debug: true and call vault.local.getRawKey(alias).

Debug mode

const vault = await Tessera.unlock(passcode, { debug: true });
const storageKey = await vault.local.getRawKey('cart');
// → 't_4a8f3c2e1b...'

Keep debug: false (the default) in production. With the flag off, getRawKey() throws — this closes an enumeration shortcut an attacker with vault access could use to distinguish real keys from honey keys.

signChallenge(challenge, expiresAt)

vault.signChallenge(challenge, expiresAt) produces an HMAC-SHA256 proof that the vault was opened within a server-issued time window. Use it for server-enforced session binding without ever transmitting the vault key.

// Server issues a short-lived challenge (e.g. from your auth API):
const { nonce, expiresAt } = await fetchServerChallenge();
// nonce: Uint8Array, expiresAt: Unix timestamp in ms

// After unlock, produce the proof:
const proof = await vault.signChallenge(nonce, expiresAt);

// Send to server — it verifies:
//   • the vault was opened (HMAC is producible only with the derived key)
//   • the challenge has not expired (client-side: throws LOCKOUT if Date.now() >= expiresAt)
//   • replay is impossible (nonce is single-use)
await sendProofToServer(proof);

Set a generous expiresAt window (≥ 5 minutes) to absorb minor clock drift between client and server.

renderFingerprint(canvas, position?)

vault.renderFingerprint(canvas) draws a deterministic visual trust indicator onto a canvas element. The indicator is derived from HMAC-SHA256(hmacKey, 'visual-fingerprint'), producing a unique symmetric 5×5 identicon for each vault passcode. The same passcode always produces the same icon; a wrong passcode — or a phishing page that cannot know the passcode — produces a visually distinct one.

const canvas = document.getElementById('fingerprint') as HTMLCanvasElement;
await vault.renderFingerprint(canvas);

// Optional position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center'
await vault.renderFingerprint(canvas, 'top-right');

Display the fingerprint immediately after unlock — before showing any sensitive content — so the user can verify they opened the correct vault.


PIN Pad

tessera ships a canvas-based PIN pad that mitigates keylogging and click-recording attacks. Digit positions are re-randomised after every completed entry; no DOM element carries a digit label that a script could read.

import { renderPinPad } from '@mrtinkz/tessera';

const cleanup = renderPinPad(document.getElementById('pin')!, {
  onUnlock: async (passcode) => {
    try {
      const vault = await Tessera.unlock(passcode);
      showApp(vault);
    } catch (err) {
      showError(err.message);
    }
  },
  onError: (remaining) => {
    showMessage(`${remaining} attempts remaining`);
  },
  randomize: true, // re-shuffle digit positions on every render (strongly recommended)
  length: 6, // digits required — clamped to [6, 16]
});

// Call cleanup() when the PIN pad unmounts (e.g. React useEffect return)
cleanup();

PIN pad length

| Scenario | Recommended length | Notes | | ----------------------- | ------------------ | ------------------------------------------------------ | | Consumer app PIN | 6 | Minimum enforced by the library | | Banking / high-security | 8–10 | Balance between security and UX | | Internal tools | 12–16 | Hard upper limit for human-entered PINs | | Programmatic unlock | — | Use Tessera.unlock(apiKey) directly; no length limit |

The canvas PIN pad only handles digit input (0–9). For passphrase-style unlock (letters, symbols), use a regular <input type="password"> wired to Tessera.unlock().

Theming

.tessera-pin-pad {
  --tessera-pad-bg: #1a1a2e;
  --tessera-btn-bg: #16213e;
  --tessera-btn-color: #e2e8f0;
  --tessera-btn-hover: #0f3460;
  --tessera-btn-size: 64px;
  --tessera-indicator-color: #4ade80;
}

Canvas exfiltration protection

After the first render, tessera overrides canvas.toDataURL → '' and canvas.toBlob → no-op on the PIN pad canvas element. This closes the XSS exfiltration path where attacker code screenshots the canvas to reconstruct the zone map (which coordinate maps to which digit). Both are restored to the native prototype methods when the returned cleanup function is called.


Honey Keys

After every write, tessera plants N decoy entries in the same backend. These entries look byte-for-byte identical to real encrypted keys (t_ + 32 hex chars with plausible-looking ciphertext). Any code path that touches one increments the suspicion score.

// Enable 5 honey keys (default is 3)
const vault = await Tessera.unlock(passcode, {
  honeyKeys: { count: 5 },
});

// Listen for honey access
vault.on('honey-triggered', ({ backend, score }) => {
  console.warn(`Honey key accessed on ${backend}. Suspicion score: ${score}`);
});

Native storage proxy

Honey keys also work for code that never goes through the tessera API. At unlock time, tessera installs a thin proxy on localStorage.getItem and sessionStorage.getItem. An XSS payload that iterates localStorage, a browser extension enumerating all storage, or a DevTools snippet that reads keys directly — all of them will trip honey detection automatically.

// This fires 'honey-triggered' even though it never called vault.local.getItem:
window.localStorage.getItem('t_some_key');

The proxy is removed when the vault locks, terminates, or goes into lockdown.

Orphan cleanup

On vault.lock() and vault.terminate(), the in-memory honey registry is cleared. The decoy storage entries persist until the next Tessera.unlock(), which runs orphan cleanup in the background and silently removes any stale decoys from previous sessions.


Suspicion Engine

tessera tracks a running suspicion score and locks down the vault if anomalous behaviour is detected. Score contributions:

| Event | Score added | Notes | | ------------------------- | ----------- | ----------------------------------- | | HMAC integrity failure | +100 | Ciphertext tampered or key mismatch | | Honey key access | +50 | Possible storage enumeration | | Passcode failure | +20 | Brute-force attempt | | Rate limit excess | varies | Automated read loop | | Visibility-change anomaly | +5 | Tab hidden for suspicious duration |

When the score reaches the lockdown threshold (default 100), tessera:

  1. Locks the vault immediately
  2. Wipes all encrypted entries from every backend — including honey keys — so an attacker cannot identify real keys by seeing which ones survived
  3. Emits suspicion-lockdown with the list of wiped keys
const vault = await Tessera.unlock(passcode, {
  suspicion: {
    thresholds: { lockdown: 150 }, // raise the threshold
    platform: 'mobile', // more lenient visibility-change scoring
  },
});

vault.on('suspicion-lockdown', ({ reason, keysWiped }) => {
  console.error(`Vault locked: ${reason}. Wiped: ${keysWiped.join(', ')}`);
  redirectToLoginPage();
});

Cross-session score persistence

By default the suspicion score resets to zero on every page reload. Enable suspicion.persistScore to carry the score across sessions. The score is HMAC-signed and written to localStorage on every increment; on unlock() it is verified, loaded, and exponential-decay-adjusted so that idle time reduces the score between sessions.

const vault = await Tessera.unlock(passcode, {
  suspicion: {
    persistScore: true, // reload-resilient threat memory
  },
});

Best Practices

Passcode strength

// ❌ Too short — brutable in seconds even with PBKDF2
await Tessera.unlock('123456');

// ✓ Reasonable PIN — 8 digits, ~100M combinations
await Tessera.unlock('84729163');

// ✓ Strong — passphrase, no upper limit
await Tessera.unlock('correct-horse-battery-staple');

// ✓ For automated systems — GUID or random hex
await Tessera.unlock(crypto.randomUUID());

Always handle the locked state

const value = await vault.local.getItem('token');
if (value === null) {
  // Could be: key doesn't exist, vault is locked, key expired, or HMAC failure.
  // Always handle null — never assume the vault is unlocked.
  redirectToLogin();
  return;
}

Match sensitivity to the data

// ✓ Use low sensitivity for non-sensitive preferences
await vault.local.setItem('theme', 'dark', { sensitivity: 'low' });

// ✓ Use critical for tokens, PII, keys
await vault.local.setItem('api-key', key, {
  sensitivity: 'critical',
  ttl: 300_000, // 5 minutes
  maxReads: 1, // burn after reading
});

Always terminate when done

// 'lock' keeps the data in storage for next session
// 'terminate' also clears event listeners and the suspicion engine
vault.terminate(); // call this when the user logs out completely

Use reconfirm for sensitive operations

vault.on('reconfirmation-required', async ({ keyAlias }) => {
  // Don't silently fail — tell the user why you need their passcode again
  const passcode = await showReconfirmDialog(`"${keyAlias}" requires re-authentication`);
  await vault.reconfirm(passcode);
});

React to security events

// At minimum, redirect to login on lockdown
vault.on('suspicion-lockdown', () => {
  clearUI();
  redirectToLogin();
});

// Log HMAC failures — they may indicate storage tampering
vault.on('hmac-failure', ({ keyAlias, backend }) => {
  logSecurityEvent({ type: 'hmac-failure', key: keyAlias, backend });
});

Use split or claim mode for sensitive session data

// With mode: 'split', neither sessionStorage NOR IndexedDB alone
// can reconstruct the value — an attacker needs both.
await vault.session.setItem('private-key', key, {
  mode: 'split',
  sensitivity: 'critical',
});

Set lockoutAction: 'wipe' for high-security apps

// If someone exhausts their attempts, wipe everything.
// There is no data worth keeping if someone is brute-forcing the vault.
const vault = await Tessera.unlock(passcode, {
  lockoutAttempts: 5,
  lockoutAction: 'wipe',
});

Never store the passcode

// ❌ Don't do this
localStorage.setItem('my-passcode', passcode);
sessionStorage.setItem('my-passcode', passcode);

// ✓ Derive the key once per session — that is what Tessera.unlock() is for
const vault = await Tessera.unlock(passcode);
// The passcode can be discarded now; the vault holds the derived key

Locking strategy

tessera locks when you tell it to. It does not know whether your user is still at the keyboard, has walked away, or switched tabs — your app does.

Wire vault.lock() to the moments that make sense for your use case:

// Tab hidden — user switched away
document.addEventListener('visibilitychange', () => {
  if (document.hidden) vault.lock();
});

// User logs out
logoutButton.addEventListener('click', () => {
  vault.terminate(); // clears event listeners and the suspicion engine
  redirectToLogin();
});

// React — lock when the component that holds the vault unmounts
useEffect(() => () => vault.lock(), []);

// Route change
router.beforeEach(() => vault.lock());

The idleTimeout option exists as a safety net — it auto-locks after a period with no vault API calls. But your app's own signals are always more accurate than a timer. Use idleTimeout as a fallback, not as your primary locking strategy.

SSR / server-side rendering

tessera requires globalThis.crypto.subtle (the Web Crypto API). In server-rendered frameworks, only call Tessera.unlock() in client-side code:

// Next.js App Router
'use client';

// Vue
onMounted(() => {
  /* unlock here */
});

// SvelteKit
import { browser } from '$app/environment';
if (browser) {
  /* unlock here */
}

Calling tessera on the server will throw UNSUPPORTED_ENV with a clear message explaining the constraint.


Security Model

tessera targets the OWASP browser storage threat model.

| Threat | Protection | Notes | | --------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | T1 Passive storage read (DevTools, file system) | AES-256-GCM encryption | All values are ciphertext; key names are rotated to opaque t_ HMAC hashes | | T2 XSS reading storage | Ciphertext is useless without the derived key | Does not prevent XSS from intercepting the passcode as it is typed | | T3 Keylogger / click recorder | Canvas PIN pad with randomised digit positions | Click coordinates cannot be mapped to digits without the in-closure zone map | | T4 Shoulder-surf | Digit positions re-randomise on every entry | An observer who sees your click positions cannot replay them | | T5 Offline brute force | PBKDF2-SHA-256 ≥ 310 000 iterations + per-value salt | ~1 second per guess on modern hardware; per-value salt defeats rainbow tables | | T6 Lockout record tampering | HMAC-SHA256 signature over the lockout record | The lockout counter is signed with the passcode-derived key; tampering is detected on next unlock | | T7 Key extraction from heap | extractable: false CryptoKey | Raw key bytes can never leave the Web Crypto engine | | T8 On-device brute force | Lockout with configurable wipe/delay/throw | Exponential backoff or complete storage wipe after N failures | | T9 Ciphertext tampering | AES-GCM authentication tag | Any byte-level modification is detected before decryption | | T10 Cross-tab forced lock (DoS) | Authenticated BroadcastChannel messages | Lock messages carry an AES-GCM proof; tabs that do not hold the vault key cannot forge them | | T11 Split share exposure | Share A encrypted before storage | In mode: 'split', Share A is encrypted with the vault key before going to sessionStorage |

What tessera does NOT protect against

  • An open vault during XSS. If an attacker has JavaScript running in your page while the vault is unlocked, they can call vault methods and read decrypted values — the same as any other code on the page can. This is not a tessera limitation; it is how browsers work. Any JavaScript in your page runs with the same permissions you do. What tessera protects is the data at rest: a stolen storage dump, a database backup, a browser extension that reads localStorage — all of those get ciphertext and nothing useful. Lock the vault as soon as it is not needed. See Locking strategy.

  • A targeted, informed attacker in your JS context. The native storage proxy catches naive enumeration scripts — XSS payloads, extensions, and DevTools snippets that iterate localStorage will trip honey detection automatically. But a targeted attacker who calls Storage.prototype.getItem.call(localStorage, key) directly bypasses the proxy. Someone sophisticated enough to do that already has full execution in your page and can keylog the passcode as it is typed. The proxy is a significant barrier against automated attacks; it was never designed to stop a deliberate, informed adversary — that is what IAM and server-side auth are for.

  • Compromised device. If the user's OS or browser is compromised at the system level, all bets are off.

  • Cookie HttpOnly / Secure flags. tessera encrypts cookie values but cannot enforce server-set cookie attributes. Use server-side session cookies for truly sensitive tokens.

  • Cross-origin attacks. tessera does not add CORS or CSP headers — those are your application's responsibility.


Changelog

Latest release: 0.1.6. Honey key cumulative-generation fix. Always use the latest version:

npm install @mrtinkz/tessera@latest

0.1.6

Bug fix — no breaking API changes, no migration required.

| Area | What changed | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Honey key generation (all adapters) | prepareHoneyKeys was computing needed = count − existingHoneyCount, so after the first write filled the pool no new decoys were ever planted. Fixed to always generate count fresh honey keys per write. N writes now produce N × count decoys (subject to maxPerBackend FIFO eviction). |

0.1.5

Security hardening and new features — no breaking API changes, no migration required.

| Area | What changed | | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | vault.signChallenge(challenge, expiresAt) | New vault method. Produces an HMAC-SHA256 proof-of-unlock for server-side challenge-response. Throws LOCKOUT when the challenge window has expired. | | vault.renderFingerprint(canvas, position?) | New vault method. Draws a deterministic identicon derived from HMAC(hmacKey, 'visual-fingerprint'). Same passcode → same icon; wrong passcode or phishing page → visually distinct icon. | | contextBinding | New config option. contextBinding.webauthn: true requires a platform authenticator (TouchID / FaceID / Windows Hello) as a second factor. Enrolled on first unlock; asserted on every subsequent unlock. | | maxUnlockDurationMs | New config option. Absolute vault-open duration ceiling independent of idle-timeout resets. | | honeyKeys.maxPerBackend | New config option (default: 500). FIFO eviction cap on the per-backend honey key Set. Bounds memory in long-lived high-write sessions. | | suspicion.persistScore | New config option (default: false). Persists suspicion score across page reloads as an HMAC-signed, decay-adjusted snapshot. | | maxValueBytes | New config option. Maximum plaintext value size; writes exceeding the limit throw VALIDATION_ERROR before encryption. | | onBeforeWrite | New config option. Write-time validation hook — return false to abort the write. | | Storage prototype proxy | installStorageProxy now also patches Storage.prototype.getItem, catching Storage.prototype.getItem.call(localStorage, key) bypass attempts. | | PIN pad toDataURL / toBlob revocation | renderPinPad overrides both to '' / no-op immediately after the initial draw, closing the XSS canvas-screenshot exfiltration path. Restored on cleanup. | | vaultId validation | resolveConfig() validates against /^[a-zA-Z0-9_-]{1,64}$/ — non-conforming values throw immediately. | | lockoutAttempts clamped to [3, 20] | Values outside this range are silently corrected in applyFloors(). | | idleTimeout < 1 s warning | resolveConfig() emits console.warn — sub-1 s timeouts fire between async adapter operations, causing silent null returns. | | exportItem non-optional | exportItem is now a required method on IStorageAdapter — compile-time guarantee on all four adapters. | | Event handler cap | TesseraEmitter caps handlers per event at 32; excess registrations are silently dropped. | | cleanOrphanedSplits fix | IDB compound-key delete was no-oping with only the key string; fixed to use the full [store, key] compound key. |

0.1.4

Bug fix — no breaking API changes, no migration required.

| Area | What changed | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Honey key post-wipe race | Deferred honey writes (50–2000 ms randomised delay) could race a lockdown: if the AES-GCM op completed after wipeAll cleared the honey registry, the write proceeded and re-added the decoy to storage. Fixed by re-checking isHoney() after the crypto await — discards the write if the registry was already cleared. Affects localStorage, sessionStorage, and cookie adapters. | | Enhancement demo | _simulateHoneyHit was silently a no-op because config.debug was not set. Demo now passes debug: true so the honey-key simulation button works correctly. |

0.1.3

Security hardening — no breaking API changes, no migration required.

| Area | What changed | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | getRawKey gated | vault.local.getRawKey() now throws unless config.debug = true. Without the flag the alias→storage key mapping is opaque, closing the enumeration shortcut an attacker with vault access could use to identify honey keys by elimination. | | Native storage proxy | localStorage.getItem and sessionStorage.getItem are proxied at unlock time. Scripts that enumerate storage natively — XSS payloads, extensions, DevTools snippets — now trip honey detection without going through the tessera API. Proxies are restored on lock(), terminate(), and lockdown. | | exportItem(alias) | New method on vault.local and vault.session. Returns the decrypted value plus full metadata snapshot (writeTime, readCount, ttl, sensitivity, …) without incrementing readCount and without surfacing raw storage keys. Sanctioned replacement for any legitimate developer introspection need. |

0.1.2

Security patch — no breaking API changes, no migration required.

| Area | What changed | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Lockdown wipes all decoys | wipeAll() now nukes every t_-prefixed entry across all backends (localStorage, sessionStorage, cookies, IDB) unconditionally on lockdown. Previously only real high/critical keys were wiped, leaving honey keys intact as identifiable survivors. | | Orphan honey key cleanup | cleanOrphanedHoneyKeys() fires as a background task at every Tessera.unlock(). Honey keys from prior sessions (orphans that the in-memory registry no longer tracks) are detected by their decrypt-OK-but-invalid-JSON signature and silently wiped. |

0.1.1

Security hardening — no breaking API changes, no migration required.

| Area | What changed | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Key-name rotation | Switched from AES-GCM (fixed-IV, breaks GCM contract) to HMAC-SHA256. A separate PBKDF2-derived HMAC key is used so the rotation function is a proper PRF. | | Lockout record | Now HMAC-signed after every successful unlock. The signature is verified on the next unlock; a tampered or replayed counter is treated as a lockout. | | Split Share A | Share A (the XOR pad) is now encrypted with the vault key before being written to sessionStorage — consistent with the rest of vault storage. | | IDB updateMetadata | Metadata updates inside IndexedDB now use a single readwrite transaction, eliminating the TOCTOU race between two sequential connections. | | BroadcastChannel lock | Lock messages now carry an AES-GCM-encrypted proof (encrypt(key, sentinel)). Tabs verify the proof before locking; same-origin pages without the vault key cannot trigger a lock. | | Miscellaneous | Fisher-Yates PIN pad shuffle uses rejection sampling (eliminates modulo bias); claim tokens are now random hex (eliminates sequential-counter IDB collisions); visibility listener is destroyed (not just reset) on lock(); whitespace-only passcodes rejected; cookie wipe cleans up internal registries. |

0.1.0

Initial release.


Browser Support

| Browser | Minimum version | | ------------------ | ---------------- | | Chrome / Edge | 89+ | | Firefox | 86+ | | Safari | 15+ | | Brave | any (Chromium) | | Opera | 75+ | | Deno | any (Web Crypto) | | Bun | any (Web Crypto) | | Cloudflare Workers | any |


License

MIT