formdecrypt 0.0.0-stage → 0.2.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.
Files changed (3) hide show
  1. package/README.md +27 -2
  2. package/formdecrypt.js +85 -0
  3. package/package.json +32 -4
package/README.md CHANGED
@@ -1,3 +1,28 @@
1
- # Temporary Holding Version
1
+ # formdecrypt
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Creates keys for [formcrypt](https://github.com/camilodelvasto/formcrypt/tree/main/formcrypt) and decrypts CSV exports of form submissions, locally. Node 20 or later, no dependencies.
4
+
5
+ ```sh
6
+ npx formdecrypt keygen # writes ./formcrypt.key, prints the public key for your form
7
+ npx formdecrypt export.csv # writes ./export.decrypted.csv
8
+ npx formdecrypt export.csv out.csv
9
+ ```
10
+
11
+ The private key is read from `./formcrypt.key`, or from the `FORMCRYPT_KEY` environment variable. Keep a copy somewhere safe: without it, submissions can't be read.
12
+
13
+ **Getting the CSV from Netlify:** Site → Forms → your form → Download as CSV.
14
+
15
+ Every cell that holds a `fc1.` envelope is decrypted in place under its column name. Columns, order and plain cells are untouched, so submissions from before you added encryption come through as they are. A cell that can't be decrypted (wrong key, or moved to another column) is left as it is, and the exit code is 1. Existing files are never overwritten.
16
+
17
+ **The output is plaintext.** Store it as carefully as the data deserves. Submissions come from strangers, so a value starting with `=`, `+`, `-` or `@` can run as a formula in a spreadsheet program. Import as text if in doubt.
18
+
19
+ ## Format v1
20
+
21
+ So readers can be written in any language:
22
+
23
+ - **Envelope:** `fc1.` + base64url (no padding) of `ephemeral P-256 public key (65 B, uncompressed) ‖ IV (12 B) ‖ AES-256-GCM ciphertext + 16 B tag`
24
+ - **AES key:** HKDF-SHA256 over ECDH(ephemeral, recipient), with salt = the 65-byte ephemeral public key and info = `formcrypt/v1`, giving 32 bytes
25
+ - **Plaintext:** the field's value, UTF-8. **Additional authenticated data:** the field's name, UTF-8
26
+ - **Keys:** `fc1pk.` + base64url(raw public key) and `fc1sk.` + base64url(PKCS#8 private key)
27
+
28
+ MIT
package/formdecrypt.js ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ // formdecrypt 0.2.0 · MIT · Keys and CSV decryption for formcrypt (format v1)
3
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
4
+
5
+ const { subtle } = globalThis.crypto
6
+ const P256 = { name: 'ECDH', namedCurve: 'P-256' }
7
+ const KEYFILE = 'formcrypt.key'
8
+ const ENVELOPE = /^fc1\.[A-Za-z0-9_-]{124,}$/
9
+ const fail = (msg) => { console.error(`formdecrypt: ${msg}`); process.exit(1) }
10
+
11
+ async function keygen() {
12
+ if (existsSync(KEYFILE)) fail(`${KEYFILE} already exists; not overwriting it`)
13
+ const kp = await subtle.generateKey(P256, true, ['deriveBits'])
14
+ const pub = Buffer.from(await subtle.exportKey('raw', kp.publicKey)).toString('base64url')
15
+ const priv = Buffer.from(await subtle.exportKey('pkcs8', kp.privateKey)).toString('base64url')
16
+ writeFileSync(KEYFILE, `fc1sk.${priv}\n`, { flag: 'wx', mode: 0o600 })
17
+ console.log(`fc1pk.${pub}`)
18
+ console.error(`Private key written to ${KEYFILE}. Keep it safe: without it, submissions can't be read.`)
19
+ }
20
+
21
+ async function loadKey() {
22
+ const s = (process.env.FORMCRYPT_KEY ?? (existsSync(KEYFILE) ? readFileSync(KEYFILE, 'utf8') : '')).trim()
23
+ if (!s.startsWith('fc1sk.')) fail(`no private key: put it in ./${KEYFILE} or in FORMCRYPT_KEY`)
24
+ return subtle.importKey('pkcs8', Buffer.from(s.slice(6), 'base64url'), P256, false, ['deriveBits'])
25
+ }
26
+
27
+ // Opens one envelope sealed under this field name. Throws if the key or the name is wrong.
28
+ async function open(envelope, privateKey, name) {
29
+ const raw = Buffer.from(envelope.slice(4), 'base64url')
30
+ const ephRaw = raw.subarray(0, 65)
31
+ const eph = await subtle.importKey('raw', ephRaw, P256, false, [])
32
+ const bits = await subtle.deriveBits({ name: 'ECDH', public: eph }, privateKey, 256)
33
+ const hkdf = await subtle.importKey('raw', bits, 'HKDF', false, ['deriveKey'])
34
+ const key = await subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: ephRaw, info: Buffer.from('formcrypt/v1') },
35
+ hkdf, { name: 'AES-GCM', length: 256 }, false, ['decrypt'])
36
+ const pt = await subtle.decrypt({ name: 'AES-GCM', iv: raw.subarray(65, 77), additionalData: Buffer.from(name) }, key, raw.subarray(77))
37
+ return Buffer.from(pt).toString('utf8')
38
+ }
39
+
40
+ // RFC 4180: quoted fields, "" escapes, line breaks inside quotes.
41
+ function parseCSV(text) {
42
+ const rows = []
43
+ let row = [], field = '', quoted = false
44
+ for (let i = 0; i < text.length; i++) {
45
+ const c = text[i]
46
+ if (quoted) {
47
+ if (c !== '"') field += c
48
+ else if (text[i + 1] === '"') { field += '"'; i++ }
49
+ else quoted = false
50
+ } else if (c === '"') quoted = true
51
+ else if (c === ',') { row.push(field); field = '' }
52
+ else if (c === '\n' || c === '\r') {
53
+ if (c === '\r' && text[i + 1] === '\n') i++
54
+ row.push(field); rows.push(row); row = []; field = ''
55
+ } else field += c
56
+ }
57
+ if (field || row.length) { row.push(field); rows.push(row) }
58
+ return rows
59
+ }
60
+ const cell = (v) => (/[",\r\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v)
61
+ const toCSV = (rows) => rows.map((r) => r.map(cell).join(',')).join('\n') + '\n'
62
+
63
+ // Every cell that looks like an envelope is decrypted in place under its column name. Nothing else changes.
64
+ async function decrypt(input, output = input.replace(/\.csv$/i, '') + '.decrypted.csv') {
65
+ if (existsSync(output)) fail(`${output} already exists; not overwriting it`)
66
+ const [head, ...body] = parseCSV(readFileSync(input, 'utf8').replace(/^/, ''))
67
+ const key = await loadKey()
68
+ let found = 0, failed = 0
69
+ for (const row of body) {
70
+ for (let i = 0; i < row.length; i++) {
71
+ const v = row[i].trim()
72
+ if (!ENVELOPE.test(v)) continue
73
+ found++
74
+ try { row[i] = await open(v, key, head[i] ?? '') } catch { failed++ }
75
+ }
76
+ }
77
+ writeFileSync(output, toCSV([head, ...body]), { flag: 'wx' })
78
+ console.error(`Wrote ${output}: ${body.length} rows, ${found} encrypted cells${failed ? `, ${failed} cells could not be decrypted` : ''}. It contains plaintext.`)
79
+ if (failed) process.exitCode = 1
80
+ }
81
+
82
+ const [a, b] = process.argv.slice(2)
83
+ if (a === 'keygen') await keygen()
84
+ else if (a && !a.startsWith('-')) await decrypt(a, b)
85
+ else console.error('usage: formdecrypt keygen | formdecrypt <export.csv> [out.csv]')
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "formdecrypt",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Create formcrypt keys and decrypt the encrypted cells of a CSV export in place, locally.",
5
+ "type": "module",
6
+ "bin": {
7
+ "formdecrypt": "formdecrypt.js"
8
+ },
9
+ "files": [
10
+ "formdecrypt.js"
11
+ ],
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "scripts": {
16
+ "test": "node --test"
17
+ },
18
+ "keywords": [
19
+ "form",
20
+ "encryption",
21
+ "decrypt",
22
+ "csv",
23
+ "netlify",
24
+ "formcrypt"
25
+ ],
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/camilodelvasto/formcrypt.git",
30
+ "directory": "formdecrypt"
31
+ },
32
+ "homepage": "https://github.com/camilodelvasto/formcrypt/tree/main/formdecrypt#readme",
33
+ "bugs": "https://github.com/camilodelvasto/formcrypt/issues"
34
+ }