forro

Forro is a end-to-end encrypted contract form based on PGP.
git clone git://git.defalsify.org/forro.git
Log | Files | Refs | LICENSE

commit 9b7e740f6f69914966d34a5f234278b54446cfde
parent 993bccbbdbc232eefe58ae5d3bdd00cf97299395
Author: lash <dev@holbrook.no>
Date:   Sun,  9 Oct 2022 14:56:08 +0000

Implement multipart payload!

Diffstat:
MROADMAP | 1+
Aapp.js | 517+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mindex.html | 615++++++++-----------------------------------------------------------------------
Mkey.js | 4++--
Mpackage.json | 5+++--
Atop.js | 44++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 630 insertions(+), 556 deletions(-)

diff --git a/ROADMAP b/ROADMAP @@ -2,3 +2,4 @@ * UX to add multiple parts for payload, including images - 0.0.6 * Multipart payload + * encrypt private key with password on export diff --git a/app.js b/app.js @@ -0,0 +1,517 @@ +// Thanks to: +// https://stackoverflow.com/questions/40031688/javascript-arraybuffer-to-hex +function buf2hex(buffer) { // buffer is an ArrayBuffer + return [...new Uint8Array(buffer)] + .map(x => x.toString(16).padStart(2, '0')) + .join(''); +} + +function msg_identifier() { + return 'msg' + g_counter; +} + +function pubkey_identifier() { + return PUBKEY_PFX + g_remote_key.getFingerprint(); +} + +function counter_identifier() { + return 'msgidx'; +} + +function debugState(state) { + let s = ''; + for (let i = 0; i < STATE_KEYS.length; i++) { + const v = 1 << i; + if (checkState(state, v)) { + const k = STATE_KEYS[i]; + if (s.length > 0) { + s += ', '; + } + s += k; + } + } + return s; +}; + +function checkState(bit_check, bit_field) { + if (bit_field != 0 && !bit_field) { + bit_field = g_state; + } + return (bit_check & bit_field) > 0; +}; + +async function loadSettings() { + let rs = await fetch(window.location.href + '/settings.json', { + method: 'GET', + }); + if (!rs.ok) { + stateChange('could not load settings'); + throw('could not load settings'); + } + return await rs.json(); +} + +function getEffectiveName(k) { + let kl = k.toPacketList(); + let klf = kl.filterByTag(openpgp.enums.packet.userID); + if (klf.length > 1) { + stateChange('local key has been identified', STATE["LOCAL_KEY_IDENTIFIED"]); + g_local_key_identified = true; + } + return klf[klf.length-1].name; +} + +async function unlockLocalKey(pwd) { + let state = []; + try { + g_local_key = await getKey(pwd); + state.push(STATE['LOCAL_KEY']); + } catch(e) { + stateChange('could not unlock key (passphrase: ' + (pwd !== undefined) + '). Reason: ' + e); + return false; + } + const decrypted = g_local_key.isDecrypted() + if (decrypted) { + state.push(STATE['LOCAL_KEY_DECRYPTED']); + } + + stateChange('found key ' + g_local_key.getKeyID().toHex() + ' (decrypted: ' + decrypted + ')', state); + return decrypted; +} + +async function applyLocalKey() { + g_local_key_id = g_local_key.getKeyID().toHex(); + g_local_key_name = getEffectiveName(g_local_key); + + stateChange('load counter'); + let c = localStorage.getItem('msg-count'); + if (c == null) { + g_counter = 0; + } else { + g_counter = parseInt(c); + } + stateChange('ready to send', STATE['RTS']); +} + +async function setUp() { + let settings = await loadSettings(); + if (settings.dev) { + stateChange("devmode on", STATE.DEV); + } + if (settings.help) { + stateChange("helpmode on", STATE.HELP); + } + + if (settings.data_endpoint !== undefined) { + g_data_endpoint = settings.data_endpoint; + stateChange('updated data endpoint to ' + settings.data_endpoint); + } + + stateChange('loaded settings', STATE['SETTINGS']); + let r = await fetch(settings.remote_pubkey_url); + let remote_key_src = await r.text(); + let remote_key = await openpgp.readKey({ + armoredKey: remote_key_src, + }); + g_remote_key = remote_key; + g_remote_key_id = g_remote_key.getKeyID().toHex(); + g_remote_key.getPrimaryUser().then((v) => { + g_remote_key_name = v.user.userID.name; + g_remote_key_email = v.user.userID.email; + stateChange('loaded remote encryption key', STATE['REMOTE_KEY']); + }); +} + +async function stateChange(s, set_states, rst_states) { + if (!set_states) { + set_states = []; + } else if (!Array.isArray(set_states)) { + set_states = [set_states]; + } + if (!rst_states) { + rst_states = []; + } else if (!Array.isArray(rst_states)) { + rst_states = [rst_states]; + } + let new_state = g_state; + for (let i = 0; i < set_states.length; i++) { + let state = parseInt(set_states[i]); + new_state |= state; + } + for (let i = 0; i < rst_states.length; i++) { + let state = parseInt(set_states[i]); + new_state = new_state & (0xffffffff & ~rst_states[i]); + } + old_state = g_state; + g_state = new_state; + + const ev = new CustomEvent('messagestatechange', { + bubbles: true, + cancelable: false, + composed: true, + detail: { + s: s, + c: g_counter, + kr: g_remote_key_id, + nr: g_remote_key_name, + kl: g_local_key_id, + nl: g_local_key_name, + old_state: old_state, + state: new_state, + }, + }); + window.dispatchEvent(ev); +} + +async function tryDispatch(s, name, email, files) { + stateChange('starting dispatch', undefined, [STATE['RTS'], STATE['SEND_ERROR']]); + console.debug('files', Object.keys(files)); + let r = undefined; + try { + r = await dispatch(s, name, email, files) + stateChange('ready to send again', STATE['RTS']); + } catch(e) { + console.error(e); + stateChange('send fail: ' + e, STATE['SEND_ERROR']); + r = 'failed'; + } + return r; +} + +function getPassphrase() { + return g_passphrase; +} + +async function tryIdentify(name, email) { + if (g_local_key_identified) { + return false; + } + g_local_key = await identify(g_local_key, name, email, getPassphrase()); + g_local_key_name = getEffectiveName(g_local_key); + await stateChange('apply name change: ' + g_local_key_name); + console.debug('updated public key', g_local_key.toPublic().armor()); + g_local_key_identified = true; +} + +async function dispatch(s, name, email, files) { + if (name) { + if (!validateEmail(email)) { + throw 'invalid email: ' + email; + } + await tryIdentify(name, email); + } + + const pubkey = g_local_key.toPublic(); + const payload = await buildMessage(s, files, pubkey); + + let pfx = msg_identifier(); + let pfx_pub = pubkey_identifier(); + let pfx_count = counter_identifier(); + + stateChange('sign and encrypt message ' + g_counter); + const sha_raw = new jsSHA("SHA-256", "TEXT", { encoding: "UTF8" }); + sha_raw.update(s); + const digest = sha_raw.getHash("HEX"); + console.debug('digest for unencrypted message:', digest); + + // this is done twice, improve + const rcpt_pubkey_verify = await generatePointer(g_local_key, pfx_pub); + console.debug('pointer for pubkey', rcpt_pubkey_verify); + + const msg_sig = await signMessage(payload); + stateChange([g_counter, digest], STATE['SIG_MESSAGE']); + + const msg = await openpgp.createMessage({ + text: payload, + }); + let r_enc = await encryptMessage(msg, pfx); + stateChange([g_counter, digest, r_enc.rcpt], STATE['ENC_MESSAGE']); + let rcpt = await dispatchToEndpoint(r_enc, pfx); + stateChange([g_counter, rcpt], STATE['ACK_MESSAGE']); + + let r_count = await encryptCounter(g_counter, pfx_count); + g_files = {}; + stateChange([g_counter, r_count.rcpt], STATE['ENC_COUNTER'], [ + STATE.ACK_MESSAGE, + STATE.ENC_MESSAGE, + ]); + let rcpt_count = await dispatchToEndpoint(r_count, pfx_count); + stateChange([g_counter, rcpt_count], STATE['ACK_COUNTER']); + + g_counter += 1; + + localStorage.setItem('msg-count', g_counter); + + //const r_enc_pub = await encryptPublicKey(g_local_key, pfx_pub); +// stateChange([rcpt_pubkey_verify, r_enc_pub.rcpt], STATE['ENC_PUBKEY'], [ +// STATE.ACK_COUNTER, +// STATE.ENC_COUNTER, +// ]); +// let rcpt_pubkey = await dispatchToEndpoint(r_enc_pub, pfx_pub); +// stateChange(rcpt_pubkey, STATE['ACK_PUBKEY']); + +// stateChange('dispatch complete. next message is ' + g_counter, undefined, [ +// STATE.ACK_PUBKEY, +// STATE.ENC_PUBKEY, +// ]); + + stateChange('dispatch complete. next message is ' + g_counter, undefined, [ + STATE.ACK_PUBKEY, + STATE.ENC_PUBKEY, + STATE.ACK_COUNTER, + STATE.ENC_COUNTER, + ]); + return rcpt; +} + +async function signMessage(payload) { + const msg = await openpgp.createMessage({ + text: payload, + }); + let msg_sig_inner = await openpgp.sign({ + signingKeys: g_local_key, + message: msg, + format: 'binary', + }); + + const msg_sig = await openpgp.createMessage({ + binary: msg_sig_inner, + }); + return msg_sig; +} + +async function encryptCounter(c, pfx) { + const msg_count = await openpgp.createMessage({ + text: '' + g_counter, + }); + + const enc_count = await openpgp.encrypt({ + encryptionKeys: g_local_key, + format: 'binary', + message: msg_count, + }); + let envelope_count = await openpgp.createMessage({ + binary: enc_count, + }); + + const auth = await generateAuth(g_local_key, envelope_count); + + const rcpt_count_verify = await generatePointer(g_local_key, pfx); + + return { + msg: enc_count, + auth: auth, + rcpt: rcpt_count_verify, + }; + +} + +async function encryptPublicKey(k, pfx) { + const pubkey_bin = g_local_key.toPublic().write(); + const msg_pubkey = await openpgp.createMessage({ + binary: pubkey_bin, + }); + + const enc_pubkey = await openpgp.encrypt({ + encryptionKeys: g_remote_key, + format: 'binary', + message: msg_pubkey, + }); + let envelope_pubkey = await openpgp.createMessage({ + binary: enc_pubkey, + }); + + const auth = await generateAuth(g_local_key, envelope_pubkey); + + const rcpt_pubkey_verify = await generatePointer(g_local_key, pfx); + + return { + msg: enc_pubkey, + auth: auth, + rcpt: rcpt_pubkey_verify, + }; +} + +async function dispatchToEndpoint(o, pfx) { + let res = await fetch(g_data_endpoint + '/' + pfx, { + method: 'PUT', + body: o.msg, + headers: { + 'Content-Type': 'application/octet-stream', + 'Authorization': 'PUBSIG ' + o.auth, + } + }); + + rcpt_remote = await res.text(); + + if (o.rcpt) { + if (rcpt_remote.toLowerCase() != o.rcpt.toLowerCase()) { + throw "mutable ref mismatch between local and server; " + o.rcpt + " != " + rcpt_remote; + } + } else { + console.warn('have no digest to check server reply against'); + } + return rcpt_remote; +} + +async function encryptMessage(msg, pfx) { + const enckey_local = await g_local_key.getEncryptionKey(); + const enckey_remote = await g_remote_key.getEncryptionKey(); + + const enc = await openpgp.encrypt({ + encryptionKeys: [g_remote_key, g_local_key], + signingKeys: [g_local_key], + format: 'binary', + message: msg, + }); + + console.debug('encrypted for keys', enckey_local.getKeyID().toHex(), enckey_remote.getKeyID().toHex()); + let envelope = await openpgp.createMessage({ + binary: enc, + }); + + const auth = await generateAuth(g_local_key, envelope); + + const rcpt = await generatePointer(g_local_key, pfx); + console.debug('digest for encrypted message:', rcpt); + + return { + msg: enc, + rcpt: rcpt, + auth: auth, + }; +} + +async function createLocalKey(pwd) { + stateChange('generate new local signing key', STATE["LOCAL_KEY_GENERATE"]); + const uid = { + name: generateName(), + email: 'foo@devnull.holbrook.no', + }; + g_local_key = await generatePGPKey(pwd, uid); + stateChange('new local signing key named ' + uid.name, STATE["LOCAL_KEY"], STATE["LOCAL_KEY_GENERATE"]); +} + +async function setPwd(pwd) { + stateChange('attempt password set', undefined, STATE['PASSPHRASE_FAIL']); + if (!pwd) { + pwd = undefined; + } + if (pwd === undefined) { + if (g_local_key === undefined) { + g_passphrase_use = false; + await createLocalKey(); + } + } else if (g_local_key === undefined) { + await createLocalKey(pwd); + } + let r = await unlockLocalKey(pwd); + if (!r) { + stateChange('key unlock fail', STATE['PASSPHRASE_FAIL']); + return false; + } + if (pwd !== undefined) { + stateChange('passphrase validated', STATE['PASSPHRASE_ACTIVE']); + } + applyLocalKey(); + g_passphrase = pwd; + g_passphrase_time = Date.now(); + return r; +} + +function purgeLocalKey() { + key_id = g_local_key_id; + localStorage.removeItem('pgp-key'); + localStorage.removeItem('msg-count'); + g_local_key = undefined; + g_local_key_id = undefined; + g_local_key_identified = false; + g_counter = 0; + g_passphrase = undefined; + g_passphrase_time = new Date(0); + const purgeResetStates = [ + STATE["LOCAL_KEY"], + STATE["LOCAL_KEY_DECRYPTED"], + STATE["LOCAL_KEY_IDENTIFIED"], + STATE["PASSPHRASE_STORED"], + STATE["RTS"], + STATE["SEND_ERROR"], + ]; + stateChange('deleted local key ' + key_id, undefined, purgeResetStates); + return true; +} + +async function fileChange(e) { + let fileButton = document.getElementById("fileAdder") + let file = fileButton.files[0]; + stateChange('processing file: ' + file.name, STATE.FILE_PROCESS); + if (file) { + let f = new FileReader(); + f.onloadend = (r) => { + let contents = btoa(r.target.result); + const sha_raw = new jsSHA("SHA-256", "TEXT", { encoding: "UTF8" }); + sha_raw.update(contents); + const digest = sha_raw.getHash("HEX"); + g_files[digest] = contents; + stateChange([digest, file.name], STATE.FILE_ADDED, STATE.FILE_PROCESS); + stateChange('file added: ' + file.name + ' = ' + digest, undefined, STATE['FILE_ADDED']); + }; + f.readAsBinaryString(file); + } +} + +async function tryHelpFor(...k) { + //if (!checkState(STATE.HELP)) { + // return; + //} + const r = await helpFor(g_helpstate, g_state, k); + g_helpstate = r.state; + const ev = new CustomEvent('help', { + bubbles: true, + cancelable: false, + composed: true, + detail: { + v: r.v, + }, + }); + window.dispatchEvent(ev); +} + +async function buildMessage(message, files, pubkey) { + let msg = { + fromName: 'Forro v' + g_version, + from: g_from, + to: g_remote_key_email, + subject: 'contact form message', + body: "Please see attachments", + cids: [], + attaches: [], + }; + for (v in files) { + const data = v.target; + const attach = { + name: files[v], + type: "application/octet-stream", + base64: g_files[v], + }; + msg.attaches.push(attach); + } + const pubkey_attach = { + name: "pubkey.asc", + type: "application/octet-stream", + raw: pubkey.armor(), + }; + msg.attaches.push(pubkey_attach); + const msg_mime = Mime.toMimeTxt(msg); + console.debug(msg_mime); + return msg_mime; +} + +window.addEventListener('messagestatechange', (v) => { + state_change = (~v.detail.old_state) & v.detail.state; + let s = v.detail.s; + if (Array.isArray(s)) { + s = '[' + s.join(', ') + ']'; + } + console.debug('message state change:', [s, v.detail.state, debugState(v.detail.state), state_change, debugState(state_change)]); +}); diff --git a/index.html b/index.html @@ -1,538 +1,39 @@ <html> <head> <script> -const PUBKEY_PFX = 'pgp.publickey'; -const STATE = { - DEV: 1 << 0, - PANIC: 1 << 1, - RTS: 1 << 2, - SEND_ERROR: 1 << 3, - SETTINGS: 1 << 4, - REMOTE_KEY: 1 << 5, - LOCAL_KEY: 1 << 6, - LOCAL_KEY_DECRYPTED: 1 << 7, - LOCAL_KEY_IDENTIFIED: 1 << 8, - LOCAL_KEY_GENERATE: 1 << 9, - PASSPHRASE_ACTIVE: 1 << 10, - PASSPHRASE_FAIL: 1 << 11, - ACK_MESSAGE: 1 << 12, - ENC_MESSAGE: 1 << 13, - ACK_PUBKEY: 1 << 14, - ENC_PUBKEY: 1 << 15, - ACK_COUNTER: 1 << 16, - ENC_COUNTER: 1 << 17, - HELP: 1 << 18, - FILE_PROCESS: 1 << 19, - FILE_ADDED: 1 << 20, -}; -const STATE_KEYS = Object.keys(STATE); - -let g_passphrase = undefined; -let g_passphrase_use = true; -let g_passphrase_time = 0; -let g_remote_key = undefined; -let g_local_key = undefined; -let g_remote_key_id = '(none)'; -let g_remote_key_name = '?'; -let g_local_key_id = '(none)'; -let g_local_key_name = '?'; -let g_local_key_identified = false; -let g_data_endpoint = window.location.href; -let g_state = 0; -let g_helpstate = 0; -let g_counter = undefined; -let g_files = {}; - +// drop-in replacement for the Base64 object used by the mime-js repo. +var Base64 = { + encode: (v, n) => { + console.debug("encode", v, n); + if (!v) + return ""; + if (n) + v = new TextEncoder().encode(v); + r = new TextDecoder().decode(v); + return btoa(r); + }, + decode: (v, n) => { + console.debug("decode", v, n); + if (!v) + return ""; + r = atob(v); + if (n) + r = new TextDecoder().decode(r); + return r; + }, +} </script> + + <script defer src="node_modules/alpinejs/dist/cdn.min.js"></script> <script src="node_modules/openpgp/dist/openpgp.min.js"></script> <script src="node_modules/jssha/dist/sha256.js"></script> - <script defer src="node_modules/alpinejs/dist/cdn.min.js"></script> + <script src="node_modules/MimeJS/dist/mime-js.min.js"></script> + <script src="top.js"></script> <script src="key.js"></script> <script src="name.js"></script> <script src="help.js"></script> - <script> - - // Thanks to: - // https://stackoverflow.com/questions/40031688/javascript-arraybuffer-to-hex - function buf2hex(buffer) { // buffer is an ArrayBuffer - return [...new Uint8Array(buffer)] - .map(x => x.toString(16).padStart(2, '0')) - .join(''); - } - - function msg_identifier() { - return 'msg' + g_counter; - } - - function pubkey_identifier() { - return PUBKEY_PFX + g_remote_key.getFingerprint(); - } - - function counter_identifier() { - return 'msgidx'; - } - - function debugState(state) { - let s = ''; - for (let i = 0; i < STATE_KEYS.length; i++) { - const v = 1 << i; - if (checkState(state, v)) { - const k = STATE_KEYS[i]; - if (s.length > 0) { - s += ', '; - } - s += k; - } - } - return s; - }; - - function checkState(bit_check, bit_field) { - if (bit_field != 0 && !bit_field) { - bit_field = g_state; - } - return (bit_check & bit_field) > 0; - }; - - async function loadSettings() { - let rs = await fetch(window.location.href + '/settings.json', { - method: 'GET', - }); - if (!rs.ok) { - stateChange('could not load settings'); - throw('could not load settings'); - } - return await rs.json(); - } - - function getEffectiveName(k) { - let kl = k.toPacketList(); - let klf = kl.filterByTag(openpgp.enums.packet.userID); - if (klf.length > 1) { - stateChange('local key has been identified', STATE["LOCAL_KEY_IDENTIFIED"]); - g_local_key_identified = true; - } - return klf[klf.length-1].name; - } - - async function unlockLocalKey(pwd) { - let state = []; - try { - g_local_key = await getKey(pwd); - state.push(STATE['LOCAL_KEY']); - } catch(e) { - stateChange('could not unlock key (passphrase: ' + (pwd !== undefined) + '). Reason: ' + e); - return false; - } - const decrypted = g_local_key.isDecrypted() - if (decrypted) { - state.push(STATE['LOCAL_KEY_DECRYPTED']); - } - - stateChange('found key ' + g_local_key.getKeyID().toHex() + ' (decrypted: ' + decrypted + ')', state); - return decrypted; - } - - async function applyLocalKey() { - g_local_key_id = g_local_key.getKeyID().toHex(); - g_local_key_name = getEffectiveName(g_local_key); - - stateChange('load counter'); - let c = localStorage.getItem('msg-count'); - if (c == null) { - g_counter = 0; - } else { - g_counter = parseInt(c); - } - stateChange('ready to send', STATE['RTS']); - } - - async function setUp() { - let settings = await loadSettings(); - if (settings.dev) { - stateChange("devmode on", STATE.DEV); - } - if (settings.help) { - stateChange("helpmode on", STATE.HELP); - } - - if (settings.data_endpoint !== undefined) { - g_data_endpoint = settings.data_endpoint; - stateChange('updated data endpoint to ' + settings.data_endpoint); - } - - stateChange('loaded settings', STATE['SETTINGS']); - let r = await fetch(settings.remote_pubkey_url); - let remote_key_src = await r.text(); - let remote_key = await openpgp.readKey({ - armoredKey: remote_key_src, - }); - g_remote_key = remote_key; - g_remote_key_id = g_remote_key.getKeyID().toHex(); - g_remote_key.getPrimaryUser().then((v) => { - g_remote_key_name = v.user.userID.name; - stateChange('loaded remote encryption key', STATE['REMOTE_KEY']); - }); - } - - async function stateChange(s, set_states, rst_states) { - if (!set_states) { - set_states = []; - } else if (!Array.isArray(set_states)) { - set_states = [set_states]; - } - if (!rst_states) { - rst_states = []; - } else if (!Array.isArray(rst_states)) { - rst_states = [rst_states]; - } - let new_state = g_state; - for (let i = 0; i < set_states.length; i++) { - let state = parseInt(set_states[i]); - new_state |= state; - } - for (let i = 0; i < rst_states.length; i++) { - let state = parseInt(set_states[i]); - new_state = new_state & (0xffffffff & ~rst_states[i]); - } - old_state = g_state; - g_state = new_state; - - const ev = new CustomEvent('messagestatechange', { - bubbles: true, - cancelable: false, - composed: true, - detail: { - s: s, - c: g_counter, - kr: g_remote_key_id, - nr: g_remote_key_name, - kl: g_local_key_id, - nl: g_local_key_name, - old_state: old_state, - state: new_state, - }, - }); - window.dispatchEvent(ev); - } - - async function tryDispatch(s, name, email) { - stateChange('starting dispatch', undefined, [STATE['RTS'], STATE['SEND_ERROR']]); - let r = undefined; - try { - r = await dispatch(s, name, email) - stateChange('ready to send again', STATE['RTS']); - } catch(e) { - console.error(e); - stateChange('send fail: ' + e, STATE['SEND_ERROR']); - r = 'failed'; - } - return r; - } - - function getPassphrase() { - return g_passphrase; - } - - async function tryIdentify(name, email) { - if (g_local_key_identified) { - return false; - } - g_local_key = await identify(g_local_key, name, email, getPassphrase()); - g_local_key_name = getEffectiveName(g_local_key); - await stateChange('apply name change: ' + g_local_key_name); - console.debug('updated public key', g_local_key.toPublic().armor()); - g_local_key_identified = true; - } - - async function dispatch(s, name, email) { - if (name) { - if (!validateEmail(email)) { - throw 'invalid email: ' + email; - } - await tryIdentify(name, email); - } - - let pfx = msg_identifier(); - let pfx_pub = pubkey_identifier(); - let pfx_count = counter_identifier(); - - stateChange('sign and encrypt message ' + g_counter); - const sha_raw = new jsSHA("SHA-256", "TEXT", { encoding: "UTF8" }); - sha_raw.update(s); - const digest = sha_raw.getHash("HEX"); - console.debug('digest for unencrypted message:', digest); - - // this is done twice, improve - const rcpt_pubkey_verify = await generatePointer(g_local_key, pfx_pub); - console.debug('pointer for pubkey', rcpt_pubkey_verify); - const payload = "msg id: " + pfx + "\npubkey link: " + g_data_endpoint + "/" + rcpt_pubkey_verify + "\n\n" + s; - - const msg_sig = await signMessage(payload); - stateChange([g_counter, digest], STATE['SIG_MESSAGE']); - - let r_enc = await encryptMessage(msg_sig, pfx); - stateChange([g_counter, digest, r_enc.rcpt], STATE['ENC_MESSAGE']); - let rcpt = await dispatchToEndpoint(r_enc, pfx); - stateChange([g_counter, rcpt], STATE['ACK_MESSAGE']); - - let r_count = await encryptCounter(g_counter, pfx_count); - stateChange([g_counter, r_count.rcpt], STATE['ENC_COUNTER']); - let rcpt_count = await dispatchToEndpoint(r_count, pfx_count); - stateChange([g_counter, rcpt_count], STATE['ACK_COUNTER']); - - g_counter += 1; - - localStorage.setItem('msg-count', g_counter); - - const r_enc_pub = await encryptPublicKey(g_local_key, pfx_pub); - stateChange([rcpt_pubkey_verify, r_enc_pub.rcpt], STATE['ENC_PUBKEY']); - let rcpt_pubkey = await dispatchToEndpoint(r_enc_pub, pfx_pub); - stateChange(rcpt_pubkey, STATE['ACK_PUBKEY']); - - stateChange('dispatch complete. next message is ' + g_counter, undefined, [ - STATE.ACK_MESSAGE, - STATE.ENC_MESSAGE, - STATE.ACK_PUBKEY, - STATE.ENC_PUBKEY, - ]); - return rcpt; - } - - async function signMessage(payload) { - const msg = await openpgp.createMessage({ - text: payload, - }); - let msg_sig_inner = await openpgp.sign({ - signingKeys: g_local_key, - message: msg, - format: 'binary', - }); - - const msg_sig = await openpgp.createMessage({ - binary: msg_sig_inner, - }); - return msg_sig; - } - - async function encryptCounter(c, pfx) { - const msg_count = await openpgp.createMessage({ - text: '' + g_counter, - }); - - const enc_count = await openpgp.encrypt({ - encryptionKeys: g_local_key, - format: 'binary', - message: msg_count, - }); - let envelope_count = await openpgp.createMessage({ - binary: enc_count, - }); - - const auth = await generateAuth(g_local_key, envelope_count); - - const rcpt_count_verify = await generatePointer(g_local_key, pfx); - - return { - msg: enc_count, - auth: auth, - rcpt: rcpt_count_verify, - }; - - } - - async function encryptPublicKey(k, pfx) { - const pubkey_bin = g_local_key.toPublic().write(); - const msg_pubkey = await openpgp.createMessage({ - binary: pubkey_bin, - }); - - const enc_pubkey = await openpgp.encrypt({ - encryptionKeys: g_remote_key, - format: 'binary', - message: msg_pubkey, - }); - let envelope_pubkey = await openpgp.createMessage({ - binary: enc_pubkey, - }); - - const auth = await generateAuth(g_local_key, envelope_pubkey); - - const rcpt_pubkey_verify = await generatePointer(g_local_key, pfx); - - return { - msg: enc_pubkey, - auth: auth, - rcpt: rcpt_pubkey_verify, - }; - } - - async function dispatchToEndpoint(o, pfx) { - let res = await fetch(g_data_endpoint + '/' + pfx, { - method: 'PUT', - body: o.msg, - headers: { - 'Content-Type': 'application/octet-stream', - 'Authorization': 'PUBSIG ' + o.auth, - } - }); - - rcpt_remote = await res.text(); - - if (o.rcpt) { - if (rcpt_remote.toLowerCase() != o.rcpt.toLowerCase()) { - throw "mutable ref mismatch between local and server; " + o.rcpt + " != " + rcpt_remote; - } - } else { - console.warn('have no digest to check server reply against'); - } - return rcpt_remote; - } - - async function encryptMessage(msg, pfx) { - const enckey_local = await g_local_key.getEncryptionKey(); - const enckey_remote = await g_remote_key.getEncryptionKey(); - - const enc = await openpgp.encrypt({ - encryptionKeys: [g_remote_key, g_local_key], - format: 'binary', - message: msg, - }); - - console.debug('encrypted for keys', enckey_local.getKeyID().toHex(), enckey_remote.getKeyID().toHex()); - let envelope = await openpgp.createMessage({ - binary: enc, - }); - - const auth = await generateAuth(g_local_key, envelope); - - const rcpt = await generatePointer(g_local_key, pfx); - console.debug('digest for encrypted message:', rcpt); - - return { - msg: enc, - rcpt: rcpt, - auth: auth, - }; - } - - async function createLocalKey(pwd) { - stateChange('generate new local signing key', STATE["LOCAL_KEY_GENERATE"]); - const uid = { - name: generateName(), - email: 'foo@devnull.holbrook.no', - }; - g_local_key = await generatePGPKey(pwd, uid); - stateChange('new local signing key named ' + uid.name, STATE["LOCAL_KEY"], STATE["LOCAL_KEY_GENERATE"]); - } - - async function setPwd(pwd) { - stateChange('attempt password set', undefined, STATE['PASSPHRASE_FAIL']); - if (!pwd) { - pwd = undefined; - } - if (pwd === undefined) { - if (g_local_key === undefined) { - g_passphrase_use = false; - await createLocalKey(); - } - } else if (g_local_key === undefined) { - await createLocalKey(pwd); - } - let r = await unlockLocalKey(pwd); - if (!r) { - stateChange('key unlock fail', STATE['PASSPHRASE_FAIL']); - return false; - } - if (pwd !== undefined) { - stateChange('passphrase validated', STATE['PASSPHRASE_ACTIVE']); - } - applyLocalKey(); - g_passphrase = pwd; - g_passphrase_time = Date.now(); - return r; - } - - function purgeLocalKey() { - key_id = g_local_key_id; - localStorage.removeItem('pgp-key'); - localStorage.removeItem('msg-count'); - g_local_key = undefined; - g_local_key_id = undefined; - g_local_key_identified = false; - g_counter = 0; - g_passphrase = undefined; - g_passphrase_time = new Date(0); - const purgeResetStates = [ - STATE["LOCAL_KEY"], - STATE["LOCAL_KEY_DECRYPTED"], - STATE["LOCAL_KEY_IDENTIFIED"], - STATE["PASSPHRASE_STORED"], - STATE["RTS"], - STATE["SEND_ERROR"], - ]; - stateChange('deleted local key ' + key_id, undefined, purgeResetStates); - return true; - } - - async function fileChange(e) { - let fileButton = document.getElementById("fileAdder") - let file = fileButton.files[0]; - stateChange('processing file: ' + file.name, STATE['FILE_PROCESS']); - if (file) { - let f = new FileReader(); - f.onloadend = (r) => { - let contents = btoa(r.target.result); - const sha_raw = new jsSHA("SHA-256", "TEXT", { encoding: "UTF8" }); - sha_raw.update(contents); - const digest = sha_raw.getHash("HEX"); - g_files[digest] = contents; - stateChange([digest, file.name], STATE['FILE_ADDED'], STATE['FILE_PROCESS']); - stateChange('file added: ' + file.name + ' = ' + digest, undefined, STATE['FILE_ADDED']); - }; - f.readAsBinaryString(file); - } - } - - async function tryHelpFor(...k) { - //if (!checkState(STATE.HELP)) { - // return; - //} - const r = await helpFor(g_helpstate, g_state, k); - g_helpstate = r.state; - const ev = new CustomEvent('help', { - bubbles: true, - cancelable: false, - composed: true, - detail: { - v: r.v, - }, - }); - window.dispatchEvent(ev); - } - - window.addEventListener('messagestatechange', (v) => { - state_change = (~v.detail.old_state) & v.detail.state; - let s = v.detail.s; - if (Array.isArray(s)) { - s = '[' + s.join(', ') + ']'; - } - console.debug('message state change:', [s, v.detail.state, debugState(v.detail.state), state_change, debugState(state_change)]); - }); - - - - </script> - <style type="text/css"> -div#helpdiv .old { - color: #aaa; -} -h1 { - margin-block-end: 1em; - margin-block-start: 0.5em; - font-size: 2em; -} - </style> + <script src="app.js"></script> + <link rel="stylesheet" type="text/css" href="style.css"></link> </head> <body x-data="{ @@ -585,7 +86,7 @@ h1 { $dispatch("passfail"); } if (checkState(STATE["ACK_MESSAGE"])) { - $dispatch("rcpt", {v: $event.detail.s}); + $dispatch("rcpt", {v: $event.detail.s[1]}); } ' @@ -701,7 +202,7 @@ h1 { ></button> - <button x-show='!haveKey' @click='tryHelpFor("nopass"); setPwd();' setPwd();' >without passphrase</button> + <button x-show='!haveKey' @click='tryHelpFor("nopass"); setPwd();' >without passphrase</button> </div> <div id="message_panel" x-show='unlockedKey' @@ -716,6 +217,8 @@ h1 { defaultname: true, key_content: '', rkey_content: '', + filez: {}, + get localKeyArmor() { return 'data:text/plain;charset=utf8,' + this.key_content; }, @@ -740,6 +243,23 @@ h1 { } return this.rkey + ' (' + this.rkey_name + ')'; }, + + addFileToList(k, v) { + this.filez[k] = v; + document.getElementById('fileAdder').value=''; + }, + + purgeFiles() { + this.filez = {}; + }, + + get fileList() { + let files = []; + for (const k in this.filez) { + files.push(this.filez[k] + ' (' + k.substring(0, 8) + ')'); + } + return files; + }, }" @rcpt.window='rcpt = $event.detail.v;' @messagestatechange.window=' @@ -749,14 +269,18 @@ h1 { rkey_name = $event.detail.nr; if (checkState(STATE["LOCAL_KEY"])) { - if (key_content == "") { - key_content = g_local_key.armor(); - } + if (key_content == "") { + key_content = g_local_key.armor(); + } } if (checkState(STATE["REMOTE_KEY"])) { - if (rkey_content == "") { - rkey_content = g_remote_key.armor(); - } + if (rkey_content == "") { + rkey_content = g_remote_key.armor(); + } + } + if (checkState(STATE.ACK_MESSAGE)) { + purgeFiles(); + document.getElementById("fileAdder").value=""; } ' > @@ -777,30 +301,17 @@ h1 { <textarea cols=72 rows=10 x-model="content" @focus="tryHelpFor('writemsg');"> </textarea> </dd> - <dt>Add files:</dt> + <dt>Add files:</dt> <dd> <input type="file" id="fileAdder" @change="fileChange();" - > + /> <ol> <template x-data="{ - filez: {}, - - addFileToList(k, v) { - this.filez[k] = v; - }, - - get fileList() { - let files = []; - for (const k in this.filez) { - files.push(this.filez[k] + ' (' + k.substring(0, 8) + ')'); - } - return files; - }, - + };" x-for="(v) in fileList" - @messagestatechange.window="if (checkState(STATE['FILE_ADDED'])) {addFileToList($event.detail.s[0], $event.detail.s[1]);}" + @messagestatechange.window="if (checkState(STATE.FILE_ADDED)) {addFileToList($event.detail.s[0], $event.detail.s[1]);}" > <li x-text="v"></li> </template> @@ -834,7 +345,7 @@ h1 { > <button x-bind:disabled='!ready' - @click="tryDispatch(content, realname, realemail);">sign, encrypt and send</button> + @click="tryDispatch(content, realname, realemail, filez);">sign, encrypt and send</button> </div> </div> </div> diff --git a/key.js b/key.js @@ -5,7 +5,7 @@ async function generatePGPKey(pwd, uid) { email: "ola@nordmann.no", }; } - uid.comment = 'Generated by forro/0.0.5, openpgpjs/5.5.0'; + uid.comment = 'Generated by forro/' + g_version + ', openpgpjs/5.5.0'; return new Promise(async (whohoo, doh) => { let v = await openpgp.generateKey({ //type: 'ecc', @@ -95,7 +95,7 @@ async function identify(pk, name, email, pwd) { const u = openpgp.UserIDPacket.fromObject({ name: name, email: email, - comment: 'manual entry on forro/0.0.5, openpgp/5.5.0', + comment: 'manual entry on forro/' + g_version + ', openpgp/5.5.0', }); let l = pk.toPacketList(); l.push(u); diff --git a/package.json b/package.json @@ -6,9 +6,10 @@ "dependencies": { "alpinejs": "3.10.3", "openpgp": "5.5.0", - "jssha": "3.2.0" + "jssha": "3.2.0", + "MimeJS": "git://git.defalsify.org/mime-js.git#lash/plain-part" }, "engines": { - "node": "~18.8" + "node": "^18.8" } } diff --git a/top.js b/top.js @@ -0,0 +1,44 @@ +const PUBKEY_PFX = 'pgp.publickey'; +const STATE = { + DEV: 1 << 0, + PANIC: 1 << 1, + RTS: 1 << 2, + SEND_ERROR: 1 << 3, + SETTINGS: 1 << 4, + REMOTE_KEY: 1 << 5, + LOCAL_KEY: 1 << 6, + LOCAL_KEY_DECRYPTED: 1 << 7, + LOCAL_KEY_IDENTIFIED: 1 << 8, + LOCAL_KEY_GENERATE: 1 << 9, + PASSPHRASE_ACTIVE: 1 << 10, + PASSPHRASE_FAIL: 1 << 11, + ACK_MESSAGE: 1 << 12, + ENC_MESSAGE: 1 << 13, + ACK_PUBKEY: 1 << 14, + ENC_PUBKEY: 1 << 15, + ACK_COUNTER: 1 << 16, + ENC_COUNTER: 1 << 17, + HELP: 1 << 18, + FILE_PROCESS: 1 << 19, + FILE_ADDED: 1 << 20, +}; +const STATE_KEYS = Object.keys(STATE); + +let g_passphrase = undefined; +let g_passphrase_use = true; +let g_passphrase_time = 0; +let g_remote_key = undefined; +let g_local_key = undefined; +let g_remote_key_id = '(none)'; +let g_remote_key_name = '?'; +let g_remote_key_email = undefined; +let g_local_key_id = '(none)'; +let g_local_key_name = '?'; +let g_local_key_identified = false; +let g_data_endpoint = window.location.href; +let g_state = 0; +let g_helpstate = 0; +let g_counter = undefined; +let g_files = {}; +let g_version = '0.0.6'; +let g_from = 'no-reply@holbrook.no';