lockform
v4.0.0
Published
Official Lockform SDK: REST API client plus end-to-end encryption (X25519 + AES-256-GCM) for forms, submissions, files, and webhooks.
Maintainers
Readme
Lockform
Official SDK for processing Lockform webhook submissions with end-to-end encryption using X25519 + AES-256-GCM.
Installation
npm install lockformFeatures
- Decrypt webhook data: Easily decrypt encrypted form submissions received via webhooks
- Signature verification: Verify webhook authenticity using HMAC-SHA256 signatures
- Field mapping: Automatically map field IDs to human-readable CSV names
- X25519 encryption: Modern, fast elliptic curve cryptography
- BIP39 support: Works with 15-word passphrases or base64 private keys
- TypeScript support: Full type definitions included
Quick Start
Decrypting Webhook Data
You can decrypt webhooks using either your 15-word passphrase or a base64-encoded private key:
Using passphrase:
import { decryptWebhookData } from 'lockform'
const mnemonic = 'your fifteen word passphrase goes here and must be exactly fifteen words'
app.post('/webhook', async (req, res) => {
const payload = req.body
const result = await decryptWebhookData({
payload,
passphrase: mnemonic,
})
console.log('Mapped data:', result.mappedData)
res.json({ success: true })
})Using base64 private key:
import { decryptWebhookData } from 'lockform'
const privateKeyBase64 = 'your-base64-encoded-x25519-private-key'
app.post('/webhook', async (req, res) => {
const payload = req.body
const result = await decryptWebhookData({
payload,
passphrase: privateKeyBase64,
})
console.log('Mapped data:', result.mappedData)
res.json({ success: true })
})Verifying Webhook Signatures
import { verifyWebhookSignature, decryptWebhookData } from 'lockform'
app.post('/webhook', async (req, res) => {
const signature = req.headers['x-signature-sha256']
const webhookSecret = process.env.WEBHOOK_SECRET
const isValid = await verifyWebhookSignature({
payload: JSON.stringify(req.body),
signature,
secret: webhookSecret,
})
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' })
}
const result = await decryptWebhookData({
payload: req.body,
passphrase: process.env.LOCKFORM_RECOVERY_PHRASE,
})
console.log('Decrypted data:', result.mappedData)
res.json({ success: true })
})API Reference
derivePrivateKey(mnemonic)
Derives a base64-encoded X25519 private key from a 15-word BIP39 passphrase. Useful for generating keys to use in edge functions.
Parameters:
mnemonic(string): Your 15-word BIP39 passphrase
Returns: string - Base64-encoded X25519 private key
Example:
import { derivePrivateKey } from 'lockform'
const mnemonic = 'your fifteen word passphrase goes here exactly fifteen words'
const privateKeyBase64 = derivePrivateKey(mnemonic)
console.log(privateKeyBase64)
// Output: "a1b2c3d4e5f6..." (base64 string)
// Store this in your edge function environment variableUse case: Run this once locally to derive your private key, then store the base64 output as an environment variable for edge functions (to avoid PBKDF2 timeout).
decryptWebhookData(options)
Decrypts an encrypted webhook payload from Lockform using X25519 + AES-256-GCM.
Parameters:
options.payload(WebhookPayload): The webhook payload received from Lockformoptions.passphrase(string): Your 15-word BIP39 passphrase (or optionally, base64-encoded X25519 private key)
Returns: Promise<DecryptedSubmission>
{
rawData: Record<string, unknown>, // Decrypted data with field IDs as keys
mappedData: Record<string, unknown>, // Decrypted data with CSV names as keys
metadata: {
event_type: string,
submission_id: string,
form_id: string,
timestamp: string,
nonce: string,
}
}Example:
const result = await decryptWebhookData({
payload: webhookPayload,
passphrase: myRecoveryPhrase,
})
console.log(result.mappedData) // { name: "John Doe", email: "[email protected]" }
console.log(result.rawData) // { "field-id-1": "John Doe", "field-id-2": "[email protected]" }verifyWebhookSignature(options)
Verifies the HMAC-SHA256 signature of a webhook payload.
Parameters:
options.payload(string): The raw JSON string of the webhook payloadoptions.signature(string): The signature from theX-Signature-SHA256headeroptions.secret(string): Your webhook secret
Returns: Promise<boolean> - true if the signature is valid, false otherwise
Example:
const isValid = await verifyWebhookSignature({
payload: JSON.stringify(req.body),
signature: req.headers['x-signature-sha256'],
secret: process.env.WEBHOOK_SECRET,
})
if (!isValid) {
throw new Error('Invalid webhook signature')
}Types
WebhookPayload
interface WebhookPayload {
event_type: string
submission_id: string
form_id: string
ciphertext: string
iv: string
salt: string
ephemeral_public_key: string
auth_tag: string
algorithm: string
nonce: string
encryption_timestamp: number
timestamp: string
field_mapping: Record<string, string>
}DecryptedSubmission
interface DecryptedSubmission {
rawData: Record<string, unknown>
mappedData: Record<string, unknown>
metadata: {
event_type: string
submission_id: string
form_id: string
timestamp: string
nonce: string
}
}Complete Example
Node.js / Express
import express from 'express'
import { decryptWebhookData, verifyWebhookSignature } from 'lockform'
const app = express()
app.use(express.json())
const RECOVERY_PHRASE = process.env.LOCKFORM_RECOVERY_PHRASE
const WEBHOOK_SECRET = process.env.LOCKFORM_WEBHOOK_SECRET
app.post('/lockform-webhook', async (req, res) => {
try {
const signature = req.headers['x-signature-sha256'] as string
const payload = req.body
if (WEBHOOK_SECRET && signature) {
const isValid = await verifyWebhookSignature({
payload: JSON.stringify(payload),
signature,
secret: WEBHOOK_SECRET,
})
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' })
}
}
const result = await decryptWebhookData({
payload,
passphrase: RECOVERY_PHRASE,
})
console.log('Form ID:', result.metadata.form_id)
console.log('Submission ID:', result.metadata.submission_id)
console.log('Data:', result.mappedData)
res.json({ success: true })
} catch (error) {
console.error('Error processing webhook:', error)
res.status(500).json({ error: 'Failed to process webhook' })
}
})
app.listen(3000, () => {
console.log('Webhook server listening on port 3000')
})Deno Edge Function
Important: Edge functions have strict CPU time limits. Use a base64-encoded private key instead of the 15-word passphrase to avoid CPU timeout errors. See the performance note below.
import { decryptWebhookData, verifyWebhookSignature, type WebhookPayload } from 'lockform'
Deno.serve(async (req) => {
if (req.method !== 'POST') {
return new Response(
JSON.stringify({ error: 'Method not allowed' }),
{ status: 405, headers: { 'Content-Type': 'application/json' } }
)
}
const recoveryPhrase = Deno.env.get('LOCKFORM_RECOVERY_PHRASE')
const webhookSecret = Deno.env.get('WEBHOOK_SECRET')
if (!recoveryPhrase) {
return new Response(
JSON.stringify({ error: 'Passphrase not configured' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
const signature = req.headers.get('x-signature-sha256')
const rawBody = await req.text()
const payload: WebhookPayload = JSON.parse(rawBody)
if (webhookSecret && signature) {
const isValid = await verifyWebhookSignature({
payload: rawBody,
signature,
secret: webhookSecret,
})
if (!isValid) {
return new Response(
JSON.stringify({ error: 'Invalid signature' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
)
}
}
const result = await decryptWebhookData({
payload,
passphrase: recoveryPhrase,
})
console.log('New submission:', result.mappedData)
return new Response(
JSON.stringify({ success: true }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
})Cryptographic Details
Lockform uses modern, audited cryptography for maximum security:
- Key Exchange: X25519 (Curve25519 Diffie-Hellman)
- Symmetric Encryption: AES-256-GCM (Galois/Counter Mode)
- Key Derivation: HKDF-SHA256 with separate salt
- Mnemonic-to-Key: PBKDF2-SHA512 (600,000 iterations)
- Mnemonic: BIP39 (15 words, 160 bits entropy)
Why X25519 instead of RSA?
- Smaller keys (32 bytes vs 4096 bits)
- Faster operations
- Better security per bit
- Modern, constant-time implementation
- Forward secrecy with ephemeral keys
Security Best Practices
- Always verify signatures: Use
verifyWebhookSignatureto ensure webhooks are genuinely from Lockform - Protect your passphrase: Store your 15-word passphrase in environment variables, never commit it to version control
- Never share your passphrase: Anyone with your 15-word passphrase can decrypt all submissions
- Use HTTPS: Always use HTTPS endpoints for webhooks in production
- Validate data: Always validate the decrypted data before processing it
- Implement idempotency: Use the
submission_idto prevent duplicate processing - Rate limiting: Implement rate limiting on your webhook endpoint
Migration from v1.x (RSA) to v2.x (X25519)
If you're migrating from the RSA-based v1.x version:
- Update your package:
npm install lockform@latest - Update your credentials: Use your 15-word passphrase instead of PEM-formatted RSA keys
- Update your code: Pass your passphrase as the
passphraseparameter (renamed fromprivateKey)
The webhook payload structure has changed:
wrapped_key→ephemeral_public_key- Added:
salt,encryption_timestamp algorithmchanged fromRSA-OAEP-4096+AES-256-GCMtoX25519+AES-256-GCM
Performance Considerations
Edge Functions (Deno, Cloudflare Workers, etc.)
Edge functions have strict CPU time limits (typically 50-100ms). The PBKDF2 key derivation with 600,000 iterations can take several seconds and will cause timeout errors.
Solution: Use a base64-encoded private key instead of the passphrase.
Option 1: Use the CLI tool (easiest)
npx lockform-derive-key
# Or if installed: npm run derive-keyThis will prompt you for your passphrase and output the base64 private key.
Option 2: Use the API programmatically
import { derivePrivateKey } from 'lockform'
const mnemonic = 'your fifteen word passphrase here exactly fifteen words'
const privateKeyBase64 = derivePrivateKey(mnemonic)
console.log(privateKeyBase64) // Store this in your edge function environmentThen use the base64 key in your edge function:
const recoveryPhrase = Deno.env.get('LOCKFORM_RECOVERY_PHRASE') // Now contains base64 key
const result = await decryptWebhookData({
payload,
passphrase: recoveryPhrase, // Automatically detects base64 format (no PBKDF2)
})The library automatically detects the format:
- Contains spaces → 15-word mnemonic (slow, runs PBKDF2)
- No spaces → Base64 private key (fast, skips PBKDF2)
Node.js / Long-running servers
You can use either format. The 15-word passphrase works fine in environments without strict CPU time limits.
Requirements
- Node.js 18.0.0 or higher
- TypeScript 5.0.0 or higher (for TypeScript projects)
License
MIT
