The browser as a stand-in token
There's no way for a web page to reach a real HSM or smart card driver, so this tool doesn't try to simulate one. Instead, it runs a real ECDSA key pair through the browser's own Web Crypto API, narrated with the exact Cryptoki function names a real token-backed application would call in the same order — C_OpenSession, C_GenerateKeyPair, C_Sign, and so on. Every cryptographic operation you see is genuine; only the "hardware" is a stand-in.
// Open session
setSession("open"); // stands in for C_OpenSession — no key material involved yet
// Login
setSession("loggedIn"); // stands in for C_Login(CKU_USER)A real token gates object visibility and private-key operations behind authentication; a browser tab has no such session concept, so these two steps are relabeled plain state, not WebCrypto calls.
extractable is CKA_EXTRACTABLE
Clicking "Generate key pair" calls crypto.subtle.generateKey with the private key's extractable flag set to false — the browser's direct equivalent of a token creating an object with CKA_EXTRACTABLE = false. This isn't cosmetic: the browser itself will now refuse any attempt to export or wrap that specific key handle, for the rest of the page's life. Nothing in this tool's own code enforces that refusal — the underlying platform does, the same role a real token's firmware plays. The security boundary is different, though: this refusal lives in the browser's JS engine, a software boundary that a compromised browser or OS could in principle be tricked around. A real HSM enforces CKA_EXTRACTABLE in tamper-resistant hardware, so the key material never exists in plaintext outside that boundary at all, regardless of what software on the host is doing. This tool demonstrates the API pattern faithfully; it doesn't — and can't — reproduce that hardware guarantee.
const keyPair = await crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" },
false, // extractable — the CKA_EXTRACTABLE = false equivalent
["sign", "verify"],
);
// keyPair.privateKey is now a CryptoKey handle only — exportKey()/wrapKey()
// on it will throw for the rest of this page's life, enforced by the browserextractable is set once, at generation, and applies to this exact key object for its whole lifetime — there's no way to flip it back on later, same as a token's CKA_EXTRACTABLE.
C_Sign works; C_WrapKey doesn't
Signing (C_SignInit + C_Sign in Cryptoki terms) works normally — using a non-extractable key for its intended operation is exactly what CKA_EXTRACTABLE = false is meant to allow. But clicking "Attempt C_WrapKey" calls crypto.subtle.wrapKey on that same private key handle, which throws — a real InvalidAccessError, not a message this tool invented. That's the identical refusal (CKR_KEY_UNEXTRACTABLE) a correctly configured token gives when an application asks it to do something CKA_EXTRACTABLE was specifically set to prevent.
// C_SignInit + C_Sign — this works, using the handle, not the raw key
const sig = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" },
keyPair.privateKey,
utf8ToBuf(message),
);
// C_WrapKey — this throws, every time, for a non-extractable key
try {
const wrappingKey = await crypto.subtle.generateKey({ name: "AES-KW", length: 256 }, false, ["wrapKey", "unwrapKey"]);
await crypto.subtle.wrapKey("pkcs8", keyPair.privateKey, wrappingKey, "AES-KW");
} catch (e) {
// InvalidAccessError — the browser's own refusal, not a check this tool wrote
console.log(e.name, e.message);
}The try/catch doesn't decide anything — it only observes the browser's refusal. Nothing upstream of the wrapKey() call needed to know the key was non-extractable.
What CKA_EXTRACTABLE = false allows
- •Sign, verify, encrypt, decrypt — using the key, as many times as needed
- •Generating new key pairs, indefinitely
What it blocks — no exceptions
- •Exporting the raw key value, in any format
- •Wrapping the key under another key — wrapping is still a form of export
The correct way to move a key off the token
The last section generates a separate, ordinary AES data key with extractable: true — the equivalent of a session key that's meant to be exported — and wraps that one successfully with AES-KW, the same mechanism used in the key-wrapping use case. This is the actual PKCS#11 pattern for moving key material: never make a long-lived private signing key extractable; generate short-lived, extractable session keys instead, and wrap only those.
const dataKey = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true, // extractable: true — this one is meant to leave
["encrypt", "decrypt"],
);
const wrappingKey = await crypto.subtle.generateKey({ name: "AES-KW", length: 256 }, false, ["wrapKey", "unwrapKey"]);
const wrapped = await crypto.subtle.wrapKey("raw", dataKey, wrappingKey, "AES-KW");
// round trip, to confirm it's the exact same key material
const unwrapped = await crypto.subtle.unwrapKey(
"raw", wrapped, wrappingKey, "AES-KW",
{ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"],
);Same wrapKey()/unwrapKey() calls as the private key attempt above — the only difference is extractable: true on this key, which is exactly why they succeed here and threw there.