___ ___
/ \ / \ VESvault
\__ / \ __/ Encrypt Everything without fear of losing the Key
\\ // https://vesvault.com https://ves.host
\\ //
___ \\_//
/ \ / \ VESlocker: Hardware-grade PIN Security API
\__ / \ __/
\\ // https://veslocker.com
\\ //
\\_//
/ \
\___/
Give a short PIN the strength of a hardware security module — without the hardware.
A 4-digit PIN has only 10,000 combinations. Anywhere it guards data that an attacker can copy (a browser, a file, a backup), they can brute-force it offline in milliseconds. VESlocker removes the offline attack entirely: the decryption key is split between the user's PIN and a secret held by a small key server, and the server hands the key back only under a strict, exponentially-throttled budget. After a few dozen wrong guesses the entry is locked for good — a true lifetime cap on attempts, the way a TPM or a secure enclave behaves, implemented in ~250 lines of vanilla JS and ~110 lines of PHP.
🔎 Try it: there is a live demo at veslocker.com.
client key server
────── ──────────
challenge = SHA-256(seed ‖ PIN) ───── id, challenge ─────▶ look up per-id secret
throttle check (2^attempts)
key = AES-GCM key ◀──── SHA-256(secret ‖ challenge) ────── return derived key
ciphertext = AES-GCM(key, data)
(ciphertext + seed are stored locally; the key is never stored)
- The data is encrypted client-side with AES-GCM. The key is derived from
the server's per-entry
secretand achallengeseeded by the user's PIN. - The server never receives the seed, the PIN, the plaintext, or the
ciphertext — only an
idand achallenge(a hash). It returns the derived key, gated by throttling. - Each request to an
idincrements an attempt counter; the next request is refused until2^attemptsseconds have passed (capped at 32). After ~32 attempts the wait exceeds a century — the entry is effectively sealed.
Install from npm (Node 20+, or any bundler):
npm install veslockerimport VESlocker from "veslocker";…or drop it straight into a page — same API, no build step:
<script src="https://veslocker.com/pub/VESlocker.js"></script>Then:
// apiUrl defaults to the public veslocker.com key server, so you can try it
// with zero config. Pass apiUrl to point at your own server/VESlocker.php:
const vl = new VESlocker({
apiUrl: "https://your-key-server.example/VESlocker.php"
});
// Encrypt a secret behind a PIN and stash it in localStorage under a name:
await vl.store("launch-codes", "1234", "the actual launch codes");
// Read it back with the PIN. fetch() returns the value, and on every read
// re-encrypts under a fresh id + seed (resetting the attempt budget) — so it's
// the right call for routine reads:
const secret = await vl.fetch("launch-codes", "1234"); // "the actual launch codes"
// Supply a new PIN and the *same* call also rotates it: fetch() reads with the
// old PIN and re-encrypts under the new one in one step, still returning the value:
const same = await vl.fetch("launch-codes", "1234", "5678"); // "the actual launch codes", now behind 5678
⚠️ Preferfetch()overpeek()for routine reads.peek()decrypts without rekeying: each call spends one of the entry's ~32 lifetime attempts and leaves the counter climbing toward the permanent seal. Reach forpeek()only when you deliberately don't want to rotate the entry.
There's a runnable Node example in example/node.mjs and a
browser one in example/browser.html.
Prefer to manage storage yourself? encrypt / decrypt are stateless and
return / take a self-contained token:
const token = await vl.encrypt("1234", "the actual launch codes");
const back = await vl.decrypt("1234", token); // "the actual launch codes"Get notified when an entry's key is accessed (an early-warning signal that someone is guessing the PIN):
vl.accessFn = (id, count, at) => {
if (count > 0) console.warn(`VESlocker '${id}' accessed ${count}× (last: ${at})`);
};The server is a single PHP file with a PDO storage layer. It defaults to SQLite — no database server to install, one file on disk — and the schema is created automatically on first request:
- Serve
server/VESlocker.phpbehind any PHP-capable HTTP server (PHP 7.4+, with thepdo_sqliteextension — bundled by default). - Point
$DBsqliteat a writable path outside the web root (the file holds every entry's secret, so it must never be served). - Point the client's
apiUrlat it.
Prefer MySQL/MariaDB (e.g. you already run one, or want multiple app servers
sharing a store)? Set $DBdriver = "mysql", fill in the $DB* settings, and
load the schema from server/VESlocker.mysql.sql.
Requirements — Server: PHP 7.4+ with PDO (pdo_sqlite, bundled by default;
or pdo_mysql). Client: any browser with
Web Crypto
(all current browsers).
VESlocker is a key oracle gated by throttling, not a vault. Reason about it honestly before you deploy:
- ✅ The server cannot read your data. It never sees plaintext or ciphertext — only an id and a challenge hash.
- ✅ No offline brute force. An attacker who copies the ciphertext cannot test PINs offline: the key also requires the server's secret, which they don't have.
- ✅ Hard lifetime cap on online guesses. Exponential per-id throttling limits an attacker (and the legitimate user) to roughly 32 attempts, ever.
⚠️ The cap is irreversible — by design. There is no reset: once an entry seals (or you simply forget the PIN), the data is gone for good, the way a lost hardware token is. The same property is a denial-of-service vector if an entry'sidever leaks — whoever holds it can spend the budget and permanently seal that entry. Theidis 256 random bits and never guessable, so this reduces to: treat theidas a bearer capability, and keep an independent backup of anything you cannot afford to lose. VESlocker is for data where "lost forever" is an acceptable failure mode, not a system of record.⚠️ Availability dependency. Decryption requires the key server to be reachable. This is by design — it is what makes the throttle unavoidable.⚠️ Two-secret compromise. If an attacker obtains both the server-side secret store and the ciphertext, the PIN can be brute-forced offline (bounded only by PIN entropy). Protect the key server's database accordingly, and treat the PIN's entropy as your last line of defense in that scenario.
Found a vulnerability? See SECURITY.md — please report privately.
Two operational rules fall straight out of the zero-knowledge design — get them right and the server is close to maintenance-free:
- Never expire entries. The server cannot tell a stored-and-forgotten
idfrom one written once and read back a year later — it has no knowledge of whether your ciphertext still exists. So it must never TTL or garbage-collect rows; doing so would silently destroy live data. Rows are tiny (~100 bytes) and kept for good; storage grows with genuine use, which is fine. - Creation is rate-limited for you. With no accounts, anyone can mint a new
id, and each newidis an unauthenticated row insert — soVESlocker.phpcaps entry creation per source address with a built-in leaky bucket (VESlocker_ratelimit), no proxy or CDN config required; retrieval of existing ids is untouched. The defaults allow a burst of 600 creations per source and about one per second after that — tune$RateWindow/$RateCostat the top ofVESlocker.phpif your clients are busier or sit behind shared NAT. That table is self-cleaning; only the entry store is kept forever.
VESlocker isn't a new cryptographic protocol — it's a deliberately minimal take on a known idea: harden a low-entropy secret (a PIN) with the help of a key server, so it can't be brute-forced offline. It's worth saying plainly how it relates to the rigorous prior art, and where it consciously trades rigor for simplicity.
The server is unauthenticated by design — and that is the zero-knowledge
property. There are no accounts, no API keys, no per-user auth. The only
credential is the entry's id: 32 cryptographically-random bytes (256 bits)
minted on the client. You cannot throttle-attack — or even address — an entry
whose id you don't hold, and guessing one costs 2^256. The server stores only
(id → secret, attempt-counter) and learns nothing that links an id to a
person, a PIN, or any ciphertext. The id is a bearer capability, not an
identity.
| What it's for | Trust anchor | Hard lifetime guess cap | No special hardware | Survives a malicious operator | |
|---|---|---|---|---|---|
| VESlocker | Decrypting local data behind a PIN | Server operator + availability | ✅ exponential → ~century | ✅ any PHP host + Web Crypto | ❌ by design |
| OPAQUE / aPAKE | Login / session key agreement | Server operator | ❌ needs bolt-on rate limiting | ✅ | partial |
| Signal SVR | PIN-recovering a stored secret | Hardware enclave (SGX) + attestation | ✅ destroy-after-N | ❌ needs SGX | ✅ |
| Pythia / PHE | Hardening + rotatable password keys | Server key (rotatable) | ❌ focus is rotation | ✅ | partial (oblivious + rotation) |
Read the table by its one standout cell: among the systems compared here, VESlocker is the only row that is ✅ in both "hard lifetime guess cap" and "no special hardware." Signal's SVR gets the cap by putting the throttle inside a secure enclave; OPAQUE and Pythia run on commodity servers but have no built-in attempt limit. VESlocker gets both by enforcing the throttle in ordinary application code — the trade-off being the last column: it trusts the operator, where an enclave would not.
- vs. OPAQUE and PAKEs. OPAQUE proves knowledge of a password and agrees a
session key without the server learning the password. It's built for
authentication, and online guessing is still possible unless you add your
own rate limiting. VESlocker does no handshake (one round trip:
id+challenge = H(seed ‖ PIN)→H(secret ‖ challenge)) and makes the throttle itself the headline feature — an exponential, lifetime-capped attempt budget — for the narrower job of unlocking local data. - vs. Signal's Secure Value Recovery. SVR is the closest cousin: a PIN, a server-side hard attempt limit, and a secret that becomes unrecoverable after too many tries. The difference is the trust anchor. SVR enforces the limit inside an SGX enclave with remote attestation, so it holds even against a malicious operator — at the cost of requiring that hardware and attestation infrastructure. VESlocker enforces the throttle in ordinary code on any commodity host, so it does not defend against the operator — it buys most of SVR's user-facing behavior for roughly none of the operational complexity, by trusting the operator and the network.
- vs. Pythia / Password-Hardened Encryption. Pythia is a verifiable,
oblivious PRF service with server-key rotation, so the server can't
dictionary-attack the input even with its own key, and a stolen database can
be invalidated by rotating. VESlocker's
challengeis a plain hash, not blinded — the server could attack the PIN if it also had theseed, which it never does (the seed is client-only and never sent). VESlocker has no obliviousness and no rotation; in exchange it's SHA-256 and a counter column in ~150 lines of PHP, with nothing to install.
The honest one-liner: VESlocker gives a PIN the behavior of a hardware attempt-limiter — rate-limited, lifetime-capped, no offline attack — by trusting a reachable server instead of a chip. If your threat model includes a malicious server operator, reach for SVR or an OPRF service. If it's "someone copied my browser storage / my backup," VESlocker covers it at a fraction of the cost — and unlike a TPM, the protected data is portable to any device that has the token and the PIN.
VESlocker is licensed per component:
| Component | License | |
|---|---|---|
client/ |
Apache License 2.0 | Embed it in your app with no copyleft obligations. |
server/ |
PolyForm Perimeter 1.0.1 | Use, modify, and self-host for any purpose — except offering a product that competes with VESlocker. |
Everything else in this repository (documentation, examples, tests) is Apache License 2.0 — see LICENSE.
Contributions are welcome under the DCO — see
CONTRIBUTING.md. In short: sign off your commits with
git commit -s.
Part of the VESvault project — Encrypt Everything without fear of losing the Key.