Compare commits

...

2 commits

Author SHA1 Message Date
renovate[bot]
befd887c39
chore(deps): update dependency node to v22 2025-09-16 23:57:16 +00:00
Simon Chan
7df9ca2642
feat(credential): allow ignoring error and continue loading next key (#803) 2025-09-17 07:55:03 +08:00
12 changed files with 647 additions and 123 deletions

View file

@ -0,0 +1,7 @@
---
"@yume-chan/adb-credential-nodejs": major
"@yume-chan/adb-credential-web": major
"@yume-chan/adb": major
---
Allow credential stores to report loading error but still trying to load next key

View file

@ -17,7 +17,7 @@ jobs:
- name: Setup node - name: Setup node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with: with:

View file

@ -10,8 +10,9 @@ import {
writeFile, writeFile,
} from "node:fs/promises"; } from "node:fs/promises";
import { homedir, hostname, userInfo } from "node:os"; import { homedir, hostname, userInfo } from "node:os";
import { resolve } from "node:path"; import { delimiter, resolve } from "node:path";
import type { MaybeError } from "@yume-chan/adb";
import { import {
adbGeneratePublicKey, adbGeneratePublicKey,
decodeBase64, decodeBase64,
@ -21,12 +22,41 @@ import {
} from "@yume-chan/adb"; } from "@yume-chan/adb";
import type { TangoKey, TangoKeyStorage } from "@yume-chan/adb-credential-web"; import type { TangoKey, TangoKeyStorage } from "@yume-chan/adb-credential-web";
export class TangoNodeStorage implements TangoKeyStorage { class KeyError extends Error {
#logger: ((message: string) => void) | undefined; readonly path: string;
constructor(logger: ((message: string) => void) | undefined) { constructor(message: string, path: string, options?: ErrorOptions) {
this.#logger = logger; super(message, options);
this.path = path;
} }
}
/**
* Can't read or parse a private key file.
*
* Check `path` for file path, and `cause` for the error.
*/
class InvalidKeyError extends KeyError {
constructor(path: string, options?: ErrorOptions) {
super(`Can't read private key file at "${path}"`, path, options);
}
}
/**
* Can't read or parse a vendor key.
*
* Check `path` for file path, and `cause` for the error.
*/
class VendorKeyError extends KeyError {
constructor(path: string, options?: ErrorOptions) {
super(`Can't read vendor key file at "${path}"`, path, options);
}
}
export class TangoNodeStorage implements TangoKeyStorage {
static readonly KeyError = KeyError;
static readonly InvalidKeyError = InvalidKeyError;
static readonly VendorKeyError = VendorKeyError;
async #getAndroidDirPath() { async #getAndroidDirPath() {
const dir = resolve(homedir(), ".android"); const dir = resolve(homedir(), ".android");
@ -75,11 +105,11 @@ export class TangoNodeStorage implements TangoKeyStorage {
pem pem
// Parse PEM in Lax format (allows spaces/line breaks everywhere) // Parse PEM in Lax format (allows spaces/line breaks everywhere)
// https://datatracker.ietf.org/doc/html/rfc7468 // https://datatracker.ietf.org/doc/html/rfc7468
.replaceAll(/-----(BEGIN|END) PRIVATE KEY-----/g, "") .replaceAll(/-----(BEGIN|END)( RSA)? PRIVATE KEY-----/g, "")
.replaceAll(/\x20|\t|\r|\n|\v|\f/g, ""), .replaceAll(/\x20|\t|\r|\n|\v|\f/g, ""),
); );
} catch (e) { } catch (e) {
throw new Error("Invalid private key file: " + path, { cause: e }); throw new InvalidKeyError(path, { cause: e });
} }
} }
@ -95,7 +125,12 @@ export class TangoNodeStorage implements TangoKeyStorage {
} }
const publicKey = await readFile(publicKeyPath, "utf8"); const publicKey = await readFile(publicKeyPath, "utf8");
return publicKey.split(" ")[1]?.trim(); const spaceIndex = publicKey.indexOf(" ");
if (spaceIndex === -1) {
return undefined;
}
const name = publicKey.substring(spaceIndex + 1).trim();
return name ? name : undefined;
} catch { } catch {
return undefined; return undefined;
} }
@ -108,20 +143,39 @@ export class TangoNodeStorage implements TangoKeyStorage {
return { privateKey, name }; return { privateKey, name };
} }
async *#readVendorKeys(path: string) { async *#readVendorKeys(
const stats = await stat(path); path: string,
): AsyncGenerator<MaybeError<TangoKey>, void, void> {
let stats;
try {
stats = await stat(path);
} catch (e) {
yield new VendorKeyError(path, { cause: e });
return;
}
if (stats.isFile()) { if (stats.isFile()) {
try { try {
yield await this.#readKey(path); yield await this.#readKey(path);
return;
} catch (e) { } catch (e) {
this.#logger?.(String(e)); yield e instanceof KeyError
? e
: new VendorKeyError(path, { cause: e });
return;
} }
return;
} }
if (stats.isDirectory()) { if (stats.isDirectory()) {
for await (const dirent of await opendir(path)) { let dir;
try {
dir = await opendir(path);
} catch (e) {
yield new VendorKeyError(path, { cause: e });
return;
}
for await (const dirent of dir) {
if (!dirent.isFile()) { if (!dirent.isFile()) {
continue; continue;
} }
@ -130,31 +184,45 @@ export class TangoNodeStorage implements TangoKeyStorage {
continue; continue;
} }
const file = resolve(path, dirent.name);
try { try {
yield await this.#readKey(resolve(path, dirent.name)); yield await this.#readKey(file);
} catch (e) { } catch (e) {
this.#logger?.(String(e)); yield e instanceof KeyError
? e
: new VendorKeyError(file, { cause: e });
} }
} }
} }
} }
async *load(): AsyncGenerator<TangoKey, void, void> { async *load(): AsyncGenerator<MaybeError<TangoKey>, void, void> {
const userKeyPath = await this.#getUserKeyPath(); const userKeyPath = await this.#getUserKeyPath();
if (existsSync(userKeyPath)) { if (existsSync(userKeyPath)) {
yield await this.#readKey(userKeyPath); try {
yield await this.#readKey(userKeyPath);
} catch (e) {
yield e instanceof KeyError
? e
: new InvalidKeyError(userKeyPath, { cause: e });
}
} }
const vendorKeys = process.env.ADB_VENDOR_KEYS; const vendorKeys = process.env.ADB_VENDOR_KEYS;
if (vendorKeys) { if (vendorKeys) {
const separator = process.platform === "win32" ? ";" : ":"; for (const path of vendorKeys.split(delimiter).filter(Boolean)) {
for (const path of vendorKeys.split(separator)) {
yield* this.#readVendorKeys(path); yield* this.#readVendorKeys(path);
} }
} }
} }
} }
export namespace TangoNodeStorage {
export type KeyError = typeof KeyError;
export type InvalidKeyError = typeof InvalidKeyError;
export type VendorKeyError = typeof VendorKeyError;
}
// Re-export everything except Web-only storages // Re-export everything except Web-only storages
export { export {
AdbWebCryptoCredentialStore, AdbWebCryptoCredentialStore,

View file

@ -1,3 +1,4 @@
import type { MaybeError } from "@yume-chan/adb";
import { encodeUtf8 } from "@yume-chan/adb"; import { encodeUtf8 } from "@yume-chan/adb";
import type { MaybePromiseLike } from "@yume-chan/async"; import type { MaybePromiseLike } from "@yume-chan/async";
import { import {
@ -103,22 +104,26 @@ export class TangoPasswordProtectedStorage implements TangoKeyStorage {
// * `data` is owned by caller and will be cleared by caller // * `data` is owned by caller and will be cleared by caller
} }
async *load(): AsyncGenerator<TangoKey, void, void> { async *load(): AsyncGenerator<MaybeError<TangoKey>, void, void> {
for await (const { for await (const result of this.#storage.load()) {
privateKey: serialized, if (result instanceof Error) {
name, yield result;
} of this.#storage.load()) { continue;
const bundle = Bundle.deserialize( }
new Uint8ArrayExactReadable(serialized),
);
const password = await this.#requestPassword("load"); const { privateKey: serialized, name } = result;
const { aesKey } = await deriveAesKey(
password,
bundle.pbkdf2Salt as Uint8Array<ArrayBuffer>,
);
try { try {
const bundle = Bundle.deserialize(
new Uint8ArrayExactReadable(serialized),
);
const password = await this.#requestPassword("load");
const { aesKey } = await deriveAesKey(
password,
bundle.pbkdf2Salt as Uint8Array<ArrayBuffer>,
);
const decrypted = await crypto.subtle.decrypt( const decrypted = await crypto.subtle.decrypt(
{ {
name: "AES-GCM", name: "AES-GCM",
@ -128,22 +133,27 @@ export class TangoPasswordProtectedStorage implements TangoKeyStorage {
bundle.encrypted as Uint8Array<ArrayBuffer>, bundle.encrypted as Uint8Array<ArrayBuffer>,
); );
yield { try {
privateKey: new Uint8Array(decrypted), yield {
name, privateKey: new Uint8Array(decrypted),
}; name,
};
// Clear secret memory } finally {
// * No way to clear `password` and `aesKey` // Clear secret memory
// * all values in `bundle` are not secrets // * No way to clear `password` and `aesKey`
// * Caller is not allowed to use `decrypted` after `yield` returns // * all values in `bundle` are not secrets
new Uint8Array(decrypted).fill(0); // * Caller is not allowed to use `decrypted` after `yield` returns
new Uint8Array(decrypted).fill(0);
}
} catch (e) { } catch (e) {
if (e instanceof DOMException && e.name === "OperationError") { if (e instanceof DOMException && e.name === "OperationError") {
throw new PasswordIncorrectError(); yield new PasswordIncorrectError();
continue;
} }
throw e; yield e instanceof Error
? e
: new Error(String(e), { cause: e });
} }
} }
} }

View file

@ -1,3 +1,4 @@
import type { MaybeError } from "@yume-chan/adb";
import { import {
buffer, buffer,
struct, struct,
@ -136,49 +137,59 @@ export class TangoPrfStorage implements TangoKeyStorage {
await this.#storage.save(bundle, name); await this.#storage.save(bundle, name);
} }
async *load(): AsyncGenerator<TangoKey, void, void> { async *load(): AsyncGenerator<MaybeError<TangoKey>, void, void> {
for await (const { for await (const result of this.#storage.load()) {
privateKey: serialized, if (result instanceof Error) {
name, yield result;
} of this.#storage.load()) { continue;
const bundle = Bundle.deserialize(
new Uint8ArrayExactReadable(serialized),
);
const prfOutput = await this.#source.get(
bundle.id as Uint8Array<ArrayBuffer>,
bundle.prfInput as Uint8Array<ArrayBuffer>,
);
this.#prevId = bundle.id as Uint8Array<ArrayBuffer>;
let aesKey: CryptoKey;
try {
aesKey = await deriveAesKey(
prfOutput,
bundle.hkdfInfo as Uint8Array<ArrayBuffer>,
bundle.hkdfSalt as Uint8Array<ArrayBuffer>,
);
} finally {
// Clear secret memory
toUint8Array(prfOutput).fill(0);
} }
const decrypted = await crypto.subtle.decrypt( const { privateKey: serialized, name } = result;
{
name: "AES-GCM",
iv: bundle.aesIv as Uint8Array<ArrayBuffer>,
},
aesKey,
bundle.encrypted as Uint8Array<ArrayBuffer>,
);
try { try {
yield { privateKey: new Uint8Array(decrypted), name }; const bundle = Bundle.deserialize(
} finally { new Uint8ArrayExactReadable(serialized),
// Clear secret memory );
// Caller is not allowed to use `decrypted` after `yield` returns
new Uint8Array(decrypted).fill(0); const prfOutput = await this.#source.get(
bundle.id as Uint8Array<ArrayBuffer>,
bundle.prfInput as Uint8Array<ArrayBuffer>,
);
this.#prevId = bundle.id as Uint8Array<ArrayBuffer>;
let aesKey: CryptoKey;
try {
aesKey = await deriveAesKey(
prfOutput,
bundle.hkdfInfo as Uint8Array<ArrayBuffer>,
bundle.hkdfSalt as Uint8Array<ArrayBuffer>,
);
} finally {
// Clear secret memory
toUint8Array(prfOutput).fill(0);
}
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: bundle.aesIv as Uint8Array<ArrayBuffer>,
},
aesKey,
bundle.encrypted as Uint8Array<ArrayBuffer>,
);
try {
yield { privateKey: new Uint8Array(decrypted), name };
} finally {
// Clear secret memory
// Caller is not allowed to use `decrypted` after `yield` returns
new Uint8Array(decrypted).fill(0);
}
} catch (e) {
yield e instanceof Error
? e
: new Error(String(e), { cause: e });
} }
} }
} }

View file

@ -1,3 +1,4 @@
import type { MaybeError } from "@yume-chan/adb";
import type { MaybePromiseLike } from "@yume-chan/async"; import type { MaybePromiseLike } from "@yume-chan/async";
export interface TangoKey { export interface TangoKey {
@ -11,5 +12,7 @@ export interface TangoKeyStorage {
name: string | undefined, name: string | undefined,
): MaybePromiseLike<undefined>; ): MaybePromiseLike<undefined>;
load(): Iterable<TangoKey> | AsyncIterable<TangoKey>; load():
| Iterable<MaybeError<TangoKey>>
| AsyncIterable<MaybeError<TangoKey>>;
} }

View file

@ -1,4 +1,8 @@
import type { AdbCredentialStore, AdbPrivateKey } from "@yume-chan/adb"; import type {
AdbCredentialStore,
AdbPrivateKey,
MaybeError,
} from "@yume-chan/adb";
import { rsaParsePrivateKey } from "@yume-chan/adb"; import { rsaParsePrivateKey } from "@yume-chan/adb";
import type { TangoKeyStorage } from "./storage/index.js"; import type { TangoKeyStorage } from "./storage/index.js";
@ -51,13 +55,28 @@ export class AdbWebCryptoCredentialStore implements AdbCredentialStore {
}; };
} }
async *iterateKeys(): AsyncGenerator<AdbPrivateKey, void, void> { async *iterateKeys(): AsyncGenerator<
for await (const key of this.#storage.load()) { MaybeError<AdbPrivateKey>,
// `privateKey` is owned by `#storage` and will be cleared by it void,
yield { void
...rsaParsePrivateKey(key.privateKey), > {
name: key.name ?? this.#name, for await (const result of this.#storage.load()) {
}; if (result instanceof Error) {
yield result;
continue;
}
try {
// `privateKey` is owned by `#storage` and will be cleared by it
yield {
...rsaParsePrivateKey(result.privateKey),
name: result.name ?? this.#name,
};
} catch (e) {
yield e instanceof Error
? e
: new Error(String(e), { cause: e });
}
} }
} }
} }

View file

@ -6,6 +6,7 @@ import {
calculateBase64EncodedLength, calculateBase64EncodedLength,
encodeBase64, encodeBase64,
encodeUtf8, encodeUtf8,
md5Digest,
} from "../utils/index.js"; } from "../utils/index.js";
import type { SimpleRsaPrivateKey } from "./crypto.js"; import type { SimpleRsaPrivateKey } from "./crypto.js";
@ -21,9 +22,11 @@ export interface AdbPrivateKey extends SimpleRsaPrivateKey {
name?: string | undefined; name?: string | undefined;
} }
export type MaybeError<T> = T | Error;
export type AdbKeyIterable = export type AdbKeyIterable =
| Iterable<AdbPrivateKey> | Iterable<MaybeError<AdbPrivateKey>>
| AsyncIterable<AdbPrivateKey>; | AsyncIterable<MaybeError<AdbPrivateKey>>;
export interface AdbCredentialStore { export interface AdbCredentialStore {
/** /**
@ -39,6 +42,20 @@ export interface AdbCredentialStore {
iterateKeys(): AdbKeyIterable; iterateKeys(): AdbKeyIterable;
} }
// https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/services/core/java/com/android/server/adb/AdbDebuggingManager.java;l=1419;drc=61197364367c9e404c7da6900658f1b16c42d0da
function getFingerprint(key: AdbPrivateKey) {
const publicKey = adbGeneratePublicKey(key);
const md5 = md5Digest(publicKey);
return Array.from(md5, (byte) => byte.toString(16).padStart(2, "0")).join(
":",
);
}
export interface AdbKeyInfo {
fingerprint: string;
name: string | undefined;
}
export const AdbAuthType = { export const AdbAuthType = {
Token: 1, Token: 1,
Signature: 2, Signature: 2,
@ -56,12 +73,29 @@ export interface AdbAuthenticator {
export class AdbDefaultAuthenticator implements AdbAuthenticator { export class AdbDefaultAuthenticator implements AdbAuthenticator {
#credentialStore: AdbCredentialStore; #credentialStore: AdbCredentialStore;
#iterator: #iterator:
| Iterator<AdbPrivateKey, void, void> | Iterator<MaybeError<AdbPrivateKey>, void, void>
| AsyncIterator<AdbPrivateKey, void, void> | AsyncIterator<MaybeError<AdbPrivateKey>, void, void>
| undefined; | undefined;
#prevKeyInfo: AdbKeyInfo | undefined;
#firstKey: AdbPrivateKey | undefined; #firstKey: AdbPrivateKey | undefined;
#onPublicKeyAuthentication = new EventEmitter<void>(); #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() { get onPublicKeyAuthentication() {
return this.#onPublicKeyAuthentication.event; return this.#onPublicKeyAuthentication.event;
} }
@ -70,11 +104,7 @@ export class AdbDefaultAuthenticator implements AdbAuthenticator {
this.#credentialStore = credentialStore; this.#credentialStore = credentialStore;
} }
async authenticate(packet: AdbPacketData): Promise<AdbPacketData> { async #iterate(token: Uint8Array): Promise<AdbPacketData | undefined> {
if (packet.arg0 !== AdbAuthType.Token) {
throw new Error("Unsupported authentication packet");
}
if (!this.#iterator) { if (!this.#iterator) {
const iterable = this.#credentialStore.iterateKeys(); const iterable = this.#credentialStore.iterateKeys();
if (Symbol.iterator in iterable) { if (Symbol.iterator in iterable) {
@ -86,21 +116,46 @@ export class AdbDefaultAuthenticator implements AdbAuthenticator {
} }
} }
const { done, value } = await this.#iterator.next(); const { done, value: result } = await this.#iterator.next();
if (!done) { if (done) {
if (!this.#firstKey) { return undefined;
this.#firstKey = value;
}
return {
command: AdbCommand.Auth,
arg0: AdbAuthType.Signature,
arg1: 0,
payload: rsaSign(value, packet.payload),
};
} }
this.#onPublicKeyAuthentication.fire(); if (result instanceof Error) {
this.#onKeyLoadError.fire(result);
return await this.#iterate(token);
}
if (!this.#firstKey) {
this.#firstKey = result;
}
// A new token implies the previous signature was rejected.
if (this.#prevKeyInfo) {
this.#onSignatureRejected.fire(this.#prevKeyInfo);
}
const fingerprint = getFingerprint(result);
this.#prevKeyInfo = { fingerprint, name: result.name };
this.#onSignatureAuthentication.fire(this.#prevKeyInfo);
return {
command: AdbCommand.Auth,
arg0: AdbAuthType.Signature,
arg1: 0,
payload: rsaSign(result, token),
};
}
async authenticate(packet: AdbPacketData): Promise<AdbPacketData> {
if (packet.arg0 !== AdbAuthType.Token) {
throw new Error("Unsupported authentication packet");
}
const signature = await this.#iterate(packet.payload);
if (signature) {
return signature;
}
let key = this.#firstKey; let key = this.#firstKey;
if (!key) { if (!key) {
@ -131,6 +186,11 @@ export class AdbDefaultAuthenticator implements AdbAuthenticator {
publicKeyBuffer.set(nameBuffer, publicKeyBase64Length + 1); publicKeyBuffer.set(nameBuffer, publicKeyBase64Length + 1);
} }
this.#onPublicKeyAuthentication.fire({
fingerprint: getFingerprint(key),
name: key.name,
});
return { return {
command: AdbCommand.Auth, command: AdbCommand.Auth,
arg0: AdbAuthType.PublicKey, arg0: AdbAuthType.PublicKey,

View file

@ -16,7 +16,11 @@ import type {
import { AdbBanner } from "../banner.js"; import { AdbBanner } from "../banner.js";
import { AdbDeviceFeatures, AdbFeature } from "../features.js"; import { AdbDeviceFeatures, AdbFeature } from "../features.js";
import type { AdbAuthenticator, AdbCredentialStore } from "./auth.js"; import type {
AdbAuthenticator,
AdbCredentialStore,
AdbKeyInfo,
} from "./auth.js";
import { AdbDefaultAuthenticator } from "./auth.js"; import { AdbDefaultAuthenticator } from "./auth.js";
import { AdbPacketDispatcher } from "./dispatcher.js"; import { AdbPacketDispatcher } from "./dispatcher.js";
import type { AdbPacketData, AdbPacketInit } from "./packet.js"; import type { AdbPacketData, AdbPacketInit } from "./packet.js";
@ -138,7 +142,14 @@ export class AdbDaemonTransport implements AdbTransport {
| { authenticator: AdbAuthenticator } | { authenticator: AdbAuthenticator }
| { | {
credentialStore: AdbCredentialStore; credentialStore: AdbCredentialStore;
onPublicKeyAuthentication?: (() => void) | undefined; onKeyLoadError?: ((error: Error) => void) | undefined;
onSignatureAuthentication?:
| ((key: AdbKeyInfo) => void)
| undefined;
onSignatureRejected?: ((key: AdbKeyInfo) => void) | undefined;
onPublicKeyAuthentication?:
| ((key: AdbKeyInfo) => void)
| undefined;
} }
)): Promise<AdbDaemonTransport> { )): Promise<AdbDaemonTransport> {
// Initially, set to highest-supported version and payload size. // Initially, set to highest-supported version and payload size.
@ -151,14 +162,28 @@ export class AdbDaemonTransport implements AdbTransport {
if ("authenticator" in options) { if ("authenticator" in options) {
authenticator = options.authenticator; authenticator = options.authenticator;
} else { } else {
authenticator = new AdbDefaultAuthenticator( const defaultAuthenticator = new AdbDefaultAuthenticator(
options.credentialStore, options.credentialStore,
); );
if (options.onPublicKeyAuthentication) { if (options.onKeyLoadError) {
( defaultAuthenticator.onKeyLoadError(options.onKeyLoadError);
authenticator as AdbDefaultAuthenticator
).onPublicKeyAuthentication(options.onPublicKeyAuthentication);
} }
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`, // Here is similar to `AdbPacketDispatcher`,

View file

@ -3,6 +3,7 @@ export * from "./array-buffer.js";
export * from "./auto-reset-event.js"; export * from "./auto-reset-event.js";
export * from "./base64.js"; export * from "./base64.js";
export * from "./hex.js"; export * from "./hex.js";
export * from "./md5.js";
export * from "./no-op.js"; export * from "./no-op.js";
export * from "./ref.js"; export * from "./ref.js";
export * from "./sequence-equal.js"; export * from "./sequence-equal.js";

View file

@ -0,0 +1,135 @@
import assert from "assert";
import { describe, it } from "node:test";
import { encodeUtf8 } from "@yume-chan/struct";
import { Md5, md5Digest } from "./md5.js";
describe("md5", () => {
it("should digest empty string", () => {
const expected = "d41d8cd98f00b204e9800998ecf8427e";
const actual = md5Digest(encodeUtf8(""));
assert.deepStrictEqual(
Buffer.from(actual),
Buffer.from(expected, "hex"),
);
});
it("should digest 'abc'", () => {
const expected = "900150983cd24fb0d6963f7d28e17f72";
const actual = md5Digest(encodeUtf8("abc"));
assert.deepStrictEqual(
Buffer.from(actual),
Buffer.from(expected, "hex"),
);
});
it("should digest 'The quick brown fox jumps over the lazy dog'", () => {
const expected = "9e107d9d372bb6826bd81d3542a419d6";
const actual = md5Digest(
encodeUtf8("The quick brown fox jumps over the lazy dog"),
);
assert.deepStrictEqual(
Buffer.from(actual),
Buffer.from(expected, "hex"),
);
});
it("should digest 'c'\u00e8'", () => {
const expected = "8ef7c2941d78fe89f31e614437c9db59";
const actual = md5Digest(encodeUtf8("c'\u00e8"));
assert.deepStrictEqual(
Buffer.from(actual),
Buffer.from(expected, "hex"),
);
});
it("should digest 'THIS IS A MESSAGE'", () => {
const expected = "78eebfd9d42958e3f31244f116ab7bbe";
const md5 = new Md5();
md5.update(encodeUtf8("THIS IS "));
md5.update(encodeUtf8("A MESSAGE"));
const actual = md5.digest();
assert.deepStrictEqual(
Buffer.from(actual),
Buffer.from(expected, "hex"),
);
});
it("should digest a long message", () => {
const input = Buffer.from(
"0100002903018d32e9c6dc423774c4c39a5a1b78f44cc2cab5f676d39" +
"f703d29bfa27dfeb870000002002f01000200004603014c2c1e835d39" +
"da71bc0857eb04c2b50fe90dbb2a8477fe7364598d6f0575999c20a6c" +
"7248c5174da6d03ac711888f762fc4ed54f7254b32273690de849c843" +
"073d002f000b0003d20003cf0003cc308203c8308202b0a0030201020" +
"20100300d06092a864886f70d0101050500308186310b300906035504" +
"0613025553311d301b060355040a13144469676974616c2042617a616" +
"1722c20496e632e31443042060355040b133b4269746d756e6b206c6f" +
"63616c686f73742d6f6e6c7920436572746966696361746573202d204" +
"17574686f72697a6174696f6e20766961204254503112301006035504" +
"0313096c6f63616c686f7374301e170d3130303231343137303931395" +
"a170d3230303231333137303931395a308186310b3009060355040613" +
"025553311d301b060355040a13144469676974616c2042617a6161722" +
"c20496e632e31443042060355040b133b4269746d756e6b206c6f6361" +
"6c686f73742d6f6e6c7920436572746966696361746573202d2041757" +
"4686f72697a6174696f6e207669612042545031123010060355040313" +
"096c6f63616c686f737430820122300d06092a864886f70d010101050" +
"00382010f003082010a0282010100dc436f17d6909d8a9d6186ea218e" +
"b5c86b848bae02219bd56a71203daf07e81bc19e7e98134136bcb0128" +
"81864bf03b3774652ad5eab85dba411a5114ffeac09babce75f313143" +
"45512cd87c91318b2e77433270a52185fc16f428c3ca412ad6e9484bc" +
"2fb87abb4e8fb71bf0f619e31a42340b35967f06c24a741a31c979c0b" +
"b8921a90a47025fbeb8adca576979e70a56830c61170c9647c18c0794" +
"d68c0df38f3aac5fc3b530e016ea5659715339f3f3c209cdee9dbe794" +
"b5af92530c5754c1d874b78974bfad994e0dfc582275e79feb522f6e4" +
"bcc2b2945baedfb0dbdaebb605f9483ff0bea29ecd5f4d6f2769965d1" +
"b3e04f8422716042680011ff676f0203010001a33f303d300c0603551" +
"d130101ff04023000300e0603551d0f0101ff0404030204f0301d0603" +
"551d250416301406082b0601050507030106082b06010505070302300" +
"d06092a864886f70d010105050003820101009c4562be3f2d8d8e3880" +
"85a697f2f106eaeff4992a43f198fe3dcf15c8229cf1043f061a38204" +
"f73d86f4fb6348048cc5279ed719873aa10e3773d92b629c2c3fcce04" +
"012c81ba3b4ec451e9644ec5191078402d845e05d02c7b4d974b45882" +
"76e5037aba7ef26a8bddeb21e10698c82f425e767dc401adf722fa73a" +
"b78cfa069bd69052d7ca6a75cc9225550e315d71c5f8764362ea4dbc6" +
"ecb837a8471043c5a7f826a71af145a053090bd4bccca6a2c552841cd" +
"b1908a8352f49283d2e641acdef667c7543af441a16f8294251e2ac37" +
"6fa507b53ae418dd038cd20cef1e7bfbf5ae03a7c88d93d843abaabbd" +
"c5f3431132f3e559d2dd414c3eda38a210b80e0000001000010201002" +
"6a220b7be857402819b78d81080d01a682599bbd00902985cc64edf8e" +
"520e4111eb0e1729a14ffa3498ca259cc9ad6fc78fa130d968ebdb78d" +
"c0b950c0aa44355f13ba678419185d7e4608fe178ca6b2cef33e41937" +
"78d1a70fe4d0dfcb110be4bbb4dbaa712177655728f914ab4c0f6c4ae" +
"f79a46b3d996c82b2ebe9ed1748eb5cace7dc44fb67e73f452a047f2e" +
"d199b3d50d5db960acf03244dc8efa4fc129faf8b65f9e52e62b55447" +
"22bd17d2358e817a777618a4265a3db277fc04851a82a91fe6cdcb812" +
"7f156e0b4a5d1f54ce2742eb70c895f5f8b85f5febe69bc73e891f928" +
"0826860a0c2ef94c7935e6215c3c4cd6b0e43e80cca396d913d36be",
"hex",
);
const expected = "d15a2da0e92c3da55dc573f885b6e653";
const md5 = new Md5();
md5.update(input);
const actual = md5.digest();
assert.deepStrictEqual(
Buffer.from(actual),
Buffer.from(expected, "hex"),
);
});
it("should digest multiple long messages", () => {
// Note: might be too slow on old browsers
// done multiple times to check hot loop optimizations
for (let loop = 0; loop < 3; loop += 1) {
const md5 = new Md5();
for (let i = 0; i < 10000; i += 1) {
md5.update(encodeUtf8("abc"));
}
assert.deepStrictEqual(
Buffer.from(md5.digest()),
Buffer.from("b3e98306e7367f93cd7cb870af64f7b7", "hex"),
);
}
});
});

View file

@ -0,0 +1,185 @@
import {
getUint32LittleEndian,
setUint32LittleEndian,
setUint64LittleEndian,
} from "@yume-chan/no-data-view";
// Taken from https://github.com/digitalbazaar/forge/blob/e3c68e9695607702587583cda291d74e5369f21c/tests/unit/md5.js#L103
// LICENSE: https://github.com/digitalbazaar/forge/blob/2c37d0bd2864199409edbb520f674d1c93652b23/LICENSE
// g values
// prettier-ignore
const gs = [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
1, 6, 11, 0, 5, 10, 15, 4, 9, 14, 3, 8, 13, 2, 7, 12,
5, 8, 11, 14, 1, 4, 7, 10, 13, 0, 3, 6, 9, 12, 15, 2,
0, 7, 14, 5, 12, 3, 10, 1, 8, 15, 6, 13, 4, 11, 2, 9];
// rounds table
// prettier-ignore
const rs = [
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21];
export class Md5 {
static #k = new Uint32Array(64);
static {
for (let i = 0; i < 64; i += 1) {
// get the result of abs(sin(i + 1)) as a 32-bit integer
Md5.#k[i] = Math.floor(Math.abs(Math.sin(i + 1)) * 0x100000000);
}
}
#state = new Uint32Array([0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476]);
#length = 0n;
#buffer = new Uint8Array(64);
#bufferLength = 0;
#w = new Uint32Array(16);
update(input: Uint8Array) {
this.#length += BigInt(input.length);
let offset = 0;
if (this.#bufferLength) {
const remaining = 64 - this.#bufferLength;
this.#buffer.set(input.subarray(0, remaining), this.#bufferLength);
if (input.length < remaining) {
this.#bufferLength += input.length;
return this;
}
this.#update(this.#buffer);
this.#bufferLength = 0;
offset = remaining;
}
const end = input.length - 64;
for (; offset <= end; offset += 64) {
this.#update(input, offset);
}
if (offset < input.length) {
this.#buffer.set(input.subarray(offset));
this.#bufferLength = input.length - offset;
}
return this;
}
#update(input: Uint8Array, offset = 0) {
let a = this.#state[0]!;
let b = this.#state[1]!;
let c = this.#state[2]!;
let d = this.#state[3]!;
let t = 0;
let f = 0;
let r = 0;
let i = 0;
// round 1
for (; i < 16; i += 1) {
this.#w[i] = getUint32LittleEndian(input, offset + i * 4);
f = d ^ (b & (c ^ d));
t = a + f + Md5.#k[i]! + this.#w[i]!;
r = rs[i]!;
a = d;
d = c;
c = b;
b += (t << r) | (t >>> (32 - r));
}
// round 2
for (; i < 32; i += 1) {
f = c ^ (d & (b ^ c));
t = a + f + Md5.#k[i]! + this.#w[gs[i]!]!;
r = rs[i]!;
a = d;
d = c;
c = b;
b += (t << r) | (t >>> (32 - r));
}
// round 3
for (; i < 48; i += 1) {
f = b ^ c ^ d;
t = a + f + Md5.#k[i]! + this.#w[gs[i]!]!;
r = rs[i]!;
a = d;
d = c;
c = b;
b += (t << r) | (t >>> (32 - r));
}
// round 4
for (; i < 64; i += 1) {
f = c ^ (b | ~d);
t = a + f + Md5.#k[i]! + this.#w[gs[i]!]!;
r = rs[i]!;
a = d;
d = c;
c = b;
b += (t << r) | (t >>> (32 - r));
}
this.#state[0]! += a;
this.#state[1]! += b;
this.#state[2]! += c;
this.#state[3]! += d;
}
digest() {
this.#buffer[this.#bufferLength] = 0x80;
this.#buffer.subarray(this.#bufferLength + 1).fill(0);
if (64 - this.#bufferLength < 8) {
this.#update(this.#buffer);
this.#buffer.fill(0);
this.#bufferLength = 0;
}
setUint64LittleEndian(
this.#buffer,
this.#buffer.length - 8,
this.#length << 3n,
);
this.#update(this.#buffer);
const result = new Uint8Array(16);
setUint32LittleEndian(result, 0, this.#state[0]!);
setUint32LittleEndian(result, 4, this.#state[1]!);
setUint32LittleEndian(result, 8, this.#state[2]!);
setUint32LittleEndian(result, 12, this.#state[3]!);
return result;
}
reset() {
this.#state[0] = 0x67452301;
this.#state[1] = 0xefcdab89;
this.#state[2] = 0x98badcfe;
this.#state[3] = 0x10325476;
this.#bufferLength = 0;
this.#length = 0n;
return this;
}
}
let instance: Md5 | undefined;
export function md5Digest(input: Uint8Array) {
if (!instance) {
instance = new Md5();
}
const result = instance.update(input).digest();
instance.reset();
return result;
}