diff --git a/.changeset/nice-bars-stay.md b/.changeset/nice-bars-stay.md new file mode 100644 index 00000000..2b58faf6 --- /dev/null +++ b/.changeset/nice-bars-stay.md @@ -0,0 +1,9 @@ +--- +"@yume-chan/adb-credential-web": major +--- + +Separate private key creation and storage. + +- Two built-in storages for Web platform: IndexedDB, LocalStorage +- Two encrypted storages (takes an inner storage to actually store the encrypted data): Password-protected, WebAuthn PRF extension. Encrypted storages can be chained for multi-factor authentication. +- File-based storage for Node.js: compatible with Google ADB diff --git a/libraries/adb-credential-nodejs/.npmignore b/libraries/adb-credential-nodejs/.npmignore new file mode 100644 index 00000000..e44e2e62 --- /dev/null +++ b/libraries/adb-credential-nodejs/.npmignore @@ -0,0 +1,16 @@ +.rush + +# Test +coverage +**/*.spec.ts +**/*.spec.js +**/*.spec.js.map +**/__helpers__ +jest.config.js + +.eslintrc.cjs +tsconfig.json +tsconfig.test.json + +# Logs +*.log diff --git a/libraries/adb-credential-nodejs/LICENSE b/libraries/adb-credential-nodejs/LICENSE new file mode 100644 index 00000000..248899ac --- /dev/null +++ b/libraries/adb-credential-nodejs/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-2025 Simon Chan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libraries/adb-credential-nodejs/README.md b/libraries/adb-credential-nodejs/README.md new file mode 100644 index 00000000..a255a634 --- /dev/null +++ b/libraries/adb-credential-nodejs/README.md @@ -0,0 +1,3 @@ +# @yume-chan/adb-credential-nodejs + +ADB credential store for Node.js diff --git a/libraries/adb-credential-nodejs/package.json b/libraries/adb-credential-nodejs/package.json new file mode 100644 index 00000000..a7b9804e --- /dev/null +++ b/libraries/adb-credential-nodejs/package.json @@ -0,0 +1,45 @@ +{ + "name": "@yume-chan/adb-credential-nodejs", + "version": "2.0.0", + "description": "ADB credential store for Node.js", + "keywords": [ + "typescript" + ], + "license": "MIT", + "author": { + "name": "Simon Chan", + "email": "cnsimonchan@live.com", + "url": "https://chensi.moe/blog" + }, + "homepage": "https://github.com/yume-chan/ya-webadb/tree/main/libraries/adb-credential-nodejs#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yume-chan/ya-webadb.git", + "directory": "libraries/adb-credential-nodejs" + }, + "bugs": { + "url": "https://github.com/yume-chan/ya-webadb/issues" + }, + "type": "module", + "main": "esm/index.js", + "types": "esm/index.d.ts", + "sideEffects": false, + "scripts": { + "build": "tsc -b tsconfig.build.json", + "lint": "run-eslint && prettier src/**/*.ts --write --tab-width 4", + "prepublishOnly": "npm run build", + "test": "run-test" + }, + "dependencies": { + "@yume-chan/adb": "workspace:^", + "@yume-chan/adb-credential-web": "workspace:^" + }, + "devDependencies": { + "@types/node": "^24.3.0", + "@yume-chan/eslint-config": "workspace:^", + "@yume-chan/test-runner": "workspace:^", + "@yume-chan/tsconfig": "workspace:^", + "prettier": "^3.6.2", + "typescript": "^5.9.2" + } +} diff --git a/libraries/adb-credential-nodejs/src/index.ts b/libraries/adb-credential-nodejs/src/index.ts new file mode 100644 index 00000000..09e3a8b1 --- /dev/null +++ b/libraries/adb-credential-nodejs/src/index.ts @@ -0,0 +1,168 @@ +// cspell: ignore adbkey + +import { existsSync } from "node:fs"; +import { + chmod, + mkdir, + opendir, + readFile, + stat, + writeFile, +} from "node:fs/promises"; +import { homedir, hostname, userInfo } from "node:os"; +import { resolve } from "node:path"; + +import { + adbGeneratePublicKey, + decodeBase64, + decodeUtf8, + encodeBase64, + rsaParsePrivateKey, +} from "@yume-chan/adb"; +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 }); + return dir; + } + + async #getUserKeyPath() { + return resolve(await this.#getAndroidDirPath(), "adbkey"); + } + + #getDefaultName() { + return userInfo().username + "@" + hostname(); + } + + async save( + privateKey: Uint8Array, + name: string | undefined, + ): Promise { + const userKeyPath = await this.#getUserKeyPath(); + + // Create PEM in Strict format + // https://datatracker.ietf.org/doc/html/rfc7468 + let pem = "-----BEGIN PRIVATE KEY-----\n"; + const base64 = decodeUtf8(encodeBase64(privateKey)); + for (let i = 0; i < base64.length; i += 64) { + pem += base64.substring(i, i + 64) + "\n"; + } + pem += "-----END PRIVATE KEY-----\n"; + await writeFile(userKeyPath, pem, { encoding: "utf8", mode: 0o600 }); + await chmod(userKeyPath, 0o600); + + name ??= this.#getDefaultName(); + const publicKey = adbGeneratePublicKey(rsaParsePrivateKey(privateKey)); + await writeFile( + userKeyPath + ".pub", + decodeUtf8(encodeBase64(publicKey)) + " " + name + "\n", + { encoding: "utf8", mode: 0o644 }, + ); + } + + async #readPrivateKey(path: string) { + try { + const pem = await readFile(path, "utf8"); + return decodeBase64( + pem + // Parse PEM in Lax format (allows spaces/line breaks everywhere) + // https://datatracker.ietf.org/doc/html/rfc7468 + .replaceAll(/-----(BEGIN|END) PRIVATE KEY-----/g, "") + .replaceAll(/\x20|\t|\r|\n|\v|\f/g, ""), + ); + } catch (e) { + throw new Error("Invalid private key file: " + path, { cause: e }); + } + } + + async #readPublicKeyName(path: string): Promise { + // Google ADB actually never reads the `.pub` file for name, + // it always returns the default name. + // So we won't throw an error if the file can't be read. + + try { + const publicKeyPath = path + ".pub"; + if (!(await stat(publicKeyPath)).isFile()) { + return undefined; + } + + const publicKey = await readFile(publicKeyPath, "utf8"); + return publicKey.split(" ")[1]?.trim(); + } catch { + return undefined; + } + } + + async #readKey(path: string): Promise { + const privateKey = await this.#readPrivateKey(path); + const name = + (await this.#readPublicKeyName(path)) ?? this.#getDefaultName(); + return { privateKey, name }; + } + + async *#readVendorKeys(path: string) { + const stats = await stat(path); + + if (stats.isFile()) { + try { + yield await this.#readKey(path); + } catch (e) { + this.#logger?.(String(e)); + } + return; + } + + if (stats.isDirectory()) { + for await (const dirent of await opendir(path)) { + if (!dirent.isFile()) { + continue; + } + + if (!dirent.name.endsWith(".adb_key")) { + continue; + } + + try { + yield await this.#readKey(resolve(path, dirent.name)); + } catch (e) { + this.#logger?.(String(e)); + } + } + } + } + + async *load(): AsyncGenerator { + const userKeyPath = await this.#getUserKeyPath(); + if (existsSync(userKeyPath)) { + yield await this.#readKey(userKeyPath); + } + + const vendorKeys = process.env.ADB_VENDOR_KEYS; + if (vendorKeys) { + const separator = process.platform === "win32" ? ";" : ":"; + for (const path of vendorKeys.split(separator)) { + yield* this.#readVendorKeys(path); + } + } + } +} + +// Re-export everything except Web-only storages +export { + AdbWebCryptoCredentialStore, + TangoPasswordProtectedStorage, + TangoPrfStorage, +} from "@yume-chan/adb-credential-web"; +export type { + TangoKey, + TangoKeyStorage, + TangoPrfSource, +} from "@yume-chan/adb-credential-web"; diff --git a/libraries/adb-credential-nodejs/tsconfig.build.json b/libraries/adb-credential-nodejs/tsconfig.build.json new file mode 100644 index 00000000..4f6977a6 --- /dev/null +++ b/libraries/adb-credential-nodejs/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./node_modules/@yume-chan/tsconfig/tsconfig.base.json", + "compilerOptions": { + "types": [ + "node" + ] + } +} diff --git a/libraries/adb-credential-nodejs/tsconfig.json b/libraries/adb-credential-nodejs/tsconfig.json new file mode 100644 index 00000000..a906ba1f --- /dev/null +++ b/libraries/adb-credential-nodejs/tsconfig.json @@ -0,0 +1,16 @@ +{ + "references": [ + { + "path": "./tsconfig.build.json" + }, + { + "path": "./tsconfig.test.json" + }, + { + "path": "../adb/tsconfig.build.json" + }, + { + "path": "../adb-credential-web/tsconfig.build.json" + }, + ] +} diff --git a/libraries/adb-credential-nodejs/tsconfig.test.json b/libraries/adb-credential-nodejs/tsconfig.test.json new file mode 100644 index 00000000..6a105912 --- /dev/null +++ b/libraries/adb-credential-nodejs/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "types": [ + "node" + ], + }, + "exclude": [] +} diff --git a/libraries/adb-credential-web/src/storage/indexed-db.ts b/libraries/adb-credential-web/src/storage/indexed-db.ts index c303319e..7f28caa0 100644 --- a/libraries/adb-credential-web/src/storage/indexed-db.ts +++ b/libraries/adb-credential-web/src/storage/indexed-db.ts @@ -1,28 +1,63 @@ -import type { TangoDataStorage } from "./type.js"; +import type { TangoKey, TangoKeyStorage } from "./type.js"; -function openDatabase() { - return new Promise((resolve, reject) => { - const request = indexedDB.open("Tango", 1); +const StoreName = "Authentication"; + +function waitRequest(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { request.onerror = () => { reject(request.error!); }; - request.onupgradeneeded = () => { - const db = request.result; - db.createObjectStore("Authentication", { autoIncrement: true }); - }; request.onsuccess = () => { - const db = request.result; - resolve(db); + resolve(request.result); }; }); } +async function openDatabase() { + const request = indexedDB.open("Tango", 1); + request.onupgradeneeded = () => { + const db = request.result; + db.createObjectStore(StoreName, { autoIncrement: true }); + }; + const db = await waitRequest(request); + + // Maintain compatibility with v2 (values are pure `Uint8Array`s) + // IndexedDB API doesn't support async upgrade transaction, + // so have to open with old version, read the data, close the database, + // then open with new version to trigger upgrade again + if (db.version === 1) { + const keys = await createTransaction(db, (tx) => + waitRequest( + tx.objectStore(StoreName).getAll() as IDBRequest, + ), + ); + + db.close(); + + const request = indexedDB.open("Tango", 2); + request.onupgradeneeded = () => { + const tx = request.transaction!; + const store = tx.objectStore(StoreName); + store.clear(); + for (const key of keys) { + store.add({ + privateKey: key, + name: undefined, + } satisfies TangoKey); + } + }; + return await waitRequest(request); + } + + return db; +} + function createTransaction( database: IDBDatabase, callback: (transaction: IDBTransaction) => T, ): Promise { return new Promise((resolve, reject) => { - const transaction = database.transaction("Authentication", "readwrite"); + const transaction = database.transaction(StoreName, "readwrite"); transaction.onerror = () => { reject(transaction.error!); }; @@ -37,35 +72,30 @@ function createTransaction( }); } -export class TangoIndexedDbStorage implements TangoDataStorage { - async save(data: Uint8Array): Promise { +export class TangoIndexedDbStorage implements TangoKeyStorage { + async save( + privateKey: Uint8Array, + name: string | undefined, + ): Promise { const db = await openDatabase(); try { await createTransaction(db, (tx) => { - const store = tx.objectStore("Authentication"); - store.add(data); + const store = tx.objectStore(StoreName); + store.add({ privateKey, name } satisfies TangoKey); }); } finally { db.close(); } } - async *load(): AsyncGenerator { + async *load(): AsyncGenerator { const db = await openDatabase(); try { const keys = await createTransaction(db, (tx) => { - return new Promise((resolve, reject) => { - const store = tx.objectStore("Authentication"); - const getRequest = store.getAll(); - getRequest.onerror = () => { - reject(getRequest.error!); - }; - getRequest.onsuccess = () => { - resolve(getRequest.result as Uint8Array[]); - }; - }); + const store = tx.objectStore(StoreName); + return waitRequest(store.getAll() as IDBRequest); }); yield* keys; @@ -79,7 +109,7 @@ export class TangoIndexedDbStorage implements TangoDataStorage { try { await createTransaction(db, (tx) => { - const store = tx.objectStore("Authentication"); + const store = tx.objectStore(StoreName); store.clear(); }); } finally { diff --git a/libraries/adb-credential-web/src/storage/local-storage.ts b/libraries/adb-credential-web/src/storage/local-storage.ts index 22d40679..da67f2f2 100644 --- a/libraries/adb-credential-web/src/storage/local-storage.ts +++ b/libraries/adb-credential-web/src/storage/local-storage.ts @@ -1,22 +1,37 @@ import { decodeBase64, decodeUtf8, encodeBase64 } from "@yume-chan/adb"; -import type { TangoDataStorage } from "./type.js"; +import type { TangoKey, TangoKeyStorage } from "./type.js"; -export class TangoLocalStorage implements TangoDataStorage { +type TangoKeyJson = { + [K in keyof TangoKey]: TangoKey[K] extends Uint8Array + ? string + : TangoKey[K]; +}; + +export class TangoLocalStorage implements TangoKeyStorage { readonly #storageKey: string; constructor(storageKey: string) { this.#storageKey = storageKey; } - save(data: Uint8Array): undefined { - localStorage.setItem(this.#storageKey, decodeUtf8(encodeBase64(data))); + save(privateKey: Uint8Array, name: string | undefined): undefined { + const json = JSON.stringify({ + privateKey: decodeUtf8(encodeBase64(privateKey)), + name, + } satisfies TangoKeyJson); + + localStorage.setItem(this.#storageKey, json); } - *load(): Generator { - const data = localStorage.getItem(this.#storageKey); - if (data) { - yield decodeBase64(data); + *load(): Generator { + const json = localStorage.getItem(this.#storageKey); + if (json) { + const { privateKey, name } = JSON.parse(json) as TangoKeyJson; + yield { + privateKey: decodeBase64(privateKey), + name, + }; } } } diff --git a/libraries/adb-credential-web/src/storage/password.ts b/libraries/adb-credential-web/src/storage/password.ts index 8a889908..e4a3e407 100644 --- a/libraries/adb-credential-web/src/storage/password.ts +++ b/libraries/adb-credential-web/src/storage/password.ts @@ -7,7 +7,7 @@ import { Uint8ArrayExactReadable, } from "@yume-chan/struct"; -import type { TangoDataStorage } from "./type.js"; +import type { TangoKey, TangoKeyStorage } from "./type.js"; const Pbkdf2SaltLength = 16; const Pbkdf2Iterations = 1_000_000; @@ -59,21 +59,24 @@ class PasswordIncorrectError extends Error { } } -export class TangoPasswordProtectedStorage implements TangoDataStorage { +export class TangoPasswordProtectedStorage implements TangoKeyStorage { static PasswordIncorrectError = PasswordIncorrectError; - readonly #storage: TangoDataStorage; + readonly #storage: TangoKeyStorage; readonly #requestPassword: TangoPasswordProtectedStorage.RequestPassword; constructor( - storage: TangoDataStorage, + storage: TangoKeyStorage, requestPassword: TangoPasswordProtectedStorage.RequestPassword, ) { this.#storage = storage; this.#requestPassword = requestPassword; } - async save(data: Uint8Array): Promise { + async save( + privateKey: Uint8Array, + name: string | undefined, + ): Promise { const password = await this.#requestPassword("save"); const { salt, aesKey } = await deriveAesKey(password); @@ -83,7 +86,7 @@ export class TangoPasswordProtectedStorage implements TangoDataStorage { const encrypted = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, aesKey, - data, + privateKey, ); const bundle = Bundle.serialize({ @@ -92,7 +95,7 @@ export class TangoPasswordProtectedStorage implements TangoDataStorage { encrypted: new Uint8Array(encrypted), }); - await this.#storage.save(bundle); + await this.#storage.save(bundle, name); // Clear secret memory // * No way to clear `password` and `aesKey` @@ -100,8 +103,11 @@ export class TangoPasswordProtectedStorage implements TangoDataStorage { // * `data` is owned by caller and will be cleared by caller } - async *load(): AsyncGenerator { - for await (const serialized of this.#storage.load()) { + async *load(): AsyncGenerator { + for await (const { + privateKey: serialized, + name, + } of this.#storage.load()) { const bundle = Bundle.deserialize( new Uint8ArrayExactReadable(serialized), ); @@ -122,7 +128,10 @@ export class TangoPasswordProtectedStorage implements TangoDataStorage { bundle.encrypted as Uint8Array, ); - yield new Uint8Array(decrypted); + yield { + privateKey: new Uint8Array(decrypted), + name, + }; // Clear secret memory // * No way to clear `password` and `aesKey` diff --git a/libraries/adb-credential-web/src/storage/prf/source.ts b/libraries/adb-credential-web/src/storage/prf/source.ts index dabcd81f..9482c326 100644 --- a/libraries/adb-credential-web/src/storage/prf/source.ts +++ b/libraries/adb-credential-web/src/storage/prf/source.ts @@ -1,11 +1,35 @@ -export interface TangoPrfSource { - create(input: Uint8Array): Promise<{ - output: BufferSource; - id: Uint8Array; - }>; +import type { MaybePromiseLike } from "@yume-chan/async"; +export interface TangoPrfCreationResult { + /** + * The generated PRF output + */ + output: BufferSource; + + /** + * ID of the created secret key + */ + id: Uint8Array; +} + +export interface TangoPrfSource { + /** + * Creates a new secret key and generate PRF output using the key and input data. + * + * @param input The input data + */ + create( + input: Uint8Array, + ): MaybePromiseLike; + + /** + * Generates PRF output using the secret key and input data. + * + * @param id ID of the secret key + * @param input The input data + */ get( id: BufferSource, input: Uint8Array, - ): Promise; + ): MaybePromiseLike; } diff --git a/libraries/adb-credential-web/src/storage/prf/storage.ts b/libraries/adb-credential-web/src/storage/prf/storage.ts index ec97a306..17cac106 100644 --- a/libraries/adb-credential-web/src/storage/prf/storage.ts +++ b/libraries/adb-credential-web/src/storage/prf/storage.ts @@ -5,7 +5,7 @@ import { Uint8ArrayExactReadable, } from "@yume-chan/struct"; -import type { TangoDataStorage } from "../type.js"; +import type { TangoKey, TangoKeyStorage } from "../type.js"; import type { TangoPrfSource } from "./source.js"; @@ -64,17 +64,29 @@ const Bundle = struct( { littleEndian: true }, ); -export class TangoPrfStorage implements TangoDataStorage { - readonly #storage: TangoDataStorage; +/** + * A `TangoDataStorage` that encrypts and decrypts data using PRF + */ +export class TangoPrfStorage implements TangoKeyStorage { + readonly #storage: TangoKeyStorage; readonly #source: TangoPrfSource; #prevId: Uint8Array | undefined; - constructor(storage: TangoDataStorage, source: TangoPrfSource) { + /** + * Creates a new instance of `TangoPrfStorage` + * + * @param storage Another `TangoDataStorage` to store and retrieve the encrypted data + * @param source The `TangoPrfSource` to generate PRF output + */ + constructor(storage: TangoKeyStorage, source: TangoPrfSource) { this.#storage = storage; this.#source = source; } - async save(data: Uint8Array): Promise { + async save( + privateKey: Uint8Array, + name: string | undefined, + ): Promise { const prfInput = new Uint8Array(PrfInputLength); crypto.getRandomValues(prfInput); @@ -95,7 +107,13 @@ export class TangoPrfStorage implements TangoDataStorage { const salt = new Uint8Array(HkdfSaltLength); crypto.getRandomValues(salt); - const aesKey = await deriveAesKey(prfOutput, info, salt); + let aesKey: CryptoKey; + try { + aesKey = await deriveAesKey(prfOutput, info, salt); + } finally { + // Clear secret memory + toUint8Array(prfOutput).fill(0); + } const iv = new Uint8Array(AesIvLength); crypto.getRandomValues(iv); @@ -103,7 +121,7 @@ export class TangoPrfStorage implements TangoDataStorage { const encrypted = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, aesKey, - data, + privateKey, ); const bundle = Bundle.serialize({ @@ -115,18 +133,14 @@ export class TangoPrfStorage implements TangoDataStorage { encrypted: new Uint8Array(encrypted), }); - await this.#storage.save(bundle); - - // Clear secret memory - // * No way to clear `aesKey` - // * `info`, `salt`, `iv`, `encrypted` and `bundle` are not secrets - // * `data` is owned by caller and will be cleared by caller - // * Need to clear `prfOutput` - toUint8Array(prfOutput).fill(0); + await this.#storage.save(bundle, name); } - async *load(): AsyncGenerator { - for await (const serialized of this.#storage.load()) { + async *load(): AsyncGenerator { + for await (const { + privateKey: serialized, + name, + } of this.#storage.load()) { const bundle = Bundle.deserialize( new Uint8ArrayExactReadable(serialized), ); @@ -138,11 +152,17 @@ export class TangoPrfStorage implements TangoDataStorage { this.#prevId = bundle.id as Uint8Array; - const aesKey = await deriveAesKey( - prfOutput, - bundle.hkdfInfo as Uint8Array, - bundle.hkdfSalt 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( { @@ -153,16 +173,13 @@ export class TangoPrfStorage implements TangoDataStorage { bundle.encrypted as Uint8Array, ); - yield new Uint8Array(decrypted); - - // Clear secret memory - // * No way to clear `aesKey` - // * `info`, `salt`, `iv`, `encrypted` and `bundle` are not secrets - // * `data` is owned by caller and will be cleared by caller - // * Caller is not allowed to use `decrypted` after `yield` returns - // * Need to clear `prfOutput` - toUint8Array(prfOutput).fill(0); - new Uint8Array(decrypted).fill(0); + 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); + } } } } diff --git a/libraries/adb-credential-web/src/storage/prf/web-authn.ts b/libraries/adb-credential-web/src/storage/prf/web-authn.ts index fdf60845..6901a08f 100644 --- a/libraries/adb-credential-web/src/storage/prf/web-authn.ts +++ b/libraries/adb-credential-web/src/storage/prf/web-authn.ts @@ -25,16 +25,24 @@ class NotSupportedError extends Error { } } -class AssertionFailedError extends Error { +class OperationCancelledError extends Error { constructor() { - super("Assertion failed"); + super("The operation is either cancelled by user or timed out"); } } export class TangoWebAuthnPrfSource implements TangoPrfSource { static NotSupportedError = NotSupportedError; - static AssertionFailedError = AssertionFailedError; + static OperationCancelledError = OperationCancelledError; + /** + * Checks if the runtime supports WebAuthn PRF extension. + * + * Note that using the extension also requires a supported authenticator. + * Whether an authenticator supports the extension can only be checked + * during the `create` process. + * @returns `true` if the runtime supports WebAuthn PRF extension + */ static async isSupported(): Promise { if (typeof PublicKeyCredential === "undefined") { return false; @@ -57,7 +65,8 @@ export class TangoWebAuthnPrfSource implements TangoPrfSource { readonly #userName: string; /** - * Create a new instance of TangoWebAuthnPrfSource + * Creates a new instance of `TangoWebAuthnPrfSource` + * * @param appName Name of your website shows in Passkey manager * @param userName Display name of the credential shows in Passkey manager */ @@ -66,6 +75,14 @@ export class TangoWebAuthnPrfSource implements TangoPrfSource { this.#userName = userName; } + /** + * Creates a new credential and generate PRF output using the credential and input data. + * + * @param input The input data + * @returns The credential ID and PRF output + * @throws `NotSupportedError` if the runtime or authenticator doesn't support PRF extension + * @throws `OperationCancelledError` if the attestation is either cancelled by user or timed out + */ async create(input: Uint8Array): Promise<{ output: BufferSource; id: Uint8Array; @@ -73,22 +90,27 @@ export class TangoWebAuthnPrfSource implements TangoPrfSource { const challenge = new Uint8Array(32); crypto.getRandomValues(challenge); - const attestation = await navigator.credentials.create({ - publicKey: { - challenge, - extensions: { prf: { eval: { first: input } } }, - pubKeyCredParams: [ - { type: "public-key", alg: -7 }, - { type: "public-key", alg: -257 }, - ], - rp: { name: this.#appName }, - user: { - id: challenge, - name: this.#userName, - displayName: this.#userName, + let attestation; + try { + attestation = await navigator.credentials.create({ + publicKey: { + challenge, + extensions: { prf: { eval: { first: input } } }, + pubKeyCredParams: [ + { type: "public-key", alg: -7 }, + { type: "public-key", alg: -257 }, + ], + rp: { name: this.#appName }, + user: { + id: challenge, + name: this.#userName, + displayName: this.#userName, + }, }, - }, - }); + }); + } catch { + throw new OperationCancelledError(); + } checkCredential(attestation); @@ -108,6 +130,14 @@ export class TangoWebAuthnPrfSource implements TangoPrfSource { return { output, id }; } + /** + * Generates PRF output using a credential and input data. + * + * @param id ID of a previously created credential + * @param input The input data + * @returns PRF output + * @throws `OperationCancelledError` if the attestation is either cancelled by user or timed out + */ async get( id: BufferSource, input: Uint8Array, @@ -125,7 +155,7 @@ export class TangoWebAuthnPrfSource implements TangoPrfSource { }, }); } catch { - throw new AssertionFailedError(); + throw new OperationCancelledError(); } checkCredential(assertion); @@ -141,5 +171,5 @@ export class TangoWebAuthnPrfSource implements TangoPrfSource { export namespace TangoWebAuthnPrfSource { export type NotSupportedError = typeof NotSupportedError; - export type AssertionFailedError = typeof AssertionFailedError; + export type OperationCancelledError = typeof OperationCancelledError; } diff --git a/libraries/adb-credential-web/src/storage/type.ts b/libraries/adb-credential-web/src/storage/type.ts index 301b95a4..87d5972e 100644 --- a/libraries/adb-credential-web/src/storage/type.ts +++ b/libraries/adb-credential-web/src/storage/type.ts @@ -1,7 +1,15 @@ import type { MaybePromiseLike } from "@yume-chan/async"; -export interface TangoDataStorage { - save(data: Uint8Array): MaybePromiseLike; - - load(): Iterable | AsyncIterable; +export interface TangoKey { + privateKey: Uint8Array; + name: string | undefined; +} + +export interface TangoKeyStorage { + save( + privateKey: Uint8Array, + name: string | undefined, + ): MaybePromiseLike; + + load(): Iterable | AsyncIterable; } diff --git a/libraries/adb-credential-web/src/store.ts b/libraries/adb-credential-web/src/store.ts index 14bdd2ae..672f0ad1 100644 --- a/libraries/adb-credential-web/src/store.ts +++ b/libraries/adb-credential-web/src/store.ts @@ -1,16 +1,16 @@ import type { AdbCredentialStore, AdbPrivateKey } from "@yume-chan/adb"; import { rsaParsePrivateKey } from "@yume-chan/adb"; -import type { TangoDataStorage } from "./storage/index.js"; +import type { TangoKeyStorage } from "./storage/index.js"; export class AdbWebCryptoCredentialStore implements AdbCredentialStore { - readonly #storage: TangoDataStorage; + readonly #storage: TangoKeyStorage; - readonly #appName: string; + readonly #name: string | undefined; - constructor(storage: TangoDataStorage, appName: string = "Tango") { + constructor(storage: TangoKeyStorage, name?: string) { this.#storage = storage; - this.#appName = appName; + this.#name = name; } async generateKey(): Promise { @@ -39,7 +39,7 @@ export class AdbWebCryptoCredentialStore implements AdbCredentialStore { const parsed = rsaParsePrivateKey(privateKey); - await this.#storage.save(privateKey); + await this.#storage.save(privateKey, this.#name); // Clear secret memory // * `privateKey` is not allowed to be used after `save` @@ -47,16 +47,16 @@ export class AdbWebCryptoCredentialStore implements AdbCredentialStore { return { ...parsed, - name: `${this.#appName}@${globalThis.location.hostname}`, + name: this.#name, }; } async *iterateKeys(): AsyncGenerator { - for await (const privateKey of this.#storage.load()) { + for await (const key of this.#storage.load()) { // `privateKey` is owned by `#storage` and will be cleared by it yield { - ...rsaParsePrivateKey(privateKey), - name: `${this.#appName}@${globalThis.location.hostname}`, + ...rsaParsePrivateKey(key.privateKey), + name: key.name ?? this.#name, }; } } diff --git a/libraries/adb/src/commands/subprocess/none/service.ts b/libraries/adb/src/commands/subprocess/none/service.ts index 09776d8e..61e023d9 100644 --- a/libraries/adb/src/commands/subprocess/none/service.ts +++ b/libraries/adb/src/commands/subprocess/none/service.ts @@ -17,15 +17,20 @@ export class AdbNoneProtocolSubprocessService { spawn = adbNoneProtocolSpawner(async (command, signal) => { // Android 7 added `shell,raw:${command}` service which also triggers raw mode, // but we want to use the most compatible one. - // + let service = "exec:"; + + if (!command.length) { + throw new Error("Command cannot be empty"); + } + // Similar to SSH, we don't escape the `command`, // because the command will be invoked by `sh -c`, // it can contain environment variables (`KEY=value command`), // and shell expansions (`echo "$KEY"` vs `echo '$KEY'`), // which we can't know how to properly escape. - const socket = await this.#adb.createSocket( - `exec:${command.join(" ")}`, - ); + service += command.join(" "); + + const socket = await this.#adb.createSocket(service); if (signal?.aborted) { await socket.close(); @@ -38,17 +43,17 @@ export class AdbNoneProtocolSubprocessService { async pty( command?: string | readonly string[], ): Promise { - if (command === undefined) { - // Run the default shell - command = ""; + let service = "shell:"; + + if (typeof command === "string") { + service += command; } else if (Array.isArray(command)) { // Don't escape `command`. See `spawn` above for details - command = command.join(" "); + service += command.join(" "); } return new AdbNoneProtocolPtyProcess( - // https://github.com/microsoft/typescript/issues/17002 - await this.#adb.createSocket(`shell:${command as string}`), + await this.#adb.createSocket(service), ); } } diff --git a/libraries/adb/src/commands/subprocess/shell/service.ts b/libraries/adb/src/commands/subprocess/shell/service.ts index 3b58443b..ac5e0629 100644 --- a/libraries/adb/src/commands/subprocess/shell/service.ts +++ b/libraries/adb/src/commands/subprocess/shell/service.ts @@ -20,10 +20,16 @@ export class AdbShellProtocolSubprocessService { } spawn = adbShellProtocolSpawner(async (command, signal) => { + let service = "shell,v2,raw:"; + + if (!command.length) { + throw new Error("Command cannot be empty"); + } + // Don't escape `command`. See `AdbNoneProtocolSubprocessService.prototype.spawn` for details. - const socket = await this.#adb.createSocket( - `shell,v2,raw:${command.join(" ")}`, - ); + service += command.join(" "); + + const socket = await this.#adb.createSocket(service); if (signal?.aborted) { await socket.close(); @@ -37,21 +43,23 @@ export class AdbShellProtocolSubprocessService { command?: string | readonly string[] | undefined; terminalType?: string; }): Promise { + const { command, terminalType } = options ?? {}; + let service = "shell,v2,pty"; - if (options?.terminalType) { - service += `,TERM=` + options.terminalType; + if (terminalType) { + if (terminalType.includes(",") || terminalType.includes(":")) { + throw new Error("terminalType must not contain ',' or ':'"); + } + service += `,TERM=` + terminalType; } - service += ":"; - if (options) { + if (typeof command === "string") { + service += command; + } else if (Array.isArray(command)) { // Don't escape `command`. See `AdbNoneProtocolSubprocessService.prototype.spawn` for details. - if (typeof options.command === "string") { - service += options.command; - } else if (Array.isArray(options.command)) { - service += options.command.join(" "); - } + service += command.join(" "); } return new AdbShellProtocolPtyProcess( diff --git a/libraries/adb/src/utils/base64.ts b/libraries/adb/src/utils/base64.ts index e981a004..8d3c869a 100644 --- a/libraries/adb/src/utils/base64.ts +++ b/libraries/adb/src/utils/base64.ts @@ -295,7 +295,20 @@ function encodeBackward( } } +function getCharIndex(input: string, offset: number) { + const charCode = input.charCodeAt(offset); + const index = charToIndex[charCode]; + if (index === undefined) { + throw new Error("Invalid Base64 character: " + input[offset]); + } + return index; +} + export function decodeBase64(input: string): Uint8Array { + if (input.length % 4 !== 0) { + throw new Error("Invalid Base64 length: " + input.length); + } + let padding: number; if (input[input.length - 2] === "=") { padding = 2; @@ -309,17 +322,18 @@ export function decodeBase64(input: string): Uint8Array { let sIndex = 0; let dIndex = 0; - while (sIndex < input.length - (padding !== 0 ? 4 : 0)) { - const a = charToIndex[input.charCodeAt(sIndex)]!; + const loopEnd = input.length - (padding !== 0 ? 4 : 0); + while (sIndex < loopEnd) { + const a = getCharIndex(input, sIndex); sIndex += 1; - const b = charToIndex[input.charCodeAt(sIndex)]!; + const b = getCharIndex(input, sIndex); sIndex += 1; - const c = charToIndex[input.charCodeAt(sIndex)]!; + const c = getCharIndex(input, sIndex); sIndex += 1; - const d = charToIndex[input.charCodeAt(sIndex)]!; + const d = getCharIndex(input, sIndex); sIndex += 1; result[dIndex] = (a << 2) | ((b & 0b11_0000) >> 4); @@ -333,23 +347,23 @@ export function decodeBase64(input: string): Uint8Array { } if (padding === 1) { - const a = charToIndex[input.charCodeAt(sIndex)]!; + const a = getCharIndex(input, sIndex); sIndex += 1; - const b = charToIndex[input.charCodeAt(sIndex)]!; + const b = getCharIndex(input, sIndex); sIndex += 1; - const c = charToIndex[input.charCodeAt(sIndex)]!; + const c = getCharIndex(input, sIndex); result[dIndex] = (a << 2) | ((b & 0b11_0000) >> 4); dIndex += 1; result[dIndex] = ((b & 0b1111) << 4) | ((c & 0b11_1100) >> 2); } else if (padding === 2) { - const a = charToIndex[input.charCodeAt(sIndex)]!; + const a = getCharIndex(input, sIndex); sIndex += 1; - const b = charToIndex[input.charCodeAt(sIndex)]!; + const b = getCharIndex(input, sIndex); result[dIndex] = (a << 2) | ((b & 0b11_0000) >> 4); } diff --git a/libraries/stream-extra/src/inspect.ts b/libraries/stream-extra/src/inspect.ts index f0bfce65..42553415 100644 --- a/libraries/stream-extra/src/inspect.ts +++ b/libraries/stream-extra/src/inspect.ts @@ -1,4 +1,5 @@ import type { MaybePromiseLike } from "@yume-chan/async"; + import { TransformStream } from "./stream.js"; export class InspectStream extends TransformStream { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7a77388..829d10db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: devDependencies: '@changesets/cli': specifier: ^2.29.6 - version: 2.29.6(@types/node@24.3.1) + version: 2.29.6(@types/node@24.4.0) apps/cli: dependencies: @@ -35,7 +35,7 @@ importers: devDependencies: '@types/node': specifier: ^24.3.1 - version: 24.3.1 + version: 24.4.0 '@yume-chan/eslint-config': specifier: workspace:^ version: link:../../toolchain/eslint-config @@ -69,7 +69,35 @@ importers: devDependencies: '@types/node': specifier: ^24.3.1 - version: 24.3.1 + version: 24.4.0 + '@yume-chan/eslint-config': + specifier: workspace:^ + version: link:../../toolchain/eslint-config + '@yume-chan/test-runner': + specifier: workspace:^ + version: link:../../toolchain/test-runner + '@yume-chan/tsconfig': + specifier: workspace:^ + version: link:../../toolchain/tsconfig + prettier: + specifier: ^3.6.2 + version: 3.6.2 + typescript: + specifier: ^5.9.2 + version: 5.9.2 + + libraries/adb-credential-nodejs: + dependencies: + '@yume-chan/adb': + specifier: workspace:^ + version: link:../adb + '@yume-chan/adb-credential-web': + specifier: workspace:^ + version: link:../adb-credential-web + devDependencies: + '@types/node': + specifier: ^24.3.0 + version: 24.3.0 '@yume-chan/eslint-config': specifier: workspace:^ version: link:../../toolchain/eslint-config @@ -131,7 +159,7 @@ importers: devDependencies: '@types/node': specifier: ^24.3.1 - version: 24.3.1 + version: 24.4.0 '@yume-chan/eslint-config': specifier: workspace:^ version: link:../../toolchain/eslint-config @@ -174,7 +202,7 @@ importers: devDependencies: '@types/node': specifier: ^24.3.1 - version: 24.3.1 + version: 24.4.0 '@yume-chan/eslint-config': specifier: workspace:^ version: link:../../toolchain/eslint-config @@ -208,7 +236,7 @@ importers: devDependencies: '@types/node': specifier: ^24.3.1 - version: 24.3.1 + version: 24.4.0 '@yume-chan/eslint-config': specifier: workspace:^ version: link:../../toolchain/eslint-config @@ -236,7 +264,7 @@ importers: devDependencies: '@types/node': specifier: ^24.3.1 - version: 24.3.1 + version: 24.4.0 '@yume-chan/eslint-config': specifier: workspace:^ version: link:../../toolchain/eslint-config @@ -280,7 +308,7 @@ importers: devDependencies: '@types/node': specifier: ^24.3.1 - version: 24.3.1 + version: 24.4.0 '@yume-chan/eslint-config': specifier: workspace:^ version: link:../../toolchain/eslint-config @@ -305,7 +333,7 @@ importers: devDependencies: '@types/node': specifier: ^24.3.1 - version: 24.3.1 + version: 24.4.0 libraries/media-codec: dependencies: @@ -315,7 +343,7 @@ importers: devDependencies: '@types/node': specifier: ^24.3.0 - version: 24.3.1 + version: 24.3.0 '@yume-chan/eslint-config': specifier: workspace:^ version: link:../../toolchain/eslint-config @@ -336,7 +364,7 @@ importers: devDependencies: '@types/node': specifier: ^24.3.1 - version: 24.3.1 + version: 24.4.0 '@yume-chan/eslint-config': specifier: workspace:^ version: link:../../toolchain/eslint-config @@ -394,7 +422,7 @@ importers: devDependencies: '@types/node': specifier: ^24.3.1 - version: 24.3.1 + version: 24.4.0 '@yume-chan/eslint-config': specifier: workspace:^ version: link:../../toolchain/eslint-config @@ -499,7 +527,7 @@ importers: devDependencies: '@types/node': specifier: ^24.3.1 - version: 24.3.1 + version: 24.4.0 '@yume-chan/eslint-config': specifier: workspace:^ version: link:../../toolchain/eslint-config @@ -527,7 +555,7 @@ importers: devDependencies: '@types/node': specifier: ^24.3.1 - version: 24.3.1 + version: 24.4.0 '@yume-chan/eslint-config': specifier: workspace:^ version: link:../../toolchain/eslint-config @@ -551,7 +579,7 @@ importers: version: 9.35.0 '@types/node': specifier: ^24.3.1 - version: 24.3.1 + version: 24.4.0 eslint: specifier: ^9.35.0 version: 9.35.0 @@ -573,7 +601,7 @@ importers: dependencies: '@types/node': specifier: ^24.3.1 - version: 24.3.1 + version: 24.4.0 json5: specifier: ^2.2.3 version: 2.2.3 @@ -610,7 +638,7 @@ importers: devDependencies: '@types/node': specifier: ^24.3.1 - version: 24.3.1 + version: 24.4.0 typescript: specifier: ^5.9.2 version: 5.9.2 @@ -623,8 +651,8 @@ importers: packages: - '@babel/runtime@7.28.4': - resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + '@babel/runtime@7.28.3': + resolution: {integrity: sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==} engines: {node: '>=6.9.0'} '@changesets/apply-release-plan@7.0.12': @@ -691,6 +719,12 @@ packages: '@emnapi/wasi-threads@1.0.4': resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==} + '@eslint-community/eslint-utils@4.7.0': + resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.9.0': resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -733,14 +767,18 @@ packages: resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + '@humanfs/node@0.16.6': + resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==} engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} + '@humanwhocodes/retry@0.3.1': + resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==} + engines: {node: '>=18.18'} + '@humanwhocodes/retry@0.4.3': resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} @@ -978,8 +1016,11 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - '@types/node@24.3.1': - resolution: {integrity: sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==} + '@types/node@24.3.0': + resolution: {integrity: sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==} + + '@types/node@24.4.0': + resolution: {integrity: sha512-gUuVEAK4/u6F9wRLznPUU4WGUacSEBDPoC2TrBkw3GAnOLHBL45QdfHOXp1kJ4ypBGLxTOB+t7NJLpKoC3gznQ==} '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} @@ -1025,6 +1066,10 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/types@8.37.0': + resolution: {integrity: sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.43.0': resolution: {integrity: sha512-vQ2FZaxJpydjSZJKiSW/LJsabFFvV7KgLC5DiLhkBcykhQj8iK9BOaDmQt74nnKdLvceM5xmhaTF+pLekrxEkw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2108,6 +2153,9 @@ packages: undici-types@7.10.0: resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} + undici-types@7.11.0: + resolution: {integrity: sha512-kt1ZriHTi7MU+Z/r9DOdAI3ONdaR3M3csEaRc6ewa4f4dTvX4cQCbJ4NkEn0ohE4hHtq85+PhPSTY+pO/1PwgA==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -2149,7 +2197,7 @@ packages: snapshots: - '@babel/runtime@7.28.4': {} + '@babel/runtime@7.28.3': {} '@changesets/apply-release-plan@7.0.12': dependencies: @@ -2180,7 +2228,7 @@ snapshots: dependencies: '@changesets/types': 6.1.0 - '@changesets/cli@2.29.6(@types/node@24.3.1)': + '@changesets/cli@2.29.6(@types/node@24.4.0)': dependencies: '@changesets/apply-release-plan': 7.0.12 '@changesets/assemble-release-plan': 6.0.9 @@ -2196,7 +2244,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.1(@types/node@24.3.1) + '@inquirer/external-editor': 1.0.1(@types/node@24.4.0) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 ci-info: 3.9.0 @@ -2311,6 +2359,11 @@ snapshots: tslib: 2.8.1 optional: true + '@eslint-community/eslint-utils@4.7.0(eslint@9.35.0)': + dependencies: + eslint: 9.35.0 + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.0(eslint@9.35.0)': dependencies: eslint: 9.35.0 @@ -2357,21 +2410,23 @@ snapshots: '@humanfs/core@0.19.1': {} - '@humanfs/node@0.16.7': + '@humanfs/node@0.16.6': dependencies: '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.4.3 + '@humanwhocodes/retry': 0.3.1 '@humanwhocodes/module-importer@1.0.1': {} + '@humanwhocodes/retry@0.3.1': {} + '@humanwhocodes/retry@0.4.3': {} - '@inquirer/external-editor@1.0.1(@types/node@24.3.1)': + '@inquirer/external-editor@1.0.1(@types/node@24.4.0)': dependencies: chardet: 2.1.0 iconv-lite: 0.6.3 optionalDependencies: - '@types/node': 24.3.1 + '@types/node': 24.4.0 '@isaacs/balanced-match@4.0.1': {} @@ -2403,14 +2458,14 @@ snapshots: '@manypkg/find-root@1.1.0': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.3 '@types/node': 12.20.55 find-up: 4.1.0 fs-extra: 8.1.0 '@manypkg/get-packages@1.1.3': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.3 '@changesets/types': 4.1.0 '@manypkg/find-root': 1.1.0 fs-extra: 8.1.0 @@ -2557,10 +2612,14 @@ snapshots: '@types/node@12.20.55': {} - '@types/node@24.3.1': + '@types/node@24.3.0': dependencies: undici-types: 7.10.0 + '@types/node@24.4.0': + dependencies: + undici-types: 7.11.0 + '@types/resolve@1.20.2': {} '@types/w3c-web-usb@1.0.12': {} @@ -2624,6 +2683,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/types@8.37.0': {} + '@typescript-eslint/types@8.43.0': {} '@typescript-eslint/typescript-estree@8.43.0(typescript@5.9.2)': @@ -2644,7 +2705,7 @@ snapshots: '@typescript-eslint/utils@8.43.0(eslint@9.35.0)(typescript@5.9.2)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.35.0) '@typescript-eslint/scope-manager': 8.43.0 '@typescript-eslint/types': 8.43.0 '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) @@ -2937,7 +2998,7 @@ snapshots: eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.35.0): dependencies: - '@typescript-eslint/types': 8.43.0 + '@typescript-eslint/types': 8.37.0 comment-parser: 1.4.1 debug: 4.4.1 eslint: 9.35.0 @@ -2972,7 +3033,7 @@ snapshots: '@eslint/eslintrc': 3.3.1 '@eslint/js': 9.35.0 '@eslint/plugin-kit': 0.3.5 - '@humanfs/node': 0.16.7 + '@humanfs/node': 0.16.6 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 @@ -3653,6 +3714,8 @@ snapshots: undici-types@7.10.0: {} + undici-types@7.11.0: {} + universalify@0.1.2: {} unrs-resolver@1.11.1: