Zion Boggan
repos/Oversight/integrations/outlook/taskpane.js
zionboggan.com ↗
195 lines · javascript
History for this file →
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
});