Skip to content

mrtinkz/tessera

Repository files navigation

tessera

tessera

Your data. Your passcode. Your rules.

Build Status npm version Bundle size npm downloads Zero dependencies TypeScript

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: 4 }, // Add 4 decoy entries to localStorage. Default: 4.

  // --- 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/pin-pad';

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: 8 });
        }}
      />
    );
  }

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

Vue 3

<script setup lang="ts">
import { useTessera } from '@mrtinkz/tessera/vue';
import { renderPinPad } from '@mrtinkz/tessera/pin-pad';
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: 8 });
  }
});
</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/pin-pad';

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

  onMount(() => {
    renderPinPad(pinEl, { onUnlock: unlock, randomize: true, length: 8 });
  });
</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: 8 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 Persistence HTTP exposure Value size Modes supported
vault.local Cross-session None ~5 MB direct
vault.session Tab-scoped None ~5 MB direct, claim, split
vault.cookie Configurable (TTL) Yes ~4 KB direct, claim
vault.idb Cross-session None Unlimited

Storage adapter differences

vault.local — localStorage

Values persist across browser restarts. Use for user preferences, UI state, and anything that should survive a page reload or tab close. All key names and values are opaque; only t_-prefixed HMAC hashes appear in storage.

vault.session — sessionStorage

Tab-scoped — the browser clears sessionStorage when the tab closes (not on page reload). Ideal for session tokens, temporary drafts, and anything that should disappear when the user leaves. Supports the split and claim modes in addition to direct.

vault.cookie — Cookies

Cookies are unique among tessera backends because they are sent to the server on every same-origin HTTP request. Key points:

  • Names in HTTP headers: tessera writes two cookies per developer key — the stable index slot (t_<32hex>) and an ephemeral data slot (a fresh random t_<32hex> per write). The developer key name (e.g. 'cart') never appears as a cookie name on the wire.
  • Values in HTTP headers: the server receives base64-encoded AES-256-GCM ciphertext — unreadable without the vault key, which never leaves the browser.
  • Honey decoys over HTTP: honey cookies also travel with every request. With the default count: 4, each write plants 4 extra t_-prefixed decoy cookies. If your server is sensitive to cookie volume, set honeyKeys.count: 0 or use vault.local/vault.session instead.
  • No httpOnly: tessera must read and write cookies via document.cookie; httpOnly cookies can only be set server-side.
  • Size limit: browsers cap individual cookies at ~4 KB. Use mode: 'claim' to keep only a short token in the cookie and store the actual payload in IndexedDB.
  • No split mode: only direct and claim are supported.
  • Cookie-specific options: expires (ms), path, domain, sameSite, secure.
await vault.cookie.set('session-id', token, {
  sameSite: 'Strict',
  secure: true,
  expires: 86_400_000, // 24 hours
});

vault.idb — IndexedDB

Named object stores let you organise encrypted data by type. Best choice for large payloads and structured data. Storage is effectively unlimited. Uses put(storeName, key, value) / get(storeName, key) rather than the getItem / setItem API.

await vault.idb.put('documents', 'report-2024', largeBlob);
const data = await vault.idb.get('documents', 'report-2024');

Key rotation and routing-table indirection

Developer-facing key names (e.g. 'cart') are never written to storage as-is. tessera uses a two-level routing-table scheme:

  • Index slot (stable): rotateKeyName(key) produces a deterministic t_ + 32 hex-char name via HMAC-SHA256. This slot stores an AES-256-GCM encrypted pointer { "slot": "<random>" } — it never changes between writes.
  • Data slot (ephemeral): a freshly generated random t_ key holds the actual encrypted value. It is replaced on every write — the old slot is forensically wiped first.

An attacker who captures two storage snapshots sees 1 stable index entry plus N+1 equally-opaque t_-prefixed entries. The real data slot cannot be identified by survival or update pattern — it is replaced on every write, identical in appearance to the honey decoys.

Cookie adapter: both the index slot name and the data slot name are rotateKeyName-derived or random t_ values. The developer key name never appears as a cookie name.

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 4 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' vault.session supports all three. vault.cookie supports direct and claim only (split is sessionStorage-only). Not available on vault.local or vault.idb. 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/pin-pad';

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: 8, // digits required — clamped to [8, 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 + routing-table indirection All values are ciphertext; key names are opaque t_ HMAC hashes; the data slot is replaced on every write — no persistent target to extract
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
T12 Temporal storage snapshot Routing-table indirection; data slot rotated on every write Storage snapshots taken at different times show the same index slot but a fresh, random data slot each write — the real entry is indistinguishable from honey decoys by persistence or mutation

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 and HTTP exposure. tessera encrypts cookie values but cannot enforce httpOnly (which requires server-side Set-Cookie). All tessera cookies — including honey decoys — travel to the server on every same-origin HTTP request. The server sees opaque t_-prefixed names and encrypted ciphertext values; it cannot read the plaintext. But the cookie count and ciphertext sizes are visible. Use vault.local or vault.session if you want no server visibility at all.

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


Changelog

Latest release: 0.1.9. No breaking API changes since 0.1.8.

npm install @mrtinkz/tessera@latest

0.1.9

Debug-mode isolation — no breaking API changes, no migration required.

Area What changed
debug config option New debug?: boolean field on EnhancedTesseraConfig (default false). Captured in an immutable closure at unlock() time — cannot be changed on a live vault.
IDebugVault interface Tessera.unlock(passcode, { debug: true }) returns IDebugVault via TypeScript overload. Extends the full vault API with two helpers: _simulateHoneyHit(backend) fires a live suspicion-engine honey hit; _honeyStorageKeys(backend) returns the current planted decoy key names. Both are permanently inert (no-op / []) on production vaults.
Non-enumerable debug surface _simulateHoneyHit and _honeyStorageKeys are defined with enumerable: false. They do not appear in Object.keys(), for…in, or devtools property listing — a script holding a production vault reference cannot discover them.
Debug activation warning console.warn('[tessera] debug: true is active …') is emitted at unlock() time, making it difficult for debug mode to reach production unnoticed.
.vanilla example default The sandbox demo now initialises to production mode (debug unchecked). An amber warning banner is shown whenever debug is toggled on.

0.1.8

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

Area What changed
Routing-table indirection (localStorage, sessionStorage, cookie, IDB) Every developer key now has a stable index slot (rotateKeyName(key)) that stores an encrypted pointer { slot }, plus an ephemeral data slot at a fresh random t_ key that is replaced on every write. A temporal observer who captures two storage snapshots cannot identify the real entry — the data slot looks identical to the honey decoys and changes on every write.
Default honeyKeys.count raised to 4 With the two-level structure (1 index + 1 data + N honey), raising the default to 4 keeps an ~80% honey ratio (4 of 6 randomly-keyed entries are decoys) from an external observer's view. Existing config using count: 3 continues to work unchanged.
Silent backward-compatible migration Vault data written by v0.1.7 and earlier is read transparently: if the index slot does not decrypt as a routing pointer but its value contains a . separator, the adapter falls back to the old single-blob format and migrates to the new layout on the next write.

0.1.7

Breaking change — one import path change required for renderPinPad consumers.

Area What changed
renderPinPad import path Removed from the main @mrtinkz/tessera entry. Import from @mrtinkz/tessera/pin-pad instead (the sub-path was already available since 0.1.x). CDN / IIFE users are unaffected — renderPinPad remains in TesseraLib.
Bundle size Removing the re-export saves ~7–8 KB gzip (~9 KB raw unminified) from the main ESM/CJS bundle for consumers who do not use the PIN pad.

Migration — change one import per file:

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

// After
import { renderPinPad } from '@mrtinkz/tessera/pin-pad';

CDN usage is unchanged:

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

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

About

Zero-dependency browser storage encryption. One passcode locks and unlocks your localStorage, sessionStorage, cookies, and IndexedDB.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors