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 });