Core cryptographic primitives for Wombat — privacy-first identity infrastructure for the EU.
This library is open source because the privacy claims Wombat makes can only be verified if the cryptographic implementation is publicly auditable.
Phase 1:
did:keygeneration and resolution (Ed25519, no registry, no server)- W3C Verifiable Credential 2.0 issuance and verification (Data Integrity / eddsa-rdfc-2022)
Phase 2:
- BBS+ selective disclosure — issue a credential over all claims, then derive a presentation that reveals only the fields the user chooses. Each derivation is randomised and cryptographically unlinkable to the original signature.
Phase 3:
did:peerpairwise DIDs — a unique DID per relationship, preventing cross-platform tracking by correlating identifiers. Two generation modes: random (device-stored) and deterministic (recoverable from a master key).
Phase 4 (deferred):
- Blind BBS+ issuance — deferred until the IETF blind BBS specification is ratified.
npm install @usewombat/cryptoRequires Node.js 20+. Works in the browser via any ESM-compatible bundler. No native dependencies. No WebAssembly.
import { generateDidKey, resolveDidKey } from "@usewombat/crypto"
// Generate a new did:key keypair
// In production, the private key lives in the device secure enclave (WebAuthn)
const keypair = await generateDidKey()
// keypair.did → "did:key:z6Mk..."
// keypair.privateKey → Uint8Array (32 bytes)
// keypair.publicKey → Uint8Array (32 bytes)
// Resolve a DID to a DID document — no network required
const doc = resolveDidKey(keypair.did)
// doc.verificationMethod[0].publicKeyMultibase → the encoded public keyimport { generateDidKey, issueCredential } from "@usewombat/crypto"
const issuerKeypair = await generateDidKey() // Wombat ID server key
const userKeypair = await generateDidKey() // user's device key
const vc = await issueCredential({
issuerDid: issuerKeypair.did,
issuerPrivateKey: issuerKeypair.privateKey,
subjectDid: userKeypair.did,
claims: {
name: "Ada Lovelace",
email: "ada@example.com",
// Only include what the user consented to share
},
validitySeconds: 30 * 24 * 60 * 60, // 30 days (default)
})The credential is a W3C VC 2.0 document with an embedded Data Integrity proof. No JWT wrapper. Self-contained — verifiable without any Wombat server.
import { verifyCredential } from "@usewombat/crypto"
const result = await verifyCredential({
credential: vc,
expectedIssuer: "did:key:z6Mk...", // enforce a specific issuer
})
if (result.valid) {
console.log(result.credential.credentialSubject.name)
} else {
console.error(result.reason, result.detail)
// reason: "invalid_proof" | "expired" | "not_yet_valid" |
// "issuer_mismatch" | "malformed_credential" |
// "unsupported_proof" | "invalid_did"
}verifyCredential never throws on invalid credentials — it always returns a
typed result. Do not branch on exceptions for security decisions.
The identity-specific wrapper signs a credential over a fixed set of claims (name, email, birthday, phone, country). Useful for Wombat ID identity VCs.
import { generateDidKey, generateBBSKeyPair, issueBBSCredential } from "@usewombat/crypto"
const issuerDid = await generateDidKey()
const bbsKeypair = generateBBSKeyPair() // BLS12-381 G2 keypair (96-byte public key)
const userDid = await generateDidKey()
const vc = await issueBBSCredential({
issuerDid: issuerDid.did,
issuerPrivateKey: bbsKeypair.privateKey,
issuerPublicKey: bbsKeypair.publicKey,
subjectDid: userDid.did,
claims: {
name: "Ada Lovelace",
email: "ada@example.com",
birthday: "1815-12-10",
country: "GB",
},
})Use signCredential when the credential subject has arbitrary fields
(e.g. capability VCs with scopes, type, expiry). No opinion about claim
structure — pure cryptographic operation.
import { generateBBSKeyPair, signCredential } from "@usewombat/crypto"
const keypair = generateBBSKeyPair()
const vc = await signCredential({
unsigned: {
"@context": ["https://www.w3.org/ns/credentials/v2"],
type: ["VerifiableCredential", "WombatCapabilityCredential"],
issuer: "did:wombat:dashboard",
validFrom: new Date().toISOString(),
validUntil: new Date(Date.now() + 3600000).toISOString(),
credentialSubject: {
id: "did:key:z6Mk...gateway",
scopes: [{ resource: "github/org/repo", mode: "r" }],
},
},
keyPair: keypair,
})
// vc.proof.messageCount === 3 (id + 2 fields)
// vc.proof.proofValue — BBS+ signature (base64url)import { derivePresentation } from "@usewombat/crypto"
// Runs on the user's device — issuer public key required, private key never needed
const presentation = await derivePresentation({
credential: vc,
disclose: new Set(["email"]), // reveal only email; name, birthday, country stay hidden
issuerPublicKey: bbsKeypair.publicKey,
})
// presentation.credentialSubject contains only { id, email }
// presentation.proof.proofValue is a fresh randomised BBS+ proof
// — unlinkable to the original signature and to any other derivationimport { deriveSelectivePresentation } from "@usewombat/crypto"
// Disclose by field name — "id" is always disclosed
const presentation = await deriveSelectivePresentation({
credential: vc,
issuerPublicKey: keypair.publicKey,
disclose: new Set(["scopes"]),
})
// presentation.credentialSubject contains only { id, scopes }
// presentation.proof.disclosedIndices — tells the verifier the original
// message indices, so partial disclosure works without a shared schemaimport { verifyPresentation } from "@usewombat/crypto"
const result = await verifyPresentation({
presentation,
issuerPublicKey: bbsKeypair.publicKey,
expectedIssuer: issuerDid.did, // optional
})
if (result.valid) {
console.log(result.disclosed.scopes) // any disclosed field
} else {
console.error(result.reason)
// reason: "invalid_proof" | "expired" | "not_yet_valid" |
// "issuer_mismatch" | "malformed_credential" |
// "unsupported_proof"
}verifyPresentation never throws — always returns a typed BBSVerifyResult.
The proof must include disclosedIndices (set by derivePresentation and
deriveSelectivePresentation). The verifier reads these directly per the
W3C VC-DI-BBS spec instead of computing indices from field names.
A pairwise DID is a unique identity per relationship. The same user has a different DID with every platform they connect to. Platforms cannot correlate users across services by comparing DIDs — they are cryptographically unrelated.
Option A — random (device-stored):
import { generateDidPeer, resolveDidPeer } from "@usewombat/crypto"
// Fresh random keypair — unique every call
// Store the private key in the device keychain if you need to reuse this identity
const pair = await generateDidPeer("did:key:z6Mk...platformDID")
// pair.did → "did:peer:0z6Mk..." (unique per call)
// pair.context → "did:key:z6Mk...platformDID"
// Resolve locally — no network
const doc = resolveDidPeer(pair.did)
// doc.authentication, doc.keyAgreement → verification method IDsOption B — deterministic (recoverable from master key):
import { generateDidKey, deriveDidPeer } from "@usewombat/crypto"
// The user's root key — their did:key private key or any 32-byte master secret
const userRoot = await generateDidKey()
// Same (masterKey, context) always produces the same DID
// Recoverable if the user loses their device
const forPlatformA = await deriveDidPeer(userRoot.privateKey, "did:key:z6Mk...platformA")
const forPlatformB = await deriveDidPeer(userRoot.privateKey, "did:key:z6Mk...platformB")
// forPlatformA.did !== forPlatformB.did — platforms cannot correlate the user
// Recover after device loss:
const recovered = await deriveDidPeer(userRoot.privateKey, "did:key:z6Mk...platformA")
// recovered.did === forPlatformA.did ✓Use Option A when the device keychain is reliable and cross-device recovery is not required. Use Option B when the user's root key is the recovery mechanism — the typical Wombat model.
import { generateDidKey, sign, verify } from "@usewombat/crypto"
const keypair = await generateDidKey()
const message = new TextEncoder().encode("hello")
const signature = await sign(message, keypair.privateKey) // Uint8Array (64 bytes)
const valid = await verify(signature, message, keypair.publicKey) // booleanThe identity-specific wrappers (issueBBSCredential, derivePresentation) use:
| Field | Type | Format |
|---|---|---|
name |
string | free text |
email |
string | email address |
birthday |
string | ISO 8601 date (YYYY-MM-DD) |
phone |
string | E.164 format |
country |
string | ISO 3166-1 alpha-2 |
For generic credentials (signCredential, deriveSelectivePresentation),
the credential subject can contain any fields — signCredential encodes
all properties sorted alphabetically, with no schema constraint. The verifier
uses proof.disclosedIndices (per W3C VC-DI-BBS) to map disclosed fields back
to their original message positions.
Only the fields the user consented to share should be included.
| Ed25519 (Phase 1) | BBS+ (Phase 2) | |
|---|---|---|
| Credential size | Small | Small |
| Proof size | 64 bytes | ~272+ bytes |
| Selective disclosure | All-or-nothing | Per-field |
| Presentation unlinkability | No | Yes |
| Verification speed | Fast | Slower (pairing) |
| Use when | Simple auth, full disclosure | Privacy-preserving selective disclosure |
If your use case requires the holder to reveal only some fields without the verifier being able to link two presentations to the same credential, use BBS+. For straightforward authentication where all claims are always disclosed, Ed25519 is simpler and faster.
No vendor lock-in. A did:key DID is derived entirely from the user's
public key. It works without Wombat, without a registry, without a blockchain.
If Wombat disappears, every identity persists.
Deny by default. verifyCredential and verifyPresentation return
{ valid: false } for any input that does not pass all checks. Unknown inputs
are rejected, not accepted.
No crypto reinvention. All cryptographic operations use @noble/* —
pure TypeScript, audited by Cure53, no native dependencies, no WebAssembly.
Proof generation stays on the device. BBS+ derivation runs in the browser. Plaintext claim values never travel to a Wombat server. The verifier learns only what the user chose to disclose.
Stable message encoding. BBS+ message encoding (buildMessagesFromSubject)
uses deterministic canonical JSON (keys sorted recursively) rather than
JSON.stringify. Plain JSON.stringify is insertion-order-dependent and can
silently change after database round-trips (PostgreSQL JSONB sorts keys
alphabetically), which would cause verification to fail with
"Challenge mismatch".
Structurally auditable. The BBS+ implementation follows the IETF BBS signature draft (draft-irtf-cfrg-bbs-signatures) and was validated against the Microsoft reference implementation (github.com/microsoft/bbs-node-reference).
See SECURITY.md for the threat model, canonicalisation design decision, and responsible disclosure process.
MIT — see LICENSE.