forro

End-to-end encrypted contract form based on PGP.
git clone git://git.defalsify.org/forro.git
Info | Log | Files | Refs | README | LICENSE

app.js (20323B)


      1 /**
      2  * Buffer to hex encoder
      3  *
      4  * Thanks to: https://stackoverflow.com/questions/40031688/javascript-arraybuffer-to-hex
      5  *
      6  * @param {object} ArrayBuffer with binary data
      7  * @returns {string} hex
      8  */
      9 function buf2hex(buffer) {
     10 	  return [...new Uint8Array(buffer)]
     11 	      .map(x => x.toString(16).padStart(2, '0'))
     12 	      .join('');
     13 }
     14 
     15 /** generate the mutable prefix for the message based on its sequence number **/ 
     16 function msg_identifier() {
     17 	return 'msg' + g_counter;
     18 }
     19 
     20 /** generate the mutable prefix for the public key **/
     21 function pubkey_identifier() {
     22 	return PUBKEY_PFX + g_remote_key.getFingerprint();
     23 }
     24 
     25 /** generate the mutable counter prefix for the message sequence number **/
     26 function counter_identifier() {
     27 	return 'msgidx';
     28 }
     29 
     30 /**
     31  * output a humane representation of the given state
     32  *
     33  * @param {number} state
     34  * @returns {string} description strings for each individual state bit set
     35  */
     36 function debugState(state) {
     37 	let s = '';
     38 	for (let i = 0; i < STATE_KEYS.length; i++) {
     39 		const v = 1 << i;
     40 		if (checkState(state, v)) {
     41 			const k = STATE_KEYS[i];
     42 			if (s.length > 0) {
     43 				s += ', ';
     44 			}
     45 			s += k;
     46 		}
     47 	}
     48 	return s;
     49 };
     50 
     51 /**
     52  * check if state bit needle is set in the given state bit haystack
     53  * @param {number} needle 
     54  * @param {number} haystack
     55  * @returns {boolean}
     56  */
     57 function checkState(bit_check, bit_field) {
     58 	if (bit_field != 0 && !bit_field) {
     59 		bit_field = g_state;
     60 	}
     61 	return (bit_check & bit_field) > 0;
     62 };
     63 
     64 /**
     65  * Retrieve settings from remote and parse them
     66  */
     67 async function loadSettings() {
     68 	let rs = await fetch(window.location.href + '/settings.json', {
     69 		method: 'GET',
     70 	});
     71 	if (!rs.ok) {
     72 		stateChange('could not load settings');
     73 		throw('could not load settings');
     74 	}
     75 	return await rs.json();
     76 }
     77 
     78 /**
     79  * Get identifiable personal name from the GPG key packetlist
     80  *
     81  * Alters application state
     82  *
     83  * @param {object} pgp key object
     84  * @returns {string} name
     85  */
     86 function getEffectiveName(k) {
     87 	let kl = k.toPacketList();
     88 	let klf = kl.filterByTag(openpgp.enums.packet.userID);
     89 	if (klf.length > 1) {
     90 		stateChange('local key has been identified', STATE["LOCAL_KEY_IDENTIFIED"]);
     91 		g_local_key_identified = true;
     92 	}
     93 	return klf[klf.length-1].name;
     94 }
     95 
     96 /**
     97  * Unlock private key in storage with given passphrase
     98  *
     99  * Alters application state
    100  *
    101  * @param {string} passphrase
    102  * @returns {boolean} true if successfully decrypted.
    103  */
    104 async function unlockLocalKey(pwd) {
    105 	let state = [];
    106 	try {
    107 		g_local_key = await getKey(pwd);
    108 		state.push(STATE['LOCAL_KEY']);
    109 	} catch(e) {
    110 		stateChange('could not unlock key (passphrase: ' + (pwd !== undefined) + '). Reason: ' + e);
    111 		return false;
    112 	}
    113 	const decrypted = g_local_key.isDecrypted()
    114 	if (decrypted) {
    115 		state.push(STATE['LOCAL_KEY_DECRYPTED']);
    116 	}
    117 
    118 	stateChange('found key ' + g_local_key.getKeyID().toHex() + ' (decrypted: ' + decrypted + ')', state);
    119 	return decrypted;
    120 }
    121 
    122 /**
    123  * Process current messaging state of local key.
    124  * 
    125  * Currently this is limited to looking up local storage record of message counter
    126  *
    127  * Alters application state
    128  *
    129  * @todo should retrieve state from remote if it is missing in local storage
    130  */
    131 async function applyLocalKey() {
    132 	g_local_key_id = g_local_key.getKeyID().toHex();
    133 	g_local_key_name = getEffectiveName(g_local_key);
    134 	
    135 	stateChange('load counter');
    136 	let c = localStorage.getItem('msg-count');
    137 	if (c == null) {
    138 		g_counter = 0;
    139 	} else {
    140 		g_counter = parseInt(c);
    141 	}
    142 	stateChange('ready to send', STATE['RTS']);
    143 }
    144 
    145 /**
    146  * Application entry point
    147  */
    148 async function setUp() {
    149 	let settings = await loadSettings();
    150 	if (settings.dev) {
    151 		stateChange("devmode on", STATE.DEV);
    152 	}
    153 	if (settings.help) {
    154 		stateChange("helpmode on", STATE.HELP);
    155 	}
    156 
    157 	if (settings.data_endpoint !== undefined) {
    158 		g_data_endpoint = settings.data_endpoint;
    159 		stateChange('updated data endpoint to ' + settings.data_endpoint);
    160 	}
    161 	if (settings.email_sender) {
    162 		g_from = settings.email_sender;
    163 	}
    164 	if (settings.email_sender_name) {
    165 		g_from_name = settings.email_sender_name;
    166 	}
    167 
    168 	stateChange('loaded settings', STATE['SETTINGS']);
    169 	let r = await fetch(settings.remote_pubkey_url);
    170 	let remote_key_src = await r.text();
    171 	let remote_key = await openpgp.readKey({
    172 		armoredKey: remote_key_src,
    173 	});
    174 	g_remote_key = remote_key;
    175 	g_remote_key_id = g_remote_key.getKeyID().toHex();
    176 	g_remote_key.getPrimaryUser().then((v) => {
    177 		g_remote_key_name = v.user.userID.name;
    178 		g_remote_key_email = v.user.userID.email;
    179 		stateChange('loaded remote encryption key', STATE['REMOTE_KEY']);
    180 	});
    181 }
    182 
    183 /**
    184  * Modify provided state.
    185  *
    186  * After this method complets, the global application state will be adjusted by
    187  * bits specified to be added or removed.
    188  *
    189  * An event will be emitted for the state change.
    190  *
    191  * @param {number} s Arbitrary object relevant to the state change
    192  * @param {number} set_states State bits to set
    193  * @param {number} set_states State bits to reset
    194  * @fires window.messagestatechange
    195  * @todo make sure this method cannot fail
    196  */
    197 async function stateChange(s, set_states, rst_states) {
    198 	if (!set_states) {
    199 		set_states = [];
    200 	} else if (!Array.isArray(set_states)) {
    201 		set_states = [set_states];
    202 	}
    203 	if (!rst_states) {
    204 		rst_states = [];
    205 	} else if (!Array.isArray(rst_states)) {
    206 		rst_states = [rst_states];
    207 	}
    208 	let new_state = g_state;
    209 	for (let i = 0; i < set_states.length; i++) {
    210 		let state = parseInt(set_states[i]);
    211 		new_state |= state;
    212 	}
    213 	for (let i = 0; i < rst_states.length; i++) {
    214 		let state = parseInt(set_states[i]);
    215 		new_state = new_state & (0xffffffff & ~rst_states[i]);
    216 	}
    217 	old_state = g_state;
    218 	g_state = new_state;
    219 
    220 	const ev = new CustomEvent('messagestatechange', {
    221 		bubbles: true,
    222 		cancelable: false,
    223 		composed: true,
    224 		detail: {
    225 			s: s,
    226 			c: g_counter,
    227 			kr: g_remote_key_id,
    228 			nr: g_remote_key_name,
    229 			kl: g_local_key_id,
    230 			nl: g_local_key_name,
    231 			old_state: old_state,
    232 			state: new_state,
    233 		},
    234 	});
    235 	window.dispatchEvent(ev);
    236 }
    237 
    238 /**
    239  * Wrapper to set state accordingly if content submission fails.
    240  *
    241  * Will change application state.
    242  *
    243  * @param {string} s Text message from contact form
    244  * @param {string} name Name of sender (claimed)
    245  * @param {string} email Email of sender (claimed)
    246  * @param {Object[]} files File attachments
    247  * @returns {string} Remote identifier if succeeds, or "failed" if not.
    248  * @see dispatch
    249  */
    250 async function tryDispatch(s, name, email, files) {
    251 	stateChange('starting dispatch', undefined, [STATE['RTS'], STATE['SEND_ERROR']]);
    252 	console.debug('files', Object.keys(files));
    253 	let r = undefined;
    254 	try {
    255 		r = await dispatch(s, name, email, files)
    256 		stateChange('successfully sent; ready to send again', STATE['RTS']);
    257 	} catch(e) {
    258 		console.error(e);
    259 		stateChange('send fail: ' + e, STATE['SEND_ERROR']);
    260 		r = 'failed';
    261 	}
    262 	return r;
    263 }
    264 
    265 /**
    266  * Getter for current passphrase in memory
    267  *
    268  * @todo passphrase should be deleted from memory when not used
    269  *
    270  **/
    271 function getPassphrase() {
    272 	return g_passphrase;
    273 }
    274 
    275 /**
    276  * Attempt to replace the current user id packet in the PGP key structure, and handle error if unsuccessful.
    277  *
    278  * Will change application state.
    279  *
    280  * @param {string} name Name replacement
    281  * @param {string} email Email replacement
    282  *
    283  */
    284 async function tryIdentify(name, email) {
    285 	if (g_local_key_identified) {
    286 		return false;
    287 	}
    288 	g_local_key = await identify(g_local_key, name, email, getPassphrase());
    289 	g_local_key_name = getEffectiveName(g_local_key);
    290 	await stateChange('apply name change: ' + g_local_key_name);
    291 	console.debug('updated public key', g_local_key.toPublic().armor());
    292 	g_local_key_identified = true;
    293 }
    294 
    295 /**
    296  * Send content to remote
    297  *
    298  * This is a high level function which orchestrates the following actions:
    299  *
    300  * 1. Apply any available identifiable information to the local public key before submission
    301  * 2. Create MIME multipart message from the submission
    302  * 3. Sign the message with our local private key
    303  * 4. Submit the message using our local public key and counter state as mutable pointer to remote
    304  * 5. Submit the counter state to remote
    305  * 6. Update local state
    306  *
    307  * Will make following changes to remote:
    308  *
    309  * 1. Submit message using public key as mutable identifier
    310  * 2. Submit counter
    311  *
    312  * Will change application state
    313  *
    314  * @param {string} s Text message from contact form
    315  * @param {string} name Name of sender (claimed)
    316  * @param {string} email Email of sender (claimed)
    317  * @param {Object[]} files File attachments
    318  * @returns {string} remote identifier for the content
    319  *
    320  */
    321 async function dispatch(s, name, email, files) {
    322 	if (name) {
    323 		if (!validateEmail(email)) {
    324 			throw 'invalid email: ' + email;
    325 		}
    326 		await tryIdentify(name, email);
    327 	}
    328 
    329 	const pubkey = g_local_key.toPublic();
    330 	const payload = await buildMessage(s, files, pubkey);
    331 
    332 	let pfx = msg_identifier();
    333 	let pfx_pub = pubkey_identifier(); 
    334 	let pfx_count = counter_identifier();
    335 
    336 	stateChange('sign and encrypt message ' + g_counter);
    337 	const sha_raw = new jsSHA("SHA-256", "TEXT", { encoding: "UTF8" });
    338 	sha_raw.update(s);
    339 	const digest = sha_raw.getHash("HEX");
    340 	console.debug('digest for unencrypted message:', digest);
    341 
    342 	// this is done twice, improve
    343 	const rcpt_pubkey_verify = await generatePointer(g_local_key, pfx_pub);
    344 	console.debug('pointer for pubkey', rcpt_pubkey_verify);
    345 
    346 	const msg_sig = await signMessage(payload);
    347 	stateChange([g_counter, digest], STATE['SIG_MESSAGE']);
    348 
    349 	const msg = await openpgp.createMessage({
    350 		text: payload,
    351 	});
    352 	let r_enc = await encryptMessage(msg, pfx);
    353 	stateChange([g_counter, digest, r_enc.rcpt], STATE['ENC_MESSAGE']);
    354 	let rcpt = await dispatchToEndpoint(r_enc, pfx, true);
    355 	stateChange([g_counter, rcpt], STATE['ACK_MESSAGE']);
    356 
    357 	let r_count = await encryptCounter(g_counter, pfx_count);
    358 	g_files = {};
    359 	stateChange([g_counter, r_count.rcpt], STATE['ENC_COUNTER'], [
    360 		STATE.ACK_MESSAGE,
    361 		STATE.ENC_MESSAGE,
    362 	]);
    363 	let rcpt_count = await dispatchToEndpoint(r_count, pfx_count);
    364 	stateChange([g_counter, rcpt_count], STATE['ACK_COUNTER']);
    365 
    366 	g_counter += 1;
    367 
    368 	localStorage.setItem('msg-count', g_counter);
    369 
    370 	//const r_enc_pub = await encryptPublicKey(g_local_key, pfx_pub);
    371 //	stateChange([rcpt_pubkey_verify, r_enc_pub.rcpt], STATE['ENC_PUBKEY'], [
    372 //		STATE.ACK_COUNTER,
    373 //		STATE.ENC_COUNTER,
    374 //	]);
    375 //	let rcpt_pubkey = await dispatchToEndpoint(r_enc_pub, pfx_pub);
    376 //	stateChange(rcpt_pubkey, STATE['ACK_PUBKEY']);
    377 
    378 //	stateChange('dispatch complete. next message is ' + g_counter, undefined, [
    379 //		STATE.ACK_PUBKEY,
    380 //		STATE.ENC_PUBKEY,
    381 //	]);
    382 
    383 	stateChange('dispatch complete. next message is ' + g_counter, undefined, [
    384 		STATE.ACK_PUBKEY,
    385 		STATE.ENC_PUBKEY,
    386 		STATE.ACK_COUNTER,
    387 		STATE.ENC_COUNTER,
    388 	]);
    389 	return rcpt;
    390 }
    391 
    392 /**
    393  * Sign provided content with local private key and return the detached signature.
    394  *
    395  * @param {string} Payload to sign
    396  * @returns {Object} openpgpjs Signed message data
    397  */
    398 async function signMessage(payload) {
    399 	const msg = await openpgp.createMessage({
    400 		text: payload,
    401 	});
    402 	let msg_sig_inner = await openpgp.sign({
    403 		signingKeys: g_local_key,
    404 		message: msg,
    405 		format: 'binary',
    406 	});
    407 
    408 	const msg_sig = await openpgp.createMessage({
    409 		binary: msg_sig_inner,
    410 	});
    411 	return msg_sig;
    412 }
    413 
    414 /**
    415  * Encrypt the counter data with the local public key to be stored
    416  * at the remote endpoint.
    417  *
    418  * @param {number} c Current counter value
    419  * @param {string} pfx Prefix to use for the remote pointer
    420  * @returns {Object} msg: Encrypted counter message, to be submitted to endpoint; auth: Remote identifier to resolve mutable data pointer; rcpt: Signature material to use for HTTP Authorization PUBSIG
    421  */
    422 async function encryptCounter(c, pfx) {
    423 	const msg_count = await openpgp.createMessage({
    424 		text: '' + g_counter,
    425 	});
    426 
    427 	const enc_count = await openpgp.encrypt({
    428 		encryptionKeys: g_local_key,
    429 		format: 'binary',
    430 		message: msg_count,
    431 	});
    432 	let envelope_count = await openpgp.createMessage({
    433 		binary: enc_count,
    434 	});
    435 
    436 	const auth = await generateAuth(g_local_key, envelope_count);
    437 
    438 	const rcpt_count_verify = await generatePointer(g_local_key, pfx);
    439 
    440 	return {
    441 		msg: enc_count,
    442 		auth: auth,
    443 		rcpt: rcpt_count_verify,
    444 	};
    445 
    446 }
    447 
    448 /**
    449  * Encrypt the public key data with the local public key to be stored
    450  * at the remote endpoint.
    451  *
    452  * @param {number} k Public key data
    453  * @param {string} pfx Prefix to use for the remote pointer
    454  * @returns {Object} msg: Encrypted pubkey message, to be submitted to endpoint; auth: Remote identifier to resolve mutable data pointer; rcpt: Signature material to use for HTTP Authorization PUBSIG
    455  * @todo k param is not being used, global local key is the key being modified
    456  */
    457 async function encryptPublicKey(k, pfx) {
    458 	const pubkey_bin = g_local_key.toPublic().write();
    459 	const msg_pubkey = await openpgp.createMessage({
    460 		binary: pubkey_bin,
    461 	});
    462 
    463 	const enc_pubkey = await openpgp.encrypt({
    464 		encryptionKeys: g_remote_key,
    465 		format: 'binary',
    466 		message: msg_pubkey,
    467 	});
    468 	let envelope_pubkey = await openpgp.createMessage({
    469 		binary: enc_pubkey,
    470 	});
    471 
    472 	const auth = await generateAuth(g_local_key, envelope_pubkey);
    473 
    474 	const rcpt_pubkey_verify = await generatePointer(g_local_key, pfx);
    475 
    476 	return {
    477 		msg: enc_pubkey,
    478 		auth: auth,
    479 		rcpt: rcpt_pubkey_verify,
    480 	};
    481 }
    482 
    483 /**
    484  * Low-level send to endpoint function
    485  *
    486  * @param {Object} o Encrypted content object
    487  * @param {string} pfx Prefix to store data under 
    488  * @param {boolean} trace Generate trace information, if available
    489  * @throws Generic error if submission failed
    490  * @returns {string} Remote identifier
    491  *
    492  */
    493 async function dispatchToEndpoint(o, pfx, trace) {
    494 	let headers = {
    495 		'Content-Type': 'application/octet-stream',
    496 		'Authorization': 'PUBSIG ' + o.auth,
    497 	};
    498 
    499 	if (trace)
    500 		headers['X-Wala-Trace'] = '1';
    501 
    502 	let res = await fetch(g_data_endpoint + '/' + pfx, {
    503 		method: 'PUT',
    504 		body: o.msg,
    505 		headers: headers,
    506 	});
    507 
    508 	rcpt_remote = await res.text();
    509 
    510 	if (o.rcpt) {
    511 		if (rcpt_remote.toLowerCase() != o.rcpt.toLowerCase()) {
    512 			throw "mutable ref mismatch between local and server; " + o.rcpt + " != " + rcpt_remote;
    513 		}
    514 	} else {
    515 		console.warn('have no digest to check server reply against');
    516 	}
    517 	return rcpt_remote;
    518 }
    519 
    520 /**
    521  * Encrypt the message data with the local public key to be stored
    522  * at the remote endpoint.
    523  *
    524  * @param {number} k Public key data
    525  * @param {string} pfx Prefix to use for the remote pointer
    526  * @returns {Object} msg: Encrypted message, to be submitted to endpoint; auth: Remote identifier to resolve mutable data pointer; rcpt: Signature material to use for HTTP Authorization PUBSIG
    527  * @todo k param is not being used, global local key is the key being modified
    528  */
    529 async function encryptMessage(msg, pfx) {
    530 	const enckey_local = await g_local_key.getEncryptionKey();
    531 	const enckey_remote = await g_remote_key.getEncryptionKey();
    532 
    533 	const enc = await openpgp.encrypt({
    534 		encryptionKeys: [g_remote_key, g_local_key],
    535 		signingKeys: [g_local_key],
    536 		format: 'binary',
    537 		message: msg,
    538 	});
    539 	
    540 	console.debug('encrypted for keys', enckey_local.getKeyID().toHex(), enckey_remote.getKeyID().toHex());
    541 	let envelope = await openpgp.createMessage({
    542 		binary: enc,
    543 	});
    544 	
    545 	const auth = await generateAuth(g_local_key, envelope);
    546 
    547 	const rcpt = await generatePointer(g_local_key, pfx);
    548 	console.debug('digest for encrypted message:', rcpt);
    549 	
    550 	return {
    551 		msg: enc,
    552 		rcpt: rcpt,
    553 		auth: auth,
    554 	};
    555 }
    556 
    557 /**
    558  * Create a new local private key
    559  *
    560  * Will change application state
    561  *
    562  * @param {string} pwd Passphrase to encrypt key with
    563  */
    564 async function createLocalKey(pwd) {
    565 	stateChange('generate new local signing key', STATE["LOCAL_KEY_GENERATE"]);
    566 	const uid = {
    567 		name: generateName(),
    568 		email: 'foo@devnull.holbrook.no',
    569 	};
    570 	g_local_key = await generatePGPKey(pwd, uid);
    571 	stateChange('new local signing key named ' + uid.name, STATE["LOCAL_KEY"], STATE["LOCAL_KEY_GENERATE"]);
    572 }
    573 
    574 /**
    575  * Unlock an existing private key with passphrase
    576  *
    577  * Will change application state
    578  *
    579  * @param {string} pwd Passphrase to decrypt key with
    580  * @returns {boolean} true if unlocked
    581  *
    582  * @todo should be more intuitiviely named method
    583  *
    584  */
    585 async function setPwd(pwd) {
    586 	stateChange('attempt password set', undefined, STATE['PASSPHRASE_FAIL']);
    587 	if (!pwd) {
    588 		pwd = undefined;
    589 	}
    590 	if (pwd === undefined) {
    591 		if (g_local_key === undefined) {
    592 			g_passphrase_use = false;
    593 			await createLocalKey();
    594 		}
    595 	} else if (g_local_key === undefined) {
    596 		await createLocalKey(pwd);
    597 	}
    598 	let r = await unlockLocalKey(pwd);
    599 	if (!r) {
    600 		stateChange('key unlock fail', STATE['PASSPHRASE_FAIL']);
    601 		return false;
    602 	}
    603 	if (pwd !== undefined) {
    604 		stateChange('passphrase validated', STATE['PASSPHRASE_ACTIVE']);
    605 	}
    606 	applyLocalKey();
    607 	g_passphrase = pwd;
    608 	g_passphrase_time = Date.now();
    609 	return r;
    610 }
    611 
    612 /**
    613  * Remove local key and related state from local storage
    614  *
    615  * Will change application state
    616  */
    617 function purgeLocalKey() {
    618 	key_id = g_local_key_id;
    619 	localStorage.removeItem('pgp-key');
    620 	localStorage.removeItem('msg-count');
    621 	g_local_key = undefined;
    622 	g_local_key_id = undefined;
    623 	g_local_key_identified = false;
    624 	g_counter = 0;
    625 	g_passphrase = undefined;
    626 	g_passphrase_time = new Date(0);
    627 	const purgeResetStates =  [
    628 		STATE["LOCAL_KEY"],
    629 		STATE["LOCAL_KEY_DECRYPTED"],
    630 		STATE["LOCAL_KEY_IDENTIFIED"],
    631 		STATE["PASSPHRASE_STORED"],
    632 		STATE["RTS"],
    633 		STATE["SEND_ERROR"],
    634 	];
    635 	stateChange('deleted local key ' + key_id, undefined, purgeResetStates);
    636 	return true;
    637 }
    638 
    639 
    640 /**
    641  * Handle file attachment request
    642  *
    643  * Will change application state.
    644  *
    645  * @todo currently no way exists to tell whether this has failed
    646  */
    647 async function fileChange() {
    648 	let fileButton = document.getElementById("fileAdder")
    649 	let file = fileButton.files[0];
    650 	stateChange('processing file: ' + file.name, STATE.FILE_PROCESS);
    651 	if (file) {
    652 		let f = new FileReader();
    653 		f.onloadend = (r) => {
    654 			let contents = btoa(r.target.result);
    655 			const sha_raw = new jsSHA("SHA-256", "TEXT", { encoding: "UTF8" });
    656 			sha_raw.update(contents);
    657 			const digest = sha_raw.getHash("HEX");
    658 			g_files[digest] = contents;
    659 			stateChange([digest, file.name], STATE.FILE_ADDED, STATE.FILE_PROCESS);
    660 			stateChange('file added: ' + file.name + ' = ' + digest, undefined, STATE['FILE_ADDED']);
    661 		};
    662 		f.readAsBinaryString(file);
    663 	}
    664 }
    665 
    666 /**
    667  * If interactive help is enabled, update the help text area with the current relevant
    668  * contextual help strings
    669  *
    670  * @param {string[]} k Zero or more help identifiers to display, in sequence
    671  * @fires window.help
    672  */
    673 async function tryHelpFor(...k) {
    674 	//if (!checkState(STATE.HELP)) {
    675 	//	return;
    676 	//}
    677 	const r = await helpFor(g_helpstate, g_state, k);
    678 	g_helpstate = r.state;
    679 	const ev = new CustomEvent('help', {
    680 		bubbles: true,
    681 		cancelable: false,
    682 		composed: true,
    683 		detail: {
    684 			v: r.v,
    685 		},
    686 	});
    687 	window.dispatchEvent(ev);
    688 }
    689 
    690 /**
    691  * Create the message to be submitted from form input.
    692  *
    693  * The message is a MIME multipart message containing the following parts:
    694  *
    695  * * The composite message of the message text field and all attachments
    696  * * The ASCII-armored public key used for the PGP signature
    697  *
    698  * This message will in turn be signed by the private key  matching the public key
    699  * that was embedded
    700  * 
    701  * @param {string} message Message string
    702  * @param {string} files Individual files to include
    703  * @returns {string} MIME Multipart message text containing all parts of the message
    704  */
    705 async function buildMessage(message, files, pubkey) {
    706 	let msg = {
    707 		fromName: g_from_name,
    708 		from: g_from,
    709 		to: g_remote_key_email,
    710 		subject: 'contact form message',
    711 		body: message,
    712 		cids: [],
    713 		attaches: [],
    714 	};
    715 	for (v in files) {
    716 		const data = v.target;
    717 		const attach = {
    718 			name: files[v],
    719 			type: "application/octet-stream",
    720 			base64: g_files[v],
    721 		};
    722 		msg.attaches.push(attach);
    723 	}
    724 	const pubkey_attach = {
    725 		name: "pubkey.asc",
    726 		type: "application/octet-stream",
    727 		raw: pubkey.armor(),
    728 	};
    729 	msg.attaches.push(pubkey_attach);
    730 	const msg_mime = Mime.toMimeTxt(msg);
    731 	console.debug(msg_mime);
    732 	return msg_mime;
    733 }
    734 
    735 
    736 /**
    737  * Execute global state change and make sure each is logged
    738  *
    739  * @todo turn off log for release version
    740  **/
    741 window.addEventListener('messagestatechange', (v) => {
    742 	state_change = (~v.detail.old_state) & v.detail.state;
    743 	let s = v.detail.s;
    744 	if (Array.isArray(s)) {
    745 		s = '[' + s.join(', ') + ']';
    746 	}
    747 	console.debug('message state change:', [s, v.detail.state, debugState(v.detail.state), state_change, debugState(state_change)]);
    748 });