aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/harness/window_leak.mjs7
-rw-r--r--packages/meshbay-hub/tests/test_android_cast.py133
-rw-r--r--packages/meshbay-hub/tests/test_android_downloads.py90
-rw-r--r--packages/meshbay-hub/tests/test_android_keys.py74
-rw-r--r--packages/meshbay-hub/tests/test_android_shell.py223
-rw-r--r--packages/meshbay-hub/tests/test_cast_discovery.py152
-rw-r--r--packages/meshbay-hub/tests/test_desktop_keyring.py22
-rw-r--r--packages/meshbay-hub/tests/test_desktop_shell.py11
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py53
-rw-r--r--packages/meshbay-hub/tests/test_keyring_vectors.py142
-rw-r--r--packages/meshbay-hub/tests/test_upload_controls_hidden.py30
-rw-r--r--packages/meshbay-hub/tests/test_video_audio_track.py4
-rw-r--r--packages/meshbay-hub/tests/test_video_seek.py65
-rw-r--r--packages/meshbay-hub/tests/vectors/gen_keyring_vectors.js232
-rw-r--r--packages/meshbay-hub/tests/vectors/keyring.json342
15 files changed, 1565 insertions, 15 deletions
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\""
+ }
+ ]
+}