From 91484d959339ebda3e3990a156c04251a7a5fa1f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 20:37:49 +0000 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20DIS=20foundation=20=E2=80=94=20cr?= =?UTF-8?q?ypto=20primitives,=20modular=20SDK,=20docs,=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the framework-agnostic crypto core (encoding, random, secure-memory, kdf/Argon2id, aead/AES-256-GCM, vault-encryption, key-management, file-encryption, integrity, format-versioning, migrations) with byte-compatible Singra formats. 42 tests (positive + negative + vectors), lint, typecheck, build all green. Adds architecture/threat-model/crypto-review/migration/licensing docs, PolyForm Noncommercial license, and CI with dependency + secret scanning. Co-Authored-By: Gaming Gruppe --- .github/workflows/ci.yml | 44 + .gitignore | 9 + CONTRIBUTING.md | 52 + LICENSE | 140 + README.md | 94 + SECURITY.md | 26 + docs/api-design.md | 74 + docs/architecture.md | 89 + docs/crypto-dependency-map.md | 78 + docs/crypto-review.md | 60 + docs/licensing.md | 80 + docs/migration-plan.md | 70 + docs/risk-analysis.md | 56 + docs/threat-model.md | 65 + docs/trust-boundaries.md | 62 + eslint.config.js | 28 + package-lock.json | 3538 +++++++++++++++++ package.json | 113 + src/aead/aead.test.ts | 61 + src/aead/index.ts | 125 + src/core/constants.ts | 19 + src/core/encoding.test.ts | 23 + src/core/encoding.ts | 60 + src/core/errors.ts | 74 + src/core/index.ts | 9 + src/core/provider.ts | 54 + src/file-encryption/file-encryption.test.ts | 121 + src/file-encryption/index.ts | 319 ++ .../format-versioning.test.ts | 36 + src/format-versioning/index.ts | 69 + src/index.ts | 122 + src/integrity/index.ts | 57 + src/integrity/integrity.test.ts | 30 + src/kdf/index.ts | 171 + src/kdf/kdf.test.ts | 70 + src/key-management/index.ts | 160 + src/key-management/key-management.test.ts | 52 + src/migrations/index.ts | 78 + src/random/index.ts | 38 + src/secure-memory/index.ts | 144 + src/secure-memory/secure-memory.test.ts | 46 + src/vault-encryption/index.ts | 117 + src/vault-encryption/vault-encryption.test.ts | 58 + tsconfig.json | 24 + tsup.config.ts | 32 + vitest.config.ts | 13 + 46 files changed, 6860 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 docs/api-design.md create mode 100644 docs/architecture.md create mode 100644 docs/crypto-dependency-map.md create mode 100644 docs/crypto-review.md create mode 100644 docs/licensing.md create mode 100644 docs/migration-plan.md create mode 100644 docs/risk-analysis.md create mode 100644 docs/threat-model.md create mode 100644 docs/trust-boundaries.md create mode 100644 eslint.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/aead/aead.test.ts create mode 100644 src/aead/index.ts create mode 100644 src/core/constants.ts create mode 100644 src/core/encoding.test.ts create mode 100644 src/core/encoding.ts create mode 100644 src/core/errors.ts create mode 100644 src/core/index.ts create mode 100644 src/core/provider.ts create mode 100644 src/file-encryption/file-encryption.test.ts create mode 100644 src/file-encryption/index.ts create mode 100644 src/format-versioning/format-versioning.test.ts create mode 100644 src/format-versioning/index.ts create mode 100644 src/index.ts create mode 100644 src/integrity/index.ts create mode 100644 src/integrity/integrity.test.ts create mode 100644 src/kdf/index.ts create mode 100644 src/kdf/kdf.test.ts create mode 100644 src/key-management/index.ts create mode 100644 src/key-management/key-management.test.ts create mode 100644 src/migrations/index.ts create mode 100644 src/random/index.ts create mode 100644 src/secure-memory/index.ts create mode 100644 src/secure-memory/secure-memory.test.ts create mode 100644 src/vault-encryption/index.ts create mode 100644 src/vault-encryption/vault-encryption.test.ts create mode 100644 tsconfig.json create mode 100644 tsup.config.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..86ffa72 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - run: npm ci + - name: Lint + run: npm run lint + - name: Typecheck + run: npm run typecheck + - name: Test + run: npm test + - name: Build + run: npm run build + + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - run: npm ci + - name: Dependency audit (fail on high+) + run: npm audit --audit-level=high + - name: Secret scan (gitleaks) + uses: gitleaks/gitleaks-action@v2 + env: + GITLEAKS_ENABLE_UPLOAD_ARTIFACT: 'false' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..300079f --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +coverage/ +*.log +.DS_Store +.env +.env.* +!.env.example +*.tsbuildinfo diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a7ff186 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,52 @@ +# Contributing to DIS + +DIS — Defensive Integration Shield is the central cryptographic layer for Singra +Vault, Singra Premium, and future projects. Crypto code carries unusual risk, so +contributions follow strict rules. + +## Contributor License Agreement (CLA) + +By submitting a contribution you agree that: + +1. You have the right to submit the work. +2. You license your contribution under the project license + ([PolyForm Noncommercial 1.0.0](LICENSE)), **and** you grant the maintainer a + perpetual, irrevocable right to relicense your contribution, including under + commercial terms. + +This preserves the project's ability to offer commercial licenses. PRs cannot be +merged without CLA agreement. + +## Hard rules (non-negotiable) + +- **No invented cryptography.** Only audited primitives (`hash-wasm`, WebCrypto, + `@noble/post-quantum`). New crypto dependencies require explicit review per the + dependency policy. +- **AEAD only**, unique nonce per call, AAD where context matters, strong KDFs, + key separation, key wrapping, secure randomness. +- **Never** static salts, password-as-key, unauthenticated CBC, global mutable + crypto state, secrets in logs, debug bypasses, or hidden fallbacks. +- **Formats are append-only.** Never edit an existing versioned format; add a new + version. Persisted-format constants are frozen. +- **Every change ships with tests** — including negative tests (wrong key, + tampered data, AAD mismatch, downgrade) and, for formats, test vectors. + +## Workflow + +1. Branch from `main` (never commit to `main`/`master` directly). +2. `npm install` +3. Make the change with focused commits. +4. Run the full gate locally: + ```bash + npm run lint + npm run typecheck + npm test + npm run build + ``` +5. Open a PR. CI must be green. Security-relevant changes need maintainer review. + +## Definition of done + +A change is done only when invariants are tested and runtime-critical paths are +exercised — a green typecheck or build alone is **not** sufficient. Unrun checks +must be stated honestly in the PR. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cf79308 --- /dev/null +++ b/LICENSE @@ -0,0 +1,140 @@ +# PolyForm Noncommercial License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the +software to do everything you might do with the software +that would otherwise infringe the licensor's copyright +in it for any permitted purpose. However, you may +only distribute the software according to [Distribution +License](#distribution-license) and make changes or new works +based on the software according to [Changes and New Works +License](#changes-and-new-works-license). + +## Distribution License + +The licensor grants you an additional copyright license +to distribute copies of the software. Your license +to distribute covers distributing the software with +changes and new works permitted by [Changes and New Works +License](#changes-and-new-works-license). + +## Notices + +You must ensure that anyone who gets a copy of any part of +the software from you also gets a copy of these terms or the +URL for them above, as well as copies of any plain-text lines +beginning with `Required Notice:` that the licensor provided +with the software. For example: + +> Required Notice: Copyright Maunting Studios (https://github.com/einmalmaik/dis) + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Noncommercial Purposes + +Any noncommercial purpose is a permitted purpose. + +## Personal Uses + +Personal use for research, experiment, and testing for +the benefit of public knowledge, personal study, private +entertainment, hobby projects, amateur pursuits, or religious +observance, without any anticipated commercial application, +is use for a permitted purpose. + +## Noncommercial Organizations + +Use by any charitable organization, educational institution, +public research organization, public safety or health +organization, environmental protection organization, +or government institution is use for a permitted purpose +regardless of the source of funding or obligations resulting +from the funding. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be +liable to you for any damages arising out of these terms or +the use or nature of the software, under any kind of legal +claim. + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. + +--- + +Required Notice: Copyright (c) 2025-2026 Maunting Studios + +For commercial licensing of DIS — Defensive Integration Shield, +contact the licensor. See `docs/licensing.md` for the full +licensing rationale and dual-licensing options. diff --git a/README.md b/README.md new file mode 100644 index 0000000..72b0188 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# DIS — Defensive Integration Shield + +> **Powered by DIS — Defensive Integration Shield** + +DIS is a central, framework-agnostic cryptographic security platform. It is the +single place where encryption, key management, secure data formats, KDFs, +versioning, and future migrations live — so that consuming applications +(Singra Vault, Singra Premium, and future projects) hold **no direct crypto +logic** and depend only on stable, versioned public APIs. + +DIS **does not invent cryptography**. It composes audited, established +primitives: + +| Concern | Primitive | Source | +| --- | --- | --- | +| Password KDF | Argon2id (versioned params) | [`hash-wasm`](https://github.com/Daninet/hash-wasm) | +| Authenticated encryption | AES-256-GCM (96-bit IV, 128-bit tag) | WebCrypto `SubtleCrypto` | +| Key separation / wrapping | HKDF-SHA-256 + AES-GCM | WebCrypto | +| Random | CSPRNG | WebCrypto `getRandomValues` | +| Post-quantum (optional) | ML-KEM-768 hybrid | [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) | + +## Status + +This is the **foundation release** (`0.1.0`). The pure, portable primitives +(encoding, random, secure memory, KDF, AEAD, vault-entry encryption, key +wrapping/rotation, chunked file encryption, integrity, format versioning, +migration framework) are implemented and tested. The post-quantum hybrid +sharing layer and the application cutover are tracked in +[`docs/migration-plan.md`](docs/migration-plan.md). + +## Install + +```bash +npm install @dis/shield +# Optional, only if you use the post-quantum sharing module: +npm install @noble/post-quantum +``` + +Requires Node `>=20.19.0` or a browser with WebCrypto. + +## Usage + +```ts +import { + deriveMasterKey, + encryptVaultEntry, + decryptVaultEntry, + createWrappedUserKey, + rotateEncryptionKeys, +} from '@dis/shield'; + +const salt = '...'; // base64, stored per account +const kek = await deriveMasterKey(masterPassword, salt); // Argon2id -> AES-GCM key + +const sealed = await encryptVaultEntry({ password: 's3cret' }, kek, entryId); +const data = await decryptVaultEntry(sealed, kek, entryId); +``` + +Narrow imports are tree-shakeable: + +```ts +import { encryptBytes } from '@dis/shield/aead'; +import { deriveRawKey } from '@dis/shield/kdf'; +``` + +## Modules + +| Entry point | Responsibility | +| --- | --- | +| `@dis/shield/core` | Errors, encoding, constants, crypto-provider abstraction | +| `@dis/shield/random` | Secure random bytes / UUIDs | +| `@dis/shield/secure-memory` | `SecureBuffer` for key material | +| `@dis/shield/kdf` | Argon2id, versioned parameters, HKDF strengthening | +| `@dis/shield/aead` | AES-256-GCM encrypt/decrypt with AAD | +| `@dis/shield/format-versioning` | Versioned, prefix-tagged envelopes | +| `@dis/shield/vault-encryption` | Vault-entry sealing (entry-id AAD binding) | +| `@dis/shield/file-encryption` | Chunked attachment encryption + manifests | +| `@dis/shield/key-management` | Content-key wrap / unwrap / rotation | +| `@dis/shield/integrity` | SHA-256, constant-time compare, verification | +| `@dis/shield/migrations` | Ordered, explicit payload migrations | + +## Security + +See [`docs/`](docs/) for the architecture overview, threat model, +trust-boundary analysis, crypto review, and migration plan. Security +properties are only claimed where they are backed by code, tests, or +documentation. Anything unverified is labelled `not verified`. + +## License + +[PolyForm Noncommercial 1.0.0](LICENSE) — source-available, free for +noncommercial use. **Not free for commercial use.** See +[`docs/licensing.md`](docs/licensing.md) for the rationale, trademark/branding +rules, and commercial dual-licensing. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..373a25b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,26 @@ +# Security Policy + +DIS is a cryptographic library. Please treat vulnerabilities responsibly. + +## Reporting + +Do **not** open public issues for security vulnerabilities. Report privately via +GitHub Security Advisories on this repository (or contact the maintainer +directly). Include affected versions, reproduction, and impact. We aim to +acknowledge within a few days. + +## Security model + +- DIS composes only audited primitives (Argon2id via `hash-wasm`, AES-256-GCM / + HKDF / SHA-256 via WebCrypto, ML-KEM-768 via `@noble/post-quantum`). It does + not invent cryptography. +- Security properties, accepted residual risks, and unverified assumptions are + documented in [`docs/threat-model.md`](docs/threat-model.md) and + [`docs/crypto-review.md`](docs/crypto-review.md). +- Persisted formats are versioned and authenticated; unknown versions fail + closed; legacy unauthenticated payloads are readable only on an explicit + migration path. + +## Supported versions + +Pre-1.0: only the latest `0.x` minor receives fixes. diff --git a/docs/api-design.md b/docs/api-design.md new file mode 100644 index 0000000..2f1fd73 --- /dev/null +++ b/docs/api-design.md @@ -0,0 +1,74 @@ +# Public API Design + +Stability contract: the symbols below are the supported surface. They follow +semantic versioning. Internal changes (KDF tuning within a version, provider +internals) must not change these signatures. + +## Stability tiers + +- **Stable** — covered by semver; breaking changes require a major bump and a + migration note. +- **Format-frozen** — string constants that appear in persisted data. These are + never edited; new behaviour means a new versioned constant. + +## High-level SDK (`@dis/shield`) + +```ts +// Vault entries (content-key in, opaque record out) +encryptVaultEntry(data, key, entryId): Promise // -> "sv-vault-v1:..." +decryptVaultEntry(sealed, key, entryId): Promise> +decryptVaultEntryForMigration(sealed, key, entryId): Promise +isCurrentVaultEntryEnvelope(sealed): boolean + +// Attachments (storage-agnostic; caller supplies chunk IO + key wrap) +encryptAttachment(input: EncryptAttachmentInput): Promise<{ manifest; manifestRoot }> +decryptAttachment(input: DecryptAttachmentInput): Promise + +// KDF +deriveMasterKey(password, saltBase64, opts?): Promise // == deriveAesGcmKey +deriveRawKey(password, saltBase64, opts?): Promise +generateSalt(): string + +// Key management +createWrappedUserKey(kdfOutputBytes, scheme?): Promise +unwrapUserKey(encryptedUserKey, kdfOutputBytes, scheme?): Promise +rotateEncryptionKeys(encryptedUserKey, oldKdf, newKdf, scheme?): Promise + +// Integrity & migrations +verifyPayloadIntegrity(bytes, expectedBase64): Promise +new MigrationRegistry().register(...).migrateToLatest(...) +``` + +## Design rules + +1. **Keys, not passwords, cross most APIs.** Only `kdf` accepts a password. + Everything else takes a `CryptoKey` or raw bytes, enforcing key separation. +2. **AAD is mandatory and explicit** where context binding matters + (`entryId`, attachment context). It is a required parameter, not optional. +3. **Caller owns secret lifetime.** Functions returning `Uint8Array` secrets + document that the caller must wipe; DIS wipes everything it allocates + internally. +4. **No app types leak in.** A vault entry is `Record`; an + attachment's storage is expressed as `readChunk`/`writeChunk` callbacks. +5. **Errors are typed and secret-free.** All decrypt failures collapse to + `DisDecryptionError` (no oracle). Unknown versions → + `DisUnsupportedFormatVersionError`. Legacy on runtime path → + `DisLegacyPayloadError`. +6. **Providers are injectable** via `setCryptoProvider` for tests/hardware. + +## Breaking-change policy + +- Format-frozen constants are append-only (`sv-vault-v1` → add `sv-vault-v2`, + never edit v1). Decryption keeps reading all supported versions. +- Removing a legacy read path is a **major** change and requires documented + evidence that no data depends on it. +- New optional parameters are minor; new required parameters are major. + +## Breaking-change risks for the apps (at cutover) + +| Change | Risk | Handling | +| --- | --- | --- | +| Premium `../singravault/src` alias → `@dis/shield` | import paths change | codemod + adapter, single PR | +| App crypto modules deleted | dangling imports | app-local `crypto` adapter re-exports DIS | +| Raw `crypto.subtle` banned | lint failures | provide adapter; fix call sites | +| Legacy no-AAD runtime read removed | old data unreadable | only after migration + telemetry gate | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..b3482fc --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,89 @@ +# DIS Architecture + +## Goal + +DIS is the single cryptographic security layer for Singra Vault, Singra +Premium, and future projects. Applications must contain **no direct crypto +logic** and consume DIS only through stable, versioned public APIs. Internal +changes to KDFs, ciphers, key hierarchies, or data formats must, wherever +possible, not force changes in consuming applications. + +## Design principles + +1. **No invented cryptography.** Only audited primitives (Argon2id via + `hash-wasm`, AES-256-GCM / HKDF / SHA-256 via WebCrypto, ML-KEM-768 via + `@noble/post-quantum`). +2. **Framework independence.** No React, no DOM-beyond-WebCrypto, no database, + no UI, no app data models. Runs in browser, Node ≥ 20, and (future) mobile + JS runtimes. +3. **Versioned formats.** Every persisted ciphertext is self-describing via a + stable prefix. Unknown in-family versions fail closed. +4. **Key separation.** Distinct keys per purpose (KEK vs content key vs file + key), derived/ wrapped, never reused across purposes. +5. **Pluggable providers.** A `CryptoProvider` seam allows substituting the + WebCrypto implementation (tests, hardware backends) without touching call + sites. +6. **Memory hygiene.** Key material flows through `SecureBuffer` / wiped + `Uint8Array`s; no secrets in immutable strings where avoidable. +7. **Honest errors.** A typed error hierarchy; no secrets in messages; AEAD + failures are indistinguishable (no padding/AAD oracle). + +## Packaging decision: single package, modular entry points + +The requirement lists ~11 example modules (`dis-core`, `dis-kdf`, …). We +evaluated three layouts: + +| Option | Pros | Cons | +| --- | --- | --- | +| **One package, many subpath exports** (chosen) | One version to reason about; trivial cross-module refactors; tree-shakeable; minimal release/CI overhead; easy for two tightly-coupled apps to adopt | Cannot version a single module independently | +| Workspaces monorepo, N published packages | Independent versioning | High overhead (build ordering, inter-dep version churn), premature for v0, harder to keep green | +| One repo, one flat module | Simplest | Violates "no utility dumping ground"; poor boundaries | + +**Decision:** ship `@dis/shield` as **one semantically-versioned package with +per-module subpath exports** (`@dis/shield/kdf`, `/aead`, `/vault-encryption`, +…). This satisfies modularity, tree-shaking, and "not everything in one utility +module" while keeping the foundation maintainable. The directory boundaries are +deliberately clean so that, if independent versioning is later needed, modules +can be promoted to workspace packages with no API change. + +## Module boundaries + +``` +core ──────────────┐ (errors, encoding, constants, provider) no deps +random ─────────────┤ uses core.provider +secure-memory ──────┤ uses core +kdf ────────────────┤ uses core + hash-wasm (Argon2id) + WebCrypto HKDF +aead ───────────────┤ uses core (AES-256-GCM) +format-versioning ──┤ uses core +integrity ──────────┤ uses core (SHA-256) +vault-encryption ───┤ uses aead + format-versioning +key-management ─────┤ uses aead + kdf + random (wrap/unwrap/rotate) +file-encryption ────┤ uses aead + kdf + random + integrity (chunked) +migrations ─────────┘ framework over the above +index (SDK facade) ─── stable public surface re-exporting the above +``` + +Dependencies only point "downward" toward `core`. There are no cycles. + +## What DIS does NOT contain + +- No UI, routing, React, or component code. +- No database / Supabase / storage client. File encryption is storage-agnostic + (caller supplies chunk read/write callbacks). +- No app data models (vault item schema, collections, billing, admin). A vault + entry is an opaque `Record`. +- No network, telemetry, analytics, or logging of secrets. + +## Provider abstraction + +`getCryptoProvider()` resolves `globalThis.crypto` by default and can be +overridden via `setCryptoProvider()`. This is the only place DIS touches a +global, keeping the rest of the code testable and portable. + +## Compatibility contract + +DIS reproduces Singra's on-disk formats exactly (envelope prefixes, IV‖CT‖tag +layout, AAD strings, HKDF `info` labels, KDF parameters). This is what allows +the applications to adopt DIS without re-encrypting existing data. These format +constants are frozen; evolving them means **adding a version**, never editing +one. See `crypto-dependency-map.md` and `migration-plan.md`. diff --git a/docs/crypto-dependency-map.md b/docs/crypto-dependency-map.md new file mode 100644 index 0000000..df70870 --- /dev/null +++ b/docs/crypto-dependency-map.md @@ -0,0 +1,78 @@ +# Crypto Dependency Map + +Inventory of cryptographic logic in the source applications, where it lives +today, and where it moves in DIS. Counts are from the repository at analysis +time and are a baseline, not a guarantee. + +## Libraries (current) + +| Library | Version | Use | Where | +| --- | --- | --- | --- | +| `hash-wasm` | 4.12.0 | Argon2id KDF | `singravault` | +| WebCrypto `SubtleCrypto` | platform | AES-GCM, HKDF, RSA-OAEP, SHA-256 | both | +| `@noble/post-quantum` | 0.5.4 | ML-KEM-768 (hybrid) | `singravault` | +| `otpauth` | ^9.5.0 | TOTP (out of DIS scope) | `singravault` | + +## Singra Vault — core crypto modules + +| File | Lines | Responsibility | DIS target | +| --- | --- | --- | --- | +| `src/services/cryptoService.ts` | 1741 | KDF, AES-GCM, vault-item envelope, verification hash, user-key wrap, RSA, shared-key | `kdf`, `aead`, `vault-encryption`, `key-management`, `format-versioning` | +| `src/services/keyMaterialService.ts` | 459 | Ensure/derive RSA + PQ key material | app orchestration over `key-management` (stays in app) | +| `src/services/secureBuffer.ts` | 301 | Memory-safe key handling | `secure-memory` | +| `src/services/pqCryptoService.ts` | 752 | ML-KEM-768 + RSA hybrid encrypt/wrap | `key-management` (PQ hybrid submodule — phase 2) | +| `src/services/deviceKeyService.ts` | — | Device-key gen + HKDF strengthening | `kdf` (HKDF strengthen) + app policy | +| `src/services/vaultIntegrityV2/*` | — | Item envelopes, manifests | `vault-encryption`, `integrity` | + +## Public crypto surface extracted (Singra Vault `cryptoService.ts`) + +`CURRENT_KDF_VERSION`, `KDF_PARAMS`, `VAULT_ITEM_ENVELOPE_V1_PREFIX`, +`generateSalt`, `deriveRawKey`, `deriveRawKeySecure`, `deriveKey`, +`importMasterKey`, `encrypt`/`decrypt`, `encryptBytes`/`decryptBytes`, +`encryptVaultItem`/`decryptVaultItem`, `decryptVaultItemForMigration`, +`isCurrentVaultItemEnvelope`, `createVerificationHash`, `verifyKey`, +`attemptKdfUpgrade`, `reEncryptString`, `reEncryptVault`, `createEncryptedUserKey`, +`migrateToUserKey`, `unwrapUserKey`, `unwrapUserKeyBytes`, `rewrapUserKey`, +`wrapPrivateKeyWithUserKey`/`unwrapPrivateKeyWithUserKey`, RSA helpers, +`generateSharedKey`/`encryptWithSharedKey`/`decryptWithSharedKey`. + +PQ (`pqCryptoService.ts`): `HYBRID_VERSION`, `buildSharedKeyWrapAad`, +`generatePQKeyPair`, `generateHybridKeyPair`, `hybridEncrypt`/`hybridDecrypt`, +`hybridWrapKey`/`hybridUnwrapKey`, `isHybridEncrypted`, `migrateToHybrid`. + +## Singra Premium — crypto usage + +| File | Crypto role | DIS target | +| --- | --- | --- | +| `src/services/fileAttachmentService.ts` | Chunked file encryption, per-file key, manifest, AAD scheme | `file-encryption` (+ Supabase/Tauri transport stays in app) | +| `components/settings/EmergencyAccessSettings.tsx` | imports `cryptoService`, `pqCryptoService` | via DIS facade | +| `components/settings/SharedCollectionsSettings.tsx` | `keyMaterialService` | app orchestration | +| `pages/GrantorVaultPage.tsx`, `pages/AuthenticatorPage.tsx` | `cryptoService`, `VaultItemData` | via DIS facade | + +**Current coupling:** Premium resolves core crypto by **filesystem path** — +`vitest.config.ts` aliases `@/services/*` to `../singravault/src`. There is no +package boundary today. Eliminating this path-alias coupling in favour of a +`@dis/shield` dependency is a primary objective. + +## Call-site footprint (Singra Vault, non-test) + +~35 non-test files import `@/services/{cryptoService,pqCryptoService,keyMaterialService,secureBuffer}`, +concentrated in `src/services`, `src/contexts/vault`, `src/components/vault`, and +`src/services/vaultIntegrityV2`. These become imports from `@dis/shield` (most +via a thin app-local `crypto` adapter — see `migration-plan.md`). + +## Format constants (frozen — part of the contract) + +| Constant | Value | Module | +| --- | --- | --- | +| Vault item envelope | `sv-vault-v1:` + `base64(IV‖CT‖tag)` | `vault-encryption` | +| User-key wrap | `usk-wrap-v2:` + `base64(IV‖CT‖tag)` | `key-management` | +| User-key wrap HKDF info | `singra-vault-wrap-v1` (zero salt) | `key-management` | +| Device-key HKDF info | `SINGRA_DEVICE_KEY_V1` | app-supplied to `kdf.strengthen` | +| File manifest envelope | `sv-file-manifest-v1:` | `file-encryption` | +| File-key AAD | `sv-file-key-v1:{owner}:{item}:{file}` | `file-encryption` | +| Chunk AAD | `sv-file-chunk-v1:{owner}:{item}:{file}:{rev}:{root}:{idx}:{count}` | `file-encryption` | +| KDF v1 / v2 | 64 MiB / 128 MiB Argon2id, t=3, p=4, 32B | `kdf` | +| AES-GCM | IV 12B, tag 128b, key 256b | `aead` | +| Verification constant | `SINGRA_VAULT_VERIFY_V3` | app (verification hash) | +| Hybrid (PQ) version byte | `VERSION_HYBRID_STANDARD_V2`; layout `ver‖pq_ct‖rsa_ct‖iv‖aes_ct` | `key-management` (phase 2) | diff --git a/docs/crypto-review.md b/docs/crypto-review.md new file mode 100644 index 0000000..fc67411 --- /dev/null +++ b/docs/crypto-review.md @@ -0,0 +1,60 @@ +# Crypto Review + +Review of the cryptographic choices DIS inherits from Singra and exposes. Each +item is rated and justified. Claims are backed by code/tests in this repo or +flagged `not verified`. + +## Primitives + +| Choice | Assessment | Notes | +| --- | --- | --- | +| **Argon2id** (memory-hard KDF) | Sound | Versioned params: v1 = 64 MiB, v2 = 128 MiB, t=3, p=4, 32-byte output. Memory-hard, resists GPU/ASIC. Upgrade path via version field. | +| **AES-256-GCM** AEAD | Sound | 96-bit random IV (correct length for GCM), 128-bit tag. Confidentiality + integrity + AAD. | +| **96-bit random IV** | Sound with caveat | Random IVs are safe for GCM up to ~2³² messages **per key**. Content-key is per-user; volumes are far below the birthday bound. Each call draws a fresh CSPRNG IV (tested). | +| **HKDF-SHA-256** for key separation | Sound | Domain-separated `info` per purpose (wrap-key, device-key). Zero salt acceptable because IKM (Argon2id output / device key) is already high-entropy. | +| **Content-key indirection** (UserKey) | Good design | Password change re-wraps the content key only; no bulk re-encryption; limits exposure of the password-derived key. | +| **Entry-id AAD on vault items** | Good | Binds ciphertext to its logical slot; defeats cross-row swap. | +| **Per-file random key + chunk AAD** | Good | File key wrapped under content key; chunk AAD binds owner/item/file/revision/manifest-root/index/count → resists reorder, splice, cross-file reuse, truncation. | +| **SHA-256 manifest root + per-chunk hash** | Good | Detects storage-level tampering independent of GCM tag. | +| **ML-KEM-768 + RSA-OAEP hybrid** (sharing) | Forward-looking | Hybrid protects against "harvest now, decrypt later"; classical RSA retained for defence in depth. Versioned blob layout. _Port to DIS in phase 2._ | +| **Non-extractable CryptoKeys** | Good | Imported keys cannot be exported from WebCrypto. | +| **SecureBuffer** wiping | Best-effort | `fill(0)` + FinalizationRegistry fallback; cannot defeat GC/string immutability (residual risk R-1). | + +## Compliance with the mandated crypto rules + +| Rule | Status | +| --- | --- | +| Audited libraries only, no invented crypto | ✔ `hash-wasm`, WebCrypto, `@noble/post-quantum` | +| AEAD | ✔ AES-256-GCM everywhere | +| Unique nonces | ✔ fresh random IV per call (tested) | +| Format versioning | ✔ prefixed envelopes, fail-closed | +| Associated data | ✔ entry id / attachment context / chunk index | +| Strong KDF | ✔ Argon2id, versioned params | +| Key separation | ✔ KEK ≠ content key ≠ file key (HKDF info) | +| Key wrapping | ✔ `usk-wrap-v2` content-key wrapping | +| Secure randomness | ✔ WebCrypto CSPRNG, never `Math.random` | +| Negative tests | ✔ wrong key, tampered, AAD mismatch, downgrade | +| Test vectors | ◐ base64/SHA-256/KDF determinism vectors present; **production golden vectors pending** (phase 1) | +| Documented migrations | ✔ this doc + migration-plan.md | +| No static salts | ✔ per-account random salt; HKDF zero-salt justified above | +| No password-as-key | ✔ always via Argon2id | +| No unauthenticated CBC | ✔ GCM only | +| No global mutable crypto state | ✔ single injectable provider seam | +| No secrets in logs / no debug bypass / no hidden fallback | ✔ typed secret-free errors; legacy read only via explicit migration API | + +## Findings / recommendations + +- **F-1 (must, phase 1):** capture byte-exact golden vectors from production and + add cross-implementation decrypt tests before any app cutover (RK-1). +- **F-2 (should):** add property-based and fuzz tests for the envelope/manifest + parsers; add large-file streaming benchmarks. +- **F-3 (consider):** optional length-hiding padding as a future format version + if metadata-size leakage (R-2) becomes in-scope. +- **F-4 (verify):** confirm browser and Tauri use identical flows; no Tauri-only + crypto in shared logic (per testing-runtime.md). + +## Not verified + +- Real-world zero-knowledge end-to-end (depends on app transport, not DIS). +- Prevalence of legacy no-AAD payloads in production. +- Correctness of nonce handling in app code outside the extracted modules. diff --git a/docs/licensing.md b/docs/licensing.md new file mode 100644 index 0000000..6d0e90e --- /dev/null +++ b/docs/licensing.md @@ -0,0 +1,80 @@ +# Licensing, Branding & Trademark + +## Requirement + +> Open Source, aber NICHT frei für kommerzielle Nutzung. Lizenzierung +> vorbereiten. Branding: „Powered by DIS — Defensive Integration Shield". + +## Honest framing first + +A truly **OSI-"open source"** license (per the Open Source Definition, item 6 — +"No Discrimination Against Fields of Endeavor") **cannot** forbid commercial +use. So "open source but not free for commercial use" is, strictly, +**source-available / noncommercial**, not OSI-open-source. We use that term +honestly rather than mislabel the project. + +## Options evaluated + +| Option | Restricts commercial use? | OSI-approved? | npm/ecosystem fit | Verdict | +| --- | --- | --- | --- | --- | +| **AGPL-3.0** | No (only forces source disclosure for network use) | Yes | Good, but copyleft scares integrators and does **not** meet "not free for commercial use" | Rejected — wrong tool; doesn't restrict commercial use | +| **MIT/Apache + Commons Clause** | Yes (bans "selling") | No (Commons Clause negates OSI status) | Workable but ambiguous wording; "selling" is narrow and litigated | Rejected — vague scope, reputational baggage | +| **PolyForm Noncommercial 1.0.0** | **Yes, explicitly** | No (by design) | Plain-text, SPDX-listed (`PolyForm-Noncommercial-1.0.0`), purpose-built, clear definitions | **Chosen** | +| Custom EULA | Yes | No | Poor (untrusted, unaudited terms) | Rejected — never roll your own license | + +## Decision + +**Primary license: PolyForm Noncommercial 1.0.0** (see [`/LICENSE`](../LICENSE)). + +Rationale: +- It does exactly what is asked: **any noncommercial purpose is permitted; all + commercial use requires a separate license.** No ambiguity about "selling". +- It is professionally drafted (PolyForm), SPDX-recognised, and far clearer than + a Commons Clause bolt-on. +- It keeps the door open for **dual licensing**: the copyright holder may grant + commercial licenses separately (see below). + +**Commercial use: dual-licensing.** Commercial users obtain a separate +commercial license from the copyright holder. Because the licensor owns the +code (and requires a CLA from contributors), it can offer commercial terms +without conflict. This is the standard "open-core / source-available + commercial" +model. + +## package.json + +`"license": "SEE LICENSE IN LICENSE"` (npm-valid for non-OSI licenses) with the +full PolyForm text shipped in `LICENSE`. SPDX id for tooling: +`PolyForm-Noncommercial-1.0.0`. + +## Contributor rules (CLA) + +To preserve the ability to dual-license, every contributor must agree that: +1. They have the right to contribute the code. +2. They license their contribution to the project under PolyForm Noncommercial + **and** grant the maintainer the right to relicense it (including under + commercial terms). + +See [`CONTRIBUTING.md`](../CONTRIBUTING.md). + +## Branding rules + +- Applications using DIS **must display** "Powered by DIS — Defensive + Integration Shield" (exposed as the `DIS_BRANDING` constant). +- The attribution must remain legible and unaltered in wording. + +## Trademark notice + +"DIS — Defensive Integration Shield" and the Singra names are trademarks of the +maintainer. The license grants copyright/patent rights to the **software**; it +does **not** grant trademark rights. You may not use the names or marks to imply +endorsement, or for a fork/derivative, without written permission. Nominative +use ("compatible with DIS") is allowed. + +## Risks for future commercial use + +- **R-L1:** Noncommercial-only deters some OSS contributors. Accepted — fits the + product strategy. +- **R-L2:** PolyForm is not OSI-approved, so DIS is not "Open Source" in the + strict trademark sense; we label it "source-available". Accepted. +- **R-L3:** Without a CLA, accepted external contributions could block + relicensing. Mitigated by the mandatory CLA. diff --git a/docs/migration-plan.md b/docs/migration-plan.md new file mode 100644 index 0000000..159f037 --- /dev/null +++ b/docs/migration-plan.md @@ -0,0 +1,70 @@ +# Migration Plan + +How the applications move from in-tree crypto to consuming `@dis/shield` +exclusively, without re-encrypting existing data and without weakening any +security control. + +## Principles + +- **No data re-encryption** is required: DIS reproduces existing formats + byte-for-byte. +- **No format edits**, only additive versions. +- **No app cutover** until cross-implementation golden vectors pass. +- **No removal of legacy read paths** until production telemetry justifies it. + +## Format compatibility matrix + +| Format | Producer today | DIS reads | DIS writes | Notes | +| --- | --- | --- | --- | --- | +| `sv-vault-v1:` vault item (AAD=entryId) | Vault | ✔ | ✔ | runtime path requires AAD | +| legacy no-AAD vault item | Vault (old) | migration API only | ✘ | upgraded to v1 on migration | +| `usk-wrap-v2:` user key | Vault | ✔ | ✔ | HKDF info `singra-vault-wrap-v1` | +| legacy unprefixed user key | Vault (old) | ✔ (read) | ✘ | re-wrapped to v2 on next rotation | +| `sv-file-manifest-v1:` + chunks | Premium | ✔ | ✔ | chunk/manifest AAD preserved | +| hybrid PQ blob (`VERSION_HYBRID_STANDARD_V2`) | Vault | phase 2 | phase 2 | layout preserved | + +## Step-by-step + +### Phase 1 — Compatibility proof (gate) +1. From a production-like Vault/Premium instance, export one sample of each + format above (ciphertext only — never plaintext or keys). +2. Add `*.vectors.test.ts` in DIS: assert DIS decrypts each sample, and that the + app decrypts DIS-produced output for the same inputs. +3. Instrument the apps to count legacy (no-AAD / unprefixed) payloads. +4. **Gate:** all vector tests green; legacy prevalence known. + +### Phase 2 — PQ hybrid port +5. Port `pqCryptoService` into `@dis/shield/key-management` (PQ submodule), + preserving the version byte and blob layout; add round-trip + `migrateToHybrid` + tests. + +### Phase 3 — Vault cutover +6. Add `@dis/shield` as a **hard dependency** of Vault. +7. Add app-local `src/services/crypto/index.ts` adapter that re-exports DIS + symbols (keeps existing import sites stable initially). +8. Redirect ~35 call sites to the adapter / DIS facade. +9. Delete `cryptoService.ts`, `pqCryptoService.ts`, internal `secureBuffer.ts`. +10. Add ESLint rule banning `crypto.subtle` / `hash-wasm` / `@noble/post-quantum` + imports outside the adapter. +11. **Verify:** `npm run lint && npm run typecheck && npm test`; dev server + + Tauri smoke test (unlock, read/write item, password change). +12. **Build-fails-without-DIS:** confirm removing `@dis/shield` breaks build and + start (no fallback). + +### Phase 4 — Premium cutover +13. Replace the `../singravault/src` path alias (vitest/build config) with the + `@dis/shield` dependency. +14. Route `fileAttachmentService` through `@dis/shield/file-encryption` + (callbacks bridge to Supabase/Tauri storage). +15. Delete duplicated crypto; add the same lint ban. +16. **Verify:** Premium test suite + attachment upload/download/restore runtime + test; confirm build/start fail without DIS. + +### Phase 5 — Legacy retirement (later, optional) +17. Once telemetry shows zero legacy payloads (or all migrated), remove the + no-AAD migration fallback in a **major** DIS release with a documented note. + +## Rollback + +Each phase is a separate PR. If a regression appears, revert the app PR; DIS +remains installed and inert. No data is mutated by installation alone. diff --git a/docs/risk-analysis.md b/docs/risk-analysis.md new file mode 100644 index 0000000..84670ae --- /dev/null +++ b/docs/risk-analysis.md @@ -0,0 +1,56 @@ +# Risk Analysis & Refactor Prioritisation + +Risk = likelihood × impact of the **extraction** going wrong (data loss, +incompatibility, security regression). Used to order the refactor. + +## Risk register + +| ID | Risk | Likelihood | Impact | Priority | Mitigation | +| --- | --- | --- | --- | --- | --- | +| RK-1 | Byte-format drift between DIS and existing data → existing vaults undecryptable | Med | Critical | **P0** | Freeze format constants; golden test vectors captured from production; cross-impl decrypt test before cutover | +| RK-2 | Legacy no-AAD payloads silently fail after cutover | Med | High | **P0** | Explicit migration API + telemetry of legacy prevalence before removing fallback | +| RK-3 | KDF parameter mismatch → wrong key derived | Low | Critical | **P0** | Immutable versioned `KDF_PARAMS`, determinism tests | +| RK-4 | Premium path-alias (`../singravault/src`) replaced incorrectly → Premium can't resolve crypto | High | High | **P1** | Replace alias with `@dis/shield` dep; build must fail without it | +| RK-5 | Apps retain shadow crypto copies → drift returns | Med | High | **P1** | Delete app crypto modules; lint rule banning raw `crypto.subtle` outside adapter | +| RK-6 | PQ hybrid extraction breaks sharing/emergency access | Med | High | **P1** | Port with hybrid version byte preserved; round-trip + migrate-to-hybrid tests | +| RK-7 | Memory-wiping regression | Low | Med | P2 | Keep `SecureBuffer`; secret-leak tests | +| RK-8 | Supply-chain compromise of new dep | Low | High | P2 | Pin versions, CI audit, encapsulate behind provider | +| RK-9 | License choice blocks legit commercial future | Low | Med | P2 | Dual-licensing path documented (see licensing.md) | +| RK-10 | Web vs Tauri crypto divergence | Med | Med | P2 | WebCrypto is common path; test both runtimes before release | + +## Refactor order (by risk) + +**Phase 0 — Foundation (this PR).** Implement + test pure primitives in DIS +(encoding, random, secure-memory, kdf, aead, vault-encryption, key-management, +file-encryption, integrity, format-versioning, migrations). Docs + license + +CI. No app changes. _Status: done in this PR._ + +**Phase 1 — Compatibility proof.** Capture golden vectors from production +Vault/Premium (one each: vault item, wrapped user key, attachment manifest + +chunk, hybrid blob). Add cross-impl tests asserting DIS decrypts them and the +app decrypts DIS output. Audit legacy no-AAD prevalence. _Gate before any app +change._ + +**Phase 2 — PQ hybrid + remaining modules.** Port `pqCryptoService` (ML-KEM-768 ++ RSA) into `key-management` PQ submodule with version byte preserved. + +**Phase 3 — Vault cutover.** Introduce app-local `crypto` adapter that re-exports +from `@dis/shield`; redirect ~35 call sites; delete `cryptoService.ts`, +`pqCryptoService.ts`, `secureBuffer.ts` internals. Add lint ban on raw +`crypto.subtle`. Make `@dis/shield` a hard dependency (build fails without it). + +**Phase 4 — Premium cutover.** Replace `../singravault/src` path alias with +`@dis/shield`; route `fileAttachmentService` through `@dis/shield/file-encryption`. +Build/start must fail without DIS. + +**Phase 5 — Verification.** Full test suites in both apps; dev-server + Tauri +runtime smoke tests of encrypt/decrypt, attachment up/download, password change, +sharing. Record runtime evidence. + +## Hard gates (do not cross without sign-off) + +- No removal of legacy fallback until production telemetry shows zero (or a + migrated) legacy population. +- No app cutover until golden-vector cross-impl tests are green. +- No weakening of `device_key_required`, drift/quarantine, or integrity checks + (forbidden by AGENTS.md). diff --git a/docs/threat-model.md b/docs/threat-model.md new file mode 100644 index 0000000..6a9d9a1 --- /dev/null +++ b/docs/threat-model.md @@ -0,0 +1,65 @@ +# Threat Model + +Scope: the cryptographic layer (DIS) and the format/key decisions it owns. +Application-level threats are noted where DIS provides a control or where the +residual risk must be carried by the application. + +Notation: **M** = mitigated by DIS, **A** = app responsibility, **R** = +accepted residual risk. + +## Adversaries + +Remote attacker, malicious/compromised server or DB, stolen device, malicious +browser extension/app, network attacker, insider/support, supply-chain +attacker, future quantum-capable attacker. + +## Threats, controls, residual risk + +| # | Threat | Control | Status | +| --- | --- | --- | --- | +| 1 | **Server/DB reads vault** | Zero-knowledge: only ciphertext + wrapped keys stored; AES-256-GCM under content key | M | +| 2 | **Ciphertext swap between rows** | Entry-id used as AEAD AAD binds ciphertext to its row | M | +| 3 | **Weak password brute force** | Argon2id 128 MiB (v2), versioned upgrade path | M (A: password policy) | +| 4 | **Nonce reuse** | Fresh CSPRNG 96-bit IV per encryption; never counter-based; negative test asserts IV uniqueness | M | +| 5 | **Downgrade / format manipulation** | Explicit version prefixes; unknown in-family versions fail closed; legacy no-AAD blocked on runtime path | M | +| 6 | **Migration attack** (force read of weaker legacy) | No-AAD read only via explicit migration API, never runtime | M | +| 7 | **Replay / rollback of chunks** | Chunk AAD binds owner/item/file/revision/manifest-root/index/count; manifest root over layout | M | +| 8 | **Cross-file chunk splice** | AAD includes file id + manifest root; per-file random key | M | +| 9 | **Tampered ciphertext** | GCM auth tag; optional per-chunk SHA-256 verification | M | +| 10 | **Key reuse across purposes** | HKDF domain separation (`info`), distinct KEK/content/file keys | M | +| 11 | **Password change forces re-encrypt** (availability/timing leak) | Content-key indirection: rotate re-wraps only | M | +| 12 | **Memory exposure / dumps** | `SecureBuffer`, wiped buffers, non-extractable keys | M (R: JS/GC cannot guarantee wiping) | +| 13 | **Secrets in logs** | Typed errors carry no secrets; no logging in crypto path | M (A: app logging) | +| 14 | **Metadata leaks** (file name, MIME, size) | Manifest is encrypted under vault key (AAD-bound); only ciphertext sizes/hashes are public | M (R: ciphertext size ≈ plaintext size) | +| 15 | **Quantum "harvest now, decrypt later"** on shared material | ML-KEM-768 + RSA hybrid wrapping for sharing | M (phase 2 in DIS) | +| 16 | **Supply-chain (malicious dep)** | No invented crypto; pinned audited deps; CI dependency + secret scanning; minimal surface | M (R: trust in `hash-wasm`/`noble`/WebCrypto) | +| 17 | **Provider substitution attack** | Single provider seam; default is platform WebCrypto; override is explicit | M | +| 18 | **Device-trust bypass** | DIS exposes HKDF strengthening; enforcement of `device_key_required` is app policy | A | +| 19 | **Recovery/export leaks** | DIS provides only authenticated formats; export packaging + recovery flows are app | A | +| 20 | **Multi-device sync conflicts / drift** | Authenticated, versioned formats; conflict/drift resolution is app | A | +| 21 | **Admin/support access** | No backdoor; no key escrow in DIS | M | +| 22 | **CI/build pipeline tampering** | CI runs lint/typecheck/test/build + dep & secret scan; no secrets needed at build | M (A: branch protection) | +| 23 | **Clipboard exposure** | Out of crypto scope | A | +| 24 | **AEAD/padding oracle** | Single opaque `DisDecryptionError` for all decrypt failures | M | + +## Accepted residual risks + +- **R-1 Memory wiping is best-effort.** JS GC is non-deterministic and strings + are immutable; `SecureBuffer` reduces but cannot eliminate exposure. +- **R-2 Ciphertext length leaks approximate plaintext length** (no padding by + default). Acceptable for password/attachment use; padding can be added as a + future format version if a threat warrants it. +- **R-3 Primitive trust.** Soundness of Argon2id (`hash-wasm`), AES-GCM/HKDF + (WebCrypto), and ML-KEM-768 (`@noble/post-quantum`) is assumed; DIS does not + re-audit these. +- **R-4 Side channels in the host engine** (timing, cache) are out of scope for + a portable JS library; constant-time compares are used where DIS controls the + code. + +## Not verified (require evidence before claims) + +- Whether all current production payloads are already on `sv-vault-v1:` / + `usk-wrap-v2:` (legacy no-AAD prevalence). Needs a production data audit. +- Whether browser, Tauri, and any mobile clients use identical crypto flows. +- Whether nonce management elsewhere in the apps (outside the extracted code) + is correct. diff --git a/docs/trust-boundaries.md b/docs/trust-boundaries.md new file mode 100644 index 0000000..376dbf3 --- /dev/null +++ b/docs/trust-boundaries.md @@ -0,0 +1,62 @@ +# Trust-Boundary Analysis + +## Assets + +- Master password (never persisted, never leaves client). +- KDF output, KEK, content key (UserKey), file keys, device keys, recovery + secrets, RSA/ML-KEM private keys. +- Plaintext vault entries and attachment contents. +- Metadata: file names, MIME types, sizes, timestamps, item ids, category + structure. +- Verification hashes, wrapped keys, manifests (stored, but authenticated). + +## Trust boundaries + +1. **Client ↔ Server (zero-knowledge boundary).** The server stores only + ciphertext, salts, wrapped keys, verification hashes, and manifests. It must + never see the master password, KDF output, or any unwrapped key. DIS runs + entirely client-side; it has no network code, which keeps this boundary + structurally enforced. +2. **Application ↔ DIS.** Apps pass passwords/keys/plaintext in and receive + ciphertext/keys out. DIS owns all primitive use. The app owns persistence, + transport, and policy (e.g. when device-key is required). +3. **DIS ↔ crypto provider.** DIS depends on an audited `CryptoProvider` + (WebCrypto by default) and `hash-wasm`/`@noble/post-quantum`. These are the + trusted computing base for primitives. +4. **Browser/Tauri runtime ↔ OS.** Memory exposure, swap, and process dumps are + outside DIS's control; `SecureBuffer` is best-effort mitigation only. +5. **Storage transport (object store / local FS).** Treated as untrusted: + chunk AADs and the manifest root bind ciphertext to its place, so a storage + operator cannot reorder, splice, or swap chunks/files undetected. + +## Key lifecycle + +``` +master password ──Argon2id(salt, version)──▶ KDF output + │ │ + │ HKDF(info=wrap)──▶ KEK + │ │ + │ AES-GCM unwrap(usk-wrap-v2)──▶ content key (UserKey) + │ │ + │ content key ──▶ vault entries (sv-vault-v1, AAD=entryId) + │ content key ──▶ wraps file keys (AAD=file-key-v1) + └─ optional HKDF strengthen with device key (info=SINGRA_DEVICE_KEY_V1) + +password change / rotation: re-wrap content key only (rotateEncryptionKeys); +no vault data is re-encrypted. +``` + +## Boundary rules enforced by DIS + +- AEAD failures do not reveal cause (no oracle). +- Unknown format versions fail closed. +- Legacy no-AAD payloads are unreadable on the runtime path; only the explicit + migration helper may read them. +- Key material is wiped after use; `CryptoKey`s are non-extractable. + +## Out of DIS scope (app responsibility) + +- Deciding device-key/passkey requirements and enforcing lock/logout state. +- Drift detection, quarantine, integrity-recovery orchestration. +- Where/whether to store salts, wrapped keys, manifests; access control. +- Clipboard, autofill, screenshot, and UI-level exposure. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..064afc1 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,28 @@ +import tseslint from '@typescript-eslint/eslint-plugin'; +import tsparser from '@typescript-eslint/parser'; + +export default [ + { + ignores: ['dist/**', 'node_modules/**', 'coverage/**'], + }, + { + files: ['src/**/*.ts'], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, + }, + plugins: { + '@typescript-eslint': tseslint, + }, + rules: { + 'no-restricted-globals': 'off', + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/explicit-module-boundary-types': 'off', + 'no-console': ['error', { allow: ['warn', 'error'] }], + eqeqeq: ['error', 'always'], + }, + }, +]; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a9e4d81 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3538 @@ +{ + "name": "@dis/shield", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@dis/shield", + "version": "0.1.0", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "hash-wasm": "4.12.0" + }, + "devDependencies": { + "@noble/post-quantum": "0.5.4", + "@types/node": "^20.19.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^9.0.0", + "tsup": "^8.5.1", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@noble/post-quantum": "^0.5.0" + }, + "peerDependenciesMeta": { + "@noble/post-quantum": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.5.4.tgz", + "integrity": "sha512-leww0zzIirrvwaYMPI9fj6aRIlA/c6Y0/lifQQ1YOOyHEr0MNH3yYpjXeiVG+tWdPps4XxGclFWX2INPO3Yo5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/curves": "~2.0.0", + "@noble/hashes": "~2.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.60.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hash-wasm": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz", + "integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==", + "license": "MIT" + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsup/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9f12491 --- /dev/null +++ b/package.json @@ -0,0 +1,113 @@ +{ + "name": "@dis/shield", + "version": "0.1.0", + "description": "DIS — Defensive Integration Shield. Central, framework-agnostic cryptographic security platform (KDF, AEAD, vault & file encryption, key management, versioned formats, migrations). Powered by DIS — Defensive Integration Shield.", + "license": "SEE LICENSE IN LICENSE", + "type": "module", + "engines": { + "node": ">=20.19.0" + }, + "sideEffects": false, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./core": { + "types": "./dist/core/index.d.ts", + "import": "./dist/core/index.js" + }, + "./random": { + "types": "./dist/random/index.d.ts", + "import": "./dist/random/index.js" + }, + "./secure-memory": { + "types": "./dist/secure-memory/index.d.ts", + "import": "./dist/secure-memory/index.js" + }, + "./kdf": { + "types": "./dist/kdf/index.d.ts", + "import": "./dist/kdf/index.js" + }, + "./aead": { + "types": "./dist/aead/index.d.ts", + "import": "./dist/aead/index.js" + }, + "./format-versioning": { + "types": "./dist/format-versioning/index.d.ts", + "import": "./dist/format-versioning/index.js" + }, + "./vault-encryption": { + "types": "./dist/vault-encryption/index.d.ts", + "import": "./dist/vault-encryption/index.js" + }, + "./file-encryption": { + "types": "./dist/file-encryption/index.d.ts", + "import": "./dist/file-encryption/index.js" + }, + "./key-management": { + "types": "./dist/key-management/index.d.ts", + "import": "./dist/key-management/index.js" + }, + "./integrity": { + "types": "./dist/integrity/index.d.ts", + "import": "./dist/integrity/index.js" + }, + "./migrations": { + "types": "./dist/migrations/index.d.ts", + "import": "./dist/migrations/index.js" + } + }, + "scripts": { + "build": "tsup", + "prepare": "npm run build", + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "hash-wasm": "4.12.0" + }, + "peerDependencies": { + "@noble/post-quantum": "^0.5.0" + }, + "peerDependenciesMeta": { + "@noble/post-quantum": { + "optional": true + } + }, + "devDependencies": { + "@noble/post-quantum": "0.5.4", + "@types/node": "^20.19.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^9.0.0", + "tsup": "^8.5.1", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + }, + "keywords": [ + "cryptography", + "encryption", + "aead", + "aes-gcm", + "argon2id", + "kdf", + "key-management", + "zero-knowledge", + "vault", + "password-manager", + "post-quantum", + "ml-kem", + "security" + ] +} diff --git a/src/aead/aead.test.ts b/src/aead/aead.test.ts new file mode 100644 index 0000000..2a828d3 --- /dev/null +++ b/src/aead/aead.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { decryptBytes, decryptString, encryptBytes, encryptString } from './index.js'; +import { importAesGcmKey } from '../kdf/index.js'; +import { base64ToBytes } from '../core/encoding.js'; +import { randomBytes } from '../random/index.js'; +import { DisDecryptionError, DisInvalidArgumentError } from '../core/errors.js'; + +async function freshKey(): Promise { + return importAesGcmKey(randomBytes(32)); +} + +describe('aead AES-256-GCM', () => { + it('round-trips strings', async () => { + const key = await freshKey(); + const ct = await encryptString('hello world', key); + expect(await decryptString(ct, key)).toBe('hello world'); + }); + + it('round-trips bytes', async () => { + const key = await freshKey(); + const data = randomBytes(100); + const ct = await encryptBytes(data, key); + const pt = await decryptBytes(ct, key); + expect([...pt]).toEqual([...data]); + }); + + it('uses a fresh random IV per call (no nonce reuse)', async () => { + const key = await freshKey(); + const a = await encryptString('same', key); + const b = await encryptString('same', key); + expect(a).not.toBe(b); + // First 12 bytes are the IV; they must differ. + expect(base64ToBytes(a).slice(0, 12)).not.toEqual(base64ToBytes(b).slice(0, 12)); + }); + + it('authenticates AAD: wrong AAD fails to decrypt', async () => { + const key = await freshKey(); + const ct = await encryptString('secret', key, 'entry-1'); + await expect(decryptString(ct, key, 'entry-2')).rejects.toBeInstanceOf(DisDecryptionError); + await expect(decryptString(ct, key)).rejects.toBeInstanceOf(DisDecryptionError); + }); + + it('fails closed on tampered ciphertext', async () => { + const key = await freshKey(); + const ct = await encryptString('secret', key); + const bytes = base64ToBytes(ct); + bytes[bytes.length - 1] ^= 0xff; + const tampered = btoa(String.fromCharCode(...bytes)); + await expect(decryptString(tampered, key)).rejects.toBeInstanceOf(DisDecryptionError); + }); + + it('rejects too-short ciphertext', async () => { + const key = await freshKey(); + await expect(decryptBytes('AAAA', key)).rejects.toBeInstanceOf(DisInvalidArgumentError); + }); + + it('does not decrypt under a different key', async () => { + const ct = await encryptString('secret', await freshKey()); + await expect(decryptString(ct, await freshKey())).rejects.toBeInstanceOf(DisDecryptionError); + }); +}); diff --git a/src/aead/index.ts b/src/aead/index.ts new file mode 100644 index 0000000..575567a --- /dev/null +++ b/src/aead/index.ts @@ -0,0 +1,125 @@ +/** + * dis-aead — authenticated encryption with associated data. + * + * Primitive: AES-256-GCM via WebCrypto. Output format is + * `base64(IV(12) || ciphertext || authTag(16))`, byte-compatible with Singra + * Vault. A fresh random 96-bit IV is generated per call. Associated data (AAD) + * is authenticated but not stored; the same AAD must be supplied on decrypt, + * which lets callers bind ciphertext to a context (e.g. an entry id) and defeat + * ciphertext-swap attacks. + */ + +import { subtle, getCryptoProvider } from '../core/provider.js'; +import { + base64ToBytes, + bytesToBase64, + bytesToUtf8, + utf8ToBytes, +} from '../core/encoding.js'; +import { AES_GCM_IV_LENGTH, AES_GCM_TAG_LENGTH } from '../core/constants.js'; +import { DisDecryptionError, DisInvalidArgumentError } from '../core/errors.js'; + +function aad(value?: string): Uint8Array | undefined { + return value ? utf8ToBytes(value) : undefined; +} + +/** Encrypts bytes with AES-256-GCM. Caller still owns/wipes `plaintextBytes`. */ +export async function encryptBytes( + plaintextBytes: Uint8Array, + key: CryptoKey, + associatedData?: string, +): Promise { + const iv = new Uint8Array(AES_GCM_IV_LENGTH); + getCryptoProvider().getRandomValues(iv); + const additionalData = aad(associatedData); + let ciphertextBytes: Uint8Array | null = null; + let combined: Uint8Array | null = null; + try { + const ciphertext = await subtle().encrypt( + { + name: 'AES-GCM', + iv, + tagLength: AES_GCM_TAG_LENGTH, + ...(additionalData && { additionalData }), + }, + key, + plaintextBytes as BufferSource, + ); + ciphertextBytes = new Uint8Array(ciphertext); + combined = new Uint8Array(iv.length + ciphertextBytes.byteLength); + combined.set(iv, 0); + combined.set(ciphertextBytes, iv.length); + return bytesToBase64(combined); + } finally { + iv.fill(0); + additionalData?.fill(0); + ciphertextBytes?.fill(0); + combined?.fill(0); + } +} + +/** Decrypts AES-256-GCM bytes. Returned buffer is secret — caller must wipe it. */ +export async function decryptBytes( + encryptedBase64: string, + key: CryptoKey, + associatedData?: string, +): Promise { + const combined = base64ToBytes(encryptedBase64); + if (combined.length <= AES_GCM_IV_LENGTH) { + combined.fill(0); + throw new DisInvalidArgumentError('Invalid encrypted data'); + } + const iv = combined.slice(0, AES_GCM_IV_LENGTH); + const ciphertext = combined.slice(AES_GCM_IV_LENGTH); + const additionalData = aad(associatedData); + try { + const plaintext = await subtle().decrypt( + { + name: 'AES-GCM', + iv, + tagLength: AES_GCM_TAG_LENGTH, + ...(additionalData && { additionalData }), + }, + key, + ciphertext as BufferSource, + ); + return new Uint8Array(plaintext); + } catch { + // Do not distinguish wrong-key / tampered / AAD-mismatch (no oracle). + throw new DisDecryptionError(); + } finally { + combined.fill(0); + iv.fill(0); + ciphertext.fill(0); + additionalData?.fill(0); + } +} + +/** Encrypts a UTF-8 string. The intermediate plaintext bytes are wiped. */ +export async function encryptString( + plaintext: string, + key: CryptoKey, + associatedData?: string, +): Promise { + const plaintextBytes = utf8ToBytes(plaintext); + try { + return await encryptBytes(plaintextBytes, key, associatedData); + } finally { + plaintextBytes.fill(0); + } +} + +/** Decrypts to a UTF-8 string. The intermediate plaintext bytes are wiped. */ +export async function decryptString( + encryptedBase64: string, + key: CryptoKey, + associatedData?: string, +): Promise { + let plaintextBytes: Uint8Array | null = null; + try { + plaintextBytes = await decryptBytes(encryptedBase64, key, associatedData); + return bytesToUtf8(plaintextBytes); + } finally { + plaintextBytes?.fill(0); + } +} diff --git a/src/core/constants.ts b/src/core/constants.ts new file mode 100644 index 0000000..b2d9d0f --- /dev/null +++ b/src/core/constants.ts @@ -0,0 +1,19 @@ +/** + * Cryptographic constants shared across DIS modules. + * + * These values are part of the on-the-wire / on-disk format contract. Changing + * any released constant is a breaking format change and MUST be expressed as a + * new format version, never an in-place edit. + */ + +/** AES-GCM IV length in bytes (96 bits — the recommended GCM nonce size). */ +export const AES_GCM_IV_LENGTH = 12; + +/** AES-GCM authentication tag length in bits. */ +export const AES_GCM_TAG_LENGTH = 128; + +/** Symmetric key length in bytes (AES-256). */ +export const AES_KEY_LENGTH = 32; + +/** Salt length in bytes (128 bits) for password-based key derivation. */ +export const KDF_SALT_LENGTH = 16; diff --git a/src/core/encoding.test.ts b/src/core/encoding.test.ts new file mode 100644 index 0000000..2bb9c53 --- /dev/null +++ b/src/core/encoding.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { base64ToBytes, bytesToBase64, bytesToUtf8, concatBytes, utf8ToBytes } from './encoding.js'; + +describe('encoding', () => { + it('round-trips base64', () => { + const data = new Uint8Array([0, 1, 2, 255, 254, 128]); + expect([...base64ToBytes(bytesToBase64(data))]).toEqual([...data]); + }); + + it('matches known base64 vectors', () => { + expect(bytesToBase64(utf8ToBytes('hello'))).toBe('aGVsbG8='); + expect(bytesToUtf8(base64ToBytes('aGVsbG8='))).toBe('hello'); + }); + + it('handles large buffers without stack overflow', () => { + const big = new Uint8Array(200000).map((_, i) => i % 256); + expect([...base64ToBytes(bytesToBase64(big))]).toEqual([...big]); + }); + + it('concatenates byte arrays', () => { + expect([...concatBytes(new Uint8Array([1]), new Uint8Array([2, 3]))]).toEqual([1, 2, 3]); + }); +}); diff --git a/src/core/encoding.ts b/src/core/encoding.ts new file mode 100644 index 0000000..7b7c830 --- /dev/null +++ b/src/core/encoding.ts @@ -0,0 +1,60 @@ +/** + * Byte/string/base64 encoding helpers. + * + * These intentionally reproduce the exact base64 encoding used by Singra Vault + * and Singra Premium so that DIS is byte-compatible with already-stored + * ciphertext. Do not "optimise" the algorithm in a way that changes output. + */ + +/** + * Encodes bytes to a standard (RFC 4648) base64 string. + * + * Uses a chunked loop over `String.fromCharCode` + `btoa` to stay identical to + * the legacy `uint8ArrayToBase64` implementation while avoiding call-stack + * overflows on large inputs. + */ +export function bytesToBase64(bytes: Uint8Array): string { + let binary = ''; + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + binary += String.fromCharCode(...chunk); + } + return btoa(binary); +} + +/** Decodes a standard base64 string to bytes. */ +export function base64ToBytes(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +/** UTF-8 encodes a string to bytes. */ +export function utf8ToBytes(text: string): Uint8Array { + return textEncoder.encode(text); +} + +/** UTF-8 decodes bytes to a string. */ +export function bytesToUtf8(bytes: Uint8Array): string { + return textDecoder.decode(bytes); +} + +/** Concatenates byte arrays into a single new buffer. */ +export function concatBytes(...parts: Uint8Array[]): Uint8Array { + let total = 0; + for (const part of parts) total += part.length; + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} diff --git a/src/core/errors.ts b/src/core/errors.ts new file mode 100644 index 0000000..5b8c2c4 --- /dev/null +++ b/src/core/errors.ts @@ -0,0 +1,74 @@ +/** + * Central, typed error hierarchy for DIS. + * + * Errors never include secret material (keys, plaintext, passwords) in their + * message or properties. Callers may safely log `error.code` and `error.message`. + */ + +export type DisErrorCode = + | 'INVALID_ARGUMENT' + | 'UNSUPPORTED_FORMAT_VERSION' + | 'DECRYPTION_FAILED' + | 'INTEGRITY_CHECK_FAILED' + | 'KEY_DERIVATION_FAILED' + | 'UNSUPPORTED_KDF_VERSION' + | 'LEGACY_PAYLOAD_REQUIRES_MIGRATION' + | 'PROVIDER_UNAVAILABLE' + | 'USE_AFTER_DESTROY'; + +/** Base class for all errors thrown by DIS. */ +export class DisError extends Error { + readonly code: DisErrorCode; + + constructor(code: DisErrorCode, message: string) { + super(message); + this.name = 'DisError'; + this.code = code; + // Maintain prototype chain when compiled to older targets. + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Thrown when an argument is missing or structurally invalid. */ +export class DisInvalidArgumentError extends DisError { + constructor(message: string) { + super('INVALID_ARGUMENT', message); + this.name = 'DisInvalidArgumentError'; + } +} + +/** + * Thrown when AEAD decryption or authentication fails. The cause (wrong key, + * tampered ciphertext, or AAD mismatch) is intentionally not distinguished to + * avoid leaking an oracle. + */ +export class DisDecryptionError extends DisError { + constructor(message = 'Decryption failed') { + super('DECRYPTION_FAILED', message); + this.name = 'DisDecryptionError'; + } +} + +/** Thrown when a versioned payload carries a version DIS cannot read. */ +export class DisUnsupportedFormatVersionError extends DisError { + constructor(message: string) { + super('UNSUPPORTED_FORMAT_VERSION', message); + this.name = 'DisUnsupportedFormatVersionError'; + } +} + +/** Thrown when an integrity / hash verification fails. */ +export class DisIntegrityError extends DisError { + constructor(message = 'Integrity check failed') { + super('INTEGRITY_CHECK_FAILED', message); + this.name = 'DisIntegrityError'; + } +} + +/** Thrown when a legacy, non-migratable payload is read on a runtime path. */ +export class DisLegacyPayloadError extends DisError { + constructor(message = 'Legacy payload requires explicit migration') { + super('LEGACY_PAYLOAD_REQUIRES_MIGRATION', message); + this.name = 'DisLegacyPayloadError'; + } +} diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 0000000..14abffd --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,9 @@ +/** + * dis-core — shared primitives, errors, encoding, and the crypto provider + * abstraction. Contains no application-specific logic. + */ + +export * from './errors.js'; +export * from './encoding.js'; +export * from './constants.js'; +export * from './provider.js'; diff --git a/src/core/provider.ts b/src/core/provider.ts new file mode 100644 index 0000000..2152113 --- /dev/null +++ b/src/core/provider.ts @@ -0,0 +1,54 @@ +/** + * Pluggable crypto provider abstraction. + * + * DIS does not invent cryptography. It binds to an audited primitive provider. + * The default provider is the platform WebCrypto implementation + * (`globalThis.crypto`), which is available in modern browsers and in Node >= 20. + * + * Exposing this as an interface keeps the door open for substituting a + * hardware-backed or test provider without touching call sites, and keeps the + * rest of DIS free of direct global access. + */ + +import { DisError } from './errors.js'; + +/** Minimal subset of the WebCrypto API that DIS relies on. */ +export interface CryptoProvider { + getRandomValues(array: T): T; + readonly subtle: SubtleCrypto; +} + +let activeProvider: CryptoProvider | null = null; + +function resolvePlatformProvider(): CryptoProvider { + const candidate = (globalThis as { crypto?: Crypto }).crypto; + if (!candidate || typeof candidate.getRandomValues !== 'function' || !candidate.subtle) { + throw new DisError( + 'PROVIDER_UNAVAILABLE', + 'No WebCrypto provider available. Provide one via setCryptoProvider().', + ); + } + return candidate; +} + +/** Returns the active crypto provider, falling back to platform WebCrypto. */ +export function getCryptoProvider(): CryptoProvider { + if (!activeProvider) { + activeProvider = resolvePlatformProvider(); + } + return activeProvider; +} + +/** + * Overrides the active crypto provider. Intended for tests and for + * environments that supply a non-global WebCrypto implementation. + * Pass `null` to reset to platform auto-detection. + */ +export function setCryptoProvider(provider: CryptoProvider | null): void { + activeProvider = provider; +} + +/** Convenience accessor for `SubtleCrypto`. */ +export function subtle(): SubtleCrypto { + return getCryptoProvider().subtle; +} diff --git a/src/file-encryption/file-encryption.test.ts b/src/file-encryption/file-encryption.test.ts new file mode 100644 index 0000000..3f4ef92 --- /dev/null +++ b/src/file-encryption/file-encryption.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import { + decryptAttachment, + encryptAttachment, + type AttachmentContext, + type FileManifestV1, +} from './index.js'; +import { decryptBytes, encryptBytes } from '../aead/index.js'; +import { importAesGcmKey } from '../kdf/index.js'; +import { randomBytes } from '../random/index.js'; + +const ctx: AttachmentContext = { ownerId: 'owner-1', vaultItemId: 'item-1', fileId: 'file-1' }; + +async function makeVaultKey(): Promise { + return importAesGcmKey(randomBytes(32)); +} + +/** In-memory chunk store used to exercise the storage-agnostic API. */ +function makeStore() { + const store = new Map(); + return { + store, + writeChunk: async (index: number, ciphertext: string) => { + store.set(index, ciphertext); + return ciphertext.length; + }, + readChunk: async (index: number) => { + const v = store.get(index); + if (!v) throw new Error(`missing chunk ${index}`); + return v; + }, + }; +} + +describe('attachment encryption (chunked)', () => { + it('round-trips a multi-chunk file', async () => { + const vaultKey = await makeVaultKey(); + const plaintext = randomBytes(10 * 1024); // > 1 chunk at small chunk size + const store = makeStore(); + + const { manifest } = await encryptAttachment({ + context: ctx, + chunkSize: 4096, + totalSize: plaintext.length, + readChunk: async (start, end) => plaintext.slice(start, end), + writeChunk: store.writeChunk, + wrapFileKey: (bytes, aad) => encryptBytes(bytes, vaultKey, aad), + metadata: { original_name: 'secret.bin', mime_type: null, last_modified: null }, + }); + + expect(manifest.chunk_count).toBe(3); + expect(manifest.original_size).toBe(plaintext.length); + + const out: number[] = []; + await decryptAttachment({ + context: ctx, + manifest, + readChunk: async (index) => store.readChunk(index), + writeChunk: async (_i, chunk) => { + out.push(...chunk); + }, + unwrapFileKey: (enc, aad) => decryptBytes(enc, vaultKey, aad), + }); + expect(out).toEqual([...plaintext]); + }); + + it('detects a tampered ciphertext chunk', async () => { + const vaultKey = await makeVaultKey(); + const plaintext = randomBytes(2048); + const store = makeStore(); + const { manifest } = await encryptAttachment({ + context: ctx, + chunkSize: 4096, + totalSize: plaintext.length, + readChunk: async (start, end) => plaintext.slice(start, end), + writeChunk: store.writeChunk, + wrapFileKey: (bytes, aad) => encryptBytes(bytes, vaultKey, aad), + metadata: { original_name: 'a', mime_type: null, last_modified: null }, + }); + // Corrupt stored chunk 0. + store.store.set(0, store.store.get(0)!.slice(0, -2) + 'AB'); + + await expect( + decryptAttachment({ + context: ctx, + manifest, + readChunk: async (index) => store.readChunk(index), + writeChunk: async () => {}, + unwrapFileKey: (enc, aad) => decryptBytes(enc, vaultKey, aad), + }), + ).rejects.toBeTruthy(); + }); + + it('rejects decryption under a different attachment context (AAD binding)', async () => { + const vaultKey = await makeVaultKey(); + const plaintext = randomBytes(1024); + const store = makeStore(); + const { manifest } = await encryptAttachment({ + context: ctx, + chunkSize: 4096, + totalSize: plaintext.length, + readChunk: async (start, end) => plaintext.slice(start, end), + writeChunk: store.writeChunk, + wrapFileKey: (bytes, aad) => encryptBytes(bytes, vaultKey, aad), + metadata: { original_name: 'a', mime_type: null, last_modified: null }, + }); + + const wrongCtx: AttachmentContext = { ...ctx, vaultItemId: 'item-2' }; + const tampered: FileManifestV1 = { ...manifest }; + await expect( + decryptAttachment({ + context: wrongCtx, + manifest: tampered, + readChunk: async (index) => store.readChunk(index), + writeChunk: async () => {}, + unwrapFileKey: (enc, aad) => decryptBytes(enc, vaultKey, aad), + verifyChunkHashes: false, + }), + ).rejects.toBeTruthy(); + }); +}); diff --git a/src/file-encryption/index.ts b/src/file-encryption/index.ts new file mode 100644 index 0000000..c098241 --- /dev/null +++ b/src/file-encryption/index.ts @@ -0,0 +1,319 @@ +/** + * dis-file-encryption / dis-attachment-streams — chunked file & attachment + * encryption. + * + * Model (byte-compatible with Singra Premium): + * - A fresh random per-file AES-256 key encrypts the file content. + * - The file is split into fixed-size chunks; each chunk is sealed with + * AES-256-GCM and a per-chunk AAD binding it to owner/item/file/revision/ + * manifest-root/index/count (defeats reorder, splice and cross-file swap). + * - The file key is wrapped by an outer vault key (supplied as a callback), + * bound by a file-key AAD. + * - A manifest records chunk hashes and a manifest root; it is sealed with + * the vault key under a manifest AAD and wrapped in `sv-file-manifest-v1:`. + * + * DIS owns the cryptography and the format. It is storage-agnostic: callers + * supply chunk read/write callbacks, so transport (e.g. object storage, local + * FS) stays in the application. + */ + +import { decryptBytes, encryptBytes } from '../aead/index.js'; +import { importAesGcmKey } from '../kdf/index.js'; +import { randomBytes } from '../random/index.js'; +import { sha256Base64, sha256JsonBase64 } from '../integrity/index.js'; +import { AES_KEY_LENGTH } from '../core/constants.js'; +import { DisInvalidArgumentError } from '../core/errors.js'; + +/** Default chunk size (4 MiB), matching the Singra Premium format. */ +export const DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024; + +export const FILE_MANIFEST_V1_PREFIX = 'sv-file-manifest-v1:'; + +/** Opaque binding context for an attachment. Ids are treated as opaque strings. */ +export interface AttachmentContext { + readonly ownerId: string; + readonly vaultItemId: string; + readonly fileId: string; +} + +/** Encrypts text with the outer vault key (e.g. DIS aead.encryptString). */ +export type VaultEncryptText = (plaintext: string, aad?: string) => Promise; +export type VaultDecryptText = (encrypted: string, aad?: string) => Promise; +export type VaultEncryptBytes = (plaintext: Uint8Array, aad?: string) => Promise; +export type VaultDecryptBytes = (encrypted: string, aad?: string) => Promise; + +export interface FileChunkManifest { + readonly index: number; + readonly plaintext_size: number; + readonly ciphertext_size: number; + readonly ciphertext_sha256: string; +} + +export interface FileManifestV1 { + readonly version: 1; + readonly algorithm: 'AES-256-GCM'; + readonly file_id: string; + readonly file_revision: number; + readonly previous_manifest_hash: string | null; + readonly manifest_root: string; + readonly owner_id: string; + readonly vault_item_id: string; + readonly original_name: string; + readonly mime_type: string | null; + readonly original_size: number; + readonly last_modified: number | null; + readonly uploaded_at: string; + readonly chunk_size: number; + readonly chunk_count: number; + readonly wrapped_file_key: string; + readonly chunks: readonly FileChunkManifest[]; + readonly preview: null; + readonly notes: null; +} + +// ---- Canonical AAD scheme (format contract) ------------------------------- + +export function manifestAad(ctx: AttachmentContext): string { + return `sv-file-manifest-v1:${ctx.ownerId}:${ctx.vaultItemId}:${ctx.fileId}`; +} + +export function fileKeyAad(ctx: AttachmentContext): string { + return `sv-file-key-v1:${ctx.ownerId}:${ctx.vaultItemId}:${ctx.fileId}`; +} + +export function chunkAad( + ctx: AttachmentContext, + fileRevision: number, + manifestRoot: string, + chunkIndex: number, + chunkCount: number, +): string { + return ( + `sv-file-chunk-v1:${ctx.ownerId}:${ctx.vaultItemId}:${ctx.fileId}:` + + `${fileRevision}:${manifestRoot}:${chunkIndex}:${chunkCount}` + ); +} + +// ---- File-key handling ---------------------------------------------------- + +/** Generates fresh per-file AES-256 key bytes (caller must wipe). */ +export function generateFileKeyBytes(): Uint8Array { + return randomBytes(AES_KEY_LENGTH); +} + +/** Imports raw file-key bytes as a non-extractable AES-GCM key. */ +export function importFileKey(rawKey: Uint8Array): Promise { + return importAesGcmKey(rawKey); +} + +// ---- Chunk crypto --------------------------------------------------------- + +/** Seals one plaintext chunk. `plaintext` is wiped before returning. */ +export async function encryptChunk( + plaintext: Uint8Array, + fileKey: CryptoKey, + aad: string, +): Promise { + try { + return await encryptBytes(plaintext, fileKey, aad); + } finally { + plaintext.fill(0); + } +} + +/** Opens one chunk. Returned bytes are plaintext — caller must wipe. */ +export async function decryptChunk( + encryptedBase64: string, + fileKey: CryptoKey, + aad: string, +): Promise { + return decryptBytes(encryptedBase64, fileKey, aad); +} + +// ---- Manifest helpers ----------------------------------------------------- + +interface PlannedChunk { + readonly index: number; + readonly plaintext_size: number; +} + +/** + * Computes the manifest root: a SHA-256 over the planned chunk layout. Binding + * each chunk's AAD to this root prevents chunk-count / size tampering. + */ +export async function computeManifestRoot(input: { + fileId: string; + fileRevision: number; + chunkSize: number; + chunkCount: number; + chunks: readonly PlannedChunk[]; +}): Promise { + return sha256JsonBase64({ + file_id: input.fileId, + file_revision: input.fileRevision, + chunk_size: input.chunkSize, + chunk_count: input.chunkCount, + chunks: input.chunks.map((c) => ({ + index: c.index, + storage_path: undefined, + plaintext_size: c.plaintext_size, + })), + }); +} + +/** SHA-256 (base64) of a ciphertext chunk, for the manifest. */ +export function chunkCiphertextHash(ciphertextBase64: string): Promise { + return sha256Base64(new TextEncoder().encode(ciphertextBase64)); +} + +export interface EncryptAttachmentInput { + readonly context: AttachmentContext; + readonly fileRevision?: number; + readonly chunkSize?: number; + /** Total plaintext size in bytes (used to plan chunks). */ + readonly totalSize: number; + /** Returns the plaintext bytes for chunk `[start, end)`. */ + readonly readChunk: (start: number, end: number) => Promise; + /** Persists a sealed chunk; returns its stored ciphertext size in bytes. */ + readonly writeChunk: (index: number, ciphertextBase64: string) => Promise; + /** Wraps the per-file key with the outer vault key. */ + readonly wrapFileKey: VaultEncryptBytes; + readonly metadata: { + readonly original_name: string; + readonly mime_type: string | null; + readonly last_modified: number | null; + }; +} + +export interface EncryptAttachmentResult { + readonly manifest: FileManifestV1; + readonly manifestRoot: string; +} + +/** + * Encrypts an attachment chunk-by-chunk and produces a sealed manifest. + * Storage is delegated to `readChunk`/`writeChunk`. The returned manifest's + * `wrapped_file_key` is bound to the file via {@link fileKeyAad}. + */ +export async function encryptAttachment( + input: EncryptAttachmentInput, +): Promise { + const chunkSize = input.chunkSize ?? DEFAULT_CHUNK_SIZE; + if (chunkSize <= 0) throw new DisInvalidArgumentError('chunkSize must be positive'); + const fileRevision = input.fileRevision ?? 1; + const chunkCount = Math.max(1, Math.ceil(input.totalSize / chunkSize)); + const ctx = input.context; + + const plannedChunks: PlannedChunk[] = Array.from({ length: chunkCount }, (_, index) => { + const start = index * chunkSize; + const end = Math.min(input.totalSize, start + chunkSize); + return { index, plaintext_size: end - start }; + }); + const manifestRoot = await computeManifestRoot({ + fileId: ctx.fileId, + fileRevision, + chunkSize, + chunkCount, + chunks: plannedChunks, + }); + + const fileKeyBytes = generateFileKeyBytes(); + const fileKey = await importFileKey(fileKeyBytes); + const chunks: FileChunkManifest[] = []; + try { + const wrappedFileKey = await input.wrapFileKey(fileKeyBytes, fileKeyAad(ctx)); + for (let index = 0; index < chunkCount; index += 1) { + const start = index * chunkSize; + const end = Math.min(input.totalSize, start + chunkSize); + const plaintext = await input.readChunk(start, end); + const aad = chunkAad(ctx, fileRevision, manifestRoot, index, chunkCount); + const ciphertext = await encryptChunk(plaintext, fileKey, aad); + const ciphertextSize = await input.writeChunk(index, ciphertext); + chunks.push({ + index, + plaintext_size: plannedChunks[index]!.plaintext_size, + ciphertext_size: ciphertextSize, + ciphertext_sha256: await chunkCiphertextHash(ciphertext), + }); + } + + const manifest: FileManifestV1 = { + version: 1, + algorithm: 'AES-256-GCM', + file_id: ctx.fileId, + file_revision: fileRevision, + previous_manifest_hash: null, + manifest_root: manifestRoot, + owner_id: ctx.ownerId, + vault_item_id: ctx.vaultItemId, + original_name: input.metadata.original_name, + mime_type: input.metadata.mime_type, + original_size: input.totalSize, + last_modified: input.metadata.last_modified, + uploaded_at: new Date().toISOString(), + chunk_size: chunkSize, + chunk_count: chunkCount, + wrapped_file_key: wrappedFileKey, + chunks, + preview: null, + notes: null, + }; + return { manifest, manifestRoot }; + } finally { + fileKeyBytes.fill(0); + } +} + +export interface DecryptAttachmentInput { + readonly context: AttachmentContext; + readonly manifest: FileManifestV1; + /** Reads a stored ciphertext chunk by index. */ + readonly readChunk: (index: number, storedSha256: string) => Promise; + /** Receives a decrypted plaintext chunk (caller may stream to disk). */ + readonly writeChunk: (index: number, plaintext: Uint8Array) => Promise; + /** Unwraps the per-file key using the outer vault key. */ + readonly unwrapFileKey: VaultDecryptBytes; + /** If true (default), verify each chunk's stored ciphertext hash. */ + readonly verifyChunkHashes?: boolean; +} + +/** + * Decrypts an attachment by streaming chunks through `readChunk`/`writeChunk`. + * Each chunk is authenticated by its AAD; when `verifyChunkHashes` is set the + * stored ciphertext hash is additionally checked before decryption. + */ +export async function decryptAttachment(input: DecryptAttachmentInput): Promise { + const { manifest, context: ctx } = input; + const verifyHashes = input.verifyChunkHashes ?? true; + const fileKeyBytes = await input.unwrapFileKey(manifest.wrapped_file_key, fileKeyAad(ctx)); + const fileKey = await importFileKey(fileKeyBytes); + try { + fileKeyBytes.fill(0); + for (const chunkMeta of manifest.chunks) { + const ciphertext = await input.readChunk(chunkMeta.index, chunkMeta.ciphertext_sha256); + if (verifyHashes) { + const actual = await chunkCiphertextHash(ciphertext); + if (actual !== chunkMeta.ciphertext_sha256) { + throw new DisInvalidArgumentError( + `Chunk ${chunkMeta.index} ciphertext hash mismatch`, + ); + } + } + const aad = chunkAad( + ctx, + manifest.file_revision, + manifest.manifest_root, + chunkMeta.index, + manifest.chunk_count, + ); + const plaintext = await decryptChunk(ciphertext, fileKey, aad); + try { + await input.writeChunk(chunkMeta.index, plaintext); + } finally { + plaintext.fill(0); + } + } + } finally { + fileKeyBytes.fill(0); + } +} diff --git a/src/format-versioning/format-versioning.test.ts b/src/format-versioning/format-versioning.test.ts new file mode 100644 index 0000000..c18a1e7 --- /dev/null +++ b/src/format-versioning/format-versioning.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { + formatEnvelope, + isCurrentEnvelope, + parseEnvelope, + type VersionedCipherEnvelopeSpec, +} from './index.js'; +import { DisUnsupportedFormatVersionError } from '../core/errors.js'; + +const spec: VersionedCipherEnvelopeSpec = { + currentPrefix: 'sv-vault-v1:', + familyPrefix: 'sv-vault-', + subject: 'vault item', +}; + +describe('format versioning', () => { + it('formats and parses the current version', () => { + const env = formatEnvelope(spec, 'BASE64'); + expect(env).toBe('sv-vault-v1:BASE64'); + const parsed = parseEnvelope(spec, env); + expect(parsed).toEqual({ version: 1, payload: 'BASE64' }); + expect(isCurrentEnvelope(spec, env)).toBe(true); + }); + + it('treats unprefixed data as legacy', () => { + expect(parseEnvelope(spec, 'rawbase64')).toEqual({ version: 'legacy', payload: 'rawbase64' }); + expect(isCurrentEnvelope(spec, 'rawbase64')).toBe(false); + }); + + it('fails closed on unknown in-family version', () => { + expect(() => parseEnvelope(spec, 'sv-vault-v7:x')).toThrowError( + DisUnsupportedFormatVersionError, + ); + expect(isCurrentEnvelope(spec, 'sv-vault-v7:x')).toBe(false); + }); +}); diff --git a/src/format-versioning/index.ts b/src/format-versioning/index.ts new file mode 100644 index 0000000..844baa4 --- /dev/null +++ b/src/format-versioning/index.ts @@ -0,0 +1,69 @@ +/** + * dis-format-versioning — versioned, prefix-tagged cipher envelopes. + * + * Every persisted ciphertext carries an explicit, human-readable version + * prefix (e.g. `sv-vault-v1:`). Parsing dispatches strictly by prefix and + * fails closed for unknown versions in the same family, so a future format can + * never be silently misread as a legacy payload. Payloads with no recognised + * prefix are reported as `legacy`, letting callers decide whether to allow them + * (e.g. only on an explicit migration path). + */ + +import { DisInvalidArgumentError, DisUnsupportedFormatVersionError } from '../core/errors.js'; + +/** Describes a family of versioned envelopes and its current prefix. */ +export interface VersionedCipherEnvelopeSpec { + /** The prefix written for the current version, e.g. `sv-vault-v1:`. */ + readonly currentPrefix: string; + /** The shared family prefix, e.g. `sv-vault-`. */ + readonly familyPrefix: string; + /** Human-readable subject used in error messages, e.g. `vault item`. */ + readonly subject: string; +} + +export type VersionedCipherEnvelope = + | { readonly version: 1; readonly payload: string } + | { readonly version: 'legacy'; readonly payload: string }; + +/** Wraps a base64 ciphertext in the spec's current version prefix. */ +export function formatEnvelope(spec: VersionedCipherEnvelopeSpec, encryptedBase64: string): string { + if (!encryptedBase64) { + throw new DisInvalidArgumentError(`Empty ${spec.subject} ciphertext`); + } + return `${spec.currentPrefix}${encryptedBase64}`; +} + +/** + * Parses an envelope. Returns `version: 1` for the current prefix, throws for + * an unknown in-family version, and returns `version: 'legacy'` otherwise. + */ +export function parseEnvelope( + spec: VersionedCipherEnvelopeSpec, + encryptedData: string, +): VersionedCipherEnvelope { + if (encryptedData.startsWith(spec.currentPrefix)) { + const payload = encryptedData.slice(spec.currentPrefix.length); + if (!payload) { + throw new DisInvalidArgumentError(`Invalid ${spec.subject} encryption envelope`); + } + return { version: 1, payload }; + } + if (encryptedData.startsWith(spec.familyPrefix)) { + throw new DisUnsupportedFormatVersionError( + `Unsupported ${spec.subject} encryption envelope version`, + ); + } + return { version: 'legacy', payload: encryptedData }; +} + +/** True if `encryptedData` is in the spec's current (v1) envelope format. */ +export function isCurrentEnvelope( + spec: VersionedCipherEnvelopeSpec, + encryptedData: string, +): boolean { + try { + return parseEnvelope(spec, encryptedData).version === 1; + } catch { + return false; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..aa15d7a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,122 @@ +/** + * DIS — Defensive Integration Shield + * Powered by DIS — Defensive Integration Shield. + * + * Stable public SDK facade. Applications should depend on this surface (or the + * individual `@dis/shield/` entry points) and never call low-level + * WebCrypto directly. Internal cryptographic changes are designed not to break + * these signatures. + */ + +export const DIS_BRANDING = 'Powered by DIS — Defensive Integration Shield' as const; + +// ---- Stable high-level API ------------------------------------------------ + +// Vault entries +export { + encryptVaultEntry, + decryptVaultEntry, + decryptVaultEntryForMigration, + isCurrentVaultEntryEnvelope, + VAULT_ITEM_ENVELOPE_V1_PREFIX, + type VaultEntryData, + type VaultEntryMigrationResult, +} from './vault-encryption/index.js'; + +// Attachments / files +export { + encryptAttachment, + decryptAttachment, + encryptChunk, + decryptChunk, + generateFileKeyBytes, + importFileKey, + DEFAULT_CHUNK_SIZE, + FILE_MANIFEST_V1_PREFIX, + type AttachmentContext, + type FileManifestV1, + type FileChunkManifest, + type EncryptAttachmentInput, + type DecryptAttachmentInput, +} from './file-encryption/index.js'; + +// Key derivation (deriveMasterKey == deriveAesGcmKey) +export { + deriveAesGcmKey as deriveMasterKey, + deriveRawKey, + importAesGcmKey, + generateSalt, + CURRENT_KDF_VERSION, + DEFAULT_KDF_PARAMS, + type KdfParams, + type DeriveRawKeyOptions, +} from './kdf/index.js'; + +// Key management / rotation (rotateEncryptionKeys == rotateWrappedKey) +export { + createWrappedUserKey, + unwrapUserKey, + unwrapUserKeyBytes, + rotateWrappedKey as rotateEncryptionKeys, + generateContentKeyBytes, + DEFAULT_KEY_WRAP_SCHEME, + type UserKeyBundle, + type KeyWrapScheme, +} from './key-management/index.js'; + +// Integrity +export { + verifyPayloadIntegrity, + sha256Base64, + sha256JsonBase64, + constantTimeEqual, +} from './integrity/index.js'; + +// Migrations (migrateEncryptedPayload via the registry) +export { + MigrationRegistry, + type Migration, + type MigrationContext, + type VersionDetector, +} from './migrations/index.js'; + +// ---- Lower-level building blocks (re-exported for advanced use) ----------- + +export { + encryptBytes, + decryptBytes, + encryptString, + decryptString, +} from './aead/index.js'; + +export { + SecureBuffer, + withSecureBuffer, + zeroBuffers, +} from './secure-memory/index.js'; + +export { randomBytes, randomUuid } from './random/index.js'; + +export { + formatEnvelope, + parseEnvelope, + isCurrentEnvelope, + type VersionedCipherEnvelopeSpec, + type VersionedCipherEnvelope, +} from './format-versioning/index.js'; + +export { + setCryptoProvider, + getCryptoProvider, + type CryptoProvider, +} from './core/provider.js'; + +export { + DisError, + DisInvalidArgumentError, + DisDecryptionError, + DisUnsupportedFormatVersionError, + DisIntegrityError, + DisLegacyPayloadError, + type DisErrorCode, +} from './core/errors.js'; diff --git a/src/integrity/index.ts b/src/integrity/index.ts new file mode 100644 index 0000000..5d9b399 --- /dev/null +++ b/src/integrity/index.ts @@ -0,0 +1,57 @@ +/** + * dis-integrity — hashing and verification helpers. + * + * Provides SHA-256 digests (base64) and constant-time comparison. Used for + * file-chunk integrity, manifest roots, and any place that must verify a + * payload has not been altered. Confidential payloads still rely on AEAD; + * these helpers cover integrity of already-encrypted / public material. + */ + +import { subtle } from '../core/provider.js'; +import { base64ToBytes, bytesToBase64, utf8ToBytes } from '../core/encoding.js'; +import { DisIntegrityError } from '../core/errors.js'; + +/** SHA-256 of raw bytes, base64-encoded. */ +export async function sha256Base64(data: Uint8Array): Promise { + const digest = await subtle().digest('SHA-256', data as BufferSource); + return bytesToBase64(new Uint8Array(digest)); +} + +/** SHA-256 of a UTF-8 string, base64-encoded. */ +export async function sha256StringBase64(data: string): Promise { + return sha256Base64(utf8ToBytes(data)); +} + +/** SHA-256 of the JSON serialisation of `value`, base64-encoded. */ +export async function sha256JsonBase64(value: unknown): Promise { + return sha256StringBase64(JSON.stringify(value)); +} + +/** Constant-time equality of two byte arrays. */ +export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a[i]! ^ b[i]!; + } + return result === 0; +} + +/** Constant-time equality of two base64 strings (compared as decoded bytes). */ +export function constantTimeEqualBase64(a: string, b: string): boolean { + return constantTimeEqual(base64ToBytes(a), base64ToBytes(b)); +} + +/** + * Verifies that `data` hashes (SHA-256) to `expectedBase64`, throwing + * {@link DisIntegrityError} on mismatch. Comparison is constant-time. + */ +export async function verifyPayloadIntegrity( + data: Uint8Array, + expectedBase64: string, +): Promise { + const actual = await sha256Base64(data); + if (!constantTimeEqualBase64(actual, expectedBase64)) { + throw new DisIntegrityError(); + } +} diff --git a/src/integrity/integrity.test.ts b/src/integrity/integrity.test.ts new file mode 100644 index 0000000..6b620a2 --- /dev/null +++ b/src/integrity/integrity.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { + constantTimeEqual, + sha256StringBase64, + verifyPayloadIntegrity, +} from './index.js'; +import { utf8ToBytes } from '../core/encoding.js'; +import { DisIntegrityError } from '../core/errors.js'; + +describe('integrity', () => { + it('matches the known SHA-256 vector for "abc"', async () => { + // SHA-256("abc") = ba7816bf... ; base64 of that digest: + expect(await sha256StringBase64('abc')).toBe('ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0='); + }); + + it('constant-time equality', () => { + expect(constantTimeEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2]))).toBe(true); + expect(constantTimeEqual(new Uint8Array([1, 2]), new Uint8Array([1, 3]))).toBe(false); + expect(constantTimeEqual(new Uint8Array([1]), new Uint8Array([1, 2]))).toBe(false); + }); + + it('verifyPayloadIntegrity passes on match and throws on mismatch', async () => { + const data = utf8ToBytes('payload'); + const expected = await sha256StringBase64('payload'); + await expect(verifyPayloadIntegrity(data, expected)).resolves.toBeUndefined(); + await expect( + verifyPayloadIntegrity(utf8ToBytes('tampered'), expected), + ).rejects.toBeInstanceOf(DisIntegrityError); + }); +}); diff --git a/src/kdf/index.ts b/src/kdf/index.ts new file mode 100644 index 0000000..34118b0 --- /dev/null +++ b/src/kdf/index.ts @@ -0,0 +1,171 @@ +/** + * dis-kdf — password-based key derivation. + * + * Primitive: Argon2id (via the audited `hash-wasm` implementation), with + * versioned, immutable parameter sets so accounts can be transparently + * upgraded to stronger parameters over time. Optional HKDF-Expand strengthening + * binds the derived key to a second factor (e.g. a device key) without + * weakening the password-derived material. + * + * DIS does not invent a KDF. Parameters follow OWASP Argon2id guidance. + */ + +import { argon2id } from 'hash-wasm'; +import { getCryptoProvider, subtle } from '../core/provider.js'; +import { base64ToBytes, bytesToBase64 } from '../core/encoding.js'; +import { KDF_SALT_LENGTH } from '../core/constants.js'; +import { + DisError, + DisInvalidArgumentError, +} from '../core/errors.js'; + +/** Argon2id parameter set. Once released, a version's params are immutable. */ +export interface KdfParams { + /** Memory cost in KiB. */ + readonly memory: number; + /** Iteration (time) cost. */ + readonly iterations: number; + /** Degree of parallelism. */ + readonly parallelism: number; + /** Output length in bytes. */ + readonly hashLength: number; +} + +/** + * Default versioned parameter registry. Byte-compatible with Singra Vault: + * v1: 64 MiB (original baseline) + * v2: 128 MiB (current; exceeds OWASP Argon2id minimum) + * + * IMPORTANT: never change an existing version's parameters — only add versions. + */ +export const DEFAULT_KDF_PARAMS: Readonly> = Object.freeze({ + 1: { memory: 65536, iterations: 3, parallelism: 4, hashLength: 32 }, + 2: { memory: 131072, iterations: 3, parallelism: 4, hashLength: 32 }, +}); + +/** The latest KDF version. New accounts derive with this version. */ +export const CURRENT_KDF_VERSION = 2; + +/** Optional second-factor strengthening via HKDF-Expand (SHA-256). */ +export interface KdfStrengthenOptions { + /** Salt for HKDF (e.g. a 256-bit device key). */ + readonly hkdfSalt: Uint8Array; + /** Domain-separation `info` string for HKDF. Caller-owned (format contract). */ + readonly info: string; +} + +export interface DeriveRawKeyOptions { + /** KDF version to look up in `params`. Defaults to {@link CURRENT_KDF_VERSION}. */ + readonly version?: number; + /** Parameter registry. Defaults to {@link DEFAULT_KDF_PARAMS}. */ + readonly params?: Readonly>; + /** Optional HKDF strengthening (e.g. device-key binding). */ + readonly strengthen?: KdfStrengthenOptions; +} + +/** Generates a cryptographically secure, base64-encoded salt. */ +export function generateSalt(): string { + const salt = new Uint8Array(KDF_SALT_LENGTH); + getCryptoProvider().getRandomValues(salt); + return bytesToBase64(salt); +} + +async function hkdfStrengthen( + argon2Output: Uint8Array, + options: KdfStrengthenOptions, +): Promise { + const baseKey = await subtle().importKey('raw', argon2Output as BufferSource, 'HKDF', false, [ + 'deriveBits', + ]); + const info = new TextEncoder().encode(options.info); + const derivedBits = await subtle().deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: options.hkdfSalt as BufferSource, + info: info as BufferSource, + }, + baseKey, + argon2Output.length * 8, + ); + return new Uint8Array(derivedBits); +} + +/** + * Derives raw key bytes from a password using Argon2id (+ optional HKDF). + * + * The caller owns the returned buffer and MUST wipe it (`.fill(0)`). + */ +export async function deriveRawKey( + password: string, + saltBase64: string, + options: DeriveRawKeyOptions = {}, +): Promise { + const version = options.version ?? CURRENT_KDF_VERSION; + const registry = options.params ?? DEFAULT_KDF_PARAMS; + const params = registry[version]; + if (!params) { + throw new DisError('UNSUPPORTED_KDF_VERSION', `Unknown KDF version: ${version}`); + } + if (typeof password !== 'string' || password.length === 0) { + throw new DisInvalidArgumentError('password must be a non-empty string'); + } + + const salt = base64ToBytes(saltBase64); + const result = (await argon2id({ + password, + salt, + parallelism: params.parallelism, + iterations: params.iterations, + memorySize: params.memory, + hashLength: params.hashLength, + outputType: 'binary', + })) as unknown; + + let argon2Bytes: Uint8Array; + if (result instanceof Uint8Array) { + argon2Bytes = result; + } else if (result instanceof ArrayBuffer) { + argon2Bytes = new Uint8Array(result); + } else { + throw new DisError('KEY_DERIVATION_FAILED', 'Unexpected Argon2id output type'); + } + + if (!options.strengthen) { + return argon2Bytes; + } + + try { + return await hkdfStrengthen(argon2Bytes, options.strengthen); + } finally { + argon2Bytes.fill(0); + } +} + +/** Imports raw 256-bit key bytes as a non-extractable AES-GCM CryptoKey. */ +export async function importAesGcmKey(keyBytes: Uint8Array | BufferSource): Promise { + return subtle().importKey( + 'raw', + keyBytes as BufferSource, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +} + +/** + * Derives a non-extractable AES-GCM key from a password. Raw key bytes are + * wiped as soon as the CryptoKey is imported. + */ +export async function deriveAesGcmKey( + password: string, + saltBase64: string, + options: DeriveRawKeyOptions = {}, +): Promise { + const keyBytes = await deriveRawKey(password, saltBase64, options); + try { + return await importAesGcmKey(keyBytes); + } finally { + keyBytes.fill(0); + } +} diff --git a/src/kdf/kdf.test.ts b/src/kdf/kdf.test.ts new file mode 100644 index 0000000..7d813b9 --- /dev/null +++ b/src/kdf/kdf.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { + CURRENT_KDF_VERSION, + DEFAULT_KDF_PARAMS, + deriveAesGcmKey, + deriveRawKey, + generateSalt, +} from './index.js'; +import { encryptString, decryptString } from '../aead/index.js'; +import { bytesToBase64 } from '../core/encoding.js'; +import { DisError } from '../core/errors.js'; + +describe('kdf Argon2id', () => { + const salt = bytesToBase64(new Uint8Array(16).fill(7)); + + it('is deterministic for the same password/salt/version', async () => { + const a = await deriveRawKey('correct horse', salt, { version: 1 }); + const b = await deriveRawKey('correct horse', salt, { version: 1 }); + expect([...a]).toEqual([...b]); + expect(a.length).toBe(32); + }); + + it('produces different output for different versions (param separation)', async () => { + const v1 = await deriveRawKey('pw', salt, { version: 1 }); + const v2 = await deriveRawKey('pw', salt, { version: 2 }); + expect([...v1]).not.toEqual([...v2]); + }); + + it('produces different output for different passwords', async () => { + const a = await deriveRawKey('pw-a', salt, { version: 1 }); + const b = await deriveRawKey('pw-b', salt, { version: 1 }); + expect([...a]).not.toEqual([...b]); + }); + + it('throws on unknown KDF version', async () => { + await expect(deriveRawKey('pw', salt, { version: 999 })).rejects.toBeInstanceOf(DisError); + }); + + it('derives a usable AES-GCM key', async () => { + const key = await deriveAesGcmKey('pw', salt, { version: CURRENT_KDF_VERSION }); + const ct = await encryptString('data', key); + expect(await decryptString(ct, key)).toBe('data'); + }); + + it('HKDF strengthening changes the derived key', async () => { + const base = await deriveRawKey('pw', salt, { version: 1 }); + const strengthened = await deriveRawKey('pw', salt, { + version: 1, + strengthen: { hkdfSalt: new Uint8Array(32).fill(9), info: 'device-key-v1' }, + }); + expect([...base]).not.toEqual([...strengthened]); + expect(strengthened.length).toBe(32); + }); + + it('generates 16-byte salts', () => { + const s = generateSalt(); + expect(atob(s).length).toBe(16); + expect(generateSalt()).not.toBe(s); + }); + + it('keeps released v1/v2 parameters immutable', () => { + expect(DEFAULT_KDF_PARAMS[1]).toEqual({ + memory: 65536, + iterations: 3, + parallelism: 4, + hashLength: 32, + }); + expect(DEFAULT_KDF_PARAMS[2]!.memory).toBe(131072); + }); +}); diff --git a/src/key-management/index.ts b/src/key-management/index.ts new file mode 100644 index 0000000..99e0fbe --- /dev/null +++ b/src/key-management/index.ts @@ -0,0 +1,160 @@ +/** + * dis-key-management — key hierarchy, wrapping, and rotation. + * + * Implements the two-tier key model used by Singra Vault: + * - A high-entropy random *content key* (the "UserKey") encrypts vault data. + * - The content key is wrapped under a *key-encryption key* (KEK) that is + * derived from the KDF output via HKDF-Expand (domain-separated `info`). + * + * Because the content key is independent of the password, changing the master + * password only re-wraps the content key — no vault data is re-encrypted. This + * is what makes {@link rotateWrappedKey} cheap and safe. + * + * Wrapped keys carry a stable, versioned prefix. The default scheme is byte + * compatible with Singra's `usk-wrap-v2:` envelope. + */ + +import { decryptBytes, decryptString, encryptBytes } from '../aead/index.js'; +import { importAesGcmKey } from '../kdf/index.js'; +import { randomBytes } from '../random/index.js'; +import { base64ToBytes } from '../core/encoding.js'; +import { subtle } from '../core/provider.js'; +import { AES_KEY_LENGTH } from '../core/constants.js'; +import { DisInvalidArgumentError } from '../core/errors.js'; + +/** Describes how a content key is wrapped: envelope prefix + HKDF domain. */ +export interface KeyWrapScheme { + /** Stable envelope prefix written for new wraps, e.g. `usk-wrap-v2:`. */ + readonly prefix: string; + /** HKDF-Expand `info` for deriving the KEK from KDF output. */ + readonly hkdfInfo: string; +} + +/** + * Default wrap scheme. Byte-compatible with Singra Vault so existing + * `encrypted_user_key` values decrypt unchanged. The `info` label is part of + * the format contract and must not be renamed without a new scheme version. + */ +export const DEFAULT_KEY_WRAP_SCHEME: KeyWrapScheme = { + prefix: 'usk-wrap-v2:', + hkdfInfo: 'singra-vault-wrap-v1', +}; + +export { importAesGcmKey }; + +export interface UserKeyBundle { + /** Wrapped content key: ``. */ + readonly encryptedUserKey: string; + /** Non-extractable AES-GCM content key, ready for vault operations. */ + readonly userKey: CryptoKey; +} + +/** Derives the KEK from raw KDF output via HKDF-Expand (zero salt, RFC 5869). */ +async function deriveWrapKeyBytes( + kdfOutputBytes: Uint8Array, + scheme: KeyWrapScheme, +): Promise { + const baseKey = await subtle().importKey('raw', kdfOutputBytes as BufferSource, 'HKDF', false, [ + 'deriveBits', + ]); + const bits = await subtle().deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + // Zero salt is correct: IKM is already high-entropy Argon2id output. + salt: new Uint8Array(32) as BufferSource, + info: new TextEncoder().encode(scheme.hkdfInfo) as BufferSource, + }, + baseKey, + 256, + ); + return new Uint8Array(bits); +} + +/** Generates fresh random content-key bytes (caller must wipe). */ +export function generateContentKeyBytes(): Uint8Array { + return randomBytes(AES_KEY_LENGTH); +} + +/** + * Creates a new random content key and wraps it under the KEK derived from + * `kdfOutputBytes`. For new accounts. The caller still owns `kdfOutputBytes`. + */ +export async function createWrappedUserKey( + kdfOutputBytes: Uint8Array, + scheme: KeyWrapScheme = DEFAULT_KEY_WRAP_SCHEME, +): Promise { + const userKeyBytes = generateContentKeyBytes(); + let wrapKeyBytes: Uint8Array | null = null; + try { + wrapKeyBytes = await deriveWrapKeyBytes(kdfOutputBytes, scheme); + const wrapKey = await importAesGcmKey(wrapKeyBytes); + const encryptedUserKey = `${scheme.prefix}${await encryptBytes(userKeyBytes, wrapKey)}`; + const userKey = await importAesGcmKey(userKeyBytes); + return { encryptedUserKey, userKey }; + } finally { + userKeyBytes.fill(0); + wrapKeyBytes?.fill(0); + } +} + +/** Unwraps a wrapped content key to raw bytes (caller must wipe). */ +export async function unwrapUserKeyBytes( + encryptedUserKey: string, + kdfOutputBytes: Uint8Array, + scheme: KeyWrapScheme = DEFAULT_KEY_WRAP_SCHEME, +): Promise { + let wrapKeyBytes: Uint8Array | null = null; + try { + wrapKeyBytes = await deriveWrapKeyBytes(kdfOutputBytes, scheme); + const wrapKey = await importAesGcmKey(wrapKeyBytes); + if (encryptedUserKey.startsWith(scheme.prefix)) { + return await decryptBytes(encryptedUserKey.slice(scheme.prefix.length), wrapKey); + } + // Legacy wrappers encrypted a base64 string (no prefix). Read-compatible. + const userKeyBase64 = await decryptString(encryptedUserKey, wrapKey); + return base64ToBytes(userKeyBase64); + } finally { + wrapKeyBytes?.fill(0); + } +} + +/** Unwraps a wrapped content key and imports it as an AES-GCM CryptoKey. */ +export async function unwrapUserKey( + encryptedUserKey: string, + kdfOutputBytes: Uint8Array, + scheme: KeyWrapScheme = DEFAULT_KEY_WRAP_SCHEME, +): Promise { + const userKeyBytes = await unwrapUserKeyBytes(encryptedUserKey, kdfOutputBytes, scheme); + try { + return await importAesGcmKey(userKeyBytes); + } finally { + userKeyBytes.fill(0); + } +} + +/** + * Re-wraps an existing content key under a new KDF output (new master password + * / new salt). The content key itself is unchanged, so NO vault data is + * re-encrypted — only the wrapper string changes. + */ +export async function rotateWrappedKey( + encryptedUserKey: string, + oldKdfOutputBytes: Uint8Array, + newKdfOutputBytes: Uint8Array, + scheme: KeyWrapScheme = DEFAULT_KEY_WRAP_SCHEME, +): Promise { + if (!encryptedUserKey) { + throw new DisInvalidArgumentError('encryptedUserKey is required'); + } + const userKeyBytes = await unwrapUserKeyBytes(encryptedUserKey, oldKdfOutputBytes, scheme); + let newWrapKeyBytes: Uint8Array | null = null; + try { + newWrapKeyBytes = await deriveWrapKeyBytes(newKdfOutputBytes, scheme); + const newWrapKey = await importAesGcmKey(newWrapKeyBytes); + return `${scheme.prefix}${await encryptBytes(userKeyBytes, newWrapKey)}`; + } finally { + userKeyBytes.fill(0); + newWrapKeyBytes?.fill(0); + } +} diff --git a/src/key-management/key-management.test.ts b/src/key-management/key-management.test.ts new file mode 100644 index 0000000..46ef8f1 --- /dev/null +++ b/src/key-management/key-management.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { + createWrappedUserKey, + rotateWrappedKey, + unwrapUserKey, + unwrapUserKeyBytes, + DEFAULT_KEY_WRAP_SCHEME, +} from './index.js'; +import { encryptVaultEntry, decryptVaultEntry } from '../vault-encryption/index.js'; +import { randomBytes } from '../random/index.js'; + +describe('key management', () => { + it('wraps and unwraps the content key under a KEK', async () => { + const kdfOut = randomBytes(32); + const bundle = await createWrappedUserKey(kdfOut); + expect(bundle.encryptedUserKey.startsWith(DEFAULT_KEY_WRAP_SCHEME.prefix)).toBe(true); + + const unwrapped = await unwrapUserKeyBytes(bundle.encryptedUserKey, kdfOut); + expect(unwrapped.length).toBe(32); + }); + + it('content key encrypts/decrypts vault data after unwrap', async () => { + const kdfOut = randomBytes(32); + const bundle = await createWrappedUserKey(kdfOut); + const sealed = await encryptVaultEntry({ secret: 42 }, bundle.userKey, 'e1'); + + const reUnwrapped = await unwrapUserKey(bundle.encryptedUserKey, kdfOut); + expect(await decryptVaultEntry(sealed, reUnwrapped, 'e1')).toEqual({ secret: 42 }); + }); + + it('rotation re-wraps without changing the content key (no data re-encryption)', async () => { + const oldKdf = randomBytes(32); + const newKdf = randomBytes(32); + const bundle = await createWrappedUserKey(oldKdf); + const sealed = await encryptVaultEntry({ secret: 'keep-me' }, bundle.userKey, 'e1'); + + const rotated = await rotateWrappedKey(bundle.encryptedUserKey, oldKdf, newKdf); + expect(rotated).not.toBe(bundle.encryptedUserKey); + + // Old KDF output no longer unwraps the rotated key. + await expect(unwrapUserKeyBytes(rotated, oldKdf)).rejects.toBeTruthy(); + + // New KDF output unwraps to the SAME content key — old data still reads. + const newKey = await unwrapUserKey(rotated, newKdf); + expect(await decryptVaultEntry(sealed, newKey, 'e1')).toEqual({ secret: 'keep-me' }); + }); + + it('fails to unwrap with the wrong KDF output', async () => { + const bundle = await createWrappedUserKey(randomBytes(32)); + await expect(unwrapUserKeyBytes(bundle.encryptedUserKey, randomBytes(32))).rejects.toBeTruthy(); + }); +}); diff --git a/src/migrations/index.ts b/src/migrations/index.ts new file mode 100644 index 0000000..2c8547a --- /dev/null +++ b/src/migrations/index.ts @@ -0,0 +1,78 @@ +/** + * dis-migrations — explicit, ordered transformation of encrypted payloads. + * + * Migrations are registered against a (subject, fromVersion) key and run in a + * deterministic order. This gives applications a single, testable place to + * evolve formats (e.g. re-wrap legacy no-AAD vault items, bump KDF parameters, + * re-encrypt under a new cipher) without scattering ad-hoc upgrade code. + * + * DIS provides the framework and the crypto; the application decides which + * migrations to register and how persistence happens. + */ + +import { DisError, DisInvalidArgumentError } from '../core/errors.js'; + +/** Context passed to a migration step. `key` material is caller-supplied. */ +export interface MigrationContext { + readonly key: CryptoKey; + /** Stable identifier the payload is bound to (e.g. entry id). */ + readonly bindingId?: string; +} + +/** A single migration: transforms a payload from `fromVersion` to `toVersion`. */ +export interface Migration { + readonly subject: string; + readonly fromVersion: number | 'legacy'; + readonly toVersion: number; + /** Returns the migrated payload string. Must be idempotent on its output. */ + migrate(payload: string, context: MigrationContext): Promise; +} + +/** Detects the current version of a payload for a subject. */ +export type VersionDetector = (payload: string) => number | 'legacy'; + +/** An ordered registry of migrations for one or more subjects. */ +export class MigrationRegistry { + private readonly migrations = new Map(); + + private keyOf(subject: string, fromVersion: number | 'legacy'): string { + return `${subject}@${fromVersion}`; + } + + /** Registers a migration. Throws if one already exists for the same step. */ + register(migration: Migration): this { + const key = this.keyOf(migration.subject, migration.fromVersion); + if (this.migrations.has(key)) { + throw new DisInvalidArgumentError(`Duplicate migration for ${key}`); + } + this.migrations.set(key, migration); + return this; + } + + /** + * Applies all applicable migrations in sequence until no further migration + * exists for the payload's detected version. Guards against cycles. + */ + async migrateToLatest( + subject: string, + payload: string, + detect: VersionDetector, + context: MigrationContext, + ): Promise { + let current = payload; + const seen = new Set(); + for (;;) { + const version = detect(current); + if (seen.has(version)) { + throw new DisError( + 'INVALID_ARGUMENT', + `Migration cycle detected for ${subject}@${version}`, + ); + } + seen.add(version); + const migration = this.migrations.get(this.keyOf(subject, version)); + if (!migration) return current; + current = await migration.migrate(current, context); + } + } +} diff --git a/src/random/index.ts b/src/random/index.ts new file mode 100644 index 0000000..f55dd12 --- /dev/null +++ b/src/random/index.ts @@ -0,0 +1,38 @@ +/** + * dis-random — secure random generation. + * + * Always sources entropy from the active crypto provider's CSPRNG + * (`getRandomValues`). DIS never uses `Math.random` for any value that affects + * confidentiality, integrity, or unpredictability. + */ + +import { getCryptoProvider } from '../core/provider.js'; +import { DisInvalidArgumentError } from '../core/errors.js'; + +/** Returns `length` cryptographically secure random bytes. */ +export function randomBytes(length: number): Uint8Array { + if (!Number.isInteger(length) || length <= 0) { + throw new DisInvalidArgumentError('randomBytes length must be a positive integer'); + } + const out = new Uint8Array(length); + getCryptoProvider().getRandomValues(out); + return out; +} + +/** Returns a RFC 4122 v4 UUID using the provider's CSPRNG. */ +export function randomUuid(): string { + const provider = getCryptoProvider() as { randomUUID?: () => string }; + if (typeof provider.randomUUID === 'function') { + return provider.randomUUID(); + } + // Fallback: build a v4 UUID from random bytes. + const bytes = randomBytes(16); + bytes[6] = (bytes[6]! & 0x0f) | 0x40; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex: string[] = []; + for (let i = 0; i < 16; i++) hex.push(bytes[i]!.toString(16).padStart(2, '0')); + return ( + `${hex[0]}${hex[1]}${hex[2]}${hex[3]}-${hex[4]}${hex[5]}-${hex[6]}${hex[7]}-` + + `${hex[8]}${hex[9]}-${hex[10]}${hex[11]}${hex[12]}${hex[13]}${hex[14]}${hex[15]}` + ); +} diff --git a/src/secure-memory/index.ts b/src/secure-memory/index.ts new file mode 100644 index 0000000..8a070b3 --- /dev/null +++ b/src/secure-memory/index.ts @@ -0,0 +1,144 @@ +/** + * dis-secure-storage / secure-memory — memory-safe handling of key material. + * + * Ported from Singra Vault's SecureBuffer. Provides controlled, callback-scoped + * access to sensitive bytes with explicit zeroing on destroy and a + * FinalizationRegistry fallback. This is defense-in-depth: JavaScript cannot + * guarantee memory wiping (GC is non-deterministic, strings are immutable), but + * controlled access plus best-effort zeroing materially reduces exposure + * (cf. KeePass CVE-2023-32784). + */ + +import { getCryptoProvider } from '../core/provider.js'; +import { DisError, DisInvalidArgumentError } from '../core/errors.js'; + +const cleanupRegistry = new FinalizationRegistry((buffer) => { + try { + buffer.fill(0); + } catch { + // Buffer may already be detached or GC'd. + } +}); + +function assertLive(destroyed: boolean): void { + if (destroyed) { + throw new DisError('USE_AFTER_DESTROY', 'SecureBuffer has been destroyed'); + } +} + +/** A wrapper for sensitive binary data with controlled access and zeroing. */ +export class SecureBuffer { + private buffer: Uint8Array; + private destroyed = false; + + constructor(size: number) { + if (size <= 0 || !Number.isInteger(size)) { + throw new DisInvalidArgumentError('SecureBuffer size must be a positive integer'); + } + this.buffer = new Uint8Array(size); + cleanupRegistry.register(this, this.buffer, this); + } + + /** Copies `bytes` into a new SecureBuffer. The source is NOT auto-zeroed. */ + static fromBytes(bytes: Uint8Array): SecureBuffer { + const secure = new SecureBuffer(bytes.length || 1); + if (bytes.length === 0) { + secure.destroy(); + throw new DisInvalidArgumentError('Cannot create SecureBuffer from empty bytes'); + } + secure.buffer.set(bytes); + return secure; + } + + /** Builds a SecureBuffer from a hex string (spaces/dashes allowed). */ + static fromHex(hex: string): SecureBuffer { + const cleanHex = hex.replace(/[\s-]/g, ''); + if (cleanHex.length === 0 || cleanHex.length % 2 !== 0) { + throw new DisInvalidArgumentError('Hex string must have a positive even length'); + } + const secure = new SecureBuffer(cleanHex.length / 2); + for (let i = 0; i < secure.buffer.length; i++) { + const value = parseInt(cleanHex.substr(i * 2, 2), 16); + if (Number.isNaN(value)) { + secure.destroy(); + throw new DisInvalidArgumentError(`Invalid hex byte at position ${i * 2}`); + } + secure.buffer[i] = value; + } + return secure; + } + + /** Allocates a SecureBuffer filled with CSPRNG bytes. */ + static random(size: number): SecureBuffer { + const secure = new SecureBuffer(size); + getCryptoProvider().getRandomValues(secure.buffer); + return secure; + } + + /** Synchronous controlled access. Do not retain the buffer past `fn`. */ + use(fn: (data: Uint8Array) => T): T { + assertLive(this.destroyed); + return fn(this.buffer); + } + + /** Asynchronous controlled access. */ + async useAsync(fn: (data: Uint8Array) => Promise): Promise { + assertLive(this.destroyed); + return fn(this.buffer); + } + + get size(): number { + assertLive(this.destroyed); + return this.buffer.length; + } + + get isDestroyed(): boolean { + return this.destroyed; + } + + /** Zeros the buffer and marks it destroyed. Idempotent. */ + destroy(): void { + if (this.destroyed) return; + this.buffer.fill(0); + cleanupRegistry.unregister(this); + this.destroyed = true; + } + + /** Returns a mutable copy of the contents (caller must zero it). */ + toBytes(): Uint8Array { + assertLive(this.destroyed); + return new Uint8Array(this.buffer); + } + + /** Constant-time equality comparison against another buffer. */ + equals(other: SecureBuffer | Uint8Array): boolean { + assertLive(this.destroyed); + const otherBytes = other instanceof SecureBuffer ? other.buffer : other; + if (this.buffer.length !== otherBytes.length) return false; + let result = 0; + for (let i = 0; i < this.buffer.length; i++) { + result |= this.buffer[i]! ^ otherBytes[i]!; + } + return result === 0; + } +} + +/** Runs `fn` with a temporary SecureBuffer that is always destroyed afterwards. */ +export async function withSecureBuffer( + bytes: Uint8Array, + fn: (secure: SecureBuffer) => Promise, +): Promise { + const secure = SecureBuffer.fromBytes(bytes); + try { + return await fn(secure); + } finally { + secure.destroy(); + } +} + +/** Zeros multiple buffers. Convenience for `finally` cleanup blocks. */ +export function zeroBuffers(...buffers: (Uint8Array | null | undefined)[]): void { + for (const buffer of buffers) { + buffer?.fill(0); + } +} diff --git a/src/secure-memory/secure-memory.test.ts b/src/secure-memory/secure-memory.test.ts new file mode 100644 index 0000000..941a16a --- /dev/null +++ b/src/secure-memory/secure-memory.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { SecureBuffer, withSecureBuffer, zeroBuffers } from './index.js'; +import { DisError } from '../core/errors.js'; + +describe('SecureBuffer', () => { + it('zeros contents on destroy and blocks use-after-destroy', () => { + const buf = SecureBuffer.fromBytes(new Uint8Array([1, 2, 3, 4])); + expect(buf.use((d) => d.length)).toBe(4); + buf.destroy(); + expect(buf.isDestroyed).toBe(true); + expect(() => buf.use((d) => d.length)).toThrowError(DisError); + // Idempotent destroy. + expect(() => buf.destroy()).not.toThrow(); + }); + + it('compares in constant time via equals', () => { + const a = SecureBuffer.fromBytes(new Uint8Array([1, 2, 3])); + expect(a.equals(new Uint8Array([1, 2, 3]))).toBe(true); + expect(a.equals(new Uint8Array([1, 2, 4]))).toBe(false); + expect(a.equals(new Uint8Array([1, 2]))).toBe(false); + a.destroy(); + }); + + it('parses hex and rejects invalid hex', () => { + const buf = SecureBuffer.fromHex('00ff-10'); + expect(buf.toBytes()).toEqual(new Uint8Array([0, 255, 16])); + buf.destroy(); + expect(() => SecureBuffer.fromHex('zz')).toThrowError(DisError); + expect(() => SecureBuffer.fromHex('abc')).toThrowError(DisError); + }); + + it('withSecureBuffer always destroys', async () => { + let captured: SecureBuffer | null = null; + await withSecureBuffer(new Uint8Array([9, 9]), async (s) => { + captured = s; + expect(s.size).toBe(2); + }); + expect(captured!.isDestroyed).toBe(true); + }); + + it('zeroBuffers tolerates null/undefined', () => { + const a = new Uint8Array([1, 2]); + zeroBuffers(a, null, undefined); + expect([...a]).toEqual([0, 0]); + }); +}); diff --git a/src/vault-encryption/index.ts b/src/vault-encryption/index.ts new file mode 100644 index 0000000..cce2d66 --- /dev/null +++ b/src/vault-encryption/index.ts @@ -0,0 +1,117 @@ +/** + * dis-vault-encryption — encryption of structured vault entries. + * + * A vault entry is an arbitrary JSON-serialisable record. It is sealed with + * AES-256-GCM and wrapped in the versioned `sv-vault-v1:` envelope, byte + * compatible with Singra Vault. The entry id is passed as AEAD associated data + * so ciphertext is cryptographically bound to its row (defeats swap attacks). + * + * Legacy (pre-versioning, no-AAD) payloads fail closed on the runtime read + * path and are only readable through the explicit migration helper. + */ + +import { decryptString, encryptString } from '../aead/index.js'; +import { + formatEnvelope, + isCurrentEnvelope, + parseEnvelope, + type VersionedCipherEnvelopeSpec, +} from '../format-versioning/index.js'; +import { DisInvalidArgumentError, DisLegacyPayloadError } from '../core/errors.js'; + +export const VAULT_ITEM_ENVELOPE_V1_PREFIX = 'sv-vault-v1:'; +const VAULT_ITEM_ENVELOPE_FAMILY_PREFIX = 'sv-vault-'; + +export const VAULT_ITEM_ENVELOPE_SPEC: VersionedCipherEnvelopeSpec = { + currentPrefix: VAULT_ITEM_ENVELOPE_V1_PREFIX, + familyPrefix: VAULT_ITEM_ENVELOPE_FAMILY_PREFIX, + subject: 'vault item', +}; + +export type VaultEntryData = Record; + +/** Seals a vault entry, binding it to `entryId` via AEAD associated data. */ +export async function encryptVaultEntry( + data: VaultEntryData, + key: CryptoKey, + entryId: string, +): Promise { + if (!entryId) { + throw new DisInvalidArgumentError('entryId is required to bind vault entry ciphertext'); + } + const json = JSON.stringify(data); + return formatEnvelope(VAULT_ITEM_ENVELOPE_SPEC, await encryptString(json, key, entryId)); +} + +/** + * Opens a vault entry. Versioned payloads are read with `entryId` as AAD. + * Legacy no-AAD payloads throw {@link DisLegacyPayloadError} on the runtime + * path; use {@link decryptVaultEntryForMigration} to read and rewrite them. + */ +export async function decryptVaultEntry( + encryptedData: string, + key: CryptoKey, + entryId: string, +): Promise { + const envelope = parseEnvelope(VAULT_ITEM_ENVELOPE_SPEC, encryptedData); + if (envelope.version === 1) { + return JSON.parse(await decryptString(envelope.payload, key, entryId)) as VaultEntryData; + } + // Legacy payloads written after AAD rollout still authenticate with entryId. + if (entryId) { + try { + return JSON.parse( + await decryptString(envelope.payload, key, entryId), + ) as VaultEntryData; + } catch { + throw new DisLegacyPayloadError('Legacy vault item without AAD requires migration.'); + } + } + throw new DisLegacyPayloadError('Legacy vault item without AAD requires migration.'); +} + +export interface VaultEntryMigrationResult { + readonly data: VaultEntryData; + readonly legacyEnvelopeUsed: boolean; + readonly legacyNoAadFallbackUsed: boolean; +} + +/** + * Decrypts an entry on an explicit migration path, permitting the no-AAD + * fallback for the oldest payloads so they can be rewritten as versioned, + * AAD-bound items. Never use on the normal runtime read path. + */ +export async function decryptVaultEntryForMigration( + encryptedData: string, + key: CryptoKey, + entryId: string, +): Promise { + const envelope = parseEnvelope(VAULT_ITEM_ENVELOPE_SPEC, encryptedData); + if (envelope.version === 1) { + return { + data: JSON.parse(await decryptString(envelope.payload, key, entryId)) as VaultEntryData, + legacyEnvelopeUsed: false, + legacyNoAadFallbackUsed: false, + }; + } + if (entryId) { + try { + return { + data: JSON.parse( + await decryptString(envelope.payload, key, entryId), + ) as VaultEntryData, + legacyEnvelopeUsed: true, + legacyNoAadFallbackUsed: false, + }; + } catch { + // Fall through to the no-AAD fallback below. + } + } + const data = JSON.parse(await decryptString(envelope.payload, key)) as VaultEntryData; + return { data, legacyEnvelopeUsed: true, legacyNoAadFallbackUsed: true }; +} + +/** True if `encryptedData` is a current versioned vault-item envelope. */ +export function isCurrentVaultEntryEnvelope(encryptedData: string): boolean { + return isCurrentEnvelope(VAULT_ITEM_ENVELOPE_SPEC, encryptedData); +} diff --git a/src/vault-encryption/vault-encryption.test.ts b/src/vault-encryption/vault-encryption.test.ts new file mode 100644 index 0000000..efa159b --- /dev/null +++ b/src/vault-encryption/vault-encryption.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { + VAULT_ITEM_ENVELOPE_V1_PREFIX, + decryptVaultEntry, + decryptVaultEntryForMigration, + encryptVaultEntry, + isCurrentVaultEntryEnvelope, +} from './index.js'; +import { encryptString } from '../aead/index.js'; +import { importAesGcmKey } from '../kdf/index.js'; +import { randomBytes } from '../random/index.js'; +import { DisLegacyPayloadError, DisUnsupportedFormatVersionError } from '../core/errors.js'; + +async function freshKey(): Promise { + return importAesGcmKey(randomBytes(32)); +} + +describe('vault entry encryption', () => { + it('round-trips structured data and uses the v1 envelope', async () => { + const key = await freshKey(); + const data = { username: 'alice', password: 'p@ss', notes: 'x' }; + const sealed = await encryptVaultEntry(data, key, 'entry-1'); + expect(sealed.startsWith(VAULT_ITEM_ENVELOPE_V1_PREFIX)).toBe(true); + expect(isCurrentVaultEntryEnvelope(sealed)).toBe(true); + expect(await decryptVaultEntry(sealed, key, 'entry-1')).toEqual(data); + }); + + it('binds ciphertext to entryId (defeats swap attacks)', async () => { + const key = await freshKey(); + const sealed = await encryptVaultEntry({ a: 1 }, key, 'entry-1'); + await expect(decryptVaultEntry(sealed, key, 'entry-2')).rejects.toBeTruthy(); + }); + + it('fails closed for unknown in-family versions', async () => { + const key = await freshKey(); + await expect( + decryptVaultEntry('sv-vault-v9:deadbeef', key, 'entry-1'), + ).rejects.toBeInstanceOf(DisUnsupportedFormatVersionError); + }); + + it('rejects legacy no-AAD payloads on the runtime read path', async () => { + const key = await freshKey(); + // A legacy payload: bare base64 AES-GCM with no AAD and no prefix. + const legacy = await encryptString(JSON.stringify({ a: 1 }), key); + await expect(decryptVaultEntry(legacy, key, 'entry-1')).rejects.toBeInstanceOf( + DisLegacyPayloadError, + ); + }); + + it('reads legacy no-AAD payloads only on the migration path', async () => { + const key = await freshKey(); + const legacy = await encryptString(JSON.stringify({ a: 1 }), key); + const result = await decryptVaultEntryForMigration(legacy, key, 'entry-1'); + expect(result.data).toEqual({ a: 1 }); + expect(result.legacyEnvelopeUsed).toBe(true); + expect(result.legacyNoAadFallbackUsed).toBe(true); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3337c4c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "declaration": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": false, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["dist", "node_modules", "**/*.test.ts"] +} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..b0c6156 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from 'tsup'; + +/** + * Each public module is a separate entry point so that consumers can import + * narrow surfaces (e.g. `@dis/shield/aead`) and bundlers can tree-shake + * unused modules. The barrel entry (`index`) re-exports the stable public SDK. + */ +export default defineConfig({ + entry: { + index: 'src/index.ts', + 'core/index': 'src/core/index.ts', + 'random/index': 'src/random/index.ts', + 'secure-memory/index': 'src/secure-memory/index.ts', + 'kdf/index': 'src/kdf/index.ts', + 'aead/index': 'src/aead/index.ts', + 'format-versioning/index': 'src/format-versioning/index.ts', + 'vault-encryption/index': 'src/vault-encryption/index.ts', + 'file-encryption/index': 'src/file-encryption/index.ts', + 'key-management/index': 'src/key-management/index.ts', + 'integrity/index': 'src/integrity/index.ts', + 'migrations/index': 'src/migrations/index.ts', + }, + format: ['esm'], + dts: true, + clean: true, + sourcemap: true, + treeshake: true, + target: 'es2022', + // Keep crypto libraries external so consumers control the exact version + // and so the post-quantum dependency stays optional. + external: ['hash-wasm', '@noble/post-quantum'], +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..35ea435 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/**/index.ts'], + }, + }, +}); From bac7c6e67d0978f37bacce8d59a2c8e8e541e6ed Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 20:39:45 +0000 Subject: [PATCH 02/10] ci: pass GITHUB_TOKEN to gitleaks for PR scanning Co-Authored-By: Gaming Gruppe --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86ffa72..31bddba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,4 +41,5 @@ jobs: - name: Secret scan (gitleaks) uses: gitleaks/gitleaks-action@v2 env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITLEAKS_ENABLE_UPLOAD_ARTIFACT: 'false' From a155735a41672874f9d0d5afa341669eceb9fe1e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 20:40:52 +0000 Subject: [PATCH 03/10] ci: run gitleaks as deterministic filesystem scan Co-Authored-By: Gaming Gruppe --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31bddba..eed3c21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: - name: Dependency audit (fail on high+) run: npm audit --audit-level=high - name: Secret scan (gitleaks) - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITLEAKS_ENABLE_UPLOAD_ARTIFACT: 'false' + run: | + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.24.3/gitleaks_8.24.3_linux_x64.tar.gz \ + | tar -xz -C /tmp gitleaks + /tmp/gitleaks detect --no-git --redact -v --exit-code 1 --source . From 1a48d38ae70aec097b35c4e3c50409da2145038a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 20:53:23 +0000 Subject: [PATCH 04/10] test(compat): add Singra golden-vector gate proving byte-compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hard-coded vectors produced by the legacy Singra cryptoService prove DIS reads already-stored production ciphertext (KDF v1/v2, AEAD ±AAD, sv-vault-v1 envelope, usk-wrap-v2 bundle). Phase 1 cutover gate. Co-Authored-By: Gaming Gruppe --- src/compat/singra-golden-vectors.test.ts | 72 ++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/compat/singra-golden-vectors.test.ts diff --git a/src/compat/singra-golden-vectors.test.ts b/src/compat/singra-golden-vectors.test.ts new file mode 100644 index 0000000..825370d --- /dev/null +++ b/src/compat/singra-golden-vectors.test.ts @@ -0,0 +1,72 @@ +/** + * Phase 1 GATE — Singra ⇄ DIS byte-compatibility golden vectors. + * + * These vectors were produced by the *legacy* in-tree Singra `cryptoService` + * (singravault) and are hard-coded here so DIS proves — in its own CI, with no + * dependency on the apps — that it stays byte-compatible with already-stored + * production ciphertext. If any of these break, the apps can NO LONGER read + * data sealed by older builds: treat a failure here as a release blocker. + * + * Inputs are deterministic (fixed password + fixed salt). KDF outputs are + * asserted exactly; AEAD/vault/user-key vectors assert that DIS decrypts the + * exact bytes legacy Singra wrote (random-IV ciphertexts can't be reproduced, + * only decrypted). + */ +import { describe, expect, it } from 'vitest'; +import { + deriveRawKey, + deriveMasterKey, + decryptString, + decryptVaultEntry, + unwrapUserKeyBytes, +} from '../index.js'; + +const PW = 'correct horse battery staple'; +const SALT = 'ZGV0ZXJtaW5pc3RpYy1zYWx0LTE2Qg=='; + +const b64ToBytes = (b64: string): Uint8Array => + Uint8Array.from(Buffer.from(b64, 'base64')); + +describe('Singra golden vectors (legacy → DIS)', () => { + it('KDF v1/v2: DIS reproduces the exact Argon2id bytes legacy derived', async () => { + const vectors: Record<1 | 2, string> = { + 1: '1ehUGlF3Tb7w90vk/872uy/4uLebUW/Vzz6b6eAoGiM=', + 2: 'SF/WwUBBYz38cCp0KAmtN0dPq+O5lpGfFs5I89HfWZI=', + }; + for (const version of [1, 2] as const) { + const raw = await deriveRawKey(PW, SALT, { version }); + expect([...raw]).toEqual([...b64ToBytes(vectors[version])]); + raw.fill(0); + } + }); + + it('AEAD: DIS decrypts legacy ciphertext (with + without AAD)', async () => { + const key = await deriveMasterKey(PW, SALT, { version: 2 }); + const withAad = '1lXWfk5cncBHIX2ntRh54N0TiwIkNL0WNdA4m2vM/6fQI/wcAw=='; + const noAad = 'GOi+biTwduypJ/A9MCzWWgV3UuS0gWkQd/KZGbdLx7FB/C7nzDPUOdny3uI='; + expect(await decryptString(withAad, key, 'aad-1')).toBe('hello DIS'); + expect(await decryptString(noAad, key)).toBe('hello DIS no aad'); + }); + + it('Vault item: DIS decrypts the legacy sv-vault-v1 envelope (entryId AAD)', async () => { + const key = await deriveMasterKey(PW, SALT, { version: 2 }); + const entryId = '11111111-1111-4111-8111-111111111111'; + const sealed = + 'sv-vault-v1:nOVpHlEQUrM/okRGd9iJZU/LyfYx5A0a3VAzAbjRWNhutkew4vmfTZML4oUTbJChb4pNblxsEE/mLRAuJJl0KgHmuFhM+bH1CPFDLBwokh8='; + expect(await decryptVaultEntry(sealed, key, entryId)).toEqual({ + username: 'alice', + password: 's3cret', + notes: 'n', + }); + }); + + it('User key wrap: DIS unwraps the legacy usk-wrap-v2 bundle to 32 bytes', async () => { + const kdfOut = await deriveRawKey(PW, SALT, { version: 2 }); + const encryptedUserKey = + 'usk-wrap-v2:OcV0IQUJiYf5E3f1a144DuFJnoy8l6cSx2tyfl09GZED0iwWm5A96tkMyq6+3G/Ao6TQw/GBNGw7UK06'; + const userKey = await unwrapUserKeyBytes(encryptedUserKey, kdfOut); + expect(userKey.length).toBe(32); + kdfOut.fill(0); + userKey.fill(0); + }); +}); From e4f3a03c754dd519e485950a36d0544d54c30a80 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 20:57:33 +0000 Subject: [PATCH 05/10] feat(post-quantum): port ML-KEM-768 + RSA-4096 hybrid key wrapping Faithful port of Singra pqCryptoService into @dis/shield/post-quantum. Wire format preserved byte-for-byte (version bytes 0x01-0x04, HKDF v1/v2, ver||pq_ct||rsa_ct||iv||aes_ct layout). Adds round-trip, fail-closed (wrong AAD / tampered) and a legacy golden-vector test proving DIS decrypts already-stored v0x04 hybrid ciphertext. Exposed via the SDK facade and as an optional @noble/post-quantum peer. Co-Authored-By: Gaming Gruppe --- README.md | 1 + docs/crypto-dependency-map.md | 2 +- package.json | 4 + src/index.ts | 20 + src/post-quantum/index.ts | 754 ++++++++++++++++++++++++++ src/post-quantum/post-quantum.test.ts | 70 +++ tsup.config.ts | 1 + 7 files changed, 851 insertions(+), 1 deletion(-) create mode 100644 src/post-quantum/index.ts create mode 100644 src/post-quantum/post-quantum.test.ts diff --git a/README.md b/README.md index 72b0188..3648c35 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ import { deriveRawKey } from '@dis/shield/kdf'; | `@dis/shield/vault-encryption` | Vault-entry sealing (entry-id AAD binding) | | `@dis/shield/file-encryption` | Chunked attachment encryption + manifests | | `@dis/shield/key-management` | Content-key wrap / unwrap / rotation | +| `@dis/shield/post-quantum` | ML-KEM-768 + RSA-4096 hybrid key wrapping (sharing / emergency access) | | `@dis/shield/integrity` | SHA-256, constant-time compare, verification | | `@dis/shield/migrations` | Ordered, explicit payload migrations | diff --git a/docs/crypto-dependency-map.md b/docs/crypto-dependency-map.md index df70870..1b5bce8 100644 --- a/docs/crypto-dependency-map.md +++ b/docs/crypto-dependency-map.md @@ -20,7 +20,7 @@ time and are a baseline, not a guarantee. | `src/services/cryptoService.ts` | 1741 | KDF, AES-GCM, vault-item envelope, verification hash, user-key wrap, RSA, shared-key | `kdf`, `aead`, `vault-encryption`, `key-management`, `format-versioning` | | `src/services/keyMaterialService.ts` | 459 | Ensure/derive RSA + PQ key material | app orchestration over `key-management` (stays in app) | | `src/services/secureBuffer.ts` | 301 | Memory-safe key handling | `secure-memory` | -| `src/services/pqCryptoService.ts` | 752 | ML-KEM-768 + RSA hybrid encrypt/wrap | `key-management` (PQ hybrid submodule — phase 2) | +| `src/services/pqCryptoService.ts` | 752 | ML-KEM-768 + RSA hybrid encrypt/wrap | `post-quantum` (ported — version bytes 0x01–0x04, HKDF v1/v2 preserved) | | `src/services/deviceKeyService.ts` | — | Device-key gen + HKDF strengthening | `kdf` (HKDF strengthen) + app policy | | `src/services/vaultIntegrityV2/*` | — | Item envelopes, manifests | `vault-encryption`, `integrity` | diff --git a/package.json b/package.json index 9f12491..1769899 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,10 @@ "types": "./dist/key-management/index.d.ts", "import": "./dist/key-management/index.js" }, + "./post-quantum": { + "types": "./dist/post-quantum/index.d.ts", + "import": "./dist/post-quantum/index.js" + }, "./integrity": { "types": "./dist/integrity/index.d.ts", "import": "./dist/integrity/index.js" diff --git a/src/index.ts b/src/index.ts index aa15d7a..267db1a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,6 +64,26 @@ export { type KeyWrapScheme, } from './key-management/index.js'; +// Post-quantum hybrid key wrapping (ML-KEM-768 + RSA-4096) for sharing / +// emergency-access keys. Optional: requires the `@noble/post-quantum` peer. +export { + generatePQKeyPair, + generateHybridKeyPair, + hybridEncrypt, + hybridDecrypt, + hybridWrapKey, + hybridUnwrapKey, + isHybridEncrypted, + isCurrentStandardEncrypted, + migrateToHybrid, + buildSharedKeyWrapAad, + HYBRID_VERSION, + SECURITY_STANDARD_VERSION, + type PQKeyPair, + type HybridKeyPair, + type SharedKeyWrapAadInput, +} from './post-quantum/index.js'; + // Integrity export { verifyPayloadIntegrity, diff --git a/src/post-quantum/index.ts b/src/post-quantum/index.ts new file mode 100644 index 0000000..fc20217 --- /dev/null +++ b/src/post-quantum/index.ts @@ -0,0 +1,754 @@ +/** + * DIS — Defensive Integration Shield · post-quantum hybrid key wrapping. + * Powered by DIS — Defensive Integration Shield. + * + * Hybrid key wrapping combining: + * - ML-KEM-768 (FIPS 203) for post-quantum key encapsulation + * - RSA-4096-OAEP for classical encryption + * + * In the product threat model this protects sharing and emergency-access + * keys against "harvest now, decrypt later" attacks. It is NOT the encryption + * layer for vault item payloads, which remain AES-256-GCM encrypted with + * user-derived symmetric keys (see `@dis/shield/vault-encryption`). + * + * Wire format is preserved byte-for-byte from the legacy Singra implementation: + * version(1) || pq_ct(1088) || rsa_ct(512) || iv(12) || aes_ct(variable) + * with version bytes 0x01 (RSA-only), 0x02 (legacy hybrid), 0x03 (HKDF-v1), + * 0x04 (HKDF-v2, current). Changing any constant below breaks decryption of + * already-stored sharing/emergency keys — treat as a hard compatibility gate. + * + * @see https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.203.pdf + */ + +import { ml_kem768 } from '@noble/post-quantum/ml-kem.js'; + +// ============ Constants ============ + +/** + * Product-wide security standard version. Centralized so all hybrid/PQ + * sharing-key provisioning and runtime enforcement paths stay in sync. + */ +export const SECURITY_STANDARD_VERSION = 1; + +/** Current hybrid ciphertext version (v2 HKDF construction) */ +export const HYBRID_VERSION = 4; + +/** Version byte for legacy RSA-only encryption */ +const VERSION_RSA_ONLY = 0x01; + +/** Version byte for legacy hybrid ML-KEM + RSA encryption */ +const VERSION_HYBRID_LEGACY = 0x02; + +/** Version byte for Security Standard v1 hybrid (HKDF-v1, legacy) */ +const VERSION_HYBRID_STANDARD_V1 = 0x03; + +/** Version byte for Security Standard v1 hybrid (HKDF-v2, current) */ +const VERSION_HYBRID_STANDARD_V2 = 0x04; + +/** ML-KEM-768 ciphertext size in bytes */ +const ML_KEM_768_CIPHERTEXT_SIZE = 1088; +const RSA_4096_CIPHERTEXT_SIZE = 512; +const AES_GCM_IV_SIZE = 12; +const AES_GCM_TAG_SIZE = 16; +const HYBRID_CIPHERTEXT_MIN_SIZE = + 1 + ML_KEM_768_CIPHERTEXT_SIZE + RSA_4096_CIPHERTEXT_SIZE + AES_GCM_IV_SIZE + AES_GCM_TAG_SIZE; + +/** ML-KEM-768 public key size in bytes */ +const ML_KEM_768_PUBLIC_KEY_SIZE = 1184; + +/** ML-KEM-768 secret key size in bytes */ +const ML_KEM_768_SECRET_KEY_SIZE = 2400; + +/** ML-KEM-768 shared secret size in bytes */ +const ML_KEM_768_SHARED_SECRET_SIZE = 32; + +/** HKDF info prefix for legacy hybrid key combination (v1) */ +const HYBRID_KDF_INFO_PREFIX = new TextEncoder().encode('Singra Vault-HybridKDF-v1:'); + +/** HKDF info string for standard-compliant hybrid key combination (v2) */ +const HYBRID_KDF_INFO_V2 = new TextEncoder().encode('Singra Vault-HybridKDF-v2:'); + +export interface SharedKeyWrapAadInput { + collectionId: string; + senderUserId: string; + recipientUserId: string; + keyVersion: string | number; +} + +export function buildSharedKeyWrapAad(input: SharedKeyWrapAadInput): string { + const collectionId = requireNonEmptyAadPart(input.collectionId, 'collectionId'); + const senderUserId = requireNonEmptyAadPart(input.senderUserId, 'senderUserId'); + const recipientUserId = requireNonEmptyAadPart(input.recipientUserId, 'recipientUserId'); + const keyVersion = requireNonEmptyAadPart(String(input.keyVersion), 'keyVersion'); + return `sv:shared-key:v1:${collectionId}:${senderUserId}:${recipientUserId}:${keyVersion}`; +} + +// ============ Key Generation ============ + +/** + * Generates a new ML-KEM-768 key pair. + * + * @returns Object with base64-encoded public and secret keys + */ +export function generatePQKeyPair(): PQKeyPair { + const seed = crypto.getRandomValues(new Uint8Array(64)); + const { publicKey, secretKey } = ml_kem768.keygen(seed); + + // Zero the seed immediately + seed.fill(0); + + return { + publicKey: uint8ArrayToBase64(publicKey), + secretKey: uint8ArrayToBase64(secretKey), + }; +} + +/** + * Generates a hybrid key pair combining ML-KEM-768 and RSA-4096. + * + * @returns Object with both PQ and RSA keys + */ +export async function generateHybridKeyPair(): Promise { + // Generate ML-KEM-768 key pair + const pqKeys = generatePQKeyPair(); + + // Generate RSA-4096 key pair + const rsaKeyPair = await crypto.subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 4096, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['encrypt', 'decrypt'] + ); + + const rsaPublicJwk = await crypto.subtle.exportKey('jwk', rsaKeyPair.publicKey); + const rsaPrivateJwk = await crypto.subtle.exportKey('jwk', rsaKeyPair.privateKey); + + return { + pqPublicKey: pqKeys.publicKey, + pqSecretKey: pqKeys.secretKey, + rsaPublicKey: JSON.stringify(rsaPublicJwk), + rsaPrivateKey: JSON.stringify(rsaPrivateJwk), + }; +} + +// ============ Hybrid Key Wrapping ============ + +/** + * Encrypts key material using hybrid ML-KEM-768 + RSA-4096-OAEP encryption. + * + * The supplied key material is encrypted with a randomly generated AES-256 key. + * This AES key is then encapsulated/encrypted with both: + * 1. ML-KEM-768 (post-quantum KEM) + * 2. RSA-4096-OAEP (classically secure) + * + * Format: version(1) || pq_ciphertext(1088) || rsa_ciphertext(512) || iv(12) || aes_ciphertext(variable) + * + * @param plaintext - Serialized key material to wrap + * @param pqPublicKey - Base64-encoded ML-KEM-768 public key + * @param rsaPublicKey - JWK string of RSA-4096 public key + * @param aad - Optional additional authenticated data for AES-GCM context binding + * @returns Base64-encoded hybrid ciphertext + */ +export async function hybridEncrypt( + plaintext: string, + pqPublicKey: string, + rsaPublicKey: string, + aad?: string +): Promise { + // 1. Generate random AES-256 key (32 bytes) + const aesKeyBytes = crypto.getRandomValues(new Uint8Array(32)); + + // 2. Encapsulate with ML-KEM-768 + const pqPubKeyBytes = base64ToUint8Array(pqPublicKey); + const { cipherText: pqCiphertext, sharedSecret: pqSharedSecret } = + ml_kem768.encapsulate(pqPubKeyBytes); + + // 3. Encrypt AES key with RSA-OAEP + const rsaPubKeyJwk = JSON.parse(rsaPublicKey); + const rsaPubKey = await crypto.subtle.importKey( + 'jwk', + rsaPubKeyJwk, + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, + ['encrypt'] + ); + + const rsaCiphertext = await crypto.subtle.encrypt( + { name: 'RSA-OAEP' }, + rsaPubKey, + asBufferSource(aesKeyBytes) + ); + + const rsaCiphertextBytes = new Uint8Array(rsaCiphertext); + + // 4. Derive combined key using standard-compliant HKDF-v2 + const combinedKey = await deriveHybridCombinedKeyV2( + pqSharedSecret, + aesKeyBytes, + rsaCiphertextBytes, + ); + + // 5. Encrypt plaintext with combined AES key + const aesKey = await crypto.subtle.importKey( + 'raw', + asBufferSource(combinedKey), + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt'] + ); + + const iv = crypto.getRandomValues(new Uint8Array(12)); + const plaintextBytes = new TextEncoder().encode(plaintext); + const aadBytes = aad ? new TextEncoder().encode(aad) : undefined; + const gcmParams: AesGcmParams = { name: 'AES-GCM', iv, tagLength: 128 }; + if (aadBytes) { + gcmParams.additionalData = aadBytes; + } + const aesCiphertext = await crypto.subtle.encrypt( + gcmParams, + aesKey, + plaintextBytes + ); + + // 6. Zero sensitive data + aesKeyBytes.fill(0); + pqSharedSecret.fill(0); + combinedKey.fill(0); + + // 7. Combine: version || pq_ciphertext || rsa_ciphertext || iv || aes_ciphertext + const aesCiphertextBytes = new Uint8Array(aesCiphertext); + + const totalLength = 1 + pqCiphertext.length + rsaCiphertextBytes.length + + iv.length + aesCiphertextBytes.length; + const combined = new Uint8Array(totalLength); + + let offset = 0; + combined[offset++] = VERSION_HYBRID_STANDARD_V2; + combined.set(pqCiphertext, offset); + offset += pqCiphertext.length; + combined.set(rsaCiphertextBytes, offset); + offset += rsaCiphertextBytes.length; + combined.set(iv, offset); + offset += iv.length; + combined.set(aesCiphertextBytes, offset); + + return uint8ArrayToBase64(combined); +} + +/** + * Decrypts hybrid ML-KEM-768 + RSA-4096-OAEP wrapped key material. + * + * @param ciphertextBase64 - Base64-encoded hybrid ciphertext + * @param pqSecretKey - Base64-encoded ML-KEM-768 secret key + * @param rsaPrivateKey - JWK string of RSA-4096 private key + * @param aad - Optional additional authenticated data (must match encrypt-time AAD) + * @returns Decrypted plaintext + * @throws Error if decryption fails or version is unsupported + */ +export async function hybridDecrypt( + ciphertextBase64: string, + pqSecretKey: string, + rsaPrivateKey: string, + aad?: string +): Promise { + return decryptHybridCiphertext(ciphertextBase64, pqSecretKey, rsaPrivateKey, false, aad); +} + +/** + * Legacy RSA-only decryption for backward compatibility. + * Used when decrypting key material wrapped before the PQ upgrade. + */ +async function legacyRsaDecrypt( + ciphertext: Uint8Array, + rsaPrivateKey: string +): Promise { + const rsaPrivKeyJwk = JSON.parse(rsaPrivateKey); + const rsaPrivKey = await crypto.subtle.importKey( + 'jwk', + rsaPrivKeyJwk, + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, + ['decrypt'] + ); + + const plaintextBytes = await crypto.subtle.decrypt( + { name: 'RSA-OAEP' }, + rsaPrivKey, + asBufferSource(ciphertext) + ); + + return new TextDecoder().decode(plaintextBytes); +} + +// ============ Key Wrapping for Shared Collections ============ + +/** + * Wraps a shared AES key using hybrid encryption. + * Used for shared collections where each member gets a wrapped copy. + * + * @param sharedKeyJwk - JWK string of the shared AES-256 key + * @param pqPublicKey - Base64-encoded ML-KEM-768 public key + * @param rsaPublicKey - JWK string of RSA-4096 public key + * @param aad - Optional additional authenticated data (e.g. collection ID) + * @returns Base64-encoded wrapped key + */ +export async function hybridWrapKey( + sharedKeyJwk: string, + pqPublicKey: string, + rsaPublicKey: string, + aad: string +): Promise { + return hybridEncrypt(sharedKeyJwk, pqPublicKey, rsaPublicKey, requireAad(aad, 'hybridWrapKey')); +} + +/** + * Unwraps a shared AES key using hybrid decryption. + * + * @param wrappedKey - Base64-encoded wrapped key + * @param pqSecretKey - Base64-encoded ML-KEM-768 secret key + * @param rsaPrivateKey - JWK string of RSA-4096 private key + * @param aad - Optional additional authenticated data (must match wrap-time AAD) + * @returns JWK string of the shared AES-256 key + */ +export async function hybridUnwrapKey( + wrappedKey: string, + pqSecretKey: string, + rsaPrivateKey: string, + aad: string +): Promise { + return hybridDecrypt(wrappedKey, pqSecretKey, rsaPrivateKey, requireAad(aad, 'hybridUnwrapKey')); +} + +// ============ Migration Helpers ============ + +/** + * Checks if wrapped key material uses any hybrid (post-quantum) encryption version. + * Recognizes legacy hybrid (0x02), standard v1 (0x03), and standard v2 (0x04). + * + * @param ciphertextBase64 - Base64-encoded ciphertext + * @returns true if any hybrid wrapped-key version, false if legacy RSA-only or unknown + */ +export function isHybridEncrypted(ciphertextBase64: string): boolean { + try { + const combined = base64ToUint8Array(ciphertextBase64); + const v = combined[0]; + return v === VERSION_HYBRID_LEGACY || + v === VERSION_HYBRID_STANDARD_V1 || + v === VERSION_HYBRID_STANDARD_V2; + } catch { + return false; + } +} + +/** + * Checks if a ciphertext uses the current standard encryption (v2 HKDF, version 0x04). + * Use this for security enforcement checks where only the latest format is acceptable. + * + * @param ciphertextBase64 - Base64-encoded ciphertext + * @returns true if current standard (0x04), false otherwise + */ +export function isCurrentStandardEncrypted(ciphertextBase64: string): boolean { + try { + const combined = base64ToUint8Array(ciphertextBase64); + return combined[0] === VERSION_HYBRID_STANDARD_V2; + } catch { + return false; + } +} + +/** + * Re-wraps legacy RSA-only or older hybrid key material with current hybrid key wrapping (v2). + * Used during migration to post-quantum protection for sharing and emergency keys. + * + * - Version 0x04: already current, returned unchanged. + * - Version 0x03: decrypted with legacy HKDF-v1, re-encrypted with HKDF-v2. + * - Version 0x02: decrypted with legacy hybrid path, re-encrypted. + * - Version 0x01 / unknown: decrypted with RSA-only, re-encrypted. + * + * @param legacyCiphertext - Base64-encoded legacy ciphertext + * @param rsaPrivateKey - JWK string of RSA private key for decryption + * @param pqSecretKey - Base64-encoded ML-KEM-768 secret key (required for hybrid legacy) + * @param pqPublicKey - Base64-encoded ML-KEM-768 public key + * @param rsaPublicKey - JWK string of RSA public key + * @returns Base64-encoded hybrid ciphertext (version 0x04) + */ +export async function migrateToHybrid( + legacyCiphertext: string, + rsaPrivateKey: string, + pqSecretKey: string | null, + pqPublicKey: string, + rsaPublicKey: string, + aad?: string, +): Promise { + const combined = base64ToUint8Array(legacyCiphertext); + const version = combined[0]; + + // Already current standard — no migration needed + if (version === VERSION_HYBRID_STANDARD_V2) { + if (aad) { + if (!pqSecretKey) { + throw new Error('PQ secret key is required to verify current hybrid ciphertext AAD.'); + } + await decryptHybridCiphertext( + legacyCiphertext, + pqSecretKey, + rsaPrivateKey, + false, + aad, + ); + } + return legacyCiphertext; + } + + let plaintext: string; + + if (version === VERSION_HYBRID_STANDARD_V1) { + // v0x03 → decrypt with legacy HKDF-v1, re-encrypt with HKDF-v2 + if (!pqSecretKey) { + throw new Error('PQ secret key is required to migrate v0x03 ciphertext.'); + } + plaintext = await decryptHybridCiphertext( + legacyCiphertext, + pqSecretKey, + rsaPrivateKey, + true, // allowLegacyFormats for internal re-encryption + aad, + ); + } else if (version === VERSION_RSA_ONLY) { + plaintext = await legacyRsaDecrypt(combined.slice(1), rsaPrivateKey); + } else if (version === VERSION_HYBRID_LEGACY) { + if (!pqSecretKey) { + throw new Error('PQ secret key is required to migrate legacy hybrid ciphertext.'); + } + + plaintext = await decryptHybridCiphertext( + legacyCiphertext, + pqSecretKey, + rsaPrivateKey, + true, + aad, + ); + } else { + // Very old format without version byte - assume raw RSA ciphertext + plaintext = await legacyRsaDecrypt(combined, rsaPrivateKey); + } + + return hybridEncrypt(plaintext, pqPublicKey, rsaPublicKey, aad); +} + +// ============ Utility Functions ============ + +function uint8ArrayToBase64(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +function base64ToUint8Array(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function concatUint8Arrays(first: Uint8Array, second: Uint8Array): Uint8Array { + const combined = new Uint8Array(first.length + second.length); + combined.set(first, 0); + combined.set(second, first.length); + return combined; +} + +/** + * Legacy HKDF key derivation for version 0x03 ciphertexts. + * Uses pqSharedSecret as IKM and aesKeyBytes as salt (non-standard). + * Kept only for backward-compatible decryption of existing 0x03 data. + * + * @private + */ +async function deriveHybridCombinedKey( + pqSharedSecret: Uint8Array, + aesKeyBytes: Uint8Array, + rsaCiphertext: Uint8Array, +): Promise { + const baseKey = await crypto.subtle.importKey( + 'raw', + asBufferSource(pqSharedSecret), + 'HKDF', + false, + ['deriveBits'], + ); + + const info = concatUint8Arrays(HYBRID_KDF_INFO_PREFIX, rsaCiphertext); + try { + const derivedBits = await crypto.subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: asBufferSource(aesKeyBytes), + info: asBufferSource(info), + }, + baseKey, + 256, + ); + + return new Uint8Array(derivedBits); + } finally { + info.fill(0); + } +} + +/** + * Standard-compliant HKDF key derivation for version 0x04 ciphertexts. + * Both secrets (pqSharedSecret and aesKeyBytes) are concatenated as IKM. + * Salt is zero-bytes (NIST-recommended for hybrid KDF). + * Info includes RSA ciphertext for context binding. + * + * @private + */ +async function deriveHybridCombinedKeyV2( + pqSharedSecret: Uint8Array, + aesKeyBytes: Uint8Array, + rsaCiphertext: Uint8Array, +): Promise { + // IKM = pqSharedSecret || aesKeyBytes (both secrets as input keying material) + const ikm = concatUint8Arrays(pqSharedSecret, aesKeyBytes); + + const baseKey = await crypto.subtle.importKey( + 'raw', + asBufferSource(ikm), + 'HKDF', + false, + ['deriveBits'], + ); + + const info = concatUint8Arrays(HYBRID_KDF_INFO_V2, rsaCiphertext); + try { + const derivedBits = await crypto.subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: asBufferSource(new Uint8Array(32)), // zero-byte salt (NIST-recommended) + info: asBufferSource(info), + }, + baseKey, + 256, + ); + + return new Uint8Array(derivedBits); + } finally { + ikm.fill(0); + info.fill(0); + } +} + +/** + * Internal decrypt function supporting all hybrid ciphertext versions. + * + * - Version 0x04: Uses HKDF-v2 (standard-compliant). + * - Version 0x03: Uses HKDF-v1 (legacy, kept for backward compat). + * - Version 0x02: Legacy hybrid (same decrypt path as 0x03). + * - Version 0x01: RSA-only (only if allowLegacyFormats=true). + * + * @param allowLegacyFormats - If false, blocks versions 0x01 and 0x02. + * Versions 0x03 and 0x04 are always accepted. + * @param aad - Optional AAD for AES-GCM context binding. + */ +async function decryptHybridCiphertext( + ciphertextBase64: string, + pqSecretKey: string, + rsaPrivateKey: string, + allowLegacyFormats: boolean, + aad?: string, +): Promise { + const combined = base64ToUint8Array(ciphertextBase64); + const version = combined[0]; + + // Versions 0x03 and 0x04 are always accepted. + // Versions 0x01 and 0x02 are only accepted if allowLegacyFormats is true. + if (!allowLegacyFormats && + version !== VERSION_HYBRID_STANDARD_V1 && + version !== VERSION_HYBRID_STANDARD_V2) { + throw new Error('Security Standard v1 requires hybrid ciphertext version 3 or 4.'); + } + + if (version === VERSION_RSA_ONLY) { + if (!allowLegacyFormats) { + throw new Error('RSA-only ciphertext is blocked by Security Standard v1.'); + } + + return legacyRsaDecrypt(combined.slice(1), rsaPrivateKey); + } + + if (version !== VERSION_HYBRID_STANDARD_V2 && + version !== VERSION_HYBRID_STANDARD_V1 && + version !== VERSION_HYBRID_LEGACY) { + throw new Error(`Unsupported encryption version: ${version}`); + } + + const { pqCiphertext, rsaCiphertext, iv, aesCiphertext } = parseHybridCiphertext(combined); + + // Decapsulate ML-KEM-768 shared secret. + const pqSecretKeyBytes = base64ToUint8Array(pqSecretKey); + const pqSharedSecret = ml_kem768.decapsulate(pqCiphertext, pqSecretKeyBytes); + + // Decrypt AES key with RSA-OAEP. + const rsaPrivKeyJwk = JSON.parse(rsaPrivateKey); + const rsaPrivKey = await crypto.subtle.importKey( + 'jwk', + rsaPrivKeyJwk, + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, + ['decrypt'] + ); + + const aesKeyBytes = new Uint8Array(await crypto.subtle.decrypt( + { name: 'RSA-OAEP' }, + rsaPrivKey, + asBufferSource(rsaCiphertext) + )); + + // Select KDF based on version + let combinedKey: Uint8Array; + if (version === VERSION_HYBRID_STANDARD_V2) { + combinedKey = await deriveHybridCombinedKeyV2( + pqSharedSecret, + aesKeyBytes, + rsaCiphertext, + ); + } else { + // 0x03 and 0x02 use legacy HKDF-v1 + combinedKey = await deriveHybridCombinedKey( + pqSharedSecret, + aesKeyBytes, + rsaCiphertext, + ); + } + + // Decrypt plaintext with combined AES key. + const aesKey = await crypto.subtle.importKey( + 'raw', + asBufferSource(combinedKey), + { name: 'AES-GCM', length: 256 }, + false, + ['decrypt'] + ); + + try { + const aadBytes = aad ? new TextEncoder().encode(aad) : undefined; + const gcmParams: AesGcmParams = { name: 'AES-GCM', iv: asBufferSource(iv), tagLength: 128 }; + if (aadBytes) { + gcmParams.additionalData = aadBytes; + } + const plaintextBytes = await crypto.subtle.decrypt( + gcmParams, + aesKey, + asBufferSource(aesCiphertext) + ); + + return new TextDecoder().decode(plaintextBytes); + } finally { + aesKeyBytes.fill(0); + pqSharedSecret.fill(0); + combinedKey.fill(0); + } +} + +function parseHybridCiphertext(combined: Uint8Array): ParsedHybridCiphertext { + const version = combined[0]; + if ( + combined.length < HYBRID_CIPHERTEXT_MIN_SIZE || + ( + version !== VERSION_HYBRID_LEGACY && + version !== VERSION_HYBRID_STANDARD_V1 && + version !== VERSION_HYBRID_STANDARD_V2 + ) + ) { + throw new Error('Invalid hybrid ciphertext format.'); + } + + let offset = 1; + + const pqCiphertext = combined.slice(offset, offset + ML_KEM_768_CIPHERTEXT_SIZE); + offset += ML_KEM_768_CIPHERTEXT_SIZE; + + const rsaCiphertext = combined.slice(offset, offset + RSA_4096_CIPHERTEXT_SIZE); + offset += RSA_4096_CIPHERTEXT_SIZE; + + const iv = combined.slice(offset, offset + AES_GCM_IV_SIZE); + offset += AES_GCM_IV_SIZE; + + const aesCiphertext = combined.slice(offset); + if ( + pqCiphertext.length !== ML_KEM_768_CIPHERTEXT_SIZE || + rsaCiphertext.length !== RSA_4096_CIPHERTEXT_SIZE || + iv.length !== AES_GCM_IV_SIZE || + aesCiphertext.length < AES_GCM_TAG_SIZE + ) { + throw new Error('Invalid hybrid ciphertext format.'); + } + + return { pqCiphertext, rsaCiphertext, iv, aesCiphertext }; +} + +function requireAad(aad: string, operation: string): string { + if (typeof aad !== 'string' || aad.trim().length === 0) { + throw new Error(`${operation} requires non-empty AAD for wrapped-key context binding.`); + } + return aad; +} + +function requireNonEmptyAadPart(value: string, label: string): string { + const normalized = value.trim(); + if (!normalized) { + throw new Error(`Missing AAD component: ${label}`); + } + if (normalized.includes(':')) { + throw new Error(`AAD component must not contain ':': ${label}`); + } + return normalized; +} + +function asBufferSource(bytes: Uint8Array): BufferSource { + return bytes as unknown as BufferSource; +} + +// ============ Type Definitions ============ + +/** + * ML-KEM-768 key pair + */ +export interface PQKeyPair { + /** Base64-encoded ML-KEM-768 public key (1184 bytes) */ + publicKey: string; + /** Base64-encoded ML-KEM-768 secret key (2400 bytes) */ + secretKey: string; +} + +/** + * Combined hybrid key pair with both PQ and classical keys + */ +export interface HybridKeyPair { + /** Base64-encoded ML-KEM-768 public key */ + pqPublicKey: string; + /** Base64-encoded ML-KEM-768 secret key */ + pqSecretKey: string; + /** JWK string of RSA-4096 public key */ + rsaPublicKey: string; + /** JWK string of RSA-4096 private key */ + rsaPrivateKey: string; +} + +interface ParsedHybridCiphertext { + pqCiphertext: Uint8Array; + rsaCiphertext: Uint8Array; + iv: Uint8Array; + aesCiphertext: Uint8Array; +} diff --git a/src/post-quantum/post-quantum.test.ts b/src/post-quantum/post-quantum.test.ts new file mode 100644 index 0000000..e6e9363 --- /dev/null +++ b/src/post-quantum/post-quantum.test.ts @@ -0,0 +1,70 @@ +/** + * Post-quantum hybrid key wrapping (ML-KEM-768 + RSA-4096) — round-trip, + * negative, and legacy golden-vector tests. + * + * The GOLDEN VECTOR below was produced by the legacy Singra pqCryptoService + * (version byte 0x04, HKDF-v2). It proves DIS decrypts already-stored hybrid + * sharing/emergency-key ciphertext byte-for-byte. A failure here means the + * apps can no longer read shared-collection / emergency-access keys. + */ +import { describe, expect, it } from "vitest"; +import { + generateHybridKeyPair, + hybridEncrypt, + hybridDecrypt, + hybridWrapKey, + hybridUnwrapKey, + isHybridEncrypted, + isCurrentStandardEncrypted, + buildSharedKeyWrapAad, +} from "../index.js"; + +const LEGACY = { + plaintext: "{\"k\":\"shared-collection-key\",\"v\":1}", + aad: "sv:shared-key:v1:col-1:sender-1:recipient-1:1", + ciphertext: "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", + pqSecretKey: "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", + rsaPrivateKey: "{\"key_ops\":[\"decrypt\"],\"ext\":true,\"kty\":\"RSA\",\"n\":\"wdbfXaiM1JkK30QBknf6szPK10ym_yv_AnuyMJHaobMKboN7EBEpbA1qAiKP64tmrfWZK9Hv0dYD0xGK5tjgCZGNlgTYlLZjQC_eSWlvrPGgAKQiuveQFBL4kR3CGK1nnBaAGDvQJJYHQOIa6FUoE8UjZ5v_T1VSimBWSQyXI2YJXjgTRUHpeteCc1GpND4p77iNQMdnUeEebKApnGEslXr_SYhG2ewddSGWxW-7dOgz4bp20FfD-a3-uAOYduLFixSAHplcbOU-O6vTFslTY9wA7u95BCesRN0JSmZsSnFF5MGtqByqUXwjivNd6Zqjl308F2lozfmtrwMgdLMvU9P7QkIerqSpxLsUaIwOWtz_hHmydLx3ZYtXkJUmV2LLD51Y_eM-skYM6QYuAW0UjT0OMw67HFycG-MlSquHu42BZYJfsGWmHdWvMSlO4Coo6iWnRG1wgWSDnY1YYOKS1xm_UTeszciZMGpzuxlFxM_A9ibx24Bjxy1Y2C32uxpLCuHfljZC-vZa3PZ_hfNH4x-A47IRgQA5ioKmNVGpLjmXfnGDC10qTqRWWiWs-pWhpsHD4mBlHxhwySevYXAsNjjg1gXLUQ-yGK7WpX09zHTMuUbEq2Y_f5A6TaUQntzrfny9VMoGl_7UAmnHKbCVQnBH07rR1CCsqgReRNV-Pf8\",\"e\":\"AQAB\",\"d\":\"DOLAh99ejVDR3N8OeQgqIvjnG8I9ZRUtp01FyTNzpJrcWgBk5pUy0GYL4rHIKNEO4F2ancPKUi63Tl601yfFAkh_cGDzbN9ltayjoEm2t3kl48-wJ1xcYu6SlkMI1iR2O3VwyoD0ve84nY2VBauI4II9xPN6g3GZQm2pDsNxgKmV0defIFQuCzvXBjIq3Lg33XcX8h3PYS1lR7SrT3lTl2micubw9CeyxZ3tV0QC4tX_gkVSWgNGRIuBNVu31PS6S8aZxcRNf_JgCA4iGDpbUCDInG7699W9_qNFrNZ7jcSoms5tQZG2Q3h3JxOKmK7uVRxpfueY-f2xGBijHbQ-XxHlkmFVtffsNQJC-5fWOi4i6SCJhNKqx2jAGYqx3E7fAFgpEBE3iSDKXos5McAp5aHUXr7VDCNh9W88s_NNqsQsV2L9NoS4cDvBi7om6ubEHemN_fc5aWoAEpkpg0IZFafN_r-uKNfqarBNDFb1Pn2OcHVjyFnRv6cHyDfldOtuSe8-KiUxLEZsQ6zdX5VM-HP7zceUTwhmotLg49fOW78_tLUlXs3dl4J3hygyxKTw-sFa4hBYlhYl4OQSV1hWnze0M85CKql0t0mBC4E1sJX6md4JBlEULQm_ygt6gLmJJ2KZExLu6bP4BRVRJoStu0-WYNVYK-DFgEdrqqlWcFE\",\"p\":\"7HuG5sWR5YypVzuvPXraF-DRkn2Dwweid8jIZ3kaoq4zd_o3ZFNfv3aA40QTKSbS7Wzji0SaM8w3WIbG_WI856LtgibSPMFYJO31WCoduyGlc61orcOywpWsPLB99QQaysssoRwSev75GNGLgb39afkar-p8tXjZ59u2w2RWkT2QQhqlTWd2On3ubgLIWjlbbjgnCGM4WqQ7XSt6h0RFr07yEawG6B0okWf0BI4KDGtcx7OMsQh3qtXgIZoV8950UpT0AO-iIFYtlf0-kVd3B0EN3a-B05-3dYJHFLoz_VrA2il8BCrAuFzW8uGh5NSVMFRuRThJ1-F-FhexhxTIsw\",\"q\":\"0dZeJgcBith_kZoX-xGGE4VMfxWZzU-snkzz95LkkREJUsSnGenkT54BpI4M5dCiPD0wJKK3uUdnHY01SxlEuC0nxfmNi1HusaEuR9dUX87vlxG-TAgfZ28kNRw1IyCD7KX1ezU69zqOwGSbPaa4JrITaj52tkRuOX7l-DQr1oun0aGueDvqxA7LSfGRwxjE02OpxzZqX3FQn9XsOd7h3VMWr43D-6sEJWZA2iR97k1hTf69nnCbb-EWbdIvecJnT5cWA7lGIN85WmgNbw-2jxY8ZOC6MiZMFbv-iX06BX4eSob6WCJQ76I8-ZD20vwupCzg6XSlJCne-opNSHijhQ\",\"dp\":\"3OXec36l9AjavhOQdBtn0do9qVr5U5q0FrRFDvK_AKs8hJwEVgDTdaOabbBPPad4bDPEsXjZmfzuzhDHnDTBs5YryeG9jOcGESj-fuaIcx7Q0Cdxmq8tMjphcydh4Rd-d2QmQjBYyu-Ve6txZzYzm2QHm7-r0lAbLEu-gvIdMvqQ4E7HjnBQrf6oU7bhs_XUBDcLrvgP0guLMFLG18fcWA-kawGIShXCqWCzPfX4SPWY6yo7B7tjHP8_p-OpEe4ANovRCXbOuOoHFw5B_b33_5yy-RtSaH3O_0M8Zo4wtj6p2p_ZqoLNFuoSFzrQ4VH6MfUMNDiKMc_-2WA0gnvVpQ\",\"dq\":\"AWVfsvkQ9Y-DKcDQsAbp0W9tltrZ7xe8mkEAzoDXrG9klHxicDWyIyV19VZMl6rPqX7utw-uETl8YiHyXNGKN391aEfEvUyKPfxIhonUMd76kRK5JWBYdSO0JfZOFDG_Lu_btjogbkyhbn482iglyXwdzPMlbwj9grxpY0FVmVPMhgSBWKNtaGiAybklsxqTFKTxGDYwdvoWAzo1HB1zezl2SSy0RRRaLrWDcPAVNmSlZRNwx4EQR6pDr-9aCYFVlp32s4ekA8v4YbWXgUmleUY4mKM2GedPUkWx59BBdo_kO7KyL6vqxe1aYn6oZbbvyH_T7zmrb5YnuZr58KV80Q\",\"qi\":\"Yw4hu6nUMTuVGCapq_ztatzvax7hi_BXtVugFARkt-BxwJkRkhrXfwVJp3wBfYLZARDpn3DZO3a6LInhmZRWsk6emD9Gdx6pRosiArrBSthWy1x9nWdD7l22S1stQbRz2fP-TcHzuzKtO5tf0qqKW4gkc7I8y10xIalaM-aLwAeHh7GAOoKh6sVO8UtGMsze-_hYOLL_bK643HRQVEJ-VbiTao1zib744KDXCfFJJwfrqZKUxMrMtHuME8AJ2m8oDIBOvDYD_pjy1cEJBJ4gksTA5gbJOOXAaETcpjrrN1aXGNkApyKId6Z6FXhhEv_UpCyqwKoGVEJF6njAp0UAFA\",\"alg\":\"RSA-OAEP-256\"}", +}; + +describe("post-quantum hybrid", () => { + it("round-trips hybridEncrypt -> hybridDecrypt with AAD", async () => { + const kp = await generateHybridKeyPair(); + const aad = buildSharedKeyWrapAad({ + collectionId: "c1", + senderUserId: "s1", + recipientUserId: "r1", + keyVersion: 1, + }); + const ct = await hybridWrapKey("super-secret-shared-key", kp.pqPublicKey, kp.rsaPublicKey, aad); + expect(isHybridEncrypted(ct)).toBe(true); + expect(isCurrentStandardEncrypted(ct)).toBe(true); + expect(await hybridUnwrapKey(ct, kp.pqSecretKey, kp.rsaPrivateKey, aad)).toBe("super-secret-shared-key"); + }); + + it("fails closed on wrong AAD", async () => { + const kp = await generateHybridKeyPair(); + const ct = await hybridEncrypt("x", kp.pqPublicKey, kp.rsaPublicKey, "aad-A"); + await expect(hybridDecrypt(ct, kp.pqSecretKey, kp.rsaPrivateKey, "aad-B")).rejects.toThrow(); + }); + + it("fails closed on tampered ciphertext", async () => { + const kp = await generateHybridKeyPair(); + const ct = await hybridEncrypt("x", kp.pqPublicKey, kp.rsaPublicKey, "aad"); + const raw = Uint8Array.from(Buffer.from(ct, "base64")); + raw[raw.length - 1] ^= 0xff; + const tampered = Buffer.from(raw).toString("base64"); + await expect(hybridDecrypt(tampered, kp.pqSecretKey, kp.rsaPrivateKey, "aad")).rejects.toThrow(); + }); + + it("GOLDEN: DIS decrypts a legacy-produced v0x04 hybrid ciphertext", async () => { + expect(isCurrentStandardEncrypted(LEGACY.ciphertext)).toBe(true); + const out = await hybridDecrypt( + LEGACY.ciphertext, + LEGACY.pqSecretKey, + LEGACY.rsaPrivateKey, + LEGACY.aad, + ); + expect(out).toBe(LEGACY.plaintext); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index b0c6156..ecd4d64 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ 'vault-encryption/index': 'src/vault-encryption/index.ts', 'file-encryption/index': 'src/file-encryption/index.ts', 'key-management/index': 'src/key-management/index.ts', + 'post-quantum/index': 'src/post-quantum/index.ts', 'integrity/index': 'src/integrity/index.ts', 'migrations/index': 'src/migrations/index.ts', }, From b958c81748f6c1922fa2e915192da2052eab12f5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 20:59:49 +0000 Subject: [PATCH 06/10] fix(ci): move PQ test vectors to fixture + gitleaks allowlist Gitleaks flagged the synthetic ML-KEM secret key in the PQ golden-vector test as a generic secret (high entropy). Move the vector into a dedicated __fixtures__ file documented as non-secret synthetic test material and narrowly allowlist that path in .gitleaks.toml (default ruleset still applies everywhere else). Co-Authored-By: Gaming Gruppe --- .github/workflows/ci.yml | 2 +- .gitleaks.toml | 19 +++++++++++++++++++ .../__fixtures__/legacy-hybrid.ts | 15 +++++++++++++++ src/post-quantum/post-quantum.test.ts | 9 +-------- 4 files changed, 36 insertions(+), 9 deletions(-) create mode 100644 .gitleaks.toml create mode 100644 src/post-quantum/__fixtures__/legacy-hybrid.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eed3c21..c81567c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,4 +42,4 @@ jobs: run: | curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.24.3/gitleaks_8.24.3_linux_x64.tar.gz \ | tar -xz -C /tmp gitleaks - /tmp/gitleaks detect --no-git --redact -v --exit-code 1 --source . + /tmp/gitleaks detect --no-git --redact -v --exit-code 1 --source . --config .gitleaks.toml diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..fc34eb3 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,19 @@ +# gitleaks configuration for DIS — Defensive Integration Shield. +# +# Extends the upstream default ruleset and adds a narrowly-scoped allowlist for +# synthetic cryptographic TEST VECTORS. These fixtures contain ephemeral keys +# (e.g. ML-KEM-768 / RSA private keys) generated solely to prove byte-for-byte +# decryption compatibility. They are NOT real secrets and correspond to no +# account or stored data. The allowlist is scoped to dedicated fixture paths so +# real secrets committed elsewhere are still caught. + +title = "DIS gitleaks config" + +[extend] +useDefault = true + +[allowlist] +description = "Synthetic crypto test vectors (not real secrets)" +paths = [ + '''src/post-quantum/__fixtures__/.*''', +] diff --git a/src/post-quantum/__fixtures__/legacy-hybrid.ts b/src/post-quantum/__fixtures__/legacy-hybrid.ts new file mode 100644 index 0000000..3263049 --- /dev/null +++ b/src/post-quantum/__fixtures__/legacy-hybrid.ts @@ -0,0 +1,15 @@ +/** + * Synthetic post-quantum hybrid test vector. + * + * NOT A REAL SECRET: the ML-KEM-768 secret key and RSA private key below were + * generated ephemerally by the legacy Singra pqCryptoService purely to prove + * DIS decrypts legacy-produced v0x04 hybrid ciphertext byte-for-byte. They + * correspond to no account or stored data. (Allowlisted in .gitleaks.toml.) + */ +export const LEGACY_HYBRID_VECTOR = { + plaintext: "{\"k\":\"shared-collection-key\",\"v\":1}", + aad: "sv:shared-key:v1:col-1:sender-1:recipient-1:1", + ciphertext: "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", + pqSecretKey: "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", + rsaPrivateKey: "{\"key_ops\":[\"decrypt\"],\"ext\":true,\"kty\":\"RSA\",\"n\":\"wdbfXaiM1JkK30QBknf6szPK10ym_yv_AnuyMJHaobMKboN7EBEpbA1qAiKP64tmrfWZK9Hv0dYD0xGK5tjgCZGNlgTYlLZjQC_eSWlvrPGgAKQiuveQFBL4kR3CGK1nnBaAGDvQJJYHQOIa6FUoE8UjZ5v_T1VSimBWSQyXI2YJXjgTRUHpeteCc1GpND4p77iNQMdnUeEebKApnGEslXr_SYhG2ewddSGWxW-7dOgz4bp20FfD-a3-uAOYduLFixSAHplcbOU-O6vTFslTY9wA7u95BCesRN0JSmZsSnFF5MGtqByqUXwjivNd6Zqjl308F2lozfmtrwMgdLMvU9P7QkIerqSpxLsUaIwOWtz_hHmydLx3ZYtXkJUmV2LLD51Y_eM-skYM6QYuAW0UjT0OMw67HFycG-MlSquHu42BZYJfsGWmHdWvMSlO4Coo6iWnRG1wgWSDnY1YYOKS1xm_UTeszciZMGpzuxlFxM_A9ibx24Bjxy1Y2C32uxpLCuHfljZC-vZa3PZ_hfNH4x-A47IRgQA5ioKmNVGpLjmXfnGDC10qTqRWWiWs-pWhpsHD4mBlHxhwySevYXAsNjjg1gXLUQ-yGK7WpX09zHTMuUbEq2Y_f5A6TaUQntzrfny9VMoGl_7UAmnHKbCVQnBH07rR1CCsqgReRNV-Pf8\",\"e\":\"AQAB\",\"d\":\"DOLAh99ejVDR3N8OeQgqIvjnG8I9ZRUtp01FyTNzpJrcWgBk5pUy0GYL4rHIKNEO4F2ancPKUi63Tl601yfFAkh_cGDzbN9ltayjoEm2t3kl48-wJ1xcYu6SlkMI1iR2O3VwyoD0ve84nY2VBauI4II9xPN6g3GZQm2pDsNxgKmV0defIFQuCzvXBjIq3Lg33XcX8h3PYS1lR7SrT3lTl2micubw9CeyxZ3tV0QC4tX_gkVSWgNGRIuBNVu31PS6S8aZxcRNf_JgCA4iGDpbUCDInG7699W9_qNFrNZ7jcSoms5tQZG2Q3h3JxOKmK7uVRxpfueY-f2xGBijHbQ-XxHlkmFVtffsNQJC-5fWOi4i6SCJhNKqx2jAGYqx3E7fAFgpEBE3iSDKXos5McAp5aHUXr7VDCNh9W88s_NNqsQsV2L9NoS4cDvBi7om6ubEHemN_fc5aWoAEpkpg0IZFafN_r-uKNfqarBNDFb1Pn2OcHVjyFnRv6cHyDfldOtuSe8-KiUxLEZsQ6zdX5VM-HP7zceUTwhmotLg49fOW78_tLUlXs3dl4J3hygyxKTw-sFa4hBYlhYl4OQSV1hWnze0M85CKql0t0mBC4E1sJX6md4JBlEULQm_ygt6gLmJJ2KZExLu6bP4BRVRJoStu0-WYNVYK-DFgEdrqqlWcFE\",\"p\":\"7HuG5sWR5YypVzuvPXraF-DRkn2Dwweid8jIZ3kaoq4zd_o3ZFNfv3aA40QTKSbS7Wzji0SaM8w3WIbG_WI856LtgibSPMFYJO31WCoduyGlc61orcOywpWsPLB99QQaysssoRwSev75GNGLgb39afkar-p8tXjZ59u2w2RWkT2QQhqlTWd2On3ubgLIWjlbbjgnCGM4WqQ7XSt6h0RFr07yEawG6B0okWf0BI4KDGtcx7OMsQh3qtXgIZoV8950UpT0AO-iIFYtlf0-kVd3B0EN3a-B05-3dYJHFLoz_VrA2il8BCrAuFzW8uGh5NSVMFRuRThJ1-F-FhexhxTIsw\",\"q\":\"0dZeJgcBith_kZoX-xGGE4VMfxWZzU-snkzz95LkkREJUsSnGenkT54BpI4M5dCiPD0wJKK3uUdnHY01SxlEuC0nxfmNi1HusaEuR9dUX87vlxG-TAgfZ28kNRw1IyCD7KX1ezU69zqOwGSbPaa4JrITaj52tkRuOX7l-DQr1oun0aGueDvqxA7LSfGRwxjE02OpxzZqX3FQn9XsOd7h3VMWr43D-6sEJWZA2iR97k1hTf69nnCbb-EWbdIvecJnT5cWA7lGIN85WmgNbw-2jxY8ZOC6MiZMFbv-iX06BX4eSob6WCJQ76I8-ZD20vwupCzg6XSlJCne-opNSHijhQ\",\"dp\":\"3OXec36l9AjavhOQdBtn0do9qVr5U5q0FrRFDvK_AKs8hJwEVgDTdaOabbBPPad4bDPEsXjZmfzuzhDHnDTBs5YryeG9jOcGESj-fuaIcx7Q0Cdxmq8tMjphcydh4Rd-d2QmQjBYyu-Ve6txZzYzm2QHm7-r0lAbLEu-gvIdMvqQ4E7HjnBQrf6oU7bhs_XUBDcLrvgP0guLMFLG18fcWA-kawGIShXCqWCzPfX4SPWY6yo7B7tjHP8_p-OpEe4ANovRCXbOuOoHFw5B_b33_5yy-RtSaH3O_0M8Zo4wtj6p2p_ZqoLNFuoSFzrQ4VH6MfUMNDiKMc_-2WA0gnvVpQ\",\"dq\":\"AWVfsvkQ9Y-DKcDQsAbp0W9tltrZ7xe8mkEAzoDXrG9klHxicDWyIyV19VZMl6rPqX7utw-uETl8YiHyXNGKN391aEfEvUyKPfxIhonUMd76kRK5JWBYdSO0JfZOFDG_Lu_btjogbkyhbn482iglyXwdzPMlbwj9grxpY0FVmVPMhgSBWKNtaGiAybklsxqTFKTxGDYwdvoWAzo1HB1zezl2SSy0RRRaLrWDcPAVNmSlZRNwx4EQR6pDr-9aCYFVlp32s4ekA8v4YbWXgUmleUY4mKM2GedPUkWx59BBdo_kO7KyL6vqxe1aYn6oZbbvyH_T7zmrb5YnuZr58KV80Q\",\"qi\":\"Yw4hu6nUMTuVGCapq_ztatzvax7hi_BXtVugFARkt-BxwJkRkhrXfwVJp3wBfYLZARDpn3DZO3a6LInhmZRWsk6emD9Gdx6pRosiArrBSthWy1x9nWdD7l22S1stQbRz2fP-TcHzuzKtO5tf0qqKW4gkc7I8y10xIalaM-aLwAeHh7GAOoKh6sVO8UtGMsze-_hYOLL_bK643HRQVEJ-VbiTao1zib744KDXCfFJJwfrqZKUxMrMtHuME8AJ2m8oDIBOvDYD_pjy1cEJBJ4gksTA5gbJOOXAaETcpjrrN1aXGNkApyKId6Z6FXhhEv_UpCyqwKoGVEJF6njAp0UAFA\",\"alg\":\"RSA-OAEP-256\"}", +} as const; diff --git a/src/post-quantum/post-quantum.test.ts b/src/post-quantum/post-quantum.test.ts index e6e9363..dd5321f 100644 --- a/src/post-quantum/post-quantum.test.ts +++ b/src/post-quantum/post-quantum.test.ts @@ -18,14 +18,7 @@ import { isCurrentStandardEncrypted, buildSharedKeyWrapAad, } from "../index.js"; - -const LEGACY = { - plaintext: "{\"k\":\"shared-collection-key\",\"v\":1}", - aad: "sv:shared-key:v1:col-1:sender-1:recipient-1:1", - ciphertext: "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", - pqSecretKey: "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", - rsaPrivateKey: "{\"key_ops\":[\"decrypt\"],\"ext\":true,\"kty\":\"RSA\",\"n\":\"wdbfXaiM1JkK30QBknf6szPK10ym_yv_AnuyMJHaobMKboN7EBEpbA1qAiKP64tmrfWZK9Hv0dYD0xGK5tjgCZGNlgTYlLZjQC_eSWlvrPGgAKQiuveQFBL4kR3CGK1nnBaAGDvQJJYHQOIa6FUoE8UjZ5v_T1VSimBWSQyXI2YJXjgTRUHpeteCc1GpND4p77iNQMdnUeEebKApnGEslXr_SYhG2ewddSGWxW-7dOgz4bp20FfD-a3-uAOYduLFixSAHplcbOU-O6vTFslTY9wA7u95BCesRN0JSmZsSnFF5MGtqByqUXwjivNd6Zqjl308F2lozfmtrwMgdLMvU9P7QkIerqSpxLsUaIwOWtz_hHmydLx3ZYtXkJUmV2LLD51Y_eM-skYM6QYuAW0UjT0OMw67HFycG-MlSquHu42BZYJfsGWmHdWvMSlO4Coo6iWnRG1wgWSDnY1YYOKS1xm_UTeszciZMGpzuxlFxM_A9ibx24Bjxy1Y2C32uxpLCuHfljZC-vZa3PZ_hfNH4x-A47IRgQA5ioKmNVGpLjmXfnGDC10qTqRWWiWs-pWhpsHD4mBlHxhwySevYXAsNjjg1gXLUQ-yGK7WpX09zHTMuUbEq2Y_f5A6TaUQntzrfny9VMoGl_7UAmnHKbCVQnBH07rR1CCsqgReRNV-Pf8\",\"e\":\"AQAB\",\"d\":\"DOLAh99ejVDR3N8OeQgqIvjnG8I9ZRUtp01FyTNzpJrcWgBk5pUy0GYL4rHIKNEO4F2ancPKUi63Tl601yfFAkh_cGDzbN9ltayjoEm2t3kl48-wJ1xcYu6SlkMI1iR2O3VwyoD0ve84nY2VBauI4II9xPN6g3GZQm2pDsNxgKmV0defIFQuCzvXBjIq3Lg33XcX8h3PYS1lR7SrT3lTl2micubw9CeyxZ3tV0QC4tX_gkVSWgNGRIuBNVu31PS6S8aZxcRNf_JgCA4iGDpbUCDInG7699W9_qNFrNZ7jcSoms5tQZG2Q3h3JxOKmK7uVRxpfueY-f2xGBijHbQ-XxHlkmFVtffsNQJC-5fWOi4i6SCJhNKqx2jAGYqx3E7fAFgpEBE3iSDKXos5McAp5aHUXr7VDCNh9W88s_NNqsQsV2L9NoS4cDvBi7om6ubEHemN_fc5aWoAEpkpg0IZFafN_r-uKNfqarBNDFb1Pn2OcHVjyFnRv6cHyDfldOtuSe8-KiUxLEZsQ6zdX5VM-HP7zceUTwhmotLg49fOW78_tLUlXs3dl4J3hygyxKTw-sFa4hBYlhYl4OQSV1hWnze0M85CKql0t0mBC4E1sJX6md4JBlEULQm_ygt6gLmJJ2KZExLu6bP4BRVRJoStu0-WYNVYK-DFgEdrqqlWcFE\",\"p\":\"7HuG5sWR5YypVzuvPXraF-DRkn2Dwweid8jIZ3kaoq4zd_o3ZFNfv3aA40QTKSbS7Wzji0SaM8w3WIbG_WI856LtgibSPMFYJO31WCoduyGlc61orcOywpWsPLB99QQaysssoRwSev75GNGLgb39afkar-p8tXjZ59u2w2RWkT2QQhqlTWd2On3ubgLIWjlbbjgnCGM4WqQ7XSt6h0RFr07yEawG6B0okWf0BI4KDGtcx7OMsQh3qtXgIZoV8950UpT0AO-iIFYtlf0-kVd3B0EN3a-B05-3dYJHFLoz_VrA2il8BCrAuFzW8uGh5NSVMFRuRThJ1-F-FhexhxTIsw\",\"q\":\"0dZeJgcBith_kZoX-xGGE4VMfxWZzU-snkzz95LkkREJUsSnGenkT54BpI4M5dCiPD0wJKK3uUdnHY01SxlEuC0nxfmNi1HusaEuR9dUX87vlxG-TAgfZ28kNRw1IyCD7KX1ezU69zqOwGSbPaa4JrITaj52tkRuOX7l-DQr1oun0aGueDvqxA7LSfGRwxjE02OpxzZqX3FQn9XsOd7h3VMWr43D-6sEJWZA2iR97k1hTf69nnCbb-EWbdIvecJnT5cWA7lGIN85WmgNbw-2jxY8ZOC6MiZMFbv-iX06BX4eSob6WCJQ76I8-ZD20vwupCzg6XSlJCne-opNSHijhQ\",\"dp\":\"3OXec36l9AjavhOQdBtn0do9qVr5U5q0FrRFDvK_AKs8hJwEVgDTdaOabbBPPad4bDPEsXjZmfzuzhDHnDTBs5YryeG9jOcGESj-fuaIcx7Q0Cdxmq8tMjphcydh4Rd-d2QmQjBYyu-Ve6txZzYzm2QHm7-r0lAbLEu-gvIdMvqQ4E7HjnBQrf6oU7bhs_XUBDcLrvgP0guLMFLG18fcWA-kawGIShXCqWCzPfX4SPWY6yo7B7tjHP8_p-OpEe4ANovRCXbOuOoHFw5B_b33_5yy-RtSaH3O_0M8Zo4wtj6p2p_ZqoLNFuoSFzrQ4VH6MfUMNDiKMc_-2WA0gnvVpQ\",\"dq\":\"AWVfsvkQ9Y-DKcDQsAbp0W9tltrZ7xe8mkEAzoDXrG9klHxicDWyIyV19VZMl6rPqX7utw-uETl8YiHyXNGKN391aEfEvUyKPfxIhonUMd76kRK5JWBYdSO0JfZOFDG_Lu_btjogbkyhbn482iglyXwdzPMlbwj9grxpY0FVmVPMhgSBWKNtaGiAybklsxqTFKTxGDYwdvoWAzo1HB1zezl2SSy0RRRaLrWDcPAVNmSlZRNwx4EQR6pDr-9aCYFVlp32s4ekA8v4YbWXgUmleUY4mKM2GedPUkWx59BBdo_kO7KyL6vqxe1aYn6oZbbvyH_T7zmrb5YnuZr58KV80Q\",\"qi\":\"Yw4hu6nUMTuVGCapq_ztatzvax7hi_BXtVugFARkt-BxwJkRkhrXfwVJp3wBfYLZARDpn3DZO3a6LInhmZRWsk6emD9Gdx6pRosiArrBSthWy1x9nWdD7l22S1stQbRz2fP-TcHzuzKtO5tf0qqKW4gkc7I8y10xIalaM-aLwAeHh7GAOoKh6sVO8UtGMsze-_hYOLL_bK643HRQVEJ-VbiTao1zib744KDXCfFJJwfrqZKUxMrMtHuME8AJ2m8oDIBOvDYD_pjy1cEJBJ4gksTA5gbJOOXAaETcpjrrN1aXGNkApyKId6Z6FXhhEv_UpCyqwKoGVEJF6njAp0UAFA\",\"alg\":\"RSA-OAEP-256\"}", -}; +import { LEGACY_HYBRID_VECTOR as LEGACY } from "./__fixtures__/legacy-hybrid.js"; describe("post-quantum hybrid", () => { it("round-trips hybridEncrypt -> hybridDecrypt with AAD", async () => { From b69eccac21972f5923c6fed8ee776111c187e01d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:06:00 +0000 Subject: [PATCH 07/10] feat(vault-crypto): add Singra application crypto profile + RSA-OAEP module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds @dis/shield/vault-crypto — the stable, named API Singra Vault/Premium consume so the apps need zero crypto code of their own. It composes the audited DIS primitives (kdf, aead, vault-encryption, key-management, asymmetric, post-quantum) and owns the application-specific versioned formats (sv-vault-v1, usk-wrap-v2, usk-v1 private-key wrap, pq-v2 hybrid keypair envelope, v3 verification hash, KDF auto-upgrade, re-encryption). Adds @dis/shield/asymmetric (RSA-OAEP-4096) and key-management helpers (createDeterministicWrappedUserKey, AES-GCM JWK shared keys). 16 profile tests prove byte-compat with legacy Singra golden vectors via the profile API plus round-trips for every profile-only format. 66 DIS tests green. Co-Authored-By: Gaming Gruppe --- package.json | 8 + src/asymmetric/index.ts | 81 ++++ src/key-management/index.ts | 45 ++ src/vault-crypto/index.ts | 847 ++++++++++++++++++++++++++++++++++++ tsup.config.ts | 2 + 5 files changed, 983 insertions(+) create mode 100644 src/asymmetric/index.ts create mode 100644 src/vault-crypto/index.ts diff --git a/package.json b/package.json index 1769899..85c56e9 100644 --- a/package.json +++ b/package.json @@ -57,10 +57,18 @@ "types": "./dist/key-management/index.d.ts", "import": "./dist/key-management/index.js" }, + "./asymmetric": { + "types": "./dist/asymmetric/index.d.ts", + "import": "./dist/asymmetric/index.js" + }, "./post-quantum": { "types": "./dist/post-quantum/index.d.ts", "import": "./dist/post-quantum/index.js" }, + "./vault-crypto": { + "types": "./dist/vault-crypto/index.d.ts", + "import": "./dist/vault-crypto/index.js" + }, "./integrity": { "types": "./dist/integrity/index.d.ts", "import": "./dist/integrity/index.js" diff --git a/src/asymmetric/index.ts b/src/asymmetric/index.ts new file mode 100644 index 0000000..2872410 --- /dev/null +++ b/src/asymmetric/index.ts @@ -0,0 +1,81 @@ +/** + * dis-asymmetric — RSA-OAEP public-key operations. + * + * Primitive: RSA-OAEP (4096-bit modulus, SHA-256) via WebCrypto. Used by the + * Singra sharing / emergency-access profile to wrap symmetric material for a + * recipient's public key. DIS does not invent any asymmetric scheme — this is + * a thin, audited wrapper over WebCrypto so applications never touch the raw + * `crypto.subtle` surface. + * + * Key material is exported/imported as JWK (the format Singra persists), so + * existing stored keys remain byte-compatible. Wire format for ciphertext is + * `base64(rsa_oaep_output)`, identical to the legacy implementation. + */ + +import { subtle } from '../core/provider.js'; +import { base64ToBytes, bytesToBase64, bytesToUtf8, utf8ToBytes } from '../core/encoding.js'; + +/** RSA-OAEP modulus length in bits. Part of the key-generation format contract. */ +export const RSA_OAEP_MODULUS_LENGTH = 4096; + +const RSA_OAEP_ALGORITHM = { + name: 'RSA-OAEP', + hash: 'SHA-256', +} as const; + +/** + * Generates an extractable RSA-OAEP-4096 key pair (SHA-256, e=65537). + * Extractable so the private key can be exported as JWK and wrapped at rest. + */ +export async function generateRsaOaepKeyPair(): Promise { + return subtle().generateKey( + { + name: 'RSA-OAEP', + modulusLength: RSA_OAEP_MODULUS_LENGTH, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['encrypt', 'decrypt'], + ); +} + +/** Exports an RSA key (public or private) as a JWK object. */ +export async function exportJwk(key: CryptoKey): Promise { + return subtle().exportKey('jwk', key); +} + +/** Imports an RSA-OAEP public key (JWK) for `encrypt`. Extractable. */ +export async function importRsaOaepPublicKey(jwk: JsonWebKey): Promise { + return subtle().importKey('jwk', jwk, RSA_OAEP_ALGORITHM, true, ['encrypt']); +} + +/** Imports an RSA-OAEP private key (JWK) for `decrypt`. Non-extractable. */ +export async function importRsaOaepPrivateKey(jwk: JsonWebKey): Promise { + return subtle().importKey('jwk', jwk, RSA_OAEP_ALGORITHM, false, ['decrypt']); +} + +/** Encrypts a UTF-8 string under an RSA-OAEP public key. Returns base64. */ +export async function rsaOaepEncrypt(plaintext: string, publicKey: CryptoKey): Promise { + const encoded = utf8ToBytes(plaintext); + try { + const encrypted = await subtle().encrypt({ name: 'RSA-OAEP' }, publicKey, encoded as BufferSource); + return bytesToBase64(new Uint8Array(encrypted)); + } finally { + encoded.fill(0); + } +} + +/** Decrypts base64 RSA-OAEP ciphertext under an RSA-OAEP private key. */ +export async function rsaOaepDecrypt(ciphertextBase64: string, privateKey: CryptoKey): Promise { + const encrypted = base64ToBytes(ciphertextBase64); + let plaintextBytes: Uint8Array | null = null; + try { + const decrypted = await subtle().decrypt({ name: 'RSA-OAEP' }, privateKey, encrypted as BufferSource); + plaintextBytes = new Uint8Array(decrypted); + return bytesToUtf8(plaintextBytes); + } finally { + encrypted.fill(0); + plaintextBytes?.fill(0); + } +} diff --git a/src/key-management/index.ts b/src/key-management/index.ts index 99e0fbe..3a673bd 100644 --- a/src/key-management/index.ts +++ b/src/key-management/index.ts @@ -133,6 +133,51 @@ export async function unwrapUserKey( } } +/** + * Wraps a *deterministic* content key derived directly from `kdfOutputBytes`, + * for EXISTING accounts migrating to the two-tier key model. The content key + * equals the raw KDF output, so vault data previously encrypted directly under + * that output remains readable without re-encryption. The KEK is still + * domain-separated (HKDF `info`), so the wrapper is independent of the key. + */ +export async function createDeterministicWrappedUserKey( + kdfOutputBytes: Uint8Array, + scheme: KeyWrapScheme = DEFAULT_KEY_WRAP_SCHEME, +): Promise { + // Copy so callers can wipe their buffer without aliasing the returned key. + const userKeyBytes = new Uint8Array(kdfOutputBytes); + let wrapKeyBytes: Uint8Array | null = null; + try { + wrapKeyBytes = await deriveWrapKeyBytes(kdfOutputBytes, scheme); + const wrapKey = await importAesGcmKey(wrapKeyBytes); + const encryptedUserKey = `${scheme.prefix}${await encryptBytes(userKeyBytes, wrapKey)}`; + const userKey = await importAesGcmKey(userKeyBytes); + return { encryptedUserKey, userKey }; + } finally { + userKeyBytes.fill(0); + wrapKeyBytes?.fill(0); + } +} + +/** Generates a fresh AES-256-GCM key and returns it as a JWK JSON string. */ +export async function generateAesGcmKeyJwk(): Promise { + const key = await subtle().generateKey({ name: 'AES-GCM', length: 256 }, true, [ + 'encrypt', + 'decrypt', + ]); + const jwk = await subtle().exportKey('jwk', key); + return JSON.stringify(jwk); +} + +/** Imports an AES-256-GCM key from a JWK JSON string for the given usages. */ +export async function importAesGcmKeyFromJwk( + jwkString: string, + usages: ReadonlyArray, +): Promise { + const jwk = JSON.parse(jwkString) as JsonWebKey; + return subtle().importKey('jwk', jwk, { name: 'AES-GCM', length: 256 }, false, [...usages]); +} + /** * Re-wraps an existing content key under a new KDF output (new master password * / new salt). The content key itself is unchanged, so NO vault data is diff --git a/src/vault-crypto/index.ts b/src/vault-crypto/index.ts new file mode 100644 index 0000000..7f4f655 --- /dev/null +++ b/src/vault-crypto/index.ts @@ -0,0 +1,847 @@ +/** + * dis-vault-crypto — the Singra Vault *application crypto profile*. + * + * This module is the stable, named API that Singra Vault (and, transitively, + * Singra Premium) consume. It does NOT re-implement any primitive: every + * operation is composed from the audited DIS modules (`kdf`, `aead`, + * `vault-encryption`, `key-management`, `asymmetric`, `post-quantum`). What it + * adds is the *application-specific composition and versioned envelope formats* + * that Singra has always used: + * + * - device-key strengthened Argon2id derivation (HKDF info `SINGRA_DEVICE_KEY_V1`) + * - the `sv-vault-v1:` vault-item envelope (entry-id AAD) + * - the two-tier UserKey model (`usk-wrap-v2:`) and private-key wrapping (`usk-v1:`) + * - sharing / emergency-access key material (`pq-v2:` hybrid keypair envelope) + * - KDF auto-upgrade, verification hashes, and re-encryption helpers + * + * Keeping these names and formats here means applications need ZERO crypto + * code of their own — they import this profile and nothing else. Every byte + * format is covered by golden-vector tests proving compatibility with the + * pre-extraction Singra implementation. + */ + +import { bytesToBase64, base64ToBytes } from '../core/encoding.js'; +import { + CURRENT_KDF_VERSION as DIS_CURRENT_KDF_VERSION, + DEFAULT_KDF_PARAMS, + deriveRawKey as disDeriveRawKey, + generateSalt as disGenerateSalt, + importAesGcmKey, + type KdfParams as DisKdfParams, +} from '../kdf/index.js'; +import { + decryptBytes as disDecryptBytes, + decryptString, + encryptBytes as disEncryptBytes, + encryptString, +} from '../aead/index.js'; +import { + decryptVaultEntry, + decryptVaultEntryForMigration, + encryptVaultEntry, + isCurrentVaultEntryEnvelope, + VAULT_ITEM_ENVELOPE_V1_PREFIX as DIS_VAULT_ITEM_ENVELOPE_V1_PREFIX, +} from '../vault-encryption/index.js'; +import { + createDeterministicWrappedUserKey, + createWrappedUserKey, + generateAesGcmKeyJwk, + importAesGcmKeyFromJwk, + rotateWrappedKey, + unwrapUserKey as disUnwrapUserKey, + unwrapUserKeyBytes as disUnwrapUserKeyBytes, + type UserKeyBundle as DisUserKeyBundle, +} from '../key-management/index.js'; +import { + exportJwk, + generateRsaOaepKeyPair, + importRsaOaepPrivateKey, + importRsaOaepPublicKey, + rsaOaepDecrypt, + rsaOaepEncrypt, +} from '../asymmetric/index.js'; +import { generatePQKeyPair } from '../post-quantum/index.js'; +import { SecureBuffer } from '../secure-memory/index.js'; + +// ============ Constants & format contract ============ + +/** The latest KDF version. Newly set-up accounts use this version. */ +export const CURRENT_KDF_VERSION = DIS_CURRENT_KDF_VERSION; + +/** Argon2id parameter set for a given KDF version. */ +export type KdfParams = DisKdfParams; + +/** + * KDF parameter sets indexed by version number. Byte-compatible with Singra. + * Exposed as a plain record for call sites that look up params by version. + */ +export const KDF_PARAMS: Record = { ...DEFAULT_KDF_PARAMS }; + +/** Versioned envelope prefix for vault item payloads. */ +export const VAULT_ITEM_ENVELOPE_V1_PREFIX = DIS_VAULT_ITEM_ENVELOPE_V1_PREFIX; + +/** HKDF `info` binding the derived key to a device key (second factor). */ +const DEVICE_KEY_HKDF_INFO = 'SINGRA_DEVICE_KEY_V1'; + +/** Constant used in v3 verification hashes (no plaintext stored in DB). */ +const VERIFICATION_CONSTANT_V3 = 'SINGRA_VAULT_VERIFY_V3'; + +/** Encrypted category field prefix used for category name/icon/color. */ +const ENCRYPTED_CATEGORY_PREFIX = 'enc:cat:v1:'; + +/** Sentinel prefix for private keys wrapped under the UserKey. */ +const USK_V1_PREFIX = 'usk-v1:'; + +/** Internal counter for legacy (no-AAD) decryption fallbacks (monitoring). */ +let _legacyDecryptCount = 0; + +export type UserKeyBundle = DisUserKeyBundle; + +/** Sensitive vault item data that gets encrypted. */ +export interface VaultItemData { + title?: string; + websiteUrl?: string; + itemType?: 'password' | 'note' | 'totp' | 'card'; + isFavorite?: boolean; + categoryId?: string | null; + username?: string; + password?: string; + notes?: string; + totpSecret?: string; + totpIssuer?: string; + totpLabel?: string; + totpAlgorithm?: 'SHA1' | 'SHA256' | 'SHA512'; + totpDigits?: 6 | 8; + totpPeriod?: number; + customFields?: Record; + /** Internal marker for duress/decoy items (never exposed to UI). */ + _duress?: boolean; +} + +// ============ Salt & KDF ============ + +/** Generates a cryptographically secure random salt (base64, 128-bit). */ +export function generateSalt(): string { + return disGenerateSalt(); +} + +function strengthenOptions(deviceKey?: Uint8Array) { + return deviceKey ? { hkdfSalt: deviceKey, info: DEVICE_KEY_HKDF_INFO } : undefined; +} + +/** + * Derives raw AES-256 key bytes from a master password using Argon2id. + * When a deviceKey is provided, the result is strengthened via HKDF-Expand + * with the device key as salt. Caller owns/must wipe the returned buffer. + */ +export async function deriveRawKey( + masterPassword: string, + saltBase64: string, + kdfVersion: number = CURRENT_KDF_VERSION, + deviceKey?: Uint8Array, +): Promise { + return disDeriveRawKey(masterPassword, saltBase64, { + version: kdfVersion, + strengthen: strengthenOptions(deviceKey), + }); +} + +/** + * Derives raw key bytes wrapped in a SecureBuffer for safer handling. + * The SecureBuffer auto-zeros on destroy. Caller MUST call `.destroy()`. + */ +export async function deriveRawKeySecure( + masterPassword: string, + saltBase64: string, + kdfVersion: number = CURRENT_KDF_VERSION, + deviceKey?: Uint8Array, +): Promise { + const rawBytes = await deriveRawKey(masterPassword, saltBase64, kdfVersion, deviceKey); + const secure = SecureBuffer.fromBytes(rawBytes); + rawBytes.fill(0); + return secure; +} + +/** Derives an AES-256-GCM CryptoKey from a master password. */ +export async function deriveKey( + masterPassword: string, + saltBase64: string, + kdfVersion: number = CURRENT_KDF_VERSION, + deviceKey?: Uint8Array, +): Promise { + const keyBytes = await deriveRawKey(masterPassword, saltBase64, kdfVersion, deviceKey); + try { + return await importMasterKey(keyBytes); + } finally { + keyBytes.fill(0); + } +} + +/** Imports raw 256-bit key bytes into a non-extractable AES-GCM CryptoKey. */ +export async function importMasterKey(keyBytes: Uint8Array | BufferSource): Promise { + return importAesGcmKey(keyBytes); +} + +// ============ AEAD (string + bytes) ============ + +/** Encrypts a UTF-8 string with AES-256-GCM. Optional AAD binds context. */ +export async function encrypt(plaintext: string, key: CryptoKey, aad?: string): Promise { + return encryptString(plaintext, key, aad); +} + +/** Encrypts binary data with AES-256-GCM. Caller owns/wipes `plaintextBytes`. */ +export async function encryptBytes( + plaintextBytes: Uint8Array, + key: CryptoKey, + aad?: string, +): Promise { + return disEncryptBytes(plaintextBytes, key, aad); +} + +/** Decrypts AES-256-GCM data to a UTF-8 string. */ +export async function decrypt(encryptedBase64: string, key: CryptoKey, aad?: string): Promise { + return decryptString(encryptedBase64, key, aad); +} + +/** Decrypts AES-256-GCM data to plaintext bytes (secret — caller must wipe). */ +export async function decryptBytes( + encryptedBase64: string, + key: CryptoKey, + aad?: string, +): Promise { + return disDecryptBytes(encryptedBase64, key, aad); +} + +// ============ Vault item envelope ============ + +/** Encrypts a vault item, binding the ciphertext to `entryId` via AAD. */ +export async function encryptVaultItem( + data: VaultItemData, + key: CryptoKey, + entryId: string, +): Promise { + return encryptVaultEntry(data as Record, key, entryId); +} + +/** + * Decrypts a vault item. Versioned payloads are read with `entryId` as AAD and + * fail closed for the oldest no-AAD payloads unless the explicit migration + * fallback is requested. + */ +export async function decryptVaultItem( + encryptedData: string, + key: CryptoKey, + entryId: string, + options: { allowLegacyNoAadFallback?: boolean } = {}, +): Promise { + if (options.allowLegacyNoAadFallback) { + const result = await decryptVaultEntryForMigration(encryptedData, key, entryId); + if (result.legacyNoAadFallbackUsed) { + _legacyDecryptCount++; + } + return result.data as VaultItemData; + } + return (await decryptVaultEntry(encryptedData, key, entryId)) as VaultItemData; +} + +export interface VaultItemMigrationDecryptResult { + data: VaultItemData; + legacyEnvelopeUsed: boolean; + legacyNoAadFallbackUsed: boolean; +} + +/** + * Decrypts an item only for an explicit migration path. Runtime reads must use + * decryptVaultItem(), which fails closed for legacy no-AAD payloads. + */ +export async function decryptVaultItemForMigration( + encryptedData: string, + key: CryptoKey, + entryId: string, +): Promise { + const result = await decryptVaultEntryForMigration(encryptedData, key, entryId); + if (result.legacyNoAadFallbackUsed) { + _legacyDecryptCount++; + } + return { + data: result.data as VaultItemData, + legacyEnvelopeUsed: result.legacyEnvelopeUsed, + legacyNoAadFallbackUsed: result.legacyNoAadFallbackUsed, + }; +} + +/** True if `encryptedData` is a current versioned vault-item envelope. */ +export function isCurrentVaultItemEnvelope(encryptedData: string): boolean { + return isCurrentVaultEntryEnvelope(encryptedData); +} + +// ============ Verification hashes ============ + +/** Creates a password verification hash (v3: encrypts a known constant). */ +export async function createVerificationHash(key: CryptoKey): Promise { + const encrypted = await encrypt(VERIFICATION_CONSTANT_V3, key); + return `v3:${encrypted}`; +} + +/** Verifies that `key` can decrypt the stored verification hash. */ +export async function verifyKey(verificationHash: string, key: CryptoKey): Promise { + try { + if (verificationHash.startsWith('v3:')) { + const encrypted = verificationHash.slice(3); + const decrypted = await decrypt(encrypted, key); + return decrypted === VERIFICATION_CONSTANT_V3; + } + if (verificationHash.startsWith('v2:')) { + const parts = verificationHash.split(':'); + if (parts.length !== 3) { + return false; + } + const challenge = parts[1]!; + const encryptedChallenge = parts[2]!; + const decrypted = await decrypt(encryptedChallenge, key); + return decrypted === challenge; + } + const decrypted = await decrypt(verificationHash, key); + return decrypted === 'SINGRA_PW_VERIFICATION'; + } catch { + return false; + } +} + +// ============ KDF auto-migration ============ + +export interface KdfUpgradeResult { + upgraded: boolean; + newKey?: CryptoKey; + oldKey?: CryptoKey; + newVerifier?: string; + activeVersion: number; + newEncryptedUserKey?: string; +} + +/** + * Attempts to upgrade KDF parameters to the latest version after unlock. + * USK path only re-wraps the 32-byte UserKey (no vault re-encryption). Legacy + * path returns old+new keys so the caller can re-encrypt vault data. + */ +export async function attemptKdfUpgrade( + masterPassword: string, + saltBase64: string, + currentVersion: number, + deviceKey?: Uint8Array, + encryptedUserKey?: string, + existingKdfOutputBytes?: Uint8Array, +): Promise { + if (currentVersion >= CURRENT_KDF_VERSION) { + return { upgraded: false, activeVersion: currentVersion }; + } + + try { + if (encryptedUserKey) { + const ownedOldBytes = existingKdfOutputBytes + ? null + : await deriveRawKey(masterPassword, saltBase64, currentVersion, deviceKey); + const oldKdfOutputBytes = existingKdfOutputBytes ?? ownedOldBytes!; + const newKdfOutputBytes = await deriveRawKey( + masterPassword, + saltBase64, + CURRENT_KDF_VERSION, + deviceKey, + ); + try { + const newEncryptedUserKey = await rewrapUserKey( + encryptedUserKey, + oldKdfOutputBytes, + newKdfOutputBytes, + ); + const newUserKey = await unwrapUserKey(newEncryptedUserKey, newKdfOutputBytes); + const newVerifier = await createVerificationHash(newUserKey); + return { + upgraded: true, + newVerifier, + newEncryptedUserKey, + activeVersion: CURRENT_KDF_VERSION, + }; + } finally { + ownedOldBytes?.fill(0); + newKdfOutputBytes.fill(0); + } + } + + const newKey = await deriveKey(masterPassword, saltBase64, CURRENT_KDF_VERSION, deviceKey); + const oldKey = await deriveKey(masterPassword, saltBase64, currentVersion, deviceKey); + const newVerifier = await createVerificationHash(newKey); + return { upgraded: true, newKey, oldKey, newVerifier, activeVersion: CURRENT_KDF_VERSION }; + } catch (err) { + console.warn( + `KDF upgrade from v${currentVersion} to v${CURRENT_KDF_VERSION} failed (likely OOM), staying on v${currentVersion}:`, + err, + ); + return { upgraded: false, activeVersion: currentVersion }; + } +} + +// ============ Vault re-encryption ============ + +/** Re-encrypts a single encrypted string from oldKey to newKey. */ +export async function reEncryptString( + encryptedBase64: string, + oldKey: CryptoKey, + newKey: CryptoKey, + aad?: string, +): Promise { + let plaintext: string; + if (aad) { + try { + plaintext = await decrypt(encryptedBase64, oldKey, aad); + } catch { + plaintext = await decrypt(encryptedBase64, oldKey); + } + } else { + plaintext = await decrypt(encryptedBase64, oldKey); + } + return encrypt(plaintext, newKey, aad); +} + +export interface ReEncryptionResult { + itemsReEncrypted: number; + categoriesReEncrypted: number; + itemUpdates: Array<{ id: string; encrypted_data: string }>; + categoryUpdates: Array<{ id: string; name: string; icon: string | null; color: string | null }>; + legacyItemsFound: number; +} + +/** + * Re-encrypts all vault items and encrypted category fields from an old key to + * a new key (required during KDF upgrades). Pure: no DB side effects. + */ +export async function reEncryptVault( + items: Array<{ id: string; encrypted_data: string }>, + categories: Array<{ id: string; name: string; icon: string | null; color: string | null }>, + oldKey: CryptoKey, + newKey: CryptoKey, +): Promise { + const itemUpdates: Array<{ id: string; encrypted_data: string }> = []; + for (const item of items) { + try { + const plaintext = await decryptVaultItem(item.encrypted_data, oldKey, item.id, { + allowLegacyNoAadFallback: true, + }); + const newEncrypted = await encryptVaultItem(plaintext, newKey, item.id); + itemUpdates.push({ id: item.id, encrypted_data: newEncrypted }); + } catch (err) { + throw new Error(`Failed to re-encrypt vault item ${item.id}: ${err}`); + } + } + + const categoryUpdates: Array<{ + id: string; + name: string; + icon: string | null; + color: string | null; + }> = []; + for (const cat of categories) { + let newName = cat.name; + let newIcon = cat.icon; + let newColor = cat.color; + let changed = false; + + if (cat.name.startsWith(ENCRYPTED_CATEGORY_PREFIX)) { + try { + const encPart = cat.name.slice(ENCRYPTED_CATEGORY_PREFIX.length); + const reEncrypted = await reEncryptString(encPart, oldKey, newKey); + newName = `${ENCRYPTED_CATEGORY_PREFIX}${reEncrypted}`; + changed = true; + } catch (err) { + throw new Error(`Failed to re-encrypt category name ${cat.id}: ${err}`); + } + } + + if (cat.icon && cat.icon.startsWith(ENCRYPTED_CATEGORY_PREFIX)) { + try { + const encPart = cat.icon.slice(ENCRYPTED_CATEGORY_PREFIX.length); + const reEncrypted = await reEncryptString(encPart, oldKey, newKey); + newIcon = `${ENCRYPTED_CATEGORY_PREFIX}${reEncrypted}`; + changed = true; + } catch (err) { + throw new Error(`Failed to re-encrypt category icon ${cat.id}: ${err}`); + } + } + + if (cat.color && cat.color.startsWith(ENCRYPTED_CATEGORY_PREFIX)) { + try { + const encPart = cat.color.slice(ENCRYPTED_CATEGORY_PREFIX.length); + const reEncrypted = await reEncryptString(encPart, oldKey, newKey); + newColor = `${ENCRYPTED_CATEGORY_PREFIX}${reEncrypted}`; + changed = true; + } catch (err) { + throw new Error(`Failed to re-encrypt category color ${cat.id}: ${err}`); + } + } + + if (changed) { + categoryUpdates.push({ id: cat.id, name: newName, icon: newIcon, color: newColor }); + } + } + + const legacyFound = _legacyDecryptCount; + _legacyDecryptCount = 0; + + return { + itemsReEncrypted: itemUpdates.length, + categoriesReEncrypted: categoryUpdates.length, + itemUpdates, + categoryUpdates, + legacyItemsFound: legacyFound, + }; +} + +// ============ Vault item data helpers ============ + +/** + * Clears sensitive references from a VaultItemData object. NOTE: JS strings are + * immutable; this only drops references so the GC can reclaim them sooner. + */ +export function clearReferences(data: VaultItemData): void { + if (data.title) data.title = ''; + if (data.websiteUrl) data.websiteUrl = ''; + if (data.itemType) data.itemType = 'password'; + if (typeof data.isFavorite === 'boolean') data.isFavorite = false; + if (typeof data.categoryId !== 'undefined') data.categoryId = null; + if (data.username) data.username = ''; + if (data.password) data.password = ''; + if (data.notes) data.notes = ''; + if (data.totpSecret) data.totpSecret = ''; + if (data.totpIssuer) data.totpIssuer = ''; + if (data.totpLabel) data.totpLabel = ''; + if (data.customFields) { + Object.keys(data.customFields).forEach((key) => { + data.customFields![key] = ''; + }); + } +} + +/** @deprecated Use clearReferences. secureClear implies wiping JS cannot do. */ +export const secureClear = clearReferences; + +// ============ Asymmetric (RSA-OAEP) for emergency access ============ + +export async function generateRSAKeyPair(): Promise { + return generateRsaOaepKeyPair(); +} + +export async function exportPublicKey(key: CryptoKey): Promise { + return exportJwk(key); +} + +export async function importPublicKey(jwk: JsonWebKey): Promise { + return importRsaOaepPublicKey(jwk); +} + +export async function importPrivateKey(jwk: JsonWebKey): Promise { + return importRsaOaepPrivateKey(jwk); +} + +export async function exportPrivateKey(key: CryptoKey): Promise { + return exportJwk(key); +} + +export async function encryptRSA(plaintext: string, publicKey: CryptoKey): Promise { + return rsaOaepEncrypt(plaintext, publicKey); +} + +export async function decryptRSA(ciphertextBase64: string, privateKey: CryptoKey): Promise { + return rsaOaepDecrypt(ciphertextBase64, privateKey); +} + +// ============ Shared-collection key material ============ + +/** + * Generates a user's asymmetric key material for shared collections. + * v1: RSA-only wrapping. v2: hybrid PQ+RSA (`pq-v2:` envelope). + */ +export async function generateUserKeyPair( + masterPassword: string, + version: 1 | 2 = 2, +): Promise<{ publicKey: string; encryptedPrivateKey: string; pqPublicKey?: string }> { + if (version === 1) { + const keyPair = await generateRsaOaepKeyPair(); + const publicKey = JSON.stringify(await exportJwk(keyPair.publicKey)); + const privateKey = JSON.stringify(await exportJwk(keyPair.privateKey)); + + const salt = generateSalt(); + const kdfVersion = CURRENT_KDF_VERSION; + const key = await deriveKey(masterPassword, salt, kdfVersion); + const encryptedPrivateKey = await encrypt(privateKey, key); + + return { + publicKey, + encryptedPrivateKey: `${kdfVersion}:${salt}:${encryptedPrivateKey}`, + }; + } + + // Version 2: hybrid PQ+RSA wrapping (ML-KEM-768 + RSA-4096). + const rsaKeyPair = await generateRsaOaepKeyPair(); + const pqKeyPair = generatePQKeyPair(); + const { publicKey: pqPublicKeyBase64, secretKey: pqSecretKeyBase64 } = pqKeyPair; + + const rsaPublicKey = JSON.stringify(await exportJwk(rsaKeyPair.publicKey)); + const rsaPrivateKey = JSON.stringify(await exportJwk(rsaKeyPair.privateKey)); + + const salt = generateSalt(); + const kdfVersion = CURRENT_KDF_VERSION; + const key = await deriveKey(masterPassword, salt, kdfVersion); + + const encryptedRsaKey = await encrypt(rsaPrivateKey, key); + const encryptedPqKey = await encrypt(pqSecretKeyBase64, key); + + const encryptedPrivateKey = `pq-v2:${kdfVersion}:${salt}:${encryptedRsaKey}:${encryptedPqKey}`; + + return { publicKey: rsaPublicKey, encryptedPrivateKey, pqPublicKey: pqPublicKeyBase64 }; +} + +/** Migrates RSA-only wrapping key material to hybrid PQ+RSA key material. */ +export async function migrateToHybridKeyPair( + encryptedPrivateKey: string, + masterPassword: string, +): Promise<{ publicKey: string; encryptedPrivateKey: string; pqPublicKey: string } | null> { + try { + if (encryptedPrivateKey.startsWith('pq-v2:')) { + return null; + } + + const parts = encryptedPrivateKey.split(':'); + let kdfVersion = 1; + let salt: string; + let encryptedData: string; + + if (parts.length === 2) { + salt = parts[0]!; + encryptedData = parts[1]!; + } else if (parts.length === 3) { + kdfVersion = parseInt(parts[0]!, 10); + salt = parts[1]!; + encryptedData = parts[2]!; + } else { + throw new Error('Invalid encrypted private key format'); + } + + const key = await deriveKey(masterPassword, salt, kdfVersion); + const rsaPrivateKey = await decrypt(encryptedData, key); + + const rsaPrivateKeyJwk = JSON.parse(rsaPrivateKey) as Record; + const rsaPublicKeyJwk = { + ...rsaPrivateKeyJwk, + d: undefined, + dp: undefined, + dq: undefined, + p: undefined, + q: undefined, + qi: undefined, + key_ops: ['encrypt'], + }; + const rsaPublicKey = JSON.stringify(rsaPublicKeyJwk); + + const pqKeyPair = generatePQKeyPair(); + const { publicKey: pqPublicKey, secretKey: pqSecretKey } = pqKeyPair; + + const newSalt = generateSalt(); + const newKdfVersion = CURRENT_KDF_VERSION; + const newKey = await deriveKey(masterPassword, newSalt, newKdfVersion); + + const encryptedRsaKey = await encrypt(rsaPrivateKey, newKey); + const encryptedPqKey = await encrypt(pqSecretKey, newKey); + + const hybridEncryptedKey = `pq-v2:${newKdfVersion}:${newSalt}:${encryptedRsaKey}:${encryptedPqKey}`; + + return { publicKey: rsaPublicKey, encryptedPrivateKey: hybridEncryptedKey, pqPublicKey }; + } catch (err) { + console.error('Failed to migrate to hybrid key pair:', err); + return null; + } +} + +/** Generates a random shared encryption key for a collection (AES-256 JWK). */ +export async function generateSharedKey(): Promise { + return generateAesGcmKeyJwk(); +} + +/** Encrypts vault item data with a shared key. Optional AAD binds context. */ +export async function encryptWithSharedKey( + data: VaultItemData, + sharedKey: string, + aad?: string, +): Promise { + const key = await importAesGcmKeyFromJwk(sharedKey, ['encrypt']); + return encrypt(JSON.stringify(data), key, aad); +} + +export interface SharedKeyDecryptOptions { + /** Allows reading pre-AAD shared ciphertexts during explicit migration only. */ + allowLegacyNoAadFallback?: boolean; +} + +/** Decrypts vault item data with a shared key. Fails closed by default. */ +export async function decryptWithSharedKey( + encryptedData: string, + sharedKey: string, + aad?: string, + options: SharedKeyDecryptOptions = {}, +): Promise { + const key = await importAesGcmKeyFromJwk(sharedKey, ['decrypt']); + let json: string; + if (aad) { + try { + json = await decrypt(encryptedData, key, aad); + } catch { + if (!options.allowLegacyNoAadFallback) { + throw new Error('Shared item decryption failed with the required AAD context.'); + } + _legacyDecryptCount++; + json = await decrypt(encryptedData, key); + } + } else { + json = await decrypt(encryptedData, key); + } + return JSON.parse(json) as VaultItemData; +} + +// ============ User Symmetric Key (USK) layer ============ + +/** Creates a new random UserKey wrapped under a KEK from the KDF output. */ +export async function createEncryptedUserKey(kdfOutputBytes: Uint8Array): Promise { + return createWrappedUserKey(kdfOutputBytes); +} + +/** Derives a deterministic UserKey from the KDF output and wraps it (migration). */ +export async function migrateToUserKey(kdfOutputBytes: Uint8Array): Promise { + return createDeterministicWrappedUserKey(kdfOutputBytes); +} + +/** Decrypts the stored encryptedUserKey to obtain the UserKey CryptoKey. */ +export async function unwrapUserKey( + encryptedUserKey: string, + kdfOutputBytes: Uint8Array, +): Promise { + return disUnwrapUserKey(encryptedUserKey, kdfOutputBytes); +} + +/** Decrypts the stored encryptedUserKey and returns the raw UserKey bytes. */ +export async function unwrapUserKeyBytes( + encryptedUserKey: string, + kdfOutputBytes: Uint8Array, +): Promise { + return disUnwrapUserKeyBytes(encryptedUserKey, kdfOutputBytes); +} + +/** Re-wraps an existing UserKey under a new KDF output. UserKey unchanged. */ +export async function rewrapUserKey( + encryptedUserKey: string, + oldKdfOutputBytes: Uint8Array, + newKdfOutputBytes: Uint8Array, +): Promise { + return rotateWrappedKey(encryptedUserKey, oldKdfOutputBytes, newKdfOutputBytes); +} + +/** Encrypts a private key (RSA JWK / PQ base64) with the UserKey (`usk-v1:`). */ +export async function wrapPrivateKeyWithUserKey( + privateKeyMaterial: string, + userKey: CryptoKey, +): Promise { + const enc = await encrypt(privateKeyMaterial, userKey); + return `${USK_V1_PREFIX}${enc}`; +} + +/** Decrypts a private key wrapped with wrapPrivateKeyWithUserKey. */ +export async function unwrapPrivateKeyWithUserKey( + wrappedKey: string, + userKey: CryptoKey, +): Promise { + if (!wrappedKey.startsWith(USK_V1_PREFIX)) { + throw new Error('unwrapPrivateKeyWithUserKey: unexpected format (missing usk-v1: prefix)'); + } + return decrypt(wrappedKey.slice(USK_V1_PREFIX.length), userKey); +} + +/** + * Decrypts a legacy private key encrypted with its own KDF derivation. + * Handles `kdfVersion:salt:enc`, `salt:enc`, and `pq-v2:kdfVersion:salt:encRsa:encPq`. + */ +export async function decryptPrivateKeyLegacy( + encryptedPrivateKey: string, + masterPassword: string, + extractPqPart = false, +): Promise { + if (encryptedPrivateKey.startsWith('pq-v2:')) { + const rest = encryptedPrivateKey.slice('pq-v2:'.length); + const colonIdx1 = rest.indexOf(':'); + const colonIdx2 = rest.indexOf(':', colonIdx1 + 1); + const colonIdx3 = rest.indexOf(':', colonIdx2 + 1); + if (colonIdx1 < 0 || colonIdx2 < 0 || colonIdx3 < 0) { + throw new Error('decryptPrivateKeyLegacy: invalid pq-v2 format'); + } + const kdfVersion = parseInt(rest.slice(0, colonIdx1), 10); + const salt = rest.slice(colonIdx1 + 1, colonIdx2); + const encRsaKey = rest.slice(colonIdx2 + 1, colonIdx3); + const encPqKey = rest.slice(colonIdx3 + 1); + const key = await deriveKey(masterPassword, salt, kdfVersion); + return extractPqPart ? decrypt(encPqKey, key) : decrypt(encRsaKey, key); + } + + const parts = encryptedPrivateKey.split(':'); + let kdfVersion = 1; + let salt: string; + let encData: string; + + if (parts.length === 2) { + salt = parts[0]!; + encData = parts[1]!; + } else if (parts.length === 3) { + kdfVersion = parseInt(parts[0]!, 10); + salt = parts[1]!; + encData = parts[2]!; + } else { + throw new Error( + `decryptPrivateKeyLegacy: unrecognised format (${parts.length} colon-separated parts)`, + ); + } + + const key = await deriveKey(masterPassword, salt, kdfVersion); + return decrypt(encData, key); +} + +/** Decrypts a stored RSA private key, dispatching on format sentinel. */ +export async function getDecryptedRsaPrivateKey( + encryptedPrivateKey: string, + userKey: CryptoKey | null, + masterPassword: string, +): Promise { + if (encryptedPrivateKey.startsWith(USK_V1_PREFIX)) { + if (!userKey) { + throw new Error('getDecryptedRsaPrivateKey: UserKey required for usk-v1 format'); + } + return unwrapPrivateKeyWithUserKey(encryptedPrivateKey, userKey); + } + return decryptPrivateKeyLegacy(encryptedPrivateKey, masterPassword, false); +} + +/** Decrypts a stored PQ (ML-KEM-768) private key, dispatching on format sentinel. */ +export async function getDecryptedPqPrivateKey( + encryptedPqPrivateKey: string, + userKey: CryptoKey | null, + masterPassword: string, +): Promise { + if (encryptedPqPrivateKey.startsWith(USK_V1_PREFIX)) { + if (!userKey) { + throw new Error('getDecryptedPqPrivateKey: UserKey required for usk-v1 format'); + } + return unwrapPrivateKeyWithUserKey(encryptedPqPrivateKey, userKey); + } + if (encryptedPqPrivateKey.startsWith('pq-v2:')) { + return decryptPrivateKeyLegacy(encryptedPqPrivateKey, masterPassword, true); + } + return decryptPrivateKeyLegacy(encryptedPqPrivateKey, masterPassword, false); +} + +// Internal: base64 helpers exposed for adapters that round-trip raw bytes. +export { bytesToBase64, base64ToBytes }; diff --git a/tsup.config.ts b/tsup.config.ts index ecd4d64..127c641 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -17,7 +17,9 @@ export default defineConfig({ 'vault-encryption/index': 'src/vault-encryption/index.ts', 'file-encryption/index': 'src/file-encryption/index.ts', 'key-management/index': 'src/key-management/index.ts', + 'asymmetric/index': 'src/asymmetric/index.ts', 'post-quantum/index': 'src/post-quantum/index.ts', + 'vault-crypto/index': 'src/vault-crypto/index.ts', 'integrity/index': 'src/integrity/index.ts', 'migrations/index': 'src/migrations/index.ts', }, From 4c32802638b1d7b293ff4747ec4274ae682b5a23 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:06:07 +0000 Subject: [PATCH 08/10] test(vault-crypto): profile byte-compat golden vectors + round-trips Co-Authored-By: Gaming Gruppe --- src/vault-crypto/vault-crypto.test.ts | 223 ++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 src/vault-crypto/vault-crypto.test.ts diff --git a/src/vault-crypto/vault-crypto.test.ts b/src/vault-crypto/vault-crypto.test.ts new file mode 100644 index 0000000..a8e7479 --- /dev/null +++ b/src/vault-crypto/vault-crypto.test.ts @@ -0,0 +1,223 @@ +/** + * Singra Vault application-profile tests. + * + * Two guarantees are asserted here: + * 1. BYTE-COMPAT: the profile reads the exact legacy golden vectors (produced + * by the pre-extraction in-tree Singra cryptoService) through its public + * API — proving the composition delegates to the proven primitives without + * altering any wire format. + * 2. ROUND-TRIP: every profile-only format (verification hash, UserKey wrap, + * `usk-v1:` private-key wrap, `pq-v2:` hybrid keypair envelope, shared key, + * RSA-OAEP, KDF upgrade, re-encryption) seals and opens correctly and fails + * closed on tampering / wrong context. + */ +import { describe, expect, it } from 'vitest'; +import * as vault from './index.js'; + +const PW = 'correct horse battery staple'; +const SALT = 'ZGV0ZXJtaW5pc3RpYy1zYWx0LTE2Qg=='; + +const b64ToBytes = (b64: string): Uint8Array => Uint8Array.from(Buffer.from(b64, 'base64')); + +describe('Singra profile — legacy golden vectors (byte-compat via profile API)', () => { + it('KDF: profile.deriveRawKey reproduces legacy Argon2id bytes (v1, v2)', async () => { + const vectors: Record<1 | 2, string> = { + 1: '1ehUGlF3Tb7w90vk/872uy/4uLebUW/Vzz6b6eAoGiM=', + 2: 'SF/WwUBBYz38cCp0KAmtN0dPq+O5lpGfFs5I89HfWZI=', + }; + for (const version of [1, 2] as const) { + const raw = await vault.deriveRawKey(PW, SALT, version); + expect([...raw]).toEqual([...b64ToBytes(vectors[version])]); + raw.fill(0); + } + }); + + it('AEAD: profile.decrypt opens legacy ciphertext (with + without AAD)', async () => { + const key = await vault.deriveKey(PW, SALT, 2); + const withAad = '1lXWfk5cncBHIX2ntRh54N0TiwIkNL0WNdA4m2vM/6fQI/wcAw=='; + const noAad = 'GOi+biTwduypJ/A9MCzWWgV3UuS0gWkQd/KZGbdLx7FB/C7nzDPUOdny3uI='; + expect(await vault.decrypt(withAad, key, 'aad-1')).toBe('hello DIS'); + expect(await vault.decrypt(noAad, key)).toBe('hello DIS no aad'); + }); + + it('Vault item: profile.decryptVaultItem opens the legacy sv-vault-v1 envelope', async () => { + const key = await vault.deriveKey(PW, SALT, 2); + const entryId = '11111111-1111-4111-8111-111111111111'; + const sealed = + 'sv-vault-v1:nOVpHlEQUrM/okRGd9iJZU/LyfYx5A0a3VAzAbjRWNhutkew4vmfTZML4oUTbJChb4pNblxsEE/mLRAuJJl0KgHmuFhM+bH1CPFDLBwokh8='; + expect(await vault.decryptVaultItem(sealed, key, entryId)).toEqual({ + username: 'alice', + password: 's3cret', + notes: 'n', + }); + }); + + it('UserKey: profile.unwrapUserKeyBytes opens the legacy usk-wrap-v2 bundle', async () => { + const kdfOut = await vault.deriveRawKey(PW, SALT, 2); + const encryptedUserKey = + 'usk-wrap-v2:OcV0IQUJiYf5E3f1a144DuFJnoy8l6cSx2tyfl09GZED0iwWm5A96tkMyq6+3G/Ao6TQw/GBNGw7UK06'; + const userKey = await vault.unwrapUserKeyBytes(encryptedUserKey, kdfOut); + expect(userKey.length).toBe(32); + kdfOut.fill(0); + userKey.fill(0); + }); +}); + +describe('Singra profile — vault item round-trip & fail-closed', () => { + it('seals and opens a vault item bound to its entry id', async () => { + const key = await vault.deriveKey(PW, SALT, 2); + const entryId = 'entry-abc'; + const data: vault.VaultItemData = { username: 'bob', password: 'hunter2', notes: 'x' }; + const sealed = await vault.encryptVaultItem(data, key, entryId); + expect(vault.isCurrentVaultItemEnvelope(sealed)).toBe(true); + expect(await vault.decryptVaultItem(sealed, key, entryId)).toEqual(data); + }); + + it('fails closed when the entry id (AAD) does not match', async () => { + const key = await vault.deriveKey(PW, SALT, 2); + const sealed = await vault.encryptVaultItem({ password: 'p' }, key, 'entry-1'); + await expect(vault.decryptVaultItem(sealed, key, 'entry-2')).rejects.toThrow(); + }); +}); + +describe('Singra profile — UserKey (USK) lifecycle', () => { + it('creates, unwraps, and rotates a wrapped UserKey without re-encrypting data', async () => { + const kdfOut = await vault.deriveRawKey(PW, SALT, 2); + const bundle = await vault.createEncryptedUserKey(kdfOut); + expect(bundle.encryptedUserKey.startsWith('usk-wrap-v2:')).toBe(true); + + // Seal a vault item under the UserKey. + const entryId = 'item-1'; + const sealed = await vault.encryptVaultItem({ password: 'pw' }, bundle.userKey, entryId); + + // Rotate to a new KDF output (e.g. password change) — data unchanged. + const newKdfOut = await vault.deriveRawKey('new master pw', SALT, 2); + const rewrapped = await vault.rewrapUserKey(bundle.encryptedUserKey, kdfOut, newKdfOut); + const userKeyAfter = await vault.unwrapUserKey(rewrapped, newKdfOut); + expect(await vault.decryptVaultItem(sealed, userKeyAfter, entryId)).toEqual({ password: 'pw' }); + + kdfOut.fill(0); + newKdfOut.fill(0); + }); + + it('migrateToUserKey derives a deterministic UserKey equal to the KDF output', async () => { + const kdfOut = await vault.deriveRawKey(PW, SALT, 2); + const bundle = await vault.migrateToUserKey(kdfOut); + const recovered = await vault.unwrapUserKeyBytes(bundle.encryptedUserKey, kdfOut); + expect([...recovered]).toEqual([...kdfOut]); + kdfOut.fill(0); + recovered.fill(0); + }); +}); + +describe('Singra profile — private key wrapping (usk-v1)', () => { + it('wraps and unwraps a private key with the UserKey', async () => { + const kdfOut = await vault.deriveRawKey(PW, SALT, 2); + const bundle = await vault.createEncryptedUserKey(kdfOut); + const material = JSON.stringify({ kty: 'oct', k: 'secret-private-key-material' }); + + const wrapped = await vault.wrapPrivateKeyWithUserKey(material, bundle.userKey); + expect(wrapped.startsWith('usk-v1:')).toBe(true); + expect(await vault.unwrapPrivateKeyWithUserKey(wrapped, bundle.userKey)).toBe(material); + + // Dispatch helper recognises the usk-v1 sentinel. + expect(await vault.getDecryptedRsaPrivateKey(wrapped, bundle.userKey, PW)).toBe(material); + kdfOut.fill(0); + }); +}); + +describe('Singra profile — sharing key material', () => { + it('generateUserKeyPair v1 (RSA) → recover private key by master password', async () => { + const { publicKey, encryptedPrivateKey } = await vault.generateUserKeyPair(PW, 1); + expect(encryptedPrivateKey).not.toContain('pq-v2:'); + + const recoveredPriv = await vault.getDecryptedRsaPrivateKey(encryptedPrivateKey, null, PW); + // The recovered private JWK can decrypt what the public key encrypts. + const pub = await vault.importPublicKey(JSON.parse(publicKey) as JsonWebKey); + const priv = await vault.importPrivateKey(JSON.parse(recoveredPriv) as JsonWebKey); + const ct = await vault.encryptRSA('shared-secret', pub); + expect(await vault.decryptRSA(ct, priv)).toBe('shared-secret'); + }, 30_000); + + it('generateUserKeyPair v2 (hybrid pq-v2) → recover both RSA and PQ private keys', async () => { + const { publicKey, encryptedPrivateKey, pqPublicKey } = await vault.generateUserKeyPair(PW, 2); + expect(encryptedPrivateKey.startsWith('pq-v2:')).toBe(true); + expect(typeof pqPublicKey).toBe('string'); + + const rsaPriv = await vault.getDecryptedRsaPrivateKey(encryptedPrivateKey, null, PW); + const pqPriv = await vault.getDecryptedPqPrivateKey(encryptedPrivateKey, null, PW); + + const pub = await vault.importPublicKey(JSON.parse(publicKey) as JsonWebKey); + const priv = await vault.importPrivateKey(JSON.parse(rsaPriv) as JsonWebKey); + const ct = await vault.encryptRSA('hybrid-secret', pub); + expect(await vault.decryptRSA(ct, priv)).toBe('hybrid-secret'); + + // PQ secret key is recoverable base64 material (length > 0). + expect(pqPriv.length).toBeGreaterThan(0); + }, 30_000); + + it('shared key seals and opens vault item data; fails closed on AAD mismatch', async () => { + const sharedKey = await vault.generateSharedKey(); + const data: vault.VaultItemData = { username: 'u', password: 'p' }; + const sealed = await vault.encryptWithSharedKey(data, sharedKey, 'collection-item-1'); + expect(await vault.decryptWithSharedKey(sealed, sharedKey, 'collection-item-1')).toEqual(data); + await expect(vault.decryptWithSharedKey(sealed, sharedKey, 'wrong-aad')).rejects.toThrow(); + }); +}); + +describe('Singra profile — verification hash', () => { + it('round-trips a v3 verification hash and rejects the wrong key', async () => { + const key = await vault.deriveKey(PW, SALT, 2); + const wrongKey = await vault.deriveKey('wrong', SALT, 2); + const hash = await vault.createVerificationHash(key); + expect(hash.startsWith('v3:')).toBe(true); + expect(await vault.verifyKey(hash, key)).toBe(true); + expect(await vault.verifyKey(hash, wrongKey)).toBe(false); + }); +}); + +describe('Singra profile — KDF upgrade & re-encryption', () => { + it('attemptKdfUpgrade (USK path) rewraps the key and produces a working verifier', async () => { + // Establish a v1 UserKey, then upgrade to current (v2). + const oldKdfOut = await vault.deriveRawKey(PW, SALT, 1); + const bundle = await vault.createEncryptedUserKey(oldKdfOut); + + const result = await vault.attemptKdfUpgrade(PW, SALT, 1, undefined, bundle.encryptedUserKey); + expect(result.upgraded).toBe(true); + expect(result.activeVersion).toBe(vault.CURRENT_KDF_VERSION); + expect(result.newEncryptedUserKey).toBeDefined(); + + const newKdfOut = await vault.deriveRawKey(PW, SALT, vault.CURRENT_KDF_VERSION); + const userKey = await vault.unwrapUserKey(result.newEncryptedUserKey!, newKdfOut); + expect(await vault.verifyKey(result.newVerifier!, userKey)).toBe(true); + oldKdfOut.fill(0); + newKdfOut.fill(0); + }); + + it('reEncryptVault migrates items from an old key to a new key', async () => { + const oldKey = await vault.deriveKey(PW, SALT, 1); + const newKey = await vault.deriveKey(PW, SALT, 2); + const items = [ + { id: 'a', encrypted_data: await vault.encryptVaultItem({ password: '1' }, oldKey, 'a') }, + { id: 'b', encrypted_data: await vault.encryptVaultItem({ password: '2' }, oldKey, 'b') }, + ]; + const out = await vault.reEncryptVault(items, [], oldKey, newKey); + expect(out.itemsReEncrypted).toBe(2); + for (const upd of out.itemUpdates) { + const data = await vault.decryptVaultItem(upd.encrypted_data, newKey, upd.id); + expect(data.password).toBe(upd.id === 'a' ? '1' : '2'); + } + }); +}); + +describe('Singra profile — RSA-OAEP interop', () => { + it('generates, exports/imports JWK, and round-trips RSA-OAEP', async () => { + const pair = await vault.generateRSAKeyPair(); + const pubJwk = await vault.exportPublicKey(pair.publicKey); + const privJwk = await vault.exportPrivateKey(pair.privateKey); + const pub = await vault.importPublicKey(pubJwk); + const priv = await vault.importPrivateKey(privJwk); + const ct = await vault.encryptRSA('rsa-payload', pub); + expect(await vault.decryptRSA(ct, priv)).toBe('rsa-payload'); + }, 30_000); +}); From 4c0214da8261f53f576d3250627c8687fb1d06da Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:59:57 +0000 Subject: [PATCH 09/10] fix(vault-crypto): match Singra contract for envelope predicate & legacy-migration logging - isCurrentVaultItemEnvelope now fails closed (throws) on unknown in-family sv-vault-* versions instead of returning false, matching legacy behaviour - decryptVaultItem/decryptVaultItemForMigration emit the legacy no-AAD marker via console.warn when the migration-only fallback is used - add profile tests locking both behaviours Co-Authored-By: Gaming Gruppe --- src/vault-crypto/index.ts | 16 +++++++++++++--- src/vault-crypto/vault-crypto.test.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/vault-crypto/index.ts b/src/vault-crypto/index.ts index 7f4f655..3112414 100644 --- a/src/vault-crypto/index.ts +++ b/src/vault-crypto/index.ts @@ -39,9 +39,10 @@ import { decryptVaultEntry, decryptVaultEntryForMigration, encryptVaultEntry, - isCurrentVaultEntryEnvelope, + VAULT_ITEM_ENVELOPE_SPEC, VAULT_ITEM_ENVELOPE_V1_PREFIX as DIS_VAULT_ITEM_ENVELOPE_V1_PREFIX, } from '../vault-encryption/index.js'; +import { parseEnvelope } from '../format-versioning/index.js'; import { createDeterministicWrappedUserKey, createWrappedUserKey, @@ -237,6 +238,7 @@ export async function decryptVaultItem( if (options.allowLegacyNoAadFallback) { const result = await decryptVaultEntryForMigration(encryptedData, key, entryId); if (result.legacyNoAadFallbackUsed) { + console.warn(`Legacy entry without AAD detected: ${entryId}`); _legacyDecryptCount++; } return result.data as VaultItemData; @@ -261,6 +263,7 @@ export async function decryptVaultItemForMigration( ): Promise { const result = await decryptVaultEntryForMigration(encryptedData, key, entryId); if (result.legacyNoAadFallbackUsed) { + console.warn(`Legacy entry without AAD detected: ${entryId}`); _legacyDecryptCount++; } return { @@ -270,9 +273,16 @@ export async function decryptVaultItemForMigration( }; } -/** True if `encryptedData` is a current versioned vault-item envelope. */ +/** + * True if `encryptedData` is a current versioned vault-item envelope. + * + * Fails closed (throws) on an unknown in-family version (`sv-vault-v:`) so a + * future format can never be silently treated as legacy by migration code — + * matching the Singra contract. Callers that need a non-throwing predicate must + * wrap this explicitly. + */ export function isCurrentVaultItemEnvelope(encryptedData: string): boolean { - return isCurrentVaultEntryEnvelope(encryptedData); + return parseEnvelope(VAULT_ITEM_ENVELOPE_SPEC, encryptedData).version === 1; } // ============ Verification hashes ============ diff --git a/src/vault-crypto/vault-crypto.test.ts b/src/vault-crypto/vault-crypto.test.ts index a8e7479..e86f48e 100644 --- a/src/vault-crypto/vault-crypto.test.ts +++ b/src/vault-crypto/vault-crypto.test.ts @@ -11,7 +11,7 @@ * RSA-OAEP, KDF upgrade, re-encryption) seals and opens correctly and fails * closed on tampering / wrong context. */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import * as vault from './index.js'; const PW = 'correct horse battery staple'; @@ -78,6 +78,31 @@ describe('Singra profile — vault item round-trip & fail-closed', () => { const sealed = await vault.encryptVaultItem({ password: 'p' }, key, 'entry-1'); await expect(vault.decryptVaultItem(sealed, key, 'entry-2')).rejects.toThrow(); }); + + it('isCurrentVaultItemEnvelope: true for v1, false for legacy, THROWS for unknown sv-vault-*', async () => { + const key = await vault.deriveKey(PW, SALT, 2); + const sealed = await vault.encryptVaultItem({ password: 'p' }, key, 'e'); + expect(vault.isCurrentVaultItemEnvelope(sealed)).toBe(true); + expect(vault.isCurrentVaultItemEnvelope('bGVnYWN5LWJhc2U2NA==')).toBe(false); + expect(() => vault.isCurrentVaultItemEnvelope('sv-vault-v99:future-payload')).toThrow( + /Unsupported vault item encryption envelope version/, + ); + }); + + it('decryptVaultItem warns + opens legacy no-AAD payloads only under explicit migration', async () => { + const key = await vault.deriveKey(PW, SALT, 2); + const item = { title: 'legacy', password: 'secret' }; + const legacyNoAad = await vault.encrypt(JSON.stringify(item), key); // no envelope, no AAD + // Runtime read path fails closed. + await expect(vault.decryptVaultItem(legacyNoAad, key, 'item-1')).rejects.toThrow(); + // Explicit migration path opens it AND logs the legacy marker. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + await expect( + vault.decryptVaultItem(legacyNoAad, key, 'item-1', { allowLegacyNoAadFallback: true }), + ).resolves.toEqual(item); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Legacy entry without AAD detected')); + warn.mockRestore(); + }); }); describe('Singra profile — UserKey (USK) lifecycle', () => { From 93a9eb24d5767edefdd1422b17f3c04d3028c922 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:02:15 +0000 Subject: [PATCH 10/10] chore(deps): bump vitest to 4.1.8 (fixes GHSA-5xrq-8626-4rwp) npm audit flagged a critical advisory in vitest <4.1.0 (Vitest UI server arbitrary file read/exec). Dev-only dependency; no production crypto impact. All 68 tests pass on vitest 4. Co-Authored-By: Gaming Gruppe --- package-lock.json | 1007 ++++++++++++++++++++++++++++++++++++--------- package.json | 2 +- 2 files changed, 803 insertions(+), 206 deletions(-) diff --git a/package-lock.json b/package-lock.json index a9e4d81..8ac4fd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "eslint": "^9.0.0", "tsup": "^8.5.1", "typescript": "^5.8.3", - "vitest": "^3.2.4" + "vitest": "^4.1.8" }, "engines": { "node": ">=20.19.0" @@ -33,6 +33,40 @@ } } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -783,6 +817,25 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@noble/curves": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", @@ -829,6 +882,280 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", @@ -1179,6 +1506,24 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1455,39 +1800,40 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -1499,42 +1845,42 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -1542,28 +1888,25 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1709,18 +2052,11 @@ } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -1742,16 +2078,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1822,7 +2148,14 @@ "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/cross-spawn": { + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", @@ -1855,16 +2188,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1872,10 +2195,20 @@ "dev": true, "license": "MIT" }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, @@ -2411,13 +2744,6 @@ "node": ">=10" } }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -2476,6 +2802,267 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -2529,13 +3116,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2630,6 +3210,17 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2720,16 +3311,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2888,6 +3469,40 @@ "node": ">=4" } }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, "node_modules/rollup": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", @@ -3011,9 +3626,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, @@ -3030,19 +3645,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -3133,30 +3735,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -3193,6 +3775,14 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/tsup": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", @@ -3308,18 +3898,17 @@ } }, "node_modules/vite": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", - "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -3335,9 +3924,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -3350,13 +3940,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -3382,89 +3975,80 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", "dev": true, "license": "MIT", "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -3475,9 +4059,22 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 85c56e9..f5cddf0 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "eslint": "^9.0.0", "tsup": "^8.5.1", "typescript": "^5.8.3", - "vitest": "^3.2.4" + "vitest": "^4.1.8" }, "keywords": [ "cryptography",