lockform 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Lockform
2
2
 
3
- Official SDK for processing Lockform webhook submissions with end-to-end encryption.
3
+ Official SDK for processing Lockform webhook submissions with end-to-end encryption using X25519 + AES-256-GCM.
4
4
 
5
5
  ## Installation
6
6
 
@@ -13,29 +13,52 @@ npm install lockform
13
13
  - **Decrypt webhook data**: Easily decrypt encrypted form submissions received via webhooks
14
14
  - **Signature verification**: Verify webhook authenticity using HMAC-SHA256 signatures
15
15
  - **Field mapping**: Automatically map field IDs to human-readable CSV names
16
+ - **X25519 encryption**: Modern, fast elliptic curve cryptography
17
+ - **BIP39 support**: Works with 15-word recovery phrases or base64 private keys
16
18
  - **TypeScript support**: Full type definitions included
17
19
 
18
20
  ## Quick Start
19
21
 
20
22
  ### Decrypting Webhook Data
21
23
 
24
+ You can decrypt webhooks using either your 15-word recovery phrase or a base64-encoded private key:
25
+
26
+ **Using recovery phrase:**
27
+
22
28
  ```typescript
23
29
  import { decryptWebhookData } from 'lockform'
24
30
 
25
- const privateKey = `-----BEGIN PRIVATE KEY-----
26
- YOUR_PRIVATE_KEY_HERE
27
- -----END PRIVATE KEY-----`
31
+ const mnemonic = 'your fifteen word recovery phrase goes here and must be exactly fifteen words'
28
32
 
29
33
  app.post('/webhook', async (req, res) => {
30
34
  const payload = req.body
31
35
 
32
36
  const result = await decryptWebhookData({
33
37
  payload,
34
- privateKey,
38
+ passphrase: mnemonic,
35
39
  })
36
40
 
37
41
  console.log('Mapped data:', result.mappedData)
42
+ res.json({ success: true })
43
+ })
44
+ ```
45
+
46
+ **Using base64 private key:**
47
+
48
+ ```typescript
49
+ import { decryptWebhookData } from 'lockform'
50
+
51
+ const privateKeyBase64 = 'your-base64-encoded-x25519-private-key'
52
+
53
+ app.post('/webhook', async (req, res) => {
54
+ const payload = req.body
55
+
56
+ const result = await decryptWebhookData({
57
+ payload,
58
+ passphrase: privateKeyBase64,
59
+ })
38
60
 
61
+ console.log('Mapped data:', result.mappedData)
39
62
  res.json({ success: true })
40
63
  })
41
64
  ```
@@ -61,11 +84,10 @@ app.post('/webhook', async (req, res) => {
61
84
 
62
85
  const result = await decryptWebhookData({
63
86
  payload: req.body,
64
- privateKey: process.env.PRIVATE_KEY,
87
+ passphrase: process.env.LOCKFORM_RECOVERY_PHRASE,
65
88
  })
66
89
 
67
90
  console.log('Decrypted data:', result.mappedData)
68
-
69
91
  res.json({ success: true })
70
92
  })
71
93
  ```
@@ -74,11 +96,11 @@ app.post('/webhook', async (req, res) => {
74
96
 
75
97
  ### `decryptWebhookData(options)`
76
98
 
77
- Decrypts an encrypted webhook payload from Lockform.
99
+ Decrypts an encrypted webhook payload from Lockform using X25519 + AES-256-GCM.
78
100
 
79
101
  **Parameters:**
80
102
  - `options.payload` (WebhookPayload): The webhook payload received from Lockform
81
- - `options.privateKey` (string): Your RSA private key in PEM format
103
+ - `options.passphrase` (string): Your 15-word BIP39 recovery phrase (or optionally, base64-encoded X25519 private key)
82
104
 
83
105
  **Returns:** `Promise<DecryptedSubmission>`
84
106
 
@@ -101,10 +123,11 @@ Decrypts an encrypted webhook payload from Lockform.
101
123
  ```typescript
102
124
  const result = await decryptWebhookData({
103
125
  payload: webhookPayload,
104
- privateKey: myPrivateKey,
126
+ passphrase: myRecoveryPhrase,
105
127
  })
106
128
 
107
- console.log(result.mappedData)
129
+ console.log(result.mappedData) // { name: "John Doe", email: "john@example.com" }
130
+ console.log(result.rawData) // { "field-id-1": "John Doe", "field-id-2": "john@example.com" }
108
131
  ```
109
132
 
110
133
  ### `verifyWebhookSignature(options)`
@@ -143,10 +166,12 @@ interface WebhookPayload {
143
166
  form_id: string
144
167
  ciphertext: string
145
168
  iv: string
146
- wrapped_key: string
169
+ salt: string
170
+ ephemeral_public_key: string
147
171
  auth_tag: string
148
172
  algorithm: string
149
173
  nonce: string
174
+ encryption_timestamp: number
150
175
  timestamp: string
151
176
  field_mapping: Record<string, string>
152
177
  }
@@ -170,6 +195,8 @@ interface DecryptedSubmission {
170
195
 
171
196
  ## Complete Example
172
197
 
198
+ ### Node.js / Express
199
+
173
200
  ```typescript
174
201
  import express from 'express'
175
202
  import { decryptWebhookData, verifyWebhookSignature } from 'lockform'
@@ -177,7 +204,7 @@ import { decryptWebhookData, verifyWebhookSignature } from 'lockform'
177
204
  const app = express()
178
205
  app.use(express.json())
179
206
 
180
- const PRIVATE_KEY = process.env.LOCKFORM_PRIVATE_KEY
207
+ const RECOVERY_PHRASE = process.env.LOCKFORM_RECOVERY_PHRASE
181
208
  const WEBHOOK_SECRET = process.env.LOCKFORM_WEBHOOK_SECRET
182
209
 
183
210
  app.post('/lockform-webhook', async (req, res) => {
@@ -199,7 +226,7 @@ app.post('/lockform-webhook', async (req, res) => {
199
226
 
200
227
  const result = await decryptWebhookData({
201
228
  payload,
202
- privateKey: PRIVATE_KEY,
229
+ passphrase: RECOVERY_PHRASE,
203
230
  })
204
231
 
205
232
  console.log('Form ID:', result.metadata.form_id)
@@ -218,12 +245,100 @@ app.listen(3000, () => {
218
245
  })
219
246
  ```
220
247
 
248
+ ### Deno Edge Function
249
+
250
+ ```typescript
251
+ import { decryptWebhookData, verifyWebhookSignature, type WebhookPayload } from 'lockform'
252
+
253
+ Deno.serve(async (req) => {
254
+ if (req.method !== 'POST') {
255
+ return new Response(
256
+ JSON.stringify({ error: 'Method not allowed' }),
257
+ { status: 405, headers: { 'Content-Type': 'application/json' } }
258
+ )
259
+ }
260
+
261
+ const recoveryPhrase = Deno.env.get('LOCKFORM_RECOVERY_PHRASE')
262
+ const webhookSecret = Deno.env.get('WEBHOOK_SECRET')
263
+
264
+ if (!recoveryPhrase) {
265
+ return new Response(
266
+ JSON.stringify({ error: 'Recovery phrase not configured' }),
267
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
268
+ )
269
+ }
270
+
271
+ const signature = req.headers.get('x-signature-sha256')
272
+ const rawBody = await req.text()
273
+ const payload: WebhookPayload = JSON.parse(rawBody)
274
+
275
+ if (webhookSecret && signature) {
276
+ const isValid = await verifyWebhookSignature({
277
+ payload: rawBody,
278
+ signature,
279
+ secret: webhookSecret,
280
+ })
281
+ if (!isValid) {
282
+ return new Response(
283
+ JSON.stringify({ error: 'Invalid signature' }),
284
+ { status: 401, headers: { 'Content-Type': 'application/json' } }
285
+ )
286
+ }
287
+ }
288
+
289
+ const result = await decryptWebhookData({
290
+ payload,
291
+ passphrase: recoveryPhrase,
292
+ })
293
+
294
+ console.log('New submission:', result.mappedData)
295
+
296
+ return new Response(
297
+ JSON.stringify({ success: true }),
298
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
299
+ )
300
+ })
301
+ ```
302
+
303
+ ## Cryptographic Details
304
+
305
+ Lockform uses modern, audited cryptography for maximum security:
306
+
307
+ - **Key Exchange**: X25519 (Curve25519 Diffie-Hellman)
308
+ - **Symmetric Encryption**: AES-256-GCM (Galois/Counter Mode)
309
+ - **Key Derivation**: HKDF-SHA256 with separate salt
310
+ - **Mnemonic-to-Key**: PBKDF2-SHA512 (600,000 iterations)
311
+ - **Mnemonic**: BIP39 (15 words, 160 bits entropy)
312
+
313
+ **Why X25519 instead of RSA?**
314
+ - Smaller keys (32 bytes vs 4096 bits)
315
+ - Faster operations
316
+ - Better security per bit
317
+ - Modern, constant-time implementation
318
+ - Forward secrecy with ephemeral keys
319
+
221
320
  ## Security Best Practices
222
321
 
223
322
  1. **Always verify signatures**: Use `verifyWebhookSignature` to ensure webhooks are genuinely from Lockform
224
- 2. **Keep private keys secure**: Store your private key in environment variables, never commit it to version control
225
- 3. **Use HTTPS**: Always use HTTPS endpoints for webhooks in production
226
- 4. **Validate data**: Always validate the decrypted data before processing it
323
+ 2. **Protect your recovery phrase**: Store your 15-word recovery phrase in environment variables, never commit it to version control
324
+ 3. **Never share your recovery phrase**: Anyone with your 15-word recovery phrase can decrypt all submissions
325
+ 4. **Use HTTPS**: Always use HTTPS endpoints for webhooks in production
326
+ 5. **Validate data**: Always validate the decrypted data before processing it
327
+ 6. **Implement idempotency**: Use the `submission_id` to prevent duplicate processing
328
+ 7. **Rate limiting**: Implement rate limiting on your webhook endpoint
329
+
330
+ ## Migration from v1.x (RSA) to v2.x (X25519)
331
+
332
+ If you're migrating from the RSA-based v1.x version:
333
+
334
+ 1. **Update your package**: `npm install lockform@latest`
335
+ 2. **Update your credentials**: Use your 15-word recovery phrase instead of PEM-formatted RSA keys
336
+ 3. **Update your code**: Pass your recovery phrase as the `passphrase` parameter (renamed from `privateKey`)
337
+
338
+ The webhook payload structure has changed:
339
+ - `wrapped_key` → `ephemeral_public_key`
340
+ - Added: `salt`, `encryption_timestamp`
341
+ - `algorithm` changed from `RSA-OAEP-4096+AES-256-GCM` to `X25519+AES-256-GCM`
227
342
 
228
343
  ## Requirements
229
344
 
package/dist/crypto.d.ts CHANGED
@@ -1,6 +1,9 @@
1
- import { webcrypto } from 'node:crypto';
2
- export declare function base64ToArrayBuffer(base64: string): Promise<ArrayBuffer>;
3
- export declare function arrayBufferToString(buffer: ArrayBuffer): string;
4
- export declare function importPrivateKey(pemKey: string): Promise<webcrypto.CryptoKey>;
5
- export declare function decryptSubmission(ciphertext: string, iv: string, wrappedKey: string, authTag: string, privateKey: webcrypto.CryptoKey): Promise<string>;
1
+ export declare function base64ToArrayBuffer(base64: string): Promise<Uint8Array>;
2
+ export declare function arrayBufferToString(buffer: ArrayBuffer | Uint8Array): string;
3
+ export declare function importPrivateKeyFromMnemonic(mnemonic: string): Uint8Array;
4
+ export declare function importPrivateKeyFromBase64(base64: string): Uint8Array;
5
+ export declare function decryptSubmission(ciphertext: string, iv: string, salt: string, ephemeralPublicKey: string, privateKey: Uint8Array, metadata?: {
6
+ formId?: string;
7
+ encryptionTimestamp?: number;
8
+ }): Promise<string>;
6
9
  export declare function createHmacSignature(payload: string, secret: string): Promise<string>;
package/dist/crypto.js CHANGED
@@ -2,55 +2,68 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.base64ToArrayBuffer = base64ToArrayBuffer;
4
4
  exports.arrayBufferToString = arrayBufferToString;
5
- exports.importPrivateKey = importPrivateKey;
5
+ exports.importPrivateKeyFromMnemonic = importPrivateKeyFromMnemonic;
6
+ exports.importPrivateKeyFromBase64 = importPrivateKeyFromBase64;
6
7
  exports.decryptSubmission = decryptSubmission;
7
8
  exports.createHmacSignature = createHmacSignature;
8
9
  const node_crypto_1 = require("node:crypto");
10
+ const ed25519_1 = require("@noble/curves/ed25519");
11
+ const hkdf_1 = require("@noble/hashes/hkdf");
12
+ const sha2_1 = require("@noble/hashes/sha2");
13
+ const pbkdf2_1 = require("@noble/hashes/pbkdf2");
14
+ const sha2_2 = require("@noble/hashes/sha2");
15
+ const bip39_1 = require("bip39");
9
16
  const crypto = node_crypto_1.webcrypto;
10
17
  async function base64ToArrayBuffer(base64) {
11
- const binaryString = Buffer.from(base64, 'base64').toString('binary');
12
- const bytes = new Uint8Array(binaryString.length);
13
- for (let i = 0; i < binaryString.length; i++) {
14
- bytes[i] = binaryString.charCodeAt(i);
18
+ const binary = Buffer.from(base64, 'base64').toString('binary');
19
+ const bytes = new Uint8Array(binary.length);
20
+ for (let i = 0; i < binary.length; i++) {
21
+ bytes[i] = binary.charCodeAt(i);
15
22
  }
16
- return bytes.buffer;
23
+ return bytes;
17
24
  }
18
25
  function arrayBufferToString(buffer) {
19
26
  return new TextDecoder().decode(buffer);
20
27
  }
21
- async function importPrivateKey(pemKey) {
22
- const pemHeader = '-----BEGIN PRIVATE KEY-----';
23
- const pemFooter = '-----END PRIVATE KEY-----';
24
- const pemContents = pemKey
25
- .replace(pemHeader, '')
26
- .replace(pemFooter, '')
27
- .replace(/\s/g, '');
28
- const binaryDer = await base64ToArrayBuffer(pemContents);
29
- return await crypto.subtle.importKey('pkcs8', binaryDer, {
30
- name: 'RSA-OAEP',
31
- hash: 'SHA-256',
32
- }, false, ['unwrapKey']);
28
+ function importPrivateKeyFromMnemonic(mnemonic) {
29
+ const seed = (0, bip39_1.mnemonicToSeedSync)(mnemonic);
30
+ const privateKey = (0, pbkdf2_1.pbkdf2)(sha2_2.sha512, seed, 'lockform-x25519-v1', {
31
+ c: 600_000,
32
+ dkLen: 32,
33
+ });
34
+ return privateKey;
33
35
  }
34
- async function decryptSubmission(ciphertext, iv, wrappedKey, authTag, privateKey) {
36
+ function importPrivateKeyFromBase64(base64) {
37
+ return Buffer.from(base64, 'base64');
38
+ }
39
+ function deriveSharedSecret(privateKey, publicKey) {
40
+ return ed25519_1.x25519.getSharedSecret(privateKey, publicKey);
41
+ }
42
+ function deriveAESKey(sharedSecret, salt) {
43
+ const info = new TextEncoder().encode('lockform-encryption-v1');
44
+ return (0, hkdf_1.hkdf)(sha2_1.sha256, sharedSecret, salt, info, 32);
45
+ }
46
+ async function decryptWithAES(ciphertext, key, iv, additionalData) {
47
+ const cryptoKey = await crypto.subtle.importKey('raw', key, { name: 'AES-GCM' }, false, ['decrypt']);
48
+ const decrypted = await crypto.subtle.decrypt({
49
+ name: 'AES-GCM',
50
+ iv: iv,
51
+ ...(additionalData && { additionalData }),
52
+ }, cryptoKey, ciphertext);
53
+ const decoder = new TextDecoder();
54
+ return decoder.decode(decrypted);
55
+ }
56
+ async function decryptSubmission(ciphertext, iv, salt, ephemeralPublicKey, privateKey, metadata) {
35
57
  const ciphertextBuffer = await base64ToArrayBuffer(ciphertext);
36
58
  const ivBuffer = await base64ToArrayBuffer(iv);
37
- const wrappedKeyBuffer = await base64ToArrayBuffer(wrappedKey);
38
- const authTagBuffer = await base64ToArrayBuffer(authTag);
39
- const combinedCiphertext = new Uint8Array(ciphertextBuffer.byteLength + authTagBuffer.byteLength);
40
- combinedCiphertext.set(new Uint8Array(ciphertextBuffer), 0);
41
- combinedCiphertext.set(new Uint8Array(authTagBuffer), ciphertextBuffer.byteLength);
42
- const unwrappedKey = await crypto.subtle.unwrapKey('raw', wrappedKeyBuffer, privateKey, {
43
- name: 'RSA-OAEP',
44
- hash: { name: 'SHA-256' },
45
- }, {
46
- name: 'AES-GCM',
47
- length: 256,
48
- }, false, ['decrypt']);
49
- const decryptedBuffer = await crypto.subtle.decrypt({
50
- name: 'AES-GCM',
51
- iv: ivBuffer,
52
- }, unwrappedKey, combinedCiphertext);
53
- return arrayBufferToString(decryptedBuffer);
59
+ const saltBuffer = await base64ToArrayBuffer(salt);
60
+ const ephemeralPublicKeyBuffer = await base64ToArrayBuffer(ephemeralPublicKey);
61
+ const sharedSecret = deriveSharedSecret(privateKey, ephemeralPublicKeyBuffer);
62
+ const aesKey = deriveAESKey(sharedSecret, saltBuffer);
63
+ const aad = metadata
64
+ ? new TextEncoder().encode(`${metadata.formId ?? ''}:${metadata.encryptionTimestamp ?? 0}`)
65
+ : undefined;
66
+ return await decryptWithAES(ciphertextBuffer, aesKey, ivBuffer, aad);
54
67
  }
55
68
  async function createHmacSignature(payload, secret) {
56
69
  const encoder = new TextEncoder();
package/dist/decrypt.js CHANGED
@@ -3,9 +3,18 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.decryptWebhookData = decryptWebhookData;
4
4
  const crypto_1 = require("./crypto");
5
5
  async function decryptWebhookData(options) {
6
- const { payload, privateKey } = options;
7
- const cryptoKey = await (0, crypto_1.importPrivateKey)(privateKey);
8
- const decryptedData = await (0, crypto_1.decryptSubmission)(payload.ciphertext, payload.iv, payload.wrapped_key, payload.auth_tag, cryptoKey);
6
+ const { payload, passphrase } = options;
7
+ let privateKeyBytes;
8
+ if (passphrase.includes(' ')) {
9
+ privateKeyBytes = (0, crypto_1.importPrivateKeyFromMnemonic)(passphrase);
10
+ }
11
+ else {
12
+ privateKeyBytes = (0, crypto_1.importPrivateKeyFromBase64)(passphrase);
13
+ }
14
+ const decryptedData = await (0, crypto_1.decryptSubmission)(payload.ciphertext, payload.iv, payload.salt, payload.ephemeral_public_key, privateKeyBytes, {
15
+ formId: payload.form_id,
16
+ encryptionTimestamp: payload.encryption_timestamp,
17
+ });
9
18
  const rawData = JSON.parse(decryptedData);
10
19
  const mappedData = {};
11
20
  for (const [fieldId, value] of Object.entries(rawData)) {
package/dist/types.d.ts CHANGED
@@ -4,10 +4,12 @@ export interface WebhookPayload {
4
4
  form_id: string;
5
5
  ciphertext: string;
6
6
  iv: string;
7
- wrapped_key: string;
7
+ salt: string;
8
+ ephemeral_public_key: string;
8
9
  auth_tag: string;
9
10
  algorithm: string;
10
11
  nonce: string;
12
+ encryption_timestamp: number;
11
13
  timestamp: string;
12
14
  field_mapping: Record<string, string>;
13
15
  }
@@ -24,7 +26,7 @@ export interface DecryptedSubmission {
24
26
  }
25
27
  export interface DecryptWebhookOptions {
26
28
  payload: WebhookPayload;
27
- privateKey: string;
29
+ passphrase: string;
28
30
  }
29
31
  export interface VerifySignatureOptions {
30
32
  payload: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lockform",
3
- "version": "1.0.0",
4
- "description": "Official SDK for processing Lockform webhook submissions with end-to-end encryption",
3
+ "version": "2.0.0",
4
+ "description": "Official SDK for processing Lockform webhook submissions with end-to-end encryption using X25519 + AES-256-GCM",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "files": [
@@ -17,11 +17,18 @@
17
17
  "webhook",
18
18
  "e2e",
19
19
  "forms",
20
- "rsa",
21
- "aes"
20
+ "x25519",
21
+ "curve25519",
22
+ "aes-gcm",
23
+ "bip39"
22
24
  ],
23
25
  "author": "",
24
26
  "license": "MIT",
27
+ "dependencies": {
28
+ "@noble/curves": "^1.6.0",
29
+ "@noble/hashes": "^1.5.0",
30
+ "bip39": "^3.1.0"
31
+ },
25
32
  "devDependencies": {
26
33
  "@types/node": "^20.0.0",
27
34
  "typescript": "^5.0.0"