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

device-seal

v1.0.2

Published

🦭 Device-bound, user-verified key custody and local encrypted storage (no backend). Browser implementation (WebAuthn PRF). Quantum-resistant symmetric core: AES-256-GCM + HKDF-SHA-512 (CNSA 2.0 symmetric/hash primitives, NIST PQC Cat 5)

Readme

device-seal (Browser and more)

🦭 Device-bound, user-verified key custody and local encrypted storage for local-fist apps β€” no backend.

Quality Gate Status Coverage Bugs Reliability Rating Security Rating Vulnerabilities Technical Debt Maintainability Rating

Codeberg stars NPM Downloads NPM Version Bundle size License: MIT

Last commit Open issues Format, test & SonarCloud

[codeberg] [issues] [npm] [github|mirror]

⚠️ NO independent audit or review yet! Use on your own risk!

  • πŸ” Device-bound & user-verified. Each secret is sealed behind a WebAuthn passkey (PRF extension); every read demands a fresh on-device user-verification ceremony, and the encryption key is re-derived per call and never stored.
  • 🚫 Build for Local-first. Zero backend. No server, no account, no key material in transit β€” the secret, the key, and the salt never leave the device.
  • βš›οΈ Quantum-resistant symmetric core. AES-256-GCM + HKDF-SHA-512 β€” CNSA 2.0 symmetric/hash primitives, NIST PQC Category 5.
  • πŸ”‘ Optional passphrase second factor. Stretched with PBKDF2-SHA-512 (700k iterations) and bound into both the authenticator's PRF input and the key derivation β€” so each guess needs a live ceremony and a stolen vault can't be brute-forced offline; required again on every unlock, and never stored.
  • 🧩 Per-app, per-entry isolation. A length-framed application namespace plus a per-entry salt bind every key and ciphertext to one app, version, and entry, so secrets can never cross between them.
  • 🌐 Origin-bound. Entries are tied to the creating hostname (the WebAuthn rpId); a copied IndexedDB store cannot be unlocked from any other origin.
  • πŸ“¦ Zero runtime dependencies β€” pure ESM, side-effect-free, tree-shakeable, and safe to import in Node/SSR (nothing touches window/location until a ceremony runs).
  • πŸš€ Tiny CDN bundle β€” a single minified ESM of ~3 KB gzipped, served from jsDelivr/unpkg.
  • 🎯 Fully typed. Written in TypeScript; ships type declarations (.d.ts).

A secret is encrypted on the device and can be read back only after the user verifies on their WebAuthn authenticator (passkey). Nothing β€” not the secret, not the key, not the salt β€” ever leaves the machine. There is no server to call, no account to create, and no key material in transit.

Status: the browser implementation (WebAuthn PRF) is what ships today. Desktop (Secure Enclave / TPM) and JVM ports are planned and share this cryptographic design, not this code. All examples below are for the browser package.

Requirements

  • A secure context β€” HTTPS, or http://localhost during development. WebAuthn and crypto.subtle are unavailable otherwise.
  • An authenticator that supports WebAuthn with the PRF extension (hmac-secret) and user verification. No attachment filter is set, so the user may use whatever they have β€” a built-in authenticator (Touch ID, Windows Hello, Android biometrics), a roaming security key (USB/NFC, e.g. a YubiKey), or their phone via the cross-device flow. (Discoverable/resident keys are not required β€” the credential id is stored locally and replayed on unlock, so resident storage is only requested as a preference.) PRF/hmac-secret is the real requirement; most current platform authenticators and modern FIDO2 security keys on recent browsers qualify. If the authenticator does not return a PRF secret, createCredential/accessCredential throw rather than store something unreadable. You can probe the browser's PRF capability up front with isDeviceSupported() β€” but it reports only what the client supports, not which authenticator the user will pick, so the ceremony is the final word.
  • ESM. The package is "type": "module" and has no runtime dependencies.

Install

npm install device-seal

Or load it straight from a CDN β€” the package ships a single minified ESM bundle (device-seal.min.js), which jsdelivr/unpkg serve from the bare package URL:

<script type="module">
  import { createCredential } from 'https://cdn.jsdelivr.net/npm/device-seal';
</script>

Quick start

import { createCredential, listCredentials, accessCredential } from 'device-seal';

// 1. Create a passkey and seal a secret behind it. Prompts the user to create a passkey
//    (user verification required). Only ciphertext + metadata is persisted; the key and plaintext never are.
const { entry } = await createCredential({
  username: '[email protected]',
  secret: 'my-api-token', // string | Uint8Array | omit to generate 32 random bytes
  label: 'Production API token',
});

const id = entry.identifier; // a UUID β€” persist this to retrieve the secret later

// 2. List what's stored. Metadata only: no decryption, no user prompt.
const all = await listCredentials({ username: '[email protected]' });

// 3. Unseal it. Prompts the user to verify on their authenticator, then returns the plaintext.
const { secret } = await accessCredential({ identifier: id });
const token = new TextDecoder().decode(secret); // -> 'my-api-token'

Let the library generate and seal a fresh random key instead of supplying one:

// Omit `secret` -> a fresh random 32-byte Uint8Array is generated, sealed, and returned to you.
const { entry, secret } = await createCredential({
  username: '[email protected]',
});
// `secret` is the 32 raw bytes, already encrypted at rest. Keep your own copy if you need a backup
// (see "No recovery" below) β€” after this call the only way back to it is a user-verified unlock.

Require a passphrase as a second factor β€” it is stretched with PBKDF2-SHA-512 and bound into both the authenticator's PRF input and the key derivation, so the entry can then be opened only with the authenticator and the passphrase, and because a wrong passphrase changes the PRF input, a stolen vault cannot be brute-forced offline:

const { entry } = await createCredential({
  username: '[email protected]',
  secret: 'my-api-token',
  passphrase: 'correct horse battery staple', // empty string === no passphrase
});

// Later β€” unlocking needs the same passphrase alongside the authenticator ceremony:
const { secret } = await accessCredential({
  identifier: entry.identifier,
  passphrase: 'correct horse battery staple',
});

API

The core async functions below, plus createVault β€” a small factory that pre-binds shared config β€” and isDeviceSupported(), a capability probe you can call before any of them. Each core function accepts an optional databaseName (default passkeyVault) to namespace its own IndexedDB store, and an advanced databaseVersion β€” omit it unless you are deliberately driving an IndexedDB upgrade (versions are monotonic, so a fixed value throws after any bump).

isDeviceSupported()

A capability probe for gating your UI: resolves true when this client can protect secrets with the WebAuthn PRF extension, false when it definitely cannot β€” no PublicKeyCredential (it is secure-context only, so absent over plain HTTP and in non-browser runtimes) / no navigator.credentials, or the browser explicitly reports PRF unsupported. It runs no ceremony, shows no prompt, and stores nothing.

isDeviceSupported(): Promise<boolean>

Capabilities are client-level, not per-authenticator: a true result means the browser supports PRF, not that the specific passkey provider the user picks does. A password manager that stores passkeys without hmac-secret (e.g. Bitwarden on Android) passes this check yet still fails the ceremony β€” so always handle a createCredential rejection too. Treat it as "hide the feature when it's hopeless," not "guarantee success." Unknown capabilities (older clients without getClientCapabilities()) resolve true, so PRF-capable authenticators are never pre-emptively locked out.

createCredential(options)

Registers a new device-bound passkey and stores a secret encrypted under it. Prompts the user to create a passkey (user verification required), derives a non-extractable AES-GCM key from the authenticator's PRF output and a per-entry salt, and persists only ciphertext.

createCredential(options: {
  username: string;                // required
  secret?: string | Uint8Array;    // string is UTF-8 encoded; Uint8Array is copied;
                                   //   omitted -> fresh random 32 bytes
  label?: string;                  // human-friendly label; defaults to `username`
  passphrase?: string;             // optional 2nd factor (PBKDF2); required again on every access.
                                   //   empty string === omitted
  applicationName?: string;        // crypto namespace; defaults to an environment-derived identity
                                   //   (web-app-manifest id -> page hostname -> 'app')
  applicationVersion?: number;     // crypto namespace version (default 1)
  databaseName?: string;           // default 'passkeyVault'
  databaseVersion?: number;        // advanced; omit to use the database's current version
}): Promise<{ entry: CredentialMetadata; secret: Uint8Array }>

Returns the entry's public metadata and the protected secret bytes (so you can use the secret immediately without a second verification). applicationName/applicationVersion form the crypto namespace woven into key derivation and authenticated data; they are recorded with the entry and reused automatically on access, so secrets from one app/version can never be unlocked as another. When you omit applicationName it defaults to a stable, environment-derived identity β€” a web-app-manifest id if one is present, otherwise the page hostname, otherwise "app" β€” chosen to stay constant across app updates.

A passphrase, if given, is stretched with PBKDF2-SHA-512 and bound into both the authenticator's PRF input and the key derivation, so the entry then requires both the authenticator and that passphrase to open; testing a guess needs a live ceremony, so a stolen vault cannot be brute-forced offline. Only a passphrased: true flag is recorded β€” never the passphrase itself; an empty string is treated as no passphrase.

Origin binding is automatic: entries are bound to the page's hostname (location.hostname, the WebAuthn rpId) and cannot be unlocked from a different hostname. There is no server endpoint β€” a credential is identified by its identifier and grouped by username.

listCredentials(filter?)

Lists stored credential metadata. No decryption, no user prompt.

listCredentials(filter?: {
  username?: string;               // narrow by username
  databaseName?: string;           // default 'passkeyVault'
  databaseVersion?: number;        // advanced
}): Promise<CredentialMetadata[]>

accessCredential(options)

Decrypts and returns a stored secret, prompting the user to verify on their authenticator. This is the only read path that returns plaintext. The application namespace recorded at creation is reused automatically β€” you do not supply it again.

accessCredential(options: {
  identifier: string;              // required β€” the `identifier` from createCredential's entry
  passphrase?: string;             // required iff the entry was created with one (`passphrased: true`)
  databaseName?: string;           // default 'passkeyVault'
  databaseVersion?: number;        // advanced
}): Promise<{ entry: CredentialMetadata; secret: Uint8Array }>

Throws if no entry matches the identifier, if the entry needs a passphrase and none (or an empty one) was supplied, or if the user cancels verification. The passphrase check runs before any authenticator prompt.

removeCredential(options)

Permanently deletes one stored credential and best-effort asks the platform credential manager to drop the now-orphaned passkey. No user verification: deletion exposes no plaintext, and any same-origin caller could clear IndexedDB anyway, so a ceremony would only add a footgun (a lost authenticator could never clean up its own entry). Irreversible β€” the encrypted secret cannot be recovered afterwards.

removeCredential(options: {
  identifier: string;              // required β€” the entry to delete
  databaseName?: string;           // default 'passkeyVault'
  databaseVersion?: number;        // advanced
}): Promise<boolean>               // true if an entry was deleted, false if none matched (idempotent no-op)

wipeVault(options?)

Deletes every stored credential in a database and best-effort asks the credential manager to drop each now-orphaned passkey. Like removeCredential, it needs no user verification and is irreversible.

wipeVault(options?: {
  databaseName?: string;           // default 'passkeyVault'
  databaseVersion?: number;        // advanced
}): Promise<number>                // count of entries removed

createVault(config?)

A small synchronous factory that pre-binds shared config β€” databaseName, databaseVersion, applicationName, applicationVersion β€” so you don't repeat it on every call. It returns { create, access, list, remove, wipe }, thin wrappers over the five functions above; per-call options are merged over the bound config (call options win).

const vault = createVault({ applicationName: 'my-app', databaseName: 'myVault' });

const { entry } = await vault.create({ username: '[email protected]', secret: 'my-api-token' });
const all = await vault.list({ username: '[email protected]' });
const { secret } = await vault.access({ identifier: entry.identifier });
await vault.remove({ identifier: entry.identifier }); // delete one entry
await vault.wipe(); // or delete every entry in the bound database

Types

interface CredentialMetadata {
  identifier: string; // UUID; the handle you pass to accessCredential
  username: string;
  label: string;
  createdAt: string; // ISO 8601
  passphrased: boolean; // true if a passphrase is required to unlock this entry
}

CredentialMetadata is the hard boundary returned to callers: every cryptographic field (salts, nonce, ciphertext, and even the application namespace) is deliberately stripped, so listing or returning an entry can never leak key material.

How it works

Every secret is sealed under a single AES-256-GCM key that is re-derived on demand and never stored:

  1. Unlock secret β€” 32 bytes produced by the authenticator's WebAuthn PRF extension, but only during a user-verified ceremony. The PRF is evaluated over a SHA-512 hash of the per-entry salt β€” and, when a passphrase is set, of the salt length-framed with the PBKDF2-SHA-512-stretched passphrase β€” so a wrong passphrase yields a different secret and each guess needs a fresh ceremony (no offline brute-force). It is the one input that requires the physical authenticator and a present human, and it is never persisted.
  2. Encryption key β€” HKDF-SHA-512 over the unlock secret (and, when a passphrase is set, the same PBKDF2-stretched passphrase material length-framed in alongside it), salted with a per-entry random salt and bound (via the HKDF info) to the application namespace and the credential id, so keys never cross between entries or apps. Non-extractable; exists only for the duration of one call.
  3. Ciphertext β€” the key encrypts the secret with AES-256-GCM under a fresh 12-byte nonce, with the application namespace and entry identifier as additional authenticated data (so a stored blob cannot be moved to another entry or namespace without failing the GCM tag).

Only opaque, non-secret material reaches IndexedDB β€” the salt, the public credential id, the nonce, the ciphertext, and plaintext metadata (username, label, timestamp, and the passphrased flag). The derived key and the plaintext secret never do. Reading reverses the chain: a fresh verification yields the unlock secret, which re-derives the same key, which decrypts. No key material is cached between calls, so every read costs exactly one fresh user verification.

AES-256-GCM and HKDF-SHA-512 are the CNSA 2.0 symmetric/hash primitives (NIST PQC Category 5), and data-at-rest confidentiality rests entirely on them. The passkey's signing keypair (ES256/RS256, etc.) only authenticates the user and never touches the encryption path, so a break of that classical keypair does not expose stored secrets.

Security model & limitations

  • Device-bound and origin-bound. Entries are bound to the authenticator and to the hostname they were created on (the WebAuthn rpId is location.hostname). The same IndexedDB data cannot be unlocked from a different hostname β€” the rpId no longer matches.
  • No backend, no sync. Nothing is uploaded; there is nothing to breach server-side and nothing to synchronize across devices.
  • No recovery β€” by design. Losing the device or authenticator, or clearing the site's browser storage, makes entries permanently unrecoverable. There is no escrow and no reset. If you need durability, keep your own backup of the secret (createCredential returns it to you once).
  • Per-read verification. Every accessCredential triggers a fresh user-verification ceremony; keys are never held in memory across calls.
  • Best-effort persistence. The module requests persistent storage, but private/incognito sessions and storage-pressure eviction can still discard IndexedDB data.

Errors

The functions reject (throw) rather than fail silently when: a required option (username, or identifier) is missing; the context is not secure or WebAuthn is unavailable; the authenticator does not return a PRF secret; the user cancels a create/verify ceremony; accessCredential is given an identifier with no stored entry; or a passphrase-protected entry is accessed without its passphrase.

Resources

  • FIPS 197 Advanced Encryption Standard (AES) algorithm https://nvlpubs.nist.gov/nistpubs/fips/nist.fips.197.pdf
  • FIPS 180-4 Secure Hash Standard (SHS) https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.180-4.pdf

License

MIT Β© thinking.tools β€” https://codeberg.org/thinking_tools/device-seal