diff --git a/.changeset/wet-trams-retire.md b/.changeset/wet-trams-retire.md new file mode 100644 index 00000000..4833043b --- /dev/null +++ b/.changeset/wet-trams-retire.md @@ -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 diff --git a/libraries/adb-credential-nodejs/src/index.ts b/libraries/adb-credential-nodejs/src/index.ts index 09e3a8b1..4c279d5d 100644 --- a/libraries/adb-credential-nodejs/src/index.ts +++ b/libraries/adb-credential-nodejs/src/index.ts @@ -12,6 +12,7 @@ import { import { homedir, hostname, userInfo } from "node:os"; import { resolve } from "node:path"; +import type { MaybeError } from "@yume-chan/adb"; import { adbGeneratePublicKey, decodeBase64, @@ -22,12 +23,6 @@ import { import type { TangoKey, TangoKeyStorage } from "@yume-chan/adb-credential-web"; export class TangoNodeStorage implements TangoKeyStorage { - #logger: ((message: string) => void) | undefined; - - constructor(logger: ((message: string) => void) | undefined) { - this.#logger = logger; - } - async #getAndroidDirPath() { const dir = resolve(homedir(), ".android"); await mkdir(dir, { mode: 0o750, recursive: true }); @@ -108,14 +103,20 @@ export class TangoNodeStorage implements TangoKeyStorage { return { privateKey, name }; } - async *#readVendorKeys(path: string) { + async *#readVendorKeys( + path: string, + ): AsyncGenerator, void, void> { const stats = await stat(path); if (stats.isFile()) { try { yield await this.#readKey(path); } catch (e) { - this.#logger?.(String(e)); + if (e instanceof Error) { + yield e; + } else { + yield new Error(String(e)); + } } return; } @@ -133,16 +134,28 @@ export class TangoNodeStorage implements TangoKeyStorage { try { yield await this.#readKey(resolve(path, dirent.name)); } catch (e) { - this.#logger?.(String(e)); + if (e instanceof Error) { + yield e; + } else { + yield new Error(String(e)); + } } } } } - async *load(): AsyncGenerator { + async *load(): AsyncGenerator, void, void> { const userKeyPath = await this.#getUserKeyPath(); if (existsSync(userKeyPath)) { - yield await this.#readKey(userKeyPath); + try { + yield await this.#readKey(userKeyPath); + } catch (e) { + if (e instanceof Error) { + yield e; + } else { + yield new Error(String(e)); + } + } } const vendorKeys = process.env.ADB_VENDOR_KEYS; diff --git a/libraries/adb-credential-web/src/storage/password.ts b/libraries/adb-credential-web/src/storage/password.ts index e4a3e407..11a51e1c 100644 --- a/libraries/adb-credential-web/src/storage/password.ts +++ b/libraries/adb-credential-web/src/storage/password.ts @@ -1,3 +1,4 @@ +import type { MaybeError } from "@yume-chan/adb"; import { encodeUtf8 } from "@yume-chan/adb"; import type { MaybePromiseLike } from "@yume-chan/async"; import { @@ -103,22 +104,26 @@ export class TangoPasswordProtectedStorage implements TangoKeyStorage { // * `data` is owned by caller and will be cleared by caller } - async *load(): AsyncGenerator { - for await (const { - privateKey: serialized, - name, - } of this.#storage.load()) { - const bundle = Bundle.deserialize( - new Uint8ArrayExactReadable(serialized), - ); + async *load(): AsyncGenerator, void, void> { + for await (const result of this.#storage.load()) { + if (result instanceof Error) { + yield result; + continue; + } - const password = await this.#requestPassword("load"); - const { aesKey } = await deriveAesKey( - password, - bundle.pbkdf2Salt as Uint8Array, - ); + const { privateKey: serialized, name } = result; try { + const bundle = Bundle.deserialize( + new Uint8ArrayExactReadable(serialized), + ); + + const password = await this.#requestPassword("load"); + const { aesKey } = await deriveAesKey( + password, + bundle.pbkdf2Salt as Uint8Array, + ); + const decrypted = await crypto.subtle.decrypt( { name: "AES-GCM", @@ -140,10 +145,16 @@ export class TangoPasswordProtectedStorage implements TangoKeyStorage { new Uint8Array(decrypted).fill(0); } catch (e) { if (e instanceof DOMException && e.name === "OperationError") { - throw new PasswordIncorrectError(); + yield new PasswordIncorrectError(); + continue; } - throw e; + if (e instanceof Error) { + yield e; + continue; + } + + yield new Error(String(e)); } } } diff --git a/libraries/adb-credential-web/src/storage/prf/storage.ts b/libraries/adb-credential-web/src/storage/prf/storage.ts index 17cac106..e4dce504 100644 --- a/libraries/adb-credential-web/src/storage/prf/storage.ts +++ b/libraries/adb-credential-web/src/storage/prf/storage.ts @@ -1,3 +1,4 @@ +import type { MaybeError } from "@yume-chan/adb"; import { buffer, struct, @@ -136,49 +137,61 @@ export class TangoPrfStorage implements TangoKeyStorage { await this.#storage.save(bundle, name); } - async *load(): AsyncGenerator { - for await (const { - privateKey: serialized, - name, - } of this.#storage.load()) { - const bundle = Bundle.deserialize( - new Uint8ArrayExactReadable(serialized), - ); - - const prfOutput = await this.#source.get( - bundle.id as Uint8Array, - bundle.prfInput as Uint8Array, - ); - - this.#prevId = bundle.id as Uint8Array; - - let aesKey: CryptoKey; - try { - aesKey = await deriveAesKey( - prfOutput, - bundle.hkdfInfo as Uint8Array, - bundle.hkdfSalt as Uint8Array, - ); - } finally { - // Clear secret memory - toUint8Array(prfOutput).fill(0); + async *load(): AsyncGenerator, void, void> { + for await (const result of this.#storage.load()) { + if (result instanceof Error) { + yield result; + continue; } - const decrypted = await crypto.subtle.decrypt( - { - name: "AES-GCM", - iv: bundle.aesIv as Uint8Array, - }, - aesKey, - bundle.encrypted as Uint8Array, - ); + const { privateKey: serialized, name } = result; 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); + const bundle = Bundle.deserialize( + new Uint8ArrayExactReadable(serialized), + ); + + const prfOutput = await this.#source.get( + bundle.id as Uint8Array, + bundle.prfInput as Uint8Array, + ); + + this.#prevId = bundle.id as Uint8Array; + + let aesKey: CryptoKey; + try { + aesKey = await deriveAesKey( + prfOutput, + bundle.hkdfInfo as Uint8Array, + bundle.hkdfSalt as Uint8Array, + ); + } finally { + // Clear secret memory + toUint8Array(prfOutput).fill(0); + } + + const decrypted = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: bundle.aesIv as Uint8Array, + }, + aesKey, + bundle.encrypted as Uint8Array, + ); + + 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) { + if (e instanceof Error) { + yield e; + } else { + yield new Error(String(e)); + } } } } diff --git a/libraries/adb-credential-web/src/storage/type.ts b/libraries/adb-credential-web/src/storage/type.ts index 87d5972e..9bb515d2 100644 --- a/libraries/adb-credential-web/src/storage/type.ts +++ b/libraries/adb-credential-web/src/storage/type.ts @@ -1,3 +1,4 @@ +import type { MaybeError } from "@yume-chan/adb"; import type { MaybePromiseLike } from "@yume-chan/async"; export interface TangoKey { @@ -11,5 +12,7 @@ export interface TangoKeyStorage { name: string | undefined, ): MaybePromiseLike; - load(): Iterable | AsyncIterable; + load(): + | Iterable> + | AsyncIterable>; } diff --git a/libraries/adb-credential-web/src/store.ts b/libraries/adb-credential-web/src/store.ts index 672f0ad1..76c7753d 100644 --- a/libraries/adb-credential-web/src/store.ts +++ b/libraries/adb-credential-web/src/store.ts @@ -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 type { TangoKeyStorage } from "./storage/index.js"; @@ -51,12 +55,21 @@ export class AdbWebCryptoCredentialStore implements AdbCredentialStore { }; } - async *iterateKeys(): AsyncGenerator { - for await (const key of this.#storage.load()) { + async *iterateKeys(): AsyncGenerator< + MaybeError, + void, + void + > { + for await (const result of this.#storage.load()) { + if (result instanceof Error) { + yield result; + continue; + } + // `privateKey` is owned by `#storage` and will be cleared by it yield { - ...rsaParsePrivateKey(key.privateKey), - name: key.name ?? this.#name, + ...rsaParsePrivateKey(result.privateKey), + name: result.name ?? this.#name, }; } } diff --git a/libraries/adb/src/daemon/auth.ts b/libraries/adb/src/daemon/auth.ts index 8bb2a1b5..5fefad43 100644 --- a/libraries/adb/src/daemon/auth.ts +++ b/libraries/adb/src/daemon/auth.ts @@ -6,6 +6,7 @@ import { calculateBase64EncodedLength, encodeBase64, encodeUtf8, + md5Digest, } from "../utils/index.js"; import type { SimpleRsaPrivateKey } from "./crypto.js"; @@ -21,9 +22,11 @@ export interface AdbPrivateKey extends SimpleRsaPrivateKey { name?: string | undefined; } +export type MaybeError = T | Error; + export type AdbKeyIterable = - | Iterable - | AsyncIterable; + | Iterable> + | AsyncIterable>; export interface AdbCredentialStore { /** @@ -39,6 +42,20 @@ export interface AdbCredentialStore { 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 = { Token: 1, Signature: 2, @@ -56,12 +73,29 @@ export interface AdbAuthenticator { export class AdbDefaultAuthenticator implements AdbAuthenticator { #credentialStore: AdbCredentialStore; #iterator: - | Iterator - | AsyncIterator + | Iterator, void, void> + | AsyncIterator, void, void> | undefined; + + #prevFingerprint: string | undefined; #firstKey: AdbPrivateKey | undefined; - #onPublicKeyAuthentication = new EventEmitter(); + #onKeyLoadError = new EventEmitter(); + get onKeyLoadError() { + return this.#onKeyLoadError.event; + } + + #onSignatureAuthentication = new EventEmitter(); + get onSignatureAuthentication() { + return this.#onSignatureAuthentication.event; + } + + #onSignatureRejected = new EventEmitter(); + get onSignatureRejected() { + return this.#onSignatureRejected.event; + } + + #onPublicKeyAuthentication = new EventEmitter(); get onPublicKeyAuthentication() { return this.#onPublicKeyAuthentication.event; } @@ -70,11 +104,7 @@ export class AdbDefaultAuthenticator implements AdbAuthenticator { this.#credentialStore = credentialStore; } - async authenticate(packet: AdbPacketData): Promise { - if (packet.arg0 !== AdbAuthType.Token) { - throw new Error("Unsupported authentication packet"); - } - + async #iterate(token: Uint8Array): Promise { if (!this.#iterator) { const iterable = this.#credentialStore.iterateKeys(); if (Symbol.iterator in iterable) { @@ -86,21 +116,51 @@ export class AdbDefaultAuthenticator implements AdbAuthenticator { } } - const { done, value } = await this.#iterator.next(); - if (!done) { - if (!this.#firstKey) { - this.#firstKey = value; - } - - return { - command: AdbCommand.Auth, - arg0: AdbAuthType.Signature, - arg1: 0, - payload: rsaSign(value, packet.payload), - }; + const { done, value: result } = await this.#iterator.next(); + if (done) { + return undefined; } - this.#onPublicKeyAuthentication.fire(); + if (result instanceof Error) { + this.#onKeyLoadError.fire(result); + return await this.#iterate(token); + } + + if (!this.#firstKey) { + this.#firstKey = result; + } + + if (this.#prevFingerprint) { + this.#onSignatureRejected.fire({ + fingerprint: this.#prevFingerprint, + name: result.name, + }); + } + + const fingerprint = getFingerprint(result); + this.#prevFingerprint = fingerprint; + this.#onSignatureAuthentication.fire({ + fingerprint, + name: result.name, + }); + + return { + command: AdbCommand.Auth, + arg0: AdbAuthType.Signature, + arg1: 0, + payload: rsaSign(result, token), + }; + } + + async authenticate(packet: AdbPacketData): Promise { + 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; if (!key) { @@ -131,6 +191,11 @@ export class AdbDefaultAuthenticator implements AdbAuthenticator { publicKeyBuffer.set(nameBuffer, publicKeyBase64Length + 1); } + this.#onPublicKeyAuthentication.fire({ + fingerprint: getFingerprint(key), + name: key.name, + }); + return { command: AdbCommand.Auth, arg0: AdbAuthType.PublicKey, diff --git a/libraries/adb/src/daemon/transport.ts b/libraries/adb/src/daemon/transport.ts index c660f1d9..ad0d108c 100644 --- a/libraries/adb/src/daemon/transport.ts +++ b/libraries/adb/src/daemon/transport.ts @@ -16,7 +16,11 @@ import type { import { AdbBanner } from "../banner.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 { AdbPacketDispatcher } from "./dispatcher.js"; import type { AdbPacketData, AdbPacketInit } from "./packet.js"; @@ -138,7 +142,14 @@ export class AdbDaemonTransport implements AdbTransport { | { authenticator: AdbAuthenticator } | { 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 { // Initially, set to highest-supported version and payload size. @@ -151,14 +162,28 @@ export class AdbDaemonTransport implements AdbTransport { if ("authenticator" in options) { authenticator = options.authenticator; } else { - authenticator = new AdbDefaultAuthenticator( + const defaultAuthenticator = new AdbDefaultAuthenticator( options.credentialStore, ); - if (options.onPublicKeyAuthentication) { - ( - authenticator as AdbDefaultAuthenticator - ).onPublicKeyAuthentication(options.onPublicKeyAuthentication); + 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`, diff --git a/libraries/adb/src/utils/index.ts b/libraries/adb/src/utils/index.ts index 1452939a..ce9a6090 100644 --- a/libraries/adb/src/utils/index.ts +++ b/libraries/adb/src/utils/index.ts @@ -3,6 +3,7 @@ export * from "./array-buffer.js"; export * from "./auto-reset-event.js"; export * from "./base64.js"; export * from "./hex.js"; +export * from "./md5.js"; export * from "./no-op.js"; export * from "./ref.js"; export * from "./sequence-equal.js"; diff --git a/libraries/adb/src/utils/md5.spec.ts b/libraries/adb/src/utils/md5.spec.ts new file mode 100644 index 00000000..f043ea0f --- /dev/null +++ b/libraries/adb/src/utils/md5.spec.ts @@ -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"), + ); + } + }); +}); diff --git a/libraries/adb/src/utils/md5.ts b/libraries/adb/src/utils/md5.ts new file mode 100644 index 00000000..ab496c10 --- /dev/null +++ b/libraries/adb/src/utils/md5.ts @@ -0,0 +1,181 @@ +import { + getUint32LittleEndian, + setUint32LittleEndian, +} 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 = 0; + + #buffer = new Uint8Array(64); + #bufferLength = 0; + + #w = new Uint32Array(16); + + update(input: Uint8Array) { + this.#length += 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; + } + + setUint32LittleEndian( + this.#buffer, + this.#buffer.length - 8, + this.#length << 3, + ); + this.#update(this.#buffer); + + const result = new Uint8Array(this.#state.buffer).slice(); + + return result; + } + + reset() { + this.#state[0] = 0x67452301; + this.#state[1] = 0xefcdab89; + this.#state[2] = 0x98badcfe; + this.#state[3] = 0x10325476; + this.#bufferLength = 0; + this.#length = 0; + 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; +}