Docs · Concepts
Verify it yourself.
The exact code that runs in your browser, copy-pasted from the repo. Plus a standalone Node.js script so you can decrypt a CLAIMA link without our site, without our domain, without trusting us at all.
Why this page exists
You should not have to trust our marketing copy when money is on the line. Crypto products earn trust by showing the bytes — what actually runs, what actually moves on-chain — and letting you reproduce both independently.
This page does that for CLAIMA. Every snippet below is the real source pulled straight out of lib/crypto.ts and lib/payload.ts. Nothing is paraphrased. If you find a discrepancy between this page and shipped behaviour, report it — that's a bug.
What's in your browser
When you open /c/new or /c, your browser loads (in order):
- The static HTML shell from Vercel.
- Compiled React + Next.js client bundle.
@solana/web3.js— the official JavaScript SDK for Solana, used to build and sign transactions.@solana/wallet-adapter-react+-phantom+-solflare— standard adapters for connecting to wallet extensions.- Our own
lib/crypto.ts(shown below), which calls the browser's nativewindow.crypto.subtle. There is no third-party crypto library.
That's the whole stack. Inspect view-source: on any page or open the DevTools Network tab — you'll see exactly these chunks and nothing else.
encrypt() — the exact source
This is the function that turns a temp wallet's secret key (+ your optional note) into the encrypted blob that lives in the URL fragment. Verbatim from lib/crypto.ts:
const PBKDF2_ITERATIONS = 250_000;
const KEY_BYTES = 32; // AES-256
const SALT_BYTES = 16;
const IV_BYTES = 12;
async function deriveAesKey(password, salt) {
const subtle = window.crypto.subtle;
const passwordKey = await subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveKey"]
);
return subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
passwordKey,
{ name: "AES-GCM", length: KEY_BYTES * 8 },
false,
["encrypt", "decrypt"]
);
}
export async function encrypt(plaintext, password) {
const subtle = window.crypto.subtle;
const salt = window.crypto.getRandomValues(new Uint8Array(SALT_BYTES));
const iv = window.crypto.getRandomValues(new Uint8Array(IV_BYTES));
const key = await deriveAesKey(password, salt);
const ct = await subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
return { salt, iv, ciphertext: new Uint8Array(ct) };
}That's it. 250,000 rounds of PBKDF2-SHA256 against a 16-byte salt, then a single AES-256-GCM encrypt with a 12-byte IV. Both salt and IV are fresh randoms per link.
decrypt() — the exact source
export async function decrypt(blob, password) {
const subtle = window.crypto.subtle;
const key = await deriveAesKey(password, blob.salt);
try {
const pt = await subtle.decrypt(
{ name: "AES-GCM", iv: blob.iv },
key,
blob.ciphertext
);
return new Uint8Array(pt);
} catch {
// AES-GCM throws OperationError on any tag mismatch (wrong password,
// tampered blob). Surface a user-meaningful error.
throw new Error("Wrong password or tampered link.");
}
}The catch block is the only place we lie a bit — AES-GCM can't actually tell the difference between "wrong password" and "tampered ciphertext." Both fail the authentication tag the same way. We surface one message because users care about the first case, not the second.
Decrypt a link with Node, no browser
Want to confirm that the encrypted blob in a link is what we say it is? Run this on any machine with Node 18+. No internet required after install.
// claima-decrypt.mjs
//
// Usage: node claima-decrypt.mjs '<full-claim-url>' '<password>'
//
// Outputs the decrypted envelope { sk, note? } as JSON.
// Verifies that the temp wallet pubkey derived from sk matches the one
// the link claims.
import { webcrypto as crypto } from "node:crypto";
import { Keypair } from "@solana/web3.js";
import bs58 from "bs58";
const [, , url, password] = process.argv;
// --- base64url helpers ---
const b64uToBytes = (s) => {
const pad = s.length % 4 ? "=".repeat(4 - (s.length % 4)) : "";
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + pad;
return Uint8Array.from(Buffer.from(b64, "base64"));
};
// --- 1. Decode the URL payload ---
const frag = url.slice(url.indexOf("#") + 1);
const payload = JSON.parse(Buffer.from(frag, "base64url").toString("utf8"));
if (payload.v !== 2) throw new Error("Unsupported payload version");
const salt = b64uToBytes(payload.salt);
const iv = b64uToBytes(payload.iv);
const ct = b64uToBytes(payload.ct);
// --- 2. Derive AES key from the password ---
const passwordKey = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveKey"]
);
const aesKey = await crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 250_000, hash: "SHA-256" },
passwordKey,
{ name: "AES-GCM", length: 256 },
false,
["decrypt"]
);
// --- 3. Decrypt the envelope ---
const ptBuf = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, aesKey, ct);
const env = JSON.parse(new TextDecoder().decode(ptBuf));
console.log("envelope:", env);
// --- 4. Sanity-check that sk produces the pubkey the URL advertised ---
const skBytes = Buffer.from(env.sk, "hex");
const keypair = Keypair.fromSecretKey(skBytes);
const derived = keypair.publicKey.toBase58();
console.log("expected pubkey:", payload.pk);
console.log("derived pubkey :", derived);
console.log("match :", derived === payload.pk);Install and run:
mkdir claima-verify && cd claima-verify
npm init -y
npm install @solana/web3.js bs58
# save the script above as claima-decrypt.mjs
node claima-decrypt.mjs 'https://claima.fun/c#eyJ2IjoyLC…' 'your-password'The match field should print true. If it ever prints false, that link was tampered with after creation and you should not use it.
Decrypt with openssl (advanced)
Pure CLI version for anyone who really doesn't want to run JS. You still need the salt, iv, and ciphertext from the payload (decode the base64url JSON from the fragment first):
# Derive the AES key
openssl kdf -keylen 32 -kdfopt digest:SHA256 \
-kdfopt pass:<your-password> \
-kdfopt salt:<salt-hex> \
-kdfopt iter:250000 PBKDF2
# That gives you the 32-byte AES key in hex. Then:
openssl enc -aes-256-gcm -d -K <aes-key-hex> -iv <iv-hex> \
-in ciphertext.bin -out plaintext.json
# Note: openssl's GCM auth-tag handling is finicky — see the man page for
# how to split the 16-byte tag off the end of the ciphertext.The Node script above is recommended unless you have a specific reason to use openssl. WebCrypto handles tag splitting for you.
Test vector
A fixed, reproducible example. The link below is real but locks 0 SOL on devnet to a throwaway wallet — you can decrypt it without spending anything.
URL: https://claima.fun/c#eyJ2IjoyLCJwayI6IjN4SkExZXhhbXBsZVRWdmVjdG9yIiwibGFtcG9ydHMiOjAsIm5ldHdvcmsiOiJkZXZuZXQiLCJzYWx0IjoiTGtUcUFkS0xMa1RxQWRLTCIsIml2IjoiVGNDR2xQQzlzcUVHWUx5VyIsImN0IjoiRkFLRUNJUEhFUlRFWFRGT1JET0NTRVhBTVBMRTAwMDAwMDAwMDAwMDAwMCJ9
Password: claima-demo
Expected envelope (after decrypt):
{
"sk": "<128 hex chars>",
"note": "this is a docs test vector"
}
Expected pubkey match: true(The test vector above is a placeholder during MVP — we'll replace it with a real, frozen link once mainnet launches so anyone can independently confirm the round trip.)
Independent audit
The cryptographic surface is intentionally tiny — one PBKDF2-derived AES-GCM channel and one System Program transfer. The code that implements it is under 200 lines in lib/crypto.ts + lib/payload.ts and uses only standard browser primitives.
A formal third-party audit is on the roadmap. In the meantime, the source is public and any cryptographer is welcome to review it. Findings will be acknowledged on this page and in the security changelog.
Bug bounty
We pay for vulnerabilities that compromise user funds or break the confidentiality promise of the URL fragment. Scope and rewards will be published before the mainnet launch. Report privately through the social channels in the footer until then.