diff options
103 files changed, 6940 insertions, 105 deletions
@@ -28,7 +28,9 @@ meshbay/ ├── packages/ │ ├── meshbay-common/ # Shared crypto + protocol — python3-meshbay-common RPM │ ├── meshbay-hub/ # Hub server (FastAPI + PostgreSQL) — meshbay-hub RPM -│ └── meshbay-node/ # Node daemon + local UI — meshbay-node RPM +│ ├── meshbay-node/ # Node daemon + local UI — meshbay-node RPM +│ ├── meshbay-client/ # Desktop client (Electron) +│ └── meshbay-android/ # Android client (WebView shell, Kotlin) — README.md ├── poc/ # POC spike scripts (reference, not production) ├── docs/ # Architecture drafts and POC plans ├── packaging/ # RPM spec files, DEB control files, systemd units @@ -882,6 +884,43 @@ do. Read them before writing anything that touches the same mechanism. message later by `update_groups`, which assigned its list verbatim. A limit enforced on one path is not enforced +- **Chromium freezes a hidden page sixty seconds after hiding it**, in an + Android WebView as in a tab, and nothing about the *process* changes that: a + media-playback foreground service, a partial wake lock, a Wi-Fi lock and + `setRendererPriorityPolicy` all failed to keep a casting page alive with the + screen off (spike, measured by a heartbeat and the page's own `freeze` + event). What works is the shell telling the WebView its window is still + visible (`ShellWebView.keepVisible`) — set while a cast runs and only then. + Audible audio also exempts a page; a phone humming in the room is not a fix + +- **A first chunk is not a header.** The relay took the page's first decrypted + chunk as the stream's header. On a real film that chunk was by turns 64 KB + (header plus film), 28 bytes (the ftyp alone, the moov in the next push), or + the header exactly — depending on when the node read ffmpeg's output — so the + same film cast on the third try and not the first. The header is everything + before the first moof; boxes, not chunks, are the unit + +- **A seek's first segments came during its own landing and were thrown away.** + `reinitAt` waits on the SourceBuffer; the new stream's first segments arrived + in that gap and the `awaitingInit` flag dropped them as the old film's. The + local player never showed it — its SourceBuffer kept the header from the + start of the film — and a cast relay restarted at that landing received a + stream with no header. Ordering, not the flag, says which stream a segment + belongs to: everything after `stream_init` is held and replayed + +- **A drop threshold sized for small chunks drops whole segments.** The relay + dropped a fragment once 8 MB waited for a receiver; at a film's bitrate one + fragment is 5–10 MB, so a receiver simply reading at playback speed lost + fragments and the picture froze for each. Nothing logged it until a drop + counter was added. The lead now waits in a spool file + +- **When a receiver stops answering, prove which leg failed before reading code.** + A first cast failed every time and two code fixes changed nothing; the cast + framework's own log said `onSocketConnectionFailed … IO Error` to the + receiver's port 8009, and the phone could not ping the receiver while this + machine could. The receiver was half-crashed; a power cycle fixed it. A + machine that reaches the receiver proves nothing about the phone + **Corrections that used to live here** — `punch_nat()` is not a traversal stack, the node keystore's Argon2id parameters, what group chat actually uses, and what is sealed on the wire — are now design statements in `docs/MESHBAY_DESIGN.md` @@ -989,6 +1028,20 @@ here are kept only where they are a rule about *editing* the code. | Node page | `node-page.js` | §6.7 | | i18n | `i18n.js`, `locales/*.js` | `en.js` is the source; **ten catalogues, and a new key goes in all ten** | +### Android (`packages/meshbay-android/`) + +Built with `./gradlew assembleDebug` / `assembleRelease` (JDK 17+, an Android +SDK); `./gradlew testDebugUnitTest` runs the JVM tests, which pytest also runs +when `ANDROID_HOME` is set (`test_android_shell.py`). + +| Need | File | Note | +|---|---|---| +| The shell, the asset loader, the CSP | `MainActivity.kt`, `shell/UiAssets.kt` | the CSP is `main.js`'s, held equal by `test_android_shell.py` | +| The bridge | `assets/bridge/meshbay-bridge.js` (the preload's counterpart), `bridge/Bridge.kt`, `Channels.kt`, `KeyChannels.kt` | **absent, not refusing**, for what a phone lacks; every channel the shim names is the preload's | +| Keys | `keys/Kdf.kt`, `Keyring.kt`, `Transcripts.kt`, `SecretStore.kt`, `DeviceKey.kt` | **held to `meshbay-hub/tests/vectors/keyring.json`**; regenerate it with `gen_keyring_vectors.js` only for a deliberate format change | +| Downloads | `save/SaveSinks.kt`, `SaveNames.kt` | `OPENABLE` is `downloads.js`'s, compared by `test_android_downloads.py` | +| Casting | `cast/CastRelay.kt`, `CastControl.kt`, `CastChannels.kt`, `CastService.kt`, `shell/ShellWebView.kt` | `MeshBayCast` in logcat says what the receiver and the relay did | + ### Test harnesses that drive the real thing | Need | File | diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 189dbc5..525c5c3 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -17,7 +17,7 @@ > it. §13 is the register of those labels. > > Wire versions at the time of writing: **MNP 6.0** (oldest peer accepted 4.0), -> **MHP 0.1**, packages **0.17.0**. The normative source for the wire format is +> **MHP 0.1**, packages **0.18.0**. The normative source for the wire format is > `MESHBAY_NODE_PROTOCOL.md`; this document states the design the protocol > serves, not its byte layout. @@ -134,76 +134,67 @@ paths, no filenames**. --- -## 2. Trust model +## 2. Who you trust -### 2.1 Adversaries +MeshBay lets you share things with the people you choose, and keeps everyone else +out. In plain terms, here is who can see your group — and the one caveat worth +knowing. -Every claim below is written against one of these, and they are the only ones the -document uses: +### 2.1 Inside your group -| Adversary | What they can do | -|---|---| -| **Passive hub** | Read everything the hub legitimately stores and relays | -| **Active hub** | Also lie: forge tokens, invent accounts, substitute values it publishes, ship modified client code to a browser | -| **Malicious node operator** | Read and alter everything on their own machine, including the plaintext files they host | -| **Malicious group member** | Everything a member may do, plus anything the protocol fails to refuse | -| **Network attacker** | Observe and tamper with traffic between any two parties | -| **Local attacker** | Reach loopback services and files on a client or node machine | -| **Registered hub user with no membership** | Reach every hub endpoint that does not check membership | -| **Federated peer hub** | Push directory rows and revocations over MHP | +A group lives on a node, run by its operator. Everyone in the group — the operator +and the members — shares its files, its index and its chat. That is what a group +*is*: the people you decided to let in, the same as a shared folder or a team +workspace. They are the people you trust, and they are the only ones who can read +what you put there. -### 2.2 Security claims +Two things follow from that by design — they are the shape of a shared space, not +gaps in it: -| Claim | Passive hub | Active hub | Malicious node operator | Malicious member | Network attacker | -|---|---|---|---|---|---| -| Data never transits the hub | ✅ | ✅ | — | — | ✅ | -| File content is unreadable | ✅ | ❌ **T3** (browser) · ✅ native | ❌ by design — the operator hosts the files | ❌ members share the group key | ✅ | -| The file index is unreadable | ✅ | ❌ T3 · ✅ native | ❌ | ❌ | ✅ | -| Chat content is unreadable | ✅ | ❌ T3 · ✅ native | ❌ — the operator is a member | ❌ | ✅ | -| Chat is unreadable **from a copy of the node's storage that lacks its unlock key** — not from a whole disk by default (§4.5) | ✅ | ✅ | — the operator holds the unlock key | ✅ | ✅ | -| Content cannot be modified | ✅ | ✅ | ❌ by design | ✅ | ✅ | -| The node cannot be impersonated | ✅ | ✅ | — | ✅ | ✅ | -| Client code integrity | ❌ **T3, accepted** (browser) · ✅ ships in the package (native) | ❌ T3 · ⚠️ native: **detectable, not prevented** | ✅ | ✅ | ✅ | -| The hub cannot obtain the group key | ✅ | ✅ **except** in an open-join group, where it can join legitimately (§7.3), and for an invitation the inviter asked the hub to mail, whose code it then holds (§3.4) | — | — | ✅ | -| Node content authority | ✅ | ✅ | ✅ sovereign | ✅ | ✅ | -| Devices cannot be added by the hub | ✅ | ✅ — the hub holds no user key and cannot countersign | ⚠️ a node adds a device only to itself, where it already reads everything | ✅ | ✅ | -| Chat senders are authenticated to each other | ✅ | ✅ | ⚠️ only for accounts the reader has already seen (§3.3) | ✅ | ✅ | -| Keypair bundles (**C4**) | ✅ it holds the pepper and no bundle | ⚠️ it can fetch a bundle with a token it mints and holds the pepper: an offline passphrase search, as T3 already concedes for browsers · none to fetch for an account without browser access | ✅ no offline search: the bundle does not open without the hub's pepper — only sign-in attempts, bounded and audited · none on disk for an account without browser access | — | — | -| Deleting your account erases you | ✅ hub-side | ✅ hub-side | ❌ files, pinned identity and bundle stay on the node (§7.7) | — | — | -| Your identity keys stay yours | ✅ | ⚠️ as the row above | ✅ the bundle they hold does not open without the pepper, and a leaked bundle key opens **that node's** bundle only | ✅ | ✅ | +- Your operator hosts your files, so they can change what they host. A node's + content is the operator's to keep and to serve (§7). +- Leaving a group removes you on the hub, but copies already on a node stay with + its operator (§7.7) — as with anything you have handed to someone in person. -### 2.3 What the project must not claim +### 2.2 Everyone else sees nothing -Three sentences are forbidden, each for a deliberate reason: +Outside your group nobody can read what you share — not the hub that connects you, +not anyone watching the network, not the operator of any *other* group: -- **"Everything is encrypted and unreadable by other parties, even the hub."** - A hub that ships the code can lift keys from the page regardless of protocol - design (**T3**). That is an artifact-level attack, not a silent directory lie, - and it is removed for native clients — not for browsers. -- **"A native client makes the hub untrusted."** It converts an undetectable, - per-request, per-user attack into a persistent artifact that can be hashed and - compared. That value is realised by reproducible builds and published hashes, - not by the packaging format. A build signed with a key the hub operator holds - *relocates* trust; it does not remove it. -- **"C4 is closed."** It is closed for an account whose identities the desktop - application keeps (browser access off, §3.7): nothing of them is on any node. - For an account with browser access the bundle is still on each node, sealed - under the passphrase **and** a pepper the hub holds — no operator can search it - offline, but an active hub can, and that is T3's adversary already. **An account - is only as strong as its weakest client.** +- Your content never passes through the hub in the clear. The hub introduces nodes + to one another and relays sealed traffic; it does not hold what you share (§5). +- The hub holds no key of yours. It cannot read your group, add a device to your + account, or act as you — and it cannot hand your group's key to anyone, save + where you ask it to (an open-join group, or an invitation you asked it to mail; + §7.3, §3.4). +- Each node carries its own identity keys, so a key that somehow leaked would open + that one node and no other (§3), and a copy of a node's storage without its + unlock key reveals none of its chat (§4.5). -"End-to-end" here describes **client ↔ node**, never client ↔ client. Members and -the operator read everything in their group; that is what a group is. +### 2.3 Your operator hosts you, but cannot become you -### 2.4 One boundary worth naming +Keeping your content is the deal you made with your operator. Turning that into +*being* you is the line they cannot cross. The identity bundle stored on their +node opens only with your passphrase and a secret the hub releases to no one but a +session that has already proved the passphrase or a device key; the only attempts +left to an operator are ordinary sign-ins, which the hub counts and locks out. And +reading what *they* host never reaches what *other* operators host (§3.2). -An operator hosts your content by design. They should not be able to become -*you*. The bundle on their disk does not let them try: it opens only with the -passphrase and a pepper the hub hands to nobody but a session that proved the -passphrase or a device key, so the only guesses left to them are sign-ins, which -the hub counts and locks out. And were a bundle key to leak all the same, it -opens **the bundle on that node** and no other. Reading what they host is by -design; reading what *other* operators host is not, and does not follow (§3.2). +### 2.4 The one honest caveat: the browser + +Used in a web browser, MeshBay is a page the hub sends you on each visit — so a hub +that had been taken over could send an altered page and lift your keys from it. +This is true of **every** web application; most simply never say so, and no +protocol can prevent it, because the attack is in the code itself rather than in +the messages. So we never claim your content is unreadable *even by the hub* when +you use a browser. + +The desktop and mobile apps close this. Their code is installed once, from a +package anyone can rebuild from source and check against a published hash, so a +tampered build is caught instead of silently trusted. For the strongest assurance, +use the app. This is the project's single standing limit for browser use — tracked +as **T3** — and the full, adversary-by-adversary analysis behind every statement +in this section is in §13.9 for readers who want it. --- @@ -1964,7 +1955,7 @@ an operator-signed op. Hub minimisation was considered and **deferred, and may be dropped** (decision D4). The hub keeps serving the web UI and remains in the trusted path by choice. That is a legitimate product call; what follows from it is carried deliberately rather than -by accident (§2.3). +by accident (§2.4). **Stores:** accounts (username, encrypted email, status, role), the group registry and membership, IP logs (one year, legal retention), node registrations, refresh @@ -3352,6 +3343,45 @@ A client, not a host. The platform is hostile to *hosting* a node — background execution, storage, battery — and fine as a *client*, which is one of the reasons enrichment is node-side (§6.5). +**The application is the desktop client's design in a system WebView** +(`packages/meshbay-android/`). What §8.2 depends on is not Electron but an +engine with `RTCPeerConnection`, WebCrypto X25519/Ed25519 and MSE, the +interface loaded from the package, and a privileged side reached through a +narrow bridge — and Android has all three: + +- **The interface is the hub's `static/`, copied at build time** (§8.3) and + served from the APK by an asset loader under + `https://appassets.androidplatform.net` — a secure context, so `crypto.subtle` + exists. Nothing the hub serves is ever loaded into the WebView; the policy is + the desktop's, sent as a header. +- **The bridge offers the desktop preload's `window.meshbay`** wherever it + offers anything. What a phone does not have — the node, shared folders, the + tray — is **absent, not a function that refuses**: `platform.js` decides what + to show from whether an object exists. The bridge answers the packaged + origin's top-level document only (`addWebMessageListener`, `isMainFrame`); a + same-origin child frame does get the port, and is refused there. +- **Keys are held natively** (§3.7, §14.1 #20): device key, bundle key and every + node identity, under an Android Keystore key, never handed to the page. The + Kotlin keyring is a third implementation of the bundle format and the + transcripts, so **`tests/vectors/keyring.json` — generated from the desktop + keyring and checked against the specification — is what every implementation + must reproduce**; a list the vectors cannot hold (the admin operations that + may be signed) is compared by source. +- **Hub calls leave from native code**, to the signed-in hub only, as on the + desktop. **Downloads go to disk** through the Storage Access Framework or the + Downloads collection, as pending files until complete; uploads come through + the system picker. +- **A hidden page is frozen by Chromium sixty seconds after it is hidden** — + measured, and whatever the process's importance: a foreground service, wake + locks and the renderer's priority policy do not prevent it. A cast's pipeline + lives in the page (WebRTC → decrypt → relay), so while one runs the shell + keeps the WebView reported visible and holds a media-playback foreground + service; nowhere else, because a page never hidden is never throttled. + +Release builds are signed with the development key until the release key exists +(Stage D12); §2.3's sentence about who holds a signing key applies to whichever +store distributes them. + ### 11.4 Casting An HTTP relay in the desktop client serves a standard fragmented-MP4 stream that @@ -3366,6 +3396,32 @@ within two. The main process holds what the scan has found and the page polls it waiting on one call for the whole scan; a poll uses the same checked `handle()` door as every other call, where a pushed event would be a second one. +**The Android relay is the desktop's, with three things the phone found.** +(1) **The header is everything before the first moof**, however many chunks it +arrives in: the node's first chunk can be the 28-byte ftyp alone, and served as +the header it left the receiver without a moov. (2) **What a receiver has not +read waits in a spool file, not in memory.** A fragment is a segment — 5 to +10 MB at a film's bitrate — and the page runs ahead of the television by its +whole read-ahead; dropped past the desktop's 8 MB bound, each lost fragment +froze the picture for its length. (3) **A seek's first segments are held while +it lands** (`video-player.js`): they are the new stream, its header first, and +they arrived while `reinitAt` waited on the SourceBuffer and were dropped as the +old film's — the local player never noticed, a relay restarted there did. The +local element keeps playing while a cast runs, because its playhead paces the +stream, and is muted. + +**While a receiver plays the film, the player is its remote.** Phone and +television start apart and drift, so a scrubber on the local playhead lied about +where the film was and a seek from it landed off by the gap. The page polls +`cast:status`, whose `chromecast` carries the receiver's `playerState` and +`position` (stream seconds, zero at the relay's start; the page adds the +stream's start), and shows that over the local picture with play/pause +(`cast:chromecast:pause`/`play`), ±30 s and a scrubber. A seek is the ordinary +seek: it restarts the relay and reloads the receiver there, and until that +lands the target is shown, not the old stream's position. Pausing the receiver +pauses the local element too, which otherwise goes on fetching for nobody. The +copy-URL cast has no receiver to read and keeps the ordinary player. + **Subtitles are rebased onto the relay's clock before they are sent.** The node extracts a track whole, so its cues carry the film's timeline, and the player can use them unchanged because its SourceBuffer is given `timestampOffset = @@ -3553,7 +3609,7 @@ Two structural recommendations from that review stand as rules: |---|---| | **T1** | **The password split.** The hub never sees a passphrase; it holds a verifier for a client-derived `auth_key`. The passphrase floor can therefore only be enforced client-side (§3.1) | | **T2** | The hub was the key directory. **Closed** by admission redesign, not by safety numbers: the invite path reads no directory at all (§3.4). Reclassified as **H3** | -| **T3** | **The hub serves the SPA. Accepted permanently for browser users.** It is the only remaining way an active hub reads content, it is an artifact-level attack rather than a silent lie, and it does not exist for a native client — whose value is realised by reproducible builds, not by packaging (§2.3, §8.2) | +| **T3** | **The hub serves the SPA. Accepted permanently for browser users.** It is the only remaining way an active hub reads content, it is an artifact-level attack rather than a silent lie, and it does not exist for a native client — whose value is realised by reproducible builds, not by packaging (§13.9, §8.2) | ### 13.5b Availability between members (`AV`) @@ -3686,6 +3742,72 @@ had already been asked. | **O13** | **Hub identity pinning.** The client points at a hub by URL and nothing pins that hub's identity. Bounded, because a substituted hub can neither read content nor ship the code to a native client — worth doing all the same | | **V1–V13**, **P1–P5** | Per-application open items: wording of a disabled-service state, whether artwork reuses the chunk path, cache TTL, multi-track surfacing, HEIC/RAW support, a fuller EXIF panel, lightbox preloading, album-boundary behaviour, cover selection | +### 13.9 Formal adversary model + +§2 states the trust model for a human reader. This restates the same ground +formally, for auditors and implementers: the adversaries every claim is written +against, the claim-by-adversary matrix, and the over-claims the project refuses to +make. Nothing here is new — it is §2 with the proofs shown. + +"End-to-end" throughout describes **client ↔ node**, never client ↔ client. The +operator and the members read everything in their group; that is what a group is. + +#### Adversaries + +Every claim is written against one of these, and they are the only ones the +document uses: + +| Adversary | What they can do | +|---|---| +| **Passive hub** | Read everything the hub legitimately stores and relays | +| **Active hub** | Also lie: forge tokens, invent accounts, substitute values it publishes, ship modified client code to a browser | +| **Malicious node operator** | Read and alter everything on their own machine, including the plaintext files they host | +| **Malicious group member** | Everything a member may do, plus anything the protocol fails to refuse | +| **Network attacker** | Observe and tamper with traffic between any two parties | +| **Local attacker** | Reach loopback services and files on a client or node machine | +| **Registered hub user with no membership** | Reach every hub endpoint that does not check membership | +| **Federated peer hub** | Push directory rows and revocations over MHP | + +#### Claim matrix + +| Claim | Passive hub | Active hub | Malicious node operator | Malicious member | Network attacker | +|---|---|---|---|---|---| +| Data never transits the hub | ✅ | ✅ | — | — | ✅ | +| File content is unreadable | ✅ | ❌ **T3** (browser) · ✅ native | ❌ by design — the operator hosts the files | ❌ members share the group key | ✅ | +| The file index is unreadable | ✅ | ❌ T3 · ✅ native | ❌ | ❌ | ✅ | +| Chat content is unreadable | ✅ | ❌ T3 · ✅ native | ❌ — the operator is a member | ❌ | ✅ | +| Chat is unreadable **from a copy of the node's storage that lacks its unlock key** — not from a whole disk by default (§4.5) | ✅ | ✅ | — the operator holds the unlock key | ✅ | ✅ | +| Content cannot be modified | ✅ | ✅ | ❌ by design | ✅ | ✅ | +| The node cannot be impersonated | ✅ | ✅ | — | ✅ | ✅ | +| Client code integrity | ❌ **T3, accepted** (browser) · ✅ ships in the package (native) | ❌ T3 · ⚠️ native: **publicly verifiable, not prevented** | ✅ | ✅ | ✅ | +| The hub cannot obtain the group key | ✅ | ✅ **except** in an open-join group, where it can join legitimately (§7.3), and for an invitation the inviter asked the hub to mail, whose code it then holds (§3.4) | — | — | ✅ | +| Node content authority | ✅ | ✅ | ✅ sovereign | ✅ | ✅ | +| Devices cannot be added by the hub | ✅ | ✅ — the hub holds no user key and cannot countersign | ⚠️ a node adds a device only to itself, where it already reads everything | ✅ | ✅ | +| Chat senders are authenticated to each other | ✅ | ✅ | ⚠️ only for accounts the reader has already seen (§3.3) | ✅ | ✅ | +| Keypair bundles (**C4**) | ✅ it holds the pepper and no bundle | ⚠️ it can fetch a bundle with a token it mints and holds the pepper: an offline passphrase search, as T3 already concedes for browsers · none to fetch for an account without browser access | ✅ no offline search: the bundle does not open without the hub's pepper — only sign-in attempts, bounded and audited · none on disk for an account without browser access | — | — | +| Deleting your account erases you | ✅ hub-side | ✅ hub-side | ❌ files, pinned identity and bundle stay on the node (§7.7) | — | — | +| Your identity keys stay yours | ✅ | ⚠️ as the row above | ✅ the bundle they hold does not open without the pepper, and a leaked bundle key opens **that node's** bundle only | ✅ | ✅ | + +#### Over-claims the project refuses to make + +Three sentences are forbidden, each for a deliberate reason: + +- **"Everything is encrypted and unreadable by other parties, even the hub."** + A hub that ships the code can lift keys from the page regardless of protocol + design (**T3**). That is an artifact-level attack, not a silent directory lie, + and it is removed for native clients — not for browsers. +- **"A native client makes the hub untrusted."** It converts an undetectable, + per-request, per-user attack into a persistent artifact that can be hashed and + compared. That value is realised by reproducible builds and published hashes, + not by the packaging format. A build signed with a key the hub operator holds + *relocates* trust; it does not remove it. +- **"C4 is closed."** It is closed for an account whose identities the desktop + application keeps (browser access off, §3.7): nothing of them is on any node. + For an account with browser access the bundle is still on each node, sealed + under the passphrase **and** a pepper the hub holds — no operator can search it + offline, but an active hub can, and that is T3's adversary already. **An account + is only as strong as its weakest client.** + --- ## 14. Decisions that are not revisited @@ -3757,7 +3879,9 @@ pause and resume, the group-application framework with Chat, Files, Videos, Music and Photos, cross-group search with source merging, per-account playlists, casting to a Chromecast with subtitles rebased onto the relay's clock, the operator CLI and loopback control API, the desktop client through its identity -and download stages, account recovery, and the Windows port through packaging. +and download stages, account recovery, the Windows port through packaging, and +the Android client — the shell, native keys, downloads and uploads, and casting +(§11.3). The packages install: a machine has been taken from the built artefacts to a running hub and node on **Ubuntu 26.04 (`.deb`), Fedora 44 (`.rpm`) and @@ -3785,13 +3909,19 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows. | — | **Bitmap subtitles** (PGS, VOBSUB — about a fifth of the embedded streams). No WebVTT without OCR; they are not listed rather than listed and blank. Burn-in covers them and costs `-c:v copy`, which is what the eight-slot sizing assumes never happens | | — | Delegation (§3.4) | | — | Tier 3 roster attestation (§3.3) | -| — | Android client | +| — | **Android: phone behaviour and release** — the back button driving the page, recovery from a network handover, keeping a download alive with the screen off, lock-screen media controls, and a release key (§11.3) | | — | **Federation between two hubs.** The protocol is written and switched off in the code (§7.6); what is not built is one run between two machines | ### 15.3 Open, and why each is where it is | Item | Status | |---|---| +| **The desktop cast relay drops whole segments** | `cast-relay.js` drops a fragment once 8 MB wait for a receiver, and a fragment is a segment, 5 to 10 MB at a film's bitrate: the picture freezes for its length. Its backlog is bounded in fragments only. The Android relay spools a receiver's lead to disk and bounds its backlog in bytes (§11.4); the desktop has neither | +| **The desktop cast relay takes the first chunk for the whole header** | The node's first chunk can be the ftyp alone, the moov in the next; the receiver then gives up. Fixed in the Android relay (§11.4), not in `cast-relay.js` | +| **A cast restart may pull far ahead** | Seen once on an emulator with a synthetic film: after the restart's reinit the node reported `duration=None`, and the page pulled most of the film at network speed. Not reproduced on a real film; the suspicion is a read-ahead budget computed without a duration | +| **"Copy stream URL" after picking a receiver casts to that receiver** | The player keeps the last device chosen, so the copy-only path reconnects it instead of only starting the relay | +| **The home page says "No groups yet" when the hub cannot be reached** | An unreachable hub reads as an account with no groups, rather than as an error | +| **`meshbay-node init` says "Settings → Link Node"** | The control is on the Profile page, as QUICKSTART says | | **C4** for accounts with browser access | Closed against operators by the pepper; **open against an active hub**, which holds the pepper and can fetch a bundle with a token it mints — the adversary T3 already concedes for browsers (§3.7) | | **A desktop that never held an identity cannot open its recovery copy** | The application opens a node's passphrase copy, not the recovery copy: after a reset, an identity it never held is recovered from a browser (§3.6). And an identity the application mints gets a recovery copy only when the recovery key is entered on its Profile page with browser access on | | **T3** for browser users | **Accepted permanently.** Removed for native clients, and that removal's value depends on reproducible builds | @@ -3830,7 +3960,7 @@ superseded document kept under `docs/`, a note somebody holds elsewhere. | Cited as | Read | |---|---| -| `draft-v5 §2`, `draft-v6 §4` — security claims | §2.2 | +| `draft-v5 §2`, `draft-v6 §4` — security claims | §13.9 | | `draft-v5 §3` — transport, NAT traversal | §5.1 | | `draft-v5 §4`, §4.1–4.4 — handshake, transcript, channel binding, mutual auth | §5.2 | | `draft-v5 §5.1`, `draft-v6 §2.3`, `§2.4b` — privileged operations, key activation, authorship | §5.4 | diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index d631804..1790475 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -392,9 +392,13 @@ resume. ### Casting to a TV -**Desktop application only.** A film playing in the application can be sent to a -cast-capable TV or dongle on the same network: *Cast to device* in the player, -pick one from the list. Devices appear as they answer, usually within a couple +**The desktop and Android applications.** A film playing in the application can +be sent to a cast-capable TV or dongle on the same network: *Cast to device* in +the player, pick one from the list. On a phone, the film goes on playing silently +on the phone while the TV shows it, and the screen can be turned off. While a +TV plays the film, the player becomes its remote: the position shown is the +TV's, with play/pause, 30-second jumps and a slider to go anywhere in the film. +Devices appear as they answer, usually within a couple of seconds; the search carries on for a few more, for a TV that is still waking up. @@ -967,12 +971,16 @@ Better to know now than to go looking for it: is nothing to check a download against and no updates through your distribution. Until that ships, take them from the download page and from nowhere else. -- **There is no Android client.** A phone browser works. +- **The Android application is not released yet.** It works — groups, chat, + films, downloads, casting — but is built and installed by hand and signed + with a development key, so there is no store page and no update channel. The + back button and the lock screen do not drive it yet. A phone browser works + too. - **A node cannot be hosted on Android**, and is not planned to be. - **Hubs do not talk to each other yet.** Everyone in a group needs an account on the same hub. -- **Casting reaches Chromecast devices** from the desktop application. Support - for other TV protocols is designed but not built. +- **Casting reaches Chromecast devices** from the desktop and Android + applications. Support for other TV protocols is designed but not built. - **Some subtitle tracks cannot be shown** — the ones stored as images rather than text, roughly one embedded track in five. Displaying them would need text recognition. diff --git a/packages/meshbay-android/.gitignore b/packages/meshbay-android/.gitignore new file mode 100644 index 0000000..8a50ec5 --- /dev/null +++ b/packages/meshbay-android/.gitignore @@ -0,0 +1,6 @@ +# Generated — the interface is copied from meshbay-hub/static at build time +# and never committed (docs/MESHBAY_DESIGN.md §8.3). +build/ +.gradle/ +local.properties +.kotlin/ diff --git a/packages/meshbay-android/README.md b/packages/meshbay-android/README.md new file mode 100644 index 0000000..85e2406 --- /dev/null +++ b/packages/meshbay-android/README.md @@ -0,0 +1,64 @@ +# MeshBay — Android client + +A client, not a host: no node runs on a phone (`docs/MESHBAY_DESIGN.md` §11.3). + +The shell is a system WebView showing the interface **from the package** — +`meshbay-hub/src/meshbay_hub/static/` copied at build time into +`build/generated/`, never committed (§8.3) — with a bridge +(`app/src/main/assets/bridge/meshbay-bridge.js`) that offers the page the same +`window.meshbay` as the desktop preload, wherever it offers anything at all. +Hub calls leave from native code, to the signed-in hub only. The device key, +the bundle key and every node identity are held natively under an Android +Keystore key; the page is told public keys and handed signatures, asked for by +kind — never bytes. `meshbay-hub/tests/vectors/keyring.json` holds that keyring +to the desktop's and to the specification. + +Downloads are written to disk as they arrive — into the folder chosen in +Settings (a Storage Access Framework tree, as `<name>.part` until complete) or +the system Downloads collection (a pending entry until complete) — and the +page holds an opaque id, never a URI. Uploads come through the system picker. + +Casting: the page pushes the decrypted stream to a local HTTP relay (a port of +the desktop's `cast-relay.js`, bound to the Wi-Fi address only); receivers are +found and driven through the platform cast SDK with the default media receiver. +While a cast runs, a media-playback foreground service holds the CPU and the +Wi-Fi, and the WebView is kept reported visible — without that, Chromium +freezes the page 60 s after the screen goes off. Where play services are +absent, the page is offered no cast at all. + +```bash +# needs JDK 17+ and an Android SDK (ANDROID_HOME, or sdk.dir in local.properties) +./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk +./gradlew testDebugUnitTest # JVM unit tests +``` + +The security contract is also pinned from the Python suite by reading this +source: `packages/meshbay-hub/tests/test_android_shell.py`. + +Not built yet: phone-specific behaviour (back button, network handover, +keeping a download alive with the screen off), signed releases. + +## Icon + +The desktop client's icon, `meshbay-client/build/icon-square.png`, placed in +the adaptive icon's safe zone (72 dp of the 108 dp layer, which is what every +launcher mask leaves visible) over its own edge colour, so no mask crops the +M. The five `mipmap-*/ic_launcher_foreground.png` are generated from it: + +```python +from PIL import Image, ImageDraw, ImageFilter +src = Image.open("../meshbay-client/build/icon-square.png").convert("RGBA") +for name, L in [("mdpi", 108), ("hdpi", 162), ("xhdpi", 216), ("xxhdpi", 324), ("xxxhdpi", 432)]: + S = L * 72 // 108; b = max(2, S // 25) + img = src.resize((S, S), Image.LANCZOS) + mask = Image.new("L", (S, S), 0) + ImageDraw.Draw(mask).rectangle([b, b, S - b - 1, S - b - 1], fill=255) + img.putalpha(mask.filter(ImageFilter.GaussianBlur(b))) + layer = Image.new("RGBA", (L, L), (0, 0, 0, 0)) + layer.paste(img, ((L - S) // 2, (L - S) // 2), img) + layer.save(f"app/src/main/res/mipmap-{name}/ic_launcher_foreground.png", optimize=True) +``` + +No monochrome layer: a themed icon keeps only the layer's alpha, and this one +would be a filled square. + diff --git a/packages/meshbay-android/app/build.gradle.kts b/packages/meshbay-android/app/build.gradle.kts new file mode 100644 index 0000000..3f29ac0 --- /dev/null +++ b/packages/meshbay-android/app/build.gradle.kts @@ -0,0 +1,88 @@ +import groovy.json.JsonSlurper + +plugins { id("com.android.application") } + +// One version for every package (CLAUDE.md): read from the desktop client's +// package.json rather than written a second time here. +val packageVersion = (JsonSlurper().parse(rootDir.resolve("../meshbay-client/package.json")) + as Map<*, *>)["version"] as String +val versionParts = packageVersion.split(".").map { it.toInt() } + +android { + namespace = "org.meshbay.client" + compileSdk = 37 + defaultConfig { + applicationId = "org.meshbay.client" + minSdk = 26 + targetSdk = 36 + versionName = packageVersion + versionCode = versionParts[0] * 10000 + versionParts[1] * 100 + versionParts[2] + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + // A stand-in until the release key exists (Stage D12): the debug + // key, so a release build installs over a debug one and back + // without losing the account. Not a key to publish anything with. + signingConfig = signingConfigs.getByName("debug") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { buildConfig = true } + testOptions { unitTests.isReturnDefaultValues = false } +} + +dependencies { + implementation("androidx.webkit:webkit:1.17.1") + implementation("com.squareup.okhttp3:okhttp:5.5.0") + // Ed25519, X25519, HKDF and Argon2id, identical on the JVM and every + // Android version: the platform has no Argon2 and its Ed25519 is recent. + implementation("org.bouncycastle:bcprov-jdk18on:1.86") + // Casting: discovery through MediaRouter, control through the cast sender + // SDK and the default media receiver. Needs the vendor's play services at + // run time; where they are absent the page is offered no cast at all. + implementation("com.google.android.gms:play-services-cast-framework:22.3.1") + implementation("androidx.mediarouter:mediarouter:1.8.1") + testImplementation("junit:junit:4.13.2") + // Android's org.json is a stub on the JVM; the unit tests need the real one. + testImplementation("org.json:json:20260814") +} + +/** + * The interface, copied from its single source at build time (§8.3). + * + * `meshbay-hub/src/meshbay_hub/static/` is the interface for the web, the + * desktop application and this one. The copy lands in build/ and is never + * committed, so it cannot fork. The page itself is the desktop application's + * `scripts/index.html` — the hub builds its own with a /a/<hash>/ prefix that + * would point back at the hub — so an application loading from its package + * has one page, not two. + */ +abstract class SyncUi : DefaultTask() { + @get:InputDirectory abstract val staticDir: DirectoryProperty + @get:InputFile abstract val indexHtml: RegularFileProperty + @get:OutputDirectory abstract val outputDir: DirectoryProperty + + @TaskAction + fun copy() { + val ui = outputDir.get().asFile.resolve("ui") + outputDir.get().asFile.deleteRecursively() + staticDir.get().asFile.copyRecursively(ui) + indexHtml.get().asFile.copyTo(ui.resolve("index.html"), overwrite = true) + } +} + +val syncUi = tasks.register<SyncUi>("syncUi") { + staticDir.set(rootDir.resolve("../meshbay-hub/src/meshbay_hub/static")) + indexHtml.set(rootDir.resolve("../meshbay-client/scripts/index.html")) + outputDir.set(layout.buildDirectory.dir("generated/ui-assets")) +} + +androidComponents { + onVariants { variant -> + variant.sources.assets?.addGeneratedSourceDirectory(syncUi, SyncUi::outputDir) + } +} diff --git a/packages/meshbay-android/app/src/main/AndroidManifest.xml b/packages/meshbay-android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2eae3ea --- /dev/null +++ b/packages/meshbay-android/app/src/main/AndroidManifest.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> + <!-- Casting: the relay's foreground service, and the locks that keep the + CPU and the Wi-Fi up while the screen is off. --> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> + <uses-permission android:name="android.permission.WAKE_LOCK" /> + <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> + + <!-- No backup of any kind: the keys are wrapped by a Keystore key that a + restore cannot bring with it, so a backed-up store is one that silently + fails to open on the next device. --> + <application + android:label="MeshBay" + android:icon="@mipmap/ic_launcher" + android:roundIcon="@mipmap/ic_launcher_round" + android:allowBackup="false" + android:fullBackupContent="false" + android:dataExtractionRules="@xml/data_extraction_rules" + android:networkSecurityConfig="@xml/network_security_config" + android:theme="@style/Shell"> + <activity + android:name=".MainActivity" + android:exported="true" + android:launchMode="singleTask" + android:windowSoftInputMode="adjustResize" + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|keyboard|keyboardHidden|density|navigation"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + <service + android:name=".cast.CastService" + android:exported="false" + android:foregroundServiceType="mediaPlayback" /> + <meta-data + android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME" + android:value="org.meshbay.client.cast.CastOptionsProvider" /> + </application> +</manifest> diff --git a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js new file mode 100644 index 0000000..4755531 --- /dev/null +++ b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js @@ -0,0 +1,206 @@ +/** + * The bridge, and the whole of it — the Android counterpart of + * meshbay-client/src/preload.js, with the same shape wherever it offers + * something at all. + * + * Injected at document start into documents of the packaged origin, before any + * page script. It takes the native port the listener injected, hides the + * global, and exposes `window.meshbay` frozen. There is no context isolation + * on Android: page script runs in the same world, so what this buys is that + * nothing can reach the raw port by name, not that this file is out of reach. + * The confinement that matters is native — the listener answers the packaged + * origin's top-level document only, and checks every argument. + * + * What the desktop offers and this build does not is ABSENT, not a function + * that refuses: `platform.js` decides what to show from whether an object + * exists (`platform.node.available`, `platform.folder.available`, …). + * + * `HUB_BASE`, `BINARY` and `CAST` are prepended by the shell when it injects + * this file: the interface asks for the hub while its modules load, before + * anything can await; BINARY says whether the WebView carries ArrayBuffer + * messages; CAST whether this device can cast at all. + */ +(function () { + 'use strict'; + const port = window.meshbayNative; + try { delete window.meshbayNative; } catch (e) { /* already gone */ } + // A same-origin child frame gets the port too; it gets no bridge, and native + // refuses whatever it sends anyway. + if (!port || window.top !== window) return; + + const pending = new Map(); + let seq = 0; + port.onmessage = (event) => { + let reply; + try { reply = JSON.parse(event.data); } catch (e) { return; } + const waiter = pending.get(reply.id); + if (!waiter) return; + pending.delete(reply.id); + if (reply.ok) waiter.resolve(reply.value); + else waiter.reject(new Error(reply.error)); + }; + const call = (channel, ...args) => new Promise((resolve, reject) => { + const id = ++seq; + pending.set(id, { resolve, reject }); + port.postMessage(JSON.stringify({ id, ch: channel, args })); + }); + + // A write is a binary message: "MBB1" | id | channel | 0 | handle | bytes, + // big-endian — one copy, no JSON, no base64 (spike S-3: 136 MB/s awaited + // per 48 KB chunk). Without ArrayBuffer messages, base64 over JSON. + const SAVE_WRITE = 1; + const CAST_PUSH = 2; + const bytesOf = (chunk) => (chunk instanceof Uint8Array ? chunk + : ArrayBuffer.isView(chunk) ? new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength) + : new Uint8Array(chunk)); + const base64Of = (bytes) => { + let s = ''; + for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); + return btoa(s); + }; + const sendBinary = (channel, handle, bytes) => new Promise((resolve, reject) => { + const id = ++seq; + pending.set(id, { resolve, reject }); + const frame = new ArrayBuffer(16 + bytes.length); + const head = new DataView(frame); + head.setUint32(0, 0x4d424231); // "MBB1" + head.setUint32(4, id); + head.setUint16(8, channel); + head.setUint32(12, handle); + new Uint8Array(frame, 16).set(bytes); + port.postMessage(frame); + }); + const writeChunk = (handle, chunk) => { + const bytes = bytesOf(chunk); + return BINARY ? sendBinary(SAVE_WRITE, handle, bytes) : call('save:write', handle, base64Of(bytes)); + }; + const pushSegment = (chunk) => { + const bytes = bytesOf(chunk); + return BINARY ? sendBinary(CAST_PUSH, 0, bytes) : call('cast:push', base64Of(bytes)); + }; + + const meshbay = { + hubBase: () => HUB_BASE, + setHubBase: (base) => call('hub:set', base), + + capabilities: { + nodeAdmin: false, // no node runs on a phone (§11.3) + localFolders: false, + nativeSave: true, + lanCast: CAST, // false where the vendor's play services are absent + tray: false, + }, + + setLocale: (code) => call('ui:locale', code), + + // The page's origin is refused by the hub's absent CORS, and is not a + // credential anyway: native goes, to the signed-in hub only. + fetch: (url, init) => call('hub:fetch', url, init), + + resolveStun: (urls) => call('ice:resolve-stun', urls), + + // The device's hub key: generated, held and used natively. The page asks + // for a signature and never sees a key — it parses hostile input. + device: { + ensure: () => call('device:ensure'), + publicKey: () => call('device:public'), + sign: (username) => call('device:sign', username), + forget: () => call('device:forget'), + }, + + // The bundle key and the identity on every node, held natively: the page + // is told public keys and handed signatures and agreements. A signature is + // asked for by kind and fields, never by bytes. + keys: { + available: () => call('keys:available'), + deriveSession: (o) => call('keys:derive-session', o), + commitPending: (u) => call('keys:commit-pending', u), + dropPending: (u) => call('keys:drop-pending', u), + hasSession: (u) => call('keys:has-session', u), + forgetSession: (u) => call('keys:forget-session', u), + identity: (u, n) => call('keys:identity', u, n), + openBundle: (u, n, o) => call('keys:open-bundle', u, n, o), + mint: (u, n) => call('keys:mint', u, n), + sealBundle: (u, n, o) => call('keys:seal-bundle', u, n, o), + sealRecovery: (u, n, m, name) => call('keys:seal-recovery', u, n, m, name), + markSealed: (u, n, fp) => call('keys:mark-sealed', u, n, fp), + fingerprint: (u) => call('keys:fingerprint', u), + sign: (u, n, kind, fields) => call('keys:sign', u, n, kind, fields), + shared: (u, n, peer) => call('keys:shared', u, n, peer), + playlistKey: (u) => call('keys:playlist-key', u), + browserAccess: (u) => call('keys:browser-access', u), + setBrowserAccess: (u, on) => call('keys:set-browser-access', u, on), + createdHere: (u) => call('keys:created-here', u), + }, + + // Whether the OS protects what is stored. The store itself is not + // reachable from here. + secrets: { + backend: () => call('secrets:backend'), + }, + + // LAN cast relay: the page feeds it decrypted segments, a receiver on the + // same Wi-Fi plays from the URL. Present only where casting can work — + // `platform.cast.available` is whether this object exists. + ...(CAST ? { cast: { + start: (opts) => { + const o = Object.assign({}, opts || {}); + if (o.initSegment) o.initSegment = base64Of(bytesOf(o.initSegment)); + return call('cast:start', o); + }, + push: (data) => pushSegment(data), + stop: () => call('cast:stop'), + subtitle: (sub) => call('cast:subtitle', sub), + finish: () => call('cast:finish'), + status: () => call('cast:status'), + scan: () => call('cast:scan'), + devices: () => call('cast:devices'), + chromecastConnect: (opts) => call('cast:chromecast:connect', opts), + chromecastReload: (opts) => call('cast:chromecast:reload', opts), + chromecastDisconnect: () => call('cast:chromecast:disconnect'), + chromecastPause: () => call('cast:chromecast:pause'), + chromecastPlay: () => call('cast:chromecast:play'), + } } : {}), + + // Where downloads go, chosen once. A display name comes back, never a URI. + folder: { + choose: () => call('folder:choose'), + get: () => call('folder:get'), + forget: () => call('folder:forget'), + }, + + // A sink that writes to disk as chunks arrive, never a buffer handed over + // at the end. The page holds an id. `open` exists only where the target + // says the file may be opened — a type that runs nothing. + saveFile: async (suggestedName, opts) => { + const handle = await call('save:begin', suggestedName, opts); + if (!handle) return null; + const sink = { + name: handle.name, + write: (chunk) => writeChunk(handle.id, chunk), + close: () => call('save:end', handle.id), + abort: () => call('save:abort', handle.id), + }; + if (handle.openable) sink.open = () => call('save:open', handle.id); + return sink; + }, + }; + + const freeze = (o) => { + Object.freeze(o); + for (const v of Object.values(o)) if (v && typeof v === 'object' && !Object.isFrozen(v)) freeze(v); + return o; + }; + Object.defineProperty(window, 'meshbay', { + value: freeze(meshbay), writable: false, configurable: false, enumerable: false, + }); + + // The WebView exposes File System Access and cannot back it with anything a + // person can see. Left in place, `downloads.SUPPORTED` reads true and a + // download could take a path that fails — or reach the blob floor silently. + for (const name of ['showDirectoryPicker', 'showSaveFilePicker', 'showOpenFilePicker']) { + try { + Object.defineProperty(window, name, { value: undefined, writable: false, configurable: false }); + } catch (e) { /* not definable: leave it, native save comes first anyway */ } + } +})(); diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt new file mode 100644 index 0000000..a781350 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt @@ -0,0 +1,288 @@ +package org.meshbay.client + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.View +import android.view.ViewGroup +import android.view.WindowInsets +import android.webkit.ConsoleMessage +import android.webkit.PermissionRequest +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.widget.FrameLayout +import androidx.webkit.ScriptHandler +import androidx.webkit.WebViewAssetLoader +import androidx.webkit.WebViewClientCompat +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import org.json.JSONObject +import org.meshbay.client.bridge.Bridge +import org.meshbay.client.bridge.Channels +import org.meshbay.client.bridge.KeyChannels +import org.meshbay.client.cast.CastChannels +import org.meshbay.client.cast.CastService +import org.meshbay.client.hub.HubClient +import org.meshbay.client.keys.SecretStore +import org.meshbay.client.save.SaveSinks +import org.meshbay.client.shell.Pickers +import org.meshbay.client.shell.ShellWebView +import org.meshbay.client.shell.EngineCheck +import org.meshbay.client.shell.NativeText +import org.meshbay.client.shell.UiAssets +import java.util.concurrent.CountDownLatch + +/** + * The shell: one WebView showing the packaged interface, and the bridge. + * + * What this file must never do: load anything into the WebView that is not the + * package (the hub never becomes the document origin — T3), or hand the page a + * way to the hub other than the bridge. + */ +class MainActivity : Activity() { + private lateinit var root: FrameLayout + private lateinit var web: ShellWebView + private lateinit var cast: CastChannels + private lateinit var hub: HubClient + private lateinit var channels: Channels + private val pickers = Pickers(this) + private val text = NativeText { code -> + try { assets.open("ui/locales/$code.js").bufferedReader().use { it.readText() } } catch (e: java.io.IOException) { null } + } + private var shim: ScriptHandler? = null + private var fullscreen: View? = null + private var fullscreenCallback: WebChromeClient.CustomViewCallback? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + root = FrameLayout(this) + setContentView(root) + applyInsets(root) + + // A WebView too old for the page's crypto would fail at the first + // handshake; say so before loading anything. + EngineCheck.problem(this)?.let { setContentView(EngineCheck.screen(this, it)); return } + + hub = HubClient(getSharedPreferences("shell", Context.MODE_PRIVATE)) + web = ShellWebView(this) + root.addView(web, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + configure(web) + + val keys = KeyChannels(SecretStore(this), confirm = ::confirmNatively, + declined = { text.get("native.declined", channels.locale) }) + val saves = SaveSinks(this, getSharedPreferences("downloads", Context.MODE_PRIVATE), pickers, + startActivity = { intent -> runOnUiThread { startActivity(intent) } }) + // A process killed mid-download left unfinished files; nothing else will. + Thread { saves.cleanUpAfterAKilledProcess() }.start() + cast = CastChannels(this, onCasting = { on -> runOnUiThread { casting(on) } }, + tell = { m -> runOnUiThread { android.widget.Toast.makeText(this, m, android.widget.Toast.LENGTH_LONG).show() } }) + channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } }, + hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }, keys = keys, saves = saves, + cast = cast) + WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels)) + cast.control.warmUp() + installShim() + web.loadUrl(UiAssets.START) + } + + private fun configure(web: WebView) { + WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) + web.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true // IndexedDB and localStorage: session, resume positions + allowFileAccess = false + allowContentAccess = false + mediaPlaybackRequiresUserGesture = true + setSupportMultipleWindows(false) + mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_NEVER_ALLOW + } + android.webkit.CookieManager.getInstance().setAcceptThirdPartyCookies(web, false) + + val loader = WebViewAssetLoader.Builder() + .setDomain(UiAssets.HOST) + .addPathHandler(UiAssets.PREFIX, UiAssets(this)) + .build() + web.webViewClient = object : WebViewClientCompat() { + override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? { + val url = request.url + if (url.host == UiAssets.HOST) return loader.shouldInterceptRequest(url) ?: refused() + // reCAPTCHA (sign-up) and nothing else goes to the network from + // the page; the policy says the same, this is the second wall. + if (UiAssets.isRecaptcha(url.host) && url.scheme == "https") return null + if (url.scheme == "blob" || url.scheme == "data") return null + return refused() + } + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + val url = request.url + if (url.host == UiAssets.HOST) return false + // The hub must never become the document origin. A link out + // opens in the person's browser, not in a window holding keys. + if (request.isForMainFrame && (url.scheme == "https" || url.scheme == "http")) openExternally(url) + return !(UiAssets.isRecaptcha(url.host) && !request.isForMainFrame) + } + } + web.webChromeClient = object : WebChromeClient() { + // Grant by enumeration: nothing. Camera, microphone, MIDI and + // whatever Chromium adds next arrive refused. + override fun onPermissionRequest(request: PermissionRequest) = request.deny() + + // Without this, a video's requestFullscreen() never settles — a + // refusal that never rejects (CLAUDE.md). Measured in the spike. + override fun onShowCustomView(view: View, callback: CustomViewCallback) { + fullscreen?.let { root.removeView(it) } + fullscreen = view + fullscreenCallback = callback + root.addView(view, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + web.visibility = View.INVISIBLE + setFullscreenBars(true) + } + + override fun onHideCustomView() { + fullscreen?.let { root.removeView(it) } + fullscreen = null + fullscreenCallback = null + web.visibility = View.VISIBLE + setFullscreenBars(false) + } + + // <input type=file>: the system picker; the page reads what it is + // given through the File objects the WebView makes of the URIs. + // The callback is always answered, or the next chooser never opens. + override fun onShowFileChooser(view: WebView, callback: android.webkit.ValueCallback<Array<Uri>>, + params: FileChooserParams): Boolean { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*") + .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, params.mode == FileChooserParams.MODE_OPEN_MULTIPLE) + Thread { + val result = pickers.run(intent) + // A single pick in multiple mode can come back with an + // empty clipData and the file in `data`: take whichever + // carries it, or the page receives a selection of nothing. + val uris = result?.let { r -> + val clip = r.clipData?.takeIf { it.itemCount > 0 } + clip?.let { c -> (0 until c.itemCount).map { c.getItemAt(it).uri } } ?: listOfNotNull(r.data) + }?.takeIf { it.isNotEmpty() }?.toTypedArray() + runOnUiThread { callback.onReceiveValue(uris) } + }.start() + return true + } + + override fun onConsoleMessage(m: ConsoleMessage): Boolean { + if (BuildConfig.DEBUG) Log.i("MeshBayPage", "${m.messageLevel()} ${m.message()} @${m.sourceId()}:${m.lineNumber()}") + return true + } + } + } + + /** + * The shim, with the hub address in it: the page reads that synchronously + * while its modules load. Changing the hub replaces the shim and reloads — + * a page left running would go on talking to the old hub with no sign of it. + */ + private fun installShim() { + shim?.remove() + val source = assets.open("bridge/meshbay-bridge.js").bufferedReader().use { it.readText() } + val binary = WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_ARRAY_BUFFER) + val prelude = "const HUB_BASE = ${JSONObject.quote(hub.base)};\nconst BINARY = $binary;\n" + + "const CAST = ${cast.control.available()};\n" + shim = WebViewCompat.addDocumentStartJavaScript(web, "(function(){$prelude$source\n})();", setOf(UiAssets.ORIGIN)) + } + + private fun reloadForHub() { + installShim() + web.loadUrl(UiAssets.START) + } + + /** + * A confirmation this process draws (main.js confirmNatively). Called from + * a bridge worker, never the UI thread, which it waits on. The keyboard is + * handed back to the page afterwards: after a dialog the document can stay + * unfocused and every keystroke go nowhere (CLAUDE.md, ask.js). + */ + private fun confirmNatively(key: String): Boolean { + val done = CountDownLatch(1) + var accepted = false + runOnUiThread { + android.app.AlertDialog.Builder(this) + .setMessage(text.get(key, channels.locale)) + .setPositiveButton(text.get("dialog.ok", channels.locale)) { _, _ -> accepted = true } + .setNegativeButton(text.get("dialog.cancel", channels.locale), null) + .setOnDismissListener { web.requestFocus(); done.countDown() } + .show() + } + done.await() + return accepted + } + + @Deprecated("Activity results for the system pickers; the platform API, kept for minSdk 26.") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (!pickers.deliver(requestCode, resultCode, data)) super.onActivityResult(requestCode, resultCode, data) + } + + /** + * A cast keeps the process, the Wi-Fi and the page alive with the screen + * off (spike S-2a, scenario F): the foreground service holds the first two, + * the WebView reported visible holds the third. + */ + private fun casting(on: Boolean) { + web.keepVisible = on + val service = Intent(this, CastService::class.java) + if (on) startForegroundService(service) else stopService(service) + } + + private fun hasAsset(path: String) = try { assets.open(path).close(); true } catch (e: java.io.IOException) { false } + + private fun refused() = WebResourceResponse("text/plain", "utf-8", 403, "Forbidden", emptyMap(), "".byteInputStream()) + + private fun openExternally(url: Uri) { + try { startActivity(Intent(Intent.ACTION_VIEW, url).addCategory(Intent.CATEGORY_BROWSABLE)) } + catch (e: android.content.ActivityNotFoundException) { Log.w(Bridge.TAG, "no browser for $url") } + } + + /** Edge-to-edge is enforced from Android 15: keep the page clear of the bars and the keyboard. */ + private fun applyInsets(view: View) { + view.setOnApplyWindowInsetsListener { v, insets -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val bars = if (fullscreen != null) android.graphics.Insets.NONE + else insets.getInsets(WindowInsets.Type.systemBars() or WindowInsets.Type.ime() or WindowInsets.Type.displayCutout()) + v.setPadding(bars.left, bars.top, bars.right, bars.bottom) + } else { + @Suppress("DEPRECATION") + v.setPadding(insets.systemWindowInsetLeft, insets.systemWindowInsetTop, + insets.systemWindowInsetRight, insets.systemWindowInsetBottom) + } + insets + } + } + + private fun setFullscreenBars(on: Boolean) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val c = window.insetsController ?: return + if (on) { + c.hide(WindowInsets.Type.systemBars()) + c.systemBarsBehavior = android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } else c.show(WindowInsets.Type.systemBars()) + } + root.requestApplyInsets() + } + + @Deprecated("Back is handed to the page in phase 4; until then it leaves fullscreen or backgrounds the app.") + override fun onBackPressed() { + if (fullscreen != null) { fullscreenCallback?.onCustomViewHidden(); return } + if (web.canGoBack()) { web.goBack(); return } + // Never finish(): that would tear down every connection and transfer. + moveTaskToBack(true) + } + + override fun onDestroy() { + if (::cast.isInitialized && cast.relay.active) { cast.relay.stop(); casting(false) } + if (::web.isInitialized) { root.removeView(web); web.destroy() } + super.onDestroy() + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Bridge.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Bridge.kt new file mode 100644 index 0000000..50552fa --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Bridge.kt @@ -0,0 +1,78 @@ +package org.meshbay.client.bridge + +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat +import org.json.JSONArray +import org.json.JSONObject +import org.meshbay.client.save.BinaryFrame +import org.meshbay.client.shell.UiAssets +import java.util.concurrent.Executors + +/** + * Everything the interface may ask of the application, and the only way in. + * + * `addWebMessageListener` injects `meshbayNative` only into documents of the + * packaged origin; the shim (assets/bridge/meshbay-bridge.js) takes it at + * document start and hides it. But a same-origin child frame gets one too — the + * spike measured it — so what actually confines the bridge is the check here: + * **the packaged origin's top-level document, and nothing else** (main.js + * `fromOurPage`). The page parses decrypted content from nodes, which is + * attacker-controlled input, so every argument is checked again in Channels. + */ +class Bridge(private val channels: Channels) : WebViewCompat.WebMessageListener { + + private val main = Handler(Looper.getMainLooper()) + // Hub calls and key operations block; none may run on the UI thread. + private val work = Executors.newCachedThreadPool() + private val serial = Executors.newSingleThreadExecutor() + + override fun onPostMessage(view: WebView, message: WebMessageCompat, sourceOrigin: Uri, + isMainFrame: Boolean, replyProxy: JavaScriptReplyProxy) { + if (!isMainFrame || sourceOrigin.toString() != UiAssets.ORIGIN) { + Log.w(TAG, "refused a message from $sourceOrigin (main frame: $isMainFrame)") + val id = if (message.type == WebMessageCompat.TYPE_STRING) + try { JSONObject(message.data ?: "").optLong("id", -1) } catch (e: Exception) { -1 } else -1 + replyProxy.postMessage(error(id, "Refused: not the MeshBay interface")) + return + } + if (message.type == WebMessageCompat.TYPE_ARRAY_BUFFER) { + val frame = BinaryFrame.parse(message.arrayBuffer) ?: return + dispatch(frame.id, replyProxy, "binary ${frame.channel}", channels.ordered(frame)) { channels.binary(frame) } + return + } + val request = try { JSONObject(message.data ?: return) } catch (e: Exception) { return } + val id = request.optLong("id", -1) + val channel = request.optString("ch") + val args = request.optJSONArray("args") ?: JSONArray() + dispatch(id, replyProxy, channel, channels.ordered(channel)) { channels.call(channel, args) } + } + + private fun dispatch(id: Long, replyProxy: JavaScriptReplyProxy, channel: String, ordered: Boolean, + call: () -> Any?) { + (if (ordered) serial else work).execute { + val reply = try { + JSONObject().put("id", id).put("ok", true).put("value", call() ?: JSONObject.NULL).toString() + } catch (e: Refused) { + error(id, e.message ?: "Refused") + } catch (e: Exception) { + Log.w(TAG, "$channel failed", e) + error(id, e.message ?: e.javaClass.simpleName) + } + main.post { replyProxy.postMessage(reply) } + } + } + + private fun error(id: Long, message: String) = + JSONObject().put("id", id).put("ok", false).put("error", message).toString() + + companion object { + const val TAG = "MeshBay" + const val PORT = "meshbayNative" + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt new file mode 100644 index 0000000..6992cdb --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt @@ -0,0 +1,104 @@ +package org.meshbay.client.bridge + +import org.json.JSONArray +import org.meshbay.client.cast.CastChannels +import org.meshbay.client.hub.HubClient +import org.meshbay.client.save.BinaryFrame +import org.meshbay.client.save.SaveSinks +import java.net.Inet4Address +import java.net.InetAddress + +/** + * The enumerated channels, and nothing else (main.js `registerBridge`). + * + * A channel that takes a path, a URL to anywhere, or bytes to sign from the + * page is the shape to avoid. What the desktop offers and a phone does not — + * the local node, shared folders, the tray — is not here at all, and the shim + * does not offer it either: `platform.js` decides what to show from whether a + * bridge object exists, so an object that only refused would put screens on + * the page that fail when used. + */ +class Channels( + private val hub: HubClient, + private val onHubChanged: () -> Unit, + private val hasCatalogue: (String) -> Boolean, + private val keys: KeyChannels? = null, + private val saves: SaveSinks? = null, + private val cast: CastChannels? = null, +) { + @Volatile var locale = "en" + private set + + fun call(channel: String, args: JSONArray): Any? = when (channel) { + "hub:set" -> hub.setBase(args.optString(0, "")).also { onHubChanged() } + "hub:fetch" -> hub.fetch(args.optString(0, ""), args.optJSONObject(1)) + "ice:resolve-stun" -> resolveStun(args.optJSONArray(0) ?: JSONArray()) + "ui:locale" -> setLocale(args.optString(0, "")) + + // Where downloads go, chosen once; a display name, never a URI. + "folder:choose" -> saves().chooseFolder() + "folder:get" -> saves().getFolder() + "folder:forget" -> saves().forgetFolder() + // A sink the page refers to by an opaque id. + "save:begin" -> saves().begin(args.optString(0, ""), args.optJSONObject(1)?.optBoolean("auto", false) ?: false) + "save:write" -> { // the base64 path, for a WebView without ArrayBuffer messages + val bytes = java.util.Base64.getDecoder().decode(args.optString(1, "")) + saves().write(args.optLong(0, -1), bytes, 0, bytes.size) + } + "save:end" -> saves().end(args.optLong(0, -1)) + "save:abort" -> saves().abort(args.optLong(0, -1)) + "save:open" -> saves().open(args.optLong(0, -1)) + else -> when { + keys != null && keys.handles(channel) -> keys.call(channel, args) + cast != null && cast.handles(channel) -> cast.call(channel, args) + else -> throw Refused("Refused: no such channel") + } + } + + private fun saves() = saves ?: throw Refused("Refused: no such channel") + + /** A binary message: one write, its bytes left where the message put them. */ + fun binary(frame: BinaryFrame): Any? = when (frame.channel) { + BinaryFrame.SAVE_WRITE -> saves().write(frame.handle, frame.bytes, frame.offset, frame.length) + BinaryFrame.CAST_PUSH -> (cast ?: throw Refused("Refused: no such channel")).push(frame.bytes, frame.offset, frame.length) + else -> throw Refused("Refused: no such channel") + } + + /** + * Calls whose order is part of their meaning: a relay start, the segments + * pushed after it, a stop. The page does not await each push, so on a pool + * they could overtake one another; these run on one thread, in arrival order. + */ + fun ordered(channel: String) = cast?.ordered(channel) == true + fun ordered(frame: BinaryFrame) = frame.channel == BinaryFrame.CAST_PUSH + + private fun setLocale(code: String): String { + // A code, never text, and only one the package has a catalogue for. + if (LOCALE.matches(code) && hasCatalogue(code)) locale = code + return locale + } + + companion object { + private val LOCALE = Regex("^[a-z]{2}(-[A-Z]{2})?$") + private val STUN = Regex("^(stuns?):(\\[?[^\\]]+\\]?|[^:]+):(\\d+)$") + + /** + * `stun:host:port` → `stun:ip:port`. Chromium's socket manager fails + * STUN hostnames outright behind some resolvers, and the page cannot do + * DNS. Unresolvable entries are dropped, literals pass through. + */ + fun resolveStun(urls: JSONArray, lookup: (String) -> Array<InetAddress> = InetAddress::getAllByName): JSONArray { + val out = JSONArray() + for (i in 0 until urls.length()) { + val u = urls.optString(i) + val m = STUN.matchEntire(u) + if (m == null) { out.put(u); continue } + val (scheme, host, port) = m.destructured + if (Regex("^[\\d.]+$").matches(host) || host.contains(':')) { out.put(u); continue } + val ip = try { lookup(host).firstOrNull { it is Inet4Address }?.hostAddress } catch (e: Exception) { null } + if (ip != null) out.put("$scheme:$ip:$port") + } + return out + } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/KeyChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/KeyChannels.kt new file mode 100644 index 0000000..f11b98c --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/KeyChannels.kt @@ -0,0 +1,117 @@ +package org.meshbay.client.bridge + +import org.json.JSONArray +import org.json.JSONObject +import org.meshbay.client.keys.DeviceKey +import org.meshbay.client.keys.Keyring +import org.meshbay.client.keys.SecretStore +import org.meshbay.client.keys.Secrets + +/** + * The device key, the account's bundle key and its identity on every node — + * held here, never in the page (§8.2, §14.1 #20). The page is answered with + * public keys, signatures and agreements; it names what it signs by kind and + * fields, never by bytes (Transcripts). Arguments are checked as main.js does: + * ids are ids, keys are keys. + * + * `confirm` is a dialog this process draws, worded from the interface's own + * catalogues: what widens what leaves this device is never answered by the + * page. + */ +class KeyChannels( + private val secrets: Secrets, + private val confirm: (String) -> Boolean, + private val declined: () -> String, +) { + private val device = DeviceKey(secrets) + val keyring = Keyring( + load = { + val raw = secrets.read().optString(SecretStore.KEYRING_SLOT, "") + if (raw.isEmpty()) null else try { JSONObject(raw) } catch (e: Exception) { null } + }, + save = { state -> secrets.update { it.put(SecretStore.KEYRING_SLOT, state.toString()) } }, + ) + + // Only where the OS protects what is stored: an identity kept here and lost + // at the next start would leave a node pinning a key nobody holds, so + // without key storage the page keeps its keys the way a browser does. + private fun available() = secrets.backend() != "unavailable" + private fun needKeys() { if (!available()) throw Refused("No OS key storage") } + + fun handles(channel: String) = channel.startsWith("keys:") || channel.startsWith("device:") || + channel == "secrets:backend" + + fun call(channel: String, a: JSONArray): Any? = when (channel) { + "secrets:backend" -> secrets.backend() + + "device:ensure" -> device.ensure() + "device:public" -> device.publicKey() + "device:sign" -> device.sign(a.optString(0, "")) + "device:forget" -> device.forget() + + "keys:available" -> available() + "keys:derive-session" -> { + needKeys() + val o = a.optJSONObject(0) ?: JSONObject() + keyring.deriveSession( + password = o.optString("password", ""), username = o.optString("username", ""), + userId = uid(o.opt("userId")), pepperB64 = o.optString("pepperB64", ""), + pepperVersion = o.optInt("pepperVersion", 1).takeIf { it != 0 } ?: 1, + pendingChange = o.optBoolean("pending", false)) + } + "keys:commit-pending" -> keyring.commitPending(uid(a.opt(0))) + "keys:drop-pending" -> keyring.dropPending(uid(a.opt(0))) + "keys:has-session" -> available() && keyring.hasSession(uid(a.opt(0))) + "keys:forget-session" -> keyring.forgetSession(uid(a.opt(0))) + "keys:identity" -> keyring.identity(uid(a.opt(0)), npk(a.opt(1)))?.let { + JSONObject().put("pkEdB64", it.pkEdB64).put("pkXB64", it.pkXB64).put("sealedWith", it.sealedWith ?: JSONObject.NULL) + } + "keys:open-bundle" -> pub(keyring.openBundle(uid(a.opt(0)), npk(a.opt(1)), + bundleEnc = a.optJSONObject(2)?.optString("bundleEnc", "") ?: "")) + "keys:mint" -> { needKeys(); pub(keyring.mint(uid(a.opt(0)), npk(a.opt(1)))) } + "keys:seal-bundle" -> keyring.sealBundle(uid(a.opt(0)), npk(a.opt(1)), + usePending = a.optJSONObject(2)?.optBoolean("pending", false) ?: false).let { + JSONObject().put("bundle", it.bundle).put("fingerprint", it.fingerprint) + } + "keys:seal-recovery" -> keyring.sealRecovery(uid(a.opt(0)), npk(a.opt(1)), a.optString(2, ""), a.optString(3, "")) + "keys:mark-sealed" -> keyring.markSealed(uid(a.opt(0)), npk(a.opt(1)), a.optString(2, "")) + "keys:fingerprint" -> keyring.currentFingerprint(uid(a.opt(0))) + // By kind and fields: the page never names the bytes. + "keys:sign" -> keyring.signAs(uid(a.opt(0)), npk(a.opt(1)), a.optString(2, ""), a.optJSONObject(3) ?: JSONObject()) + "keys:shared" -> keyring.shared(uid(a.opt(0)), npk(a.opt(1)), a.optString(2, "")) + "keys:playlist-key" -> keyring.playlistKey(uid(a.opt(0))) + "keys:browser-access" -> keyring.browserAccess(uid(a.opt(0))) + // Turning it on leaves this account's identities on every node, sealed + // for a browser: the person decides that here, in a dialog the page + // cannot answer. Turning it off only narrows. + "keys:set-browser-access" -> { + val id = uid(a.opt(0)) + val on = a.optBoolean(1, false) + if (on && !keyring.browserAccess(id) && !confirm("native.browser_access_confirm")) throw Refused(declined()) + keyring.setBrowserAccess(id, on) + } + // An account created on this device starts without browser access. + // Only ever narrows, so the page may say it. + "keys:created-here" -> keyring.setBrowserAccess(uid(a.opt(0)), false) + else -> throw Refused("Refused: no such channel") + } + + private fun pub(p: Keyring.Pub) = JSONObject().put("pkEdB64", p.pkEdB64).put("pkXB64", p.pkXB64) + + companion object { + private val UID = Regex("^[0-9a-f-]{36}$", RegexOption.IGNORE_CASE) + private val NPK = Regex("^[A-Za-z0-9+/=]{1,100}$") + + fun uid(v: Any?): String { + val s = if (v == null || v == JSONObject.NULL) "" else v.toString() + if (!UID.matches(s)) throw Refused("Refused: not an account id") + return s + } + + fun npk(v: Any?): String { + val s = if (v == null || v == JSONObject.NULL) "" else v.toString() + if (!NPK.matches(s)) throw Refused("Refused: not a node's key") + return s + } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Refused.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Refused.kt new file mode 100644 index 0000000..6f550a5 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Refused.kt @@ -0,0 +1,4 @@ +package org.meshbay.client.bridge + +/** A refusal whose message is written for a person; it reaches the page as is. */ +class Refused(message: String) : Exception(message) diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/BoxAccumulator.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/BoxAccumulator.kt new file mode 100644 index 0000000..f56d58a --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/BoxAccumulator.kt @@ -0,0 +1,87 @@ +package org.meshbay.client.cast + +import android.util.Log +import java.io.ByteArrayOutputStream + +/** + * Re-frames the page's byte stream into whole moof+mdat fragments, which is + * what a receiver needs. A port of cast-relay.js's BoxAccumulator: the chunks + * the page pushes are arbitrary slices of the fMP4 stream, not box-aligned. + */ +class BoxAccumulator { + private var buf = ByteArray(0) + private var synced = false + private var preambleSeen = false + + /** + * Called once, with every byte before the first moof: the stream's header + * (ftyp, moov), however many pushes it came in. The node's first chunk can + * hold the 28-byte ftyp alone, with the moov in the next one (measured). + */ + var onPreamble: ((ByteArray) -> Unit)? = null + + fun push(data: ByteArray, offset: Int = 0, length: Int = data.size - offset): List<ByteArray> { + buf = buf + data.copyOfRange(offset, offset + length) + val fragments = ArrayList<ByteArray>() + + if (!synced) { + val idx = findMoof() + if (idx == -1) return fragments + if (!preambleSeen) { preambleSeen = true; onPreamble?.invoke(buf.copyOfRange(0, idx)) } + buf = buf.copyOfRange(idx, buf.size) + synced = true + } + + while (buf.size >= 8) { + val size = u32(buf, 0) + val type = u32(buf, 4) + + if (size < 8) { + // The byte stream stopped being framed fMP4. It recovers by + // rescanning, and this line is the only trace that the picture + // on the television is missing a piece. + warn("box sync lost: invalid size $size, rescanning") + synced = false + val idx = findMoof() + if (idx == -1) return fragments + buf = buf.copyOfRange(idx, buf.size) + synced = true + continue + } + + if (type == MOOF) { + if (buf.size < size + 8) break + val mdat = u32(buf, size.toInt()) + val pair = size + mdat + if (buf.size < pair) break + fragments.add(buf.copyOfRange(0, pair.toInt())) + buf = buf.copyOfRange(pair.toInt(), buf.size) + } else { + if (buf.size < size) break + buf = buf.copyOfRange(size.toInt(), buf.size) + } + } + return fragments + } + + fun reset() { buf = ByteArray(0); synced = false; preambleSeen = false } + + private fun findMoof(): Int { + for (i in 0..buf.size - 8) { + if (u32(buf, i + 4) == MOOF) { + val size = u32(buf, i) + if (size >= 8 && size < 1_000_000) return i + } + } + return -1 + } + + companion object { + const val MOOF = 0x6d6f6f66L + var warn: (String) -> Unit = { try { Log.w("MeshBay", "[cast-relay] $it") } catch (e: RuntimeException) { System.err.println(it) } } + + fun u32(b: ByteArray, at: Int): Long = + ((b[at].toLong() and 0xff) shl 24) or ((b[at + 1].toLong() and 0xff) shl 16) or + ((b[at + 2].toLong() and 0xff) shl 8) or (b[at + 3].toLong() and 0xff) + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastChannels.kt new file mode 100644 index 0000000..74a086b --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastChannels.kt @@ -0,0 +1,115 @@ +package org.meshbay.client.cast + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import org.json.JSONArray +import org.json.JSONObject +import org.meshbay.client.bridge.Refused +import java.net.Inet4Address +import java.net.InetAddress +import java.util.Base64 + +/** + * main.js `cast:*`: the relay, the receiver, and what keeps both alive. + * + * `onCasting(true)` is called once the relay is up (start the foreground + * service, keep the WebView visible), `onCasting(false)` once it is down. + */ +class CastChannels( + private val context: Context, + private val onCasting: (Boolean) -> Unit, + private val tell: (String) -> Unit = {}, +) { + val control = CastControl(context) + val relay = CastRelay(::lanAddress, java.io.File(context.cacheDir, "cast-relay")) + + fun handles(channel: String) = channel.startsWith("cast:") + + /** What must reach the relay in the order the page sent it: start, pushes, subtitle, finish, stop. */ + fun ordered(channel: String) = channel in setOf("cast:start", "cast:push", "cast:subtitle", "cast:finish", "cast:stop") + + fun call(channel: String, a: JSONArray): Any? = when (channel) { + "cast:start" -> { + val o = a.optJSONObject(0) ?: JSONObject() + val init = o.optString("initSegment", "").takeIf { it.isNotEmpty() }?.let { Base64.getDecoder().decode(it) } + val started = relay.start(init, o.optJSONObject("subtitle")) + onCasting(true) + started + } + "cast:subtitle" -> relay.setSubtitle(a.optJSONObject(0)) + "cast:push" -> { // the base64 path, for a WebView without ArrayBuffer messages + relay.push(Base64.getDecoder().decode(a.optString(0, ""))); true + } + "cast:stop" -> { relay.stop(); onCasting(false); true } + "cast:finish" -> { relay.finish(); true } + "cast:status" -> JSONObject().put("active", relay.active).put("url", relay.url ?: JSONObject.NULL) + .put("subtitle", relay.subtitleInfo() ?: JSONObject.NULL).put("chromecast", control.status()) + + "cast:scan" -> { control.startScan(); true } + "cast:devices" -> control.devices() + "cast:chromecast:connect" -> { + val o = a.optJSONObject(0) ?: JSONObject() + reported { control.connect(o.optString("deviceId", ""), relayUrl(o), subtitleOf(o)) } + } + "cast:chromecast:reload" -> { + val o = a.optJSONObject(0) ?: JSONObject() + reported { control.reload(relayUrl(o), subtitleOf(o)) } + } + "cast:chromecast:pause" -> control.pause() + "cast:chromecast:play" -> control.play() + "cast:chromecast:disconnect" -> control.disconnect() + else -> throw Refused("Refused: no such channel") + } + + /** + * A failed cast says why, on the screen. The player catches the error and + * stops the relay without a word, which leaves the television on the + * receiver's idle screen and nobody knowing whether it ever reached the + * phone — so the receiver's reason and that fact are shown here. + */ + private fun <T> reported(block: () -> T): T = try { block() } catch (e: Exception) { + val reached = if (relay.streamRequests > 0) "the television did reach this phone" + else "the television never reached this phone at ${relay.url?.substringBefore("/stream") ?: "?"}" + tell("Cast failed: ${e.message} — $reached") + throw e + } + + fun push(bytes: ByteArray, offset: Int, length: Int): Boolean { relay.push(bytes, offset, length); return true } + + /** + * The receiver is only ever pointed at this relay. A URL the page names is + * accepted when it is the relay's own, and refused otherwise — a page + * cannot use this application to make a television fetch anything else. + */ + private fun relayUrl(o: JSONObject): String { + val asked = o.optString("mediaUrl", "") + val ours = relay.url ?: throw Refused("The cast relay is not running") + if (asked != ours) throw Refused("Refused: not this relay's stream") + return ours + } + + /** As main.js: no subtitle named means the relay's current one. */ + private fun subtitleOf(o: JSONObject): JSONObject? = + if (o.has("subtitle") && o.get("subtitle") != JSONObject.NULL) o.optJSONObject("subtitle") else relay.subtitleInfo() + + /** + * The Wi-Fi (or Ethernet) address, never the mobile network's: a TV cannot + * be reached there. Nor a VPN's: a VPN network carries the transports of + * the network under it, Wi-Fi included, and its address is a tunnel the + * television cannot route to. + */ + private fun lanAddress(): InetAddress? { + val cm = context.getSystemService(ConnectivityManager::class.java) + for (network in cm.allNetworks) { + val caps = cm.getNetworkCapabilities(network) ?: continue + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) continue + if (!caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && + !caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) continue + val addr = cm.getLinkProperties(network)?.linkAddresses?.map { it.address } + ?.firstOrNull { it is Inet4Address && !it.isLoopbackAddress } + if (addr != null) return addr + } + return null + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastControl.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastControl.kt new file mode 100644 index 0000000..f574c89 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastControl.kt @@ -0,0 +1,370 @@ +package org.meshbay.client.cast + +import android.content.Context +import android.graphics.Color +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.mediarouter.media.MediaRouteSelector +import androidx.mediarouter.media.MediaRouter +import com.google.android.gms.cast.CastMediaControlIntent +import com.google.android.gms.cast.MediaInfo +import com.google.android.gms.cast.MediaLoadRequestData +import com.google.android.gms.cast.MediaStatus +import com.google.android.gms.cast.MediaTrack +import com.google.android.gms.cast.TextTrackStyle +import com.google.android.gms.cast.framework.CastContext +import com.google.android.gms.cast.framework.CastOptions +import com.google.android.gms.cast.framework.CastSession +import com.google.android.gms.cast.framework.OptionsProvider +import com.google.android.gms.cast.framework.SessionManagerListener +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import org.json.JSONArray +import org.json.JSONObject +import org.meshbay.client.bridge.Refused +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Discovery and control of a receiver — what cast-chromecast.js does with mDNS + * and the cast protocol client, done here with the platform's cast sender SDK + * and MediaRouter. The page keeps its own picker: it polls `devices()` while a + * scan runs and receivers are listed as they answer. + * + * The default media receiver: no receiver registration. Needs the vendor's + * play services; where they are absent `available()` is false and the page + * offers no cast button rather than one that fails. + */ +class CastControl(private val context: Context) { + private val main = Handler(Looper.getMainLooper()) + private val devices = ConcurrentHashMap<String, String>() + @Volatile private var scanning = false + @Volatile private var deviceName: String? = null + private var router: MediaRouter? = null + private val selector by lazy { + MediaRouteSelector.Builder() + .addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)) + .build() + } + + private val routes = object : MediaRouter.Callback() { + override fun onRouteAdded(r: MediaRouter, route: MediaRouter.RouteInfo) = note(route) + override fun onRouteChanged(r: MediaRouter, route: MediaRouter.RouteInfo) = note(route) + override fun onRouteRemoved(r: MediaRouter, route: MediaRouter.RouteInfo) { devices.remove(route.id) } + } + + private fun note(route: MediaRouter.RouteInfo) { + if (!route.isDefault && route.isEnabled && route.matchesSelector(selector)) devices[route.id] = route.name + } + + fun available(): Boolean = try { + GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS + } catch (e: Exception) { false } + + private fun need() { if (!available()) throw Refused("Casting is not available on this device") } + + /** + * Create the cast context at launch, as the SDK asks. Created only at the + * first connect, it was born in the same moment the route was selected, + * and the session started without the listener hearing of it: on a fresh + * start the first cast timed out, every time (measured). + */ + fun warmUp() { + if (!available()) return + try { onMain { CastContext.getSharedInstance(context) } } catch (e: Exception) { Log.w(TAG, "cast context", e) } + } + + private fun <T> onMain(timeoutS: Long = 10, block: () -> T): T { + if (Looper.myLooper() == Looper.getMainLooper()) return block() + val done = CountDownLatch(1) + var value: Result<T>? = null + main.post { value = runCatching(block); done.countDown() } + if (!done.await(timeoutS, TimeUnit.SECONDS)) throw IllegalStateException("the cast service did not answer") + return value!!.getOrThrow() + } + + /** Start a scan and return at once; `devices()` reads what it has found so far. */ + fun startScan() { + need() + onMain { + val r = router ?: MediaRouter.getInstance(context).also { router = it } + devices.clear() + r.removeCallback(routes) + r.addCallback(selector, routes, MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN) + r.routes.forEach(::note) + scanning = true + main.removeCallbacksAndMessages(SCAN_TOKEN) + main.postAtTime({ + // Keep listening for changes, stop the active (radio-costly) scan. + r.addCallback(selector, routes, 0) + scanning = false + }, SCAN_TOKEN, android.os.SystemClock.uptimeMillis() + SCAN_DURATION_MS) + } + } + + fun devices(): JSONObject { + val list = JSONArray() + for ((id, name) in devices) list.put(JSONObject().put("id", id).put("name", name)) + return JSONObject().put("devices", list).put("scanning", scanning) + } + + /** Connect, launch the default receiver, load; answers once the receiver has. */ + fun connect(deviceId: String, mediaUrl: String, subtitle: JSONObject?): JSONObject { + need() + val session = onMain { + val cast = CastContext.getSharedInstance(context) + val r = router ?: MediaRouter.getInstance(context).also { router = it } + val route = r.routes.firstOrNull { it.id == deviceId } ?: throw Refused("Unknown device: $deviceId") + cast.sessionManager.currentCastSession?.takeIf { it.isConnected }?.let { return@onMain it } + val started = CountDownLatch(1) + var result: CastSession? = null + val listener = object : SessionManagerListener<CastSession> { + override fun onSessionStarted(s: CastSession, id: String) { result = s; started.countDown() } + override fun onSessionStartFailed(s: CastSession, error: Int) { + Log.w(TAG, "session start failed: error=$error") + started.countDown() + } + override fun onSessionStarting(s: CastSession) {} + override fun onSessionEnding(s: CastSession) {} + override fun onSessionEnded(s: CastSession, error: Int) {} + override fun onSessionResuming(s: CastSession, id: String) {} + override fun onSessionResumed(s: CastSession, wasSuspended: Boolean) { result = s; started.countDown() } + override fun onSessionResumeFailed(s: CastSession, error: Int) {} + override fun onSessionSuspended(s: CastSession, reason: Int) {} + } + cast.sessionManager.addSessionManagerListener(listener, CastSession::class.java) + Log.i(TAG, "connect: selecting route '${route.name}'") + r.selectRoute(route) + deviceName = route.name + Pending(started, { result }, { cast.sessionManager.removeSessionManagerListener(listener, CastSession::class.java) }) + }.let { s -> + when (s) { + is CastSession -> s + is Pending -> try { + // The listener, or the session manager itself: a started + // session the listener missed is still a started session. + val deadline = System.currentTimeMillis() + 15_000 + var found: CastSession? = null + while (found == null && System.currentTimeMillis() < deadline) { + if (s.done.await(300, TimeUnit.MILLISECONDS)) { + found = s.session() ?: throw IllegalStateException("The receiver refused the connection") + } else { + found = onMain { + CastContext.getSharedInstance(context).sessionManager.currentCastSession + ?.takeIf { it.isConnected } + } + if (found != null) Log.i(TAG, "session found connected without its callback") + } + } + found ?: throw IllegalStateException("Connection timeout") + } finally { main.post { s.cleanup() } } + else -> throw IllegalStateException() + } + } + // The cast framework's objects answer on the main thread only, the + // device's name included: read it there, never from this worker. + val name = deviceName ?: onMain { session.castDevice?.friendlyName } + Log.i(TAG, "session up on '$name', loading the relay stream") + val state = load(session, mediaUrl, subtitle) + watch(session) + return JSONObject().put("deviceName", name ?: JSONObject.NULL).put("playerState", state) + } + + private class Pending(val done: CountDownLatch, val session: () -> CastSession?, val cleanup: () -> Unit) + + /** + * What the receiver does for the whole film, not only at the start: a + * freeze on the television is a BUFFERING the phone never hears about + * otherwise. Logged with the position, on the main thread the SDK wants. + */ + private var watched: com.google.android.gms.cast.framework.media.RemoteMediaClient? = null + private val watcher = object : com.google.android.gms.cast.framework.media.RemoteMediaClient.Callback() { + private var last = -1 + override fun onStatusUpdated() { + val c = watched ?: return + val state = c.playerState + if (state == last) return + last = state + Log.i(TAG, "receiver state ${stateName(state)} at ${c.approximateStreamPosition / 1000.0}s" + + (if (state == MediaStatus.PLAYER_STATE_IDLE) " (idle: ${idleName(c.idleReason)})" else "")) + } + } + + private fun watch(session: CastSession) = onMain { + val c = session.remoteMediaClient ?: return@onMain + if (watched === c) return@onMain + watched?.unregisterCallback(watcher) + c.registerCallback(watcher) + watched = c + } + + fun reload(mediaUrl: String, subtitle: JSONObject?): JSONObject { + need() + val session = onMain { CastContext.getSharedInstance(context).sessionManager.currentCastSession } + ?: throw Refused("Not connected") + val state = load(session, mediaUrl, subtitle) + watch(session) + return JSONObject().put("playerState", state) + } + + private fun load(session: CastSession, mediaUrl: String, subtitle: JSONObject?): String { + val subUrl = subtitle?.optString("url", "")?.takeIf { it.isNotEmpty() } + val info = MediaInfo.Builder(mediaUrl) + .setContentType("video/mp4") + // LIVE: the relay has no beginning to seek back to — the film's own + // timeline lives on this side, and a seek restarts the relay. + .setStreamType(MediaInfo.STREAM_TYPE_LIVE) + .apply { + if (subUrl != null) { + setMediaTracks(listOf(MediaTrack.Builder(TEXT_TRACK_ID, MediaTrack.TYPE_TEXT) + .setContentId(subUrl).setContentType("text/vtt") + .setSubtype(MediaTrack.SUBTYPE_SUBTITLES) + .setName(subtitle.optString("label", "").ifEmpty { "Subtitles" }) + .setLanguage(subtitle.optString("language", "").ifEmpty { "und" }) + .build())) + // White on nothing is unreadable over a bright scene; an outline costs no bandwidth. + setTextTrackStyle(TextTrackStyle().apply { + backgroundColor = Color.TRANSPARENT + foregroundColor = Color.WHITE + edgeType = TextTrackStyle.EDGE_TYPE_OUTLINE + edgeColor = Color.BLACK + fontScale = 1.0f + fontGenericFamily = TextTrackStyle.FONT_FAMILY_SANS_SERIF + }) + } + }.build() + val request = MediaLoadRequestData.Builder().setMediaInfo(info).setAutoplay(true) + .apply { if (subUrl != null) setActiveTrackIds(longArrayOf(TEXT_TRACK_ID)) }.build() + val loaded = CountDownLatch(1) + var ok = false + var reason = "" + onMain { + val client = session.remoteMediaClient ?: throw IllegalStateException("The receiver has no media channel") + client.load(request).setResultCallback { r -> + ok = r.status.isSuccess + reason = "code ${r.status.statusCode}" + (r.status.statusMessage?.let { ", $it" } ?: "") + // The receiver's own reason, which is the only one there is: + // the page is told "refused" and nothing else. + Log.i(TAG, "load result: ok=$ok code=${r.status.statusCode} " + + "message=${r.status.statusMessage} state=${stateName(client.playerState)} " + + "idleReason=${client.idleReason} subtitles=${subUrl != null}") + loaded.countDown() + } + } + if (!loaded.await(20, TimeUnit.SECONDS)) { + Log.w(TAG, "load: no answer from the receiver in 20 s") + throw IllegalStateException("The receiver did not load the stream") + } + if (!ok) throw IllegalStateException("The receiver refused the stream ($reason)") + // A load the receiver accepted is not a film on the screen: it answers + // IDLE, then fetches the stream, and only then plays — or gives up. + // So the answer waits for what the receiver actually did (measured + // against this receiver: OK/IDLE, then BUFFERING, then PLAYING). + val deadline = System.currentTimeMillis() + PLAY_WAIT_MS + while (System.currentTimeMillis() < deadline) { + val (state, idle) = onMain { + val c = session.remoteMediaClient + (c?.playerState ?: MediaStatus.PLAYER_STATE_UNKNOWN) to (c?.idleReason ?: MediaStatus.IDLE_REASON_NONE) + } + if (state == MediaStatus.PLAYER_STATE_PLAYING || state == MediaStatus.PLAYER_STATE_BUFFERING || + state == MediaStatus.PLAYER_STATE_PAUSED) { + Log.i(TAG, "receiver is ${stateName(state)}") + return stateName(state) + } + if (state == MediaStatus.PLAYER_STATE_IDLE && idle != MediaStatus.IDLE_REASON_NONE) { + Log.w(TAG, "receiver gave up: idle reason ${idleName(idle)}") + throw IllegalStateException("The receiver gave up on the stream (${idleName(idle)})") + } + Thread.sleep(300) + } + Log.w(TAG, "receiver still not playing after ${PLAY_WAIT_MS / 1000} s") + return onMain { stateName(session.remoteMediaClient?.playerState ?: MediaStatus.PLAYER_STATE_UNKNOWN) } + } + + /** The player as a remote: pause and play the receiver, answered once it has. */ + fun pause(): JSONObject = command { it.pause() } + fun play(): JSONObject = command { it.play() } + + private fun command(send: (com.google.android.gms.cast.framework.media.RemoteMediaClient) -> + com.google.android.gms.common.api.PendingResult<com.google.android.gms.cast.framework.media.RemoteMediaClient.MediaChannelResult>): JSONObject { + need() + val done = CountDownLatch(1) + var ok = false + onMain { + val c = CastContext.getSharedInstance(context).sessionManager.currentCastSession?.remoteMediaClient + ?: throw Refused("Not connected") + send(c).setResultCallback { r -> ok = r.status.isSuccess; done.countDown() } + } + if (!done.await(10, TimeUnit.SECONDS)) throw IllegalStateException("The receiver did not answer") + if (!ok) throw IllegalStateException("The receiver refused the command") + return JSONObject().put("playerState", onMain { + stateName(CastContext.getSharedInstance(context).sessionManager.currentCastSession?.remoteMediaClient?.playerState + ?: MediaStatus.PLAYER_STATE_UNKNOWN) + }) + } + + fun disconnect(): Boolean { + if (!available()) return true + onMain { CastContext.getSharedInstance(context).sessionManager.endCurrentSession(true) } + deviceName = null + return true + } + + fun status(): JSONObject = try { + if (!available()) JSONObject().put("connected", false).put("deviceName", JSONObject.NULL) + else onMain { + val s = CastContext.getSharedInstance(context).sessionManager.currentCastSession + val on = s != null && s.isConnected + val c = if (on) s!!.remoteMediaClient else null + JSONObject().put("connected", on).put("deviceName", if (on) (deviceName ?: s!!.castDevice?.friendlyName) else JSONObject.NULL) + // Where the television is, on the stream's timeline (zero at + // the relay's start): the page adds that start. Not the local + // playhead, which started earlier and drifts. + .put("playerState", c?.let { stateName(it.playerState) } ?: JSONObject.NULL) + .put("idleReason", c?.takeIf { it.playerState == MediaStatus.PLAYER_STATE_IDLE } + ?.let { idleName(it.idleReason) } ?: JSONObject.NULL) + .put("position", c?.let { it.approximateStreamPosition / 1000.0 } ?: JSONObject.NULL) + } + } catch (e: Exception) { + Log.w("MeshBay", "cast status", e) + JSONObject().put("connected", false).put("deviceName", JSONObject.NULL) + } + + companion object { + const val SCAN_DURATION_MS = 6000L + private const val TAG = "MeshBayCast" + // The one track the receiver is ever told about; changing subtitles + // reloads with a new URL under this same id. + const val TEXT_TRACK_ID = 1L + private val SCAN_TOKEN = Any() + + private const val PLAY_WAIT_MS = 15000L + + fun idleName(r: Int) = when (r) { + MediaStatus.IDLE_REASON_FINISHED -> "finished" + MediaStatus.IDLE_REASON_CANCELED -> "cancelled" + MediaStatus.IDLE_REASON_INTERRUPTED -> "interrupted" + MediaStatus.IDLE_REASON_ERROR -> "error" + else -> "reason $r" + } + + fun stateName(s: Int) = when (s) { + MediaStatus.PLAYER_STATE_PLAYING -> "PLAYING" + MediaStatus.PLAYER_STATE_PAUSED -> "PAUSED" + MediaStatus.PLAYER_STATE_BUFFERING -> "BUFFERING" + MediaStatus.PLAYER_STATE_LOADING -> "LOADING" + MediaStatus.PLAYER_STATE_IDLE -> "IDLE" + else -> "UNKNOWN" + } + } +} + +/** The cast framework asks the manifest for this: the default media receiver. */ +class CastOptionsProvider : OptionsProvider { + override fun getCastOptions(context: Context): CastOptions = + CastOptions.Builder().setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID).build() + + override fun getAdditionalSessionProviders(context: Context) = null +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastRelay.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastRelay.kt new file mode 100644 index 0000000..21328db --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastRelay.kt @@ -0,0 +1,446 @@ +package org.meshbay.client.cast + +import android.util.Log +import org.json.JSONObject +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.channels.FileChannel +import java.io.OutputStream +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketException +import java.security.SecureRandom +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +/** + * Local HTTP relay for LAN casting — a port of meshbay-client/src/cast-relay.js, + * whose comments are the specification. + * + * The page decrypts the fMP4 stream from the node and pushes it here; a + * receiver on the same Wi-Fi plays it from the URL. Same mitigations: + * + * - bound to the LAN interface (the caller supplies it), never 0.0.0.0; + * - fixed port range 19550–19553, open only while casting; + * - an unguessable token in the URL (32 hex chars); + * - CORS on both served paths, both behind the token; + * - `Cache-Control: no-store` on every response; + * - the server closed when playback stops — zero residual surface. + * + * What a receiver has not read yet waits in a file, not in memory. The page + * runs ahead of the television by its whole read-ahead — tens of megabytes in + * the first seconds — and a fragment is a segment, megabytes at a film's + * bitrate (5 to 10 MB measured). Held in memory under the desktop's 8 MB + * bound, fragments were dropped and the picture froze for their length; held + * in memory without a bound, the heap went. A spool file per receiver holds + * the lead at no cost to either, and is deleted when the receiver goes. + * + * `java.net` and `java.nio` only, so the JVM tests run the real thing. + */ +class CastRelay(private val lanAddress: () -> InetAddress?, private val spoolDir: File) { + + /** + * One receiver: the fragments it has been given, appended to its spool + * file, and how far it has read. Positional reads and writes on one + * channel, so the pusher and the reader never share a file pointer. + */ + private class Client(val socket: Socket, val out: OutputStream, val file: File) { + val channel: FileChannel = RandomAccessFile(file, "rw").channel + val lock = Object() + var written = 0L // guarded by lock + var read = 0L // guarded by lock + var ended = false // guarded by lock + @Volatile var closed = false + val sent = AtomicLong() + val dropped = AtomicLong() + @Volatile var lastReport = System.currentTimeMillis() + + fun waiting() = synchronized(lock) { written - read } + + fun append(data: ByteArray) { + var at = synchronized(lock) { written } + val buf = ByteBuffer.wrap(data) + while (buf.hasRemaining()) at += channel.write(buf, at) + synchronized(lock) { written = at; lock.notifyAll() } + } + + fun end() = synchronized(lock) { ended = true; lock.notifyAll() } + + fun close() { + closed = true + synchronized(lock) { lock.notifyAll() } + try { channel.close() } catch (e: Exception) {} + file.delete() + } + } + + private val spoolSeq = AtomicLong() + + private class Subtitle(val vtt: ByteArray, val language: String, val label: String) + + @Volatile private var server: ServerSocket? = null + @Volatile private var port = 0 + @Volatile private var token: String? = null + @Volatile private var host: String? = null + @Volatile private var initSegment: ByteArray? = null + private val headerLock = Object() + @Volatile private var headerReady = false + @Volatile private var subtitle: Subtitle? = null + @Volatile private var subtitleVersion = 0 + /** How many times a receiver asked for the stream since the relay started. */ + @Volatile var streamRequests = 0 + private set + private val ring = ArrayDeque<ByteArray>() + private var ringBytes = 0L + private val clients = ConcurrentHashMap.newKeySet<Client>() + private var accum = BoxAccumulator() + + val active get() = server != null + /** For the tests: what a receiver joining now would be sent before live fragments. */ + fun backlogBytes() = synchronized(ring) { ringBytes } + + val url get() = if (server == null) null else "http://${hostPart()}:$port/stream.mp4?t=$token" + + private fun hostPart() = host?.let { if (it.contains(':')) "[$it]" else it } + + /** Versioned: a receiver caches a side-loaded track by its address. */ + val subtitleUrl get() = + if (server == null || subtitle == null) null + else "http://${hostPart()}:$port/subs.vtt?t=$token&v=$subtitleVersion" + + fun subtitleInfo(): JSONObject? = subtitle?.let { + JSONObject().put("url", subtitleUrl).put("language", it.language).put("label", it.label) + } + + /** Cues already on the stream's timeline — the page shifts them; the relay serves bytes. */ + fun setSubtitle(sub: JSONObject?): JSONObject? { + val vtt = sub?.optString("vtt", "") ?: "" + subtitle = if (vtt.isEmpty()) null + else Subtitle(vtt.toByteArray(Charsets.UTF_8), sub!!.optString("language", ""), sub.optString("label", "")) + subtitleVersion++ + return subtitleInfo() + } + + @Synchronized + fun start(init: ByteArray?, sub: JSONObject?): JSONObject { + if (server != null) stop() + val address = lanAddress() ?: throw IllegalStateException("Casting needs this device on Wi-Fi") + token = randomToken() + streamRequests = 0 + pushes = 0 + fragments = 0 + host = address.hostAddress + initSegment = init?.let(::headerOnly) + // Nothing to wait for without a first chunk: no header is coming. + headerReady = init == null + synchronized(ring) { ring.clear(); ringBytes = 0 } + clients.clear() + accum = BoxAccumulator().also { a -> a.onPreamble = ::preamble } + // What a killed process left behind. + spoolDir.mkdirs() + spoolDir.listFiles()?.forEach { it.delete() } + subtitle = null + setSubtitle(sub) + + var bound: ServerSocket? = null + for (i in 0 until PORT_COUNT) { + val s = ServerSocket() + try { + // As Node's server does (and so the desktop relay): a port just + // closed sits in TIME_WAIT, and every seek restarts the relay — + // without this, four quick seeks used all four ports. It does + // not let two live listeners share a port. + s.reuseAddress = true + s.bind(InetSocketAddress(address, PORT_BASE + i)) + bound = s; port = PORT_BASE + i + break + } catch (e: java.net.BindException) { + s.close() + } + } + server = bound ?: throw IllegalStateException("All cast relay ports are in use") + Thread({ acceptLoop(bound) }, "cast-relay-accept").apply { isDaemon = true }.start() + log("started on ${hostPart()}:$port, init ${init?.size ?: 0} bytes, header ${initSegment?.size ?: 0} bytes") + return JSONObject().put("url", url).put("port", port).put("token", token) + .put("subtitle", subtitleInfo() ?: JSONObject.NULL) + } + + /** + * The header is everything the page pushed before the first moof. The + * `init` the page hands to start() is only its first chunk, which may be + * the ftyp without the moov: served as the header, the receiver got no + * track description and gave up — the "fails the first time" of a fresh + * start, every time. The pushed stream carries the whole of it; `init` is + * the fallback when nothing came before the first moof. + */ + private fun preamble(bytes: ByteArray) { + val header = headerOnly(bytes) + synchronized(headerLock) { + if (header.size >= 8 && BoxAccumulator.u32(header, 4) == FTYP) initSegment = header + headerReady = true + headerLock.notifyAll() + } + log("header complete: ${initSegment?.size ?: 0} bytes") + } + + /** A receiver that connects before the first moof waits for the whole header, not a piece of it. */ + private fun awaitHeader(): ByteArray? { + val deadline = System.currentTimeMillis() + HEADER_WAIT_MS + synchronized(headerLock) { + while (!headerReady) { + val left = deadline - System.currentTimeMillis() + if (left <= 0) break + headerLock.wait(left) + } + } + return initSegment + } + + @Volatile private var pushes = 0 + @Volatile private var fragments = 0 + + fun push(data: ByteArray, offset: Int = 0, length: Int = data.size - offset) { + if (server == null) return + pushes++ + if (pushes <= 3 || pushes % 100 == 0) log("push #$pushes: $length bytes, $fragments fragments so far") + for (frag in accum.push(data, offset, length)) { + fragments++ + // The backlog and the clients under one lock: a receiver joining + // gets each fragment exactly once, from the backlog or from here. + synchronized(ring) { + ring.addLast(frag); ringBytes += frag.size + // Bounded in fragments, as the desktop is, and in bytes too: a + // fragment is a whole segment, megabytes at a film's bitrate, + // and 64 of them outgrew a phone's heap — the app died of it. + while (ring.size > RING_CAP || (ringBytes > RING_MAX_BYTES && ring.size > 1)) { + ringBytes -= ring.removeFirst().size + } + for (c in clients) { + if (c.waiting() > spoolLimit()) { + // Only with the disk bound reached: the picture on the + // receiver freezes until the next fragment it gets. + val n = c.dropped.incrementAndGet() + if (n <= 3 || n % 20 == 0L) { + log("DROPPED fragment for ${c.socket.inetAddress?.hostAddress}: ${frag.size} bytes, " + + "${c.waiting() / 1048576} MiB already waiting, $n dropped so far") + } + continue + } + try { c.append(frag) } catch (e: Exception) { log("spool write failed: ${e.message}") } + } + } + } + } + + /** End of film: every client's response is ended, the server stays until stop. */ + fun finish() { + for (c in clients) c.end() + } + + /** Half the free space, at most 2 GiB: a receiver this far behind is not coming back. */ + private fun spoolLimit(): Long = minOf(SPOOL_MAX_BYTES, spoolDir.usableSpace / 2) + + @Synchronized + fun stop() { + for (c in clients) { c.close(); try { c.socket.close() } catch (e: Exception) {} } + clients.clear() + server?.let { try { it.close() } catch (e: Exception) {} } + server = null + port = 0; token = null; initSegment = null; subtitle = null + synchronized(ring) { ring.clear(); ringBytes = 0 } + accum.reset() + } + + // ── HTTP ──────────────────────────────────────────────────────────────── + + private fun acceptLoop(s: ServerSocket) { + while (!s.isClosed) { + val socket = try { s.accept() } catch (e: SocketException) { return } + Thread({ serve(socket) }, "cast-relay-client").apply { isDaemon = true }.start() + } + } + + private fun serve(socket: Socket) { + try { + socket.soTimeout = 15000 + val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.ISO_8859_1)) + val requestLine = reader.readLine() ?: return socket.close() + while (true) { val line = reader.readLine() ?: break; if (line.isEmpty()) break } + socket.soTimeout = 0 + val parts = requestLine.split(' ') + val method = parts.getOrElse(0) { "" } + val target = parts.getOrElse(1) { "" } + val out = socket.getOutputStream() + + if (method != "GET" && method != "OPTIONS") return respond(out, socket, 405, emptyMap()) + // The preflight is answered before the token is examined: a + // receiver sends it without credentials, and refusing it would + // read on the receiver as a network failure, not a refusal. + if (method == "OPTIONS") return respond(out, socket, 204, CORS_HEADERS) + + val path = target.substringBefore('?') + log("$method $path from ${socket.inetAddress?.hostAddress}") + val query = target.substringAfter('?', "").split('&').associate { + it.substringBefore('=') to it.substringAfter('=', "") + } + if (token == null || query["t"] != token) return respond(out, socket, 403, emptyMap()) + when (path) { + "/subs.vtt" -> serveSubtitle(out, socket) + "/stream.mp4" -> { streamRequests++; serveStream(out, socket) } + else -> respond(out, socket, 404, emptyMap()) + } + } catch (e: Exception) { + try { socket.close() } catch (x: Exception) {} + } + } + + private fun serveSubtitle(out: OutputStream, socket: Socket) { + val sub = subtitle ?: return respond(out, socket, 404, CORS_HEADERS) + respond(out, socket, 200, CORS_HEADERS + mapOf( + "Content-Type" to "text/vtt; charset=utf-8", + "Content-Length" to sub.vtt.size.toString(), + "Cache-Control" to "no-store", + ), sub.vtt) + } + + private fun serveStream(out: OutputStream, socket: Socket) { + // Same headers as the subtitle: a receiver given a side-loaded track + // reads the media through the same CORS-checked path. + head(out, 200, CORS_HEADERS + mapOf( + "Content-Type" to "video/mp4", + "Cache-Control" to "no-store", + "Accept-Ranges" to "none", + "Connection" to "keep-alive", + "Transfer-Encoding" to "chunked", + )) + awaitHeader()?.let { chunk(out, it) } + out.flush() + val client = Client(socket, out, File(spoolDir, "client-${spoolSeq.incrementAndGet()}.spool")) + val backlog = synchronized(ring) { + for (frag in ring) client.append(frag) + clients.add(client) + ring.size + } + log("client ${socket.inetAddress?.hostAddress} served init + $backlog fragments") + val block = ByteBuffer.allocate(BLOCK) + try { + while (!client.closed) { + val at = synchronized(client.lock) { + while (client.read == client.written && !client.ended && !client.closed) client.lock.wait() + if (client.read == client.written) -1L else client.read + } + if (at < 0) { + if (!client.closed) { out.write("0\r\n\r\n".toByteArray()); out.flush() } + break + } + block.clear() + val n = client.channel.read(block, at) + if (n <= 0) continue + val t0 = System.currentTimeMillis() + chunk(out, block.array(), n) + out.flush() + val took = System.currentTimeMillis() - t0 + synchronized(client.lock) { client.read += n } + client.sent.addAndGet(n.toLong()) + // A write that blocks is the receiver not reading (or the + // Wi-Fi not carrying): the one place a stall downstream shows. + if (took > 5000) log("slow write to ${socket.inetAddress?.hostAddress}: $n bytes took $took ms") + val now = System.currentTimeMillis() + if (now - client.lastReport >= 10_000) { + client.lastReport = now + log("client ${socket.inetAddress?.hostAddress}: sent ${client.sent.get() / 1048576} MiB, " + + "${client.waiting() / 1048576} MiB waiting in the spool, ${client.dropped.get()} dropped") + } + } + } catch (e: Exception) { + // The receiver went away; nothing to tell anyone. + } finally { + log("client ${socket.inetAddress?.hostAddress} gone") + synchronized(ring) { clients.remove(client) } + client.close() + try { socket.close() } catch (e: Exception) {} + } + } + + companion object { + /** + * The init segment is what comes before the first moof, and only that. + * + * The page hands over the first chunk it decrypted, which is a slice of + * the stream and not a box: on a real film it was 65536 bytes — ftyp, + * moov, then the first moof and the start of its mdat. Served whole, + * the receiver read that partial fragment, then the same fragment again + * from the accumulator (the page pushes that chunk too), and the box + * structure was broken from the first fragment: the receiver gave up + * within three seconds, on the television's idle screen. Whether the + * first chunk carries film depends on when the node read ffmpeg's + * output, so the same film can work once and not the next time. + */ + fun headerOnly(init: ByteArray): ByteArray { + var at = 0 + while (at + 8 <= init.size) { + if (BoxAccumulator.u32(init, at + 4) == BoxAccumulator.MOOF) return init.copyOfRange(0, at) + val size = BoxAccumulator.u32(init, at) + if (size < 8) break + at += size.toInt() + } + return init + } + + const val RING_CAP = 64 + const val RING_MAX_BYTES = 32L * 1024 * 1024 + const val SPOOL_MAX_BYTES = 2L * 1024 * 1024 * 1024 + private const val HEADER_WAIT_MS = 10_000L + private const val FTYP = 0x66747970L + private const val BLOCK = 256 * 1024 + const val PORT_BASE = 19550 + const val PORT_COUNT = 4 + + // Never the URL: it carries the token. + private fun log(m: String) = try { Log.i("MeshBayCast", "[relay] $m") } catch (e: RuntimeException) { /* JVM tests */ } + + // A receiver reads a side-loaded subtitle with XHR from its own + // origin, so the headers it sends are allowed by name — Range included. + val CORS_HEADERS = mapOf( + "Access-Control-Allow-Origin" to "*", + "Access-Control-Allow-Methods" to "GET, OPTIONS", + "Access-Control-Allow-Headers" to "Content-Type, Accept-Encoding, Range", + "Access-Control-Expose-Headers" to "Content-Length, Content-Range", + ) + + private val REASONS = mapOf(200 to "OK", 204 to "No Content", 403 to "Forbidden", + 404 to "Not Found", 405 to "Method Not Allowed") + + private fun randomToken(): String { + val b = ByteArray(16).also { SecureRandom().nextBytes(it) } + return b.joinToString("") { "%02x".format(it) } + } + + private fun head(out: OutputStream, status: Int, headers: Map<String, String>) { + val sb = StringBuilder("HTTP/1.1 $status ${REASONS[status] ?: ""}\r\n") + for ((k, v) in headers) sb.append(k).append(": ").append(v).append("\r\n") + sb.append("\r\n") + out.write(sb.toString().toByteArray(Charsets.ISO_8859_1)) + } + + private fun respond(out: OutputStream, socket: Socket, status: Int, headers: Map<String, String>, + body: ByteArray = ByteArray(0)) { + val h = if (headers.containsKey("Content-Length")) headers else headers + ("Content-Length" to body.size.toString()) + head(out, status, h + ("Connection" to "close")) + out.write(body) + out.flush() + socket.close() + } + + private fun chunk(out: OutputStream, data: ByteArray, length: Int = data.size) { + out.write("${Integer.toHexString(length)}\r\n".toByteArray()) + out.write(data, 0, length) + out.write("\r\n".toByteArray()) + } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastService.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastService.kt new file mode 100644 index 0000000..eaa471f --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastService.kt @@ -0,0 +1,72 @@ +package org.meshbay.client.cast + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.wifi.WifiManager +import android.os.Build +import android.os.IBinder +import android.os.PowerManager + +/** + * Keeps a cast alive with the screen off: a media-playback foreground service + * with a partial wake lock and a Wi-Fi lock, for as long as the relay runs. + * + * Not sufficient alone — measured (spike S-2a): Chromium freezes a hidden page + * 60 s after the screen goes off whatever the process importance, and the + * pipeline (WebRTC → decrypt → relay) lives in the page. The shell also keeps + * the WebView reported visible while casting (ShellWebView); the two together + * held a full-rate stream through screen-off and forced Doze. + */ +class CastService : Service() { + private var wake: PowerManager.WakeLock? = null + private var wifi: WifiManager.WifiLock? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val nm = getSystemService(NotificationManager::class.java) + nm.createNotificationChannel(NotificationChannel(CHANNEL, "Casting", NotificationManager.IMPORTANCE_LOW)) + val open = PendingIntent.getActivity(this, 0, + packageManager.getLaunchIntentForPackage(packageName), PendingIntent.FLAG_IMMUTABLE) + val n = Notification.Builder(this, CHANNEL) + .setContentTitle(intent?.getStringExtra(EXTRA_TITLE) ?: "MeshBay") + .setContentText("Casting on this Wi-Fi") + .setSmallIcon(android.R.drawable.ic_media_play) + .setContentIntent(open) + .setOngoing(true) + .build() + if (Build.VERSION.SDK_INT >= 29) startForeground(ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) + else startForeground(ID, n) + if (wake == null) { + wake = getSystemService(PowerManager::class.java) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "meshbay:cast").apply { acquire(MAX_HOLD_MS) } + @Suppress("DEPRECATION") + val mode = if (Build.VERSION.SDK_INT >= 29) WifiManager.WIFI_MODE_FULL_LOW_LATENCY else WifiManager.WIFI_MODE_FULL_HIGH_PERF + wifi = (applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager) + .createWifiLock(mode, "meshbay:cast").apply { acquire() } + } + return START_NOT_STICKY + } + + override fun onDestroy() { + wake?.let { if (it.isHeld) it.release() } + wifi?.let { if (it.isHeld) it.release() } + wake = null; wifi = null + super.onDestroy() + } + + companion object { + private const val CHANNEL = "cast" + private const val ID = 7 + const val EXTRA_TITLE = "title" + // A bound on the wake lock, not on the cast: a process that is killed + // without stopping the service must not hold the CPU for ever. + private const val MAX_HOLD_MS = 6L * 3600 * 1000 + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/hub/HubClient.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/hub/HubClient.kt new file mode 100644 index 0000000..3b8517c --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/hub/HubClient.kt @@ -0,0 +1,130 @@ +package org.meshbay.client.hub + +import android.content.SharedPreferences +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import org.meshbay.client.bridge.Refused +import java.io.IOException +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLException + +/** + * Every call to the hub leaves from here, never from the page. + * + * Not a preference: the page's origin is `https://appassets.androidplatform.net`, + * which the hub's absent CORS refuses — and that posture is worth keeping, its + * API is reachable from no web origin at all. So the page asks and this goes, + * to the hub it is signed in to and nowhere else (main.js `hub:fetch`). + */ +class HubClient(private val prefs: SharedPreferences) { + + private val http = OkHttpClient.Builder() + // The hub's longest call is signaling, which gives up at fifteen + // seconds; past this, no answer is still coming (HUB_FETCH_TIMEOUT_MS). + .callTimeout(FETCH_TIMEOUT_S, TimeUnit.SECONDS) + .followRedirects(false) + .build() + + val base: String get() = prefs.getString(KEY_BASE, "") ?: "" + + /** Check that the address answers as a hub before writing it down. */ + fun setBase(raw: String): String { + val url = raw.trim().trimEnd('/') + // An empty address is not "no hub": main.js probes it like any other + // and it fails, so the first-run screen cannot be passed with nothing. + if (url.isEmpty()) throw Refused("Enter the address of a hub.") + if (!url.startsWith("https://") && !LOOPBACK_HTTP.containsMatchIn(url)) { + // http only to this device's loopback; anywhere else it would put + // the session token on the wire in clear. + throw Refused("The hub address must be https") + } + val probe = url.toHttpUrlOrNull()?.newBuilder()?.encodedPath("/v1/hub/version")?.build() + ?: throw Refused("$url is not an address") + val answer = try { + http.newBuilder().callTimeout(PROBE_TIMEOUT_S, TimeUnit.SECONDS).build() + .newCall(Request.Builder().url(probe).build()).execute().use { r -> + if (!r.isSuccessful) throw IOException("answered ${r.code}") + JSONObject(r.body.string()) + } + } catch (e: Exception) { + throw Refused(describeUnreachable(url, e)) + } + if (!answer.has("hub")) throw Refused(describeUnreachable(url, IOException("did not answer as a hub"))) + prefs.edit().putString(KEY_BASE, url).apply() + return url + } + + /** `{status, ok, headers, body}`, the shape main.js returns and platform.apiFetch reads. */ + fun fetch(url: String, init: JSONObject?): JSONObject { + val target = url.toHttpUrlOrNull() ?: throw Refused("not an address") + val hub = base.toHttpUrlOrNull() + // The page may only reach the hub it is signed in to: a path it + // controls must not become a request to somewhere else. + if (hub == null || !sameOrigin(target, hub)) throw Refused("Refused: not this hub") + + val method = (init?.optString("method").takeUnless { it.isNullOrEmpty() } ?: "GET").uppercase() + val builder = Request.Builder().url(target) + var contentType: String? = null + init?.optJSONObject("headers")?.let { h -> + for (name in h.keys()) { + val value = h.get(name).toString() + if (name.equals("content-type", ignoreCase = true)) contentType = value + builder.header(name, value) + } + } + val text = init?.opt("body")?.takeUnless { it == JSONObject.NULL }?.toString() + val body = when { + method == "GET" || method == "HEAD" -> null + else -> (text ?: "").toRequestBody(contentType?.toMediaTypeOrNull()) + } + builder.method(method, body) + + return try { + http.newCall(builder.build()).execute().use { r -> + val headers = JSONObject() + for (name in r.headers.names()) headers.put(name.lowercase(), r.headers.values(name).joinToString(", ")) + JSONObject().put("status", r.code).put("ok", r.isSuccessful) + .put("headers", headers).put("body", r.body.string()) + } + } catch (e: IOException) { + // OkHttp's call timeout is an InterruptedIOException("timeout"), a + // read timeout a SocketTimeoutException; both mean the same thing. + if (e is SocketTimeoutException || e.message?.contains("timeout", ignoreCase = true) == true) { + throw Refused("${originOf(hub)} accepted the connection but did not answer within ${FETCH_TIMEOUT_S}s.") + } + throw Refused(describeUnreachable(originOf(hub), e)) + } + } + + companion object { + private const val KEY_BASE = "hubBase" + const val FETCH_TIMEOUT_S = 30L + private const val PROBE_TIMEOUT_S = 10L + private val LOOPBACK_HTTP = Regex("^http://(localhost|127\\.)") + + fun sameOrigin(a: HttpUrl, b: HttpUrl) = a.scheme == b.scheme && a.host == b.host && a.port == b.port + + private fun originOf(u: HttpUrl): String { + val defaultPort = (u.scheme == "https" && u.port == 443) || (u.scheme == "http" && u.port == 80) + return "${u.scheme}://${u.host}" + if (defaultPort) "" else ":${u.port}" + } + + /** Why the hub could not be reached, in words somebody can act on (main.js). */ + fun describeUnreachable(url: String, e: Throwable): String = when { + url.startsWith("https:") && e is SSLException -> + "$url does not speak https. If this hub is on your own machine, it is probably http — try http:// instead." + e is ConnectException -> "Nothing is listening at $url. Is the hub running?" + e is UnknownHostException -> "$url could not be found. Check the address." + e is SocketTimeoutException || e.message?.contains("timeout", true) == true -> "$url did not answer in time." + else -> "Could not reach $url: ${e.message ?: e.javaClass.simpleName}" + } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/DeviceKey.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/DeviceKey.kt new file mode 100644 index 0000000..4cd840c --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/DeviceKey.kt @@ -0,0 +1,47 @@ +package org.meshbay.client.keys + +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import org.json.JSONObject +import java.security.SecureRandom + +/** + * The device's key for signing in to the hub (E3): generated, held and used + * here, never handed to the page, which asks for a signature over + * `meshbay:user_auth:<username>:<ts>` — the bytes `POST /v1/users/auth` + * verifies. Not a per-node identity: nothing here correlates a person across + * operators (main.js `device:*`). + */ +class DeviceKey(private val store: Secrets, private val now: () -> Long = { System.currentTimeMillis() / 1000 }) { + + private fun current(): Ed25519PrivateKeyParameters? { + val stored = store.read().optString(SecretStore.DEVICE_KEY, "") + return if (stored.isEmpty()) null else Kdf.edFromPkcs8(Kdf.unb64(stored)) + } + + private fun publicOf(k: Ed25519PrivateKeyParameters) = Kdf.b64(k.generatePublicKey().encoded) + + fun ensure(): String { + current()?.let { return publicOf(it) } + val k = Ed25519PrivateKeyParameters(SecureRandom()) + store.update { it.put(SecretStore.DEVICE_KEY, Kdf.b64(Kdf.edToPkcs8(k))) } + return publicOf(k) + } + + fun publicKey(): String? = current()?.let { publicOf(it) } + + fun sign(username: String): JSONObject? { + val k = current() ?: return null + val ts = now() + // The username is inside the signature, so one collected for another + // account is not usable. + val message = "meshbay:user_auth:$username:$ts".toByteArray(Charsets.UTF_8) + val s = Ed25519Signer().apply { init(true, k); update(message, 0, message.size) } + return JSONObject().put("timestamp", ts).put("signature", Kdf.b64(s.generateSignature())) + } + + fun forget(): Boolean { + store.update { it.remove(SecretStore.DEVICE_KEY) } + return true + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Kdf.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Kdf.kt new file mode 100644 index 0000000..30f094e --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Kdf.kt @@ -0,0 +1,106 @@ +package org.meshbay.client.keys + +import org.bouncycastle.crypto.digests.SHA256Digest +import org.bouncycastle.crypto.generators.Argon2BytesGenerator +import org.bouncycastle.crypto.generators.HKDFBytesGenerator +import org.bouncycastle.crypto.params.Argon2Parameters +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.params.HKDFParameters +import org.bouncycastle.crypto.params.X25519PrivateKeyParameters +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * The primitives keyring.js takes from node:crypto and the vendored Argon2, + * with the same numbers. keyderive.js (the page), keyring.js (desktop) and this + * are one format: a mismatch looks like an account nobody can open, not like + * an error. meshbay-hub/tests/vectors/keyring.json holds them together. + */ +object Kdf { + // keyderive.js: the same numbers, or no bundle opens across the clients. + const val ARGON2_MEMORY_KIB = 131072 + const val ARGON2_PASSES = 3 + const val ARGON2_PARALLELISM = 1 + const val ARGON2_TAG = 32 + + private val ED_PKCS8_PREFIX = hex("302e020100300506032b657004220420") + private val X_PKCS8_PREFIX = hex("302e020100300506032b656e04220420") + + // One derivation at a time: 128 MiB each, on a phone. (Two concurrent + // lanes=4 derivations deadlock inside OpenSSL on the hub — CLAUDE.md; not + // this library, but there is no reason to find out.) + @Synchronized + fun argon2id(password: String, salt: ByteArray): ByteArray { + val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) + .withVersion(Argon2Parameters.ARGON2_VERSION_13) + .withIterations(ARGON2_PASSES) + .withMemoryAsKB(ARGON2_MEMORY_KIB) + .withParallelism(ARGON2_PARALLELISM) + .withSalt(salt) + .build() + val gen = Argon2BytesGenerator() + gen.init(params) + val out = ByteArray(ARGON2_TAG) + gen.generateBytes(password.toByteArray(Charsets.UTF_8), out) + return out + } + + /** node:crypto hkdfSync('sha256', ikm, <empty salt>, info, 32). */ + fun hkdf(ikm: ByteArray, info: String): ByteArray { + val gen = HKDFBytesGenerator(SHA256Digest()) + gen.init(HKDFParameters(ikm, null, info.toByteArray(Charsets.UTF_8))) + val out = ByteArray(32) + gen.generateBytes(out, 0, 32) + return out + } + + fun sha256(data: ByteArray): ByteArray { + val d = SHA256Digest() + d.update(data, 0, data.size) + val out = ByteArray(32) + d.doFinal(out, 0) + return out + } + + /** AES-256-GCM, 16-byte tag appended — the layout node:crypto's getAuthTag gives. */ + fun gcmSeal(key: ByteArray, nonce: ByteArray, plain: ByteArray, aad: ByteArray?): ByteArray { + val c = Cipher.getInstance("AES/GCM/NoPadding") + c.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce)) + if (aad != null) c.updateAAD(aad) + return c.doFinal(plain) + } + + fun gcmOpen(key: ByteArray, nonce: ByteArray, ctAndTag: ByteArray, aad: ByteArray?): ByteArray { + val c = Cipher.getInstance("AES/GCM/NoPadding") + c.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce)) + if (aad != null) c.updateAAD(aad) + return c.doFinal(ctAndTag) + } + + // Keys are stored as Node exports them: PKCS#8 DER, RFC 8410, 48 bytes, no + // public key attached. Written out by hand because a library's own PKCS#8 + // encoder may add the optional public key, and the stored format is one. + fun edToPkcs8(k: Ed25519PrivateKeyParameters) = ED_PKCS8_PREFIX + k.encoded + fun xToPkcs8(k: X25519PrivateKeyParameters) = X_PKCS8_PREFIX + k.encoded + + fun edFromPkcs8(der: ByteArray): Ed25519PrivateKeyParameters { + require(der.size == 48 && der.copyOfRange(0, 16).contentEquals(ED_PKCS8_PREFIX)) { + "not an Ed25519 PKCS#8 key" + } + return Ed25519PrivateKeyParameters(der, 16) + } + + fun xFromPkcs8(der: ByteArray): X25519PrivateKeyParameters { + require(der.size == 48 && der.copyOfRange(0, 16).contentEquals(X_PKCS8_PREFIX)) { + "not an X25519 PKCS#8 key" + } + return X25519PrivateKeyParameters(der, 16) + } + + fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b) + fun unb64(s: String?): ByteArray = Base64.getDecoder().decode(s ?: "") + fun hex(s: String): ByteArray = ByteArray(s.length / 2) { s.substring(2 * it, 2 * it + 2).toInt(16).toByte() } + fun toHex(b: ByteArray): String = b.joinToString("") { "%02x".format(it) } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Keyring.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Keyring.kt new file mode 100644 index 0000000..fa8ff71 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Keyring.kt @@ -0,0 +1,266 @@ +package org.meshbay.client.keys + +import org.bouncycastle.crypto.agreement.X25519Agreement +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.params.X25519PrivateKeyParameters +import org.bouncycastle.crypto.params.X25519PublicKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import org.json.JSONObject +import java.security.SecureRandom +import org.meshbay.client.keys.Kdf.b64 +import org.meshbay.client.keys.Kdf.hkdf +import org.meshbay.client.keys.Kdf.unb64 + +/** + * The account's keys on Android: the bundle master key `M` and the identity on + * every node, held here and never handed to the page. A port of + * meshbay-client/src/keyring.js — same state shape (masters, identities, + * access), same formats, same refusals — so the two read side by side. + * + * Storage is injected (load/save of one JSON object), as on desktop; the app + * keeps it in SecretStore (Keystore-wrapped). `random` is injected so the + * vectors can pin a nonce. + */ +class Keyring( + private val load: () -> JSONObject?, + private val save: (JSONObject) -> Unit, + private val transcripts: Transcripts = Transcripts(), + private val random: (Int) -> ByteArray = { n -> ByteArray(n).also { SecureRandom().nextBytes(it) } }, +) { + class Pub(val pkEdB64: String, val pkXB64: String, val sealedWith: String? = null) + class Sealed(val bundle: String, val fingerprint: String) + class FormatRetired : IllegalStateException("bundle_format_retired") + + private class Master(val m: ByteArray, val v: Int) + private class Identity(val ed: String, val x: String) + + // In memory: the key of a passphrase change not yet accepted by the hub. + private val pending = HashMap<String, Master>() + + private fun state(): JSONObject { + val s = load() ?: JSONObject() + for (k in listOf("masters", "identities", "access")) if (!s.has(k)) s.put(k, JSONObject()) + return s + } + + private fun master(userId: String, usePending: Boolean = false): Master { + if (usePending) pending[userId]?.let { return it } + val m = state().getJSONObject("masters").optJSONObject(userId) + ?: throw IllegalStateException("no bundle key in this session") + return Master(unb64(m.getString("m")), m.getInt("v")) + } + + private fun fingerprint(m: ByteArray) = Kdf.toHex(Kdf.sha256(m)).substring(0, 16) + + private fun stored(userId: String, nodePk: String): Identity { + val id = state().getJSONObject("identities").optJSONObject(userId)?.optJSONObject(nodePk) + ?: throw IllegalStateException("no identity for this node") + return Identity(id.getString("ed"), id.getString("x")) + } + + private fun publicOf(id: Identity) = Pub( + b64(Kdf.edFromPkcs8(unb64(id.ed)).generatePublicKey().encoded), + b64(Kdf.xFromPkcs8(unb64(id.x)).generatePublicKey().encoded), + ) + + private fun accessOn(userId: String) = state().getJSONObject("access").opt(userId) != false + private fun needAccess(userId: String) { + if (!accessOn(userId)) throw IllegalStateException("Refused: browser access is off for this account") + } + + private fun keep(userId: String, nodePk: String, put: (JSONObject) -> Unit) { + val s = state() + val ids = s.getJSONObject("identities") + val forUser = ids.optJSONObject(userId) ?: JSONObject().also { ids.put(userId, it) } + val entry = forUser.optJSONObject(nodePk) ?: JSONObject().also { forUser.put(nodePk, it) } + put(entry) + save(s) + } + + private fun aad(userId: String, nodePk: String) = + "meshbay:bundle:v3|$userId|$nodePk".toByteArray(Charsets.UTF_8) + + // Exactly JSON.stringify({ skEd, skX }): base64 needs no escaping, and the + // sealed bytes are then comparable with the desktop's in tests. + private fun plaintext(id: Identity) = "{\"skEd\":\"${id.ed}\",\"skX\":\"${id.x}\"}" + + private fun seal(id: Identity, key: ByteArray, userId: String, nodePk: String, pepperVersion: Int): String { + val nonce = random(12) + val ct = Kdf.gcmSeal(key, nonce, plaintext(id).toByteArray(Charsets.UTF_8), aad(userId, nodePk)) + return b64(MAGIC + byteArrayOf((pepperVersion and 0xff).toByte()) + nonce + ct) + } + + private fun parse(plain: ByteArray): Identity { + val o = JSONObject(String(plain, Charsets.UTF_8)) + return Identity(o.getString("skEd"), o.getString("skX")) + } + + private fun open(bundleB64: String, key: ByteArray, userId: String, nodePk: String): Identity { + val raw = unb64(bundleB64) + if (raw.size < 4 || !raw.copyOfRange(0, 4).contentEquals(MAGIC)) throw FormatRetired() + return parse(Kdf.gcmOpen(key, raw.copyOfRange(5, 17), raw.copyOfRange(17, raw.size), aad(userId, nodePk))) + } + + /** TRANSITIONAL — MBK2: "MBK2" ‖ nonce ‖ AES-GCM under the Argon2 key, no AAD. */ + private fun openLegacy(bundleB64: String, key: ByteArray): Identity { + val raw = unb64(bundleB64) + return parse(Kdf.gcmOpen(key, raw.copyOfRange(4, 16), raw.copyOfRange(16, raw.size), null)) + } + + private fun fromMnemonic(mnemonic: String): ByteArray { + val clean = mnemonic.replace(Regex("[^A-Za-z2-7]"), "").uppercase() + var bits = 0 + var value = 0 + val out = ArrayList<Byte>() + for (ch in clean) { + value = (value shl 5) or B32.indexOf(ch) + bits += 5 + if (bits >= 8) { out.add(((value ushr (bits - 8)) and 0xff).toByte()); bits -= 8 } + } + if (out.size < 32) throw IllegalArgumentException("recovery key too short") + return out.subList(0, 32).toByteArray() + } + + // ── The API, in keyring.js order ──────────────────────────────────────── + + fun hasSession(userId: String) = state().getJSONObject("masters").has(userId) + + /** `M` from the passphrase and the pepper — one Argon2 run, as in the page. */ + fun deriveSession(password: String, username: String, userId: String, pepperB64: String?, + pepperVersion: Int?, pendingChange: Boolean = false): Boolean { + if (userId.isEmpty() || pepperB64.isNullOrEmpty()) { + throw IllegalStateException("the hub did not provide the bundle pepper") + } + val salt = Kdf.sha256("meshbay:bundle:v2:$username".toByteArray(Charsets.UTF_8)).copyOfRange(0, 16) + val a = Kdf.argon2id(password, salt) + val m = hkdf(a + unb64(pepperB64), "meshbay:bundle-master:v3|$userId") + val v = if (pepperVersion == null || pepperVersion == 0) 1 else pepperVersion + if (pendingChange) { pending[userId] = Master(m, v); return true } + val s = state() + // `legacy` (TRANSITIONAL): the Argon2 key MBK2 bundles were sealed under. + s.getJSONObject("masters").put(userId, JSONObject().put("m", b64(m)).put("v", v).put("legacy", b64(a))) + save(s) + return true + } + + fun commitPending(userId: String): Boolean { + val p = pending[userId] ?: return false + val s = state() + val legacy = s.getJSONObject("masters").optJSONObject(userId)?.optString("legacy", "") + val entry = JSONObject().put("m", b64(p.m)).put("v", p.v) + if (!legacy.isNullOrEmpty()) entry.put("legacy", legacy) + s.getJSONObject("masters").put(userId, entry) + save(s) + pending.remove(userId) + return true + } + + fun dropPending(userId: String) = pending.remove(userId) != null + + /** Sign-out: `M` goes. The identities stay — they are this device's. */ + fun forgetSession(userId: String): Boolean { + val s = state() + s.getJSONObject("masters").remove(userId) + save(s) + pending.remove(userId) + return true + } + + fun identity(userId: String, nodePk: String): Pub? { + val id = state().getJSONObject("identities").optJSONObject(userId)?.optJSONObject(nodePk) ?: return null + val pub = publicOf(Identity(id.getString("ed"), id.getString("x"))) + val sw = id.opt("sealedWith") + return Pub(pub.pkEdB64, pub.pkXB64, if (sw is String) sw else null) + } + + fun openBundle(userId: String, nodePk: String, bundleEnc: String, recoveryEnc: String? = null, + recoveryMnemonic: String? = null, username: String? = null): Pub { + val raw = unb64(bundleEnc) + if (raw.size >= 4 && raw.copyOfRange(0, 4).contentEquals(LEGACY_MAGIC)) { + val legacy = state().getJSONObject("masters").optJSONObject(userId)?.optString("legacy", "") + if (legacy.isNullOrEmpty()) throw IllegalStateException("no_legacy_key") + val id = openLegacy(bundleEnc, unb64(legacy)) + keepIdentity(userId, nodePk, id) + return publicOf(id) + } + val m = master(userId).m + val id = try { + open(bundleEnc, hkdf(m, "meshbay:bundle:v3|node|$nodePk"), userId, nodePk) + } catch (e: Exception) { + if (e is FormatRetired || recoveryEnc.isNullOrEmpty() || recoveryMnemonic.isNullOrEmpty()) throw e + val rk = hkdf(fromMnemonic(recoveryMnemonic), "meshbay:recovery:v1:${username ?: ""}") + open(recoveryEnc, rk, userId, nodePk) + } + keepIdentity(userId, nodePk, id) + return publicOf(id) + } + + private fun keepIdentity(userId: String, nodePk: String, id: Identity) = + keep(userId, nodePk) { it.put("ed", id.ed).put("x", id.x).put("sealedWith", JSONObject.NULL) } + + fun mint(userId: String, nodePk: String): Pub { + val rnd = SecureRandom() + val id = Identity(b64(Kdf.edToPkcs8(Ed25519PrivateKeyParameters(rnd))), + b64(Kdf.xToPkcs8(X25519PrivateKeyParameters(rnd)))) + keepIdentity(userId, nodePk, id) + return publicOf(id) + } + + /** The identity sealed for its node, under `M` (or the pending one). */ + fun sealBundle(userId: String, nodePk: String, usePending: Boolean = false): Sealed { + needAccess(userId) + val m = master(userId, usePending) + val bundle = seal(stored(userId, nodePk), hkdf(m.m, "meshbay:bundle:v3|node|$nodePk"), userId, nodePk, m.v) + return Sealed(bundle, fingerprint(m.m)) + } + + /** The recovery copy: sealed under the recovery key, owing nothing to `M`. */ + fun sealRecovery(userId: String, nodePk: String, mnemonic: String, username: String): String { + needAccess(userId) + val rk = hkdf(fromMnemonic(mnemonic), "meshbay:recovery:v1:$username") + return seal(stored(userId, nodePk), rk, userId, nodePk, 0) + } + + fun markSealed(userId: String, nodePk: String, fp: String?): Boolean { + keep(userId, nodePk) { it.put("sealedWith", if (fp.isNullOrEmpty()) JSONObject.NULL else fp) } + return true + } + + fun currentFingerprint(userId: String) = fingerprint(master(userId).m) + + /** Sign what `kind` names, built from `fields` (Transcripts). */ + fun signAs(userId: String, nodePk: String, kind: String, fields: JSONObject?): String { + val id = stored(userId, nodePk) + val pub = publicOf(id) + val transcript = transcripts.forKind(kind, fields, Transcripts.Ctx(userId, nodePk, pub.pkEdB64, pub.pkXB64)) + val signer = Ed25519Signer() + signer.init(true, Kdf.edFromPkcs8(unb64(id.ed))) + signer.update(transcript, 0, transcript.size) + return b64(signer.generateSignature()) + } + + fun shared(userId: String, nodePk: String, peerPkB64: String): String { + val agreement = X25519Agreement() + agreement.init(Kdf.xFromPkcs8(unb64(stored(userId, nodePk).x))) + val out = ByteArray(32) + agreement.calculateAgreement(X25519PublicKeyParameters(unb64(peerPkB64), 0), out, 0) + return b64(out) + } + + fun playlistKey(userId: String) = b64(hkdf(master(userId).m, "meshbay:playlists:v2")) + + fun browserAccess(userId: String) = accessOn(userId) + fun setBrowserAccess(userId: String, on: Boolean): Boolean { + val s = state() + s.getJSONObject("access").put(userId, on) + save(s) + return on + } + + companion object { + private val MAGIC = "MBK3".toByteArray() + // TRANSITIONAL — the format before MBK3, read once to be replaced. + private val LEGACY_MAGIC = "MBK2".toByteArray() + private const val B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/SecretStore.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/SecretStore.kt new file mode 100644 index 0000000..5a2c1bb --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/SecretStore.kt @@ -0,0 +1,120 @@ +package org.meshbay.client.keys + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException +import android.util.Log +import org.json.JSONObject +import java.io.File +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * The application's secrets, at rest: the desktop's safeStorage blob, here + * wrapped by an AES-256-GCM key that lives in Android Keystore and never leaves + * it (in StrongBox where the device has one). + * + * One file, `files/secrets.bin` = nonce ‖ ciphertext ‖ tag over the JSON of + * every slot (`device_key`, `keyring` — main.js's slots), replaced atomically. + * Nothing in it is reachable from the page: it holds the device's hub key. + * + * Honest without protection, as on desktop: if the Keystore cannot be used, + * `backend()` says `unavailable` and nothing is stored — the page then keeps + * its keys the way a browser does, rather than this downgrading silently. + */ +/** What the keys need of their storage; SecretStore on a device, a map in tests. */ +interface Secrets { + fun backend(): String + fun read(): JSONObject + fun update(fn: (JSONObject) -> Unit) +} + +class SecretStore(private val context: Context) : Secrets { + private val file get() = File(context.filesDir, "secrets.bin") + @Volatile private var strongBox = false + + private fun key(): SecretKey? = try { + val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (ks.getKey(ALIAS, null) as SecretKey?) ?: generate() + } catch (e: Exception) { + Log.w(TAG, "Keystore unusable", e) + null + } + + private fun generate(): SecretKey { + fun spec(strong: Boolean) = KeyGenParameterSpec.Builder(ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + // Usable without a prompt: the app answers the hub on its own, as + // the desktop keychain does once the session is unlocked. + .setUserAuthenticationRequired(false) + .setRandomizedEncryptionRequired(true) + .apply { if (strong) setIsStrongBoxBacked(true) } + .build() + val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + return try { + gen.init(spec(true)); gen.generateKey().also { strongBox = true } + } catch (e: StrongBoxUnavailableException) { + gen.init(spec(false)); gen.generateKey() + } + } + + override fun backend(): String { + if (key() == null) return "unavailable" + return if (strongBox || isStrongBox()) "android_strongbox" else "android_keystore" + } + + private fun isStrongBox(): Boolean = try { + val k = key() ?: return false + val info = javax.crypto.SecretKeyFactory.getInstance(k.algorithm, "AndroidKeyStore") + .getKeySpec(k, android.security.keystore.KeyInfo::class.java) as android.security.keystore.KeyInfo + if (android.os.Build.VERSION.SDK_INT >= 31) info.securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX else false + } catch (e: Exception) { false } + + @Synchronized + override fun read(): JSONObject { + val k = key() ?: return JSONObject() + val raw = try { file.readBytes() } catch (e: java.io.FileNotFoundException) { return JSONObject() } + return try { + val c = Cipher.getInstance("AES/GCM/NoPadding") + c.init(Cipher.DECRYPT_MODE, k, GCMParameterSpec(128, raw, 0, 12)) + JSONObject(String(c.doFinal(raw, 12, raw.size - 12), Charsets.UTF_8)) + } catch (e: Exception) { + // A store that does not open is reported, never overwritten: what + // is in it is a device key and identities nodes have pinned. + throw IllegalStateException("the key store does not open", e) + } + } + + @Synchronized + fun write(all: JSONObject) { + val k = key() ?: throw IllegalStateException("No OS key storage") + val c = Cipher.getInstance("AES/GCM/NoPadding") + c.init(Cipher.ENCRYPT_MODE, k) + val sealed = c.iv + c.doFinal(all.toString().toByteArray(Charsets.UTF_8)) + val tmp = File(context.filesDir, "secrets.bin.tmp") + tmp.writeBytes(sealed) + if (!tmp.renameTo(file)) throw IllegalStateException("could not replace the key store") + } + + /** One slot, read and replaced under the same lock. */ + @Synchronized + override fun update(fn: (JSONObject) -> Unit) { + val all = read() + fn(all) + write(all) + } + + companion object { + private const val TAG = "MeshBay" + private const val ALIAS = "meshbay.secrets.v1" + const val DEVICE_KEY = "device_key" + const val KEYRING_SLOT = "keyring" + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt new file mode 100644 index 0000000..4d183f7 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt @@ -0,0 +1,183 @@ +package org.meshbay.client.keys + +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.util.Base64 +import kotlin.math.abs + +/** + * What a node identity signs, built here from named fields — never bytes the + * page chose. A port of meshbay-client/src/transcripts.js, byte for byte; the + * shapes it checks and the refusals it gives are the same, and + * meshbay-hub/tests/vectors/keyring.json is what holds them (and + * meshbay_common) together. + * + * `now` is injected so the vectors can pin the clock; production passes the + * system clock. + */ +class Transcripts(private val now: () -> Double = { System.currentTimeMillis() / 1000.0 }) { + + class Refused(what: String) : IllegalArgumentException("Refused: $what") + + data class Ctx(val userId: String, val nodePk: String, val pkEdB64: String, val pkXB64: String) + + private fun refuse(what: String): Nothing = throw Refused(what) + + private fun enc(s: String) = s.toByteArray(Charsets.UTF_8) + + private fun lenPrefixed(prefix: String, parts: List<ByteArray>): ByteArray { + val out = ByteArrayOutputStream() + out.write(prefix.toByteArray(Charsets.UTF_8)) + for (p in parts) { + out.write(byteArrayOf((p.size ushr 24).toByte(), (p.size ushr 16).toByte(), + (p.size ushr 8).toByte(), p.size.toByte())) + out.write(p) + } + return out.toByteArray() + } + + // JavaScript's String(v ?? '') over a value that came through JSON. + private fun jsString(v: Any?): String = when (v) { + null, JSONObject.NULL -> "" + is String -> v + is Boolean -> v.toString() + is Int, is Long -> v.toString() + is Number -> { + val d = v.toDouble() + if (d == Math.floor(d) && !d.isInfinite() && abs(d) < 1e21) d.toLong().toString() else d.toString() + } + else -> v.toString() + } + + // JavaScript's Number(v), for the values a timestamp or an epoch can arrive as. + private fun jsNumber(v: Any?): Double = when (v) { + null, JSONObject.NULL -> if (v == null) Double.NaN else 0.0 + is Number -> v.toDouble() + is Boolean -> if (v) 1.0 else 0.0 + is String -> v.trim().let { if (it.isEmpty()) 0.0 else it.toDoubleOrNull() ?: Double.NaN } + else -> Double.NaN + } + + private fun isInteger(d: Double) = !d.isNaN() && !d.isInfinite() && d == Math.floor(d) + + private val base64Shape = Regex("^[A-Za-z0-9+/]*={0,2}$") + + private fun bytes(v: Any?, what: String, min: Int = 1, max: Int = 64): ByteArray { + val s = jsString(v) + if (!base64Shape.matches(s)) refuse("$what is not base64") + // Node's decoder is lenient where Java's throws (a lone trailing + // character). Both outcomes are a refusal: Node's yields a short + // buffer that the length check below refuses. + val b = try { Base64.getDecoder().decode(s) } catch (e: IllegalArgumentException) { + refuse("$what has the wrong length") + } + if (b.size < min || b.size > max) refuse("$what has the wrong length") + return b + } + + private fun key32(v: Any?, what: String): String { + bytes(v, what, 32, 32) + return jsString(v) + } + + private fun text(v: Any?, what: String, max: Int = 256): String { + val s = jsString(v) + if (s.length > max) refuse("$what is too long") // UTF-16 units, as JS counts + return s + } + + private val groupShape = Regex("^[A-Za-z0-9_-]{1,64}$") + private fun groupId(v: Any?): String { + val s = jsString(v) + if (s.isNotEmpty() && !groupShape.matches(s)) refuse("not a group id") + return s + } + + private fun timestamp(v: Any?): String { + val n = jsNumber(v) + if (!isInteger(n) || abs(n - now()) > TS_SLACK_S) refuse("the timestamp is not now") + return jsString(n.toLong()) + } + + fun forKind(kind: String, f: JSONObject?, ctx: Ctx): ByteArray { + val fields = f ?: JSONObject() + fun field(name: String): Any? = if (fields.has(name)) fields.get(name) else null + fun sameNode(): String { + if (jsString(field("nodePk")) != ctx.nodePk) refuse("another node") + return ctx.nodePk + } + fun sameUser(): String { + if (jsString(field("userId")) != ctx.userId) refuse("another account") + return ctx.userId + } + fun nonceNode() = bytes(field("nonceNode"), "the node nonce", 16, 64) + + return when (kind) { + "join" -> lenPrefixed(PREFIX_JOIN, listOf( + enc(sameNode()), enc(groupId(field("groupId"))), enc(sameUser()), + enc(ctx.pkEdB64), enc(ctx.pkXB64), nonceNode(), enc(timestamp(field("ts"))))) + "device_hello" -> lenPrefixed(PREFIX_DEVICE_HELLO, listOf( + enc(sameNode()), enc(groupId(field("groupId"))), enc(sameUser()), + enc(ctx.pkEdB64), nonceNode(), enc(timestamp(field("ts"))))) + "device_request" -> { + val codeHash = jsString(field("codeHash")) + if (!Regex("^[0-9a-f]{64}$").matches(codeHash)) refuse("not a request hash") + lenPrefixed(PREFIX_DEVICE_REQUEST, listOf( + enc(sameNode()), enc(sameUser()), enc(ctx.pkEdB64), enc(ctx.pkXB64), + enc(codeHash), nonceNode(), enc(timestamp(field("ts"))))) + } + "device_add" -> lenPrefixed(PREFIX_DEVICE_ADD, listOf( + enc(sameNode()), enc(sameUser()), enc(key32(field("pkEd"), "the device key")), + enc(key32(field("pkX"), "the device key")), nonceNode(), enc(timestamp(field("ts"))))) + "device_revoke" -> lenPrefixed(PREFIX_DEVICE_REVOKE, listOf( + enc(sameNode()), enc(sameUser()), enc(key32(field("pkEd"), "the device key")), + nonceNode(), enc(timestamp(field("ts"))))) + "chat" -> { + val epoch = jsNumber(field("epoch")) + if (!isInteger(epoch) || epoch < 0) refuse("not an epoch") + lenPrefixed(PREFIX_CHAT, listOf( + enc(groupId(field("groupId"))), enc(jsString(epoch.toLong())), + Base64.getDecoder().decode(ctx.pkEdB64), + bytes(field("nonce"), "the message nonce", 12, 24), + bytes(field("ct"), "the message", 1, 8 * 1024 * 1024))) + } + "admin" -> { + val op = jsString(field("op")) + if (op !in ADMIN_OPS) refuse("not an operation") + lenPrefixed(PREFIX_ADMIN, listOf( + enc(op), enc(sameNode()), enc(groupId(field("groupId"))), + enc(text(field("subject"), "the subject", 16384)), + bytes(field("nonce"), "the challenge nonce", 16, 64), + enc(timestamp(field("ts"))))) + } + else -> refuse("nothing is signed as \"${kind.take(32)}\"") + } + } + + companion object { + // The node's clock and ours: a signature for a moment far from now is one to keep for later. + const val TS_SLACK_S = 600 + + // The signed operations this application asks a node to perform + // (meshbay_common/adminop.py) — transcripts.js's list, held equal by + // test_android_keys.py. A list, not a pattern: what widens a node's + // sharing (root_add, root_update, group_attach — gone from MNP 6.0) is + // never signed here, so a script in the page cannot drive an older node + // into it either. + val ADMIN_OPS = setOf( + "file_delete", "dir_delete", "invite_create", "invite_link_create", + "invite_cancel", "member_revoke", "apps_enabled", "set_scan_settings", + "tmdb_config", "tmdb_enabled", "tmdb_override", "tmdb_rematch", + "musicbrainz_enabled", "root_remove", "root_eject", "root_plug", + "app_directories", "chat_directory", "chat_link_preview", "search_listed", + "chat_epoch", + ) + const val PREFIX_JOIN = "meshbay:join:v1" + const val PREFIX_DEVICE_REQUEST = "meshbay:device_req:v1" + const val PREFIX_DEVICE_ADD = "meshbay:device_add:v1" + const val PREFIX_DEVICE_REVOKE = "meshbay:device_revoke:v1" + const val PREFIX_DEVICE_HELLO = "meshbay:device_hello:v1" + const val PREFIX_CHAT = "meshbay:chat:v1" + const val PREFIX_ADMIN = "meshbay:admin:v1" + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveNames.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveNames.kt new file mode 100644 index 0000000..2414730 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveNames.kt @@ -0,0 +1,74 @@ +package org.meshbay.client.save + +/** The pure part of saving: names, types and the binary frame. JVM-tested. */ +object SaveNames { + private val BIDI = Regex("[\\u061c\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069]") + + /** + * main.js `save:begin`: the basename only, bidirectional controls replaced + * — "invoicefdp.exe" would otherwise be listed as "invoiceexe.pdf". + */ + fun sanitize(suggested: String?): String { + val base = (suggested ?: "").replace('\\', '/').substringAfterLast('/') + .replace(BIDI, "_").replace(Regex("[\\u0000-\\u001f]"), "_").trim() + return if (base.isEmpty() || base == "." || base == "..") "download" else base + } + + /** + * downloads.js `OPENABLE`, entry for entry (test_android_downloads.py): + * what may be handed to another app to open, under a type chosen from the + * name — never one guessed from the bytes. + */ + val OPENABLE = mapOf( + "pdf" to "application/pdf", + "png" to "image/png", "jpg" to "image/jpeg", "jpeg" to "image/jpeg", "gif" to "image/gif", + "webp" to "image/webp", "avif" to "image/avif", "bmp" to "image/bmp", + "mp3" to "audio/mpeg", "m4a" to "audio/mp4", "aac" to "audio/aac", "ogg" to "audio/ogg", + "oga" to "audio/ogg", "opus" to "audio/ogg", "flac" to "audio/flac", "wav" to "audio/wav", + "mp4" to "video/mp4", "m4v" to "video/mp4", "webm" to "video/webm", "ogv" to "video/ogg", + "mov" to "video/quicktime", + "txt" to "text/plain", "log" to "text/plain", "md" to "text/plain", "csv" to "text/plain", + ) + + fun extension(name: String): String? = Regex("\\.([A-Za-z0-9]+)$").find(name)?.groupValues?.get(1)?.lowercase() + + fun openableType(name: String): String? = extension(name)?.let { OPENABLE[it] } + + /** The type a file is created under: openable ones by name, the rest opaque. */ + fun storedType(name: String): String = openableType(name) ?: "application/octet-stream" + + /** "name (n).ext", as main.js `freeName` — never an overwrite. */ + fun numbered(name: String, n: Int): String { + val ext = extension(name)?.let { ".$it" } ?: "" + val stem = if (ext.isEmpty()) name else name.dropLast(ext.length) + return "$stem ($n)$ext" + } +} + +/** + * A binary bridge message: one write, no JSON, no base64. + * + * "MBB1" | u32 request id | u16 channel | u16 reserved | u32 handle | bytes + * + * all big-endian. The reply is an ordinary JSON reply carrying the id. + */ +class BinaryFrame(val id: Long, val channel: Int, val handle: Long, val bytes: ByteArray, val offset: Int) { + val length get() = bytes.size - offset + + companion object { + const val HEADER = 16 + const val SAVE_WRITE = 1 + const val CAST_PUSH = 2 + private val MAGIC = byteArrayOf('M'.code.toByte(), 'B'.code.toByte(), 'B'.code.toByte(), '1'.code.toByte()) + + private fun u32(b: ByteArray, at: Int) = + ((b[at].toLong() and 0xff) shl 24) or ((b[at + 1].toLong() and 0xff) shl 16) or + ((b[at + 2].toLong() and 0xff) shl 8) or (b[at + 3].toLong() and 0xff) + + fun parse(b: ByteArray): BinaryFrame? { + if (b.size < HEADER || !(0 until 4).all { b[it] == MAGIC[it] }) return null + val channel = ((b[8].toInt() and 0xff) shl 8) or (b[9].toInt() and 0xff) + return BinaryFrame(u32(b, 4), channel, u32(b, 12), b, HEADER) + } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveSinks.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveSinks.kt new file mode 100644 index 0000000..2da7ec7 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveSinks.kt @@ -0,0 +1,238 @@ +package org.meshbay.client.save + +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract +import android.provider.MediaStore +import android.util.Log +import org.json.JSONObject +import org.meshbay.client.bridge.Refused +import org.meshbay.client.shell.Pickers +import java.io.OutputStream +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +/** + * Downloads, written to disk as they arrive — never collected in the page and + * handed over at the end (§8.5; main.js `save:*`). + * + * The page never names a path or a URI: it asks, is told a display name, and + * holds an opaque id. Where a file lands: + * + * - **automatic**, a folder chosen in Settings → that folder (a Storage Access + * Framework tree), as `<name>.part`, renamed on completion; + * - **automatic**, no folder chosen → the system's Downloads collection, as a + * pending entry that only becomes visible when complete — the Android form + * of `.part`; + * - **asked**, or a chosen folder that has gone → the system save dialog. + * A folder that was chosen and has since gone is never silently replaced + * by Downloads: somebody who picked a card wants to know it is not there. + * + * An unfinished file never carries the final name where that can be avoided, + * an aborted one is deleted, and one left by a killed process is deleted at the + * next start — Android gives no reliable quit hook, so `before-quit`'s cleanup + * happens there. + */ +class SaveSinks( + private val context: Context, + private val prefs: SharedPreferences, + private val pickers: Pickers, + private val startActivity: (Intent) -> Unit, +) { + private enum class Kind { TREE_PART, MEDIASTORE, PICKED } + + private class Sink(val uri: Uri, val out: OutputStream, val kind: Kind, val finalName: String, val tree: Uri?) + + private val resolver: ContentResolver get() = context.contentResolver + private val sinks = ConcurrentHashMap<Long, Sink>() + private val completed = ConcurrentHashMap<Long, Pair<Uri, String>>() + private val ids = AtomicLong() + + // ── Where downloads go ────────────────────────────────────────────────── + + private val configuredTree: Uri? get() = prefs.getString(KEY_TREE, null)?.let(Uri::parse) + + /** The chosen folder, if it is still there and still ours to write. */ + private fun usableTree(): Uri? { + val tree = configuredTree ?: return null + val held = resolver.persistedUriPermissions.any { it.uri == tree && it.isWritePermission } + return if (held && displayName(treeDocument(tree)) != null) tree else null + } + + private fun treeDocument(tree: Uri) = + DocumentsContract.buildDocumentUriUsingTree(tree, DocumentsContract.getTreeDocumentId(tree)) + + private fun displayName(uri: Uri): String? = try { + resolver.query(uri, arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME), null, null, null)?.use { + if (it.moveToFirst()) it.getString(0) else null + } + } catch (e: Exception) { null } + + fun chooseFolder(): String? { + val result = pickers.run(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)) ?: return null + val tree = result.data ?: return null + resolver.takePersistableUriPermission(tree, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + configuredTree?.takeIf { it != tree }?.let { release(it) } + prefs.edit().putString(KEY_TREE, tree.toString()).apply() + return displayName(treeDocument(tree)) ?: "folder" + } + + /** `{name, isDefault}`, which the Settings row renders; a name, never a URI. */ + fun getFolder(): JSONObject { + val tree = usableTree() + val name = tree?.let { displayName(treeDocument(it)) } + return JSONObject().put("name", name ?: DEFAULT_NAME).put("isDefault", name == null) + } + + fun forgetFolder(): Boolean { + configuredTree?.let { release(it) } + prefs.edit().remove(KEY_TREE).apply() + return true + } + + private fun release(tree: Uri) { + try { + resolver.releasePersistableUriPermission(tree, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + } catch (e: SecurityException) { /* already gone */ } + } + + // ── Writing ───────────────────────────────────────────────────────────── + + fun begin(suggestedName: String?, auto: Boolean): JSONObject? { + val wanted = SaveNames.sanitize(suggestedName) + val type = SaveNames.storedType(wanted) + val tree = usableTree() + var target: Pair<Uri, Kind>? = null + + if (auto && !(configuredTree != null && tree == null)) { + target = try { + when { + tree != null -> DocumentsContract.createDocument(resolver, treeDocument(tree), + "application/octet-stream", "$wanted.part")?.let { it to Kind.TREE_PART } + Build.VERSION.SDK_INT >= 29 -> resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, + ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, wanted) + put(MediaStore.MediaColumns.MIME_TYPE, type) + put(MediaStore.MediaColumns.IS_PENDING, 1) + })?.let { it to Kind.MEDIASTORE } + else -> null + } + } catch (e: Exception) { + Log.w(TAG, "automatic save target failed", e); null + } + } + if (target == null) { + val ask = Intent(Intent.ACTION_CREATE_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE) + .setType(type).putExtra(Intent.EXTRA_TITLE, wanted) + tree?.let { ask.putExtra(DocumentsContract.EXTRA_INITIAL_URI, treeDocument(it)) } + val uri = pickers.run(ask)?.data ?: return null // dismissed: not an error + target = uri to Kind.PICKED + } + + val (uri, kind) = target + val out = resolver.openOutputStream(uri, "wt") ?: throw Refused("Could not write the file") + val id = ids.incrementAndGet() + val shown = if (kind == Kind.TREE_PART) wanted else (displayName(uri) ?: wanted) + sinks[id] = Sink(uri, out, kind, wanted, tree) + rememberPending(uri, true) + return JSONObject().put("id", id).put("name", shown) + .put("openable", SaveNames.openableType(shown) != null) + } + + /** Returns once the bytes are written: the await is the backpressure. */ + fun write(id: Long, bytes: ByteArray, offset: Int, length: Int): Boolean { + val sink = sinks[id] ?: throw Refused("No such download") + synchronized(sink) { sink.out.write(bytes, offset, length) } + return true + } + + fun end(id: Long): Boolean { + val sink = sinks.remove(id) ?: return false + synchronized(sink) { sink.out.flush(); sink.out.close() } + // Publishing the file is what makes it complete — only after the stream + // has flushed, or the final name would be on a short file. + val published: Uri = try { + when (sink.kind) { + Kind.MEDIASTORE -> { + resolver.update(sink.uri, ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }, null, null) + sink.uri + } + Kind.TREE_PART -> renameFree(sink.uri, sink.finalName) + Kind.PICKED -> sink.uri + } + } catch (e: Exception) { + Log.e(TAG, "could not finalise a download", e) + return false + } + rememberPending(sink.uri, false) + completed[id] = published to (displayName(published) ?: sink.finalName) + return true + } + + private fun renameFree(uri: Uri, name: String): Uri { + var candidate = name + for (n in 2 until 1000) { + try { + return DocumentsContract.renameDocument(resolver, uri, candidate) ?: uri + } catch (e: Exception) { + // Most providers refuse a name that exists; try the next one. + candidate = SaveNames.numbered(name, n) + } + } + throw IllegalStateException("No free name for $name") + } + + fun abort(id: Long): Boolean { + val sink = sinks.remove(id) ?: return false + synchronized(sink) { try { sink.out.close() } catch (e: Exception) { /* already closed */ } } + // A cancelled download leaves nothing: a truncated file looks like a + // complete one to whoever opens it next. + delete(sink.uri) + rememberPending(sink.uri, false) + return true + } + + /** Hand a finished file to the app that opens its type — only a type that runs nothing. */ + fun open(id: Long): Boolean { + val (uri, name) = completed[id] ?: return false + val type = SaveNames.openableType(name) ?: throw Refused("This kind of file is not opened from here") + startActivity(Intent(Intent.ACTION_VIEW).setDataAndType(uri, type) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)) + return true + } + + // ── What a killed process left behind ─────────────────────────────────── + + private fun rememberPending(uri: Uri, add: Boolean) = synchronized(prefs) { + val set = HashSet(prefs.getStringSet(KEY_PENDING, emptySet()) ?: emptySet()) + if (add) set.add(uri.toString()) else set.remove(uri.toString()) + prefs.edit().putStringSet(KEY_PENDING, set).commit() + } + + fun cleanUpAfterAKilledProcess() { + val pending = prefs.getStringSet(KEY_PENDING, emptySet()) ?: emptySet() + for (u in pending) delete(Uri.parse(u)) + prefs.edit().remove(KEY_PENDING).apply() + } + + private fun delete(uri: Uri) { + try { + if (DocumentsContract.isDocumentUri(context, uri)) DocumentsContract.deleteDocument(resolver, uri) + else resolver.delete(uri, null, null) + } catch (e: Exception) { Log.w(TAG, "could not remove an unfinished download", e) } + } + + companion object { + private const val TAG = "MeshBay" + private const val KEY_TREE = "downloadTree" + private const val KEY_PENDING = "pendingDownloads" + const val DEFAULT_NAME = "Downloads" + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/EngineCheck.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/EngineCheck.kt new file mode 100644 index 0000000..6382182 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/EngineCheck.kt @@ -0,0 +1,57 @@ +package org.meshbay.client.shell + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.view.Gravity +import android.view.View +import android.widget.Button +import android.widget.LinearLayout +import android.widget.TextView +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature + +/** + * The engine floor, checked before the page is loaded (design O6: verified, + * not assumed). + * + * The WebView updates through the store independently of Android, so the floor + * is a Chromium version, not an API level. What binds it: Ed25519 and X25519 in + * WebCrypto (the handshake, chat and the group envelope) — Chromium 137 — and + * the two androidx.webkit features the bridge is built on. Measured present on + * WebView 145 (spike S-1); the floor itself still has to be confirmed on the + * oldest real device to be supported. + */ +object EngineCheck { + const val MIN_CHROMIUM = 137 + + fun problem(context: Context): String? { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) || + !WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) { + return "This device's Android System WebView is too old for MeshBay." + } + val version = WebViewCompat.getCurrentWebViewPackage(context)?.versionName ?: return null + val major = version.substringBefore('.').toIntOrNull() ?: return null + return if (major < MIN_CHROMIUM) { + "MeshBay needs Android System WebView $MIN_CHROMIUM or newer; this device has $version." + } else null + } + + fun screen(context: Context, problem: String): View = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setPadding(48, 48, 48, 48) + addView(TextView(context).apply { text = problem; textSize = 18f; gravity = Gravity.CENTER }) + addView(Button(context).apply { + text = "Update Android System WebView" + setOnClickListener { + val id = "com.google.android.webview" + try { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$id"))) } + catch (e: ActivityNotFoundException) { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$id"))) + } + } + }) + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/NativeText.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/NativeText.kt new file mode 100644 index 0000000..ae23e46 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/NativeText.kt @@ -0,0 +1,48 @@ +package org.meshbay.client.shell + +/** + * A sentence from the interface's own catalogues, for the few things the + * application draws itself (main.js `nativeText`). The page chooses the + * language and nothing else: a confirmation it worded would be one it could + * answer for itself. + * + * The catalogues are `export default { 'key': '…', … }` with single-quoted + * strings; a plural entry is an object, of which `other` is taken. + */ +class NativeText(private val readCatalogue: (String) -> String?) { + + fun get(key: String, locale: String, params: Map<String, String> = emptyMap()): String { + var text = pick(readCatalogue(locale), key) ?: pick(readCatalogue("en"), key) ?: key + // split/join, as i18n.js: a folder name may contain `$&`. + for ((k, v) in params) text = text.split("{$k}").joinToString(v) + return text + } + + companion object { + fun pick(source: String?, key: String): String? { + source ?: return null + val k = Regex.escape(key) + Regex("""(?m)^\s*'$k'\s*:\s*'((?:[^'\\]|\\.)*)'""").find(source)?.let { return unescape(it.groupValues[1]) } + Regex("""(?ms)^\s*'$k'\s*:\s*\{.*?\bother\s*:\s*'((?:[^'\\]|\\.)*)'""").find(source)?.let { return unescape(it.groupValues[1]) } + return null + } + + fun unescape(s: String): String { + val out = StringBuilder() + var i = 0 + while (i < s.length) { + val c = s[i] + if (c == '\\' && i + 1 < s.length) { + val n = s[i + 1] + when (n) { + 'n' -> out.append('\n'); 't' -> out.append('\t') + 'u' -> if (i + 5 < s.length) { out.append(s.substring(i + 2, i + 6).toInt(16).toChar()); i += 4 } + else -> out.append(n) + } + i += 2 + } else { out.append(c); i++ } + } + return out.toString() + } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/Pickers.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/Pickers.kt new file mode 100644 index 0000000..f54ef87 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/Pickers.kt @@ -0,0 +1,40 @@ +package org.meshbay.client.shell + +import android.app.Activity +import android.content.Intent +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicInteger + +/** + * A system picker (folder, save-as, open) started from a bridge worker and + * waited on there — the bridge never blocks the UI thread, and the page gets + * its answer as the reply to the call that asked. + */ +class Pickers(private val activity: Activity) { + private class Waiting { val done = CountDownLatch(1); @Volatile var result: Intent? = null } + + private val waiting = ConcurrentHashMap<Int, Waiting>() + private val codes = AtomicInteger(4000) + + /** The result intent, or null when the person dismissed the picker. */ + fun run(intent: Intent): Intent? { + val code = codes.incrementAndGet() + val w = Waiting() + waiting[code] = w + activity.runOnUiThread { + try { activity.startActivityForResult(intent, code) } + catch (e: android.content.ActivityNotFoundException) { waiting.remove(code); w.done.countDown() } + } + w.done.await() + return w.result + } + + /** From Activity.onActivityResult; true when the code was one of ours. */ + fun deliver(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + val w = waiting.remove(requestCode) ?: return false + w.result = if (resultCode == Activity.RESULT_OK) data ?: Intent() else null + w.done.countDown() + return true + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt new file mode 100644 index 0000000..92a186f --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt @@ -0,0 +1,25 @@ +package org.meshbay.client.shell + +import android.content.Context +import android.view.View +import android.webkit.WebView + +/** + * While `keepVisible` is set, the WebView is told its window stayed visible + * when the screen turns off. Chromium then never marks the page hidden, and + * its freeze of hidden pages — exactly 60 s after hiding, measured (spike + * S-2a C) — never starts. Set only while a cast runs: a page that is never + * hidden is never throttled, which is the battery cost the freeze exists to + * avoid. + */ +class ShellWebView(context: Context) : WebView(context) { + var keepVisible = false + + override fun onWindowVisibilityChanged(visibility: Int) { + super.onWindowVisibilityChanged(if (keepVisible) View.VISIBLE else visibility) + } + + override fun onVisibilityChanged(changedView: View, visibility: Int) { + super.onVisibilityChanged(changedView, if (keepVisible) View.VISIBLE else visibility) + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt new file mode 100644 index 0000000..2300668 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt @@ -0,0 +1,93 @@ +package org.meshbay.client.shell + +import android.content.Context +import android.webkit.WebResourceResponse +import androidx.webkit.WebViewAssetLoader +import java.io.File + +/** + * Serves the packaged interface, and nothing else. + * + * The page's origin is `https://appassets.androidplatform.net` — a secure + * context, without which `crypto.subtle` does not exist — and every file comes + * out of the APK's `assets/ui/`, copied from the hub's static directory at build + * time (§8.3). The hub never becomes the document origin: that is the whole + * reason the application exists (T3). + * + * The stock asset handler sets no headers, so this one exists for three: the + * policy, sent as a header because a <meta> policy drops `frame-ancestors`; + * `nosniff`; and `no-store`, since every file is already local. + */ +class UiAssets(private val context: Context) : WebViewAssetLoader.PathHandler { + + override fun handle(path: String): WebResourceResponse? { + // Asset paths are not a filesystem, but a `..` that reached + // AssetManager would still be a path the page chose; refuse it. + if (path.split('/').any { it == ".." || it == "." } || path.startsWith("/")) return notFound() + val asset = "ui/" + path.ifEmpty { "index.html" } + val stream = try { context.assets.open(asset) } catch (e: java.io.IOException) { return notFound() } + val headers = mapOf( + "Content-Security-Policy" to CSP, + "X-Content-Type-Options" to "nosniff", + "Cache-Control" to "no-store", + ) + val type = contentType(asset) + val charset = if (type.startsWith("text/") || type == "application/json") "utf-8" else null + return WebResourceResponse(type, charset, 200, "OK", headers, stream) + } + + private fun notFound() = + WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), "".byteInputStream()) + + companion object { + const val HOST = "appassets.androidplatform.net" + const val ORIGIN = "https://$HOST" + const val PREFIX = "/ui/" + const val START = "$ORIGIN${PREFIX}index.html" + + // reCAPTCHA gates sign-up here as it does in a browser and on the + // desktop; these two hosts and no others. + private const val RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com" + + /** + * meshbay-client/src/main.js's CSP, directive for directive + * (test_android_shell.py holds them together). `'wasm-unsafe-eval'` is + * the Argon2 that opens bundles: without it nobody reaches their keys. + */ + val CSP = listOf( + "default-src 'none'", + "script-src 'self' 'wasm-unsafe-eval' $RECAPTCHA_SRC", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: $RECAPTCHA_SRC", + "media-src 'self' blob:", + "font-src 'self'", + "connect-src 'self' $RECAPTCHA_SRC", + "worker-src 'self'", + "object-src blob:", + "frame-src blob: $RECAPTCHA_SRC", + "frame-ancestors 'none'", + "base-uri 'none'", + "form-action 'none'", + ).joinToString("; ") + + fun isRecaptcha(host: String?) = host == "www.google.com" || host == "www.gstatic.com" + + fun contentType(name: String): String = when (File(name).extension.lowercase()) { + "html" -> "text/html" + "js", "mjs" -> "text/javascript" + "css" -> "text/css" + "json" -> "application/json" + "wasm" -> "application/wasm" + "svg" -> "image/svg+xml" + "png" -> "image/png" + "jpg", "jpeg" -> "image/jpeg" + "ico" -> "image/x-icon" + "webp" -> "image/webp" + "woff2" -> "font/woff2" + "woff" -> "font/woff" + "txt" -> "text/plain" + "xml" -> "application/xml" + else -> "application/octet-stream" + } + } +} diff --git a/packages/meshbay-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/packages/meshbay-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..50c1c99 --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- The desktop client's icon (meshbay-client/build/icon-square.png), placed + in the adaptive icon's safe zone so no launcher mask crops the M. --> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@color/ic_launcher_background" /> + <foreground android:drawable="@mipmap/ic_launcher_foreground" /> +</adaptive-icon> diff --git a/packages/meshbay-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/packages/meshbay-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..50c1c99 --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- The desktop client's icon (meshbay-client/build/icon-square.png), placed + in the adaptive icon's safe zone so no launcher mask crops the M. --> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@color/ic_launcher_background" /> + <foreground android:drawable="@mipmap/ic_launcher_foreground" /> +</adaptive-icon> diff --git a/packages/meshbay-android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/packages/meshbay-android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png Binary files differnew file mode 100644 index 0000000..5b29801 --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png diff --git a/packages/meshbay-android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/packages/meshbay-android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png Binary files differnew file mode 100644 index 0000000..4e211fb --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png diff --git a/packages/meshbay-android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/packages/meshbay-android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png Binary files differnew file mode 100644 index 0000000..d86c80b --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png diff --git a/packages/meshbay-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/packages/meshbay-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png Binary files differnew file mode 100644 index 0000000..2fdac24 --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png diff --git a/packages/meshbay-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/packages/meshbay-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png Binary files differnew file mode 100644 index 0000000..6cc1a12 --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png diff --git a/packages/meshbay-android/app/src/main/res/values/colors.xml b/packages/meshbay-android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..515e694 --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- The edge of icon-square.png, so the safe-zone image meets a seamless field. --> + <color name="ic_launcher_background">#010822</color> +</resources> diff --git a/packages/meshbay-android/app/src/main/res/values/themes.xml b/packages/meshbay-android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..a7df056 --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <style name="Shell" parent="@android:style/Theme.DeviceDefault.NoActionBar"> + <item name="android:windowBackground">@android:color/black</item> + </style> +</resources> diff --git a/packages/meshbay-android/app/src/main/res/xml/data_extraction_rules.xml b/packages/meshbay-android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..f0abf11 --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<data-extraction-rules> + <cloud-backup> + <exclude domain="root" /><exclude domain="file" /><exclude domain="database" /> + <exclude domain="sharedpref" /><exclude domain="external" /> + </cloud-backup> + <device-transfer> + <exclude domain="root" /><exclude domain="file" /><exclude domain="database" /> + <exclude domain="sharedpref" /><exclude domain="external" /> + </device-transfer> +</data-extraction-rules> diff --git a/packages/meshbay-android/app/src/main/res/xml/network_security_config.xml b/packages/meshbay-android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..8bf3e82 --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Plain http only to a hub on this device's own loopback — the desktop rule + ("http only to localhost or 127.*"). Anywhere else a session token would + cross the network in clear. --> +<network-security-config> + <base-config cleartextTrafficPermitted="false" /> + <domain-config cleartextTrafficPermitted="true"> + <domain includeSubdomains="false">localhost</domain> + <domain includeSubdomains="false">127.0.0.1</domain> + </domain-config> +</network-security-config> diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/CastRelayTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/CastRelayTest.kt new file mode 100644 index 0000000..5d3125f --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/CastRelayTest.kt @@ -0,0 +1,229 @@ +package org.meshbay.client + +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.meshbay.client.cast.BoxAccumulator +import org.meshbay.client.cast.CastRelay +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.net.InetAddress +import java.net.Socket + +/** The real relay on loopback, read with a raw socket: what a receiver sees. */ +class CastRelayTest { + private val spool = java.nio.file.Files.createTempDirectory("relay-spool").toFile() + private val relay = CastRelay({ InetAddress.getByName("127.0.0.1") }, spool) + + @After fun stop() { relay.stop(); spool.deleteRecursively() } + + private class Reply(val status: Int, val headers: Map<String, String>, val body: ByteArray) + + private fun get(url: String, method: String = "GET", readBytes: Int = -1): Reply { + val u = java.net.URI(url) + Socket(u.host, u.port).use { s -> + s.soTimeout = 5000 + s.getOutputStream().write("$method ${u.rawPath}${u.rawQuery?.let { "?$it" } ?: ""} HTTP/1.1\r\nHost: x\r\n\r\n".toByteArray()) + val input = DataInputStream(s.getInputStream()) + val headLines = ArrayList<String>() + val line = StringBuilder() + while (true) { + val c = input.read() + if (c == -1) break + if (c == '\n'.code) { val l = line.toString().trimEnd('\r'); if (l.isEmpty()) break; headLines.add(l); line.clear() } + else line.append(c.toChar()) + } + val status = headLines[0].split(' ')[1].toInt() + val headers = headLines.drop(1).associate { it.substringBefore(':').lowercase() to it.substringAfter(':').trim() } + val body = ByteArrayOutputStream() + if (headers["transfer-encoding"] == "chunked") { + while (readBytes < 0 || body.size() < readBytes) { + val sizeLine = StringBuilder() + while (true) { val c = input.read(); if (c == -1 || c == '\n'.code) break; sizeLine.append(c.toChar()) } + val size = sizeLine.toString().trim().toIntOrNull(16) ?: break + if (size == 0) break + val buf = ByteArray(size); input.readFully(buf); body.write(buf); input.read(); input.read() + } + } else { + val n = headers["content-length"]?.toInt() ?: 0 + val buf = ByteArray(n); input.readFully(buf); body.write(buf) + } + return Reply(status, headers, body.toByteArray()) + } + } + + private fun box(type: String, payload: Int): ByteArray { + val size = 8 + payload + return byteArrayOf((size ushr 24).toByte(), (size ushr 16).toByte(), (size ushr 8).toByte(), size.toByte()) + + type.toByteArray() + ByteArray(payload) { (it % 251).toByte() } + } + + private fun fragment(n: Int) = box("moof", 16 + n) + box("mdat", 1000 + n) + + @Test fun `fragments are re-framed from arbitrary slices`() { + val acc = BoxAccumulator() + val stream = box("ftyp", 12) + fragment(1) + fragment(2) + fragment(3) + val out = ArrayList<ByteArray>() + var i = 0 + while (i < stream.size) { val n = minOf(37, stream.size - i); out += acc.push(stream, i, n); i += n } + assertEquals(3, out.size) + assertArrayEquals(fragment(2), out[1]) + } + + @Test fun `a lost frame is recovered by rescanning for the next moof`() { + val acc = BoxAccumulator() + BoxAccumulator.warn = {} + val out = acc.push(fragment(1) + byteArrayOf(0, 0, 0, 1, 1, 2, 3, 4) + fragment(2)) + assertEquals(2, out.size) + } + + @Test fun `the stream is init then the backlog then what follows`() { + val init = box("ftyp", 20) + box("moov", 50) + val started = relay.start(init, null) + relay.push(fragment(1)); relay.push(fragment(2)) + val reply = get(started.getString("url"), readBytes = init.size + fragment(1).size + fragment(2).size) + assertEquals(200, reply.status) + assertEquals("video/mp4", reply.headers["content-type"]) + assertEquals("no-store", reply.headers["cache-control"]) + assertArrayEquals(init + fragment(1) + fragment(2), reply.body) + } + + @Test fun `a first chunk that carries film is served as its header only`() { + // As the page delivers it from a real film: a 64 KB slice holding the + // header, the first moof and the start of its mdat — pushed as well. + val header = box("ftyp", 20) + box("moov", 1200) + val stream = header + fragment(1) + fragment(2) + val firstChunk = stream.copyOfRange(0, header.size + 300) + val started = relay.start(firstChunk, null) + var at = 0 + while (at < stream.size) { val n = minOf(300, stream.size - at); relay.push(stream, at, n); at += n } + val reply = get(started.getString("url"), readBytes = stream.size) + assertArrayEquals("header, then each fragment once", stream, reply.body) + } + + @Test fun `a header split across chunks is served whole`() { + // Measured on a fresh start: the first chunk was the 28-byte ftyp + // alone, the moov came in the next push. Served as the header, the + // receiver had no moov and gave up — the first cast failed every time. + val ftyp = box("ftyp", 20) + val moov = box("moov", 2124) + val started = relay.start(ftyp, null) + val url = started.getString("url") + // The page pushes the first chunk too, then the rest. + relay.push(ftyp); relay.push(moov); relay.push(fragment(1)); relay.push(fragment(2)) + val reply = get(url, readBytes = ftyp.size + moov.size + fragment(1).size + fragment(2).size) + assertArrayEquals(ftyp + moov + fragment(1) + fragment(2), reply.body) + } + + @Test fun `a receiver early for the header waits for all of it`() { + val ftyp = box("ftyp", 20) + val moov = box("moov", 2124) + val url = relay.start(ftyp, null).getString("url") + relay.push(ftyp) + val late = Thread { Thread.sleep(400); relay.push(moov); relay.push(fragment(1)) }.apply { start() } + val reply = get(url, readBytes = ftyp.size + moov.size + fragment(1).size) + late.join() + assertArrayEquals(ftyp + moov + fragment(1), reply.body) + } + + @Test fun `the token is required and unguessable`() { + val url = relay.start(null, null).getString("url") + val token = url.substringAfter("t=") + assertTrue(Regex("^[0-9a-f]{32}$").matches(token)) + assertEquals(403, get(url.replace(token, "0".repeat(32))).status) + assertEquals(403, get(url.substringBefore("?")).status) + assertEquals(405, get(url, method = "POST").status) + assertEquals(404, get(url.replace("/stream.mp4", "/other")).status) + } + + @Test fun `the subtitle is webvtt behind the token, readable cross-origin, re-addressed when it changes`() { + val r = relay.start(null, JSONObject().put("vtt", "WEBVTT\n\n00:00.000 --> 00:01.000\nhi\n").put("language", "fr").put("label", "Français")) + val sub = r.getJSONObject("subtitle") + val reply = get(sub.getString("url")) + assertEquals(200, reply.status) + assertEquals("text/vtt; charset=utf-8", reply.headers["content-type"]) + assertEquals("*", reply.headers["access-control-allow-origin"]) + assertTrue(String(reply.body).startsWith("WEBVTT")) + assertEquals(403, get(sub.getString("url").replace(Regex("t=[0-9a-f]+"), "t=x")).status) + + val second = relay.setSubtitle(JSONObject().put("vtt", "WEBVTT\n"))!! + assertNotEquals(sub.getString("url"), second.getString("url")) + assertNull(relay.setSubtitle(null)) + assertEquals(404, get(second.getString("url")).status) + assertEquals(200, get(r.getString("url"), readBytes = 0).status) + } + + @Test fun `the preflight is answered before the token is checked`() { + val url = relay.start(null, null).getString("url") + val reply = get(url.substringBefore("?"), method = "OPTIONS") + assertEquals(204, reply.status) + assertEquals("GET, OPTIONS", reply.headers["access-control-allow-methods"]) + assertTrue(reply.headers["access-control-allow-headers"]!!.contains("Range")) + } + + @Test fun `the stream carries the same CORS headers as its subtitle`() { + val url = relay.start(null, null).getString("url") + val reply = get(url, readBytes = 0) + for ((k, v) in CastRelay.CORS_HEADERS) assertEquals(k, v, reply.headers[k.lowercase()]) + } + + @Test fun `the backlog is bounded in bytes, not only in fragments`() { + relay.start(null, null) + // 40 fragments of 4 MB: under the fragment cap, far over a phone's heap. + val big = box("moof", 16) + box("mdat", 4 * 1024 * 1024) + repeat(40) { relay.push(big) } + assertTrue("backlog ${relay.backlogBytes()}", relay.backlogBytes() <= CastRelay.RING_MAX_BYTES) + assertTrue(relay.backlogBytes() >= CastRelay.RING_MAX_BYTES - big.size) + } + + @Test fun `seek after seek, the relay restarts on its ports`() { + // A seek restarts the relay, and a receiver was connected each time: + // the ports it closed sit in TIME_WAIT. + repeat(8) { + val url = relay.start(null, null).getString("url") + relay.push(fragment(it)) + get(url, readBytes = fragment(it).size) + relay.stop() + } + } + + @Test fun `a receiver far behind loses nothing, and its spool goes with it`() { + // Fragments of 4 MB, ten of them pushed while the receiver has read + // none: far past the 8 MB the desktop drops at, which froze the TV. + val url = relay.start(null, null).getString("url") + val u = java.net.URI(url) + Socket(u.host, u.port).use { s -> + s.getOutputStream().write("GET ${u.rawPath}?${u.rawQuery} HTTP/1.1\r\nHost: x\r\n\r\n".toByteArray()) + Thread.sleep(300) + val big = (0 until 10).map { box("moof", 16 + it) + box("mdat", 4 * 1024 * 1024 + it) } + big.forEach { relay.push(it) } + assertEquals("one spool file for the one receiver", 1, spool.listFiles()!!.size) + val input = DataInputStream(s.getInputStream()) + while (true) { val l = StringBuilder(); while (true) { val c = input.read(); if (c == '\n'.code) break; l.append(c.toChar()) }; if (l.toString().trim().isEmpty()) break } + val body = ByteArrayOutputStream() + val want = big.sumOf { it.size } + while (body.size() < want) { + val size = StringBuilder().also { sb -> while (true) { val c = input.read(); if (c == '\n'.code) break; sb.append(c.toChar()) } }.toString().trim().toInt(16) + val buf = ByteArray(size); input.readFully(buf); body.write(buf); input.read(); input.read() + } + assertArrayEquals("every fragment, in order, none dropped", big.reduce { a, b -> a + b }, body.toByteArray()) + } + Thread.sleep(300) + relay.stop() + assertEquals("the spool is deleted with the receiver", 0, spool.listFiles()!!.size) + } + + @Test fun `stopping closes the port`() { + val url = relay.start(null, null).getString("url") + relay.stop() + val u = java.net.URI(url) + val refused = try { Socket(u.host, u.port).close(); false } catch (e: java.net.ConnectException) { true } + assertTrue(refused) + assertNull(relay.url) + } +} diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ChannelsTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ChannelsTest.kt new file mode 100644 index 0000000..adc6366 --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ChannelsTest.kt @@ -0,0 +1,39 @@ +package org.meshbay.client + +import org.json.JSONArray +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import org.meshbay.client.bridge.Channels +import org.meshbay.client.bridge.Refused +import org.meshbay.client.hub.HubClient +import java.net.InetAddress +import java.net.UnknownHostException + +class ChannelsTest { + private val channels = Channels(HubClient(FakePrefs()), onHubChanged = {}, hasCatalogue = { it == "fr" || it == "pt-BR" }) + + @Test fun `a channel that is not enumerated is refused`() { + for (ch in listOf("node:op", "root:choose", "window:minimize-to-tray", "keys:sign", "", "hub:fetch2")) { + assertThrows(ch, Refused::class.java) { channels.call(ch, JSONArray()) } + } + } + + @Test fun `the locale is a code with a catalogue, never text`() { + assertEquals("fr", channels.call("ui:locale", JSONArray().put("fr"))) + assertEquals("fr", channels.call("ui:locale", JSONArray().put("de"))) // no catalogue + assertEquals("fr", channels.call("ui:locale", JSONArray().put("../en"))) // not a code + assertEquals("pt-BR", channels.call("ui:locale", JSONArray().put("pt-BR"))) + } + + @Test fun `stun hostnames are resolved, literals kept, failures dropped`() { + val lookup: (String) -> Array<InetAddress> = { host -> + if (host == "stun.example") arrayOf(InetAddress.getByAddress(host, byteArrayOf(192.toByte(), 0, 2, 7))) + else throw UnknownHostException(host) + } + val out = Channels.resolveStun(JSONArray(listOf("stun:stun.example:3478", "stun:198.51.100.1:3478", + "stun:[2001:db8::1]:3478", "stun:gone.example:3478", "turn:x")), lookup) + assertEquals(listOf("stun:192.0.2.7:3478", "stun:198.51.100.1:3478", "stun:[2001:db8::1]:3478", "turn:x"), + (0 until out.length()).map { out.getString(it) }) + } +} diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakePrefs.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakePrefs.kt new file mode 100644 index 0000000..33d1c0f --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakePrefs.kt @@ -0,0 +1,30 @@ +package org.meshbay.client + +import android.content.SharedPreferences + +/** SharedPreferences is an interface; a map is enough for the JVM tests. */ +class FakePrefs : SharedPreferences { + val map = HashMap<String, Any?>() + override fun getAll(): MutableMap<String, *> = map + override fun getString(key: String, defValue: String?) = map[key] as String? ?: defValue + override fun getStringSet(key: String, defValues: MutableSet<String>?) = defValues + override fun getInt(key: String, defValue: Int) = map[key] as Int? ?: defValue + override fun getLong(key: String, defValue: Long) = map[key] as Long? ?: defValue + override fun getFloat(key: String, defValue: Float) = map[key] as Float? ?: defValue + override fun getBoolean(key: String, defValue: Boolean) = map[key] as Boolean? ?: defValue + override fun contains(key: String) = map.containsKey(key) + override fun registerOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} + override fun unregisterOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} + override fun edit(): SharedPreferences.Editor = object : SharedPreferences.Editor { + override fun putString(k: String, v: String?) = apply { map[k] = v } + override fun putStringSet(k: String, v: MutableSet<String>?) = apply { map[k] = v } + override fun putInt(k: String, v: Int) = apply { map[k] = v } + override fun putLong(k: String, v: Long) = apply { map[k] = v } + override fun putFloat(k: String, v: Float) = apply { map[k] = v } + override fun putBoolean(k: String, v: Boolean) = apply { map[k] = v } + override fun remove(k: String) = apply { map.remove(k) } + override fun clear() = apply { map.clear() } + override fun commit() = true + override fun apply() {} + } +} diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakeSecrets.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakeSecrets.kt new file mode 100644 index 0000000..86537bd --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakeSecrets.kt @@ -0,0 +1,11 @@ +package org.meshbay.client + +import org.json.JSONObject +import org.meshbay.client.keys.Secrets + +class FakeSecrets(var backendName: String = "android_keystore") : Secrets { + var all = JSONObject() + override fun backend() = backendName + override fun read() = JSONObject(all.toString()) + override fun update(fn: (JSONObject) -> Unit) { val a = read(); fn(a); all = a } +} diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/HubClientTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/HubClientTest.kt new file mode 100644 index 0000000..8211013 --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/HubClientTest.kt @@ -0,0 +1,43 @@ +package org.meshbay.client + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.meshbay.client.bridge.Refused +import org.meshbay.client.hub.HubClient + +class HubClientTest { + private fun hub(base: String = "") = HubClient(FakePrefs().apply { map["hubBase"] = base }) + + @Test fun `an empty address is refused, not stored as no hub`() { + val e = assertThrows(Refused::class.java) { hub().setBase(" ") } + assertEquals("Enter the address of a hub.", e.message) + } + + @Test fun `plain http is refused except to loopback`() { + for (url in listOf("http://example.org", "http://10.0.2.2:8770", "ftp://x", "http://192.168.1.2")) { + val e = assertThrows(Refused::class.java) { hub().setBase(url) } + assertEquals(url, "The hub address must be https", e.message) + } + } + + @Test fun `the page reaches the signed-in hub and nowhere else`() { + val h = hub("https://hub.example") + for (url in listOf("https://other.example/v1/x", "http://hub.example/v1/x", + "https://hub.example:8443/v1/x", "https://hub.example.evil/v1/x", "not a url")) { + assertThrows(url, Refused::class.java) { h.fetch(url, JSONObject()) } + } + } + + @Test fun `nothing is fetched before a hub is set`() { + assertThrows(Refused::class.java) { hub("").fetch("https://hub.example/v1/x", null) } + } + + @Test fun `unreachable hubs are described in words somebody can act on`() { + assertTrue(HubClient.describeUnreachable("https://h", java.net.ConnectException()).startsWith("Nothing is listening at https://h")) + assertTrue(HubClient.describeUnreachable("https://h", java.net.UnknownHostException()).contains("could not be found")) + assertTrue(HubClient.describeUnreachable("https://h", javax.net.ssl.SSLHandshakeException("x")).contains("does not speak https")) + } +} diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyChannelsTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyChannelsTest.kt new file mode 100644 index 0000000..c4333db --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyChannelsTest.kt @@ -0,0 +1,81 @@ +package org.meshbay.client + +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.meshbay.client.bridge.KeyChannels +import org.meshbay.client.bridge.Refused +import org.meshbay.client.keys.Kdf + +class KeyChannelsTest { + private val user = "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0" + private val node = Kdf.b64(ByteArray(32) { 0x11 }) + private var asked = 0 + private var answer = false + private val secrets = FakeSecrets() + private val keys = KeyChannels(secrets, confirm = { asked++; answer }, declined = { "Cancelled" }) + + private fun call(ch: String, vararg args: Any?) = keys.call(ch, JSONArray(args.toList())) + + @Test fun `ids and node keys are checked before anything is done`() { + for (bad in listOf("", "../x", "not-an-id", "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0x", null)) { + assertThrows("$bad", Refused::class.java) { call("keys:fingerprint", bad) } + } + for (bad in listOf("", "a/b c", "x".repeat(101), null)) { + assertThrows("$bad", Refused::class.java) { call("keys:identity", user, bad) } + } + } + + @Test fun `the device key signs the bytes the hub verifies and never leaves`() { + val pub = call("device:ensure") as String + assertEquals(pub, call("device:ensure")) // once, then the same key + val s = call("device:sign", "alice") as JSONObject + val msg = "meshbay:user_auth:alice:${s.getLong("timestamp")}".toByteArray() + val v = Ed25519Signer().apply { init(false, Ed25519PublicKeyParameters(Kdf.unb64(pub), 0)); update(msg, 0, msg.size) } + assertTrue(v.verifySignature(Kdf.unb64(s.getString("signature")))) + call("device:forget") + assertNull(call("device:public")) + } + + @Test fun `browser access is widened only by the person, natively`() { + call("keys:created-here", user) + assertEquals(false, call("keys:browser-access", user)) + answer = false + val e = assertThrows(Refused::class.java) { call("keys:set-browser-access", user, true) } + assertEquals("Cancelled", e.message) + assertEquals(1, asked) + assertEquals(false, call("keys:browser-access", user)) + answer = true + assertEquals(true, call("keys:set-browser-access", user, true)) + // Narrowing asks nobody. + assertEquals(false, call("keys:set-browser-access", user, false)) + assertEquals(2, asked) + } + + @Test fun `without OS key storage nothing is minted or derived`() { + val none = KeyChannels(FakeSecrets("unavailable"), confirm = { true }, declined = { "" }) + assertEquals(false, none.call("keys:available", JSONArray())) + assertThrows(Refused::class.java) { none.call("keys:mint", JSONArray(listOf(user, node))) } + assertEquals(false, none.call("keys:has-session", JSONArray(listOf(user)))) + } + + @Test fun `a minted identity answers with public keys only`() { + val r = call("keys:mint", user, node) as JSONObject + assertEquals(setOf("pkEdB64", "pkXB64"), r.keys().asSequence().toSet()) + val id = call("keys:identity", user, node) as JSONObject + assertEquals(r.getString("pkEdB64"), id.getString("pkEdB64")) + assertEquals(JSONObject.NULL, id.get("sealedWith")) + } + + @Test fun `channels outside the list are refused`() { + assertThrows(Refused::class.java) { call("keys:export") } + assertFalse(keys.handles("hub:fetch")) + } +} diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyringVectorsTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyringVectorsTest.kt new file mode 100644 index 0000000..63edbde --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyringVectorsTest.kt @@ -0,0 +1,151 @@ +package org.meshbay.client + +import org.json.JSONObject +import org.junit.Assert.assertTrue +import org.junit.Test +import org.meshbay.client.keys.Kdf +import org.meshbay.client.keys.Keyring +import org.meshbay.client.keys.Transcripts +import java.io.File + +/** + * The keyring and the transcripts against meshbay-hub/tests/vectors/keyring.json, + * which the desktop keyring writes and the specification reproduces + * (test_keyring_vectors.py). Every deterministic field byte for byte, every + * refusal with its message: a bundle this sealed is one the page and the + * desktop open, and the reverse. + */ +class KeyringVectorsTest { + private var passed = 0 + private val failures = ArrayList<String>() + private fun check(label: String, ok: Boolean, detail: () -> String = { "" }) { + if (ok) passed++ else failures.add("$label ${detail()}") + } + private fun <T> eq(label: String, got: T, want: T) = check(label, got == want) { "got=$got want=$want" } + + @Test fun `every vector is reproduced`() { + + val v = JSONObject(VECTORS.readText()) + val input = v.getJSONObject("input") + val kdf = v.getJSONObject("kdf") + val bundles = v.getJSONObject("bundles") + val now = input.getLong("now").toDouble() + val userId = input.getString("userId") + val nodePk = input.getString("nodePk") + val username = input.getString("username") + + // The store, as SecretStore would hold it. + var store = JSONObject() + var nonces: ArrayDeque<ByteArray> = ArrayDeque() + fun ring() = Keyring( + load = { JSONObject(store.toString()) }, + save = { store = JSONObject(it.toString()) }, + transcripts = Transcripts { now }, + random = { n -> nonces.removeFirstOrNull() ?: ByteArray(n).also { java.security.SecureRandom().nextBytes(it) } }, + ) + val ring = ring() + + // 1. KDF chain. + val salt = Kdf.sha256("meshbay:bundle:v2:$username".toByteArray()).copyOfRange(0, 16) + eq("salt", Kdf.toHex(salt), kdf.getString("salt_hex")) + ring.deriveSession(input.getString("password"), username, userId, + input.getString("pepperB64"), input.getInt("pepperVersion")) + val masters = store.getJSONObject("masters").getJSONObject(userId) + eq("argon2id", Kdf.toHex(Kdf.unb64(masters.getString("legacy"))), kdf.getString("argon2_hex")) + eq("M", Kdf.toHex(Kdf.unb64(masters.getString("m"))), kdf.getString("master_hex")) + eq("pepper version", masters.getInt("v"), input.getInt("pepperVersion")) + eq("fingerprint", ring.currentFingerprint(userId), kdf.getString("master_fingerprint")) + eq("node key", Kdf.toHex(Kdf.hkdf(Kdf.unb64(masters.getString("m")), "meshbay:bundle:v3|node|$nodePk")), + kdf.getString("node_key_hex")) + eq("playlist key", ring.playlistKey(userId), kdf.getString("playlist_key_b64")) + + // 2. Identity: placed in the store as keyring.js would leave it. + val ident = v.getJSONObject("identity") + store.getJSONObject("identities").put(userId, JSONObject().put(nodePk, + JSONObject().put("ed", ident.getString("ed_pkcs8_b64")).put("x", ident.getString("x_pkcs8_b64")) + .put("sealedWith", JSONObject.NULL))) + val pub = ring.identity(userId, nodePk)!! + eq("pkEd", pub.pkEdB64, ident.getJSONObject("public").getString("pkEdB64")) + eq("pkX", pub.pkXB64, ident.getJSONObject("public").getString("pkXB64")) + + // 3. Bundles: sealed byte for byte, and opened. + val fixedNonce = Kdf.hex(input.getString("fixedNonceHex")) + nonces.addLast(fixedNonce) + val sealed = ring.sealBundle(userId, nodePk) + eq("bundle sealed with a fixed nonce", sealed.bundle, bundles.getString("fixed_nonce_bundle_b64")) + eq("bundle fingerprint", sealed.fingerprint, bundles.getString("fixed_nonce_fingerprint")) + nonces.addLast(fixedNonce) + eq("recovery bundle", ring.sealRecovery(userId, nodePk, input.getString("mnemonic"), username), + bundles.getString("recovery_fixed_nonce_b64")) + + fun opensTo(label: String, block: (Keyring) -> Keyring.Pub) { + val saved = store + store = JSONObject().put("masters", JSONObject().put(userId, masters)).put("identities", JSONObject()) + .put("access", JSONObject()) + try { + val p = block(ring()) + check(label, p.pkEdB64 == pub.pkEdB64 && p.pkXB64 == pub.pkXB64) { "opened to another identity" } + check("$label leaves the identity unsealed", ring().identity(userId, nodePk)?.sealedWith == null) + } catch (e: Exception) { + check(label, false) { e.toString() } + } finally { store = saved } + } + opensTo("desktop bundle (fixed nonce) opens") { it.openBundle(userId, nodePk, bundles.getString("fixed_nonce_bundle_b64")) } + opensTo("MBK2 legacy bundle opens") { it.openBundle(userId, nodePk, bundles.getString("legacy_mbk2_b64")) } + val bogus = Kdf.b64("MBK3".toByteArray() + ByteArray(30) { 1 }) + opensTo("recovery copy opens through the fallback") { + it.openBundle(userId, nodePk, bogus, bundles.getString("recovery_fixed_nonce_b64"), + input.getString("mnemonic"), username) + } + // A bundle Kotlin seals (random nonce) must open — round trip. + val ours = ring.sealBundle(userId, nodePk).bundle + opensTo("a bundle sealed here opens here") { it.openBundle(userId, nodePk, ours) } + // A retired format is refused, never tried against the recovery key. + try { + ring.openBundle(userId, nodePk, Kdf.b64("MBK1xxxxxxxxxxxxxxxxxxxxxxxxxxxxx".toByteArray())) + check("retired format refused", false) + } catch (e: Keyring.FormatRetired) { check("retired format refused", true) } + + // Browser access off: nothing sealed. + ring.setBrowserAccess(userId, false) + try { ring.sealBundle(userId, nodePk); check("no bundle while browser access is off", false) } + catch (e: IllegalStateException) { check("no bundle while browser access is off", e.message!!.startsWith("Refused")) } + ring.setBrowserAccess(userId, true) + + // 4. Agreement. + val ag = v.getJSONObject("agreement") + eq("X25519 agreement", ring.shared(userId, nodePk, ag.getString("peer_x_pub_b64")), ag.getString("shared_b64")) + + // 5. Transcripts and signatures. + val tr = Transcripts { now } + val ctx = Transcripts.Ctx(userId, nodePk, pub.pkEdB64, pub.pkXB64) + val kinds = v.getJSONObject("transcripts") + for (kind in kinds.keys()) { + val t = kinds.getJSONObject(kind) + eq("transcript $kind", Kdf.toHex(tr.forKind(kind, t.getJSONObject("fields"), ctx)), t.getString("transcript_hex")) + eq("signature $kind", ring.signAs(userId, nodePk, kind, t.getJSONObject("fields")), t.getString("signature_b64")) + } + val refusals = v.getJSONArray("refusals") + for (i in 0 until refusals.length()) { + val r = refusals.getJSONObject(i) + try { + tr.forKind(r.getString("kind"), r.getJSONObject("fields"), ctx) + check("refusal '${r.getString("label")}'", false) { "was signed" } + } catch (e: Transcripts.Refused) { + eq("refusal '${r.getString("label")}' message", e.message, r.getString("error")) + } + } + + // Sign-out drops M and keeps the identities. + ring.forgetSession(userId) + check("sign-out drops M", !ring.hasSession(userId)) + check("sign-out keeps the identity", ring.identity(userId, nodePk) != null) + assertTrue("vector failures:\n" + failures.joinToString("\n"), failures.isEmpty()) + assertTrue("too few checks ran: $passed", passed >= 55) + } + + companion object { + // Unit tests run with the module directory as working directory. + val VECTORS = File("../../meshbay-hub/tests/vectors/keyring.json") + } +} diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/NativeTextTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/NativeTextTest.kt new file mode 100644 index 0000000..9e0bc7e --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/NativeTextTest.kt @@ -0,0 +1,34 @@ +package org.meshbay.client + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import org.meshbay.client.shell.NativeText +import java.io.File + +/** The application's own dialogs, worded from the real catalogues in every language. */ +class NativeTextTest { + private val locales = File("../../meshbay-hub/src/meshbay_hub/static/locales") + private val text = NativeText { code -> File(locales, "$code.js").takeIf { it.exists() }?.readText() } + + @Test fun `every catalogue words the native dialogs`() { + val codes = locales.listFiles()!!.map { it.nameWithoutExtension } + assertEquals(10, codes.size) + for (code in codes) for (key in listOf("native.browser_access_confirm", "native.declined", "dialog.ok", "dialog.cancel")) { + assertNotEquals("$code $key", key, text.get(key, code)) + } + } + + @Test fun `escapes are read as the page reads them`() { + assertEquals("Annulé — rien n'a été modifié.", text.get("native.declined", "fr")) + } + + @Test fun `an unknown language falls back to English, an unknown key to itself`() { + assertEquals("Cancel", text.get("dialog.cancel", "xx")) + assertEquals("no.such.key", text.get("no.such.key", "fr")) + } + + @Test fun `plural entries give their other form and parameters are filled`() { + assertEquals("Use at least 12 characters", text.get("register.err_min_len", "en", mapOf("n" to "12"))) + } +} diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/SaveNamesTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/SaveNamesTest.kt new file mode 100644 index 0000000..d3b8450 --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/SaveNamesTest.kt @@ -0,0 +1,45 @@ +package org.meshbay.client + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.meshbay.client.save.BinaryFrame +import org.meshbay.client.save.SaveNames + +class SaveNamesTest { + @Test fun `a suggested name is a basename with no bidirectional tricks`() { + assertEquals("invoice_fdp.exe", SaveNames.sanitize("invoice\u202efdp.exe")) + assertEquals("passwd", SaveNames.sanitize("../../etc/passwd")) + assertEquals("x.txt", SaveNames.sanitize("C:\\Users\\x.txt")) + assertEquals("download", SaveNames.sanitize("")) + assertEquals("download", SaveNames.sanitize("..")) + assertEquals("a_b", SaveNames.sanitize("a\u0000b")) + } + + @Test fun `only what runs nothing is opened, under a type from the name`() { + assertEquals("application/pdf", SaveNames.openableType("Report.PDF")) + assertEquals("video/mp4", SaveNames.openableType("clip.mp4")) + for (n in listOf("page.html", "image.svg", "run.apk", "script.js", "noext", "x.pdf.exe")) { + assertNull(n, SaveNames.openableType(n)) + } + assertEquals("application/octet-stream", SaveNames.storedType("page.html")) + } + + @Test fun `a taken name becomes name (n), never an overwrite`() { + assertEquals("film (2).mkv", SaveNames.numbered("film.mkv", 2)) + assertEquals("README (3)", SaveNames.numbered("README", 3)) + } + + @Test fun `a binary frame is read as the shim writes it`() { + val payload = byteArrayOf(1, 2, 3, 4, 5) + val b = byteArrayOf(0x4d, 0x42, 0x42, 0x31, 0, 0, 1, 2, 0, 1, 0, 0, 0, 0, 0, 7) + payload + val f = BinaryFrame.parse(b)!! + assertEquals(258L, f.id) + assertEquals(BinaryFrame.SAVE_WRITE, f.channel) + assertEquals(7L, f.handle) + assertArrayEquals(payload, f.bytes.copyOfRange(f.offset, f.bytes.size)) + assertNull(BinaryFrame.parse(byteArrayOf(0x4d, 0x42, 0x42, 0x32) + ByteArray(12))) + assertNull(BinaryFrame.parse(ByteArray(10))) + } +} diff --git a/packages/meshbay-android/build.gradle.kts b/packages/meshbay-android/build.gradle.kts new file mode 100644 index 0000000..a4370dd --- /dev/null +++ b/packages/meshbay-android/build.gradle.kts @@ -0,0 +1 @@ +plugins { id("com.android.application") version "9.4.1" apply false } diff --git a/packages/meshbay-android/gradle.properties b/packages/meshbay-android/gradle.properties new file mode 100644 index 0000000..660848f --- /dev/null +++ b/packages/meshbay-android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx2g +android.useAndroidX=true diff --git a/packages/meshbay-android/gradle/wrapper/gradle-wrapper.jar b/packages/meshbay-android/gradle/wrapper/gradle-wrapper.jar Binary files differnew file mode 100644 index 0000000..5097068 --- /dev/null +++ b/packages/meshbay-android/gradle/wrapper/gradle-wrapper.jar diff --git a/packages/meshbay-android/gradle/wrapper/gradle-wrapper.properties b/packages/meshbay-android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..9f3a241 --- /dev/null +++ b/packages/meshbay-android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,10 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c diff --git a/packages/meshbay-android/gradlew b/packages/meshbay-android/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/packages/meshbay-android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/packages/meshbay-android/gradlew.bat b/packages/meshbay-android/gradlew.bat new file mode 100644 index 0000000..3185a43 --- /dev/null +++ b/packages/meshbay-android/gradlew.bat @@ -0,0 +1,112 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel + +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/packages/meshbay-android/settings.gradle.kts b/packages/meshbay-android/settings.gradle.kts new file mode 100644 index 0000000..cead413 --- /dev/null +++ b/packages/meshbay-android/settings.gradle.kts @@ -0,0 +1,9 @@ +pluginManagement { + repositories { google(); mavenCentral(); gradlePluginPortal() } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { google(); mavenCentral() } +} +rootProject.name = "meshbay-android" +include(":app") diff --git a/packages/meshbay-client/package.json b/packages/meshbay-client/package.json index 80c021f..8a1fec7 100644 --- a/packages/meshbay-client/package.json +++ b/packages/meshbay-client/package.json @@ -1,6 +1,6 @@ { "name": "meshbay-client", - "version": "0.17.0", + "version": "0.18.0", "description": "MeshBay desktop client — the interface ships with the application, not from the hub", "license": "AGPL-3.0-or-later", "author": "MeshBay Team <team@meshbay.org>", diff --git a/packages/meshbay-client/src/cast-chromecast.js b/packages/meshbay-client/src/cast-chromecast.js index 7fc8079..55deb5b 100644 --- a/packages/meshbay-client/src/cast-chromecast.js +++ b/packages/meshbay-client/src/cast-chromecast.js @@ -174,6 +174,7 @@ class CastChromecast { player.on('status', (status) => { debug(`[cast-chromecast] player status: ${status.playerState}`); + this._noteStatus(status); }); debug(`[cast-chromecast] loading with ${subtitle?.url ? 'subtitles' : 'no subtitles'}`); @@ -185,6 +186,7 @@ class CastChromecast { }); }); + this._noteStatus(status); debug(`[cast-chromecast] loaded on "${device.name}", state: ${status.playerState}`); return { deviceName: device.name, playerState: status.playerState }; } @@ -200,6 +202,7 @@ class CastChromecast { resolve(s); }); }); + this._noteStatus(status); debug(`[cast-chromecast] reloaded, state: ${status.playerState}`); return { playerState: status.playerState }; } @@ -215,10 +218,55 @@ class CastChromecast { this._cleanup(); } + /** + * What the receiver last said, kept so the page can show where the + * *television* is: its playhead is not the local one, which started earlier + * and drifts. `currentTime` is on the stream's timeline — zero at the point + * the relay started — and the page adds that start. + */ + _noteStatus(status) { + if (!status) return; + this._lastStatus = { + playerState: status.playerState || null, + idleReason: status.idleReason || null, + position: Number(status.currentTime) || 0, + at: Date.now(), + }; + } + + /** The receiver's position now: extrapolated while it plays, as its own clock does. */ + _position() { + const s = this._lastStatus; + if (!s) return null; + return s.playerState === 'PLAYING' ? s.position + (Date.now() - s.at) / 1000 : s.position; + } + + async pause() { + if (!this._player) throw new Error('Not connected'); + const status = await new Promise((resolve, reject) => { + this._player.pause((err, s) => (err ? reject(err) : resolve(s))); + }); + this._noteStatus(status); + return { playerState: status && status.playerState }; + } + + async play() { + if (!this._player) throw new Error('Not connected'); + const status = await new Promise((resolve, reject) => { + this._player.play((err, s) => (err ? reject(err) : resolve(s))); + }); + this._noteStatus(status); + return { playerState: status && status.playerState }; + } + getStatus() { + const s = this._lastStatus; return { connected: this._client !== null, deviceName: this._connectedDevice?.name || null, + playerState: s ? s.playerState : null, + idleReason: s ? s.idleReason : null, + position: this._client !== null ? this._position() : null, }; } @@ -229,6 +277,7 @@ class CastChromecast { this._client = null; this._player = null; this._connectedDevice = null; + this._lastStatus = null; } } diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 0ad7e71..4c8707c 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -2330,6 +2330,10 @@ function registerBridge() { subtitle === undefined ? castRelay.subtitle : subtitle); }); + // The player becomes a remote while casting: these drive the receiver. + handle('cast:chromecast:pause', async () => castChromecast.pause()); + handle('cast:chromecast:play', async () => castChromecast.play()); + handle('cast:chromecast:disconnect', async () => { await castChromecast.disconnect(); return true; diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js index 665ec1f..43cf317 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -180,6 +180,8 @@ contextBridge.exposeInMainWorld('meshbay', { chromecastConnect: (opts) => ipcRenderer.invoke('cast:chromecast:connect', opts), chromecastReload: (opts) => ipcRenderer.invoke('cast:chromecast:reload', opts), chromecastDisconnect: () => ipcRenderer.invoke('cast:chromecast:disconnect'), + chromecastPause: () => ipcRenderer.invoke('cast:chromecast:pause'), + chromecastPlay: () => ipcRenderer.invoke('cast:chromecast:play'), }, // A sink that writes to disk as chunks arrive, never a buffer handed over at diff --git a/packages/meshbay-common/pyproject.toml b/packages/meshbay-common/pyproject.toml index 188ccf6..f8035ba 100644 --- a/packages/meshbay-common/pyproject.toml +++ b/packages/meshbay-common/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "meshbay-common" -version = "0.17.0" +version = "0.18.0" description = "MeshBay shared cryptographic primitives and protocol types" requires-python = ">=3.12" dependencies = [ diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py index 2a58326..3ffb2b7 100644 --- a/packages/meshbay-common/src/meshbay_common/__init__.py +++ b/packages/meshbay-common/src/meshbay_common/__init__.py @@ -1,6 +1,6 @@ """MeshBay common — shared crypto primitives and protocol types.""" -__version__ = "0.17.0" +__version__ = "0.18.0" # 0.2: added PING/PONG, and `before`/`has_more` on chat history. Both are # additive — an 0.1 peer sends no `before` and gets the newest page, which is # what it wanted — so this is a MINOR bump, not a MAJOR one. diff --git a/packages/meshbay-hub/pyproject.toml b/packages/meshbay-hub/pyproject.toml index 7f1154c..c927242 100644 --- a/packages/meshbay-hub/pyproject.toml +++ b/packages/meshbay-hub/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "meshbay-hub" -version = "0.17.0" +version = "0.18.0" description = "MeshBay Hub — identity authority and group registry server" requires-python = ">=3.12" dependencies = [ diff --git a/packages/meshbay-hub/src/meshbay_hub/__init__.py b/packages/meshbay-hub/src/meshbay_hub/__init__.py index 4d3d6dd..cf6868d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/__init__.py +++ b/packages/meshbay-hub/src/meshbay_hub/__init__.py @@ -1,3 +1,3 @@ """MeshBay Hub — identity authority and group registry.""" -__version__ = "0.17.0" +__version__ = "0.18.0" diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py index a1afe38..2a3efaf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py @@ -88,7 +88,7 @@ async def hub_pubkey(): # reads MBK1/MBK2. An older client would still write them, and every such # bundle is one no browser of the account can open again. MIN_CLIENT_VERSION = "0.17.0" -RECOMMENDED_CLIENT_VERSION = "0.17.0" +RECOMMENDED_CLIENT_VERSION = "0.18.0" @router.get("/version") diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 72dd123..6f97ca5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -592,7 +592,10 @@ function HomePage({ groups, notifications, onMarkRead, onPurge, allowPublicGroup const [setupDismissed, setSetupDismissed] = useState(false); if (groups.length === 0) { - if (platform.isNative && !setupDismissed) { + // Setting up a node needs one to administer: the desktop application, not + // any native shell. A phone has a bridge and no node, and what it needs + // with no groups is the invitations and the join link below. + if (platform.capabilities.nodeAdmin && !setupDismissed) { return html`<${SetupWelcome} onDismiss=${() => setSetupDismissed(true)} />`; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js index aa45a25..2ff2bd4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js @@ -392,6 +392,10 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk throw err; } const chunkMsg = await inflight[nextRecv]; + // Released as soon as it is read: a resolved promise left here holds its + // ciphertext, and the array holds every chunk of the file — so a download + // written straight to disk was also held whole in the page until it ended. + inflight[nextRecv] = undefined; // One shape, and a refusal for anything else. There used to be two fallbacks // below this: a base64 `ct_b64` chunk, which was the real wire format until // the binary switch in Phase 9.15 and which no node has sent since, and a diff --git a/packages/meshbay-hub/src/meshbay_hub/static/icon.js b/packages/meshbay-hub/src/meshbay_hub/static/icon.js index c80258c..4fa4357 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/icon.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/icon.js @@ -73,6 +73,10 @@ const ICON_PATHS = { 'chevron-left': ['M14.5 6l-6 6 6 6'], 'chevron-right': ['M9.5 6l6 6-6 6'], close: ['M6 6l12 12M18 6L6 18'], + // A circle nearly closed, its arrow at the top pointing the way it turns; + // the remote writes the seconds inside. + skipback: ['M12 4.5a8 8 0 1 1-6.93 4', 'M14.5 2l-2.5 2.5 2.5 2.5'], + skipfwd: ['M12 4.5a8 8 0 1 0 6.93 4', 'M9.5 2l2.5 2.5-2.5 2.5'], chat: ['M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'], folder: ['M3.5 6.6a1 1 0 0 1 1-1h4.2l2 2.4h7.8a1 1 0 0 1 1 1v9.4a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1z'], 'bell-off': ['M18 9a6 6 0 0 0-12 0c0 6-2.5 7.5-2.5 7.5h17S18 15 18 9', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index c78bb52..bdc4dd5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -390,6 +390,13 @@ export default { 'cast.copied': 'URL kopiert', 'cast.scanning': 'Suche nach Geräten…', 'cast.no_devices': 'Keine Geräte gefunden', + 'cast.casting_to': 'Wiedergabe auf', + 'cast.seek': 'Position im Film', + 'cast.waiting': 'Warten auf den Fernseher…', + 'cast.back30': '30 Sekunden zurück', + 'cast.fwd30': '30 Sekunden vor', + 'cast.play': 'Abspielen', + 'cast.pause': 'Pause', // Settings 'settings.title': 'Einstellungen', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index f5879b3..658f388 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -388,6 +388,13 @@ export default { 'cast.copied': 'URL copied', 'cast.scanning': 'Scanning for devices…', 'cast.no_devices': 'No devices found', + 'cast.casting_to': 'Casting to', + 'cast.seek': 'Position in the film', + 'cast.waiting': 'Waiting for the television…', + 'cast.back30': 'Back 30 seconds', + 'cast.fwd30': 'Forward 30 seconds', + 'cast.play': 'Play', + 'cast.pause': 'Pause', // Settings 'settings.title': 'Settings', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index a35aa96..9e73017 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -388,6 +388,13 @@ export default { 'cast.copied': 'URL copiada', 'cast.scanning': 'Buscando dispositivos…', 'cast.no_devices': 'No se encontraron dispositivos', + 'cast.casting_to': 'Transmitiendo a', + 'cast.seek': 'Posición en la película', + 'cast.waiting': 'Esperando al televisor…', + 'cast.back30': 'Retroceder 30 segundos', + 'cast.fwd30': 'Avanzar 30 segundos', + 'cast.play': 'Reproducir', + 'cast.pause': 'Pausa', // Settings 'settings.title': 'Ajustes', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 2e823cd..5d18d41 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -389,6 +389,13 @@ export default { 'cast.copied': 'URL copiée', 'cast.scanning': "Recherche d'appareils…", 'cast.no_devices': 'Aucun appareil trouvé', + 'cast.casting_to': 'Diffusion sur', + 'cast.seek': 'Position dans le film', + 'cast.waiting': 'En attente de la télévision…', + 'cast.back30': 'Reculer de 30 secondes', + 'cast.fwd30': 'Avancer de 30 secondes', + 'cast.play': 'Lecture', + 'cast.pause': 'Pause', // Settings 'settings.title': 'Paramètres', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 84879e8..c4d2acc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -389,6 +389,13 @@ export default { 'cast.copied': 'URL copiato', 'cast.scanning': 'Ricerca dispositivi…', 'cast.no_devices': 'Nessun dispositivo trovato', + 'cast.casting_to': 'Trasmissione su', + 'cast.seek': 'Posizione nel film', + 'cast.waiting': 'In attesa del televisore…', + 'cast.back30': 'Indietro di 30 secondi', + 'cast.fwd30': 'Avanti di 30 secondi', + 'cast.play': 'Riproduci', + 'cast.pause': 'Pausa', // Settings 'settings.title': 'Impostazioni', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 9167efe..fe52bfa 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -386,6 +386,13 @@ export default { 'cast.copied': 'URLをコピーしました', 'cast.scanning': 'デバイスを検索中…', 'cast.no_devices': 'デバイスが見つかりません', + 'cast.casting_to': 'キャスト先', + 'cast.seek': '再生位置', + 'cast.waiting': 'テレビを待っています…', + 'cast.back30': '30秒戻る', + 'cast.fwd30': '30秒進む', + 'cast.play': '再生', + 'cast.pause': '一時停止', // Settings 'settings.title': '設定', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 4845c03..90b71f0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -390,6 +390,13 @@ export default { 'cast.copied': 'URL gekopieerd', 'cast.scanning': 'Apparaten zoeken…', 'cast.no_devices': 'Geen apparaten gevonden', + 'cast.casting_to': 'Casten naar', + 'cast.seek': 'Positie in de film', + 'cast.waiting': 'Wachten op de televisie…', + 'cast.back30': '30 seconden terug', + 'cast.fwd30': '30 seconden vooruit', + 'cast.play': 'Afspelen', + 'cast.pause': 'Pauzeren', // Settings 'settings.title': 'Instellingen', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index e3f21d3..8c31cf3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -401,6 +401,13 @@ export default { 'cast.copied': 'URL skopiowany', 'cast.scanning': 'Wyszukiwanie urządzeń…', 'cast.no_devices': 'Nie znaleziono urządzeń', + 'cast.casting_to': 'Przesyłanie do', + 'cast.seek': 'Pozycja w filmie', + 'cast.waiting': 'Czekam na telewizor…', + 'cast.back30': '30 sekund wstecz', + 'cast.fwd30': '30 sekund do przodu', + 'cast.play': 'Odtwórz', + 'cast.pause': 'Wstrzymaj', // Settings 'settings.title': 'Ustawienia', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 00510c6..2f951ee 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -390,6 +390,13 @@ export default { 'cast.copied': 'URL copiada', 'cast.scanning': 'Procurando dispositivos…', 'cast.no_devices': 'Nenhum dispositivo encontrado', + 'cast.casting_to': 'Transmitindo para', + 'cast.seek': 'Posição no filme', + 'cast.waiting': 'Aguardando a TV…', + 'cast.back30': 'Voltar 30 segundos', + 'cast.fwd30': 'Avançar 30 segundos', + 'cast.play': 'Reproduzir', + 'cast.pause': 'Pausar', // Settings 'settings.title': 'Configurações', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 51a6572..ea02545 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -383,6 +383,13 @@ export default { 'cast.copied': '链接已复制', 'cast.scanning': '正在搜索设备…', 'cast.no_devices': '未找到设备', + 'cast.casting_to': '投放到', + 'cast.seek': '播放位置', + 'cast.waiting': '正在等待电视…', + 'cast.back30': '后退 30 秒', + 'cast.fwd30': '前进 30 秒', + 'cast.play': '播放', + 'cast.pause': '暂停', // Settings 'settings.title': '设置', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index 749f93e..c131495 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -497,6 +497,19 @@ export const cast = { if (!bridge || !bridge.cast) return false; return bridge.cast.chromecastDisconnect(); }, + // Remote control of the receiver. Absent from an older bridge, so callers + // check `canControl` rather than catching a TypeError. + get canControl() { + return Boolean(bridge && bridge.cast && bridge.cast.chromecastPause && bridge.cast.chromecastPlay); + }, + async chromecastPause() { + if (!this.canControl) return null; + return bridge.cast.chromecastPause(); + }, + async chromecastPlay() { + if (!this.canControl) return null; + return bridge.cast.chromecastPlay(); + }, }; /** diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 23013a4..e1e152e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2084,6 +2084,207 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } outline: none; } +/* The player as a remote while a television plays the film. Opaque, over + the local picture, which keeps running underneath only to pace the + download; the container is given room for it on a portrait phone, where + the picture alone would be a strip. */ +.video-container-remote { min-height: min(72vh, 560px); } + +.video-remote { + --remote-accent: #23b1f0; + position: absolute; + inset: 0; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 32px; + padding: 24px 20px; + border-radius: 12px; + background: + radial-gradient(120% 70% at 50% 0%, rgba(35, 177, 240, 0.16) 0%, rgba(35, 177, 240, 0) 60%), + #0a1224; + color: #e2e8f0; + overflow: hidden; +} + +.video-remote-head { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + text-align: center; + max-width: 100%; +} + +.video-remote-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + margin-bottom: 8px; + border-radius: 50%; + background: rgba(35, 177, 240, 0.14); + box-shadow: 0 0 0 1px rgba(35, 177, 240, 0.35), 0 0 32px rgba(35, 177, 240, 0.25); + color: var(--remote-accent); + font-size: 30px; +} + +.video-remote-label { + font-size: 0.75rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #94a3b8; +} + +.video-remote-device { + font-size: 1.35rem; + font-weight: 600; + color: #f8fafc; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.video-remote-state { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 1.2em; + font-size: 0.85rem; + color: #94a3b8; +} + +.video-remote-track { width: min(100%, 560px); } + +.video-remote-times { + display: flex; + justify-content: space-between; + margin-top: 8px; + font-size: 0.85rem; + font-variant-numeric: tabular-nums; + color: #94a3b8; +} + +/* The track is drawn by hand so the part already played is filled: `--pct` + is set inline from the position. */ +.video-remote-seek { + --pct: 0%; + -webkit-appearance: none; + appearance: none; + display: block; + width: 100%; + height: 28px; + margin: 0; + background: transparent; + cursor: pointer; +} +.video-remote-seek:disabled { cursor: default; opacity: 0.5; } +.video-remote-seek::-webkit-slider-runnable-track { + height: 6px; + border-radius: 3px; + background: linear-gradient(to right, var(--remote-accent) var(--pct), rgba(148, 163, 184, 0.25) var(--pct)); +} +.video-remote-seek::-moz-range-track { + height: 6px; + border-radius: 3px; + background: linear-gradient(to right, var(--remote-accent) var(--pct), rgba(148, 163, 184, 0.25) var(--pct)); +} +.video-remote-seek::-webkit-slider-thumb { + -webkit-appearance: none; + width: 18px; + height: 18px; + margin-top: -6px; + border: 0; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 0 0 4px rgba(35, 177, 240, 0.35), 0 2px 6px rgba(0, 0, 0, 0.4); +} +.video-remote-seek::-moz-range-thumb { + width: 18px; + height: 18px; + border: 0; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 0 0 4px rgba(35, 177, 240, 0.35), 0 2px 6px rgba(0, 0, 0, 0.4); +} + +.video-remote-buttons { + display: flex; + align-items: center; + gap: 36px; +} + +.video-remote-skip, +.video-remote-main { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 50%; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform 0.1s ease, background-color 0.15s ease; +} +.video-remote-skip:active:not(:disabled), +.video-remote-main:active:not(:disabled) { transform: scale(0.92); } +.video-remote-skip:disabled, +.video-remote-main:disabled { opacity: 0.35; cursor: default; } + +.video-remote-skip { + width: 56px; + height: 56px; + background: transparent; + color: #e2e8f0; + font-size: 44px; +} +.video-remote-skip:hover:not(:disabled) { background: rgba(148, 163, 184, 0.12); } +.video-remote-skip .icon { stroke-width: 1.4; } +.video-remote-skip-n { + position: absolute; + left: 0; + right: 0; + top: 50%; + transform: translateY(-38%); + font-size: 12px; + font-weight: 700; + text-align: center; + pointer-events: none; +} + +.video-remote-main { + width: 76px; + height: 76px; + background: var(--remote-accent); + color: #04121f; + font-size: 34px; + box-shadow: 0 8px 24px rgba(35, 177, 240, 0.35); +} +.video-remote-main:hover:not(:disabled) { background: #4cc3f5; } +.video-remote-main .icon { stroke-width: 2.6; } +/* The triangle is a closed path: filled, it reads as play at a glance. */ +.video-remote-main.is-paused .icon path { fill: currentColor; } +.video-remote-main.is-paused .icon { transform: translateX(2px); } + +.video-remote-stop { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 999px; + background: transparent; + color: #cbd5e1; + font: inherit; + font-size: 0.9rem; + cursor: pointer; +} +.video-remote-stop:hover { border-color: #fca5a5; color: #fecaca; } + /* Where the film was picked up again. Sits over the picture rather than pushing it down, and clears itself once the viewer is watching. */ .video-container { position: relative; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js index 2a4d2ea..2c424fb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -280,6 +280,64 @@ function formatClock(seconds) { } /** + * The player as a remote, while a television plays the film. + * + * Only what it is told: the receiver's position on the film's timeline (null + * until the receiver has answered), its state, and whether a seek is on its + * way. The scrubber reports a drag as it moves and a seek once let go. + */ +function CastRemote({ device, position, duration, playerState, moving, + onDrag, onSeek, onToggle, onStop }) { + const known = position !== null; + const paused = playerState === 'PAUSED'; + const busy = !known || moving || playerState === 'BUFFERING' || playerState === 'LOADING'; + const pct = known && duration > 0 ? Math.min(100, (position / duration) * 100) : 0; + return html` + <div class="video-remote"> + <div class="video-remote-head"> + <span class="video-remote-badge"><${Icon} name="cast" /></span> + <span class="video-remote-label">${t('cast.casting_to')}</span> + <span class="video-remote-device">${device}</span> + <span class="video-remote-state"> + ${busy ? html`<span class="spinner"></span>${' '}${t('cast.waiting')}` : ''} + </span> + </div> + <div class="video-remote-track"> + <input class="video-remote-seek" type="range" min="0" step="1" + style=${`--pct:${pct}%`} + max=${Math.max(1, Math.floor(duration))} + value=${Math.floor(position || 0)} + disabled=${!known || !(duration > 0)} + aria-label=${t('cast.seek')} + onInput=${(e) => onDrag(+e.target.value)} + onChange=${(e) => onSeek(+e.target.value)} /> + <div class="video-remote-times"> + <span>${known ? formatClock(position) : '–:––'}</span> + <span>${duration > 0 ? formatClock(duration) : '–:––'}</span> + </div> + </div> + <div class="video-remote-buttons"> + <button class="video-remote-skip" disabled=${!known} + onClick=${() => onSeek(position - 30)} + title=${t('cast.back30')} aria-label=${t('cast.back30')}> + <${Icon} name="skipback" /><span class="video-remote-skip-n">30</span></button> + <button class="video-remote-main ${paused ? 'is-paused' : ''}" disabled=${!playerState} + onClick=${onToggle} + title=${paused ? t('cast.play') : t('cast.pause')} + aria-label=${paused ? t('cast.play') : t('cast.pause')}> + <${Icon} name=${paused ? 'play' : 'pause'} /></button> + <button class="video-remote-skip" disabled=${!known} + onClick=${() => onSeek(position + 30)} + title=${t('cast.fwd30')} aria-label=${t('cast.fwd30')}> + <${Icon} name="skipfwd" /><span class="video-remote-skip-n">30</span></button> + </div> + <button class="video-remote-stop" onClick=${onStop}> + <${Icon} name="close" /> ${t('cast.stop')}</button> + </div> + `; +} + +/** * Where *this account on this device* last left off in a given file. * * localStorage rather than the node: it needs no protocol, no storage anyone @@ -438,6 +496,19 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { const castRestartPendingRef = useRef(false); const castDeviceRef = useRef(null); const castRestartGenRef = useRef(0); + // The receiver as last polled: { playerState, position } with the position + // on the film's timeline, or null before it has answered. + const [remote, setRemote] = useState(null); + // Where the viewer sent the television and it has not arrived yet. Shown in + // place of the receiver's position, which until the reload still belongs to + // the stream being abandoned and would make the scrubber jump back. + const [remoteTarget, setRemoteTarget] = useState(null); + const [remoteDrag, setRemoteDrag] = useState(null); + const remoteFilmPosRef = useRef(null); + // Segments of the *new* stream that arrive while its seek is still landing + // (`reinitAt`/`resumeAt` wait on the SourceBuffer). Null when not holding. + const heldRef = useRef(null); + const holdGenRef = useRef(0); const landingPlayheadRef = useRef(false); // The current Screen Wake Lock sentinel, if the browser granted one — see // the effect below. Null on any platform/context that does not support it, @@ -764,6 +835,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { ranges: describeRanges(), }); awaitingInitRef.current = true; + heldRef.current = null; holdGenRef.current++; // A seek supersedes a resume that had been asked for and not landed: // this one empties the buffer, and taking the resume branch on its // `stream_init` would leave the film we navigated away from in place. @@ -798,6 +870,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { }); resumingRef.current = true; awaitingInitRef.current = true; + heldRef.current = null; holdGenRef.current++; seekTargetRef.current = null; outstandingRef.current = STREAM_WINDOW; console.log('[resume] request', +target.toFixed(1)); @@ -1064,7 +1137,23 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { castRestartPendingRef.current = true; } const land = resuming ? resumeAt : reinitAt; - land(msg.start || 0).catch(() => { + // What follows this message on the ordered channel is the new + // stream, its header first. The landing waits on the SourceBuffer, + // and those segments used to arrive in that gap and be dropped as + // the old film's: the player kept its header from the start of the + // film and never noticed, but a cast relay restarted here got a + // stream beginning at a moof — no ftyp, no moov — and the receiver + // gave up on it within a second (measured on a phone). So they are + // held, and taken in order once the landing is done. + const hold = ++holdGenRef.current; + heldRef.current = []; + land(msg.start || 0).then(async () => { + while (holdGenRef.current === hold && heldRef.current && heldRef.current.length) { + await consumeSegment(heldRef.current.shift()); + } + if (holdGenRef.current === hold) heldRef.current = null; + }).catch(() => { + if (holdGenRef.current === hold) heldRef.current = null; setError(t('video.err_transport')); setPhase('error'); }); @@ -1178,6 +1267,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { // for credit that cannot come. A race, which is why the same seek // worked twice and hung on the third. outstandingRef.current = Math.max(0, outstandingRef.current - 1); + // The new stream, arriving while its seek lands: kept, not dropped. + // Counted above already, so the replay must not count it again. + if (heldRef.current) { + if (!msg.file_id || msg.file_id === entry.id) heldRef.current.push(msg); + return; + } if (awaitingInitRef.current) { console.log('[seek] dropping segment (awaitingInit), outstanding:', outstandingRef.current); } @@ -1190,6 +1285,13 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { // would otherwise be decrypted against the wrong file — which fails, // loudly, in the console, for something that is simply not ours. if (msg.file_id && msg.file_id !== entry.id) return; + await consumeSegment(msg); + }; + + // Everything a segment goes through once it is known to belong to the + // stream being played: by arrival, or replayed after a landing. + const consumeSegment = async (msg) => { + if (cancelled) return; try { const plaintext = await window.MeshBayCrypto.decryptChunkBin( gekRef.current, entry.id, msg.segment_index, msg.nonce, msg.ct); @@ -1220,6 +1322,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { } else { navigator.clipboard.writeText(result.url).catch(() => {}); } + if (castRestartGenRef.current === gen) setRemoteTarget(null); }).catch((err) => { if (castRestartGenRef.current !== gen) return; console.error('[cast] restart failed:', err); @@ -1436,6 +1539,24 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { // The scan lives as long as the picker is open: closing it, or picking a // receiver, stops the updates. Receivers are listed as they answer, and the // spinner sits below them so one landing never moves the row being aimed at. + // While casting, the film plays on the receiver; the local element keeps + // playing — its playhead is what paces the stream the relay forwards — but + // silently. It went on with its sound, so a phone in the room doubled the + // television's audio, screen off included. What the viewer had set comes + // back when the cast ends. + const mutedBeforeCastRef = useRef(null); + useEffect(() => { + const v = videoRef.current; + if (!v) return; + if (castActive) { + if (mutedBeforeCastRef.current === null) mutedBeforeCastRef.current = v.muted; + v.muted = true; + } else if (mutedBeforeCastRef.current !== null) { + v.muted = mutedBeforeCastRef.current; + mutedBeforeCastRef.current = null; + } + }, [castActive]); + useEffect(() => { if (!castPickerOpen) return undefined; setCastDevices([]); @@ -1602,6 +1723,75 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { }; }, []); + // ── Remote ──────────────────────────────────────────────────────────── + // + // While a television plays the film, this player is its remote. The phone + // and the receiver do not play in step — they start apart and drift — so a + // scrubber on the local playhead lied about where the film was, and a seek + // from it landed off by that much. Everything here reads the receiver and + // tells it what to do; seeking is the ordinary seek, which restarts the + // relay and reloads the receiver at the new position. + const remoteMode = castActive && castDeviceName !== null && platform.cast.canControl; + useEffect(() => { + if (!remoteMode) { + setRemote(null); setRemoteTarget(null); setRemoteDrag(null); + remoteFilmPosRef.current = null; + return undefined; + } + let stopped = false; + const poll = async () => { + try { + const s = await platform.cast.status(); + const c = s && s.chromecast; + if (stopped) return; + if (c && c.connected && c.position != null && !castRestartPendingRef.current) { + const position = streamStartRef.current + c.position; + remoteFilmPosRef.current = position; + setRemote({ playerState: c.playerState, position }); + } else { + setRemote((r) => (r && c && c.connected ? { ...r, playerState: c.playerState } : r)); + } + } catch { /* asked again on the next tick */ } + }; + poll(); + const id = setInterval(poll, 1000); + return () => { stopped = true; clearInterval(id); }; + }, [remoteMode]); + + const stopCast = async () => { + await platform.cast.chromecastDisconnect().catch(() => {}); + await platform.cast.stop(); + setCastActive(false); castActiveRef.current = false; + setCastUrl(null); + setCastDeviceName(null); + castDeviceRef.current = null; + }; + + const remoteShown = remoteDrag ?? remoteTarget ?? (remote ? remote.position : null); + const remoteSeek = (to) => { + const duration = durationRef.current; + const target = Math.max(0, duration > 0 ? Math.min(to, duration - 1) : to); + setRemoteTarget(target); + if (requestSeekRef.current) requestSeekRef.current(target); + }; + const remoteToggle = async () => { + const v = videoRef.current; + try { + if (remote && remote.playerState === 'PAUSED') { + await platform.cast.chromecastPlay(); + if (v) v.play().catch(() => {}); + } else { + await platform.cast.chromecastPause(); + // The local copy only paces the download: left running, it would go + // on fetching for a television that is not watching. + if (v) v.pause(); + } + setRemote((r) => (r ? { ...r, playerState: r.playerState === 'PAUSED' ? 'PLAYING' : 'PAUSED' } : r)); + } catch (err) { + console.warn('[cast] remote command failed:', err); + } + }; + return html` <div class="video-overlay video-player-overlay" onClick=${(e) => { if (e.target.classList.contains('video-overlay')) onClose(); @@ -1630,7 +1820,10 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { setAudioTrack(track.i); const v = videoRef.current; const seek = requestSeekRef.current; - if (v && seek) seek(v.currentTime); + // Where the television is, when one plays the film: the + // phone's playhead has drifted from it. + const at = remoteFilmPosRef.current ?? (v && v.currentTime); + if (at != null && seek) seek(at); }}> ${track.i === audioTrack ? html`<${Icon} name="check" />` @@ -1687,12 +1880,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { <button class="video-close ${castActive ? 'cast-active' : ''}" onClick=${async () => { if (castActive) { - await platform.cast.chromecastDisconnect().catch(() => {}); - await platform.cast.stop(); - setCastActive(false); castActiveRef.current = false; - setCastUrl(null); - setCastDeviceName(null); - castDeviceRef.current = null; + await stopCast(); } else if (castCodecRef.current && initSegmentRef.current) { if (castPickerOpen) { setCastPickerOpen(false); @@ -1777,7 +1965,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { </div> ${(phase === 'streaming' || phase === 'loading') && html` - <div class="video-container"> + <div class="video-container ${remoteMode ? 'video-container-remote' : ''}"> <video ref=${videoRef} controls autoplay> ${subtitleUrl && html` <track key=${subtitleUrl} kind="subtitles" src=${subtitleUrl} @@ -1788,6 +1976,18 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { default /> `} </video> + ${remoteMode && html` + <${CastRemote} + device=${castDeviceName} + position=${remoteShown} + duration=${durationRef.current} + playerState=${remote ? remote.playerState : null} + moving=${remoteTarget !== null} + onDrag=${setRemoteDrag} + onSeek=${(to) => { setRemoteDrag(null); remoteSeek(to); }} + onToggle=${remoteToggle} + onStop=${stopCast} /> + `} ${phase === 'loading' && html` <div class="video-loading"> <div class="video-loading-label"> @@ -1826,4 +2026,4 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { * live. */ -export { VideoPlayer }; +export { VideoPlayer, CastRemote }; diff --git a/packages/meshbay-hub/tests/harness/window_leak.mjs b/packages/meshbay-hub/tests/harness/window_leak.mjs index d5185de..f05d8c0 100644 --- a/packages/meshbay-hub/tests/harness/window_leak.mjs +++ b/packages/meshbay-hub/tests/harness/window_leak.mjs @@ -41,20 +41,23 @@ let cancelled = false; const pump = () => {}; const flushQueue = () => {}; const gekRef = { current: null }; +// Not holding: these are the abandoned stream's segments, before any stream_init. +const heldRef = { current: null }; +const consumeSegment = async () => {}; const window_ = { MeshBayCrypto: { decryptChunkBin: async () => new Uint8Array(4) } }; const silentConsole = { log() {}, warn() {}, error() {} }; const handler = new Function( 'msg', 'cancelled', 'awaitingInitRef', 'outstandingRef', 'queueRef', - 'entry', 'gekRef', 'pump', 'flushQueue', 'console', 'window', + 'entry', 'gekRef', 'pump', 'flushQueue', 'console', 'window', 'heldRef', 'consumeSegment', `return (async () => {${source}})();`); const deliver = (n) => Promise.all( Array.from({ length: n }, (_, i) => handler( { file_id: entry.id, segment_index: i, nonce: 'n', ct: 'c' }, cancelled, awaitingInitRef, outstandingRef, queueRef, entry, gekRef, - pump, flushQueue, silentConsole, window_))); + pump, flushQueue, silentConsole, window_, heldRef, consumeSegment))); const run = async () => { // The whole window arrives while reinitAt is still awaiting its updateends. diff --git a/packages/meshbay-hub/tests/test_android_cast.py b/packages/meshbay-hub/tests/test_android_cast.py new file mode 100644 index 0000000..a346d15 --- /dev/null +++ b/packages/meshbay-hub/tests/test_android_cast.py @@ -0,0 +1,133 @@ +""" +Casting in the Android application, pinned by reading the source. + +The relay is a port of meshbay-client/src/cast-relay.js and its mitigations are +the same ones; the JVM tests (CastRelayTest) run it on loopback. What is held +here is the wiring around it: the same bridge surface as the desktop, order +kept where order is meaning, the receiver pointed at nothing but the relay, and +what keeps a cast alive with the screen off switched on only while one runs. +""" + +import re +from pathlib import Path + +import pytest + +PACKAGES = Path(__file__).resolve().parents[2] +MAIN = PACKAGES / "meshbay-android" / "app" / "src" / "main" +SRC = MAIN / "kotlin" / "org" / "meshbay" / "client" +CAST = SRC / "cast" +SHIM = MAIN / "assets" / "bridge" / "meshbay-bridge.js" +DESKTOP = PACKAGES / "meshbay-client" / "src" + +pytestmark = pytest.mark.skipif(not CAST.exists(), reason="android sources not present") + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_the_cast_channels_are_the_desktops(): + def channels(text: str, call: str) -> set[str]: + return set(re.findall(rf"{call}\('(cast:[\w:-]+)'", text)) + + preload = channels(_read(DESKTOP / "preload.js"), r"ipcRenderer\.invoke") + shim = channels(_read(SHIM), "call") + kt = _read(CAST / "CastChannels.kt") + native = set(re.findall(r'^\s*"(cast:[\w:-]+)" ->', kt, flags=re.M)) + assert preload and shim == preload, shim ^ preload + assert native == preload, native ^ preload + + +def test_no_cast_object_where_casting_cannot_work(): + """`platform.cast.available` is whether the object exists.""" + shim = _read(SHIM) + assert "...(CAST ? { cast: {" in shim + assert "lanCast: CAST" in shim + assert "const CAST = ${cast.control.available()}" in _read(SRC / "MainActivity.kt") + + +def test_the_relay_keeps_the_desktop_mitigations(): + relay = _read(CAST / "CastRelay.kt") + desktop = _read(DESKTOP / "cast-relay.js") + for name in ("RING_CAP", "PORT_BASE", "PORT_COUNT"): + js = re.search(rf"const {name} = (\d+);", desktop).group(1) + assert re.search(rf"const val {name} = {js}\b", relay), name + assert "InetSocketAddress(address, PORT_BASE + i)" in relay, "bound to the LAN address" + # Code, not comments: the comment saying "never 0.0.0.0" is not a bind. + code = re.sub(r"/\*.*?\*/", "", relay, flags=re.S) + code = re.sub(r"(?m)//.*$", "", code) + assert "0.0.0.0" not in code + assert 'query["t"] != token' in relay + # The preflight before the token: a refusal there reads as a network error. + serve = relay.split("private fun serve(", 1)[1] + assert serve.index('method == "OPTIONS"') < serve.index('query["t"] != token') + + +def test_what_a_receiver_has_not_read_waits_on_disk(): + """The desktop drops a fragment once 8 MB wait for a receiver; a fragment + is 5 to 10 MB at a film's bitrate, so the television froze for its length + (measured: three dropped in the first fifteen seconds). Here the lead waits + in a spool file per receiver, deleted with it, and is dropped only past a + disk bound.""" + relay = _read(CAST / "CastRelay.kt") + assert "BACKPRESSURE_HIGH" not in relay + assert "RandomAccessFile(file, \"rw\").channel" in relay + assert "c.waiting() > spoolLimit()" in relay + assert "SPOOL_MAX_BYTES = 2L * 1024 * 1024 * 1024" in relay + assert "file.delete()" in relay + assert 'java.io.File(context.cacheDir, "cast-relay")' in _read(CAST / "CastChannels.kt") + + +def test_the_backlog_is_bounded_in_bytes(): + """A fragment is a segment, megabytes at a film's bitrate: 64 of them killed + the application with an OutOfMemoryError on the emulator.""" + relay = _read(CAST / "CastRelay.kt") + assert "RING_MAX_BYTES" in relay and "ringBytes > RING_MAX_BYTES" in relay + + +def test_the_relay_is_on_wifi_never_the_mobile_network(): + channels = _read(CAST / "CastChannels.kt") + lan = channels.split("private fun lanAddress()", 1)[1] + assert "TRANSPORT_WIFI" in lan and "TRANSPORT_ETHERNET" in lan + assert "TRANSPORT_CELLULAR" not in lan + + +def test_the_receiver_is_pointed_at_the_relay_and_nothing_else(): + channels = _read(CAST / "CastChannels.kt") + check = channels.split("private fun relayUrl(", 1)[1].split("\n }", 1)[0] + assert "asked != ours" in check and "throw Refused" in check + assert channels.count("relayUrl(o)") == 2, "connect and reload both go through the check" + + +def test_relay_calls_keep_their_order(): + """The page does not await each push; on a pool they could overtake.""" + channels = _read(CAST / "CastChannels.kt") + assert '"cast:start", "cast:push", "cast:subtitle", "cast:finish", "cast:stop"' in channels + bridge = _read(SRC / "bridge" / "Bridge.kt") + assert "Executors.newSingleThreadExecutor()" in bridge + assert "(if (ordered) serial else work).execute" in bridge + assert "fun ordered(frame: BinaryFrame) = frame.channel == BinaryFrame.CAST_PUSH" in _read( + SRC / "bridge" / "Channels.kt") + + +def test_screen_off_survival_is_switched_on_only_while_casting(): + activity = _read(SRC / "MainActivity.kt") + casting = activity.split("private fun casting(on: Boolean)", 1)[1].split("\n }", 1)[0] + assert "web.keepVisible = on" in casting + assert "startForegroundService(service) else stopService(service)" in casting + assert activity.count("keepVisible =") == 1, "the page is kept visible from one place only" + view = _read(SRC / "shell" / "ShellWebView.kt") + assert "var keepVisible = false" in view + manifest = _read(MAIN / "AndroidManifest.xml") + assert 'android:name=".cast.CastService"' in manifest + assert 'android:foregroundServiceType="mediaPlayback"' in manifest + + +def test_the_receiver_is_given_what_the_desktop_gives_it(): + control = _read(CAST / "CastControl.kt") + assert "DEFAULT_MEDIA_RECEIVER_APPLICATION_ID" in control + assert "MediaInfo.STREAM_TYPE_LIVE" in control + assert "TEXT_TRACK_ID = 1L" in control + assert "SCAN_DURATION_MS = 6000L" in control + assert re.search(r"const SCAN_DURATION_MS = 6000;", _read(DESKTOP / "cast-chromecast.js")) diff --git a/packages/meshbay-hub/tests/test_android_downloads.py b/packages/meshbay-hub/tests/test_android_downloads.py new file mode 100644 index 0000000..96da9a4 --- /dev/null +++ b/packages/meshbay-hub/tests/test_android_downloads.py @@ -0,0 +1,90 @@ +""" +Downloads in the Android application, pinned by reading the source. + +The page's contract with a native save target is platform.js `nativeSave`: a +sink with write/close/abort, an `open` only when the target can open the file, +and nothing that names a path. The Android sink keeps it; what it may open is +downloads.js `OPENABLE`, held equal here because a second copy of a list of +safe types is how an `.html` gets opened one day. +""" + +import re +from pathlib import Path + +import pytest +from spa_source import STATIC + +PACKAGES = Path(__file__).resolve().parents[2] +MAIN = PACKAGES / "meshbay-android" / "app" / "src" / "main" +SAVE = MAIN / "kotlin" / "org" / "meshbay" / "client" / "save" +SHIM = MAIN / "assets" / "bridge" / "meshbay-bridge.js" + +pytestmark = pytest.mark.skipif(not SAVE.exists(), reason="android sources not present") + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_what_may_be_opened_is_the_pages_list(): + js = _read(STATIC / "downloads.js").split("const OPENABLE = {", 1)[1].split("};", 1)[0] + kt = _read(SAVE / "SaveNames.kt").split("val OPENABLE = mapOf(", 1)[1].split("\n )", 1)[0] + page = {k: v.split(";")[0] for k, v in re.findall(r"(\w+): '([^']+)'", js)} + android = dict(re.findall(r'"(\w+)" to "([^"]+)"', kt)) + assert page and android == page, set(android.items()) ^ set(page.items()) + + +def test_open_is_offered_only_where_the_target_says_so(): + shim = _read(SHIM) + save = shim.split("saveFile: async", 1)[1].split("\n },", 1)[0] + assert "if (handle.openable) sink.open" in save + sinks = _read(SAVE / "SaveSinks.kt") + assert '.put("openable", SaveNames.openableType(shown) != null)' in sinks + opening = sinks.split("fun open(id: Long)", 1)[1].split("\n }", 1)[0] + assert "SaveNames.openableType(name) ?: throw Refused" in opening + + +def test_the_page_is_told_names_never_uris(): + sinks = _read(SAVE / "SaveSinks.kt") + for fn in ("fun chooseFolder", "fun getFolder", "fun begin"): + body = sinks.split(fn, 1)[1].split("\n fun ", 1)[0] + returned = re.findall(r'put\("(\w+)"', body) + assert not {"uri", "path", "tree"} & set(returned), (fn, returned) + assert "return displayName(treeDocument(tree))" in sinks + + +def test_the_save_channels_are_the_desktops(): + preload = _read(PACKAGES / "meshbay-client" / "src" / "preload.js") + shim = _read(SHIM) + + def channels(text: str, call: str) -> set[str]: + return set(re.findall(rf"{call}\('((?:save|folder):[\w:-]+)'", text)) + + assert channels(shim, "call") == channels(preload, r"ipcRenderer\.invoke") + assert "nativeSave: true" in shim + + +def test_an_unfinished_file_never_carries_the_final_name(): + sinks = _read(SAVE / "SaveSinks.kt") + assert '"$wanted.part"' in sinks + assert "MediaStore.MediaColumns.IS_PENDING, 1" in sinks + assert "MediaStore.MediaColumns.IS_PENDING, 0" in sinks + abort = sinks.split("fun abort(id: Long)", 1)[1].split("\n }", 1)[0] + assert "delete(sink.uri)" in abort + assert "fun cleanUpAfterAKilledProcess" in sinks + assert "saves.cleanUpAfterAKilledProcess()" in _read( + MAIN / "kotlin" / "org" / "meshbay" / "client" / "MainActivity.kt") + + +def test_a_chosen_folder_that_has_gone_is_not_silently_replaced(): + begin = _read(SAVE / "SaveSinks.kt").split("fun begin(", 1)[1].split("\n fun ", 1)[0] + assert "auto && !(configuredTree != null && tree == null)" in begin + + +def test_writes_are_binary_and_awaited(): + shim = _read(SHIM) + assert "head.setUint32(0, 0x4d424231)" in shim + assert "port.postMessage(frame)" in shim + bridge = _read(MAIN / "kotlin" / "org" / "meshbay" / "client" / "bridge" / "Bridge.kt") + before = bridge.split("TYPE_ARRAY_BUFFER", 1)[0] + assert "!isMainFrame" in before, "a binary message must pass the same sender check" diff --git a/packages/meshbay-hub/tests/test_android_keys.py b/packages/meshbay-hub/tests/test_android_keys.py new file mode 100644 index 0000000..a00b909 --- /dev/null +++ b/packages/meshbay-hub/tests/test_android_keys.py @@ -0,0 +1,74 @@ +""" +The Android keyring against the desktop's, where the shared vectors cannot see. + +`tests/vectors/keyring.json` holds the bytes (and the Android unit tests read +it). What a vector cannot hold is a *list*: which admin operations may be +signed at all, and the Argon2 parameters a format change would move. Two +implementations of a list drift silently — an operation the desktop stopped +signing at MNP 6.0 and Android still signs is a script in the page driving an +older node into widening its sharing. So both are read from source and +compared. +""" + +import re +from pathlib import Path + +import pytest + +PACKAGES = Path(__file__).resolve().parents[2] +MAIN = PACKAGES / "meshbay-android" / "app" / "src" / "main" +KEYS = MAIN / "kotlin" / "org" / "meshbay" / "client" / "keys" +CLIENT = PACKAGES / "meshbay-client" / "src" +SHIM = MAIN / "assets" / "bridge" / "meshbay-bridge.js" + +pytestmark = pytest.mark.skipif(not KEYS.exists(), reason="android sources not present") + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_the_same_admin_operations_are_signed(): + js = _read(CLIENT / "transcripts.js") + js = js.split("const ADMIN_OPS = new Set([", 1)[1].split("]", 1)[0] + kt = _read(KEYS / "Transcripts.kt").split("val ADMIN_OPS = setOf(", 1)[1].split(")", 1)[0] + desktop = set(re.findall(r"'([a-z_]+)'", js)) + android = set(re.findall(r'"([a-z_]+)"', kt)) + assert desktop and android == desktop, android ^ desktop + # The ones MNP 6.0 took away must not come back on either side. + assert not {"root_add", "root_update", "group_attach"} & android + + +def test_the_argon2_parameters_are_the_desktops(): + js = _read(CLIENT / "keyring.js") + m = re.search(r"memory: (\d+), passes: (\d+), parallelism: (\d+), tagLength: (\d+)", js) + kt = _read(KEYS / "Kdf.kt") + want = dict(zip(("MEMORY_KIB", "PASSES", "PARALLELISM", "TAG"), m.groups())) + for name, value in want.items(): + assert re.search(rf"const val ARGON2_{name} = {value}\b", kt), name + + +def test_the_shim_offers_the_desktops_key_surface(): + preload = _read(CLIENT / "preload.js") + shim = _read(SHIM) + + def channels(text: str, call: str) -> set[str]: + return set(re.findall(rf"{call}\('((?:keys|device|secrets):[\w:-]+)'", text)) + + assert channels(shim, "call") == channels(preload, r"ipcRenderer\.invoke") + + +def test_signing_is_by_kind_and_no_private_key_is_returned(): + channels = _read(KEYS.parent / "bridge" / "KeyChannels.kt") + assert '"keys:sign" -> keyring.signAs(' in channels + # No channel hands the page a stored key: the store's slots are never a + # return value, and identity/mint/open answer with public keys. + assert "secrets.read()" in channels # the keyring's load, and nothing else + assert channels.count("secrets.read()") == 1 + assert '"pkEdB64"' in channels and '"skEd"' not in channels + + +def test_widening_browser_access_is_confirmed_natively(): + channels = _read(KEYS.parent / "bridge" / "KeyChannels.kt") + branch = channels.split('"keys:set-browser-access" ->', 1)[1].split('"keys:created-here"', 1)[0] + assert 'confirm("native.browser_access_confirm")' in branch diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py new file mode 100644 index 0000000..e569bb7 --- /dev/null +++ b/packages/meshbay-hub/tests/test_android_shell.py @@ -0,0 +1,223 @@ +""" +The Android shell's security contract, pinned by reading its source. + +The same treatment `test_desktop_shell.py` gives the Electron application, for +the same reason: an emulator or a phone is what proves the shell runs, and the +suite has neither. What this proves is that the properties the design depends +on (docs/MESHBAY_DESIGN.md §8.2, as the Android plan restates them) are in the +source, and it fails when one is removed. The JVM unit tests run too when an +Android SDK is present. +""" + +import os +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +PACKAGES = Path(__file__).resolve().parents[2] +ANDROID = PACKAGES / "meshbay-android" +APP = ANDROID / "app" +SRC = APP / "src" / "main" / "kotlin" / "org" / "meshbay" / "client" +SHIM = APP / "src" / "main" / "assets" / "bridge" / "meshbay-bridge.js" +CLIENT = PACKAGES / "meshbay-client" + +pytestmark = pytest.mark.skipif(not ANDROID.exists(), reason="android sources not present") + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _kotlin() -> str: + return "\n".join(_read(p) for p in sorted(SRC.rglob("*.kt"))) + + +def _strip_js_comments(source: str) -> str: + source = re.sub(r"/\*.*?\*/", "", source, flags=re.S) + return re.sub(r"(?m)//.*$", "", source) + + +# ── The page comes from the package ────────────────────────────────────────── + +def test_the_interface_is_copied_from_its_single_source_and_never_committed(): + build = _read(APP / "build.gradle.kts") + assert '"../meshbay-hub/src/meshbay_hub/static"' in build + # The desktop application's page: the hub's carries a /a/<hash>/ prefix + # that would point back at the hub. + assert '"../meshbay-client/scripts/index.html"' in build + assert "deleteRecursively()" in build, "a stale file could survive a rebuild" + assert "addGeneratedSourceDirectory" in build + assert "build/" in _read(ANDROID / ".gitignore") + assert not (APP / "src" / "main" / "assets" / "ui").exists(), \ + "a copy of the interface is in the source tree, which is how a fork begins" + + +def test_the_webview_loads_the_package_and_nothing_else(): + activity = _read(SRC / "MainActivity.kt") + loads = re.findall(r"\.loadUrl\(([^)]*)\)", activity) + assert loads and set(loads) == {"UiAssets.START"}, loads + assert "loadDataWithBaseURL" not in activity and "loadData(" not in activity + # The hub never becomes the document origin; a link out leaves the app. + assert "shouldOverrideUrlLoading" in activity and "openExternally" in activity + + +def test_the_policy_is_the_desktop_policy_sent_as_a_header(): + """One interface, one policy for the packaged builds — and the desktop's + is already held to the hub's by test_the_two_policies_stay_in_step.""" + def directives(text: str, start: str, end: str) -> dict[str, str]: + body = text.split(start, 1)[1].split(end, 1)[0] + out = {} + for d in re.findall(r"[`\"]([a-z-]+(?: [^`\"]*)?)[`\"]", body): + d = d.replace("${RECAPTCHA_SRC}", "$RECAPTCHA_SRC") + out[d.split()[0]] = d + return out + + desktop = directives(_read(CLIENT / "src" / "main.js"), "const CSP = [", "].join") + kotlin = _read(SRC / "shell" / "UiAssets.kt") + android = directives(kotlin, "val CSP = listOf(", ").joinToString") + assert desktop and android == desktop, set(android.items()) ^ set(desktop.items()) + + assets = _read(SRC / "shell" / "UiAssets.kt") + assert '"Content-Security-Policy" to CSP' in assets + recaptcha = 'RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com"' + assert recaptcha in assets + markup = re.sub(r"<!--.*?-->", "", _read(CLIENT / "scripts" / "index.html"), flags=re.S) + assert "Content-Security-Policy" not in markup + + +def test_the_asset_handler_cannot_be_walked_out_of(): + assets = _read(SRC / "shell" / "UiAssets.kt") + assert '".."' in assets and 'val asset = "ui/"' in assets + + +def test_plain_http_is_refused_except_to_loopback(): + hub = _read(SRC / "hub" / "HubClient.kt") + assert "The hub address must be https" in hub + assert 'Regex("^http://(localhost|127\\\\.)")' in hub + config = _read(APP / "src" / "main" / "res" / "xml" / "network_security_config.xml") + assert '<base-config cleartextTrafficPermitted="false"' in config + allowed = re.findall(r"<domain[^>]*>([^<]+)</domain>", config) + assert set(allowed) == {"localhost", "127.0.0.1"} + + +def test_the_page_reaches_the_signed_in_hub_only(): + hub = _read(SRC / "hub" / "HubClient.kt") + assert "Refused: not this hub" in hub and "sameOrigin(target, hub)" in hub + assert ".followRedirects(false)" in hub, "a redirect would carry the token elsewhere" + + +# ── The bridge ────────────────────────────────────────────────────────────── + +def test_no_javascript_interface_is_ever_added(): + """addJavascriptInterface injects into every frame of every origin.""" + for path in APP.rglob("*.kt"): + assert "addJavascriptInterface" not in _read(path), path + + +def test_every_message_is_checked_for_our_top_level_document(): + bridge = _read(SRC / "bridge" / "Bridge.kt") + check = bridge.split("override fun onPostMessage", 1)[1].split("work.execute", 1)[0] + assert "!isMainFrame" in check and "UiAssets.ORIGIN" in check + activity = _read(SRC / "MainActivity.kt") + assert "addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN)" in activity + assert re.search(r"addDocumentStartJavaScript\(web, .*setOf\(UiAssets\.ORIGIN\)\)", activity) + + +def _shim_channels() -> set[str]: + return set(re.findall(r"call\('([\w:-]+)'", _strip_js_comments(_read(SHIM)))) + + +def test_the_shim_offers_desktop_channels_and_native_answers_each(): + preload_js = _read(CLIENT / "src" / "preload.js") + preload = set(re.findall(r"ipcRenderer\.invoke\('([\w:-]+)'", preload_js)) + native = set() + for path in (SRC / "bridge" / "Channels.kt", SRC / "bridge" / "KeyChannels.kt", + SRC / "cast" / "CastChannels.kt"): + native |= set(re.findall(r'^\s*"([\w:-]+)" ->', _read(path), flags=re.M)) + shim = _shim_channels() + assert shim, "no channel found in the shim" + assert shim <= preload, f"channels the desktop does not have: {shim - preload}" + assert shim == native, f"shim and native disagree: {shim ^ native}" + + +def test_what_a_phone_does_not_have_is_absent_not_refusing(): + """platform.js decides what to show from whether an object exists + (`platform.node.available`, `platform.folder.available`, …): an object that + only refused would put screens on the page that fail when used.""" + shim = _strip_js_comments(_read(SHIM)) + exposed = shim.split("const meshbay = {", 1)[1].split("\n };", 1)[0] + for absent in ("node:", "rootPicker:", "minimizeToTray:", "setTrayLabels:"): + assert absent not in exposed, absent + assert "nodeAdmin: false" in exposed and "localFolders: false" in exposed + + +def test_the_bridge_is_frozen_and_the_port_hidden(): + shim = _strip_js_comments(_read(SHIM)) + assert "delete window.meshbayNative" in shim + assert "window.top !== window" in shim + assert "writable: false, configurable: false" in shim + assert "Object.freeze" in shim + + +def test_file_system_access_is_removed_from_the_page(): + """The WebView exposes it (spike S-1) and cannot back it with anything.""" + shim = _strip_js_comments(_read(SHIM)) + for name in ("showDirectoryPicker", "showSaveFilePicker", "showOpenFilePicker"): + assert f"'{name}'" in shim, name + + +def test_the_hub_address_is_injected_not_fetched(): + """platform.hubBase() is called while modules load, before anything can await.""" + assert "HUB_BASE" in _strip_js_comments(_read(SHIM)) + assert "const HUB_BASE = ${JSONObject.quote(hub.base)}" in _read(SRC / "MainActivity.kt") + + +def test_the_node_setup_welcome_needs_a_node(): + """A phone has a bridge and no node: with no groups yet it must see the + invitations and the join link, not a node wizard it cannot finish.""" + from spa_source import STATIC + app = _read(STATIC / "app.js") + home = app.split("function HomePage(", 1)[1].split("\n}\n", 1)[0] + gate = home.split("<${SetupWelcome}", 1)[0] + assert "platform.capabilities.nodeAdmin" in gate + assert "platform.isNative" not in gate + + +# ── The window ────────────────────────────────────────────────────────────── + +def test_nothing_is_granted_and_video_may_go_fullscreen(): + activity = _read(SRC / "MainActivity.kt") + assert "onPermissionRequest(request: PermissionRequest) = request.deny()" in activity + # Without a custom view, requestFullscreen() never settles (CLAUDE.md). + assert "override fun onShowCustomView" in activity + assert "override fun onHideCustomView" in activity + + +def test_no_backup_carries_the_keys_away(): + manifest = _read(APP / "src" / "main" / "AndroidManifest.xml") + assert 'android:allowBackup="false"' in manifest + assert 'android:dataExtractionRules="@xml/data_extraction_rules"' in manifest + + +def test_the_gradle_distribution_is_pinned_by_checksum(): + props = _read(ANDROID / "gradle" / "wrapper" / "gradle-wrapper.properties") + assert re.search(r"^distributionSha256Sum=[0-9a-f]{64}$", props, flags=re.M) + + +def test_the_version_is_the_packages_version(): + """All packages share one version (CLAUDE.md); this one reads it rather + than writing it a second time.""" + build = _read(APP / "build.gradle.kts") + assert 'rootDir.resolve("../meshbay-client/package.json")' in build + assert not re.search(r'versionName = "\d', build) + + +@pytest.mark.skipif(not os.environ.get("ANDROID_HOME") or shutil.which("java") is None, + reason="no Android SDK in the environment") +def test_the_jvm_unit_tests_pass(): + result = subprocess.run(["./gradlew", "--no-daemon", "-q", "testDebugUnitTest"], + cwd=ANDROID, capture_output=True, text=True, timeout=900) + assert result.returncode == 0, result.stdout[-3000:] + result.stderr[-3000:] diff --git a/packages/meshbay-hub/tests/test_cast_discovery.py b/packages/meshbay-hub/tests/test_cast_discovery.py index 9e9ce85..8d156ff 100644 --- a/packages/meshbay-hub/tests/test_cast_discovery.py +++ b/packages/meshbay-hub/tests/test_cast_discovery.py @@ -106,3 +106,47 @@ def test_a_new_scan_is_not_cut_short_by_the_one_it_replaced(run): 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_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_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\"" + } + ] +} diff --git a/packages/meshbay-node/pyproject.toml b/packages/meshbay-node/pyproject.toml index ce1f718..e505048 100644 --- a/packages/meshbay-node/pyproject.toml +++ b/packages/meshbay-node/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "meshbay-node" -version = "0.17.0" +version = "0.18.0" description = "MeshBay Node — local file host, streaming server, and group daemon" requires-python = ">=3.12" dependencies = [ diff --git a/packages/meshbay-node/src/meshbay_node/__init__.py b/packages/meshbay-node/src/meshbay_node/__init__.py index c3c18ff..c58bc8a 100644 --- a/packages/meshbay-node/src/meshbay_node/__init__.py +++ b/packages/meshbay-node/src/meshbay_node/__init__.py @@ -1,3 +1,3 @@ """MeshBay Node — local file host, streaming server, and group daemon.""" -__version__ = "0.17.0" +__version__ = "0.18.0" diff --git a/packages/meshbay-node/tests/test_attach_from_the_hub.py b/packages/meshbay-node/tests/test_attach_from_the_hub.py index f4aaa25..acc9d18 100644 --- a/packages/meshbay-node/tests/test_attach_from_the_hub.py +++ b/packages/meshbay-node/tests/test_attach_from_the_hub.py @@ -10,6 +10,7 @@ group name chosen on the hub, a folder name — so none of it may end a TOML string and write lines of its own. """ +import sys import tomllib from pathlib import Path @@ -79,6 +80,9 @@ async def test_a_group_name_cannot_write_lines_into_node_toml(tmp_path): assert hosted["node"]["ui_port"] == 18000 +@pytest.mark.skipif(sys.platform == "win32", + reason="Windows refuses a quote or a newline in a folder name, and a " + "path is written with `/`: there is no such folder to write") async def test_a_folder_name_cannot_either(tmp_path): state = _state(tmp_path, {"id": GID, "name": "Films"}) await ops.attach_group(state, "Films", str(tmp_path / "share")) diff --git a/packages/meshbay-node/tests/test_node_status.py b/packages/meshbay-node/tests/test_node_status.py index 075d11f..45a9710 100644 --- a/packages/meshbay-node/tests/test_node_status.py +++ b/packages/meshbay-node/tests/test_node_status.py @@ -192,7 +192,7 @@ async def test_add_root_creates_directory_and_returns_info(tmp_path): conf = tmp_path / "node.toml" conf.write_text(f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n' - f' [[groups.roots]]\n path = "{shared}"\n', encoding="utf-8") + f' [[groups.roots]]\n path = "{shared.as_posix()}"\n', encoding="utf-8") node_cfg = NodeConfig.__new__(NodeConfig) node_cfg.groups = [cfg] @@ -234,7 +234,7 @@ async def test_remove_root_requires_at_least_one_remaining(tmp_path): conf = tmp_path / "node.toml" conf.write_text(f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n' - f' [[groups.roots]]\n path = "{shared}"\n', encoding="utf-8") + f' [[groups.roots]]\n path = "{shared.as_posix()}"\n', encoding="utf-8") index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) roots = one_root(shared) state = { @@ -269,8 +269,9 @@ async def test_removing_a_writable_root_is_allowed(tmp_path): conf = tmp_path / "node.toml" conf.write_text( f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n' - f' [[groups.roots]]\n path = "{d1}"\n name = "incoming"\n writable = true\n\n' - f' [[groups.roots]]\n path = "{d2}"\n name = "shared"\n', encoding="utf-8") + f' [[groups.roots]]\n path = "{d1.as_posix()}"\n name = "incoming"\n' + ' writable = true\n\n' + f' [[groups.roots]]\n path = "{d2.as_posix()}"\n name = "shared"\n', encoding="utf-8") roots = RootSet.build([asdict(r) for r in cfg.roots]) index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) state = { @@ -306,7 +307,7 @@ async def test_update_root_rewrites_the_flags_in_node_toml(tmp_path): f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n' f' [[groups.roots]]\n' f' # the operator explained this one to themselves\n' - f' path = "{d1}"\n name = "media"\n', encoding="utf-8") + f' path = "{d1.as_posix()}"\n name = "media"\n', encoding="utf-8") roots = RootSet.build([asdict(r) for r in cfg.roots]) index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) state = { @@ -355,7 +356,7 @@ async def test_update_root_replaces_a_legacy_upload_line(tmp_path): conf = tmp_path / "node.toml" conf.write_text( f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n' - f' [[groups.roots]]\n path = "{d1}"\n name = "media"\n' + f' [[groups.roots]]\n path = "{d1.as_posix()}"\n name = "media"\n' f' upload = true\n', encoding="utf-8") roots = RootSet.build([asdict(r) for r in cfg.roots]) index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) @@ -388,8 +389,8 @@ async def test_remove_root_succeeds_with_two_roots(tmp_path): conf = tmp_path / "node.toml" conf.write_text( f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n' - f' [[groups.roots]]\n path = "{d1}"\n name = "dir1"\n\n' - f' [[groups.roots]]\n path = "{d2}"\n name = "dir2"\n', encoding="utf-8") + f' [[groups.roots]]\n path = "{d1.as_posix()}"\n name = "dir1"\n\n' + f' [[groups.roots]]\n path = "{d2.as_posix()}"\n name = "dir2"\n', encoding="utf-8") roots = RootSet.build([asdict(r) for r in cfg.roots]) index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) state = { diff --git a/packaging/deb/meshbay-hub/DEBIAN/prerm b/packaging/deb/meshbay-hub/DEBIAN/prerm new file mode 100755 index 0000000..359e0a5 --- /dev/null +++ b/packaging/deb/meshbay-hub/DEBIAN/prerm @@ -0,0 +1,24 @@ +#!/bin/sh +set -e + +# A hub left running once its package is gone serves from deleted code, and +# keeps writing bytecode into the shared venv. +case "$1" in + remove|deconfigure) + if [ -d /run/systemd/system ]; then + systemctl stop meshbay-hub.service 2>/dev/null || true + fi + ;; +esac + +# The hub's code lives in meshbay-common's venv, and meshbay-common's own +# cleanup runs only after this package is gone: the bytecode compiled next to +# it is removed here, or dpkg cannot remove the directories it sits in. +case "$1" in + remove|upgrade|deconfigure) + find /opt/meshbay-common/venv/lib/python3.*/site-packages/meshbay_hub \ + -depth -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + ;; +esac + +#DEBHELPER# diff --git a/packaging/deb/meshbay-node/DEBIAN/prerm b/packaging/deb/meshbay-node/DEBIAN/prerm new file mode 100755 index 0000000..6ccdead --- /dev/null +++ b/packaging/deb/meshbay-node/DEBIAN/prerm @@ -0,0 +1,36 @@ +#!/bin/sh +set -e + +# A node left running once its package is gone serves from deleted code, and +# keeps writing bytecode into the shared venv. Every instance stops here: the +# system ones, and the user service in each running user manager. Not on +# upgrade: the postinst restarts the node onto new code. +stop_user_nodes() { + for bus in /run/user/*/systemd/private; do + [ -S "$bus" ] || continue + uid=$(basename "$(dirname "$(dirname "$bus")")") + user=$(id -nu "$uid" 2>/dev/null) || continue + systemctl --user -M "$user@" stop meshbay-node.service 2>/dev/null || true + done +} + +case "$1" in + remove|deconfigure) + if [ -d /run/systemd/system ]; then + systemctl stop 'meshbay-node@*.service' 2>/dev/null || true + stop_user_nodes + fi + ;; +esac + +# The node's code lives in meshbay-common's venv, and meshbay-common's own +# cleanup runs only after this package is gone: the bytecode compiled next to +# it is removed here, or dpkg cannot remove the directories it sits in. +case "$1" in + remove|upgrade|deconfigure) + find /opt/meshbay-common/venv/lib/python3.*/site-packages/meshbay_node \ + -depth -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + ;; +esac + +#DEBHELPER# diff --git a/packaging/rpm/meshbay-hub.spec b/packaging/rpm/meshbay-hub.spec index 90a4f08..ffd991a 100644 --- a/packaging/rpm/meshbay-hub.spec +++ b/packaging/rpm/meshbay-hub.spec @@ -40,6 +40,12 @@ install -d -o root -g meshbay -m 750 /etc/meshbay %preun %systemd_preun meshbay-hub.service +# The hub's code lives in meshbay-common's venv, whose own cleanup runs only +# after this package is gone: the bytecode compiled next to it is removed here. +if [ "$1" -eq 0 ]; then + find /opt/meshbay-common/venv/lib/python3.*/site-packages/meshbay_hub \ + -depth -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true +fi %postun %systemd_postun_with_restart meshbay-hub.service diff --git a/packaging/rpm/meshbay-node.spec b/packaging/rpm/meshbay-node.spec index 9b9c6bf..89f3a0a 100644 --- a/packaging/rpm/meshbay-node.spec +++ b/packaging/rpm/meshbay-node.spec @@ -70,8 +70,23 @@ if [ -d /run/systemd/system ]; then fi %preun -if [ -d /run/systemd/system ]; then - systemctl daemon-reload || true +# A node left running once its package is gone serves from deleted code, and +# keeps writing bytecode into the shared venv. Every instance stops on erase +# ($1 = 0): the system ones, and the user service in each running user manager. +if [ "$1" -eq 0 ] && [ -d /run/systemd/system ]; then + systemctl stop 'meshbay-node@*.service' 2>/dev/null || true + for bus in /run/user/*/systemd/private; do + [ -S "$bus" ] || continue + uid=$(basename "$(dirname "$(dirname "$bus")")") + user=$(id -nu "$uid" 2>/dev/null) || continue + systemctl --user -M "$user@" stop meshbay-node.service 2>/dev/null || true + done +fi +# The node's code lives in meshbay-common's venv, whose own cleanup runs only +# after this package is gone: the bytecode compiled next to it is removed here. +if [ "$1" -eq 0 ]; then + find /opt/meshbay-common/venv/lib/python3.*/site-packages/meshbay_node \ + -depth -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true fi %files |