| 1 | |
| 2 | import { parseSealed, verifyManifestSignature, decryptSealed } from 'https://oversightprotocol.dev/viewer/viewer.js'; |
| 3 | import { xchacha20poly1305 } from 'https://oversightprotocol.dev/viewer/vendor/noble-ciphers-chacha-1.3.0.js'; |
| 4 | import { ml_kem768 } from 'https://oversightprotocol.dev/viewer/vendor/noble-post-quantum-ml-kem-0.6.1.js'; |
| 5 | |
| 6 | const SEAL_EXTS = ['.sealed', '.oversight']; |
| 7 | |
| 8 | let parsed = null; |
| 9 | let plaintext = null; |
| 10 | |
| 11 | function setStatus(text, kind) { |
| 12 | const el = document.getElementById('status'); |
| 13 | el.textContent = text; |
| 14 | el.className = 'badge ' + (kind || 'wait'); |
| 15 | } |
| 16 | |
| 17 | function setError(msg) { |
| 18 | const el = document.getElementById('error'); |
| 19 | if (msg) { |
| 20 | el.textContent = msg; |
| 21 | el.style.display = 'block'; |
| 22 | } else { |
| 23 | el.style.display = 'none'; |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | function show(id, on) { |
| 28 | document.getElementById(id).style.display = on ? '' : 'none'; |
| 29 | } |
| 30 | |
| 31 | function isSealedName(name) { |
| 32 | const n = (name || '').toLowerCase(); |
| 33 | return SEAL_EXTS.some(ext => n.endsWith(ext)); |
| 34 | } |
| 35 | |
| 36 | function base64ToBytes(b64) { |
| 37 | const bin = atob(b64); |
| 38 | const out = new Uint8Array(bin.length); |
| 39 | for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); |
| 40 | return out; |
| 41 | } |
| 42 | |
| 43 | function populateAttachmentSelect(attachments) { |
| 44 | const sel = document.getElementById('attachment-select'); |
| 45 | sel.innerHTML = ''; |
| 46 | for (const att of attachments) { |
| 47 | const opt = document.createElement('option'); |
| 48 | opt.value = att.id; |
| 49 | opt.textContent = `${att.name} (${att.size} bytes)`; |
| 50 | opt.dataset.name = att.name; |
| 51 | sel.appendChild(opt); |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | function renderManifest(manifest, sigOk) { |
| 56 | const kv = document.getElementById('manifest-kv'); |
| 57 | kv.innerHTML = ''; |
| 58 | const rows = [ |
| 59 | ['suite', manifest.suite || ''], |
| 60 | ['issuer_id', manifest.issuer_id || ''], |
| 61 | ['recipient', (manifest.recipient && manifest.recipient.id) || ''], |
| 62 | ['content_type', manifest.content_type || ''], |
| 63 | ['content_hash', manifest.content_hash || ''], |
| 64 | ['signature', sigOk ? 'verified' : 'INVALID'], |
| 65 | ]; |
| 66 | for (const [k, v] of rows) { |
| 67 | const ks = document.createElement('span'); ks.textContent = k; |
| 68 | const vs = document.createElement('span'); |
| 69 | const code = document.createElement('code'); code.textContent = v; |
| 70 | vs.appendChild(code); |
| 71 | kv.appendChild(ks); kv.appendChild(vs); |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | Office.onReady(info => { |
| 76 | if (info.host !== Office.HostType.Outlook) { |
| 77 | setStatus('not running in Outlook', 'bad'); |
| 78 | setError('This task pane only runs inside Outlook.'); |
| 79 | return; |
| 80 | } |
| 81 | refreshFromCurrentItem(); |
| 82 | |
| 83 | if (Office.context.mailbox && Office.context.mailbox.addHandlerAsync) { |
| 84 | try { |
| 85 | Office.context.mailbox.addHandlerAsync( |
| 86 | Office.EventType.ItemChanged, |
| 87 | refreshFromCurrentItem, |
| 88 | ); |
| 89 | } catch (_) { |
| 90 | } |
| 91 | } |
| 92 | }); |
| 93 | |
| 94 | function refreshFromCurrentItem() { |
| 95 | setError(''); |
| 96 | show('attachment-row', false); |
| 97 | show('manifest-row', false); |
| 98 | show('decrypt-row', false); |
| 99 | show('plaintext-out', false); |
| 100 | parsed = null; |
| 101 | plaintext = null; |
| 102 | |
| 103 | const item = Office.context.mailbox && Office.context.mailbox.item; |
| 104 | if (!item || !item.attachments) { |
| 105 | setStatus('no message selected', 'wait'); |
| 106 | return; |
| 107 | } |
| 108 | const sealed = (item.attachments || []).filter(a => isSealedName(a.name)); |
| 109 | if (sealed.length === 0) { |
| 110 | setStatus('no .sealed attachment on this message', 'wait'); |
| 111 | return; |
| 112 | } |
| 113 | setStatus(`${sealed.length} sealed attachment(s) found`, 'ok'); |
| 114 | populateAttachmentSelect(sealed); |
| 115 | show('attachment-row', true); |
| 116 | } |
| 117 | |
| 118 | document.getElementById('btn-load').addEventListener('click', () => { |
| 119 | setError(''); |
| 120 | const sel = document.getElementById('attachment-select'); |
| 121 | const attId = sel.value; |
| 122 | if (!attId) return; |
| 123 | |
| 124 | const item = Office.context.mailbox.item; |
| 125 | item.getAttachmentContentAsync(attId, { asyncContext: null }, async (result) => { |
| 126 | if (result.status !== Office.AsyncResultStatus.Succeeded) { |
| 127 | setError('Outlook refused to provide the attachment: ' + (result.error && result.error.message)); |
| 128 | return; |
| 129 | } |
| 130 | const fmt = result.value && result.value.format; |
| 131 | const data = result.value && result.value.content; |
| 132 | if (fmt !== Office.MailboxEnums.AttachmentContentFormat.Base64 || !data) { |
| 133 | setError('unexpected attachment format: ' + fmt); |
| 134 | return; |
| 135 | } |
| 136 | let bytes; |
| 137 | try { |
| 138 | bytes = base64ToBytes(data); |
| 139 | } catch (e) { |
| 140 | setError('attachment was not valid base64: ' + e.message); |
| 141 | return; |
| 142 | } |
| 143 | try { |
| 144 | parsed = parseSealed(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)); |
| 145 | } catch (e) { |
| 146 | setError('not a valid Oversight sealed file: ' + e.message); |
| 147 | return; |
| 148 | } |
| 149 | let sig; |
| 150 | try { |
| 151 | sig = await verifyManifestSignature(parsed.manifest); |
| 152 | } catch (e) { |
| 153 | setError('signature check failed to run: ' + e.message); |
| 154 | return; |
| 155 | } |
| 156 | renderManifest(parsed.manifest, !!(sig && sig.ok)); |
| 157 | show('manifest-row', true); |
| 158 | show('decrypt-row', true); |
| 159 | setStatus(sig.ok ? `signature verified (${parsed.suiteName})` : 'SIGNATURE INVALID', sig.ok ? 'ok' : 'bad'); |
| 160 | }); |
| 161 | }); |
| 162 | |
| 163 | document.getElementById('btn-decrypt').addEventListener('click', async () => { |
| 164 | setError(''); |
| 165 | show('plaintext-out', false); |
| 166 | if (!parsed) { setError('Load a sealed attachment first.'); return; } |
| 167 | const raw = document.getElementById('identity-text').value.trim(); |
| 168 | if (!raw) { setError('Paste your identity JSON.'); return; } |
| 169 | let identity; |
| 170 | try { identity = JSON.parse(raw); } |
| 171 | catch (e) { setError('identity JSON could not be parsed: ' + e.message); return; } |
| 172 | |
| 173 | try { |
| 174 | plaintext = await decryptSealed(parsed, identity, xchacha20poly1305, ml_kem768); |
| 175 | } catch (e) { |
| 176 | setError('decrypt failed: ' + e.message); |
| 177 | return; |
| 178 | } |
| 179 | const text = new TextDecoder('utf-8', { fatal: false }).decode(plaintext); |
| 180 | document.getElementById('plaintext-preview').textContent = text.slice(0, 1024) + (text.length > 1024 ? '\n...' : ''); |
| 181 | show('plaintext-out', true); |
| 182 | }); |
| 183 | |
| 184 | document.getElementById('btn-download').addEventListener('click', () => { |
| 185 | if (!plaintext) return; |
| 186 | const blob = new Blob([plaintext], { type: 'application/octet-stream' }); |
| 187 | const url = URL.createObjectURL(blob); |
| 188 | const a = document.createElement('a'); |
| 189 | a.href = url; |
| 190 | const name = (parsed && parsed.manifest && parsed.manifest.filename) || 'plaintext.bin'; |
| 191 | a.download = name.replace(/\.sealed$|\.oversight$/i, '') || 'plaintext.bin'; |
| 192 | document.body.appendChild(a); |
| 193 | a.click(); |
| 194 | setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 0); |
| 195 | }); |