refactor(adb): completely separate daemon authenticator

This commit is contained in:
Simon Chan 2025-09-30 21:11:54 +08:00
parent 6db5d8eb74
commit 96824ef984
No known key found for this signature in database
GPG key ID: A8B69F750B9BCEDD
5 changed files with 277 additions and 274 deletions

View file

@ -1,2 +1,2 @@
export * from "./manager.js";
export * from "./storage/index.js";
export * from "./store.js";

View file

@ -1,13 +1,47 @@
import type {
AdbCredentialStore,
AdbCredentialManager,
AdbDaemonDefaultAuthenticationProcessorInit,
AdbPrivateKey,
MaybeError,
} from "@yume-chan/adb";
import { rsaParsePrivateKey } from "@yume-chan/adb";
import {
AdbDaemonDefaultAuthenticationProcessor,
AdbDaemonDefaultAuthenticator,
rsaParsePrivateKey,
} from "@yume-chan/adb";
import type { TangoKeyStorage } from "./storage/index.js";
export class AdbWebCryptoCredentialManager implements AdbCredentialStore {
export class AdbWebCryptoCredentialManager implements AdbCredentialManager {
static createDefaultAuthenticationProcessor(
storage: TangoKeyStorage,
init?: Omit<
AdbDaemonDefaultAuthenticationProcessorInit,
"credentialStore"
> & {
name?: string;
},
) {
return new AdbDaemonDefaultAuthenticationProcessor({
...init,
credentialManager: new this(storage, init?.name),
});
}
static createDefaultAuthenticator(
storage: TangoKeyStorage,
init?: Omit<
AdbDaemonDefaultAuthenticationProcessorInit,
"credentialStore"
> & {
name?: string;
},
) {
return new AdbDaemonDefaultAuthenticator(() =>
this.createDefaultAuthenticationProcessor(storage, init),
);
}
readonly #storage: TangoKeyStorage;
readonly #name: string | undefined;

View file

@ -5,13 +5,16 @@ import { encodeUtf8 } from "@yume-chan/struct";
import { decodeBase64 } from "../utils/base64.js";
import type { AdbCredentialStore } from "./auth.js";
import { AdbAuthType, AdbDefaultAuthenticator } from "./auth.js";
import type { AdbCredentialManager } from "./auth.js";
import {
AdbAuthType,
AdbDaemonDefaultAuthenticationProcessor,
} from "./auth.js";
import type { SimpleRsaPrivateKey } from "./crypto.js";
import { rsaParsePrivateKey } from "./crypto.js";
import { AdbCommand } from "./packet.js";
class MockCredentialStore implements AdbCredentialStore {
class MockCredentialStore implements AdbCredentialManager {
key: SimpleRsaPrivateKey;
name: string | undefined;
@ -75,17 +78,19 @@ const PUBLIC_KEY =
"QAAAANVsDNqDk46/2Qg74n5POy5nK/XA8glCLkvXMks9p885+GQ2WiVUctG8LP/W5cII11Pk1KsZ+90ccZV2fdjv+tnW/8li9iEWTC+G1udFMxsIQ+HRPvJF0Xl9JXDsC6pvdo9ic4d6r5BC9BGiijd0enoG/tHkJhMhbPf/j7+MWXDrF+BeJeyj0mWArbqS599IO2qUCZiNjRakAa/iESG6Om4xCJWTT8wGhSTs81cHcEeSmQ2ixRwS+uaa/8iK/mv6BvCep5qgFrJW1G9LD2WciVgTpOSc6B1N/OA92hwJYp2lHLPWZl6bJIYHqrzdHCxc4EEVVYHkSBdFy1w2vhg2YgRTlpbP00NVrZb6Car8BTqPnwTRIkHBC6nnrg6cWMQ0xusMtxChKBoYGhCLHY4iKK6ra3P1Ou1UXu0WySau3s+Av9FFXxtAuMAJUA+5GSMQGGECRhwLX910OfnHHN+VxqJkHQye4vNhIH5C1dJ39HJoxAdwH2tF7v7GF2fwsy2lUa3Vj6bBssWivCB9cKyJR0GVPZJZ1uah24ecvspwtAqbtxvj7ZD9l7AD92geEJdLrsbfhNaDyAioQ2grI32gdp80su/7BrdAsPaSomxCYBB8opmS+oJq6qTYxNZ0doT9EEyT5D9rl9UXXxq+rQbDpKV1rOQo5zJJ2GkELhUrslFm6n4+JQEAAQA=";
describe("auth", () => {
describe("AdbDefaultAuthenticator", () => {
describe("AdbDaemonDefaultAuthenticationProcessor", () => {
it("should generate correct public key without name", async () => {
const store = new MockCredentialStore(
new Uint8Array(PRIVATE_KEY),
undefined,
);
const authenticator = new AdbDefaultAuthenticator(store);
const authenticator = new AdbDaemonDefaultAuthenticationProcessor({
credentialManager: store,
});
const challenge = new Uint8Array(20);
const first = await authenticator.authenticate({
const first = await authenticator.process({
command: AdbCommand.Auth,
arg0: AdbAuthType.Token,
arg1: 0,
@ -96,7 +101,7 @@ describe("auth", () => {
assert.strictEqual(first.command, AdbCommand.Auth);
assert.strictEqual(first.arg0, AdbAuthType.Signature);
const result = await authenticator.authenticate({
const result = await authenticator.process({
command: AdbCommand.Auth,
arg0: AdbAuthType.Token,
arg1: 0,
@ -119,10 +124,12 @@ describe("auth", () => {
name,
);
const authenticator = new AdbDefaultAuthenticator(store);
const authenticator = new AdbDaemonDefaultAuthenticationProcessor({
credentialManager: store,
});
const challenge = new Uint8Array(20);
const first = await authenticator.authenticate({
const first = await authenticator.process({
command: AdbCommand.Auth,
arg0: AdbAuthType.Token,
arg1: 0,
@ -133,7 +140,7 @@ describe("auth", () => {
assert.strictEqual(first.command, AdbCommand.Auth);
assert.strictEqual(first.arg0, AdbAuthType.Signature);
const result = await authenticator.authenticate({
const result = await authenticator.process({
command: AdbCommand.Auth,
arg0: AdbAuthType.Token,
arg1: 0,

View file

@ -1,7 +1,14 @@
import type { MaybePromiseLike } from "@yume-chan/async";
import { EventEmitter } from "@yume-chan/event";
import { EmptyUint8Array } from "@yume-chan/struct";
import { PromiseResolver } from "@yume-chan/async";
import type { WritableStreamDefaultWriter } from "@yume-chan/stream-extra";
import {
AbortController,
Consumable,
WritableStream,
} from "@yume-chan/stream-extra";
import { decodeUtf8, EmptyUint8Array } from "@yume-chan/struct";
import { AdbDeviceFeatures, AdbFeature } from "../features.js";
import {
calculateBase64EncodedLength,
encodeBase64,
@ -15,8 +22,13 @@ import {
adbGetPublicKeySize,
rsaSign,
} from "./crypto.js";
import type { AdbPacketData } from "./packet.js";
import { AdbCommand } from "./packet.js";
import type { AdbPacketData, AdbPacketInit } from "./packet.js";
import { AdbCommand, calculateChecksum } from "./packet.js";
import type { AdbDaemonTransportInit } from "./transport.js";
import {
ADB_DAEMON_DEFAULT_INITIAL_PAYLOAD_SIZE,
AdbDaemonTransport,
} from "./transport.js";
export interface AdbPrivateKey extends SimpleRsaPrivateKey {
name?: string | undefined;
@ -28,7 +40,7 @@ export type AdbKeyIterable =
| Iterable<MaybeError<AdbPrivateKey>>
| AsyncIterable<MaybeError<AdbPrivateKey>>;
export interface AdbCredentialStore {
export interface AdbCredentialManager {
/**
* Generates and stores a RSA private key with modulus length `2048` and public exponent `65537`.
*/
@ -64,14 +76,29 @@ export const AdbAuthType = {
export type AdbAuthType = (typeof AdbAuthType)[keyof typeof AdbAuthType];
export interface AdbAuthenticator {
authenticate(packet: AdbPacketData): Promise<AdbPacketData>;
export interface AdbDaemonAuthenticationProcessor {
process(packet: AdbPacketData): Promise<AdbPacketData>;
close?(): MaybePromiseLike<undefined>;
}
export class AdbDefaultAuthenticator implements AdbAuthenticator {
#credentialStore: AdbCredentialStore;
export interface AdbDaemonDefaultAuthenticationProcessorInit {
credentialManager: AdbCredentialManager;
onKeyLoadError?: ((error: Error) => void) | undefined;
onSignatureAuthentication?: ((key: AdbKeyInfo) => void) | undefined;
onSignatureRejected?: ((key: AdbKeyInfo) => void) | undefined;
onPublicKeyAuthentication?: ((key: AdbKeyInfo) => void) | undefined;
}
export class AdbDaemonDefaultAuthenticationProcessor
implements AdbDaemonAuthenticationProcessor
{
#credentialStore: AdbCredentialManager;
#onKeyLoadError: ((error: Error) => void) | undefined;
#onSignatureAuthentication: ((key: AdbKeyInfo) => void) | undefined;
#onSignatureRejected: ((key: AdbKeyInfo) => void) | undefined;
#onPublicKeyAuthentication: ((key: AdbKeyInfo) => void) | undefined;
#iterator:
| Iterator<MaybeError<AdbPrivateKey>, void, void>
| AsyncIterator<MaybeError<AdbPrivateKey>, void, void>
@ -80,28 +107,12 @@ export class AdbDefaultAuthenticator implements AdbAuthenticator {
#prevKeyInfo: AdbKeyInfo | undefined;
#firstKey: AdbPrivateKey | undefined;
#onKeyLoadError = new EventEmitter<Error>();
get onKeyLoadError() {
return this.#onKeyLoadError.event;
}
#onSignatureAuthentication = new EventEmitter<AdbKeyInfo>();
get onSignatureAuthentication() {
return this.#onSignatureAuthentication.event;
}
#onSignatureRejected = new EventEmitter<AdbKeyInfo>();
get onSignatureRejected() {
return this.#onSignatureRejected.event;
}
#onPublicKeyAuthentication = new EventEmitter<AdbKeyInfo>();
get onPublicKeyAuthentication() {
return this.#onPublicKeyAuthentication.event;
}
constructor(credentialStore: AdbCredentialStore) {
this.#credentialStore = credentialStore;
constructor(init: AdbDaemonDefaultAuthenticationProcessorInit) {
this.#credentialStore = init.credentialManager;
this.#onKeyLoadError = init.onKeyLoadError;
this.#onSignatureAuthentication = init.onSignatureAuthentication;
this.#onSignatureRejected = init.onSignatureRejected;
this.#onPublicKeyAuthentication = init.onPublicKeyAuthentication;
}
async #iterate(token: Uint8Array): Promise<AdbPacketData | undefined> {
@ -122,7 +133,7 @@ export class AdbDefaultAuthenticator implements AdbAuthenticator {
}
if (result instanceof Error) {
this.#onKeyLoadError.fire(result);
this.#onKeyLoadError?.(result);
return await this.#iterate(token);
}
@ -132,12 +143,12 @@ export class AdbDefaultAuthenticator implements AdbAuthenticator {
// A new token implies the previous signature was rejected.
if (this.#prevKeyInfo) {
this.#onSignatureRejected.fire(this.#prevKeyInfo);
this.#onSignatureRejected?.(this.#prevKeyInfo);
}
const fingerprint = getFingerprint(result);
this.#prevKeyInfo = { fingerprint, name: result.name };
this.#onSignatureAuthentication.fire(this.#prevKeyInfo);
this.#onSignatureAuthentication?.(this.#prevKeyInfo);
return {
command: AdbCommand.Auth,
@ -147,7 +158,7 @@ export class AdbDefaultAuthenticator implements AdbAuthenticator {
};
}
async authenticate(packet: AdbPacketData): Promise<AdbPacketData> {
async process(packet: AdbPacketData): Promise<AdbPacketData> {
if (packet.arg0 !== AdbAuthType.Token) {
throw new Error("Unsupported authentication packet");
}
@ -186,7 +197,7 @@ export class AdbDefaultAuthenticator implements AdbAuthenticator {
publicKeyBuffer.set(nameBuffer, publicKeyBase64Length + 1);
}
this.#onPublicKeyAuthentication.fire({
this.#onPublicKeyAuthentication?.({
fingerprint: getFingerprint(key),
name: key.name,
});
@ -206,3 +217,169 @@ export class AdbDefaultAuthenticator implements AdbAuthenticator {
this.#firstKey = undefined;
}
}
export type AdbDaemonAuthenticateOptions = Pick<
AdbDaemonTransportInit,
| "serial"
| "connection"
| "features"
| "initialDelayedAckBytes"
| "preserveConnection"
| "readTimeLimit"
>;
export interface AdbDaemonAuthenticator {
authenticate(
options: AdbDaemonAuthenticateOptions,
): Promise<AdbDaemonTransport>;
}
export class AdbDaemonDefaultAuthenticator implements AdbDaemonAuthenticator {
static authenticate(
processor: AdbDaemonDefaultAuthenticationProcessor,
options: AdbDaemonAuthenticateOptions,
): Promise<AdbDaemonTransport> {
const authenticator = new AdbDaemonDefaultAuthenticator(
() => processor,
);
return authenticator.authenticate(options);
}
#createProcessor: () => AdbDaemonAuthenticationProcessor;
constructor(createProcessor: () => AdbDaemonAuthenticationProcessor) {
this.#createProcessor = createProcessor;
}
#sendPacket(
writer: WritableStreamDefaultWriter<Consumable<AdbPacketInit>>,
init: AdbPacketData,
): Promise<void>;
#sendPacket(
writer: WritableStreamDefaultWriter<Consumable<AdbPacketInit>>,
init: AdbPacketInit,
) {
// Always send checksum in auth steps
// Because we don't know if the device needs it or not.
init.checksum = calculateChecksum(init.payload);
init.magic = init.command ^ 0xffffffff;
return Consumable.WritableStream.write(writer, init);
}
async authenticate({
serial,
connection,
features = AdbDeviceFeatures,
initialDelayedAckBytes = ADB_DAEMON_DEFAULT_INITIAL_PAYLOAD_SIZE,
preserveConnection,
readTimeLimit,
}: AdbDaemonAuthenticateOptions): Promise<AdbDaemonTransport> {
const processor = this.#createProcessor();
// Initially, set to highest-supported version and payload size.
let version = 0x01000001;
// Android 4: 4K, Android 7: 256K, Android 9: 1M
let maxPayloadSize = 1024 * 1024;
const resolver = new PromiseResolver<string>();
// Here is similar to `AdbPacketDispatcher`,
// But the received packet types and send packet processing are different.
const abortController = new AbortController();
const writer = connection.writable.getWriter();
const pipe = connection.readable
.pipeTo(
new WritableStream({
write: async (packet) => {
switch (packet.command) {
case AdbCommand.Connect:
version = Math.min(version, packet.arg0);
maxPayloadSize = Math.min(
maxPayloadSize,
packet.arg1,
);
resolver.resolve(decodeUtf8(packet.payload));
break;
case AdbCommand.Auth: {
await this.#sendPacket(
writer,
await processor.process(packet),
);
break;
}
default:
// Maybe the previous ADB client exited without reading all packets,
// so they are still waiting in OS internal buffer.
// Just ignore them.
// Because a `Connect` packet will reset the device,
// Eventually there will be `Connect` and `Auth` response packets.
break;
}
},
}),
{
// Don't cancel the source ReadableStream on AbortSignal abort.
preventCancel: true,
signal: abortController.signal,
},
)
.then(
async () => {
await processor.close?.();
// If `resolver` is already settled, call `reject` won't do anything.
resolver.reject(
new Error("Connection closed unexpectedly"),
);
},
async (e) => {
await processor.close?.();
resolver.reject(e);
},
);
if (initialDelayedAckBytes <= 0) {
const index = features.indexOf(AdbFeature.DelayedAck);
if (index !== -1) {
features = features.toSpliced(index, 1);
}
}
let banner: string;
try {
await this.#sendPacket(writer, {
command: AdbCommand.Connect,
arg0: version,
arg1: maxPayloadSize,
// The terminating `;` is required in formal definition
// But ADB daemon (all versions) can still work without it
payload: encodeUtf8(`host::features=${features.join(",")}`),
});
banner = await resolver.promise;
} finally {
// When failed, release locks on `connection` so the caller can try again.
// When success, also release locks so `AdbPacketDispatcher` can use them.
abortController.abort();
writer.releaseLock();
// Wait until pipe stops (`ReadableStream` lock released)
await pipe;
}
return new AdbDaemonTransport({
serial,
connection,
version,
maxPayloadSize,
banner,
features,
initialDelayedAckBytes,
preserveConnection: preserveConnection,
readTimeLimit: readTimeLimit,
});
}
}

View file

@ -1,12 +1,5 @@
import type { MaybePromiseLike } from "@yume-chan/async";
import { PromiseResolver } from "@yume-chan/async";
import type { ReadableWritablePair } from "@yume-chan/stream-extra";
import {
AbortController,
Consumable,
WritableStream,
} from "@yume-chan/stream-extra";
import { decodeUtf8, encodeUtf8 } from "@yume-chan/struct";
import type { Consumable, ReadableWritablePair } from "@yume-chan/stream-extra";
import type {
AdbIncomingSocketHandler,
@ -17,14 +10,11 @@ import { AdbBanner } from "../banner.js";
import { AdbDeviceFeatures, AdbFeature } from "../features.js";
import type {
AdbAuthenticator,
AdbCredentialStore,
AdbKeyInfo,
AdbDaemonAuthenticateOptions,
AdbDaemonAuthenticator,
} from "./auth.js";
import { AdbDefaultAuthenticator } from "./auth.js";
import { AdbPacketDispatcher } from "./dispatcher.js";
import type { AdbPacketData, AdbPacketInit } from "./packet.js";
import { AdbCommand, calculateChecksum } from "./packet.js";
export const ADB_DAEMON_VERSION_OMIT_CHECKSUM = 0x01000001;
export const ADB_DAEMON_DEFAULT_INITIAL_PAYLOAD_SIZE = 32 * 1024 * 1024;
@ -34,53 +24,7 @@ export type AdbDaemonConnection = ReadableWritablePair<
Consumable<AdbPacketInit>
>;
export interface AdbDaemonAuthenticationOptions {
serial: string;
connection: AdbDaemonConnection;
features?: readonly AdbFeature[];
/**
* The number of bytes the device can send before receiving an ack packet.
* Using delayed ack can improve the throughput,
* especially when the device is connected over Wi-Fi (so the latency is higher).
*
* Set to 0 or any negative value to disable delayed ack in handshake.
* Otherwise the value must be in the range of unsigned 32-bit integer.
*
* Delayed ack was added in Android 14,
* this option will be ignored when the device doesn't support it.
*
* @default ADB_DAEMON_DEFAULT_INITIAL_PAYLOAD_SIZE
*/
initialDelayedAckBytes?: number;
/**
* Whether to keep the `connection` open (don't call `writable.close` and `readable.cancel`)
* when `AdbDaemonTransport.close` is called.
*
* Note that when `authenticate` fails,
* no matter which value this option has,
* the `connection` is always kept open, so it can be used in another `authenticate` call.
*
* @default false
*/
preserveConnection?: boolean | undefined;
/**
* When set, the transport will throw an error when
* one of the socket readable stalls for this amount of milliseconds.
*
* Because ADB is a multiplexed protocol, blocking one socket will also block all other sockets.
* It's important to always read from all sockets to prevent stalling.
*
* This option is helpful to detect bugs in the client code.
*
* @default undefined
*/
readTimeLimit?: number | undefined;
}
interface AdbDaemonTransportInit {
export interface AdbDaemonTransportInit {
serial: string;
connection: AdbDaemonConnection;
version: number;
@ -128,170 +72,11 @@ interface AdbDaemonTransportInit {
* An ADB Transport that connects to ADB Daemons directly.
*/
export class AdbDaemonTransport implements AdbTransport {
/**
* Authenticate with the ADB Daemon and create a new transport.
*/
static async authenticate({
serial,
connection,
features = AdbDeviceFeatures,
initialDelayedAckBytes = ADB_DAEMON_DEFAULT_INITIAL_PAYLOAD_SIZE,
...options
}: AdbDaemonAuthenticationOptions &
(
| { authenticator: AdbAuthenticator }
| {
credentialStore: AdbCredentialStore;
onKeyLoadError?: ((error: Error) => void) | undefined;
onSignatureAuthentication?:
| ((key: AdbKeyInfo) => void)
| undefined;
onSignatureRejected?: ((key: AdbKeyInfo) => void) | undefined;
onPublicKeyAuthentication?:
| ((key: AdbKeyInfo) => void)
| undefined;
}
)): Promise<AdbDaemonTransport> {
// Initially, set to highest-supported version and payload size.
let version = 0x01000001;
// Android 4: 4K, Android 7: 256K, Android 9: 1M
let maxPayloadSize = 1024 * 1024;
const resolver = new PromiseResolver<string>();
let authenticator: AdbAuthenticator;
if ("authenticator" in options) {
authenticator = options.authenticator;
} else {
const defaultAuthenticator = new AdbDefaultAuthenticator(
options.credentialStore,
);
if (options.onKeyLoadError) {
defaultAuthenticator.onKeyLoadError(options.onKeyLoadError);
}
if (options.onSignatureAuthentication) {
defaultAuthenticator.onSignatureAuthentication(
options.onSignatureAuthentication,
);
}
if (options.onSignatureRejected) {
defaultAuthenticator.onSignatureRejected(
options.onSignatureRejected,
);
}
if (options.onPublicKeyAuthentication) {
defaultAuthenticator.onPublicKeyAuthentication(
options.onPublicKeyAuthentication,
);
}
authenticator = defaultAuthenticator;
}
// Here is similar to `AdbPacketDispatcher`,
// But the received packet types and send packet processing are different.
const abortController = new AbortController();
const pipe = connection.readable
.pipeTo(
new WritableStream({
async write(packet) {
switch (packet.command) {
case AdbCommand.Connect:
version = Math.min(version, packet.arg0);
maxPayloadSize = Math.min(
maxPayloadSize,
packet.arg1,
);
resolver.resolve(decodeUtf8(packet.payload));
break;
case AdbCommand.Auth: {
await sendPacket(
await authenticator.authenticate(packet),
);
break;
}
default:
// Maybe the previous ADB client exited without reading all packets,
// so they are still waiting in OS internal buffer.
// Just ignore them.
// Because a `Connect` packet will reset the device,
// Eventually there will be `Connect` and `Auth` response packets.
break;
}
},
}),
{
// Don't cancel the source ReadableStream on AbortSignal abort.
preventCancel: true,
signal: abortController.signal,
},
)
.then(
async () => {
await authenticator.close?.();
// If `resolver` is already settled, call `reject` won't do anything.
resolver.reject(
new Error("Connection closed unexpectedly"),
);
},
async (e) => {
await authenticator.close?.();
resolver.reject(e);
},
);
const writer = connection.writable.getWriter();
async function sendPacket(init: AdbPacketData) {
// Always send checksum in auth steps
// Because we don't know if the device needs it or not.
(init as AdbPacketInit).checksum = calculateChecksum(init.payload);
(init as AdbPacketInit).magic = init.command ^ 0xffffffff;
await Consumable.WritableStream.write(
writer,
init as AdbPacketInit,
);
}
if (initialDelayedAckBytes <= 0) {
const index = features.indexOf(AdbFeature.DelayedAck);
if (index !== -1) {
features = features.toSpliced(index, 1);
}
}
let banner: string;
try {
await sendPacket({
command: AdbCommand.Connect,
arg0: version,
arg1: maxPayloadSize,
// The terminating `;` is required in formal definition
// But ADB daemon (all versions) can still work without it
payload: encodeUtf8(`host::features=${features.join(",")}`),
});
banner = await resolver.promise;
} finally {
// When failed, release locks on `connection` so the caller can try again.
// When success, also release locks so `AdbPacketDispatcher` can use them.
abortController.abort();
writer.releaseLock();
// Wait until pipe stops (`ReadableStream` lock released)
await pipe;
}
return new AdbDaemonTransport({
serial,
connection,
version,
maxPayloadSize,
banner,
features,
initialDelayedAckBytes,
preserveConnection: options.preserveConnection,
readTimeLimit: options.readTimeLimit,
});
static authenticate(
authenticator: AdbDaemonAuthenticator,
options: AdbDaemonAuthenticateOptions,
) {
return authenticator.authenticate(options);
}
#connection: AdbDaemonConnection;