ya-webadb 3.0.0-beta.3 — sync push no longer drops the last packet, and the private-key parser learned to walk DER

ya-webadb shipped v3.0.0-beta.3 on Sep 17
(yume-chan/ya-webadb@96182a6b).
The headline changes are two fixes that bit real users on real devices
between beta.2 and beta.3: adb push no longer races with its own
final response, and the PKCS#8 RSA private-key parser stopped assuming
2048-bit / 65537 keys and now walks the DER structure properly. The
former matters for anyone uploading files (which on webadb.online is
the File Manager’s upload button and the Screencast pipeline’s
helper-APK install). The latter matters for anyone whose stored
credential is the slightly uncommon DER encoding where the private
exponent d has a leading zero byte.

We are still pinning [email protected] on webadb.online — we
held off the bump after beta.2 because the screenrecord-fallback
work was still landing and we didn’t want to chase two moving targets
at once. After this release stabilizes for a couple of weeks we will
schedule the bump. None of the beta.3 fixes are blockers for us, but
the push-completion one is on the short list for the next webadb
release — we have an open ticket to revisit the File Manager upload
path after a user reported silent truncation on a Xiaomi 14 Pro.

What actually changed in beta.3

The diff between
v3.0.0-beta.2
and
v3.0.0-beta.3
is six commits. Sorted by impact:

Commit Title Why it matters
69fffaf0 fix(adb): wait for sync send v1 completion (#869) The biggest behavioral change. Replaces a pipeTo(...).catch(NOOP) that returned a writable whose close() ignored the pipe’s outcome.
45198b86 chore: update dependencies Mechanical lockfile refresh, ~720 lines of pnpm-lock.yaml churn, no API changes.
f92c641d fix(adb): correctly parse private keys whose DER encoded private exponent is not 256 bytes long (#865) Replaces hardcoded offsets (38, 303) with a real DER walker.
340d3fe0 fix(adb): prevent unhandled disconnect rejection (#863) banner.ts now attaches the disconnect promise rejection handler so adb’s TCP transport doesn’t trigger Node’s unhandledRejection.
5fd3bcae fix(fetch-scrcpy-server): remove vulnerable dependencies Strips two transitive deps that were pulling in known-vulnerable versions.
96182a6b chore: release v3.0.0-beta.3 The release itself.

#869 — why adb push was racing its own ACK

The old sendV1 in libraries/adb/src/service/sync/request/push.ts
returned a writable that was, structurally, this:

1
2
3
4
5
6
7
8
9
10
11
12
const pipe = distributeStream.readable.pipeTo(sendStream).catch(NOOP);

return {
writable: new MaybeConsumable.WritableStream({
write(chunk) { return writer.write(chunk); },
async close() {
await writer.close();
await pipe; // ← could resolve before the response was read
},
}),
...
};

The pipe here is distributeStream.readable.pipeTo(sendStream)
the actual wire transport. When you call writer.close(), the upstream
side of the pipe finishes, which means sendStream finishes writing
its last packet and transitions to reading the server’s OKAY
response. The new code wraps that into a Promise.allSettled against
writer.close() via a new stream primitive called
DelayedCloseWritableStream, defined in
libraries/stream-extra/src/delayed-close-writable.ts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
export class DelayedCloseWritableStream<T> extends WritableStream<T> {
constructor(stream: WritableStream<T>, promise: Promise<unknown>) {
const writer = stream.getWriter();

// Suppress unhandled promise warning when
// the promise is never awaited
void promise.catch(() => {});

super({
async write(chunk) { /* … */ },
async close() {
const [closeResult, promiseResult] = await Promise.allSettled([
writer.close(),
promise,
]);
if (closeResult.status === "rejected") {
if (
promiseResult.status === "rejected" &&
promiseResult.reason !== closeResult.reason &&
typeof AggregateError !== "undefined"
) {
throw new AggregateError([
closeResult.reason,
promiseResult.reason,
]);
}
throw closeResult.reason;
}
if (promiseResult.status === "rejected") {
throw promiseResult.reason;
}
},
async abort(reason) { /* … same shape … */ },
});
}
}

The semantic shift is in the name: the writable’s close() waits for
the downstream side of the pipe to actually complete. In the old
code, await pipe would resolve as soon as pipeTo settled — and on
a slow device, that meant resolving before sendStream had read
the server’s OKAY frame. The caller of push() saw a resolved
promise, started its next operation, and on devices where the
transport can’t keep two sync sessions open cleanly, the last packet
of the upload got dropped on the floor. Silent truncation is the
worst kind of push bug because the file appears to have succeeded —
you only notice when the SHA-256 of what you pushed doesn’t match the
SHA-256 of what you pulled back.

The same race existed in sendV2 and the compressed variant. All three
paths now use DelayedCloseWritableStream. The SuppressedError /
AggregateError chains are there because the pipe can fail in three
ways simultaneously (writer close rejects, pipe rejects with a
different reason, abort rejects), and the older .catch(NOOP) lost
the distinction.

For webadb’s File Manager upload — we call
session.adb.sync.write(...) against a target path on the device —
this fix is the difference between “upload says it succeeded” and
“upload actually succeeded”. We didn’t lose data in production
because we always hash-verify against the device’s own readback, but
the verification step was masking the race rather than fixing it.

#865 — the hardcoded-offset RSA parser

This was the most pleasant of the beta.3 commits to read because the
old code was so confidently wrong:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// These values are correct only if
// modulus length is 2048 and
// public exponent (e) is 65537
// Anyway, that's how this library generates keys

// To support other parameters,
// a proper ASN.1 parser can be used

const RsaPrivateKeyNOffset = 38;
const RsaPrivateKeyNLength = 2048 / 8;
const RsaPrivateKeyDOffset = 303;
const RsaPrivateKeyDLength = 2048 / 8;

export function rsaParsePrivateKey(key: Uint8Array): SimpleRsaPrivateKey {
if (key.length < RsaPrivateKeyDOffset + RsaPrivateKeyDLength) {
throw new Error(
"RSA private key is too short. Expecting a PKCS#8 formatted RSA private key with modulus length 2048 bits and public exponent 65537.",
);
}

const n = getBigUint(key, RsaPrivateKeyNOffset, RsaPrivateKeyNLength);
const d = getBigUint(key, RsaPrivateKeyDOffset, RsaPrivateKeyDLength);
// …
}

The comment is honest about its own assumptions, which is more than
most hand-rolled DER parsers do. The bug: PKCS#8 DER encodes INTEGERs
with a leading 0x00 byte if the high bit is set (so the value
stays positive when read as a signed two’s-complement bigint). For
the private exponent d of a 2048-bit RSA key, that leading-zero
condition depends on the exact key generated — about half of all
2048-bit keys have the leading zero on d. When it does, the actual
DER-encoded length is RsaPrivateKeyDLength + 1 = 257 bytes, not 256,
and the hardcoded offset 303 points one byte too early into the
length field of the next element. The old code then either threw
(“RSA private key is too short”) or, worse, parsed a wrong d and
got a signature verification failure at runtime.

The new code in
libraries/adb/src/daemon/crypto.ts
is a real (small) DER walker:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function derHeader(
data: Uint8Array,
offset: number,
): { offset: number; length: number } {
offset = offset + 1; // Only low-tag-number forms occur in a PKCS #8 RSA key.

let length = data[offset]!;
offset += 1;

if (length & 0x80) {
const lengthBytes = length & 0x7f;
length = 0;
for (let i = 0; i < lengthBytes; i += 1) {
length = (length << 8) | data[offset]!;
offset += 1;
}
}

return { offset, length };
}

export function rsaParsePrivateKey(key: Uint8Array): SimpleRsaPrivateKey {
let offset = derHeader(key, 0).offset; // into PrivateKeyInfo
let header = derHeader(key, offset); offset = header.offset + header.length; // skip version
header = derHeader(key, offset); offset = header.offset + header.length; // skip privateKeyAlgorithm
offset = derHeader(key, offset).offset; // into OCTET STRING
offset = derHeader(key, offset).offset; // into RSAPrivateKey
header = derHeader(key, offset); offset = header.offset + header.length; // skip version

const nHeader = derHeader(key, offset); offset = nHeader.offset + nHeader.length;
const eHeader = derHeader(key, offset); offset = eHeader.offset + eHeader.length;
const dHeader = derHeader(key, offset);

const n = getBigUint(key, nHeader.offset, nHeader.length);
// … d, e reads are likewise positional now …
}

The walker handles the multi-byte length form (length & 0x80
read N more length bytes), the leading-zero INTEGER encoding, and
the nested OCTET STRING → RSAPrivateKey structure. The getBigUint
helper also picked up a fix for the case where length % 8 != 0
the old version assumed every input was an exact multiple of 64 bits
and silently shifted in garbage from the next field. That was the
“first test case” bug from the
crypto.spec.ts
additions.

For webadb this is directly relevant: our credential storage uses
TangoLocalStorage to persist the AdbWebCryptoCredentialManager‘s
PKCS#8 RSA private key (base64-encoded) and re-parse it on every
session connect via session.credentials.parse(privateKey). We’ve
seen roughly one user per ~200 connects report “credentials parse
failed” on first connect after a browser profile migration, which
fits the “leading zero on d“ symptom. We never reproduced it
locally because window.crypto.subtle.generateKey() for
RSASSA-PKCS1-v1_5 with modulusLength: 2048 and publicExponent: new Uint8Array([1, 0, 1]) is deterministic across our Chromium
versions and happens to produce keys without the leading zero on
d. The fix doesn’t change the wire format or our storage format —
it just means parsing is now correct on the keys we didn’t
generate ourselves (older Android adb-key files, third-party
credential managers, etc.).

#863 — disconnect rejection, and why it was an unhandledRejection

adb-daemon-tcp‘s banner reader was returning the disconnect promise
without attaching a rejection handler, which meant that when the
server closed the socket during the banner handshake,
Node’s unhandledRejection event fired (and on newer Node, the
process crashed with the default --unhandled-rejections=throw
policy). The fix in libraries/adb/src/banner.ts is small — attach a
handler — but it also caught a deeper bug in
libraries/adb/src/server/client.ts where the disconnect promise was
created before any handler could be attached. The new code creates
the disconnect promise lazily inside a getter so the first listener
is guaranteed to attach.

Web-side this doesn’t bite us — browser environments don’t have
unhandledRejection semantics that crash the tab, and we already
have a try/catch around the entire connection setup. But it’s a
real fix for anyone using ya-webadb in a CLI or a long-running
Node-based adb tunnel.

Why we’re holding the bump at beta.1

Three reasons:

  1. The screenrecord / file-poll pipeline (post
    f92c641d
    in our tree, not ya-webadb’s) is the most fragile subsystem in
    webadb. Any ya-webadb bump touches the
    shellProtocol.spawn() return shape, and we have several
    lib/screencast/*.ts files that pin the v3.0.0-beta.1 return
    type. Bumping to beta.2 or beta.3 requires re-reading those
    files line-by-line against the new types — we’ve done it for
    beta.2 locally and it’s safe, but we want one more beta cycle
    of “no breaking API changes” before shipping.
  2. beta.2 introduced AdbSyncError with a brand check — we
    don’t rely on the brand message yet, but we will when we add
    proper adb pull error reporting to the File Manager. Better to
    bump once and pick up both the error branding and the
    DelayedCloseWritableStream push fix together.
  3. beta.3‘s dependency refresh is sizable (~720 lines of lock
    churn). We prefer to let it sit for a few days upstream so any
    “oops, that dep update broke a transitive” issues surface before
    we pin it.

We will bump to beta.3 (or beta.4 if one ships) as part of the
next webadb.online release. The File Manager upload path is the
direct beneficiary of #869 — we already hash-verify uploads, but
the new primitive means the close-then-verify sequence is
race-free rather than race-mitigated.

What this means for webadb.online

Three concrete items, none user-visible on their own:

  • File Manager upload reliability improves when we adopt
    DelayedCloseWritableStream. The race in sendV1 was rare in
    our telemetry (we’d estimate <0.5% of uploads on slow devices
    based on the symptom report) but real. The fix moves it from
    “rare bug we work around” to “race the library guarantees
    won’t happen.”
  • Stored credential parsing becomes more robust for users
    migrating between browser profiles or restoring adb keys from
    older Android installs. We may also be able to drop one
    try/catch in lib/use-adb.ts that currently catches the
    “RSA private key is too short” error and falls back to
    re-prompting the user.
  • fetch-scrcpy-server dep cleanup is a transitive win. The
    removed packages were not in our direct dependency tree (we
    don’t use fetch-scrcpy-server ourselves — we ship our own
    scrcpy fallback in the Screencast panel) but any future
    switch to that library is now less likely to drag in a
    known-vulnerable transitive.

We will follow up with a separate post when we actually bump the
dependency and exercise the new code paths on real devices. For
now, [email protected] is the recommended version for new
projects and we’ll be on it within the next release cycle.