diff options
Diffstat (limited to 'packages/meshbay-hub')
40 files changed, 2201 insertions, 302 deletions
diff --git a/packages/meshbay-hub/pyproject.toml b/packages/meshbay-hub/pyproject.toml index 7f1154c..c927242 100644 --- a/packages/meshbay-hub/pyproject.toml +++ b/packages/meshbay-hub/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "meshbay-hub" -version = "0.17.0" +version = "0.18.0" description = "MeshBay Hub — identity authority and group registry server" requires-python = ">=3.12" dependencies = [ diff --git a/packages/meshbay-hub/src/meshbay_hub/__init__.py b/packages/meshbay-hub/src/meshbay_hub/__init__.py index 4d3d6dd..cf6868d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/__init__.py +++ b/packages/meshbay-hub/src/meshbay_hub/__init__.py @@ -1,3 +1,3 @@ """MeshBay Hub — identity authority and group registry.""" -__version__ = "0.17.0" +__version__ = "0.18.0" diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py index a1afe38..2a3efaf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py @@ -88,7 +88,7 @@ async def hub_pubkey(): # reads MBK1/MBK2. An older client would still write them, and every such # bundle is one no browser of the account can open again. MIN_CLIENT_VERSION = "0.17.0" -RECOMMENDED_CLIENT_VERSION = "0.17.0" +RECOMMENDED_CLIENT_VERSION = "0.18.0" @router.get("/version") diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 72dd123..6f97ca5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -592,7 +592,10 @@ function HomePage({ groups, notifications, onMarkRead, onPurge, allowPublicGroup const [setupDismissed, setSetupDismissed] = useState(false); if (groups.length === 0) { - if (platform.isNative && !setupDismissed) { + // Setting up a node needs one to administer: the desktop application, not + // any native shell. A phone has a bridge and no node, and what it needs + // with no groups is the invitations and the join link below. + if (platform.capabilities.nodeAdmin && !setupDismissed) { return html`<${SetupWelcome} onDismiss=${() => setSetupDismissed(true)} />`; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js index c239cc8..ce5ec76 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js @@ -328,14 +328,6 @@ async function secretDigest(value) { .map((b) => b.toString(16).padStart(2, '0')).join(''); } -function rootAddSubject(path, name, kind, writable, removable) { - return adminSubject({ path, name, kind, writable, removable }); -} - -function groupAttachSubject(name, sharedDir, writable) { - return adminSubject({ name, shared_dir: sharedDir, writable }); -} - function inviteCreateSubject(userId, username) { return adminSubject({ user_id: userId, username }); } @@ -625,7 +617,7 @@ window.MeshBayCrypto = { importGEK, deriveChunkKey, decryptChunkBin, openGroup, sealGroup, unwrapGEK, b64encode, b64decode, - adminTranscript, adminSubject, rootAddSubject, groupAttachSubject, + adminTranscript, adminSubject, inviteCreateSubject, tmdbConfigSubject, handshakeTranscript, handshakeProof, webrtcBinding, challengeTranscript, joinTranscript, verifyNodeSignature, constantTimeEqual, deviceRequestTranscript, deviceAddTranscript, deviceHelloTranscript, diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js index aa45a25..2ff2bd4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js @@ -392,6 +392,10 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk throw err; } const chunkMsg = await inflight[nextRecv]; + // Released as soon as it is read: a resolved promise left here holds its + // ciphertext, and the array holds every chunk of the file — so a download + // written straight to disk was also held whole in the page until it ended. + inflight[nextRecv] = undefined; // One shape, and a refusal for anything else. There used to be two fallbacks // below this: a base64 `ct_b64` chunk, which was the real wire format until // the binary switch in Phase 9.15 and which no node has sent since, and a diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index bde218b..29aa7eb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -22,21 +22,23 @@ export const INVITE_EMAIL_PREF = 'invite_email'; * One component, two modes, because the Create Group wizard and the Settings * page were drifting apart while showing the same thing: * - * mode="live" — a hosted group. Every change is a signed operator op sent - * over MNP, or the loopback API when the node is on this - * machine and there is no live connection. + * mode="live" — a hosted group. What widens the node's sharing — adding + * a directory, its `writable` and `removable` switches — goes + * through the loopback API only, so only on the node's own + * machine. Removing, ejecting and plugging are signed ops + * over MNP, or the loopback API when there is no connection. * mode="local" — the wizard, before the group exists. Changes are held in * an array the caller owns; nothing is persisted until the * group is attached. * - * **Both paths matter and neither is optional.** The operator of a node is not - * necessarily sitting at it: they may be signing in from any browser, and the - * only thing that reaches their node from there is MNP. An earlier version of - * this read its roots exclusively from the loopback API, which resolves to - * "not available" in a browser — so the section rendered for nobody on the - * web, while the controls it replaced had worked there. `mnpRoots` is the - * source whenever a connection exists; the loopback list is the fallback for - * a local node that is not currently connected (a group still scanning, say). + * **The list is shown wherever the operator is.** They may be signing in from + * any browser, and the only thing that reaches their node from there is MNP. + * An earlier version of this read its roots exclusively from the loopback API, + * which resolves to "not available" in a browser — so the section rendered for + * nobody on the web. `mnpRoots` is the source whenever a connection exists; the + * loopback list is the fallback for a local node that is not currently + * connected (a group still scanning, say). From a browser the table is read + * only where it would widen anything (MNP 6.0). * * Props: * roots — the node's current roots: { name, path, writable, @@ -75,8 +77,6 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, <p class=${msg.error ? 'error-msg' : 'settings-hint'} role=${msg.error ? 'alert' : 'status'} style="margin-top:10px">${msg.text}</p>`; - const [pathDraft, setPathDraft] = useState(''); - const [addingByPath, setAddingByPath] = useState(false); // A toggle has to move under the finger, and the answer only comes back // when the node has signed, written node.toml and pushed the new table. @@ -115,12 +115,20 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, // left out otherwise, rather than printing a row of blanks. const hasPaths = displayRoots.some((r) => r.path); - // Which door a change goes through. MNP first: it is the only one that - // exists for an operator on the web, and it is signed, which the loopback - // API is not (it is authorized by being on localhost with the run token). + // Which door a change goes through. + // + // Widening what the node shares — a new directory, `writable`, `removable` — + // only through the loopback API, which exists only on the node's own + // machine. Over MNP these were signed ops, and a + // signature proves that the operator's key signed, not that the operator + // meant it: in a browser that key is driven by code the hub serves. + // + // Narrowing — remove, eject, plug — MNP first, then loopback. const overMnp = !isLocal && transport && transport.connected; const overLoopback = !isLocal && !overMnp && nodeAvail; + const onNodeMachine = !isLocal && nodeAvail; const canEdit = isLocal || overMnp || overLoopback; + const canWiden = isLocal || onNodeMachine; // Deliberately no index refresh after a root change. // @@ -158,9 +166,8 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, ...prev, [rootName]: { ...(prev[rootName] || {}), ...updates }, })); const ok = await run(async () => { - if (overMnp) await transport.updateRoot(groupId, rootName, updates, signFn); - else if (overLoopback) await platform.node.op('updateRoot', { groupId, rootName, updates }); - else throw new Error(t('node.root_no_route')); + if (onNodeMachine) await platform.node.op('updateRoot', { groupId, rootName, updates }); + else throw new Error(t('settings_node.roots_local_only_hint')); }); // Only a failure clears the patch here; a success waits for the node's // own table, so the switch never travels backwards on its way forwards. @@ -169,8 +176,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, const next = { ...prev }; delete next[rootName]; return next; }); } - }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback, - transport, groupId, signFn, run]); + }, [isLocal, localRoots, onLocalRootsChange, onNodeMachine, groupId, run]); const doEjectRoot = useCallback((rootName) => run(async () => { if (overMnp) await transport.ejectRoot(groupId, rootName, signFn); @@ -203,10 +209,9 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback, transport, groupId, signFn, run]); - // Adding a root needs a directory that exists on the *node's* filesystem. - // With the node on this machine that is a native folder picker; from any - // other browser the operator has to type the path, because nothing in a web - // page can browse a remote disk. Both end at the same signed op. + // Adding a root: a native folder picker on the node's own machine, and + // nothing anywhere else — a path typed into a page is a path a script in the + // page could have typed. const addRootAtPath = useCallback(async (path, name) => { if (isLocal) { if ((localRoots || []).some(r => r.path === path)) return true; @@ -222,16 +227,12 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, } setIndexProgress(null); return run(async () => { - if (overMnp) { - await transport.addRoot(groupId, path, { name }, signFn); - } else if (overLoopback) { - await platform.node.op('addRoot', { groupId, path, name }); - await platform.node.op('reload'); - await platform.watchIndexProgress(groupId, setIndexProgress); - } else throw new Error(t('node.root_no_route')); + if (!onNodeMachine) throw new Error(t('settings_node.roots_local_only_hint')); + await platform.node.op('addRoot', { groupId, path, name }); + await platform.node.op('reload'); + await platform.watchIndexProgress(groupId, setIndexProgress); }); - }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback, - transport, groupId, signFn, run]); + }, [isLocal, localRoots, onLocalRootsChange, onNodeMachine, groupId, run]); const doPickRoot = useCallback(async () => { const chosen = await platform.rootPicker.choose(); @@ -240,48 +241,23 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, if (ok && !isLocal) say(t('node.root_added')); }, [addRootAtPath, isLocal]); - const doAddByPath = useCallback(async () => { - const path = pathDraft.trim(); - if (!path) return; - // The name is the node's business — it derives the basename and refuses a - // duplicate. Sending one guessed from a string typed here would be a - // second opinion about something already decided in one place. - const ok = await addRootAtPath(path, ''); - if (ok) { setPathDraft(''); setAddingByPath(false); if (!isLocal) say(t('node.root_added')); } - }, [pathDraft, addRootAtPath, isLocal]); - - const addControls = !canEdit ? '' : html` - ${platform.rootPicker.available ? html` - <button class="btn btn-small btn-secondary" style="margin-top:8px" - disabled=${busy} onClick=${doPickRoot}> - <${Icon} name="folder-plus" /> ${t('node.add_root')} - </button> - ` : addingByPath ? html` - <div class="sdt-add-row"> - <input class="sdt-add-input" type="text" value=${pathDraft} - placeholder=${t('node.root_path_placeholder')} - disabled=${busy} - onInput=${(e) => setPathDraft(e.target.value)} - onKeyDown=${(e) => { if (e.key === 'Enter') doAddByPath(); }} /> - <button class="btn btn-small btn-secondary" disabled=${busy || !pathDraft.trim()} - onClick=${doAddByPath}>${t('node.add_root')}</button> - <button class="btn btn-small" disabled=${busy} - onClick=${() => { setAddingByPath(false); setPathDraft(''); }}> - ${t('settings.cancel')}</button> - </div> - <p class="settings-hint">${t('node.root_path_hint')}</p> - ` : html` - <button class="btn btn-small btn-secondary" style="margin-top:8px" - disabled=${busy} onClick=${() => setAddingByPath(true)}> - <${Icon} name="folder-plus" /> ${t('node.add_root')} - </button> - `} + const addControls = !canWiden || !platform.rootPicker.available ? '' : html` + <button class="btn btn-small btn-secondary" style="margin-top:8px" + disabled=${busy} onClick=${doPickRoot}> + <${Icon} name="folder-plus" /> ${t('node.add_root')} + </button> `; + // Said once, under the table, rather than as a tooltip on each switch: the + // switches are not broken, they are somewhere else. + const localOnlyHint = canEdit && !canWiden && html` + <p class="settings-hint" style="margin-top:8px"> + ${t('settings_node.roots_local_only_hint')}</p>`; if (!displayRoots.length) { return html` <div class="shared-directories-table"> <p class="settings-hint">${t('settings_node.shared_directories_hint')}</p> + ${localOnlyHint} ${addControls} ${message} </div> @@ -326,14 +302,15 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, doing the job. */''} ${canEdit && html` <td class="sdt-col-toggle"> - <${ToggleSwitch} checked=${!!r.writable} disabled=${busy || !!r.ejected} + <${ToggleSwitch} checked=${!!r.writable} + disabled=${busy || !!r.ejected || !canWiden} label=${t('node.root_rw')} onChange=${(v) => doUpdateRoot(r.name, { writable: v })} /> </td> `} ${canEdit && !isLocal && html` <td class="sdt-col-toggle"> - <${ToggleSwitch} checked=${!!r.removable} disabled=${busy} + <${ToggleSwitch} checked=${!!r.removable} disabled=${busy || !canWiden} label=${t('node.removable')} onChange=${(v) => doUpdateRoot(r.name, { removable: v })} /> </td> @@ -360,6 +337,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, `; })} </tbody> </table> + ${localOnlyHint} ${addControls} ${message} ${indexProgress && indexProgress.scanning && html` @@ -426,6 +404,9 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, // Node loopback state (Electron-only) const [nodeDetected, setNodeDetected] = useState(false); + // A node on this machine is not necessarily the one hosting this group, and + // the table's loopback door is only a door to *that* node. + const [nodeHostsGroup, setNodeHostsGroup] = useState(false); const [nodeRoots, setNodeRoots] = useState([]); const [nodeGroupName, setNodeGroupName] = useState(''); const [nodeBusy, setNodeBusy] = useState(false); @@ -464,6 +445,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, const data = await platform.node.op('groups'); const groups = data.groups || []; const ng = groups.find(g => g.id === groupId); + setNodeHostsGroup(Boolean(ng)); if (ng) { setNodeRoots(ng.roots || []); setNodeGroupName(ng.name || ''); @@ -1182,7 +1164,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, groupId=${groupId} transport=${transportRef.current} signFn=${adminSignFn} - nodeDetected=${nodeDetected} + nodeDetected=${nodeDetected && nodeHostsGroup} onRootsChange=${loadNodeInfo} onRefreshIndex=${onRefreshIndex} /> </${CollapsibleSection}> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/icon.js b/packages/meshbay-hub/src/meshbay_hub/static/icon.js index c80258c..4fa4357 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/icon.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/icon.js @@ -73,6 +73,10 @@ const ICON_PATHS = { 'chevron-left': ['M14.5 6l-6 6 6 6'], 'chevron-right': ['M9.5 6l6 6-6 6'], close: ['M6 6l12 12M18 6L6 18'], + // A circle nearly closed, its arrow at the top pointing the way it turns; + // the remote writes the seconds inside. + skipback: ['M12 4.5a8 8 0 1 1-6.93 4', 'M14.5 2l-2.5 2.5 2.5 2.5'], + skipfwd: ['M12 4.5a8 8 0 1 0 6.93 4', 'M9.5 2l2.5 2.5-2.5 2.5'], chat: ['M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'], folder: ['M3.5 6.6a1 1 0 0 1 1-1h4.2l2 2.4h7.8a1 1 0 0 1 1 1v9.4a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1z'], 'bell-off': ['M18 9a6 6 0 0 0-12 0c0 6-2.5 7.5-2.5 7.5h17S18 15 18 9', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index c650f75..bdc4dd5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -390,6 +390,13 @@ export default { 'cast.copied': 'URL kopiert', 'cast.scanning': 'Suche nach Geräten…', 'cast.no_devices': 'Keine Geräte gefunden', + 'cast.casting_to': 'Wiedergabe auf', + 'cast.seek': 'Position im Film', + 'cast.waiting': 'Warten auf den Fernseher…', + 'cast.back30': '30 Sekunden zurück', + 'cast.fwd30': '30 Sekunden vor', + 'cast.play': 'Abspielen', + 'cast.pause': 'Pause', // Settings 'settings.title': 'Einstellungen', @@ -956,8 +963,6 @@ export default { 'node.root_no_signing_key': 'Kein Signaturschlüssel verfügbar — koppeln Sie dieses Gerät zuerst mit dem Node', 'node.root_no_route': 'Keine Verbindung zum Node — verbinden Sie sich damit oder verwenden Sie die App auf dem Rechner, der ihn hostet', 'node.root_remove_last': 'Eine Gruppe braucht mindestens ein Verzeichnis', - 'node.root_path_hint': 'Der Pfad, wie der Node ihn sieht, auf dem Rechner, der diese Gruppe hostet.', - 'node.root_path_placeholder': '/home/user/Media', 'node.root_path': 'Pfad', 'node.directory': 'Verzeichnis', 'node.root_added': 'Verzeichnis hinzugefügt.', @@ -1079,6 +1084,7 @@ export default { 'settings_app.tmdb_token_prompt': 'Registrieren Sie sich bei TMDB, um einen eigenen API-Schlüssel zu erzeugen.', 'settings_app.tmdb_token_link': 'Schlüssel holen', 'settings_node.roots_offline_hint': 'Nicht mit dem Node verbunden — Änderungen laufen über den lokalen Node und greifen beim nächsten Neuladen.', + 'settings_node.roots_local_only_hint': 'Ein Verzeichnis hinzufügen sowie Lesen/Schreiben oder Wechselmedium umschalten geht nur auf dem Rechner, auf dem der Node läuft — in der Desktop-Anwendung oder mit meshbay-node root.', 'settings_node.directories_title': 'App-Verzeichnisse', 'settings_node.directories_hint': 'Freigegebene Ordner und welchen davon die Videos-, Musik- und Fotos-Apps als eigene(n) Einstiegspunkt(e) nutzen.', @@ -1251,7 +1257,6 @@ export default { 'settings.browser_access_off_in_browser': 'Der Browserzugang ist für dieses Konto ausgeschaltet. Er wird in der MeshBay-Desktopanwendung eingeschaltet, die diesen Browser auch für sich selbst freigeben kann.', 'group.browser_access_off': 'Der Browserzugang ist für dieses Konto ausgeschaltet. Schalten Sie ihn in der MeshBay-Desktopanwendung (Profil) ein oder geben Sie diesen Browser dort frei.', // Worded by the desktop main process for its own dialogs (main.js). - 'native.folder_confirm': 'Den Ordner {path} mit den Mitgliedern einer auf diesem Computer gehosteten Gruppe teilen? Er wurde nicht in der Ordnerauswahl gewählt.', 'native.node_account_confirm': 'Der Node auf diesem Computer ist für {current} eingerichtet. Stattdessen für {next} einrichten? Er stellt dann die Gruppen, die er für {current} hostet, nicht mehr bereit.', 'native.browser_access_confirm': 'Die Anmeldung über einen Browser erlauben? Die Anwendung legt Ihre Identität auf jedem genutzten Node ab, so versiegelt, dass nur Ihre Passphrase zusammen mit Ihrem Hub-Konto sie öffnen kann.', 'native.declined': 'Abgebrochen — nichts wurde geändert.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index b4e4adf..658f388 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -388,6 +388,13 @@ export default { 'cast.copied': 'URL copied', 'cast.scanning': 'Scanning for devices…', 'cast.no_devices': 'No devices found', + 'cast.casting_to': 'Casting to', + 'cast.seek': 'Position in the film', + 'cast.waiting': 'Waiting for the television…', + 'cast.back30': 'Back 30 seconds', + 'cast.fwd30': 'Forward 30 seconds', + 'cast.play': 'Play', + 'cast.pause': 'Pause', // Settings 'settings.title': 'Settings', @@ -778,6 +785,7 @@ export default { 'settings_app.tmdb_token_prompt': 'Sign up on TMDB to generate your own API key.', 'settings_app.tmdb_token_link': 'Get a key', 'settings_node.roots_offline_hint': 'Not connected to the node — changes go through the local node instead, and take effect on its next reload.', + 'settings_node.roots_local_only_hint': 'Adding a directory, and switching read-write or removable, are done on the machine running the node — in the desktop application, or with meshbay-node root.', 'settings_node.directories_title': 'App directories', 'settings_node.directories_hint': 'Which shared folders the Videos, Music and Photos apps use as their entry point(s).', @@ -1030,8 +1038,6 @@ export default { 'node.root_no_signing_key': 'No signing key available — pair this device with the node first', 'node.root_no_route': 'No route to the node — connect to it, or use the app on the machine hosting it', 'node.root_remove_last': 'A group needs at least one directory', - 'node.root_path_hint': 'The path as the node sees it, on the machine hosting this group.', - 'node.root_path_placeholder': '/home/user/Media', 'node.root_path': 'Path', 'node.root_added': 'Directory added.', 'node.root_removed': 'Directory removed. Restart recommended to update the index.', @@ -1232,7 +1238,6 @@ export default { 'settings.browser_access_off_in_browser': 'Browser access is off for this account. It is turned on in the MeshBay desktop application, which can also approve this browser for itself.', 'group.browser_access_off': 'Browser access is off for this account. Turn it on in the MeshBay desktop application (Profile), or approve this browser from it.', // Worded by the desktop main process for its own dialogs (main.js). - 'native.folder_confirm': 'Share the folder {path} with the members of a group hosted on this computer? It was not chosen in the folder picker.', 'native.node_account_confirm': 'The node on this computer is set up for {current}. Set it up for {next} instead? It will stop serving the groups it hosts for {current}.', 'native.browser_access_confirm': 'Allow signing in from a browser? The application will leave your identity on every node you use, sealed so that only your passphrase together with your hub account can open it.', 'native.declined': 'Cancelled — nothing was changed.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 7422b13..9e73017 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -388,6 +388,13 @@ export default { 'cast.copied': 'URL copiada', 'cast.scanning': 'Buscando dispositivos…', 'cast.no_devices': 'No se encontraron dispositivos', + 'cast.casting_to': 'Transmitiendo a', + 'cast.seek': 'Posición en la película', + 'cast.waiting': 'Esperando al televisor…', + 'cast.back30': 'Retroceder 30 segundos', + 'cast.fwd30': 'Avanzar 30 segundos', + 'cast.play': 'Reproducir', + 'cast.pause': 'Pausa', // Settings 'settings.title': 'Ajustes', @@ -950,8 +957,6 @@ export default { 'node.root_no_signing_key': 'No hay clave de firma disponible: empareja primero este dispositivo con el nodo', 'node.root_no_route': 'No hay ruta al nodo: conéctate a él o usa la aplicación en la máquina que lo aloja', 'node.root_remove_last': 'Un grupo necesita al menos un directorio', - 'node.root_path_hint': 'La ruta tal como la ve el nodo, en la máquina que aloja este grupo.', - 'node.root_path_placeholder': '/home/user/Media', 'node.root_path': 'Ruta', 'node.directory': 'Directorio', 'node.root_added': 'Directorio añadido.', @@ -1073,6 +1078,7 @@ export default { 'settings_app.tmdb_token_prompt': 'Regístrate en TMDB para generar tu propia clave de API.', 'settings_app.tmdb_token_link': 'Obtener una clave', 'settings_node.roots_offline_hint': 'Sin conexión con el nodo: los cambios pasan por el nodo local y se aplican en su próxima recarga.', + 'settings_node.roots_local_only_hint': 'Añadir una carpeta y cambiar lectura-escritura o extraíble se hace en la máquina del nodo: en la aplicación de escritorio o con meshbay-node root.', 'settings_node.directories_title': 'Directorios de apps', 'settings_node.directories_hint': 'Carpetas compartidas, y cuál de ellas usan las apps de Vídeos, Música y Fotos como su(s) propio(s) punto(s) de entrada.', @@ -1245,7 +1251,6 @@ export default { 'settings.browser_access_off_in_browser': 'El acceso desde el navegador está desactivado para esta cuenta. Se activa en la aplicación de escritorio de MeshBay, que también puede aprobar este navegador por sí mismo.', 'group.browser_access_off': 'El acceso desde el navegador está desactivado para esta cuenta. Actívelo en la aplicación de escritorio de MeshBay (Perfil) o apruebe este navegador desde ella.', // Worded by the desktop main process for its own dialogs (main.js). - 'native.folder_confirm': '¿Compartir la carpeta {path} con los miembros de un grupo alojado en este ordenador? No se eligió en el selector de carpetas.', 'native.node_account_confirm': 'El node de este ordenador está configurado para {current}. ¿Configurarlo para {next} en su lugar? Dejará de servir los grupos que aloja para {current}.', 'native.browser_access_confirm': '¿Permitir el acceso desde un navegador? La aplicación dejará su identidad en cada node que use, sellada de modo que solo su frase de contraseña, junto con su cuenta del hub, pueda abrirla.', 'native.declined': 'Cancelado: no se ha cambiado nada.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index d7d9228..5d18d41 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -389,6 +389,13 @@ export default { 'cast.copied': 'URL copiée', 'cast.scanning': "Recherche d'appareils…", 'cast.no_devices': 'Aucun appareil trouvé', + 'cast.casting_to': 'Diffusion sur', + 'cast.seek': 'Position dans le film', + 'cast.waiting': 'En attente de la télévision…', + 'cast.back30': 'Reculer de 30 secondes', + 'cast.fwd30': 'Avancer de 30 secondes', + 'cast.play': 'Lecture', + 'cast.pause': 'Pause', // Settings 'settings.title': 'Paramètres', @@ -959,8 +966,6 @@ export default { 'node.root_no_signing_key': 'Aucune clé de signature disponible — appairez d\'abord cet appareil avec le nœud', 'node.root_no_route': 'Aucune route vers le nœud — connectez-vous à lui, ou utilisez l\'application sur la machine qui l\'héberge', 'node.root_remove_last': 'Un groupe a besoin d\'au moins un répertoire', - 'node.root_path_hint': 'Le chemin tel que le nœud le voit, sur la machine qui héberge ce groupe.', - 'node.root_path_placeholder': '/home/user/Media', 'node.root_path': 'Chemin', 'node.root_added': 'Répertoire ajouté.', 'node.root_remove_confirm': 'Retirer « {name} » de ce groupe ?', @@ -1091,6 +1096,7 @@ export default { 'settings_app.tmdb_token_prompt': 'Créez un compte TMDB pour générer votre propre clé d\'API.', 'settings_app.tmdb_token_link': 'Obtenir une clé', 'settings_node.roots_offline_hint': 'Non connecté au nœud — les changements passent par le nœud local et prennent effet à son prochain rechargement.', + 'settings_node.roots_local_only_hint': 'L\'ajout d\'un dossier et le passage en lecture-écriture ou amovible se font sur la machine du nœud — dans l\'application de bureau, ou avec meshbay-node root.', 'settings_node.directories_title': 'Répertoires des applications', 'settings_node.directories_hint': 'Quel(s) dossier(s) partagés les applications Vidéos, Musique et Photos utilisent comme leur(s) propre(s) point(s) d\'entrée.', @@ -1260,7 +1266,6 @@ export default { 'settings.browser_access_off_in_browser': 'L\'accès depuis un navigateur est désactivé pour ce compte. Il s\'active dans l\'application de bureau MeshBay, qui peut aussi approuver ce navigateur pour lui-même.', 'group.browser_access_off': 'L\'accès depuis un navigateur est désactivé pour ce compte. Activez-le dans l\'application de bureau MeshBay (Profil), ou approuvez ce navigateur depuis celle-ci.', // Worded by the desktop main process for its own dialogs (main.js). - 'native.folder_confirm': 'Partager le dossier {path} avec les membres d\'un groupe hébergé sur cet ordinateur ? Il n\'a pas été choisi dans le sélecteur de dossier.', 'native.node_account_confirm': 'Le node de cet ordinateur est configuré pour {current}. Le configurer pour {next} à la place ? Il cessera de servir les groupes qu\'il héberge pour {current}.', 'native.browser_access_confirm': 'Autoriser la connexion depuis un navigateur ? L\'application déposera votre identité sur chaque node que vous utilisez, scellée de sorte que seule votre phrase secrète, avec votre compte sur le hub, puisse l\'ouvrir.', 'native.declined': 'Annulé — rien n\'a été modifié.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 5052378..c4d2acc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -389,6 +389,13 @@ export default { 'cast.copied': 'URL copiato', 'cast.scanning': 'Ricerca dispositivi…', 'cast.no_devices': 'Nessun dispositivo trovato', + 'cast.casting_to': 'Trasmissione su', + 'cast.seek': 'Posizione nel film', + 'cast.waiting': 'In attesa del televisore…', + 'cast.back30': 'Indietro di 30 secondi', + 'cast.fwd30': 'Avanti di 30 secondi', + 'cast.play': 'Riproduci', + 'cast.pause': 'Pausa', // Settings 'settings.title': 'Impostazioni', @@ -958,8 +965,6 @@ export default { 'node.root_no_signing_key': 'Nessuna chiave di firma disponibile: associa prima questo dispositivo al nodo', 'node.root_no_route': 'Nessuna via verso il nodo: connettiti a esso oppure usa l\'applicazione sulla macchina che lo ospita', 'node.root_remove_last': 'Un gruppo ha bisogno di almeno una directory', - 'node.root_path_hint': 'Il percorso come lo vede il nodo, sulla macchina che ospita questo gruppo.', - 'node.root_path_placeholder': '/home/user/Media', 'node.root_path': 'Percorso', 'node.directory': 'Directory', 'node.root_added': 'Directory aggiunta.', @@ -1087,6 +1092,7 @@ export default { 'settings_app.tmdb_token_prompt': 'Registrati su TMDB per generare la tua chiave API.', 'settings_app.tmdb_token_link': 'Ottieni una chiave', 'settings_node.roots_offline_hint': 'Non connesso al nodo: le modifiche passano dal nodo locale e hanno effetto al successivo ricaricamento.', + 'settings_node.roots_local_only_hint': 'L\'aggiunta di una cartella e il passaggio a lettura-scrittura o rimovibile si fanno sulla macchina del nodo: nell\'applicazione desktop o con meshbay-node root.', 'settings_node.directories_title': 'Directory delle app', 'settings_node.directories_hint': 'Cartelle condivise, e quale di esse le app Video, Musica e Foto usano come proprio/i punto/i di ingresso.', @@ -1259,7 +1265,6 @@ export default { 'settings.browser_access_off_in_browser': 'L\'accesso dal browser è disattivato per questo account. Si attiva nell\'applicazione desktop di MeshBay, che può anche approvare questo browser per sé.', 'group.browser_access_off': 'L\'accesso dal browser è disattivato per questo account. Attivalo nell\'applicazione desktop di MeshBay (Profilo) o approva questo browser da lì.', // Worded by the desktop main process for its own dialogs (main.js). - 'native.folder_confirm': 'Condividere la cartella {path} con i membri di un gruppo ospitato su questo computer? Non è stata scelta nel selettore di cartelle.', 'native.node_account_confirm': 'Il node di questo computer è configurato per {current}. Configurarlo invece per {next}? Smetterà di servire i gruppi che ospita per {current}.', 'native.browser_access_confirm': 'Consentire l\'accesso da un browser? L\'applicazione lascerà la tua identità su ogni node che usi, sigillata in modo che solo la tua passphrase, insieme al tuo account sull\'hub, possa aprirla.', 'native.declined': 'Annullato: non è stato modificato nulla.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 47a968c..fe52bfa 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -386,6 +386,13 @@ export default { 'cast.copied': 'URLをコピーしました', 'cast.scanning': 'デバイスを検索中…', 'cast.no_devices': 'デバイスが見つかりません', + 'cast.casting_to': 'キャスト先', + 'cast.seek': '再生位置', + 'cast.waiting': 'テレビを待っています…', + 'cast.back30': '30秒戻る', + 'cast.fwd30': '30秒進む', + 'cast.play': '再生', + 'cast.pause': '一時停止', // Settings 'settings.title': '設定', @@ -944,8 +951,6 @@ export default { 'node.root_no_signing_key': '署名鍵がありません — 先にこの端末をノードとペアリングしてください', 'node.root_no_route': 'ノードへの経路がありません — 接続するか、ノードを動かしているマシンでアプリを使ってください', 'node.root_remove_last': 'グループには少なくとも 1 つのディレクトリが必要です', - 'node.root_path_hint': 'このグループをホストしているマシン上で、ノードから見たパスです。', - 'node.root_path_placeholder': '/home/user/Media', 'node.root_path': 'パス', 'node.directory': 'ディレクトリ', 'node.root_added': 'ディレクトリを追加しました。', @@ -1071,6 +1076,7 @@ export default { 'settings_app.tmdb_token_prompt': 'TMDB に登録して、自分の API キーを発行してください。', 'settings_app.tmdb_token_link': 'キーを取得', 'settings_node.roots_offline_hint': 'ノードに接続していません — 変更はローカルノード経由で行われ、次回の再読み込みで反映されます。', + 'settings_node.roots_local_only_hint': 'ディレクトリの追加と、読み書き・リムーバブルの切り替えは、ノードが動いているマシンで行います — デスクトップアプリ、または meshbay-node root で。', 'settings_node.directories_title': 'アプリのディレクトリ', 'settings_node.directories_hint': '共有フォルダと、動画・音楽・写真の各アプリがそれぞれの起点として使用するフォルダです。', @@ -1243,7 +1249,6 @@ export default { 'settings.browser_access_off_in_browser': 'このアカウントではブラウザーからのアクセスがオフです。MeshBay デスクトップアプリでオンにできます。アプリからこのブラウザーだけを承認することもできます。', 'group.browser_access_off': 'このアカウントではブラウザーからのアクセスがオフです。MeshBay デスクトップアプリ(プロフィール)でオンにするか、アプリからこのブラウザーを承認してください。', // Worded by the desktop main process for its own dialogs (main.js). - 'native.folder_confirm': 'このコンピューターでホストしているグループのメンバーとフォルダー {path} を共有しますか?このフォルダーはフォルダー選択画面で選ばれたものではありません。', 'native.node_account_confirm': 'このコンピューターの node は {current} 用に設定されています。代わりに {next} 用に設定しますか?{current} のためにホストしているグループは提供されなくなります。', 'native.browser_access_confirm': 'ブラウザーからのサインインを許可しますか?アプリは、利用中のすべての node にあなたの ID を残します。これは、パスフレーズと hub のアカウントの両方がそろった場合にのみ開けるよう封印されます。', 'native.declined': 'キャンセルしました。何も変更されていません。', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index eaad700..90b71f0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -390,6 +390,13 @@ export default { 'cast.copied': 'URL gekopieerd', 'cast.scanning': 'Apparaten zoeken…', 'cast.no_devices': 'Geen apparaten gevonden', + 'cast.casting_to': 'Casten naar', + 'cast.seek': 'Positie in de film', + 'cast.waiting': 'Wachten op de televisie…', + 'cast.back30': '30 seconden terug', + 'cast.fwd30': '30 seconden vooruit', + 'cast.play': 'Afspelen', + 'cast.pause': 'Pauzeren', // Settings 'settings.title': 'Instellingen', @@ -960,8 +967,6 @@ export default { 'node.root_no_signing_key': 'Geen ondertekeningssleutel beschikbaar — koppel dit apparaat eerst aan de node', 'node.root_no_route': 'Geen route naar de node — maak verbinding, of gebruik de app op de machine die hem host', 'node.root_remove_last': 'Een groep heeft minstens één map nodig', - 'node.root_path_hint': 'Het pad zoals de node het ziet, op de machine die deze groep host.', - 'node.root_path_placeholder': '/home/user/Media', 'node.root_path': 'Pad', 'node.directory': 'Map', 'node.root_added': 'Map toegevoegd.', @@ -1089,6 +1094,7 @@ export default { 'settings_app.tmdb_token_prompt': 'Meld u aan bij TMDB om uw eigen API-sleutel te maken.', 'settings_app.tmdb_token_link': 'Sleutel ophalen', 'settings_node.roots_offline_hint': 'Niet verbonden met de node — wijzigingen gaan via de lokale node en worden bij de volgende herlaadbeurt actief.', + 'settings_node.roots_local_only_hint': 'Een map toevoegen en lezen-schrijven of verwisselbaar omzetten gebeurt op de machine van de node — in de desktopapplicatie of met meshbay-node root.', 'settings_node.directories_title': 'App-mappen', 'settings_node.directories_hint': 'Gedeelde mappen, en welke daarvan de Video\'s-, Muziek- en Foto\'s-apps als eigen startpunt(en) gebruiken.', @@ -1261,7 +1267,6 @@ export default { 'settings.browser_access_off_in_browser': 'Browsertoegang staat uit voor dit account. Die wordt aangezet in de MeshBay-desktoptoepassing, die deze browser ook voor zichzelf kan goedkeuren.', 'group.browser_access_off': 'Browsertoegang staat uit voor dit account. Zet die aan in de MeshBay-desktoptoepassing (Profiel), of keur deze browser daar goed.', // Worded by the desktop main process for its own dialogs (main.js). - 'native.folder_confirm': 'De map {path} delen met de leden van een groep die op deze computer wordt gehost? Hij is niet gekozen in de mapkiezer.', 'native.node_account_confirm': 'De node op deze computer is ingesteld voor {current}. In plaats daarvan instellen voor {next}? Hij stopt dan met het aanbieden van de groepen die hij voor {current} host.', 'native.browser_access_confirm': 'Aanmelden vanuit een browser toestaan? De toepassing laat je identiteit achter op elke node die je gebruikt, zo verzegeld dat alleen je wachtzin samen met je hub-account haar kan openen.', 'native.declined': 'Geannuleerd — er is niets gewijzigd.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 2635fb0..8c31cf3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -401,6 +401,13 @@ export default { 'cast.copied': 'URL skopiowany', 'cast.scanning': 'Wyszukiwanie urządzeń…', 'cast.no_devices': 'Nie znaleziono urządzeń', + 'cast.casting_to': 'Przesyłanie do', + 'cast.seek': 'Pozycja w filmie', + 'cast.waiting': 'Czekam na telewizor…', + 'cast.back30': '30 sekund wstecz', + 'cast.fwd30': '30 sekund do przodu', + 'cast.play': 'Odtwórz', + 'cast.pause': 'Wstrzymaj', // Settings 'settings.title': 'Ustawienia', @@ -982,8 +989,6 @@ export default { 'node.root_no_signing_key': 'Brak klucza podpisu — najpierw sparuj to urządzenie z węzłem', 'node.root_no_route': 'Brak połączenia z węzłem — połącz się z nim albo użyj aplikacji na komputerze, który go hostuje', 'node.root_remove_last': 'Grupa wymaga co najmniej jednego katalogu', - 'node.root_path_hint': 'Ścieżka widziana przez węzeł, na komputerze hostującym tę grupę.', - 'node.root_path_placeholder': '/home/user/Media', 'node.root_path': 'Ścieżka', 'node.directory': 'Katalog', 'node.root_added': 'Katalog dodany.', @@ -1115,6 +1120,7 @@ export default { 'settings_app.tmdb_token_prompt': 'Zarejestruj się w TMDB, aby wygenerować własny klucz API.', 'settings_app.tmdb_token_link': 'Pobierz klucz', 'settings_node.roots_offline_hint': 'Brak połączenia z węzłem — zmiany przechodzą przez węzeł lokalny i zaczną działać po jego następnym przeładowaniu.', + 'settings_node.roots_local_only_hint': 'Dodanie katalogu oraz przełączenie odczytu-zapisu lub nośnika wymiennego odbywa się na komputerze węzła — w aplikacji desktopowej lub poleceniem meshbay-node root.', 'settings_node.directories_title': 'Katalogi aplikacji', 'settings_node.directories_hint': 'Katalogi udostępnione oraz to, który z nich aplikacje Wideo, Muzyka i Zdjęcia traktują jako własny punkt (punkty) wejścia.', @@ -1287,7 +1293,6 @@ export default { 'settings.browser_access_off_in_browser': 'Dostęp z przeglądarki jest wyłączony dla tego konta. Włącza się go w aplikacji desktopowej MeshBay, która może też zatwierdzić tę przeglądarkę dla niej samej.', 'group.browser_access_off': 'Dostęp z przeglądarki jest wyłączony dla tego konta. Włącz go w aplikacji desktopowej MeshBay (Profil) albo zatwierdź tę przeglądarkę w tej aplikacji.', // Worded by the desktop main process for its own dialogs (main.js). - 'native.folder_confirm': 'Udostępnić folder {path} członkom grupy hostowanej na tym komputerze? Nie został wybrany w oknie wyboru folderu.', 'native.node_account_confirm': 'Node na tym komputerze jest skonfigurowany dla {current}. Skonfigurować go zamiast tego dla {next}? Przestanie obsługiwać grupy, które hostuje dla {current}.', 'native.browser_access_confirm': 'Zezwolić na logowanie z przeglądarki? Aplikacja pozostawi Twoją tożsamość na każdym używanym node, zapieczętowaną tak, by otworzyć ją mogło tylko Twoje hasło wraz z kontem na hubie.', 'native.declined': 'Anulowano — nic nie zostało zmienione.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 207c1b7..2f951ee 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -390,6 +390,13 @@ export default { 'cast.copied': 'URL copiada', 'cast.scanning': 'Procurando dispositivos…', 'cast.no_devices': 'Nenhum dispositivo encontrado', + 'cast.casting_to': 'Transmitindo para', + 'cast.seek': 'Posição no filme', + 'cast.waiting': 'Aguardando a TV…', + 'cast.back30': 'Voltar 30 segundos', + 'cast.fwd30': 'Avançar 30 segundos', + 'cast.play': 'Reproduzir', + 'cast.pause': 'Pausar', // Settings 'settings.title': 'Configurações', @@ -951,8 +958,6 @@ export default { 'node.root_no_signing_key': 'Nenhuma chave de assinatura disponível — pareie este dispositivo com o nó primeiro', 'node.root_no_route': 'Sem rota até o nó — conecte-se a ele ou use o aplicativo na máquina que o hospeda', 'node.root_remove_last': 'Um grupo precisa de pelo menos um diretório', - 'node.root_path_hint': 'O caminho como o nó o vê, na máquina que hospeda este grupo.', - 'node.root_path_placeholder': '/home/user/Media', 'node.root_path': 'Caminho', 'node.directory': 'Diretório', 'node.root_added': 'Diretório adicionado.', @@ -1074,6 +1079,7 @@ export default { 'settings_app.tmdb_token_prompt': 'Cadastre-se no TMDB para gerar sua própria chave de API.', 'settings_app.tmdb_token_link': 'Obter uma chave', 'settings_node.roots_offline_hint': 'Sem conexão com o nó — as alterações passam pelo nó local e entram em vigor no próximo recarregamento.', + 'settings_node.roots_local_only_hint': 'Adicionar uma pasta e alternar leitura-gravação ou removível são feitos na máquina do nó — no aplicativo de desktop ou com meshbay-node root.', 'settings_node.directories_title': 'Diretórios de apps', 'settings_node.directories_hint': 'Pastas compartilhadas, e qual delas os apps Vídeos, Música e Fotos tratam como seu(s) próprio(s) ponto(s) de entrada.', @@ -1246,7 +1252,6 @@ export default { 'settings.browser_access_off_in_browser': 'O acesso pelo navegador está desativado para esta conta. Ele é ativado no aplicativo de desktop do MeshBay, que também pode aprovar este navegador para si.', 'group.browser_access_off': 'O acesso pelo navegador está desativado para esta conta. Ative-o no aplicativo de desktop do MeshBay (Perfil) ou aprove este navegador por ele.', // Worded by the desktop main process for its own dialogs (main.js). - 'native.folder_confirm': 'Compartilhar a pasta {path} com os membros de um grupo hospedado neste computador? Ela não foi escolhida no seletor de pastas.', 'native.node_account_confirm': 'O node deste computador está configurado para {current}. Configurá-lo para {next} em vez disso? Ele deixará de servir os grupos que hospeda para {current}.', 'native.browser_access_confirm': 'Permitir o acesso por um navegador? O aplicativo deixará sua identidade em cada node que você usa, selada de forma que apenas sua frase secreta, junto com sua conta no hub, possa abri-la.', 'native.declined': 'Cancelado — nada foi alterado.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 3ea8699..ea02545 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -383,6 +383,13 @@ export default { 'cast.copied': '链接已复制', 'cast.scanning': '正在搜索设备…', 'cast.no_devices': '未找到设备', + 'cast.casting_to': '投放到', + 'cast.seek': '播放位置', + 'cast.waiting': '正在等待电视…', + 'cast.back30': '后退 30 秒', + 'cast.fwd30': '前进 30 秒', + 'cast.play': '播放', + 'cast.pause': '暂停', // Settings 'settings.title': '设置', @@ -932,8 +939,6 @@ export default { 'node.root_no_signing_key': '没有可用的签名密钥 — 请先将本设备与节点配对', 'node.root_no_route': '无法连接到节点 — 请先连接,或在运行该节点的机器上使用应用', 'node.root_remove_last': '每个群组至少需要一个目录', - 'node.root_path_hint': '托管该群组的机器上,节点所看到的路径。', - 'node.root_path_placeholder': '/home/user/Media', 'node.root_path': '路径', 'node.directory': '目录', 'node.root_added': '目录已添加。', @@ -1059,6 +1064,7 @@ export default { 'settings_app.tmdb_token_prompt': '在 TMDB 注册以生成你自己的 API 密钥。', 'settings_app.tmdb_token_link': '获取密钥', 'settings_node.roots_offline_hint': '未连接到节点 — 变更将通过本地节点进行,并在其下次重新加载时生效。', + 'settings_node.roots_local_only_hint': '添加目录以及切换读写或可移除,只能在运行节点的机器上进行 — 在桌面应用中,或使用 meshbay-node root。', 'settings_node.directories_title': '应用目录', 'settings_node.directories_hint': '共享文件夹,以及“视频”“音乐”和“照片”应用各自使用哪个(些)作为入口。', @@ -1232,7 +1238,6 @@ export default { 'settings.browser_access_off_in_browser': '此账户已关闭浏览器访问。可在 MeshBay 桌面应用中开启,该应用也可以单独批准此浏览器。', 'group.browser_access_off': '此账户已关闭浏览器访问。请在 MeshBay 桌面应用(个人资料)中开启,或从该应用批准此浏览器。', // Worded by the desktop main process for its own dialogs (main.js). - 'native.folder_confirm': '与这台电脑上托管的群组成员共享文件夹 {path}?该文件夹不是在文件夹选择器中选择的。', 'native.node_account_confirm': '这台电脑上的 node 已为 {current} 设置。改为为 {next} 设置吗?它将不再为 {current} 提供其托管的群组。', 'native.browser_access_confirm': '允许从浏览器登录吗?应用会在您使用的每个 node 上留下您的身份,并加以封存,只有您的密码短语配合您的 hub 账户才能打开。', 'native.declined': '已取消,未做任何更改。', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index 19f12a6..c131495 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -430,6 +430,8 @@ export async function waitForRootsIndexed(groupId, onProgress, * Absent in a browser, where the relay cannot run: there is no main process * to bind a server socket in, and a page cannot open one. */ +const CAST_POLL_MS = 250; + export const cast = { available: Boolean(bridge && bridge.cast), async start(opts) { @@ -456,9 +458,32 @@ export const cast = { if (!bridge || !bridge.cast) return null; return bridge.cast.status(); }, - async discover() { - if (!bridge || !bridge.cast) return []; - return bridge.cast.discover(); + // Lists receivers as they answer rather than at the end of the scan: most + // answer within two seconds, and the scan runs on for the ones coming back + // from a reset. `onUpdate(devices, scanning)` is called on each poll until + // the scan ends; the returned function stops it being called at all. + discover(onUpdate) { + if (!bridge || !bridge.cast) { + onUpdate([], false); + return () => {}; + } + let stopped = false; + let timer = null; + const poll = async () => { + const snap = await bridge.cast.devices().catch(() => null); + if (stopped) return; + if (!snap) { + onUpdate([], false); + return; + } + onUpdate(snap.devices, snap.scanning); + if (snap.scanning) timer = setTimeout(poll, CAST_POLL_MS); + }; + bridge.cast.scan().then(poll, () => { if (!stopped) onUpdate([], false); }); + return () => { + stopped = true; + clearTimeout(timer); + }; }, async chromecastConnect(opts) { if (!bridge || !bridge.cast) return null; @@ -472,6 +497,19 @@ export const cast = { if (!bridge || !bridge.cast) return false; return bridge.cast.chromecastDisconnect(); }, + // Remote control of the receiver. Absent from an older bridge, so callers + // check `canControl` rather than catching a TypeError. + get canControl() { + return Boolean(bridge && bridge.cast && bridge.cast.chromecastPause && bridge.cast.chromecastPlay); + }, + async chromecastPause() { + if (!this.canControl) return null; + return bridge.cast.chromecastPause(); + }, + async chromecastPlay() { + if (!this.canControl) return null; + return bridge.cast.chromecastPlay(); + }, }; /** diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index fc91596..e1e152e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2084,6 +2084,207 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } outline: none; } +/* The player as a remote while a television plays the film. Opaque, over + the local picture, which keeps running underneath only to pace the + download; the container is given room for it on a portrait phone, where + the picture alone would be a strip. */ +.video-container-remote { min-height: min(72vh, 560px); } + +.video-remote { + --remote-accent: #23b1f0; + position: absolute; + inset: 0; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 32px; + padding: 24px 20px; + border-radius: 12px; + background: + radial-gradient(120% 70% at 50% 0%, rgba(35, 177, 240, 0.16) 0%, rgba(35, 177, 240, 0) 60%), + #0a1224; + color: #e2e8f0; + overflow: hidden; +} + +.video-remote-head { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + text-align: center; + max-width: 100%; +} + +.video-remote-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + margin-bottom: 8px; + border-radius: 50%; + background: rgba(35, 177, 240, 0.14); + box-shadow: 0 0 0 1px rgba(35, 177, 240, 0.35), 0 0 32px rgba(35, 177, 240, 0.25); + color: var(--remote-accent); + font-size: 30px; +} + +.video-remote-label { + font-size: 0.75rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #94a3b8; +} + +.video-remote-device { + font-size: 1.35rem; + font-weight: 600; + color: #f8fafc; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.video-remote-state { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 1.2em; + font-size: 0.85rem; + color: #94a3b8; +} + +.video-remote-track { width: min(100%, 560px); } + +.video-remote-times { + display: flex; + justify-content: space-between; + margin-top: 8px; + font-size: 0.85rem; + font-variant-numeric: tabular-nums; + color: #94a3b8; +} + +/* The track is drawn by hand so the part already played is filled: `--pct` + is set inline from the position. */ +.video-remote-seek { + --pct: 0%; + -webkit-appearance: none; + appearance: none; + display: block; + width: 100%; + height: 28px; + margin: 0; + background: transparent; + cursor: pointer; +} +.video-remote-seek:disabled { cursor: default; opacity: 0.5; } +.video-remote-seek::-webkit-slider-runnable-track { + height: 6px; + border-radius: 3px; + background: linear-gradient(to right, var(--remote-accent) var(--pct), rgba(148, 163, 184, 0.25) var(--pct)); +} +.video-remote-seek::-moz-range-track { + height: 6px; + border-radius: 3px; + background: linear-gradient(to right, var(--remote-accent) var(--pct), rgba(148, 163, 184, 0.25) var(--pct)); +} +.video-remote-seek::-webkit-slider-thumb { + -webkit-appearance: none; + width: 18px; + height: 18px; + margin-top: -6px; + border: 0; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 0 0 4px rgba(35, 177, 240, 0.35), 0 2px 6px rgba(0, 0, 0, 0.4); +} +.video-remote-seek::-moz-range-thumb { + width: 18px; + height: 18px; + border: 0; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 0 0 4px rgba(35, 177, 240, 0.35), 0 2px 6px rgba(0, 0, 0, 0.4); +} + +.video-remote-buttons { + display: flex; + align-items: center; + gap: 36px; +} + +.video-remote-skip, +.video-remote-main { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 50%; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform 0.1s ease, background-color 0.15s ease; +} +.video-remote-skip:active:not(:disabled), +.video-remote-main:active:not(:disabled) { transform: scale(0.92); } +.video-remote-skip:disabled, +.video-remote-main:disabled { opacity: 0.35; cursor: default; } + +.video-remote-skip { + width: 56px; + height: 56px; + background: transparent; + color: #e2e8f0; + font-size: 44px; +} +.video-remote-skip:hover:not(:disabled) { background: rgba(148, 163, 184, 0.12); } +.video-remote-skip .icon { stroke-width: 1.4; } +.video-remote-skip-n { + position: absolute; + left: 0; + right: 0; + top: 50%; + transform: translateY(-38%); + font-size: 12px; + font-weight: 700; + text-align: center; + pointer-events: none; +} + +.video-remote-main { + width: 76px; + height: 76px; + background: var(--remote-accent); + color: #04121f; + font-size: 34px; + box-shadow: 0 8px 24px rgba(35, 177, 240, 0.35); +} +.video-remote-main:hover:not(:disabled) { background: #4cc3f5; } +.video-remote-main .icon { stroke-width: 2.6; } +/* The triangle is a closed path: filled, it reads as play at a glance. */ +.video-remote-main.is-paused .icon path { fill: currentColor; } +.video-remote-main.is-paused .icon { transform: translateX(2px); } + +.video-remote-stop { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 999px; + background: transparent; + color: #cbd5e1; + font: inherit; + font-size: 0.9rem; + cursor: pointer; +} +.video-remote-stop:hover { border-color: #fca5a5; color: #fecaca; } + /* Where the film was picked up again. Sits over the picture rather than pushing it down, and clears itself once the viewer is watching. */ .video-container { position: relative; } @@ -3346,13 +3547,6 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.sdt-add-row { display: flex; gap: 6px; align-items: center; margin-top: 8px; } -.sdt-add-input { - flex: 1 1 auto; min-width: 0; padding: 5px 8px; - border: 1px solid var(--border); border-radius: 4px; - background: var(--bg-surface); color: var(--text); - font-family: inherit; font-size: 0.9em; -} .sdt-col-toggle { width: 90px; text-align: center; } .sdt-col-toggle th { text-align: center; } .sdt-col-toggle .toggle-switch { justify-content: center; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js index 1a5a4c0..63cb644 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js @@ -241,37 +241,6 @@ extendTransport(class { // ── Node management (D5) ─────────────────────────────────────────────── - async fetchNodeStatus() { - const msg = await this._sendAndWait({ type: 'node_status', v: '0.1' }); - if (msg.type === 'error') throw new Error(msg.detail); - return msg; - } - - async updateNodeSettings(settings) { - const msg = await this._sendAndWait({ - type: 'node_settings_set', v: '0.1', settings, - }); - if (msg.type === 'error') throw new Error(msg.detail); - return msg; - } - - async addRoot(groupId, path, { name, kind, writable, removable } = {}, signFn) { - const msg = await this._sendAndWait({ - type: 'root_add', v: '1.1', - group_id: groupId, path, - name: name || '', kind: kind || 'generic', - writable: !!writable, removable: !!removable, - }); - if (msg.type === 'error') throw new Error(msg.detail); - if (msg.type === 'admin_challenge') { - // Everything the node will act on is in the subject, `writable` included. - const subject = window.MeshBayCrypto.rootAddSubject( - path, name || '', kind || 'generic', !!writable, !!removable); - return this._authorizeAdminOp(msg, 'root_add', subject, signFn); - } - return msg; - } - async removeRoot(groupId, rootName, signFn) { const msg = await this._sendAndWait({ type: 'root_remove', v: '0.1', @@ -284,24 +253,6 @@ extendTransport(class { return msg; } - async updateRoot(groupId, rootName, { writable, removable } = {}, signFn) { - const updates = []; - if (writable !== undefined) updates.push(`rw=${writable ? 'on' : 'off'}`); - if (removable !== undefined) updates.push(`rem=${removable ? 'on' : 'off'}`); - const subject = updates.length ? `${rootName}:${updates.join(',')}` : rootName; - const msg = await this._sendAndWait({ - type: 'root_update', v: '1.1', - group_id: groupId, root_name: rootName, - ...(writable !== undefined && { writable }), - ...(removable !== undefined && { removable }), - }); - if (msg.type === 'error') throw new Error(msg.detail); - if (msg.type === 'admin_challenge') { - return this._authorizeAdminOp(msg, 'root_update', subject, signFn); - } - return msg; - } - async ejectRoot(groupId, rootName, signFn) { const msg = await this._sendAndWait({ type: 'root_eject', v: '1.1', @@ -326,81 +277,6 @@ extendTransport(class { return msg; } - async unpinMember(userId, signFn) { - const msg = await this._sendAndWait({ - type: 'member_unpin', v: '0.1', user_id: userId, - }); - if (msg.type === 'error') throw new Error(msg.detail); - if (msg.type === 'admin_challenge') { - return this._authorizeAdminOp(msg, 'member_unpin', userId, signFn); - } - return msg; - } - - async rotateGek(groupId, signFn) { - const msg = await this._sendAndWait({ - type: 'gek_rotate', v: '0.1', group_id: groupId, - }); - if (msg.type === 'error') throw new Error(msg.detail); - if (msg.type === 'admin_challenge') { - return this._authorizeAdminOp(msg, 'gek_rotate', groupId, signFn); - } - return msg; - } - - async fetchRoster(groupId) { - const msg = await this._sendAndWait({ - type: 'roster_read', v: '0.1', group_id: groupId || '', - }); - if (msg.type === 'error') throw new Error(msg.detail); - return msg; - } - - async fetchDenylist() { - const msg = await this._sendAndWait({ type: 'denylist_read', v: '0.1' }); - if (msg.type === 'error') throw new Error(msg.detail); - return msg; - } - - async clearDenylist(subject) { - const msg = await this._sendAndWait({ - type: 'denylist_clear', v: '0.1', subject: subject || '', - }); - if (msg.type === 'error') throw new Error(msg.detail); - return msg; - } - - async attachGroup(name, sharedDir, uploadDir, signFn) { - const msg = await this._sendAndWait({ - type: 'group_attach', v: '0.1', - name, shared_dir: sharedDir, upload_dir: uploadDir || '', writable: true, - }); - if (msg.type === 'error') throw new Error(msg.detail); - if (msg.type === 'admin_challenge') { - // The directory being exposed is signed, not only the group's name. - const subject = window.MeshBayCrypto.groupAttachSubject(name, sharedDir, true); - return this._authorizeAdminOp(msg, 'group_attach', subject, signFn); - } - return msg; - } - - async detachGroup(name, signFn) { - const msg = await this._sendAndWait({ - type: 'group_detach', v: '0.1', name, - }); - if (msg.type === 'error') throw new Error(msg.detail); - if (msg.type === 'admin_challenge') { - return this._authorizeAdminOp(msg, 'group_detach', name, signFn); - } - return msg; - } - - async reloadConfig() { - const msg = await this._sendAndWait({ type: 'node_reload', v: '0.1' }); - if (msg.type === 'error') throw new Error(msg.detail); - return msg; - } - /** * Ask the node for a one-time pairing code admitting `userId` to this group. * diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js index e49eb4c..b734d44 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js @@ -57,7 +57,7 @@ extendTransport(class { * * Not a switch — there is nothing to turn on. The removals that matter open * an epoch by themselves; this is the operator saying "move the key anyway", - * the same instruction as `rotateGek` and signed for the same reason. + * signed like every operator instruction. */ async rotateChatEpoch(signFn) { // This connection's own group, not a parameter. Every settings pane takes diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js index cf53c08..b4d4048 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js @@ -99,7 +99,7 @@ extendTransport(class { * queried in (e.g. "fr-FR") — one for the whole node, since both are one * operator's shared credential/cache, not a per-group concern (see * setTmdbEnabled below for the per-group on/off switch). Signed like - * setAppsEnabled/updateRoot — an unsigned change would let any + * setAppsEnabled — an unsigned change would let any * member alter outbound third-party network traffic the operator never * agreed to (docs/MESHBAY_DESIGN.md §9.7, §6.5). `token: ''` explicitly clears * a previously-set custom token; omit it (undefined/null), like diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index f9e1370..279396a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -193,8 +193,7 @@ function _aborted() { // table, then on Chat's directory, where it meant the pane went on showing an // unsaved-looking draft after a save that had worked. const BROADCAST_ACK_TYPES = new Set([ - 'root_update_ack', 'root_eject_ack', 'root_plug_ack', - 'root_add_ack', 'root_remove_ack', + 'root_eject_ack', 'root_plug_ack', 'root_remove_ack', 'app_directories_ack', 'chat_directory_ack', 'chat_link_preview_ack', 'chat_epoch_ack', 'search_listed_ack', ]); @@ -220,10 +219,9 @@ const ADMIN_OP_TYPES = new Set([ 'tmdb_override', 'tmdb_rematch', 'tmdb_config', 'tmdb_enabled', 'musicbrainz_enabled', 'file_delete', 'dir_delete', 'apps_enabled', 'set_scan_settings', 'member_revoke', - 'root_add', 'root_remove', 'root_update', 'root_eject', 'root_plug', + 'root_remove', 'root_eject', 'root_plug', 'app_directories', 'chat_directory', 'chat_link_preview', 'chat_epoch', - 'search_listed', 'member_unpin', 'gek_rotate', 'group_attach', - 'group_detach', 'invite_create', 'invite_link_create', 'invite_cancel', + 'search_listed', 'invite_create', 'invite_link_create', 'invite_cancel', ]); // ── Diagnostic trace (opt-in, off by default) ─────────────────────────────── @@ -367,9 +365,10 @@ window.addEventListener('hashchange', () => { // The `v: '0.1'` on every other message in this file is the historical value // and is read by nothing; it is left alone deliberately. The range is // negotiated once, at the start, not restated per message. -const MNP_V = '5.0'; -// Not raised with 5.0 (see meshbay_common/__init__.py): the break is confined to -// four signed operations, which a peer on the other side of it refuses to sign. +const MNP_V = '6.0'; +// Not raised with 5.0 or 6.0 (see meshbay_common/__init__.py): each break is +// confined to a few signed operations — 5.0 changed four subjects, 6.0 removed +// root_add, root_update and group_attach — and everything else still works. // Set at 4.0, a flag day. A member now presents a short-lived // MNP-audience token in the handshake, not its hub session token — a node // older than 4.0 expected the session token, and one newer refuses it, so the @@ -2030,11 +2029,11 @@ class MeshBayTransport { this._onMusicbrainzEnabled(Boolean(msg.enabled)); } - // A root's flags changed, or one was ejected, plugged, added or removed. - // Broadcast by the node to every peer, so everyone's table updates without - // waiting for the next index_sync. - if (msg.type === 'root_update_ack' || msg.type === 'root_eject_ack' - || msg.type === 'root_plug_ack' || msg.type === 'root_add_ack' + // A root was ejected, plugged or removed. Broadcast by the node to every + // peer, so everyone's table updates without waiting for the next index_sync. + // A root added or a flag changed — on the node's own machine, never over + // MNP — arrives in the index_delta the node pushes. + if (msg.type === 'root_eject_ack' || msg.type === 'root_plug_ack' || msg.type === 'root_remove_ack') { if (this._onRootsChanged) this._onRootsChanged(msg); } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js index fa10d15..2c424fb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -280,6 +280,64 @@ function formatClock(seconds) { } /** + * The player as a remote, while a television plays the film. + * + * Only what it is told: the receiver's position on the film's timeline (null + * until the receiver has answered), its state, and whether a seek is on its + * way. The scrubber reports a drag as it moves and a seek once let go. + */ +function CastRemote({ device, position, duration, playerState, moving, + onDrag, onSeek, onToggle, onStop }) { + const known = position !== null; + const paused = playerState === 'PAUSED'; + const busy = !known || moving || playerState === 'BUFFERING' || playerState === 'LOADING'; + const pct = known && duration > 0 ? Math.min(100, (position / duration) * 100) : 0; + return html` + <div class="video-remote"> + <div class="video-remote-head"> + <span class="video-remote-badge"><${Icon} name="cast" /></span> + <span class="video-remote-label">${t('cast.casting_to')}</span> + <span class="video-remote-device">${device}</span> + <span class="video-remote-state"> + ${busy ? html`<span class="spinner"></span>${' '}${t('cast.waiting')}` : ''} + </span> + </div> + <div class="video-remote-track"> + <input class="video-remote-seek" type="range" min="0" step="1" + style=${`--pct:${pct}%`} + max=${Math.max(1, Math.floor(duration))} + value=${Math.floor(position || 0)} + disabled=${!known || !(duration > 0)} + aria-label=${t('cast.seek')} + onInput=${(e) => onDrag(+e.target.value)} + onChange=${(e) => onSeek(+e.target.value)} /> + <div class="video-remote-times"> + <span>${known ? formatClock(position) : '–:––'}</span> + <span>${duration > 0 ? formatClock(duration) : '–:––'}</span> + </div> + </div> + <div class="video-remote-buttons"> + <button class="video-remote-skip" disabled=${!known} + onClick=${() => onSeek(position - 30)} + title=${t('cast.back30')} aria-label=${t('cast.back30')}> + <${Icon} name="skipback" /><span class="video-remote-skip-n">30</span></button> + <button class="video-remote-main ${paused ? 'is-paused' : ''}" disabled=${!playerState} + onClick=${onToggle} + title=${paused ? t('cast.play') : t('cast.pause')} + aria-label=${paused ? t('cast.play') : t('cast.pause')}> + <${Icon} name=${paused ? 'play' : 'pause'} /></button> + <button class="video-remote-skip" disabled=${!known} + onClick=${() => onSeek(position + 30)} + title=${t('cast.fwd30')} aria-label=${t('cast.fwd30')}> + <${Icon} name="skipfwd" /><span class="video-remote-skip-n">30</span></button> + </div> + <button class="video-remote-stop" onClick=${onStop}> + <${Icon} name="close" /> ${t('cast.stop')}</button> + </div> + `; +} + +/** * Where *this account on this device* last left off in a given file. * * localStorage rather than the node: it needs no protocol, no storage anyone @@ -438,6 +496,19 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { const castRestartPendingRef = useRef(false); const castDeviceRef = useRef(null); const castRestartGenRef = useRef(0); + // The receiver as last polled: { playerState, position } with the position + // on the film's timeline, or null before it has answered. + const [remote, setRemote] = useState(null); + // Where the viewer sent the television and it has not arrived yet. Shown in + // place of the receiver's position, which until the reload still belongs to + // the stream being abandoned and would make the scrubber jump back. + const [remoteTarget, setRemoteTarget] = useState(null); + const [remoteDrag, setRemoteDrag] = useState(null); + const remoteFilmPosRef = useRef(null); + // Segments of the *new* stream that arrive while its seek is still landing + // (`reinitAt`/`resumeAt` wait on the SourceBuffer). Null when not holding. + const heldRef = useRef(null); + const holdGenRef = useRef(0); const landingPlayheadRef = useRef(false); // The current Screen Wake Lock sentinel, if the browser granted one — see // the effect below. Null on any platform/context that does not support it, @@ -764,6 +835,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { ranges: describeRanges(), }); awaitingInitRef.current = true; + heldRef.current = null; holdGenRef.current++; // A seek supersedes a resume that had been asked for and not landed: // this one empties the buffer, and taking the resume branch on its // `stream_init` would leave the film we navigated away from in place. @@ -798,6 +870,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { }); resumingRef.current = true; awaitingInitRef.current = true; + heldRef.current = null; holdGenRef.current++; seekTargetRef.current = null; outstandingRef.current = STREAM_WINDOW; console.log('[resume] request', +target.toFixed(1)); @@ -1064,7 +1137,23 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { castRestartPendingRef.current = true; } const land = resuming ? resumeAt : reinitAt; - land(msg.start || 0).catch(() => { + // What follows this message on the ordered channel is the new + // stream, its header first. The landing waits on the SourceBuffer, + // and those segments used to arrive in that gap and be dropped as + // the old film's: the player kept its header from the start of the + // film and never noticed, but a cast relay restarted here got a + // stream beginning at a moof — no ftyp, no moov — and the receiver + // gave up on it within a second (measured on a phone). So they are + // held, and taken in order once the landing is done. + const hold = ++holdGenRef.current; + heldRef.current = []; + land(msg.start || 0).then(async () => { + while (holdGenRef.current === hold && heldRef.current && heldRef.current.length) { + await consumeSegment(heldRef.current.shift()); + } + if (holdGenRef.current === hold) heldRef.current = null; + }).catch(() => { + if (holdGenRef.current === hold) heldRef.current = null; setError(t('video.err_transport')); setPhase('error'); }); @@ -1178,6 +1267,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { // for credit that cannot come. A race, which is why the same seek // worked twice and hung on the third. outstandingRef.current = Math.max(0, outstandingRef.current - 1); + // The new stream, arriving while its seek lands: kept, not dropped. + // Counted above already, so the replay must not count it again. + if (heldRef.current) { + if (!msg.file_id || msg.file_id === entry.id) heldRef.current.push(msg); + return; + } if (awaitingInitRef.current) { console.log('[seek] dropping segment (awaitingInit), outstanding:', outstandingRef.current); } @@ -1190,6 +1285,13 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { // would otherwise be decrypted against the wrong file — which fails, // loudly, in the console, for something that is simply not ours. if (msg.file_id && msg.file_id !== entry.id) return; + await consumeSegment(msg); + }; + + // Everything a segment goes through once it is known to belong to the + // stream being played: by arrival, or replayed after a landing. + const consumeSegment = async (msg) => { + if (cancelled) return; try { const plaintext = await window.MeshBayCrypto.decryptChunkBin( gekRef.current, entry.id, msg.segment_index, msg.nonce, msg.ct); @@ -1220,6 +1322,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { } else { navigator.clipboard.writeText(result.url).catch(() => {}); } + if (castRestartGenRef.current === gen) setRemoteTarget(null); }).catch((err) => { if (castRestartGenRef.current !== gen) return; console.error('[cast] restart failed:', err); @@ -1433,6 +1536,37 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { } }, [phase]); + // The scan lives as long as the picker is open: closing it, or picking a + // receiver, stops the updates. Receivers are listed as they answer, and the + // spinner sits below them so one landing never moves the row being aimed at. + // While casting, the film plays on the receiver; the local element keeps + // playing — its playhead is what paces the stream the relay forwards — but + // silently. It went on with its sound, so a phone in the room doubled the + // television's audio, screen off included. What the viewer had set comes + // back when the cast ends. + const mutedBeforeCastRef = useRef(null); + useEffect(() => { + const v = videoRef.current; + if (!v) return; + if (castActive) { + if (mutedBeforeCastRef.current === null) mutedBeforeCastRef.current = v.muted; + v.muted = true; + } else if (mutedBeforeCastRef.current !== null) { + v.muted = mutedBeforeCastRef.current; + mutedBeforeCastRef.current = null; + } + }, [castActive]); + + useEffect(() => { + if (!castPickerOpen) return undefined; + setCastDevices([]); + setCastScanning(true); + return platform.cast.discover((devices, scanning) => { + setCastDevices(devices || []); + setCastScanning(scanning); + }); + }, [castPickerOpen]); + useEffect(() => { return () => { if (blobUrlRef.current) { @@ -1589,6 +1723,75 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { }; }, []); + // ── Remote ──────────────────────────────────────────────────────────── + // + // While a television plays the film, this player is its remote. The phone + // and the receiver do not play in step — they start apart and drift — so a + // scrubber on the local playhead lied about where the film was, and a seek + // from it landed off by that much. Everything here reads the receiver and + // tells it what to do; seeking is the ordinary seek, which restarts the + // relay and reloads the receiver at the new position. + const remoteMode = castActive && castDeviceName !== null && platform.cast.canControl; + useEffect(() => { + if (!remoteMode) { + setRemote(null); setRemoteTarget(null); setRemoteDrag(null); + remoteFilmPosRef.current = null; + return undefined; + } + let stopped = false; + const poll = async () => { + try { + const s = await platform.cast.status(); + const c = s && s.chromecast; + if (stopped) return; + if (c && c.connected && c.position != null && !castRestartPendingRef.current) { + const position = streamStartRef.current + c.position; + remoteFilmPosRef.current = position; + setRemote({ playerState: c.playerState, position }); + } else { + setRemote((r) => (r && c && c.connected ? { ...r, playerState: c.playerState } : r)); + } + } catch { /* asked again on the next tick */ } + }; + poll(); + const id = setInterval(poll, 1000); + return () => { stopped = true; clearInterval(id); }; + }, [remoteMode]); + + const stopCast = async () => { + await platform.cast.chromecastDisconnect().catch(() => {}); + await platform.cast.stop(); + setCastActive(false); castActiveRef.current = false; + setCastUrl(null); + setCastDeviceName(null); + castDeviceRef.current = null; + }; + + const remoteShown = remoteDrag ?? remoteTarget ?? (remote ? remote.position : null); + const remoteSeek = (to) => { + const duration = durationRef.current; + const target = Math.max(0, duration > 0 ? Math.min(to, duration - 1) : to); + setRemoteTarget(target); + if (requestSeekRef.current) requestSeekRef.current(target); + }; + const remoteToggle = async () => { + const v = videoRef.current; + try { + if (remote && remote.playerState === 'PAUSED') { + await platform.cast.chromecastPlay(); + if (v) v.play().catch(() => {}); + } else { + await platform.cast.chromecastPause(); + // The local copy only paces the download: left running, it would go + // on fetching for a television that is not watching. + if (v) v.pause(); + } + setRemote((r) => (r ? { ...r, playerState: r.playerState === 'PAUSED' ? 'PLAYING' : 'PAUSED' } : r)); + } catch (err) { + console.warn('[cast] remote command failed:', err); + } + }; + return html` <div class="video-overlay video-player-overlay" onClick=${(e) => { if (e.target.classList.contains('video-overlay')) onClose(); @@ -1617,7 +1820,10 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { setAudioTrack(track.i); const v = videoRef.current; const seek = requestSeekRef.current; - if (v && seek) seek(v.currentTime); + // Where the television is, when one plays the film: the + // phone's playhead has drifted from it. + const at = remoteFilmPosRef.current ?? (v && v.currentTime); + if (at != null && seek) seek(at); }}> ${track.i === audioTrack ? html`<${Icon} name="check" />` @@ -1674,23 +1880,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { <button class="video-close ${castActive ? 'cast-active' : ''}" onClick=${async () => { if (castActive) { - await platform.cast.chromecastDisconnect().catch(() => {}); - await platform.cast.stop(); - setCastActive(false); castActiveRef.current = false; - setCastUrl(null); - setCastDeviceName(null); - castDeviceRef.current = null; + await stopCast(); } else if (castCodecRef.current && initSegmentRef.current) { if (castPickerOpen) { setCastPickerOpen(false); } else { setCastPickerOpen(true); - setCastScanning(true); - setCastDevices([]); - platform.cast.discover().then((devices) => { - setCastDevices(devices || []); - setCastScanning(false); - }).catch(() => setCastScanning(false)); } } }} @@ -1698,12 +1893,6 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { <${Icon} name="cast" /></button> ${castPickerOpen && html` <div class="cast-picker"> - ${castScanning && html` - <div class="cast-picker-item cast-picker-scanning"> - <span class="spinner" style="width:14px;height:14px"></span> - ${t('cast.scanning')} - </div> - `} ${castDevices.map(d => html` <button class="cast-picker-item" onClick=${() => { setCastPickerOpen(false); @@ -1718,6 +1907,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { <${Icon} name="cast" /> ${d.name} </button> `)} + ${castScanning && html` + <div class="cast-picker-item cast-picker-scanning"> + <span class="spinner" style="width:14px;height:14px"></span> + ${t('cast.scanning')} + </div> + `} ${!castScanning && castDevices.length === 0 && html` <div class="cast-picker-item cast-picker-empty"> ${t('cast.no_devices')} @@ -1770,7 +1965,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { </div> ${(phase === 'streaming' || phase === 'loading') && html` - <div class="video-container"> + <div class="video-container ${remoteMode ? 'video-container-remote' : ''}"> <video ref=${videoRef} controls autoplay> ${subtitleUrl && html` <track key=${subtitleUrl} kind="subtitles" src=${subtitleUrl} @@ -1781,6 +1976,18 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { default /> `} </video> + ${remoteMode && html` + <${CastRemote} + device=${castDeviceName} + position=${remoteShown} + duration=${durationRef.current} + playerState=${remote ? remote.playerState : null} + moving=${remoteTarget !== null} + onDrag=${setRemoteDrag} + onSeek=${(to) => { setRemoteDrag(null); remoteSeek(to); }} + onToggle=${remoteToggle} + onStop=${stopCast} /> + `} ${phase === 'loading' && html` <div class="video-loading"> <div class="video-loading-label"> @@ -1819,4 +2026,4 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { * live. */ -export { VideoPlayer }; +export { VideoPlayer, CastRemote }; diff --git a/packages/meshbay-hub/tests/harness/window_leak.mjs b/packages/meshbay-hub/tests/harness/window_leak.mjs index d5185de..f05d8c0 100644 --- a/packages/meshbay-hub/tests/harness/window_leak.mjs +++ b/packages/meshbay-hub/tests/harness/window_leak.mjs @@ -41,20 +41,23 @@ let cancelled = false; const pump = () => {}; const flushQueue = () => {}; const gekRef = { current: null }; +// Not holding: these are the abandoned stream's segments, before any stream_init. +const heldRef = { current: null }; +const consumeSegment = async () => {}; const window_ = { MeshBayCrypto: { decryptChunkBin: async () => new Uint8Array(4) } }; const silentConsole = { log() {}, warn() {}, error() {} }; const handler = new Function( 'msg', 'cancelled', 'awaitingInitRef', 'outstandingRef', 'queueRef', - 'entry', 'gekRef', 'pump', 'flushQueue', 'console', 'window', + 'entry', 'gekRef', 'pump', 'flushQueue', 'console', 'window', 'heldRef', 'consumeSegment', `return (async () => {${source}})();`); const deliver = (n) => Promise.all( Array.from({ length: n }, (_, i) => handler( { file_id: entry.id, segment_index: i, nonce: 'n', ct: 'c' }, cancelled, awaitingInitRef, outstandingRef, queueRef, entry, gekRef, - pump, flushQueue, silentConsole, window_))); + pump, flushQueue, silentConsole, window_, heldRef, consumeSegment))); const run = async () => { // The whole window arrives while reinitAt is still awaiting its updateends. diff --git a/packages/meshbay-hub/tests/test_android_cast.py b/packages/meshbay-hub/tests/test_android_cast.py new file mode 100644 index 0000000..a346d15 --- /dev/null +++ b/packages/meshbay-hub/tests/test_android_cast.py @@ -0,0 +1,133 @@ +""" +Casting in the Android application, pinned by reading the source. + +The relay is a port of meshbay-client/src/cast-relay.js and its mitigations are +the same ones; the JVM tests (CastRelayTest) run it on loopback. What is held +here is the wiring around it: the same bridge surface as the desktop, order +kept where order is meaning, the receiver pointed at nothing but the relay, and +what keeps a cast alive with the screen off switched on only while one runs. +""" + +import re +from pathlib import Path + +import pytest + +PACKAGES = Path(__file__).resolve().parents[2] +MAIN = PACKAGES / "meshbay-android" / "app" / "src" / "main" +SRC = MAIN / "kotlin" / "org" / "meshbay" / "client" +CAST = SRC / "cast" +SHIM = MAIN / "assets" / "bridge" / "meshbay-bridge.js" +DESKTOP = PACKAGES / "meshbay-client" / "src" + +pytestmark = pytest.mark.skipif(not CAST.exists(), reason="android sources not present") + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_the_cast_channels_are_the_desktops(): + def channels(text: str, call: str) -> set[str]: + return set(re.findall(rf"{call}\('(cast:[\w:-]+)'", text)) + + preload = channels(_read(DESKTOP / "preload.js"), r"ipcRenderer\.invoke") + shim = channels(_read(SHIM), "call") + kt = _read(CAST / "CastChannels.kt") + native = set(re.findall(r'^\s*"(cast:[\w:-]+)" ->', kt, flags=re.M)) + assert preload and shim == preload, shim ^ preload + assert native == preload, native ^ preload + + +def test_no_cast_object_where_casting_cannot_work(): + """`platform.cast.available` is whether the object exists.""" + shim = _read(SHIM) + assert "...(CAST ? { cast: {" in shim + assert "lanCast: CAST" in shim + assert "const CAST = ${cast.control.available()}" in _read(SRC / "MainActivity.kt") + + +def test_the_relay_keeps_the_desktop_mitigations(): + relay = _read(CAST / "CastRelay.kt") + desktop = _read(DESKTOP / "cast-relay.js") + for name in ("RING_CAP", "PORT_BASE", "PORT_COUNT"): + js = re.search(rf"const {name} = (\d+);", desktop).group(1) + assert re.search(rf"const val {name} = {js}\b", relay), name + assert "InetSocketAddress(address, PORT_BASE + i)" in relay, "bound to the LAN address" + # Code, not comments: the comment saying "never 0.0.0.0" is not a bind. + code = re.sub(r"/\*.*?\*/", "", relay, flags=re.S) + code = re.sub(r"(?m)//.*$", "", code) + assert "0.0.0.0" not in code + assert 'query["t"] != token' in relay + # The preflight before the token: a refusal there reads as a network error. + serve = relay.split("private fun serve(", 1)[1] + assert serve.index('method == "OPTIONS"') < serve.index('query["t"] != token') + + +def test_what_a_receiver_has_not_read_waits_on_disk(): + """The desktop drops a fragment once 8 MB wait for a receiver; a fragment + is 5 to 10 MB at a film's bitrate, so the television froze for its length + (measured: three dropped in the first fifteen seconds). Here the lead waits + in a spool file per receiver, deleted with it, and is dropped only past a + disk bound.""" + relay = _read(CAST / "CastRelay.kt") + assert "BACKPRESSURE_HIGH" not in relay + assert "RandomAccessFile(file, \"rw\").channel" in relay + assert "c.waiting() > spoolLimit()" in relay + assert "SPOOL_MAX_BYTES = 2L * 1024 * 1024 * 1024" in relay + assert "file.delete()" in relay + assert 'java.io.File(context.cacheDir, "cast-relay")' in _read(CAST / "CastChannels.kt") + + +def test_the_backlog_is_bounded_in_bytes(): + """A fragment is a segment, megabytes at a film's bitrate: 64 of them killed + the application with an OutOfMemoryError on the emulator.""" + relay = _read(CAST / "CastRelay.kt") + assert "RING_MAX_BYTES" in relay and "ringBytes > RING_MAX_BYTES" in relay + + +def test_the_relay_is_on_wifi_never_the_mobile_network(): + channels = _read(CAST / "CastChannels.kt") + lan = channels.split("private fun lanAddress()", 1)[1] + assert "TRANSPORT_WIFI" in lan and "TRANSPORT_ETHERNET" in lan + assert "TRANSPORT_CELLULAR" not in lan + + +def test_the_receiver_is_pointed_at_the_relay_and_nothing_else(): + channels = _read(CAST / "CastChannels.kt") + check = channels.split("private fun relayUrl(", 1)[1].split("\n }", 1)[0] + assert "asked != ours" in check and "throw Refused" in check + assert channels.count("relayUrl(o)") == 2, "connect and reload both go through the check" + + +def test_relay_calls_keep_their_order(): + """The page does not await each push; on a pool they could overtake.""" + channels = _read(CAST / "CastChannels.kt") + assert '"cast:start", "cast:push", "cast:subtitle", "cast:finish", "cast:stop"' in channels + bridge = _read(SRC / "bridge" / "Bridge.kt") + assert "Executors.newSingleThreadExecutor()" in bridge + assert "(if (ordered) serial else work).execute" in bridge + assert "fun ordered(frame: BinaryFrame) = frame.channel == BinaryFrame.CAST_PUSH" in _read( + SRC / "bridge" / "Channels.kt") + + +def test_screen_off_survival_is_switched_on_only_while_casting(): + activity = _read(SRC / "MainActivity.kt") + casting = activity.split("private fun casting(on: Boolean)", 1)[1].split("\n }", 1)[0] + assert "web.keepVisible = on" in casting + assert "startForegroundService(service) else stopService(service)" in casting + assert activity.count("keepVisible =") == 1, "the page is kept visible from one place only" + view = _read(SRC / "shell" / "ShellWebView.kt") + assert "var keepVisible = false" in view + manifest = _read(MAIN / "AndroidManifest.xml") + assert 'android:name=".cast.CastService"' in manifest + assert 'android:foregroundServiceType="mediaPlayback"' in manifest + + +def test_the_receiver_is_given_what_the_desktop_gives_it(): + control = _read(CAST / "CastControl.kt") + assert "DEFAULT_MEDIA_RECEIVER_APPLICATION_ID" in control + assert "MediaInfo.STREAM_TYPE_LIVE" in control + assert "TEXT_TRACK_ID = 1L" in control + assert "SCAN_DURATION_MS = 6000L" in control + assert re.search(r"const SCAN_DURATION_MS = 6000;", _read(DESKTOP / "cast-chromecast.js")) diff --git a/packages/meshbay-hub/tests/test_android_downloads.py b/packages/meshbay-hub/tests/test_android_downloads.py new file mode 100644 index 0000000..96da9a4 --- /dev/null +++ b/packages/meshbay-hub/tests/test_android_downloads.py @@ -0,0 +1,90 @@ +""" +Downloads in the Android application, pinned by reading the source. + +The page's contract with a native save target is platform.js `nativeSave`: a +sink with write/close/abort, an `open` only when the target can open the file, +and nothing that names a path. The Android sink keeps it; what it may open is +downloads.js `OPENABLE`, held equal here because a second copy of a list of +safe types is how an `.html` gets opened one day. +""" + +import re +from pathlib import Path + +import pytest +from spa_source import STATIC + +PACKAGES = Path(__file__).resolve().parents[2] +MAIN = PACKAGES / "meshbay-android" / "app" / "src" / "main" +SAVE = MAIN / "kotlin" / "org" / "meshbay" / "client" / "save" +SHIM = MAIN / "assets" / "bridge" / "meshbay-bridge.js" + +pytestmark = pytest.mark.skipif(not SAVE.exists(), reason="android sources not present") + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_what_may_be_opened_is_the_pages_list(): + js = _read(STATIC / "downloads.js").split("const OPENABLE = {", 1)[1].split("};", 1)[0] + kt = _read(SAVE / "SaveNames.kt").split("val OPENABLE = mapOf(", 1)[1].split("\n )", 1)[0] + page = {k: v.split(";")[0] for k, v in re.findall(r"(\w+): '([^']+)'", js)} + android = dict(re.findall(r'"(\w+)" to "([^"]+)"', kt)) + assert page and android == page, set(android.items()) ^ set(page.items()) + + +def test_open_is_offered_only_where_the_target_says_so(): + shim = _read(SHIM) + save = shim.split("saveFile: async", 1)[1].split("\n },", 1)[0] + assert "if (handle.openable) sink.open" in save + sinks = _read(SAVE / "SaveSinks.kt") + assert '.put("openable", SaveNames.openableType(shown) != null)' in sinks + opening = sinks.split("fun open(id: Long)", 1)[1].split("\n }", 1)[0] + assert "SaveNames.openableType(name) ?: throw Refused" in opening + + +def test_the_page_is_told_names_never_uris(): + sinks = _read(SAVE / "SaveSinks.kt") + for fn in ("fun chooseFolder", "fun getFolder", "fun begin"): + body = sinks.split(fn, 1)[1].split("\n fun ", 1)[0] + returned = re.findall(r'put\("(\w+)"', body) + assert not {"uri", "path", "tree"} & set(returned), (fn, returned) + assert "return displayName(treeDocument(tree))" in sinks + + +def test_the_save_channels_are_the_desktops(): + preload = _read(PACKAGES / "meshbay-client" / "src" / "preload.js") + shim = _read(SHIM) + + def channels(text: str, call: str) -> set[str]: + return set(re.findall(rf"{call}\('((?:save|folder):[\w:-]+)'", text)) + + assert channels(shim, "call") == channels(preload, r"ipcRenderer\.invoke") + assert "nativeSave: true" in shim + + +def test_an_unfinished_file_never_carries_the_final_name(): + sinks = _read(SAVE / "SaveSinks.kt") + assert '"$wanted.part"' in sinks + assert "MediaStore.MediaColumns.IS_PENDING, 1" in sinks + assert "MediaStore.MediaColumns.IS_PENDING, 0" in sinks + abort = sinks.split("fun abort(id: Long)", 1)[1].split("\n }", 1)[0] + assert "delete(sink.uri)" in abort + assert "fun cleanUpAfterAKilledProcess" in sinks + assert "saves.cleanUpAfterAKilledProcess()" in _read( + MAIN / "kotlin" / "org" / "meshbay" / "client" / "MainActivity.kt") + + +def test_a_chosen_folder_that_has_gone_is_not_silently_replaced(): + begin = _read(SAVE / "SaveSinks.kt").split("fun begin(", 1)[1].split("\n fun ", 1)[0] + assert "auto && !(configuredTree != null && tree == null)" in begin + + +def test_writes_are_binary_and_awaited(): + shim = _read(SHIM) + assert "head.setUint32(0, 0x4d424231)" in shim + assert "port.postMessage(frame)" in shim + bridge = _read(MAIN / "kotlin" / "org" / "meshbay" / "client" / "bridge" / "Bridge.kt") + before = bridge.split("TYPE_ARRAY_BUFFER", 1)[0] + assert "!isMainFrame" in before, "a binary message must pass the same sender check" diff --git a/packages/meshbay-hub/tests/test_android_keys.py b/packages/meshbay-hub/tests/test_android_keys.py new file mode 100644 index 0000000..a00b909 --- /dev/null +++ b/packages/meshbay-hub/tests/test_android_keys.py @@ -0,0 +1,74 @@ +""" +The Android keyring against the desktop's, where the shared vectors cannot see. + +`tests/vectors/keyring.json` holds the bytes (and the Android unit tests read +it). What a vector cannot hold is a *list*: which admin operations may be +signed at all, and the Argon2 parameters a format change would move. Two +implementations of a list drift silently — an operation the desktop stopped +signing at MNP 6.0 and Android still signs is a script in the page driving an +older node into widening its sharing. So both are read from source and +compared. +""" + +import re +from pathlib import Path + +import pytest + +PACKAGES = Path(__file__).resolve().parents[2] +MAIN = PACKAGES / "meshbay-android" / "app" / "src" / "main" +KEYS = MAIN / "kotlin" / "org" / "meshbay" / "client" / "keys" +CLIENT = PACKAGES / "meshbay-client" / "src" +SHIM = MAIN / "assets" / "bridge" / "meshbay-bridge.js" + +pytestmark = pytest.mark.skipif(not KEYS.exists(), reason="android sources not present") + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_the_same_admin_operations_are_signed(): + js = _read(CLIENT / "transcripts.js") + js = js.split("const ADMIN_OPS = new Set([", 1)[1].split("]", 1)[0] + kt = _read(KEYS / "Transcripts.kt").split("val ADMIN_OPS = setOf(", 1)[1].split(")", 1)[0] + desktop = set(re.findall(r"'([a-z_]+)'", js)) + android = set(re.findall(r'"([a-z_]+)"', kt)) + assert desktop and android == desktop, android ^ desktop + # The ones MNP 6.0 took away must not come back on either side. + assert not {"root_add", "root_update", "group_attach"} & android + + +def test_the_argon2_parameters_are_the_desktops(): + js = _read(CLIENT / "keyring.js") + m = re.search(r"memory: (\d+), passes: (\d+), parallelism: (\d+), tagLength: (\d+)", js) + kt = _read(KEYS / "Kdf.kt") + want = dict(zip(("MEMORY_KIB", "PASSES", "PARALLELISM", "TAG"), m.groups())) + for name, value in want.items(): + assert re.search(rf"const val ARGON2_{name} = {value}\b", kt), name + + +def test_the_shim_offers_the_desktops_key_surface(): + preload = _read(CLIENT / "preload.js") + shim = _read(SHIM) + + def channels(text: str, call: str) -> set[str]: + return set(re.findall(rf"{call}\('((?:keys|device|secrets):[\w:-]+)'", text)) + + assert channels(shim, "call") == channels(preload, r"ipcRenderer\.invoke") + + +def test_signing_is_by_kind_and_no_private_key_is_returned(): + channels = _read(KEYS.parent / "bridge" / "KeyChannels.kt") + assert '"keys:sign" -> keyring.signAs(' in channels + # No channel hands the page a stored key: the store's slots are never a + # return value, and identity/mint/open answer with public keys. + assert "secrets.read()" in channels # the keyring's load, and nothing else + assert channels.count("secrets.read()") == 1 + assert '"pkEdB64"' in channels and '"skEd"' not in channels + + +def test_widening_browser_access_is_confirmed_natively(): + channels = _read(KEYS.parent / "bridge" / "KeyChannels.kt") + branch = channels.split('"keys:set-browser-access" ->', 1)[1].split('"keys:created-here"', 1)[0] + assert 'confirm("native.browser_access_confirm")' in branch diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py new file mode 100644 index 0000000..e569bb7 --- /dev/null +++ b/packages/meshbay-hub/tests/test_android_shell.py @@ -0,0 +1,223 @@ +""" +The Android shell's security contract, pinned by reading its source. + +The same treatment `test_desktop_shell.py` gives the Electron application, for +the same reason: an emulator or a phone is what proves the shell runs, and the +suite has neither. What this proves is that the properties the design depends +on (docs/MESHBAY_DESIGN.md §8.2, as the Android plan restates them) are in the +source, and it fails when one is removed. The JVM unit tests run too when an +Android SDK is present. +""" + +import os +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +PACKAGES = Path(__file__).resolve().parents[2] +ANDROID = PACKAGES / "meshbay-android" +APP = ANDROID / "app" +SRC = APP / "src" / "main" / "kotlin" / "org" / "meshbay" / "client" +SHIM = APP / "src" / "main" / "assets" / "bridge" / "meshbay-bridge.js" +CLIENT = PACKAGES / "meshbay-client" + +pytestmark = pytest.mark.skipif(not ANDROID.exists(), reason="android sources not present") + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _kotlin() -> str: + return "\n".join(_read(p) for p in sorted(SRC.rglob("*.kt"))) + + +def _strip_js_comments(source: str) -> str: + source = re.sub(r"/\*.*?\*/", "", source, flags=re.S) + return re.sub(r"(?m)//.*$", "", source) + + +# ── The page comes from the package ────────────────────────────────────────── + +def test_the_interface_is_copied_from_its_single_source_and_never_committed(): + build = _read(APP / "build.gradle.kts") + assert '"../meshbay-hub/src/meshbay_hub/static"' in build + # The desktop application's page: the hub's carries a /a/<hash>/ prefix + # that would point back at the hub. + assert '"../meshbay-client/scripts/index.html"' in build + assert "deleteRecursively()" in build, "a stale file could survive a rebuild" + assert "addGeneratedSourceDirectory" in build + assert "build/" in _read(ANDROID / ".gitignore") + assert not (APP / "src" / "main" / "assets" / "ui").exists(), \ + "a copy of the interface is in the source tree, which is how a fork begins" + + +def test_the_webview_loads_the_package_and_nothing_else(): + activity = _read(SRC / "MainActivity.kt") + loads = re.findall(r"\.loadUrl\(([^)]*)\)", activity) + assert loads and set(loads) == {"UiAssets.START"}, loads + assert "loadDataWithBaseURL" not in activity and "loadData(" not in activity + # The hub never becomes the document origin; a link out leaves the app. + assert "shouldOverrideUrlLoading" in activity and "openExternally" in activity + + +def test_the_policy_is_the_desktop_policy_sent_as_a_header(): + """One interface, one policy for the packaged builds — and the desktop's + is already held to the hub's by test_the_two_policies_stay_in_step.""" + def directives(text: str, start: str, end: str) -> dict[str, str]: + body = text.split(start, 1)[1].split(end, 1)[0] + out = {} + for d in re.findall(r"[`\"]([a-z-]+(?: [^`\"]*)?)[`\"]", body): + d = d.replace("${RECAPTCHA_SRC}", "$RECAPTCHA_SRC") + out[d.split()[0]] = d + return out + + desktop = directives(_read(CLIENT / "src" / "main.js"), "const CSP = [", "].join") + kotlin = _read(SRC / "shell" / "UiAssets.kt") + android = directives(kotlin, "val CSP = listOf(", ").joinToString") + assert desktop and android == desktop, set(android.items()) ^ set(desktop.items()) + + assets = _read(SRC / "shell" / "UiAssets.kt") + assert '"Content-Security-Policy" to CSP' in assets + recaptcha = 'RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com"' + assert recaptcha in assets + markup = re.sub(r"<!--.*?-->", "", _read(CLIENT / "scripts" / "index.html"), flags=re.S) + assert "Content-Security-Policy" not in markup + + +def test_the_asset_handler_cannot_be_walked_out_of(): + assets = _read(SRC / "shell" / "UiAssets.kt") + assert '".."' in assets and 'val asset = "ui/"' in assets + + +def test_plain_http_is_refused_except_to_loopback(): + hub = _read(SRC / "hub" / "HubClient.kt") + assert "The hub address must be https" in hub + assert 'Regex("^http://(localhost|127\\\\.)")' in hub + config = _read(APP / "src" / "main" / "res" / "xml" / "network_security_config.xml") + assert '<base-config cleartextTrafficPermitted="false"' in config + allowed = re.findall(r"<domain[^>]*>([^<]+)</domain>", config) + assert set(allowed) == {"localhost", "127.0.0.1"} + + +def test_the_page_reaches_the_signed_in_hub_only(): + hub = _read(SRC / "hub" / "HubClient.kt") + assert "Refused: not this hub" in hub and "sameOrigin(target, hub)" in hub + assert ".followRedirects(false)" in hub, "a redirect would carry the token elsewhere" + + +# ── The bridge ────────────────────────────────────────────────────────────── + +def test_no_javascript_interface_is_ever_added(): + """addJavascriptInterface injects into every frame of every origin.""" + for path in APP.rglob("*.kt"): + assert "addJavascriptInterface" not in _read(path), path + + +def test_every_message_is_checked_for_our_top_level_document(): + bridge = _read(SRC / "bridge" / "Bridge.kt") + check = bridge.split("override fun onPostMessage", 1)[1].split("work.execute", 1)[0] + assert "!isMainFrame" in check and "UiAssets.ORIGIN" in check + activity = _read(SRC / "MainActivity.kt") + assert "addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN)" in activity + assert re.search(r"addDocumentStartJavaScript\(web, .*setOf\(UiAssets\.ORIGIN\)\)", activity) + + +def _shim_channels() -> set[str]: + return set(re.findall(r"call\('([\w:-]+)'", _strip_js_comments(_read(SHIM)))) + + +def test_the_shim_offers_desktop_channels_and_native_answers_each(): + preload_js = _read(CLIENT / "src" / "preload.js") + preload = set(re.findall(r"ipcRenderer\.invoke\('([\w:-]+)'", preload_js)) + native = set() + for path in (SRC / "bridge" / "Channels.kt", SRC / "bridge" / "KeyChannels.kt", + SRC / "cast" / "CastChannels.kt"): + native |= set(re.findall(r'^\s*"([\w:-]+)" ->', _read(path), flags=re.M)) + shim = _shim_channels() + assert shim, "no channel found in the shim" + assert shim <= preload, f"channels the desktop does not have: {shim - preload}" + assert shim == native, f"shim and native disagree: {shim ^ native}" + + +def test_what_a_phone_does_not_have_is_absent_not_refusing(): + """platform.js decides what to show from whether an object exists + (`platform.node.available`, `platform.folder.available`, …): an object that + only refused would put screens on the page that fail when used.""" + shim = _strip_js_comments(_read(SHIM)) + exposed = shim.split("const meshbay = {", 1)[1].split("\n };", 1)[0] + for absent in ("node:", "rootPicker:", "minimizeToTray:", "setTrayLabels:"): + assert absent not in exposed, absent + assert "nodeAdmin: false" in exposed and "localFolders: false" in exposed + + +def test_the_bridge_is_frozen_and_the_port_hidden(): + shim = _strip_js_comments(_read(SHIM)) + assert "delete window.meshbayNative" in shim + assert "window.top !== window" in shim + assert "writable: false, configurable: false" in shim + assert "Object.freeze" in shim + + +def test_file_system_access_is_removed_from_the_page(): + """The WebView exposes it (spike S-1) and cannot back it with anything.""" + shim = _strip_js_comments(_read(SHIM)) + for name in ("showDirectoryPicker", "showSaveFilePicker", "showOpenFilePicker"): + assert f"'{name}'" in shim, name + + +def test_the_hub_address_is_injected_not_fetched(): + """platform.hubBase() is called while modules load, before anything can await.""" + assert "HUB_BASE" in _strip_js_comments(_read(SHIM)) + assert "const HUB_BASE = ${JSONObject.quote(hub.base)}" in _read(SRC / "MainActivity.kt") + + +def test_the_node_setup_welcome_needs_a_node(): + """A phone has a bridge and no node: with no groups yet it must see the + invitations and the join link, not a node wizard it cannot finish.""" + from spa_source import STATIC + app = _read(STATIC / "app.js") + home = app.split("function HomePage(", 1)[1].split("\n}\n", 1)[0] + gate = home.split("<${SetupWelcome}", 1)[0] + assert "platform.capabilities.nodeAdmin" in gate + assert "platform.isNative" not in gate + + +# ── The window ────────────────────────────────────────────────────────────── + +def test_nothing_is_granted_and_video_may_go_fullscreen(): + activity = _read(SRC / "MainActivity.kt") + assert "onPermissionRequest(request: PermissionRequest) = request.deny()" in activity + # Without a custom view, requestFullscreen() never settles (CLAUDE.md). + assert "override fun onShowCustomView" in activity + assert "override fun onHideCustomView" in activity + + +def test_no_backup_carries_the_keys_away(): + manifest = _read(APP / "src" / "main" / "AndroidManifest.xml") + assert 'android:allowBackup="false"' in manifest + assert 'android:dataExtractionRules="@xml/data_extraction_rules"' in manifest + + +def test_the_gradle_distribution_is_pinned_by_checksum(): + props = _read(ANDROID / "gradle" / "wrapper" / "gradle-wrapper.properties") + assert re.search(r"^distributionSha256Sum=[0-9a-f]{64}$", props, flags=re.M) + + +def test_the_version_is_the_packages_version(): + """All packages share one version (CLAUDE.md); this one reads it rather + than writing it a second time.""" + build = _read(APP / "build.gradle.kts") + assert 'rootDir.resolve("../meshbay-client/package.json")' in build + assert not re.search(r'versionName = "\d', build) + + +@pytest.mark.skipif(not os.environ.get("ANDROID_HOME") or shutil.which("java") is None, + reason="no Android SDK in the environment") +def test_the_jvm_unit_tests_pass(): + result = subprocess.run(["./gradlew", "--no-daemon", "-q", "testDebugUnitTest"], + cwd=ANDROID, capture_output=True, text=True, timeout=900) + assert result.returncode == 0, result.stdout[-3000:] + result.stderr[-3000:] diff --git a/packages/meshbay-hub/tests/test_cast_discovery.py b/packages/meshbay-hub/tests/test_cast_discovery.py new file mode 100644 index 0000000..8d156ff --- /dev/null +++ b/packages/meshbay-hub/tests/test_cast_discovery.py @@ -0,0 +1,152 @@ +""" +Cast receivers are listed as they answer, not at the end of the scan. + +The scan runs its full length because a receiver coming back from a reset can +take several seconds to answer, but most answer within two; a picker that showed +nothing until the end was slow every time it was opened. These tests run the real +`CastChromecast` with mDNS replaced by a stub that answers on cue, and the clock +shortened, so what they measure is what the page's poll would see. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +CLIENT = Path(__file__).resolve().parents[2] / "meshbay-client" +CHROMECAST = CLIENT / "src" / "cast-chromecast.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not CHROMECAST.exists(), + reason="node or the desktop client sources are not available") + +# Stubs the two dependencies at `require` time, and makes six seconds of scan +# last sixty milliseconds by scaling every timer the module arms. +SCRIPT = r""" +const Module = require('node:module'); +const browsers = []; +const realLoad = Module._load; +Module._load = function (request, ...rest) { + if (request === 'bonjour-service') { + return { Bonjour: class { + find(_q, onUp) { + const b = { onUp, stopped: false, stop() { this.stopped = true; } }; + browsers.push(b); + return b; + } + } }; + } + if (request === 'castv2-client') return { Client: class {}, DefaultMediaReceiver: {} }; + return realLoad.call(this, request, ...rest); +}; +const realSetTimeout = global.setTimeout; +global.setTimeout = (fn, ms, ...a) => realSetTimeout(fn, ms / 100, ...a); +const wait = (ms) => new Promise((r) => realSetTimeout(r, ms)); + +const CastChromecast = require(process.argv[2]); +const tv = (id) => ({ name: id, txt: { id, fn: `TV ${id}` }, + addresses: ['10.0.0.9'], port: 8009 }); + +(async () => { + const cc = new CastChromecast(); + const out = {}; + + cc.startScan(); + out.atStart = cc.devices(); + browsers[0].onUp(tv('a')); + out.afterFirstAnswer = cc.devices(); + await wait(100); + out.afterScan = cc.devices(); + out.firstBrowserStopped = browsers[0].stopped; + + // A second scan started over a first: the first's timer must not end it. + cc.startScan(); + await wait(40); + cc.startScan(); + await wait(40); + out.restartedStillScanning = cc.devices().scanning; + out.restartClearedOldDevices = cc.devices().devices.length === 0; + await wait(60); + out.restartedEnds = !cc.devices().scanning; + console.log(JSON.stringify(out)); +})(); +""" + + +@pytest.fixture(scope="module") +def run(tmp_path_factory): + script = tmp_path_factory.mktemp("cd") / "discover.cjs" + script.write_text(SCRIPT, encoding="utf-8") + proc = subprocess.run( + ["node", str(script), str(CHROMECAST)], + capture_output=True, text=True, encoding="utf-8", timeout=60) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_a_receiver_is_listed_before_the_scan_ends(run): + assert run["atStart"] == {"devices": [], "scanning": True} + assert run["afterFirstAnswer"]["scanning"] is True + assert [d["name"] for d in run["afterFirstAnswer"]["devices"]] == ["TV a"] + + +def test_the_scan_ends_on_its_own_and_keeps_what_it_found(run): + assert run["afterScan"]["scanning"] is False + assert [d["id"] for d in run["afterScan"]["devices"]] == ["a"] + assert run["firstBrowserStopped"] is True + + +def test_a_new_scan_is_not_cut_short_by_the_one_it_replaced(run): + """ + The old code left the first scan's timer armed, and it stopped whichever + browser was current when it fired — the new one, two thirds early. + """ + assert run["restartedStillScanning"] is True + assert run["restartClearedOldDevices"] is True + assert run["restartedEnds"] is True + + +def test_the_local_player_is_silent_while_casting(): + """The local element keeps playing while casting — its playhead paces the + stream the relay forwards — so it must not keep its sound: a phone in the + room doubled the television's audio, screen off included. Whatever the + viewer had set comes back when the cast ends.""" + from spa_source import STATIC + player = (STATIC / "video-player.js").read_text(encoding="utf-8") + effect = player.split("const mutedBeforeCastRef = useRef(null);", 1)[1] + effect = effect.split("}, [castActive]);", 1)[0] + assert "v.muted = true;" in effect + assert "mutedBeforeCastRef.current = v.muted;" in effect + assert "v.muted = mutedBeforeCastRef.current;" in effect + assert "pause()" not in effect, "pausing would stop the stream the receiver is playing" + + +def test_the_player_is_a_remote_while_casting(): + """Phone and television do not play in step, so while a receiver plays the + film the scrubber shows the receiver's position (stream time plus where + the stream started) and every seek goes through the ordinary seek, which + restarts the relay and reloads the receiver there. The copy-URL cast has + no receiver to read and keeps the local player.""" + from spa_source import STATIC + player = (STATIC / "video-player.js").read_text(encoding="utf-8") + remote = player.split("// ── Remote ──", 1)[1].split("return html`", 1)[0] + assert "castDeviceName !== null && platform.cast.canControl" in remote + assert "streamStartRef.current + c.position" in remote + # Until the restart lands, the receiver's position is the old stream's. + assert "!castRestartPendingRef.current" in remote + assert "requestSeekRef.current(target)" in remote + assert "platform.cast.chromecastPause()" in remote + assert "platform.cast.chromecastPlay()" in remote + # A language change restarts the stream where the television is. + assert "remoteFilmPosRef.current ?? (v && v.currentTime)" in player + + +def test_every_locale_names_the_remote_controls(): + from spa_source import STATIC + keys = ["cast.casting_to", "cast.seek", "cast.waiting", "cast.back30", "cast.fwd30", "cast.play", "cast.pause"] + for f in sorted((STATIC / "locales").glob("*.js")): + text = f.read_text(encoding="utf-8") + for k in keys: + assert f"'{k}':" in text, f"{f.name} lacks {k}" diff --git a/packages/meshbay-hub/tests/test_desktop_keyring.py b/packages/meshbay-hub/tests/test_desktop_keyring.py index e55e6d0..af7cc37 100644 --- a/packages/meshbay-hub/tests/test_desktop_keyring.py +++ b/packages/meshbay-hub/tests/test_desktop_keyring.py @@ -13,6 +13,7 @@ page, and a reference written from the specification in Python. import base64 import hashlib import json +import re import shutil import subprocess from pathlib import Path @@ -93,6 +94,9 @@ const v = JSON.parse(fs.readFileSync(input, 'utf8')); other_node: await refusal('join', { ...F.join, nodePk: 'T3RoZXJOb2Rl' }), other_account: await refusal('join', { ...F.join, userId: 'someone-else' }), stale: await refusal('join', { ...F.join, ts: ts - 3600 }), + root_add: await refusal('admin', { ...F.admin, op: 'root_add' }), + root_update: await refusal('admin', { ...F.admin, op: 'root_update' }), + group_attach: await refusal('admin', { ...F.admin, op: 'group_attach' }), }; const eph = require('crypto').generateKeyPairSync('x25519'); @@ -268,6 +272,24 @@ def test_the_page_names_a_kind_and_never_the_bytes(out): assert "not now" in r["stale"] +def test_what_widens_a_nodes_sharing_is_never_signed(out): + """Gone from MNP 6.0, and refused here as well: a node older than that + still accepts them, and a script in the page must not be able to get one + signed for it.""" + for op in ("root_add", "root_update", "group_attach"): + assert "not an operation" in out["refused"][op], op + + +def test_the_application_signs_exactly_the_nodes_operations(): + from meshbay_common import adminop + src = (Path(__file__).resolve().parents[2] / "meshbay-client" / "src" + / "transcripts.js").read_text(encoding="utf-8") + listed = set(re.findall(r"'([a-z_]+)'", + src.split("const ADMIN_OPS = new Set([")[1].split("]);")[0])) + catalogue = {v for k, v in vars(adminop).items() if k.startswith("OP_")} + assert listed == catalogue + + def test_nothing_is_sealed_for_a_browser_while_browser_access_is_off(out): for what, err in out["sealing_while_access_off"].items(): assert err and "browser access is off" in err, what diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py index e80933c..c2ea4ca 100644 --- a/packages/meshbay-hub/tests/test_desktop_shell.py +++ b/packages/meshbay-hub/tests/test_desktop_shell.py @@ -425,13 +425,10 @@ def test_the_page_names_node_operations_not_routes(): assert defined <= used, f"defined but never called: {defined - used}" -@pytest.mark.parametrize("op", ["attachGroup", "addRoot", "clearDenylist"]) -def test_what_widens_the_node_is_confirmed_natively(op): - """Sharing a folder, hosting a group, re-admitting a revoked subject: asked - by a dialog the main process draws, which a script in the page cannot - answer. A folder chosen in the native picker is its own confirmation.""" - body = _node_ops()[op] - assert "confirmOrRefuse(" in body or "confirmFolder(" in body +def test_readmitting_a_revoked_subject_is_confirmed_natively(): + """Asked by a dialog the main process draws, which a script in the page + cannot answer.""" + assert "confirmOrRefuse(" in _node_ops()["clearDenylist"] def test_the_native_dialogs_are_worded_in_every_language(): diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py index 3716f4c..7062d39 100644 --- a/packages/meshbay-hub/tests/test_downloads.py +++ b/packages/meshbay-hub/tests/test_downloads.py @@ -602,3 +602,56 @@ console.log(JSON.stringify(out)); f"resume at {out['resumeFrom']} — that gap is a hole in the file") # The resumed run covers exactly the rest, and repeats nothing. assert out["writtenAfterResume"] == list(range(out["resumeFrom"], 10)), out + + +def test_a_written_chunk_is_not_held_until_the_download_ends(tmp_path): + """A file streamed to disk holds one pipeline window in the page, not the + whole file. + + `pipelinedDownload` kept every chunk's resolved promise in `inflight` for + the length of the call, and each promise held its ciphertext — so a file + written straight to disk was also held whole in memory until the last + chunk landed. Measured in the Android application on a 2 GB download: the + page's JS heap tracked the bytes already written, 905 MB at 928 MB, and the + renderer reached 2.1 GB before dropping to 82 MB at the end. Every target + that writes as it goes (a granted folder, a service worker, the desktop's + native save) had the same cost. The real function runs here, with each + chunk message weakly referenced and the collector forced between writes. + """ + src = (STATIC / "file-utils.js").read_text(encoding="utf-8") + fn = src[src.index("async function pipelinedDownload"):] + fn = fn[:fn.index("\n}\n") + 2] + + script = tmp_path / "retention.mjs" + script.write_text(""" +const PIPELINE_WINDOW = 4; +const TOTAL = 40; +const refs = []; +let worst = 0; +const _fetchChunkResilient = async (transport, fileId, i) => { + const msg = { ct: new Uint8Array(64 * 1024), nonce: new Uint8Array(12) }; + refs[i] = new WeakRef(msg); + return msg; +}; +const _writeOrStall = async (w, bytes, index) => { + // A macrotask ends the job that keeps WeakRef targets alive; then collect. + await new Promise((r) => setTimeout(r, 0)); + globalThis.gc(); + let alive = 0; + for (let j = 0; j < index - PIPELINE_WINDOW; j++) if (refs[j] && refs[j].deref()) alive++; + worst = Math.max(worst, alive); +}; +globalThis.window = { MeshBayCrypto: { + decryptChunkBin: async () => ({ byteLength: 64 * 1024 }), +} }; +""" + fn + """ +await pipelinedDownload({}, 'k', 'file', TOTAL, () => {}, {}, { aborted: false }, '', 0); +console.log(JSON.stringify({ worst })); +""", encoding="utf-8") + proc = subprocess.run(["node", "--expose-gc", str(script)], capture_output=True, + text=True, encoding="utf-8", timeout=60) + assert proc.returncode == 0, proc.stderr + worst = json.loads(proc.stdout)["worst"] + assert worst == 0, ( + f"{worst} chunks already written were still held when a later one was — " + "the download keeps the whole file in memory until it ends") diff --git a/packages/meshbay-hub/tests/test_keyring_vectors.py b/packages/meshbay-hub/tests/test_keyring_vectors.py new file mode 100644 index 0000000..3928965 --- /dev/null +++ b/packages/meshbay-hub/tests/test_keyring_vectors.py @@ -0,0 +1,142 @@ +""" +The keypair-bundle and transcript vectors (`tests/vectors/keyring.json`). + +One file that every implementation of the bundle format and of the signed +transcripts has to reproduce: the page (`keyderive.js`), the desktop keyring +(`keyring.js`, `transcripts.js`), the Python reference below, and the Android +keyring, whose unit tests read the same file. Pairwise parity tests grow with +the square of the implementations; a shared file grows by one consumer. + +Two things are checked here. The file is what the shipped desktop code writes, +so it cannot drift from the code it describes. And the specification +(`docs/MESHBAY_DESIGN.md` §3.7, written out with argon2-cffi and +`cryptography`, sharing nothing with the generator) arrives at the same bytes. +""" + +import base64 +import hashlib +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +VECTORS = Path(__file__).resolve().parent / "vectors" +FILE = VECTORS / "keyring.json" +GENERATOR = VECTORS / "gen_keyring_vectors.js" +KEYRING = Path(__file__).resolve().parents[2] / "meshbay-client" / "src" / "keyring.js" + +try: + from argon2.low_level import Type, hash_secret_raw + HAVE_ARGON2 = True +except ImportError: + HAVE_ARGON2 = False + + +def _vectors() -> dict: + return json.loads(FILE.read_text(encoding="utf-8")) + + +@pytest.mark.skipif(shutil.which("node") is None or not KEYRING.exists(), + reason="node or the desktop client sources are unavailable") +def test_the_file_is_what_the_shipped_keyring_writes(): + """Regenerated from keyring.js and transcripts.js, byte for byte. A change + to either that alters a bundle or a transcript fails here first — which is + the moment to decide whether it is a format change every client must make.""" + out = subprocess.run(["node", str(GENERATOR)], capture_output=True, text=True, + timeout=120, check=True).stdout + assert json.loads(out) == _vectors(), ( + "keyring.js or transcripts.js no longer produce tests/vectors/keyring.json; " + "if the format change is deliberate, regenerate it and update every client") + + +def _hkdf(ikm: bytes, info: str) -> bytes: + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.kdf.hkdf import HKDF + return HKDF(hashes.SHA256(), 32, None, info.encode()).derive(ikm) + + +@pytest.fixture(scope="module") +def spec(): + """The chain from the specification: passphrase → Argon2id → M → keys.""" + if not HAVE_ARGON2: + pytest.skip("argon2-cffi is unavailable") + v = _vectors() + i, p = v["input"], v["kdf"]["argon2_params"] + salt = hashlib.sha256(f"meshbay:bundle:v2:{i['username']}".encode()).digest()[:16] + a = hash_secret_raw(i["password"].encode(), salt, time_cost=p["passes"], + memory_cost=p["memory"], parallelism=p["parallelism"], + hash_len=p["tagLength"], type=Type.ID) + m = _hkdf(a + base64.b64decode(i["pepperB64"]), f"meshbay:bundle-master:v3|{i['userId']}") + return {"v": v, "salt": salt, "a": a, "m": m, + "node_key": _hkdf(m, f"meshbay:bundle:v3|node|{i['nodePk']}"), + "recovery_key": _hkdf(bytes.fromhex(v["kdf"]["mnemonic_bytes_hex"]), + f"meshbay:recovery:v1:{i['username']}")} + + +def test_the_specification_derives_the_same_keys(spec): + k = spec["v"]["kdf"] + assert spec["salt"].hex() == k["salt_hex"] + assert spec["a"].hex() == k["argon2_hex"] + assert spec["m"].hex() == k["master_hex"] + assert hashlib.sha256(spec["m"]).hexdigest()[:16] == k["master_fingerprint"] + assert spec["node_key"].hex() == k["node_key_hex"] + playlist = _hkdf(spec["m"], "meshbay:playlists:v2") + assert base64.b64encode(playlist).decode() == k["playlist_key_b64"] + assert spec["recovery_key"].hex() == k["recovery_key_hex"] + + +def test_the_specification_seals_the_same_bundles(spec): + """MBK3 = magic ‖ pepper version ‖ nonce ‖ AES-GCM(JSON, aad). With the nonce + pinned, sealing is deterministic, so every client must write these bytes.""" + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + v = spec["v"] + i, b = v["input"], v["bundles"] + nonce = bytes.fromhex(i["fixedNonceHex"]) + plain, aad = b["sealed_json_plaintext"].encode(), b["aad"].encode() + + def seal(key: bytes, version: int) -> str: + return base64.b64encode(b"MBK3" + bytes([version]) + nonce + + AESGCM(key).encrypt(nonce, plain, aad)).decode() + + assert seal(spec["node_key"], i["pepperVersion"]) == b["fixed_nonce_bundle_b64"] + assert seal(spec["recovery_key"], 0) == b["recovery_fixed_nonce_b64"] + raw = base64.b64decode(b["legacy_mbk2_b64"]) # TRANSITIONAL: MBK2, no AAD + assert AESGCM(spec["a"]).decrypt(raw[4:16], raw[16:], None) == plain + + +def test_the_identity_signs_and_agrees_as_recorded(): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.hazmat.primitives.asymmetric.x25519 import ( + X25519PrivateKey, + X25519PublicKey, + ) + v = _vectors() + i = v["input"] + ed = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(i["edSeedHex"])) + x = X25519PrivateKey.from_private_bytes(bytes.fromhex(i["xSeedHex"])) + + def raw(k) -> str: + return base64.b64encode(k.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw)).decode() + + assert raw(ed) == v["identity"]["public"]["pkEdB64"] + assert raw(x) == v["identity"]["public"]["pkXB64"] + peer = X25519PublicKey.from_public_bytes(bytes.fromhex(i["peerXPubHex"])) + assert base64.b64encode(x.exchange(peer)).decode() == v["agreement"]["shared_b64"] + for kind, t in v["transcripts"].items(): + sig = base64.b64encode(ed.sign(bytes.fromhex(t["transcript_hex"]))).decode() + assert sig == t["signature_b64"], kind + + +def test_every_signed_kind_and_a_refusal_for_each_check_is_recorded(): + """A consumer that passes an empty list proves nothing; pin what is there.""" + v = _vectors() + assert set(v["transcripts"]) == {"join", "device_hello", "device_request", + "device_add", "device_revoke", "chat", "admin"} + labels = {r["label"] for r in v["refusals"]} + assert {"another node", "another account", "stale timestamp", "unknown kind", + "an op that widens sharing (gone from MNP 6.0)"} <= labels + assert all(r["error"].startswith("Refused: ") for r in v["refusals"]) diff --git a/packages/meshbay-hub/tests/test_upload_controls_hidden.py b/packages/meshbay-hub/tests/test_upload_controls_hidden.py index 6316e44..947ba75 100644 --- a/packages/meshbay-hub/tests/test_upload_controls_hidden.py +++ b/packages/meshbay-hub/tests/test_upload_controls_hidden.py @@ -231,7 +231,7 @@ def test_the_notice_also_answers_the_operators_own_request(): def test_changing_a_root_is_signed(): transport = transport_source() - for method in ("updateRoot", "ejectRoot", "plugRoot"): + for method in ("ejectRoot", "plugRoot", "removeRoot"): body = transport[transport.index(f"async {method}("):] body = body[:body.index("\n async ", 1)] assert "admin_challenge" in body and "_authorizeAdminOp" in body, ( @@ -261,12 +261,34 @@ def test_the_operator_is_offered_it_on_the_web_too(): "the shared directories section still requires a local node") table = _component(source, "SharedDirectoriesTable") - for call in ("transport.updateRoot", "transport.ejectRoot", - "transport.plugRoot", "transport.removeRoot", - "transport.addRoot"): + for call in ("transport.ejectRoot", "transport.plugRoot", "transport.removeRoot"): assert call in table, f"{call} has no MNP route from the table" +def test_what_widens_the_sharing_never_crosses_mnp(): + """ + Adding a directory and switching `writable` or `removable` are done on the + node's own machine (MNP 6.0). Over MNP they were signed ops, and a signature + proves that the operator's key signed: in a browser that key is driven by + code the hub serves. The list stays visible from anywhere; those controls + are read-only there. + """ + transport = transport_source() + for method in ("addRoot", "updateRoot", "attachGroup"): + assert f"async {method}(" not in transport, f"{method} is an MNP call again" + for mtype in ("'root_add'", "'root_update'", "'group_attach'"): + assert mtype not in transport, f"{mtype} is sent or expected again" + + table = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), + "SharedDirectoriesTable") + assert "platform.node.op('addRoot'" in table + assert "platform.node.op('updateRoot'" in table + assert "const canWiden = isLocal || onNodeMachine;" in table + assert table.count("!canWiden") >= 3, ( + "a writable or removable switch is live where it cannot be honoured") + assert "settings_node.roots_local_only_hint" in table + + def test_the_roots_shown_come_from_the_live_connection_when_there_is_one(): """ The loopback list is a second source, and the two drift: it is read once on diff --git a/packages/meshbay-hub/tests/test_video_audio_track.py b/packages/meshbay-hub/tests/test_video_audio_track.py index 82d6e18..6515798 100644 --- a/packages/meshbay-hub/tests/test_video_audio_track.py +++ b/packages/meshbay-hub/tests/test_video_audio_track.py @@ -203,8 +203,8 @@ def test_changing_track_restarts_the_stream_where_the_film_already_was(app): assert "audioTrackRef.current = track.i" in handler assert "requestSeekRef.current" in handler, \ "a track change must go through the seek path" - assert "seek(v.currentTime)" in handler, \ - "a track change must resume where the film already was" + assert "remoteFilmPosRef.current ?? (v && v.currentTime)" in handler, \ + "a track change must resume where the film already was (on the television, when casting)" def test_the_player_believes_the_node_about_which_track_is_playing(app): diff --git a/packages/meshbay-hub/tests/test_video_seek.py b/packages/meshbay-hub/tests/test_video_seek.py index 3289eda..3bcdb71 100644 --- a/packages/meshbay-hub/tests/test_video_seek.py +++ b/packages/meshbay-hub/tests/test_video_seek.py @@ -320,3 +320,68 @@ def test_the_resume_strings_exist_everywhere(locale): text = (STATIC / "locales" / f"{locale}.js").read_text(encoding="utf-8") for key in ("video.resumed_at", "video.from_start"): assert key in text, f"{locale} is missing {key}" + + +def test_a_seeks_first_segments_are_held_while_it_lands_not_dropped(tmp_path): + """What follows a `stream_init` is the new stream, its header first. + + The landing (`reinitAt`/`resumeAt`) waits on the SourceBuffer, and those + segments used to arrive in that gap and be dropped as the old film's. The + player never noticed — its SourceBuffer kept the header from the start of + the film — but a cast relay restarted at that landing was handed a stream + beginning at a moof, and the receiver gave up within a second. Measured on + a phone: two segments dropped one millisecond after `stream_init`, a relay + header of 0 bytes; with them held, a 2 KB header and the film on the TV. + + The real handler and the real drain run here: held while landing, counted + once, another file's segment refused, and replayed in arrival order. + """ + import json + import subprocess + + if shutil.which("node") is None: + pytest.skip("node is not available") + app = APP.read_text(encoding="utf-8") + start = app.index("transport.onStreamData = async (msg) => {") + handler = app[app.index("{", start) + 1:app.index("\n };", start)] + drain_at = app.index("land(msg.start || 0).then(async () => {") + drain = app[app.index("{", drain_at) + 1:app.index("}).catch(", drain_at)] + + script = tmp_path / "hold.mjs" + script.write_text( + f"const handlerSrc = {json.dumps(handler)}, drainSrc = {json.dumps(drain)};\n" + """ +const consumed = []; +const env = { + cancelled: false, entry: { id: 'film' }, + outstandingRef: { current: 8 }, awaitingInitRef: { current: true }, + heldRef: { current: [] }, holdGenRef: { current: 1 }, hold: 1, + consumeSegment: async (m) => { consumed.push(m.segment_index); }, + console: { log() {} }, +}; +const names = Object.keys(env); +const handler = new Function(...names, 'msg', `return (async () => {${handlerSrc}})();`); +const drain = new Function(...names, `return (async () => {${drainSrc}})();`); +const call = (fn, msg) => fn(...names.map((k) => env[k]), msg); + +// Landing: the new stream arrives, plus one stray segment from another file. +for (const [i, file] of [[0, 'film'], [1, 'film'], [2, 'other'], [3, 'film']]) { + await call(handler, { file_id: file, segment_index: i }); +} +const held = env.heldRef.current.map((m) => m.segment_index); +const consumedWhileLanding = consumed.length; +const outstanding = env.outstandingRef.current; +// The landing completes. +env.awaitingInitRef.current = false; +await call(drain); +console.log(JSON.stringify({ held, consumedWhileLanding, outstanding, consumed, + heldAfter: env.heldRef.current })); +""", encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, + encoding="utf-8", timeout=60) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert out["held"] == [0, 1, 3], "the new stream's segments were dropped or mixed" + assert out["consumedWhileLanding"] == 0, "a segment was taken before the landing finished" + assert out["outstanding"] == 4, "each arrival is counted once, held or not" + assert out["consumed"] == [0, 1, 3], "the replay must keep arrival order, header first" + assert out["heldAfter"] is None, "holding must stop once the landing has drained" diff --git a/packages/meshbay-hub/tests/vectors/gen_keyring_vectors.js b/packages/meshbay-hub/tests/vectors/gen_keyring_vectors.js new file mode 100644 index 0000000..055070f --- /dev/null +++ b/packages/meshbay-hub/tests/vectors/gen_keyring_vectors.js @@ -0,0 +1,232 @@ +// Golden vectors for the keypair bundle and the transcripts, produced by the +// code the desktop application ships: meshbay-client's keyring.js and +// transcripts.js, with the vendored Argon2 the page also runs. +// +// node gen_keyring_vectors.js > keyring.json +// +// Every implementation of the bundle format and of the transcripts — the page, +// the desktop keyring, the Python reference, the Android keyring — must +// reproduce this file (test_keyring_vectors.py, and the Android unit tests). +// It is regenerated deliberately, for a format change, never by a test. The +// output is deterministic: nonces and the clock are pinned. + +'use strict'; + +const path = require('node:path'); +const crypto = require('node:crypto'); + +const REPO = path.resolve(__dirname, '..', '..', '..', '..'); +const CLIENT = path.join(REPO, 'packages/meshbay-client/src'); +const VENDOR = path.join(REPO, 'packages/meshbay-hub/src/meshbay_hub/static/vendor'); + +const { createKeyring } = require(path.join(CLIENT, 'keyring.js')); +const { transcriptFor } = require(path.join(CLIENT, 'transcripts.js')); +const { wasmArgon2 } = require(path.join(CLIENT, 'argon2-wasm.js')); + +const b64 = (b) => Buffer.from(b).toString('base64'); +const hex = (b) => Buffer.from(b).toString('hex'); +const hkdf = (ikm, info) => Buffer.from( + crypto.hkdfSync('sha256', ikm, Buffer.alloc(0), Buffer.from(info), 32)); + +// Fixed inputs. Invented values only. +const NOW = 1790000000; // a fixed "now" for transcript timestamps +const INPUT = { + username: 'vector-user', + userId: '0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0', + password: 'a passphrase used only for vectors', + pepperB64: b64(Buffer.alloc(32, 7)), + pepperVersion: 3, + nodePk: b64(Buffer.alloc(32, 0x11)), + otherNodePk: b64(Buffer.alloc(32, 0x22)), + groupId: 'grp_vector-01', + mnemonic: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', + edSeedHex: '9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60', + xSeedHex: '77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a', + peerXPubHex: 'de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f', + fixedNonceHex: '000102030405060708090a0b', + legacyNonceHex: '0b0a09080706050403020100', + now: NOW, +}; + +const pkcs8 = (prefixHex, seedHex) => + Buffer.concat([Buffer.from(prefixHex, 'hex'), Buffer.from(seedHex, 'hex')]); +const ED_PKCS8 = pkcs8('302e020100300506032b657004220420', INPUT.edSeedHex); +const X_PKCS8 = pkcs8('302e020100300506032b656e04220420', INPUT.xSeedHex); + +function withFixedRandom(bytesHex, fn) { + const real = crypto.randomBytes; + crypto.randomBytes = (n) => { + const b = Buffer.from(bytesHex, 'hex'); + if (b.length !== n) throw new Error(`fixed random of ${b.length}, asked ${n}`); + return b; + }; + try { return fn(); } finally { crypto.randomBytes = real; } +} + +function withNow(seconds, fn) { + const real = Date.now; + Date.now = () => seconds * 1000; + try { return fn(); } finally { Date.now = real; } +} + +(async () => { + const argon2 = wasmArgon2(VENDOR); + let store = {}; + const ring = createKeyring({ + argon2, + load: () => JSON.parse(JSON.stringify(store)), + save: (o) => { store = JSON.parse(JSON.stringify(o)); }, + }); + + // 1. KDF chain. + const salt = crypto.createHash('sha256') + .update(`meshbay:bundle:v2:${INPUT.username}`).digest().subarray(0, 16); + const ARGON2 = { memory: 131072, passes: 3, parallelism: 1, tagLength: 32 }; + await ring.deriveSession({ + password: INPUT.password, username: INPUT.username, userId: INPUT.userId, + pepperB64: INPUT.pepperB64, pepperVersion: INPUT.pepperVersion, + }); + const m = Buffer.from(store.masters[INPUT.userId].m, 'base64'); + const a = Buffer.from(store.masters[INPUT.userId].legacy, 'base64'); + const kdf = { + argon2_params: ARGON2, + salt_hex: hex(salt), + argon2_hex: hex(a), + master_hex: hex(m), + master_fingerprint: ring.currentFingerprint(INPUT.userId), + node_key_hex: hex(hkdf(m, `meshbay:bundle:v3|node|${INPUT.nodePk}`)), + playlist_key_b64: ring.playlistKey(INPUT.userId), + recovery_key_hex: null, // filled below + }; + + // 2. A fixed identity, placed in the store as mint() would leave it. + store.identities[INPUT.userId] = { + [INPUT.nodePk]: { ed: b64(ED_PKCS8), x: b64(X_PKCS8), sealedWith: null }, + }; + const identity = { + ed_pkcs8_b64: b64(ED_PKCS8), + x_pkcs8_b64: b64(X_PKCS8), + public: ring.identity(INPUT.userId, INPUT.nodePk), + }; + + // 3. Bundles. + const fixed = withFixedRandom(INPUT.fixedNonceHex, + () => ring.sealBundle(INPUT.userId, INPUT.nodePk)); + const recovery = withFixedRandom(INPUT.fixedNonceHex, + () => ring.sealRecovery(INPUT.userId, INPUT.nodePk, INPUT.mnemonic, INPUT.username)); + + // The recovery key, re-derived the way keyring.js does (fromMnemonic + hkdf). + const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + let bits = 0; let value = 0; const raw = []; + for (const ch of INPUT.mnemonic.replace(/[^A-Za-z2-7]/g, '').toUpperCase()) { + value = (value << 5) | B32.indexOf(ch); bits += 5; + if (bits >= 8) { raw.push((value >>> (bits - 8)) & 0xff); bits -= 8; } + } + kdf.mnemonic_bytes_hex = hex(Buffer.from(raw.slice(0, 32))); + kdf.recovery_key_hex = hex(hkdf(Buffer.from(raw.slice(0, 32)), + `meshbay:recovery:v1:${INPUT.username}`)); + + // MBK2 (TRANSITIONAL): "MBK2" ‖ nonce ‖ AES-GCM(JSON) under the Argon2 key, no AAD. + const legacyNonce = Buffer.from(INPUT.legacyNonceHex, 'hex'); + const c = crypto.createCipheriv('aes-256-gcm', a, legacyNonce); + const plain = JSON.stringify({ skEd: b64(ED_PKCS8), skX: b64(X_PKCS8) }); + const legacy = b64(Buffer.concat([ + Buffer.from('MBK2'), legacyNonce, c.update(plain), c.final(), c.getAuthTag()])); + + // Each must open through the shipped keyring, into a fresh store. + const check = async (label, bundleEnc, extra = {}) => { + let s2 = {}; + const r2 = createKeyring({ argon2, load: () => JSON.parse(JSON.stringify(s2)), + save: (o) => { s2 = JSON.parse(JSON.stringify(o)); } }); + await r2.deriveSession({ password: INPUT.password, username: INPUT.username, + userId: INPUT.userId, pepperB64: INPUT.pepperB64, pepperVersion: INPUT.pepperVersion }); + const pub = r2.openBundle(INPUT.userId, INPUT.nodePk, { bundleEnc, ...extra }); + if (pub.pkEdB64 !== identity.public.pkEdB64 || pub.pkXB64 !== identity.public.pkXB64) { + throw new Error(`${label} opened to another identity`); + } + return true; + }; + await check('fixed', fixed.bundle); + await check('legacy', legacy); + // The recovery copy, through the fallback: a passphrase copy that does not open. + await check('recovery', b64(Buffer.concat([Buffer.from('MBK3'), Buffer.alloc(30, 1)])), { + recoveryEnc: recovery, recoveryMnemonic: INPUT.mnemonic, username: INPUT.username }); + + const bundles = { + sealed_json_plaintext: plain, + aad: `meshbay:bundle:v3|${INPUT.userId}|${INPUT.nodePk}`, + fixed_nonce_bundle_b64: fixed.bundle, + fixed_nonce_fingerprint: fixed.fingerprint, + recovery_fixed_nonce_b64: recovery, + legacy_mbk2_b64: legacy, + }; + + // 4. Agreement. + const agreement = { + peer_x_pub_b64: b64(Buffer.from(INPUT.peerXPubHex, 'hex')), + shared_b64: ring.shared(INPUT.userId, INPUT.nodePk, b64(Buffer.from(INPUT.peerXPubHex, 'hex'))), + }; + + // 5. Transcripts: every kind, then refusals. + const ctx = { userId: INPUT.userId, nodePk: INPUT.nodePk, ...identity.public }; + delete ctx.sealedWith; + const nonceNode = b64(Buffer.alloc(32, 0x33)); + const kinds = { + join: { nodePk: INPUT.nodePk, groupId: INPUT.groupId, userId: INPUT.userId, nonceNode, ts: NOW }, + device_hello: { nodePk: INPUT.nodePk, groupId: INPUT.groupId, userId: INPUT.userId, nonceNode, ts: NOW - 30 }, + device_request: { nodePk: INPUT.nodePk, userId: INPUT.userId, + codeHash: 'ab'.repeat(32), nonceNode, ts: NOW + 30 }, + device_add: { nodePk: INPUT.nodePk, userId: INPUT.userId, + pkEd: b64(Buffer.alloc(32, 0x44)), pkX: b64(Buffer.alloc(32, 0x55)), nonceNode, ts: NOW }, + device_revoke: { nodePk: INPUT.nodePk, userId: INPUT.userId, + pkEd: b64(Buffer.alloc(32, 0x44)), nonceNode, ts: NOW }, + chat: { groupId: INPUT.groupId, epoch: 4, nonce: b64(Buffer.alloc(24, 0x66)), + ct: b64(Buffer.from('ciphertext of a chat line, opaque here')) }, + admin: { op: 'apps_enabled', nodePk: INPUT.nodePk, groupId: INPUT.groupId, + subject: '{"apps":["chat","videos"],"note":"é ü 漢"}', + nonce: b64(Buffer.alloc(16, 0x77)), ts: NOW }, + }; + const transcripts = {}; + for (const [kind, fields] of Object.entries(kinds)) { + const bytes = withNow(NOW, () => transcriptFor(kind, fields, ctx)); + const sig = withNow(NOW, () => ring.signAs(INPUT.userId, INPUT.nodePk, kind, fields)); + transcripts[kind] = { fields, transcript_hex: hex(bytes), signature_b64: sig }; + } + + const refusals = []; + const refuse = (label, kind, fields) => { + try { + withNow(NOW, () => transcriptFor(kind, fields, ctx)); + throw new Error(`vector "${label}" was NOT refused by transcripts.js`); + } catch (e) { + if (/NOT refused/.test(e.message)) throw e; + refusals.push({ label, kind, fields, error: e.message }); + } + }; + refuse('another node', 'join', { ...kinds.join, nodePk: INPUT.otherNodePk }); + refuse('another account', 'join', { ...kinds.join, userId: '00000000-0000-4000-8000-000000000000' }); + refuse('stale timestamp', 'join', { ...kinds.join, ts: NOW - 601 }); + refuse('future timestamp', 'device_hello', { ...kinds.device_hello, ts: NOW + 601 }); + refuse('fractional timestamp', 'join', { ...kinds.join, ts: NOW + 0.5 }); + refuse('bad group id', 'join', { ...kinds.join, groupId: 'a/b' }); + refuse('short node nonce', 'join', { ...kinds.join, nonceNode: b64(Buffer.alloc(15)) }); + refuse('not base64', 'join', { ...kinds.join, nonceNode: 'not*base64' }); + refuse('bad request hash', 'device_request', { ...kinds.device_request, codeHash: 'AB'.repeat(32) }); + refuse('device key wrong length', 'device_add', { ...kinds.device_add, pkEd: b64(Buffer.alloc(31)) }); + refuse('negative epoch', 'chat', { ...kinds.chat, epoch: -1 }); + refuse('chat nonce too short', 'chat', { ...kinds.chat, nonce: b64(Buffer.alloc(11)) }); + refuse('bad op', 'admin', { ...kinds.admin, op: 'Drop-Table' }); + refuse('an op that widens sharing (gone from MNP 6.0)', 'admin', { ...kinds.admin, op: 'root_add' }); + refuse('a well-formed op not on the list', 'admin', { ...kinds.admin, op: 'set_app_setting' }); + refuse('subject too long', 'admin', { ...kinds.admin, subject: 'x'.repeat(16385) }); + refuse('unknown kind', 'sign_anything', { bytes: 'AAAA' }); + + const out = { + _about: 'Generated by gen_keyring_vectors.js from meshbay-client keyring.js and ' + + 'transcripts.js with the vendored Argon2. Every implementation of the bundle ' + + 'format and the transcripts must reproduce every field.', + input: INPUT, + kdf, identity, bundles, agreement, transcripts, refusals, + }; + process.stdout.write(JSON.stringify(out, null, 2) + '\n'); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/packages/meshbay-hub/tests/vectors/keyring.json b/packages/meshbay-hub/tests/vectors/keyring.json new file mode 100644 index 0000000..84ecff0 --- /dev/null +++ b/packages/meshbay-hub/tests/vectors/keyring.json @@ -0,0 +1,342 @@ +{ + "_about": "Generated by gen_keyring_vectors.js from meshbay-client keyring.js and transcripts.js with the vendored Argon2. Every implementation of the bundle format and the transcripts must reproduce every field.", + "input": { + "username": "vector-user", + "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", + "password": "a passphrase used only for vectors", + "pepperB64": "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=", + "pepperVersion": 3, + "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=", + "otherNodePk": "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI=", + "groupId": "grp_vector-01", + "mnemonic": "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", + "edSeedHex": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + "xSeedHex": "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a", + "peerXPubHex": "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f", + "fixedNonceHex": "000102030405060708090a0b", + "legacyNonceHex": "0b0a09080706050403020100", + "now": 1790000000 + }, + "kdf": { + "argon2_params": { + "memory": 131072, + "passes": 3, + "parallelism": 1, + "tagLength": 32 + }, + "salt_hex": "2244e8b97822de2b9e210e22301700ff", + "argon2_hex": "95e8531f721421b13bba6e6585de932654d26e5428793f8734b4e7da74b14a7c", + "master_hex": "ecb972b6454304630cecffe115a9f679905ce98457c741f7d534f575322b1cef", + "master_fingerprint": "292fe17f616a1ef6", + "node_key_hex": "948aa161c470cd16e944cbc1dfc1a375a76a17761ad80014423d64cf2401bd2f", + "playlist_key_b64": "Gyd4KTER2IS4dHieFp9DkiHExSP3hjLT/SMXF0TBUno=", + "recovery_key_hex": "612b9cf5cc507ba1483555d7748dc7e20734374994baa092fca605df3600a8c3", + "mnemonic_bytes_hex": "00443214c74254b635cf84653a56d7c675be77df00443214c74254b635cf8465" + }, + "identity": { + "ed_pkcs8_b64": "MC4CAQAwBQYDK2VwBCIEIJ1hsZ3v/VpguoRK9JLsLMREScVpezJpGXA7rAMcrn9g", + "x_pkcs8_b64": "MC4CAQAwBQYDK2VuBCIEIHcHbQpzGKV9PBbBclGyZkXfTC+H68CZKrF3+6UduSwq", + "public": { + "pkEdB64": "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=", + "pkXB64": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=", + "sealedWith": null + } + }, + "bundles": { + "sealed_json_plaintext": "{\"skEd\":\"MC4CAQAwBQYDK2VwBCIEIJ1hsZ3v/VpguoRK9JLsLMREScVpezJpGXA7rAMcrn9g\",\"skX\":\"MC4CAQAwBQYDK2VuBCIEIHcHbQpzGKV9PBbBclGyZkXfTC+H68CZKrF3+6UduSwq\"}", + "aad": "meshbay:bundle:v3|0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0|ERERERERERERERERERERERERERERERERERERERERERE=", + "fixed_nonce_bundle_b64": "TUJLMwMAAQIDBAUGBwgJCgu/0e64MEzT13AuLRE9rV3gw4cF1jPwKvIl8h0OsNnW1UsbNLdQcWg+SVVgNSOfR2SneoWBbieI1Gypb/9osJ7gkWHfOcvlMqa0AdjoS3ww/Tf0/hL/LLB5B04w1oujnfsvhCL6zaPZtjyPZ5PUc6Ks7AqXmJZtuqSN33qEjZoQH9xQKNb6LUVJrWTjSUl2NZ02Y1mIVOd6Y9Af421u/vn41FIxwg==", + "fixed_nonce_fingerprint": "292fe17f616a1ef6", + "recovery_fixed_nonce_b64": "TUJLMwAAAQIDBAUGBwgJCgvqVgf86f7WSRXeERiv5CqFqgsFVR5vXLcYOKVOWblJErRq4D3pWWM1UMSyWOEjv0Q88dukHmm/ncpvUV24rtrBwQ3a2o0O3Zu4QQxKispflVoCuYIakbwh8dSF5Qh7Pgdat2TpWO0/OekZpvXv6kUdiMFviB1qKSkzE0od+qgdh0Wokqb5cvD9Mxr5CQkrNlMVkGs+O73ITEIUkDlDHNNSr+2GMg==", + "legacy_mbk2_b64": "TUJLMgsKCQgHBgUEAwIBAOAz0yDaxedAe3ervmsyggv0fyDeHyTeMdfuBhO3mEHtfub86kZFyk6RG+SUuZHd2uReHxdA+6sZhexlTb32eK7Fg2MfsAhXlVdO6p0JS+LT2Dx4IV8KV7f8En1n5RE7ppy2QtMjqKzi56nzY0uihtNDmgnaQgas4anOMFJDzONfR6ek8f5DQWAKxDc/AKb8jf+DnhOY2F3J5NNzl4swIXe60FND" + }, + "agreement": { + "peer_x_pub_b64": "3p7bfXt9wbTTW2HC7OQ1Nz+DQ8hbeGdNrfx+FG+IK08=", + "shared_b64": "Sl2dW6TOLeFyjjv0gDUPJeB+IclH0Z4zdvCbPB4WF0I=" + }, + "transcripts": { + "join": { + "fields": { + "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=", + "groupId": "grp_vector-01", + "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", + "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=", + "ts": 1790000000 + }, + "transcript_hex": "6d6573686261793a6a6f696e3a76310000002c455245524552455245524552455245524552455245524552455245524552455245524552455245524552453d0000000d6772705f766563746f722d30310000002430663165326433632d346235612d343936382d383737362d6135623463336432653166300000002c3131715941594b7843726656532f3754795751484f6737686376506170694d6c727749616150634855526f3d0000002c6853447743596b777031523069333363744437335767322f4f67306d4f427230363653706a717162546d6f3d0000002033333333333333333333333333333333333333333333333333333333333333330000000a31373930303030303030", + "signature_b64": "PjmA9stj7pqTWdaHGgNQjiouiC3V6YktCa7ebXy7aZVUIeeoKT5nf7hqhkkNV0hUUvYZoWsu9rD14TwVZI6pBw==" + }, + "device_hello": { + "fields": { + "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=", + "groupId": "grp_vector-01", + "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", + "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=", + "ts": 1789999970 + }, + "transcript_hex": "6d6573686261793a6465766963655f68656c6c6f3a76310000002c455245524552455245524552455245524552455245524552455245524552455245524552455245524552453d0000000d6772705f766563746f722d30310000002430663165326433632d346235612d343936382d383737362d6135623463336432653166300000002c3131715941594b7843726656532f3754795751484f6737686376506170694d6c727749616150634855526f3d0000002033333333333333333333333333333333333333333333333333333333333333330000000a31373839393939393730", + "signature_b64": "IVOAHoLco3TvqaRdN4lvv8YGmE4PQu54njs23luu/j51vOdXmkbAVS9HYBrSmPhVPxc9UW5B/KY9vdzHYnMZBg==" + }, + "device_request": { + "fields": { + "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=", + "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", + "codeHash": "abababababababababababababababababababababababababababababababab", + "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=", + "ts": 1790000030 + }, + "transcript_hex": "6d6573686261793a6465766963655f7265713a76310000002c455245524552455245524552455245524552455245524552455245524552455245524552455245524552453d0000002430663165326433632d346235612d343936382d383737362d6135623463336432653166300000002c3131715941594b7843726656532f3754795751484f6737686376506170694d6c727749616150634855526f3d0000002c6853447743596b777031523069333363744437335767322f4f67306d4f427230363653706a717162546d6f3d00000040616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261620000002033333333333333333333333333333333333333333333333333333333333333330000000a31373930303030303330", + "signature_b64": "wi1DvdWqKYSvrmweN8U8E/IGe5akrEO1nXClVjBoKsr2geksMrxnOrkAFSO2Ecf7js4hT2OxoYgVBzjiRdV0AA==" + }, + "device_add": { + "fields": { + "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=", + "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", + "pkEd": "REREREREREREREREREREREREREREREREREREREREREQ=", + "pkX": "VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU=", + "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=", + "ts": 1790000000 + }, + "transcript_hex": "6d6573686261793a6465766963655f6164643a76310000002c455245524552455245524552455245524552455245524552455245524552455245524552455245524552453d0000002430663165326433632d346235612d343936382d383737362d6135623463336432653166300000002c524552455245524552455245524552455245524552455245524552455245524552455245524552455245513d0000002c565656565656565656565656565656565656565656565656565656565656565656565656565656565656553d0000002033333333333333333333333333333333333333333333333333333333333333330000000a31373930303030303030", + "signature_b64": "D+tRe/2fbscnA3wdhHsnEfUCu0U/JszfIhFvYC8lEy8AqiD7osn3GWotQIMGSO3FoQz62WJHZsAdUaelgQJUCg==" + }, + "device_revoke": { + "fields": { + "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=", + "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", + "pkEd": "REREREREREREREREREREREREREREREREREREREREREQ=", + "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=", + "ts": 1790000000 + }, + "transcript_hex": "6d6573686261793a6465766963655f7265766f6b653a76310000002c455245524552455245524552455245524552455245524552455245524552455245524552455245524552453d0000002430663165326433632d346235612d343936382d383737362d6135623463336432653166300000002c524552455245524552455245524552455245524552455245524552455245524552455245524552455245513d0000002033333333333333333333333333333333333333333333333333333333333333330000000a31373930303030303030", + "signature_b64": "NmVrQU3Fb0rms+wYQI9TIdc7Ry0H/G9CLU5stNNnGe6/WU29bSU8V81FXk6ze5dOfi0ezz4YmWrem7cRAR5MBg==" + }, + "chat": { + "fields": { + "groupId": "grp_vector-01", + "epoch": 4, + "nonce": "ZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZm", + "ct": "Y2lwaGVydGV4dCBvZiBhIGNoYXQgbGluZSwgb3BhcXVlIGhlcmU=" + }, + "transcript_hex": "6d6573686261793a636861743a76310000000d6772705f766563746f722d3031000000013400000020d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a000000186666666666666666666666666666666666666666666666660000002663697068657274657874206f6620612063686174206c696e652c206f70617175652068657265", + "signature_b64": "Ogv5d2lhr0ABJacfp0XEbUH7jO8lIplbCZLj1jL5bt8kHyPvKpRDruZkCg+vo9sOFWjMuAlIzQALuS6zE62JBA==" + }, + "admin": { + "fields": { + "op": "apps_enabled", + "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=", + "groupId": "grp_vector-01", + "subject": "{\"apps\":[\"chat\",\"videos\"],\"note\":\"é ü 漢\"}", + "nonce": "d3d3d3d3d3d3d3d3d3d3dw==", + "ts": 1790000000 + }, + "transcript_hex": "6d6573686261793a61646d696e3a76310000000c617070735f656e61626c65640000002c455245524552455245524552455245524552455245524552455245524552455245524552455245524552453d0000000d6772705f766563746f722d30310000002d7b2261707073223a5b2263686174222c22766964656f73225d2c226e6f7465223a22c3a920c3bc20e6bca2227d00000010777777777777777777777777777777770000000a31373930303030303030", + "signature_b64": "ocx6tu6SKu3U8mEQUWhNNIZieGDfYquLdtBfez0vqb2EkfFzPfD1yraP3OhlvZYTIZfnWfzJ1R9y/sRN6vZgAg==" + } + }, + "refusals": [ + { + "label": "another node", + "kind": "join", + "fields": { + "nodePk": "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI=", + "groupId": "grp_vector-01", + "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", + "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=", + "ts": 1790000000 + }, + "error": "Refused: another node" + }, + { + "label": "another account", + "kind": "join", + "fields": { + "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=", + "groupId": "grp_vector-01", + "userId": "00000000-0000-4000-8000-000000000000", + "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=", + "ts": 1790000000 + }, + "error": "Refused: another account" + }, + { + "label": "stale timestamp", + "kind": "join", + "fields": { + "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=", + "groupId": "grp_vector-01", + "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", + "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=", + "ts": 1789999399 + }, + "error": "Refused: the timestamp is not now" + }, + { + "label": "future timestamp", + "kind": "device_hello", + "fields": { + "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=", + "groupId": "grp_vector-01", + "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", + "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=", + "ts": 1790000601 + }, + "error": "Refused: the timestamp is not now" + }, + { + "label": "fractional timestamp", + "kind": "join", + "fields": { + "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=", + "groupId": "grp_vector-01", + "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", + "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=", + "ts": 1790000000.5 + }, + "error": "Refused: the timestamp is not now" + }, + { + "label": "bad group id", + "kind": "join", + "fields": { + "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=", + "groupId": "a/b", + "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", + "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=", + "ts": 1790000000 + }, + "error": "Refused: not a group id" + }, + { + "label": "short node nonce", + "kind": "join", + "fields": { + "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=", + "groupId": "grp_vector-01", + "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", + "nonceNode": "AAAAAAAAAAAAAAAAAAAA", + "ts": 1790000000 + }, + "error": "Refused: the node nonce has the wrong length" + }, + { + "label": "not base64", + "kind": "join", + "fields": { + "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=", + "groupId": "grp_vector-01", + "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", + "nonceNode": "not*base64", + "ts": 1790000000 + }, + "error": "Refused: the node nonce is not base64" + }, + { + "label": "bad request hash", + "kind": "device_request", + "fields": { + "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=", + "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", + "codeHash": "ABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABAB", + "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=", + "ts": 1790000030 + }, + "error": "Refused: not a request hash" + }, + { + "label": "device key wrong length", + "kind": "device_add", + "fields": { + "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=", + "userId": "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", + "pkEd": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "pkX": "VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU=", + "nonceNode": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=", + "ts": 1790000000 + }, + "error": "Refused: the device key has the wrong length" + }, + { + "label": "negative epoch", + "kind": "chat", + "fields": { + "groupId": "grp_vector-01", + "epoch": -1, + "nonce": "ZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZm", + "ct": "Y2lwaGVydGV4dCBvZiBhIGNoYXQgbGluZSwgb3BhcXVlIGhlcmU=" + }, + "error": "Refused: not an epoch" + }, + { + "label": "chat nonce too short", + "kind": "chat", + "fields": { + "groupId": "grp_vector-01", + "epoch": 4, + "nonce": "AAAAAAAAAAAAAAA=", + "ct": "Y2lwaGVydGV4dCBvZiBhIGNoYXQgbGluZSwgb3BhcXVlIGhlcmU=" + }, + "error": "Refused: the message nonce has the wrong length" + }, + { + "label": "bad op", + "kind": "admin", + "fields": { + "op": "Drop-Table", + "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=", + "groupId": "grp_vector-01", + "subject": "{\"apps\":[\"chat\",\"videos\"],\"note\":\"é ü 漢\"}", + "nonce": "d3d3d3d3d3d3d3d3d3d3dw==", + "ts": 1790000000 + }, + "error": "Refused: not an operation" + }, + { + "label": "an op that widens sharing (gone from MNP 6.0)", + "kind": "admin", + "fields": { + "op": "root_add", + "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=", + "groupId": "grp_vector-01", + "subject": "{\"apps\":[\"chat\",\"videos\"],\"note\":\"é ü 漢\"}", + "nonce": "d3d3d3d3d3d3d3d3d3d3dw==", + "ts": 1790000000 + }, + "error": "Refused: not an operation" + }, + { + "label": "a well-formed op not on the list", + "kind": "admin", + "fields": { + "op": "set_app_setting", + "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=", + "groupId": "grp_vector-01", + "subject": "{\"apps\":[\"chat\",\"videos\"],\"note\":\"é ü 漢\"}", + "nonce": "d3d3d3d3d3d3d3d3d3d3dw==", + "ts": 1790000000 + }, + "error": "Refused: not an operation" + }, + { + "label": "subject too long", + "kind": "admin", + "fields": { + "op": "apps_enabled", + "nodePk": "ERERERERERERERERERERERERERERERERERERERERERE=", + "groupId": "grp_vector-01", + "subject": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "nonce": "d3d3d3d3d3d3d3d3d3d3dw==", + "ts": 1790000000 + }, + "error": "Refused: the subject is too long" + }, + { + "label": "unknown kind", + "kind": "sign_anything", + "fields": { + "bytes": "AAAA" + }, + "error": "Refused: nothing is signed as \"sign_anything\"" + } + ] +} |