Skip to content

usewombat/crypto

Repository files navigation

@usewombat/crypto

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.

What's in this package

Phase 1:

  • did:key generation 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:peer pairwise 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.

Install

npm install @usewombat/crypto

Requires Node.js 20+. Works in the browser via any ESM-compatible bundler. No native dependencies. No WebAssembly.


Usage

Generate a DID

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 key

Issue an Ed25519 Verifiable Credential (Phase 1)

import { 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.

Verify an Ed25519 Credential

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.


Issue an Identity BBS+ Credential (Phase 2 convenience wrapper)

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",
  },
})

Sign any Unsigned Credential (generic, raw primitive)

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)

Derive a Selective Disclosure Presentation (identity VCs)

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 derivation

Derive a Selective Disclosure Presentation (generic, any credential)

import { 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 schema

Verify a BBS+ Presentation

import { 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.


Pairwise DIDs — did:peer (Phase 3)

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 IDs

Option 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.


Sign and verify raw Ed25519 messages

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)  // boolean

Supported claim fields

The 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 fieldssignCredential 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.


Choosing between Ed25519 and BBS+

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.


Design principles

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).


Security

See SECURITY.md for the threat model, canonicalisation design decision, and responsible disclosure process.

License

MIT — see LICENSE.

About

Core cryptographic primitives for Wombat

Resources

License

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors