formdecrypt 0.0.0-stage → 0.3.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 +27 -2
- package/formdecrypt.js +85 -0
- package/package.json +32 -4
package/README.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
-
#
|
|
1
|
+
# formdecrypt
|
|
2
2
|
|
|
3
|
-
|
|
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-` + lowercase hex 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.3.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-[0-9a-f]{186,}$/
|
|
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), 'hex')
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.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
|
+
}
|