tessera Svelte Themes

Tessera

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

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: 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.
lockoutAttempts number 5 Failed Tessera.unlock() calls before lockout fires.
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.

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
  }
});

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;
}

Honey Keys

After every write, tessera plants N decoy entries in localStorage. These entries look identical to real encrypted keys (t_ + 32 hex chars with plausible-looking ciphertext). Any code path that touches a honey key 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}`);
});

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


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();
});

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 (installed at unlock time to catch scripts that read honey keys without going through the tessera API) can be bypassed by code that calls Storage.prototype.getItem.call(localStorage, key) directly. An attacker sophisticated enough to do that already has full execution in your page and can keylog the passcode as it is typed. The proxy catches naive enumeration scripts. It was never meant to stop a targeted attack — 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

Important: All users must upgrade to 0.1.4. Earlier versions contain honey key security vulnerabilities. Upgrade immediately:

npm install @mrtinkz/[email protected]

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

Top categories

Loading Svelte Themes