formcrypt 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.
- package/README.md +35 -2
- package/formcrypt.js +91 -0
- package/package.json +23 -4
package/README.md
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
-
#
|
|
1
|
+
# formcrypt
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Encrypts the form fields you choose in the visitor's browser, each on its own, then lets the form submit as usual. Your form host (Netlify Forms, Formspree, your own server) sees the same fields as before, holding ciphertext instead of values. You decrypt on your own computer with [formdecrypt](https://github.com/camilodelvasto/formcrypt/tree/main/formdecrypt).
|
|
4
|
+
|
|
5
|
+
## Use
|
|
6
|
+
|
|
7
|
+
Create a key pair with `npx formdecrypt keygen`, then:
|
|
8
|
+
|
|
9
|
+
```html
|
|
10
|
+
<script src="https://cdn.jsdelivr.net/npm/formcrypt@0.2" defer></script>
|
|
11
|
+
|
|
12
|
+
<form name="contact" method="POST" data-netlify="true" data-formcrypt="fc1pk.…your public key…">
|
|
13
|
+
<input type="hidden" name="form-name" value="contact">
|
|
14
|
+
|
|
15
|
+
<fieldset data-formcrypt disabled> <!-- encrypted -->
|
|
16
|
+
<input name="name">
|
|
17
|
+
<input name="email" type="email" required>
|
|
18
|
+
<textarea name="message"></textarea>
|
|
19
|
+
</fieldset>
|
|
20
|
+
|
|
21
|
+
<button>Send</button>
|
|
22
|
+
</form>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- Fields inside `<fieldset data-formcrypt>` are encrypted, each under its own name. Everything outside, including honeypots and CAPTCHA tokens, is sent unchanged.
|
|
26
|
+
- Keep `disabled` on the fieldset. The script enables it; without the script, those fields can't be sent.
|
|
27
|
+
- Needs HTTPS. File inputs aren't supported.
|
|
28
|
+
|
|
29
|
+
## What it protects
|
|
30
|
+
|
|
31
|
+
- **Protects:** the encrypted fields wherever the host keeps them: dashboard, exports, API, notification emails.
|
|
32
|
+
- **Doesn't hide** IP, user agent, time, fields you didn't encrypt, which fields are empty, or roughly how long values are.
|
|
33
|
+
- **Doesn't protect against** whoever serves this script or your page; they could swap the key. Self-host the file if that matters to you.
|
|
34
|
+
- **Lose the private key and the data is gone.**
|
|
35
|
+
|
|
36
|
+
MIT
|
package/formcrypt.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/*! formcrypt 0.2.0 · MIT · Encrypts marked form fields in the browser, each on its own, before the form is sent. Format v1 (fc1.) */
|
|
2
|
+
(function () {
|
|
3
|
+
'use strict'
|
|
4
|
+
var subtle = globalThis.crypto && globalThis.crypto.subtle
|
|
5
|
+
var P256 = { name: 'ECDH', namedCurve: 'P-256' }
|
|
6
|
+
var enc = new TextEncoder()
|
|
7
|
+
|
|
8
|
+
function b64(u8) {
|
|
9
|
+
var s = ''
|
|
10
|
+
for (var i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i])
|
|
11
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
12
|
+
}
|
|
13
|
+
function unb64(s) {
|
|
14
|
+
s = s.replace(/-/g, '+').replace(/_/g, '/')
|
|
15
|
+
return Uint8Array.from(atob(s + '==='.slice((s.length + 3) % 4)), function (c) { return c.charCodeAt(0) })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Encrypts one string for the public key. The field name is authenticated, not encrypted:
|
|
19
|
+
// a ciphertext only opens under the name it was sealed with.
|
|
20
|
+
async function seal(value, publicKey, name) {
|
|
21
|
+
if (!/^fc1pk\./.test(publicKey)) throw new TypeError('expected a public key (fc1pk.…)')
|
|
22
|
+
var pub = await subtle.importKey('raw', unb64(publicKey.slice(6)), P256, false, [])
|
|
23
|
+
var eph = await subtle.generateKey(P256, false, ['deriveBits'])
|
|
24
|
+
var ephRaw = new Uint8Array(await subtle.exportKey('raw', eph.publicKey))
|
|
25
|
+
var bits = await subtle.deriveBits({ name: 'ECDH', public: pub }, eph.privateKey, 256)
|
|
26
|
+
var hkdf = await subtle.importKey('raw', bits, 'HKDF', false, ['deriveKey'])
|
|
27
|
+
var key = await subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: ephRaw, info: enc.encode('formcrypt/v1') },
|
|
28
|
+
hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt'])
|
|
29
|
+
var iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
|
|
30
|
+
var ct = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv: iv, additionalData: enc.encode(name) }, key, enc.encode(value)))
|
|
31
|
+
var out = new Uint8Array(77 + ct.length)
|
|
32
|
+
out.set(ephRaw, 0)
|
|
33
|
+
out.set(iv, 65)
|
|
34
|
+
out.set(ct, 77)
|
|
35
|
+
return 'fc1.' + b64(out)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
globalThis.formcrypt = { seal: seal }
|
|
39
|
+
if (typeof document === 'undefined') return
|
|
40
|
+
|
|
41
|
+
var passing = new WeakSet()
|
|
42
|
+
|
|
43
|
+
// Marked fieldsets ship disabled, so without this script (or without Web Crypto) they can't be sent.
|
|
44
|
+
function reset() {
|
|
45
|
+
if (!subtle) return
|
|
46
|
+
document.querySelectorAll('form[data-formcrypt] fieldset[data-formcrypt]').forEach(function (f) { f.disabled = false })
|
|
47
|
+
document.querySelectorAll('form[data-formcrypt] input[data-formcrypt]').forEach(function (i) { i.remove() })
|
|
48
|
+
}
|
|
49
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', reset)
|
|
50
|
+
else reset()
|
|
51
|
+
addEventListener('pageshow', function (e) { if (e.persisted) reset() })
|
|
52
|
+
|
|
53
|
+
// Capture phase on document: runs before any handler on the form, and needs no DOM-ready wait.
|
|
54
|
+
document.addEventListener('submit', async function (e) {
|
|
55
|
+
var form = e.target
|
|
56
|
+
if (!(form instanceof HTMLFormElement) || !form.hasAttribute('data-formcrypt') || passing.has(form)) return
|
|
57
|
+
e.preventDefault()
|
|
58
|
+
e.stopImmediatePropagation()
|
|
59
|
+
try {
|
|
60
|
+
var key = form.getAttribute('data-formcrypt')
|
|
61
|
+
var sets = form.querySelectorAll('fieldset[data-formcrypt]')
|
|
62
|
+
var fd = new FormData(form)
|
|
63
|
+
var names = []
|
|
64
|
+
sets.forEach(function (set) {
|
|
65
|
+
Array.prototype.forEach.call(set.elements, function (el) {
|
|
66
|
+
if (el.name && names.indexOf(el.name) < 0) names.push(el.name)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
for (var i = 0; i < names.length; i++) {
|
|
70
|
+
var values = fd.getAll(names[i])
|
|
71
|
+
for (var j = 0; j < values.length; j++) {
|
|
72
|
+
if (typeof values[j] !== 'string') throw new Error('file inputs are not supported')
|
|
73
|
+
var hidden = document.createElement('input')
|
|
74
|
+
hidden.type = 'hidden'
|
|
75
|
+
hidden.name = names[i]
|
|
76
|
+
hidden.value = await seal(values[j], key, names[i])
|
|
77
|
+
hidden.setAttribute('data-formcrypt', '')
|
|
78
|
+
form.appendChild(hidden)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
sets.forEach(function (f) { f.disabled = true }) // disabled fields are never submitted
|
|
82
|
+
// Web Crypto can finish inside the original submit dispatch, where Chrome ignores requestSubmit().
|
|
83
|
+
await new Promise(function (r) { setTimeout(r) })
|
|
84
|
+
passing.add(form)
|
|
85
|
+
try { form.requestSubmit(e.submitter) } finally { passing.delete(form) }
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.error('formcrypt: nothing was sent.', err)
|
|
88
|
+
reset()
|
|
89
|
+
}
|
|
90
|
+
}, true)
|
|
91
|
+
})()
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formcrypt",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Encrypt chosen form fields in the browser, each under its own name, before the form is sent. Works with any form host. No config, no dependencies.",
|
|
5
|
+
"main": "formcrypt.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"formcrypt.js"
|
|
8
|
+
],
|
|
9
|
+
"keywords": [
|
|
10
|
+
"form",
|
|
11
|
+
"encryption",
|
|
12
|
+
"privacy",
|
|
13
|
+
"netlify",
|
|
14
|
+
"static-site",
|
|
15
|
+
"webcrypto"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/camilodelvasto/formcrypt.git",
|
|
21
|
+
"directory": "formcrypt"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/camilodelvasto/formcrypt/tree/main/formcrypt#readme",
|
|
24
|
+
"bugs": "https://github.com/camilodelvasto/formcrypt/issues"
|
|
25
|
+
}
|