@leftoff/cli 0.0.0-stage → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +14 -0
- package/CHANGELOG.md +564 -0
- package/LICENSE.md +9 -0
- package/README.md +28 -2
- package/assets/sample-transcript.jsonl +12 -0
- package/bin/checkpoints.js +7 -0
- package/bin/leftoff.js +585 -0
- package/hooks/README.md +22 -0
- package/hooks/settings-hooks.example.json +82 -0
- package/mod/.claude-plugin/plugin.json +15 -0
- package/mod/hooks/core.js +1397 -0
- package/mod/hooks/hooks.json +4 -0
- package/mod/hooks/register.js +1006 -0
- package/package.json +45 -4
- package/templates/agent-feed-worker/README.md +45 -0
- package/templates/agent-feed-worker/src/index.js +69 -0
- package/templates/agent-feed-worker/wrangler.toml +20 -0
- package/ui/app.css +1282 -0
- package/ui/app.js +2979 -0
- package/ui/apple-touch-icon.png +0 -0
- package/ui/changelog-md.js +64 -0
- package/ui/favicon-32.png +0 -0
- package/ui/favicon-badge-32.png +0 -0
- package/ui/favicon-badge.svg +1 -0
- package/ui/favicon.ico +0 -0
- package/ui/favicon.svg +1 -0
- package/ui/go.js +68 -0
- package/ui/icon-192.png +0 -0
- package/ui/icon-512.png +0 -0
- package/ui/icon-maskable-512.png +0 -0
- package/ui/icons.js +41 -0
- package/ui/index.html +55 -0
- package/ui/manifest.webmanifest +37 -0
- package/ui/needs.js +197 -0
- package/ui/offline.css +11 -0
- package/ui/offline.html +24 -0
- package/ui/pair.html +31 -0
- package/ui/picker.js +294 -0
- package/ui/relay-client.js +187 -0
- package/ui/relay-crypto.js +155 -0
- package/ui/relay-pair.js +48 -0
- package/ui/repos.js +167 -0
- package/ui/settings-nav.js +23 -0
- package/ui/summary-examples.js +66 -0
- package/ui/sw.js +34 -0
- package/ui/theme.js +41 -0
- package/ui/vendor/LUCIDE-LICENSE.txt +45 -0
package/ui/picker.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// Leftoff's own dropdowns (v0.5.9). The app never uses a native <select> or
|
|
2
|
+
// <datalist>: their menus are the OS's (a grey popup on a Mac, a wheel or a
|
|
3
|
+
// bare list with a stray "/" on an iPhone) and can't be styled. Two elements:
|
|
4
|
+
//
|
|
5
|
+
// <lo-select id="f-tz" aria-label="Time zone"><option value="">…</option>…</lo-select>
|
|
6
|
+
// A button that opens a listbox. Like a select: .value, .selectedIndex,
|
|
7
|
+
// .options, .disabled, and a bubbling "change" event. More than 10 options
|
|
8
|
+
// get a filter box at the top of the menu.
|
|
9
|
+
// <lo-combo id="f-space-repo" placeholder="repo folder name"><option value="clutch_hunt" data-sub="~/src">…</lo-combo>
|
|
10
|
+
// A text field with suggestions that filter as you type; any text is allowed.
|
|
11
|
+
// .value is the text. "input" and "change" bubble from it.
|
|
12
|
+
//
|
|
13
|
+
// Both: arrows, Home/End, Enter, Escape, Tab; a click outside closes; the menu
|
|
14
|
+
// never leaves the viewport, and under 600px wide it opens as a bottom sheet.
|
|
15
|
+
// ARIA: combobox + listbox + option, aria-activedescendant, aria-selected.
|
|
16
|
+
// Markup helpers: selectHtml() and comboHtml(). test/unit/ui-no-native-select.test.mjs
|
|
17
|
+
// fails if any other file in ui/ writes a native select or datalist.
|
|
18
|
+
|
|
19
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
20
|
+
const CHEVRON = '<svg class="lo-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>'
|
|
21
|
+
const CHECK = '<svg class="lo-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20 6 9 17l-5-5"/></svg>'
|
|
22
|
+
const SHEET_MAX = 599.98
|
|
23
|
+
const FILTER_AT = 10
|
|
24
|
+
let uid = 0
|
|
25
|
+
let open = null // { host, menu, list, items, active, filter, backdrop }
|
|
26
|
+
|
|
27
|
+
const attrs = (o = {}) => Object.entries(o).filter(([, v]) => v != null && v !== false).map(([k, v]) => ' ' + k + (v === true ? '' : '="' + esc(v) + '"')).join('')
|
|
28
|
+
|
|
29
|
+
// options: [{ value, label, sub, selected }] or [value, label] pairs or plain strings
|
|
30
|
+
function normOpts(options) {
|
|
31
|
+
return (options || []).map((o) => (Array.isArray(o) ? { value: o[0], label: o[1] != null ? o[1] : o[0], sub: o[2] } : typeof o === 'object' && o ? o : { value: o, label: o }))
|
|
32
|
+
}
|
|
33
|
+
export function selectHtml({ id, value, options, label, cls, attrs: extra, disabled } = {}) {
|
|
34
|
+
const opts = normOpts(options)
|
|
35
|
+
const has = opts.some((o) => String(o.value) === String(value))
|
|
36
|
+
return '<lo-select' + attrs({ id, class: cls, 'aria-label': label, disabled: !!disabled, ...(extra || {}) }) + '>' +
|
|
37
|
+
opts.map((o, i) => '<option' + attrs({ value: o.value, 'data-sub': o.sub, selected: has ? String(o.value) === String(value) : (o.selected || (value == null && i === 0)) }) + '>' + esc(o.label) + '</option>').join('') + '</lo-select>'
|
|
38
|
+
}
|
|
39
|
+
export function comboHtml({ id, value, options, label, placeholder, cls, attrs: extra } = {}) {
|
|
40
|
+
return '<lo-combo' + attrs({ id, class: cls, 'aria-label': label, placeholder, value, ...(extra || {}) }) + '>' +
|
|
41
|
+
normOpts(options).map((o) => '<option' + attrs({ value: o.value, 'data-sub': o.sub }) + '>' + esc(o.label) + '</option>').join('') + '</lo-combo>'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const readOpts = (el) => [...el.querySelectorAll(':scope > option')].map((o) => ({ value: o.getAttribute('value') != null ? o.getAttribute('value') : o.textContent, label: o.textContent, sub: o.dataset.sub || '', selected: o.hasAttribute('selected') }))
|
|
45
|
+
const narrow = () => window.innerWidth <= SHEET_MAX
|
|
46
|
+
const fire = (el, type) => el.dispatchEvent(new Event(type, { bubbles: true }))
|
|
47
|
+
|
|
48
|
+
// (a plain class under Node, so the unit tests can import the markup helpers)
|
|
49
|
+
const Base = typeof HTMLElement === 'undefined' ? class {} : HTMLElement
|
|
50
|
+
class LoSelect extends Base {
|
|
51
|
+
connectedCallback() {
|
|
52
|
+
if (this._ready) return
|
|
53
|
+
this._ready = true
|
|
54
|
+
this._opts = readOpts(this)
|
|
55
|
+
const sel = this._opts.findIndex((o) => o.selected)
|
|
56
|
+
this._index = sel >= 0 ? sel : (this._opts.length ? 0 : -1)
|
|
57
|
+
this._id = 'lo' + (++uid)
|
|
58
|
+
const lbl = this.getAttribute('aria-label') || ''
|
|
59
|
+
this.removeAttribute('aria-label')
|
|
60
|
+
this.innerHTML = '<button type="button" class="lo-sel-btn" role="combobox" aria-haspopup="listbox" aria-expanded="false" aria-controls="' + this._id + '-list"' + (lbl ? ' aria-label="' + esc(lbl) + '"' : '') + (this.hasAttribute('disabled') ? ' disabled' : '') + '>' +
|
|
61
|
+
'<span class="lo-sel-val"></span>' + CHEVRON + '</button>'
|
|
62
|
+
this._btn = this.firstChild
|
|
63
|
+
if (!lbl && this.id) { const l = document.querySelector('label[for="' + CSS.escape(this.id) + '"]'); if (l) { if (!l.id) l.id = this._id + '-label'; this._btn.setAttribute('aria-labelledby', l.id + ' ' + this._id + '-val') } }
|
|
64
|
+
this._btn.querySelector('.lo-sel-val').id = this._id + '-val'
|
|
65
|
+
this._paint()
|
|
66
|
+
this._btn.addEventListener('click', () => (open && open.host === this ? closeMenu() : openMenu(this)))
|
|
67
|
+
this._btn.addEventListener('keydown', (e) => onKey(this, e))
|
|
68
|
+
}
|
|
69
|
+
_paint() { const o = this._opts[this._index]; const v = this._btn && this._btn.querySelector('.lo-sel-val'); if (v) v.textContent = o ? o.label : ''; if (this._btn) this._btn.title = o ? o.label : '' }
|
|
70
|
+
get options() { return (this._opts || readOpts(this)).map((o) => ({ ...o })) }
|
|
71
|
+
get value() { const o = (this._opts || readOpts(this))[this._ready ? this._index : Math.max(0, readOpts(this).findIndex((x) => x.selected))]; return o ? String(o.value) : '' }
|
|
72
|
+
set value(v) { if (!this._ready) this.connectedCallback(); const i = this._opts.findIndex((o) => String(o.value) === String(v)); if (i >= 0) { this._index = i; this._paint() } }
|
|
73
|
+
get selectedIndex() { return this._ready ? this._index : Math.max(0, readOpts(this).findIndex((x) => x.selected)) }
|
|
74
|
+
set selectedIndex(i) { if (!this._ready) this.connectedCallback(); if (i >= 0 && i < this._opts.length) { this._index = i; this._paint() } }
|
|
75
|
+
get disabled() { return this.hasAttribute('disabled') }
|
|
76
|
+
set disabled(v) { this.toggleAttribute('disabled', !!v); if (this._btn) this._btn.disabled = !!v }
|
|
77
|
+
focus() { if (this._btn) this._btn.focus() }
|
|
78
|
+
// the control a menu anchors to and returns focus to
|
|
79
|
+
get _anchor() { return this._btn }
|
|
80
|
+
_choose(i) {
|
|
81
|
+
if (i < 0 || i >= this._opts.length) return
|
|
82
|
+
const changed = i !== this._index
|
|
83
|
+
this._index = i; this._paint(); closeMenu(); this._btn.focus()
|
|
84
|
+
if (changed) { fire(this, 'input'); fire(this, 'change') }
|
|
85
|
+
}
|
|
86
|
+
_items(q) { return this._opts.map((o, i) => ({ ...o, i })).filter((o) => !q || (o.label + ' ' + o.value + ' ' + o.sub).toLowerCase().includes(q)) }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
class LoCombo extends Base {
|
|
90
|
+
connectedCallback() {
|
|
91
|
+
if (this._ready) return
|
|
92
|
+
this._ready = true
|
|
93
|
+
this._opts = readOpts(this)
|
|
94
|
+
this._id = 'lo' + (++uid)
|
|
95
|
+
const a = (k) => this.getAttribute(k)
|
|
96
|
+
const inputId = this.id ? this.id + '-input' : this._id + '-input'
|
|
97
|
+
this.innerHTML = '<input type="text" class="lo-combo-input" id="' + esc(inputId) + '" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="' + this._id + '-list" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"' +
|
|
98
|
+
(a('aria-label') ? ' aria-label="' + esc(a('aria-label')) + '"' : '') + (a('placeholder') ? ' placeholder="' + esc(a('placeholder')) + '"' : '') + '>' +
|
|
99
|
+
'<button type="button" class="lo-combo-btn" tabindex="-1" aria-label="Show suggestions">' + CHEVRON + '</button>'
|
|
100
|
+
this.removeAttribute('aria-label')
|
|
101
|
+
this._input = this.querySelector('input')
|
|
102
|
+
if (a('value')) this._input.value = a('value')
|
|
103
|
+
const btn = this.querySelector('.lo-combo-btn')
|
|
104
|
+
btn.addEventListener('mousedown', (e) => e.preventDefault())
|
|
105
|
+
btn.addEventListener('click', () => { if (open && open.host === this) closeMenu(); else { this._input.focus(); openMenu(this, { all: true }) } })
|
|
106
|
+
this._input.addEventListener('input', (e) => { e.stopPropagation(); fire(this, 'input'); if (this._items(this._q()).length) openMenu(this); else closeMenu() })
|
|
107
|
+
this._input.addEventListener('change', (e) => { e.stopPropagation(); fire(this, 'change') })
|
|
108
|
+
this._input.addEventListener('keydown', (e) => onKey(this, e))
|
|
109
|
+
this._input.addEventListener('focus', () => { if (narrow()) return; if (!this._input.value && this._opts.length) openMenu(this, { all: true }) })
|
|
110
|
+
}
|
|
111
|
+
_q() { return this._input ? this._input.value.trim().toLowerCase() : '' }
|
|
112
|
+
get options() { return (this._opts || readOpts(this)).map((o) => ({ ...o })) }
|
|
113
|
+
get value() { return this._input ? this._input.value : (this.getAttribute('value') || '') }
|
|
114
|
+
set value(v) { if (this._input) this._input.value = v == null ? '' : String(v); else this.setAttribute('value', v == null ? '' : String(v)) }
|
|
115
|
+
focus() { if (this._input) this._input.focus() }
|
|
116
|
+
get _anchor() { return this._input }
|
|
117
|
+
_choose(i) {
|
|
118
|
+
const o = this._opts[i]
|
|
119
|
+
if (!o) return
|
|
120
|
+
this._input.value = String(o.value); closeMenu(); this._input.focus()
|
|
121
|
+
fire(this, 'input'); fire(this, 'change')
|
|
122
|
+
}
|
|
123
|
+
_items(q, all) {
|
|
124
|
+
const list = this._opts.map((o, i) => ({ ...o, i }))
|
|
125
|
+
if (all || !q) return list
|
|
126
|
+
// names that start with the text first, then the rest that contain it
|
|
127
|
+
const starts = list.filter((o) => o.label.toLowerCase().startsWith(q))
|
|
128
|
+
return starts.concat(list.filter((o) => !starts.includes(o) && (o.label + ' ' + o.sub).toLowerCase().includes(q)))
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function openMenu(host, { all = false } = {}) {
|
|
133
|
+
if (host.disabled) return
|
|
134
|
+
const combo = host instanceof LoCombo
|
|
135
|
+
if (open && open.host !== host) closeMenu()
|
|
136
|
+
// a select opens as a bottom sheet on a phone; a combo keeps its field in
|
|
137
|
+
// view (the keyboard is up), so its list is a full-width panel under it
|
|
138
|
+
const sheet = narrow() && !combo
|
|
139
|
+
if (!open) {
|
|
140
|
+
const menu = document.createElement('div')
|
|
141
|
+
menu.className = 'lo-menu' + (sheet ? ' sheet' : '')
|
|
142
|
+
const label = host.dataset.title || (host._anchor && (host._anchor.getAttribute('aria-label') || (host._anchor.getAttribute('aria-labelledby') && document.getElementById(host._anchor.getAttribute('aria-labelledby').split(' ')[0]) || {}).textContent)) || ''
|
|
143
|
+
const wantFilter = !combo && host._opts.length > FILTER_AT
|
|
144
|
+
menu.innerHTML = (sheet ? '<div class="lo-sheet-head"><span>' + esc(label) + '</span><button type="button" class="lo-sheet-done">Done</button></div>' : '') +
|
|
145
|
+
(wantFilter ? '<div class="lo-filter-wrap"><input type="search" class="lo-filter" placeholder="Filter…" aria-label="Filter ' + esc(label) + '" autocomplete="off" autocapitalize="off" spellcheck="false" aria-controls="' + host._id + '-list"></div>' : '') +
|
|
146
|
+
'<div class="lo-list" role="listbox" id="' + host._id + '-list"' + (label ? ' aria-label="' + esc(label) + '"' : '') + '></div>'
|
|
147
|
+
let backdrop = null
|
|
148
|
+
if (sheet) { backdrop = document.createElement('div'); backdrop.className = 'lo-backdrop'; document.body.appendChild(backdrop); backdrop.addEventListener('click', () => closeMenu()) }
|
|
149
|
+
document.body.appendChild(menu)
|
|
150
|
+
open = { host, menu, list: menu.querySelector('.lo-list'), filter: menu.querySelector('.lo-filter'), backdrop, active: -1, items: [] }
|
|
151
|
+
host._anchor.setAttribute('aria-expanded', 'true')
|
|
152
|
+
host.classList.add('open')
|
|
153
|
+
menu.addEventListener('mousedown', (e) => { if (!e.target.closest('.lo-filter')) e.preventDefault() })
|
|
154
|
+
menu.addEventListener('click', (e) => {
|
|
155
|
+
if (e.target.closest('.lo-sheet-done')) { closeMenu(); host._anchor.focus(); return }
|
|
156
|
+
const opt = e.target.closest('[role="option"]')
|
|
157
|
+
if (opt) host._choose(Number(opt.dataset.i))
|
|
158
|
+
})
|
|
159
|
+
menu.addEventListener('mousemove', (e) => { const opt = e.target.closest('[role="option"]'); if (opt) setActive(open.items.findIndex((x) => x.i === Number(opt.dataset.i)), { scroll: false }) })
|
|
160
|
+
if (open.filter) {
|
|
161
|
+
open.filter.addEventListener('input', () => fill(host, open.filter.value.trim().toLowerCase()))
|
|
162
|
+
open.filter.addEventListener('keydown', (e) => onKey(host, e))
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
fill(host, combo ? (all ? '' : host._q()) : '', { all })
|
|
166
|
+
if (open.filter && !sheet) open.filter.focus({ preventScroll: true })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function fill(host, q, { all = false } = {}) {
|
|
170
|
+
if (!open) return
|
|
171
|
+
const combo = host instanceof LoCombo
|
|
172
|
+
open.items = host._items(q, all)
|
|
173
|
+
const cur = combo ? -1 : host._index
|
|
174
|
+
const needle = q && !all ? q : ''
|
|
175
|
+
const mark = (s) => { if (!needle) return esc(s); const i = s.toLowerCase().indexOf(needle); return i < 0 ? esc(s) : esc(s.slice(0, i)) + '<mark>' + esc(s.slice(i, i + needle.length)) + '</mark>' + esc(s.slice(i + needle.length)) }
|
|
176
|
+
open.list.innerHTML = open.items.length ? open.items.map((o, k) => {
|
|
177
|
+
const sel = combo ? String(o.value) === host.value : o.i === cur
|
|
178
|
+
return '<div role="option" class="lo-opt' + (sel ? ' sel' : '') + '" id="' + host._id + '-o' + o.i + '" data-i="' + o.i + '" aria-selected="' + sel + '">' + CHECK +
|
|
179
|
+
'<span class="lo-opt-text"><span class="lo-opt-label">' + mark(o.label) + '</span>' + (o.sub ? '<span class="lo-opt-sub">' + esc(o.sub) + '</span>' : '') + '</span></div>'
|
|
180
|
+
}).join('') : '<div class="lo-empty">No match</div>'
|
|
181
|
+
const selPos = open.items.findIndex((o) => (combo ? String(o.value) === host.value : o.i === cur))
|
|
182
|
+
setActive(combo ? (q && !all && open.items.length ? 0 : selPos) : (selPos >= 0 ? selPos : 0), { scroll: true })
|
|
183
|
+
place()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function setActive(k, { scroll = true } = {}) {
|
|
187
|
+
if (!open) return
|
|
188
|
+
const { list, items, host } = open
|
|
189
|
+
open.active = k
|
|
190
|
+
list.querySelectorAll('.lo-opt.active').forEach((el) => el.classList.remove('active'))
|
|
191
|
+
const it = items[k]
|
|
192
|
+
const target = open.filter || host._anchor
|
|
193
|
+
if (!it) { host._anchor.removeAttribute('aria-activedescendant'); if (open.filter) open.filter.removeAttribute('aria-activedescendant'); return }
|
|
194
|
+
const el = document.getElementById(host._id + '-o' + it.i)
|
|
195
|
+
if (el) { el.classList.add('active'); if (scroll) el.scrollIntoView({ block: 'nearest' }) }
|
|
196
|
+
target.setAttribute('aria-activedescendant', host._id + '-o' + it.i)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Desktop: under the control (above it when there is more room there), at
|
|
200
|
+
// least as wide as the control, never past the viewport's edges.
|
|
201
|
+
function place() {
|
|
202
|
+
if (!open) return
|
|
203
|
+
const { menu, host } = open
|
|
204
|
+
const vw = document.documentElement.clientWidth, vh = (window.visualViewport && window.visualViewport.height) || window.innerHeight
|
|
205
|
+
if (menu.classList.contains('sheet')) { menu.style.maxHeight = Math.round(vh * 0.72) + 'px'; return }
|
|
206
|
+
const r = host.getBoundingClientRect()
|
|
207
|
+
const pad = 8
|
|
208
|
+
const full = narrow()
|
|
209
|
+
menu.classList.toggle('wide', full)
|
|
210
|
+
menu.style.minWidth = (full ? vw - 2 * pad : Math.min(Math.max(r.width, 180), vw - 2 * pad)) + 'px'
|
|
211
|
+
menu.style.maxWidth = (full ? vw - 2 * pad : Math.min(440, vw - 2 * pad)) + 'px'
|
|
212
|
+
const below = vh - r.bottom - pad, above = r.top - pad
|
|
213
|
+
const up = below < 220 && above > below
|
|
214
|
+
const room = Math.max(120, Math.min(340, (up ? above : below) - 6))
|
|
215
|
+
menu.style.maxHeight = room + 'px'
|
|
216
|
+
const w = menu.offsetWidth, h = menu.offsetHeight
|
|
217
|
+
menu.style.left = Math.max(pad, Math.min(r.left, vw - pad - w)) + 'px'
|
|
218
|
+
menu.style.top = (up ? Math.max(pad, r.top - 6 - h) : r.bottom + 6) + 'px'
|
|
219
|
+
menu.classList.toggle('up', up)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function closeMenu() {
|
|
223
|
+
if (!open) return
|
|
224
|
+
const { menu, backdrop, host } = open
|
|
225
|
+
open = null
|
|
226
|
+
menu.remove(); if (backdrop) backdrop.remove()
|
|
227
|
+
host.classList.remove('open')
|
|
228
|
+
if (host._anchor) { host._anchor.setAttribute('aria-expanded', 'false'); host._anchor.removeAttribute('aria-activedescendant') }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function typeahead(host, ch) {
|
|
232
|
+
host._ta = (Date.now() - (host._taAt || 0) < 700 ? host._ta || '' : '') + ch.toLowerCase()
|
|
233
|
+
host._taAt = Date.now()
|
|
234
|
+
const opts = open ? open.items : host._opts.map((o, i) => ({ ...o, i }))
|
|
235
|
+
const k = opts.findIndex((o) => o.label.toLowerCase().startsWith(host._ta))
|
|
236
|
+
if (k < 0) return
|
|
237
|
+
if (open) setActive(k); else host._choose(opts[k].i)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function onKey(host, e) {
|
|
241
|
+
const combo = host instanceof LoCombo
|
|
242
|
+
const isOpen = open && open.host === host
|
|
243
|
+
const n = isOpen ? open.items.length : 0
|
|
244
|
+
switch (e.key) {
|
|
245
|
+
case 'ArrowDown': case 'ArrowUp': {
|
|
246
|
+
e.preventDefault()
|
|
247
|
+
if (!isOpen) { openMenu(host, { all: combo && !host._q() }); if (e.altKey) return; return }
|
|
248
|
+
if (e.altKey && e.key === 'ArrowUp') { if (open.active >= 0) host._choose(open.items[open.active].i); return }
|
|
249
|
+
const d = e.key === 'ArrowDown' ? 1 : -1
|
|
250
|
+
setActive(n ? (open.active < 0 ? (d > 0 ? 0 : n - 1) : (open.active + d + n) % n) : -1)
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
case 'Home': case 'End':
|
|
254
|
+
if (!isOpen || (combo && !e.ctrlKey)) return
|
|
255
|
+
e.preventDefault(); setActive(e.key === 'Home' ? 0 : n - 1); return
|
|
256
|
+
case 'PageDown': case 'PageUp':
|
|
257
|
+
if (!isOpen) return
|
|
258
|
+
e.preventDefault(); setActive(Math.max(0, Math.min(n - 1, open.active + (e.key === 'PageDown' ? 8 : -8)))); return
|
|
259
|
+
case 'Enter':
|
|
260
|
+
if (isOpen && open.active >= 0 && open.items[open.active]) { e.preventDefault(); host._choose(open.items[open.active].i); return }
|
|
261
|
+
if (!combo) { e.preventDefault(); if (!isOpen) openMenu(host) }
|
|
262
|
+
return
|
|
263
|
+
case ' ':
|
|
264
|
+
if (combo || (open && e.target === open.filter)) return
|
|
265
|
+
e.preventDefault()
|
|
266
|
+
if (isOpen && open.active >= 0) host._choose(open.items[open.active].i); else if (!isOpen) openMenu(host)
|
|
267
|
+
return
|
|
268
|
+
case 'Escape':
|
|
269
|
+
if (isOpen) { e.preventDefault(); e.stopPropagation(); closeMenu(); host._anchor.focus() }
|
|
270
|
+
return
|
|
271
|
+
case 'Tab':
|
|
272
|
+
if (isOpen) closeMenu()
|
|
273
|
+
return
|
|
274
|
+
default:
|
|
275
|
+
if (!combo && e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey && !(open && e.target === open.filter)) { e.preventDefault(); typeahead(host, e.key) }
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (typeof window !== 'undefined' && window.customElements && !customElements.get('lo-select')) {
|
|
280
|
+
customElements.define('lo-select', LoSelect)
|
|
281
|
+
customElements.define('lo-combo', LoCombo)
|
|
282
|
+
// a click outside (or a tap on the page behind a sheet) closes; so does the page moving under a desktop menu
|
|
283
|
+
document.addEventListener('pointerdown', (e) => { if (open && !open.menu.contains(e.target) && !open.host.contains(e.target)) closeMenu() }, true)
|
|
284
|
+
window.addEventListener('resize', () => { if (open) closeMenu() })
|
|
285
|
+
document.addEventListener('scroll', (e) => { if (open && !open.menu.contains(e.target)) place() }, true)
|
|
286
|
+
// <label for="f-tz"> focuses (and on a click, opens) the control, as it would a select
|
|
287
|
+
document.addEventListener('click', (e) => {
|
|
288
|
+
const l = e.target.closest && e.target.closest('label[for]')
|
|
289
|
+
if (!l) return
|
|
290
|
+
const host = document.getElementById(l.getAttribute('for'))
|
|
291
|
+
if (!host || !(host instanceof LoSelect || host instanceof LoCombo)) return
|
|
292
|
+
e.preventDefault(); host.focus()
|
|
293
|
+
})
|
|
294
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// The board through the hosted relay (v0.6.0, "Use from anywhere").
|
|
2
|
+
//
|
|
3
|
+
// Loaded on every board page; it does nothing unless the relay marked the page
|
|
4
|
+
// (<meta name="leftoff-relay">, added at <user>.leftoff.dev only). Then every
|
|
5
|
+
// same-origin /v1/ call the board makes is sealed with this browser's device
|
|
6
|
+
// key (made at pairing, kept in IndexedDB, not extractable) and sent to one of
|
|
7
|
+
// your Macs through /_relay/api. The relay passes ciphertext. The live-update
|
|
8
|
+
// stream becomes a long-poll over the same tunnel. Which Mac: the one you
|
|
9
|
+
// picked in the status popover, else the most recently active one.
|
|
10
|
+
import { sealRequest, openResponse } from './relay-crypto.js'
|
|
11
|
+
|
|
12
|
+
const meta = typeof document !== 'undefined' && document.querySelector('meta[name="leftoff-relay"]')
|
|
13
|
+
export const RELAY_USER = meta ? meta.getAttribute('content') : null
|
|
14
|
+
const MAC_KEY = 'leftoff.relay.mac'
|
|
15
|
+
const realFetch = typeof window !== 'undefined' ? window.fetch.bind(window) : null
|
|
16
|
+
|
|
17
|
+
// ------------------------------------------------------------ key store
|
|
18
|
+
|
|
19
|
+
function idb() {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const r = indexedDB.open('leftoff-relay', 1)
|
|
22
|
+
r.onupgradeneeded = () => r.result.createObjectStore('keys')
|
|
23
|
+
r.onsuccess = () => resolve(r.result)
|
|
24
|
+
r.onerror = () => reject(r.error)
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
export async function saveDeviceKey(rec) {
|
|
28
|
+
const db = await idb()
|
|
29
|
+
await new Promise((resolve, reject) => { const t = db.transaction('keys', 'readwrite'); t.objectStore('keys').put(rec, 'device'); t.oncomplete = resolve; t.onerror = () => reject(t.error) })
|
|
30
|
+
}
|
|
31
|
+
export async function loadDeviceKey() {
|
|
32
|
+
try {
|
|
33
|
+
const db = await idb()
|
|
34
|
+
return await new Promise((resolve) => { const t = db.transaction('keys', 'readonly'); const g = t.objectStore('keys').get('device'); g.onsuccess = () => resolve(g.result || null); g.onerror = () => resolve(null) })
|
|
35
|
+
} catch { return null }
|
|
36
|
+
}
|
|
37
|
+
export async function forgetDeviceKey() {
|
|
38
|
+
try { const db = await idb(); await new Promise((resolve) => { const t = db.transaction('keys', 'readwrite'); t.objectStore('keys').delete('device'); t.oncomplete = resolve; t.onerror = resolve }) } catch {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ------------------------------------------------------------ state
|
|
42
|
+
|
|
43
|
+
const relay = { user: RELAY_USER, device: null, key: null, status: null, mac: null, ready: null, latencyMs: null }
|
|
44
|
+
|
|
45
|
+
function pickedMac() { try { return localStorage.getItem(MAC_KEY) } catch { return null } }
|
|
46
|
+
function remember(mac) {
|
|
47
|
+
try { if (mac) localStorage.setItem(MAC_KEY, mac); else localStorage.removeItem(MAC_KEY) } catch {}
|
|
48
|
+
}
|
|
49
|
+
function cookieMac(mac) { document.cookie = 'lo_mac=' + encodeURIComponent(mac || '') + '; Path=/; SameSite=Lax; Max-Age=' + (mac ? 31536000 : 0) + (location.protocol === 'https:' ? '; Secure' : '') }
|
|
50
|
+
|
|
51
|
+
async function refreshStatus() {
|
|
52
|
+
const t0 = performance.now()
|
|
53
|
+
const res = await realFetch('/_relay/status', { credentials: 'same-origin', cache: 'no-store' })
|
|
54
|
+
if (res.status === 401) { notPaired(); throw new Error('not paired') }
|
|
55
|
+
relay.status = await res.json()
|
|
56
|
+
relay.latencyMs = Math.round(performance.now() - t0)
|
|
57
|
+
const online = relay.status.macs.filter((m) => m.online).map((m) => m.machine)
|
|
58
|
+
const want = pickedMac()
|
|
59
|
+
relay.mac = want && online.includes(want) ? want : relay.status.default
|
|
60
|
+
cookieMac(relay.mac)
|
|
61
|
+
return relay.status
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function init() {
|
|
65
|
+
const rec = await loadDeviceKey()
|
|
66
|
+
if (!rec || !rec.key || (rec.user && rec.user !== RELAY_USER)) { needsKey(); throw new Error('no device key') }
|
|
67
|
+
relay.device = rec.device; relay.key = rec.key
|
|
68
|
+
await refreshStatus()
|
|
69
|
+
if (!relay.mac) { asleep(); throw new Error('asleep') }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ------------------------------------------------------------ the tunnel
|
|
73
|
+
|
|
74
|
+
async function tunnel(url, init = {}, retried = false) {
|
|
75
|
+
await relay.ready
|
|
76
|
+
const method = String(init.method || 'GET').toUpperCase()
|
|
77
|
+
let b = init.body == null ? null : init.body
|
|
78
|
+
if (b != null && typeof b !== 'string') b = await new Response(b).text()
|
|
79
|
+
const h = {}
|
|
80
|
+
const hdrs = new Headers(init.headers || {})
|
|
81
|
+
if (hdrs.get('content-type')) h['content-type'] = hdrs.get('content-type')
|
|
82
|
+
const env = await sealRequest(relay.key, { device: relay.device, machine: relay.mac, body: { m: method, p: url.pathname + url.search, b, h } })
|
|
83
|
+
const res = await realFetch('/_relay/api', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify(env), signal: init.signal, cache: 'no-store' })
|
|
84
|
+
if (res.status === 200) {
|
|
85
|
+
const out = await openResponse(relay.key, env, await res.json())
|
|
86
|
+
return new Response(out.b, { status: out.s, headers: out.h })
|
|
87
|
+
}
|
|
88
|
+
const j = await res.clone().json().catch(() => ({}))
|
|
89
|
+
if (res.status === 401) { notPaired(j.code === 'unknown_device'); return res }
|
|
90
|
+
if (res.status === 503 && j.code === 'mac_offline' && !retried) {
|
|
91
|
+
// the Mac you were on went to sleep: another one takes over, or the asleep page
|
|
92
|
+
if (j.default && j.default !== relay.mac) { relay.mac = j.default; cookieMac(relay.mac); window.dispatchEvent(new CustomEvent('leftoff-relay', { detail: { mac: relay.mac, switched: true } })); return tunnel(url, init, true) }
|
|
93
|
+
asleep()
|
|
94
|
+
}
|
|
95
|
+
return res
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function patchedFetch(input, init) {
|
|
99
|
+
try {
|
|
100
|
+
const url = new URL(typeof input === 'string' ? input : input.url, location.href)
|
|
101
|
+
if (url.origin === location.origin && url.pathname.startsWith('/v1/')) return tunnel(url, typeof input === 'string' ? init : { method: input.method, headers: input.headers, body: init && init.body, ...(init || {}) })
|
|
102
|
+
} catch {}
|
|
103
|
+
return realFetch(input, init)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// EventSource('/v1/stream') -> long-polls of GET /v1/events through the tunnel.
|
|
107
|
+
class RelayEventSource extends EventTarget {
|
|
108
|
+
constructor(url) {
|
|
109
|
+
super()
|
|
110
|
+
this.url = url; this.readyState = 0; this.onopen = null; this.onerror = null; this.onmessage = null
|
|
111
|
+
this.seq = null; this.ctrl = null
|
|
112
|
+
this.loop()
|
|
113
|
+
}
|
|
114
|
+
async loop() {
|
|
115
|
+
let fails = 0
|
|
116
|
+
while (this.readyState !== 2) {
|
|
117
|
+
try {
|
|
118
|
+
this.ctrl = new AbortController()
|
|
119
|
+
const res = await patchedFetch('/v1/events?after=' + (this.seq == null ? -1 : this.seq) + '&ms=20000', { signal: this.ctrl.signal })
|
|
120
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
121
|
+
const j = await res.json()
|
|
122
|
+
if (this.readyState === 0) { this.readyState = 1; if (this.onopen) this.onopen(new Event('open')) }
|
|
123
|
+
const first = this.seq == null
|
|
124
|
+
this.seq = j.seq
|
|
125
|
+
if (!first) for (const e of j.events || []) {
|
|
126
|
+
const ev = new MessageEvent(e.event, { data: JSON.stringify(e.data) })
|
|
127
|
+
this.dispatchEvent(ev)
|
|
128
|
+
}
|
|
129
|
+
fails = 0
|
|
130
|
+
} catch (e) {
|
|
131
|
+
if (this.readyState === 2) return
|
|
132
|
+
fails++
|
|
133
|
+
if (this.onerror) this.onerror(new Event('error'))
|
|
134
|
+
await new Promise((r) => setTimeout(r, Math.min(15000, 1000 * 2 ** Math.min(fails, 4))))
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
close() { this.readyState = 2; if (this.ctrl) this.ctrl.abort() }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ------------------------------------------------------------ screens
|
|
142
|
+
|
|
143
|
+
function screen(title, html) {
|
|
144
|
+
const el = document.createElement('div')
|
|
145
|
+
el.className = 'relay-screen'
|
|
146
|
+
el.setAttribute('role', 'dialog')
|
|
147
|
+
el.innerHTML = '<div class="relay-card"><div class="relay-logo">Leftoff</div><h1>' + title + '</h1>' + html + '</div>'
|
|
148
|
+
const put = () => { document.querySelectorAll('.relay-screen').forEach((x) => x.remove()); document.body.appendChild(el) }
|
|
149
|
+
if (document.body) put(); else document.addEventListener('DOMContentLoaded', put)
|
|
150
|
+
}
|
|
151
|
+
function needsKey() {
|
|
152
|
+
screen('Pair this browser again', '<p>This browser has a pass for ' + esc(RELAY_USER) + '.leftoff.dev but not the key that unlocks your board (it lives only in this browser, and it’s gone: private browsing, cleared website data, or another browser).</p>' +
|
|
153
|
+
'<p>On a Mac: Settings → Phone & remote → <b>Pair a phone</b>, then scan the QR code.</p><p><a class="btn primary" href="/pair.html">I have a code</a></p>')
|
|
154
|
+
}
|
|
155
|
+
function notPaired(revoked) {
|
|
156
|
+
forgetDeviceKey()
|
|
157
|
+
screen('This browser isn’t paired anymore', '<p>' + (revoked ? 'It was revoked on one of your Macs.' : 'It was revoked, or its pass expired.') + ' Pair it again from a Mac: Settings → Phone & remote → <b>Pair a phone</b>.</p><p><a class="btn primary" href="/pair.html">I have a code</a></p>')
|
|
158
|
+
}
|
|
159
|
+
let asleepShown = false
|
|
160
|
+
function asleep() {
|
|
161
|
+
if (asleepShown) return
|
|
162
|
+
asleepShown = true
|
|
163
|
+
// the relay draws the asleep page (with when each Mac was last seen)
|
|
164
|
+
location.replace('/?asleep=' + Date.now())
|
|
165
|
+
}
|
|
166
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]))
|
|
167
|
+
|
|
168
|
+
// ------------------------------------------------------------ public
|
|
169
|
+
|
|
170
|
+
export const leftoffRelay = {
|
|
171
|
+
user: RELAY_USER,
|
|
172
|
+
get mac() { return relay.mac },
|
|
173
|
+
get device() { return relay.device },
|
|
174
|
+
get latencyMs() { return relay.latencyMs },
|
|
175
|
+
status: () => refreshStatus(),
|
|
176
|
+
// Switch Macs: the board reloads from the other one.
|
|
177
|
+
pick(mac) { remember(mac); cookieMac(mac); location.reload() },
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (RELAY_USER) {
|
|
181
|
+
window.fetch = patchedFetch
|
|
182
|
+
window.EventSource = RelayEventSource
|
|
183
|
+
window.leftoffRelay = leftoffRelay
|
|
184
|
+
relay.ready = init()
|
|
185
|
+
relay.ready.catch(() => {})
|
|
186
|
+
document.documentElement.classList.add('via-relay')
|
|
187
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// End-to-end encryption between a paired browser and your Macs, for
|
|
2
|
+
// "Use from anywhere" (v0.6.0). The same file runs in the browser (served to
|
|
3
|
+
// the page) and in the daemon (Node's WebCrypto), so both sides agree by
|
|
4
|
+
// construction. Nothing here is secret: the security is in the keys.
|
|
5
|
+
//
|
|
6
|
+
// Pairing (docs/relay.md has the picture):
|
|
7
|
+
// code 16 Crockford base32 characters, XXXX-XXXX-XXXX-XXXX. The first 4
|
|
8
|
+
// are the pairing id the relay routes by; the whole code is the
|
|
9
|
+
// secret, and it reaches the phone only inside the QR code / link
|
|
10
|
+
// fragment (#…), which browsers never send to a server, or typed.
|
|
11
|
+
// browser makes a P-256 key pair, sends { pair, pub, name, mac } where
|
|
12
|
+
// mac = HMAC(code, "leftoff-pair-v1|browser|<pair>|<pub>")
|
|
13
|
+
// Mac checks mac, makes its own P-256 key, answers { device, pub, mac }
|
|
14
|
+
// with mac = HMAC(code, "leftoff-pair-v1|mac|<pair>|<browserPub>|<macPub>|<device>")
|
|
15
|
+
// both K = HKDF-SHA256(ECDH, salt = code, info = "leftoff-relay-device-v1|<device>")
|
|
16
|
+
// The relay carries all of it and learns neither the code nor K: the ECDH
|
|
17
|
+
// private halves never leave the browser and the Mac, and the two MACs stop
|
|
18
|
+
// it from swapping in its own keys while the code is live (10 minutes).
|
|
19
|
+
//
|
|
20
|
+
// Requests: AES-256-GCM under K, a fresh 12-byte IV each, with the device,
|
|
21
|
+
// the target Mac, the request id and the time bound in as additional data,
|
|
22
|
+
// so a request can't be replayed, moved to the other Mac or answered with a
|
|
23
|
+
// different response.
|
|
24
|
+
const C = globalThis.crypto
|
|
25
|
+
const subtle = C.subtle
|
|
26
|
+
const te = new TextEncoder()
|
|
27
|
+
const td = new TextDecoder()
|
|
28
|
+
|
|
29
|
+
export const CODE_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
|
|
30
|
+
export const PAIR_TTL_MS = 10 * 60 * 1000
|
|
31
|
+
export const REQ_WINDOW_MS = 2 * 60 * 1000
|
|
32
|
+
|
|
33
|
+
export function b64u(bytes) {
|
|
34
|
+
const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
|
|
35
|
+
let s = ''
|
|
36
|
+
for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i])
|
|
37
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
38
|
+
}
|
|
39
|
+
export function unb64u(s) {
|
|
40
|
+
s = String(s || '').replace(/-/g, '+').replace(/_/g, '/')
|
|
41
|
+
while (s.length % 4) s += '='
|
|
42
|
+
const bin = atob(s)
|
|
43
|
+
const out = new Uint8Array(bin.length)
|
|
44
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
export function randomBytes(n) { return C.getRandomValues(new Uint8Array(n)) }
|
|
48
|
+
export function randomId(n = 16) { return b64u(randomBytes(n)) }
|
|
49
|
+
|
|
50
|
+
// ------------------------------------------------------------ pairing codes
|
|
51
|
+
|
|
52
|
+
export function newPairCode() {
|
|
53
|
+
const r = randomBytes(16)
|
|
54
|
+
let s = ''
|
|
55
|
+
for (let i = 0; i < 16; i++) s += CODE_ALPHABET[r[i] & 31]
|
|
56
|
+
return s.match(/.{4}/g).join('-')
|
|
57
|
+
}
|
|
58
|
+
// Typed by hand: case, dashes and spaces don't matter; O→0, I/L→1 as Crockford says.
|
|
59
|
+
export function normalizeCode(code) {
|
|
60
|
+
const s = String(code || '').toUpperCase().replace(/[\s-]/g, '').replace(/O/g, '0').replace(/[IL]/g, '1')
|
|
61
|
+
if (s.length !== 16 || [...s].some((ch) => !CODE_ALPHABET.includes(ch))) return null
|
|
62
|
+
return s.match(/.{4}/g).join('-')
|
|
63
|
+
}
|
|
64
|
+
export function pairIdOf(code) { const n = normalizeCode(code); return n ? n.slice(0, 4) : null }
|
|
65
|
+
// A link's fragment, #XXXX-XXXX-XXXX-XXXX (or #c=…): the code, or null.
|
|
66
|
+
export function codeFromHash(hash) {
|
|
67
|
+
const h = String(hash || '').replace(/^#/, '')
|
|
68
|
+
const m = h.match(/(?:^|[&?])c=([^&]+)/)
|
|
69
|
+
return normalizeCode(decodeURIComponent(m ? m[1] : h))
|
|
70
|
+
}
|
|
71
|
+
export function pairExpired(record, now = Date.now()) { return !record || !(record.expiresAt > now) }
|
|
72
|
+
|
|
73
|
+
async function hmacKey(code) {
|
|
74
|
+
return subtle.importKey('raw', te.encode(normalizeCode(code) || ''), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify'])
|
|
75
|
+
}
|
|
76
|
+
export async function pairMac(code, parts) {
|
|
77
|
+
const k = await hmacKey(code)
|
|
78
|
+
return b64u(await subtle.sign('HMAC', k, te.encode('leftoff-pair-v1|' + parts.join('|'))))
|
|
79
|
+
}
|
|
80
|
+
export async function pairMacOk(code, parts, mac) {
|
|
81
|
+
const k = await hmacKey(code)
|
|
82
|
+
try { return await subtle.verify('HMAC', k, unb64u(mac), te.encode('leftoff-pair-v1|' + parts.join('|'))) } catch { return false }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// P-256 because every Safari since 2017 has it.
|
|
86
|
+
export async function newEcdh({ extractable = false } = {}) {
|
|
87
|
+
const kp = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, extractable, ['deriveBits'])
|
|
88
|
+
const pub = b64u(await subtle.exportKey('raw', kp.publicKey))
|
|
89
|
+
return { privateKey: kp.privateKey, pub }
|
|
90
|
+
}
|
|
91
|
+
// K as an AES-GCM key. In the browser it's non-extractable: page code can use
|
|
92
|
+
// it but can't read it out. The Mac asks for the raw bytes to wrap them.
|
|
93
|
+
export async function deriveDeviceKey({ privateKey, peerPub, code, device, extractable = false }) {
|
|
94
|
+
const peer = await subtle.importKey('raw', unb64u(peerPub), { name: 'ECDH', namedCurve: 'P-256' }, false, [])
|
|
95
|
+
const shared = await subtle.deriveBits({ name: 'ECDH', public: peer }, privateKey, 256)
|
|
96
|
+
const ikm = await subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey', 'deriveBits'])
|
|
97
|
+
const params = { name: 'HKDF', hash: 'SHA-256', salt: te.encode(normalizeCode(code)), info: te.encode('leftoff-relay-device-v1|' + device) }
|
|
98
|
+
if (extractable) {
|
|
99
|
+
const raw = new Uint8Array(await subtle.deriveBits(params, ikm, 256))
|
|
100
|
+
return { raw, key: await importDeviceKey(raw) }
|
|
101
|
+
}
|
|
102
|
+
return { key: await subtle.deriveKey(params, ikm, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']) }
|
|
103
|
+
}
|
|
104
|
+
export function importDeviceKey(raw) {
|
|
105
|
+
return subtle.importKey('raw', raw instanceof Uint8Array ? raw : unb64u(raw), { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ------------------------------------------------------------ envelopes
|
|
109
|
+
|
|
110
|
+
const reqAad = (e) => te.encode(['leftoff-tunnel-v1', 'req', e.d, e.to, e.id, e.ts].join('|'))
|
|
111
|
+
const resAad = (e, req) => te.encode(['leftoff-tunnel-v1', 'res', req.d, req.to, req.id].join('|'))
|
|
112
|
+
|
|
113
|
+
// body: { m: method, p: path with query, b: body text or null, h: { content-type } }
|
|
114
|
+
export async function sealRequest(key, { device, machine, body, now = Date.now(), id = randomId(16) }) {
|
|
115
|
+
const env = { v: 1, d: device, to: machine, id, ts: now }
|
|
116
|
+
const iv = randomBytes(12)
|
|
117
|
+
const ct = await subtle.encrypt({ name: 'AES-GCM', iv, additionalData: reqAad(env) }, key, te.encode(JSON.stringify(body)))
|
|
118
|
+
return { ...env, iv: b64u(iv), ct: b64u(ct) }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export class TunnelError extends Error { constructor(code, message) { super(message || code); this.code = code } }
|
|
122
|
+
|
|
123
|
+
// The Mac's side. keyFor(device) -> CryptoKey or null (unknown or revoked).
|
|
124
|
+
// seen: a ReplayCache (or anything with has/add).
|
|
125
|
+
export async function openRequest(env, { keyFor, machine, seen, now = Date.now() }) {
|
|
126
|
+
if (!env || env.v !== 1 || typeof env.d !== 'string' || typeof env.id !== 'string' || typeof env.iv !== 'string' || typeof env.ct !== 'string' || typeof env.ts !== 'number') throw new TunnelError('bad_envelope')
|
|
127
|
+
if (env.to !== machine) throw new TunnelError('wrong_target', 'sealed for ' + env.to)
|
|
128
|
+
if (Math.abs(now - env.ts) > REQ_WINDOW_MS) throw new TunnelError('expired', 'the request is too old (or a clock is off)')
|
|
129
|
+
const key = await keyFor(env.d)
|
|
130
|
+
if (!key) throw new TunnelError('unknown_device', 'this browser isn’t paired with this Mac, or it was revoked')
|
|
131
|
+
if (seen && seen.has(env.d + '|' + env.id)) throw new TunnelError('replayed')
|
|
132
|
+
let body
|
|
133
|
+
try { body = JSON.parse(td.decode(await subtle.decrypt({ name: 'AES-GCM', iv: unb64u(env.iv), additionalData: reqAad(env) }, key, unb64u(env.ct)))) } catch { throw new TunnelError('decrypt_failed') }
|
|
134
|
+
if (seen) seen.add(env.d + '|' + env.id, env.ts + REQ_WINDOW_MS)
|
|
135
|
+
return body
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// body: { s: status, h: { content-type }, b: text }
|
|
139
|
+
export async function sealResponse(key, req, body) {
|
|
140
|
+
const iv = randomBytes(12)
|
|
141
|
+
const ct = await subtle.encrypt({ name: 'AES-GCM', iv, additionalData: resAad(null, req) }, key, te.encode(JSON.stringify(body)))
|
|
142
|
+
return { v: 1, re: req.id, iv: b64u(iv), ct: b64u(ct) }
|
|
143
|
+
}
|
|
144
|
+
export async function openResponse(key, req, env) {
|
|
145
|
+
if (!env || env.re !== req.id) throw new TunnelError('bad_response')
|
|
146
|
+
try { return JSON.parse(td.decode(await subtle.decrypt({ name: 'AES-GCM', iv: unb64u(env.iv), additionalData: resAad(null, req) }, key, unb64u(env.ct)))) } catch { throw new TunnelError('decrypt_failed') }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Remembers request ids until they can no longer pass the time check.
|
|
150
|
+
export class ReplayCache {
|
|
151
|
+
constructor() { this.m = new Map() }
|
|
152
|
+
has(k) { this.sweep(); return this.m.has(k) }
|
|
153
|
+
add(k, until) { this.m.set(k, until) }
|
|
154
|
+
sweep(now = Date.now()) { if (this.m.size < 500) return; for (const [k, t] of this.m) if (t < now) this.m.delete(k) }
|
|
155
|
+
}
|