From 89d8b03052a7855757246b5331a9721eaa3a72aa Mon Sep 17 00:00:00 2001 From: einmalmaik Date: Sat, 13 Jun 2026 02:13:51 +0200 Subject: [PATCH 01/10] docs(api): clarify deriveMasterKey + deviceKey strengthen paths Discrepancy #1: the SDK has TWO KDF paths but the old doc only showed one. Generic KDF (kdf module) takes a typed opts.strengthen for HKDF-Expand. Singra Vault profile (vault-crypto) takes positional deviceKey + pinned HKDF info 'SINGRA_DEVICE_KEY_V1'. Add both signatures with the wire-format anchor. --- docs/api-design.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/api-design.md b/docs/api-design.md index 2f1fd73..ed5005d 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -24,11 +24,32 @@ isCurrentVaultEntryEnvelope(sealed): boolean encryptAttachment(input: EncryptAttachmentInput): Promise<{ manifest; manifestRoot }> decryptAttachment(input: DecryptAttachmentInput): Promise -// KDF +// KDF (see @dis/shield/kdf for low-level access; vault-crypto profile below) deriveMasterKey(password, saltBase64, opts?): Promise // == deriveAesGcmKey deriveRawKey(password, saltBase64, opts?): Promise generateSalt(): string +// Two KDF paths exist — pick by what you need: +// +// (1) Generic: takes a typed `opts.strengthen` for HKDF-Expand second-factor binding +deriveAesGcmKey(password, saltBase64, { + version?: number, // KDF version registry; defaults to CURRENT_KDF_VERSION + params?: Readonly>, // optional override + strengthen?: { // optional HKDF second-factor binding + hkdfSalt: Uint8Array, + info: string, // caller-owned domain-separation label + }, +}): Promise + +// (2) Singra Vault profile (vault-crypto): positional `deviceKey` is the common case +deriveRawKey(masterPassword, saltBase64, kdfVersion?, deviceKey?): Promise +deriveKey(masterPassword, saltBase64, kdfVersion?, deviceKey?): Promise +deriveRawKeySecure(masterPassword, saltBase64, kdfVersion?, deviceKey?): Promise +// +// When `deviceKey` is supplied, DIS strengthens the Argon2id output via HKDF-Expand +// with HKDF info = 'SINGRA_DEVICE_KEY_V1' and salt = the device key bytes. +// (The `info` string is part of the wire-format contract; see crypto-dependency-map.md.) + // Key management createWrappedUserKey(kdfOutputBytes, scheme?): Promise unwrapUserKey(encryptedUserKey, kdfOutputBytes, scheme?): Promise From 267f61bd79266c5fd286aef7e1fe821149312ee8 Mon Sep 17 00:00:00 2001 From: einmalmaik Date: Sat, 13 Jun 2026 02:14:04 +0200 Subject: [PATCH 02/10] docs(crypto-map): correct otpauth status (now in DIS, not app) Discrepancy #5: the dependency-map said otpauth was 'out of DIS scope' and shipped by singravault, but as of Phase 6 TOTP lives inside @dis/shield/totp. Apps no longer need to install otpauth directly. --- docs/crypto-dependency-map.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/crypto-dependency-map.md b/docs/crypto-dependency-map.md index 1b5bce8..41f28b4 100644 --- a/docs/crypto-dependency-map.md +++ b/docs/crypto-dependency-map.md @@ -11,7 +11,7 @@ time and are a baseline, not a guarantee. | `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` | +| `otpauth` | ^9.5.0 | TOTP (RFC 6238) — now lives inside DIS `@dis/shield/totp` | DIS repo (dep) | ## Singra Vault — core crypto modules From beb30a5b85592635168683626b72ac60cd5320fd Mon Sep 17 00:00:00 2001 From: einmalmaik Date: Sat, 13 Jun 2026 02:14:29 +0200 Subject: [PATCH 03/10] docs(migrations): add framework guide (DIS provides framework, app provides steps) Discrepancy #6: api-design.md suggested 'new MigrationRegistry().register(...).migrateToLatest(...)' as if DIS ships migrations out of the box. It does not. New file docs/migrations.md explains the split: framework in DIS, concrete steps in the app. Includes end-to-end example and a typical 'legacy no-AAD -> sv-vault-v1' step. --- docs/migrations.md | 109 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 docs/migrations.md diff --git a/docs/migrations.md b/docs/migrations.md new file mode 100644 index 0000000..dd94913 --- /dev/null +++ b/docs/migrations.md @@ -0,0 +1,109 @@ +# Migrations + +DIS provides a **migration framework** for evolving encrypted payload formats. +DIS does **not** ship any pre-registered migration steps — applications define +and register the migrations that match their own format history. + +## Why a framework, not a registry + +Migrations are inherently application-specific: + +- The *subject* ("vault item", "user-key bundle", "attachment manifest") is + chosen by the application. +- The *version detector* is a function that inspects a payload and returns its + current version — it is shaped by the application's chosen format constants. +- The *step function* knows the application's old payload shape and how to + rewrite it into the new one. + +Hard-coding migrations into DIS would either bake in assumptions about one +specific app's format history, or explode into a swiss-army-knife that tries +to support every conceivable history. Neither is the right call. + +## The pieces + +```ts +import { MigrationRegistry, type Migration, type VersionDetector, type MigrationContext } from '@dis/shield'; + +// A single migration: transforms a payload from one version to the next. +interface Migration { + subject: string; // e.g. 'vault item' + fromVersion: number | 'legacy'; + toVersion: number; + migrate(payload: string, context: MigrationContext): Promise; +} + +// Detects the current version of a payload. Returns 'legacy' for un-versioned payloads. +type VersionDetector = (payload: string) => number | 'legacy'; + +// Context passed to every step. The application owns key material and binding ids. +interface MigrationContext { + key: CryptoKey; + bindingId?: string; // e.g. vault-item id, for AAD-bound formats +} + +// The registry enforces (subject, fromVersion) uniqueness and runs steps in order. +const registry = new MigrationRegistry() + .register(legacyNoAadToV1) + .register(v1ToV2); +``` + +## End-to-end usage + +```ts +import { MigrationRegistry, type VersionDetector } from '@dis/shield'; + +const detectVaultItemVersion: VersionDetector = (payload) => { + if (payload.startsWith('sv-vault-v1:')) return 1; + return 'legacy'; +}; + +const upgraded = await registry.migrateToLatest( + 'vault item', // subject + storedCiphertext, // the persisted payload + detectVaultItemVersion, // how to read its current version + { key: userKey, bindingId: entryId }, +); +``` + +The registry walks `legacy → 1 → 2 → …` until a step is missing, then returns +the most recent migrated payload. Cycles are detected and throw +`DisError('INVALID_ARGUMENT', …)`. + +## What a typical step looks like + +```ts +// Re-wrap a legacy no-AAD vault item as sv-vault-v1 with AAD binding. +const legacyNoAadToV1: Migration = { + subject: 'vault item', + fromVersion: 'legacy', + toVersion: 1, + async migrate(payload, { key, bindingId }) { + // Use the explicit migration helper — never re-encrypt on the runtime path. + const { data } = await decryptVaultEntryForMigration(payload, key, bindingId!); + return encryptVaultEntry(data, key, bindingId!); + }, +}; +``` + +## Rules of thumb + +- **One step, one version bump.** If you need `legacy → 1 → 2`, register two + migrations; the registry chains them. +- **Steps must be idempotent on their output.** Re-running a step on its own + output must not corrupt the payload. This is required for the cycle-guard + to work and for safe retries. +- **Steps are pure with respect to key material.** A step may *use* `context.key` + but must not mutate it. DIS wipes its own allocations; you own yours. +- **Don't put security decisions in the detector.** The detector is a parser + (looks at a prefix, parses a header), not a verifier. Authenticate at the + read boundary, not during migration. +- **For new applications, start with no migrations.** Only register steps when + you have a concrete format change to ship. + +## When to NOT use this framework + +- **One-off format fixes during development** — just write a one-time script + that calls the migration helpers and updates the database. The framework is + for migrations that may re-run on user data after a deploy. +- **Schema migrations of plain (non-encrypted) columns** — that's a database + concern, not a crypto one. Use your database's native migration tooling. From c401e2596585749975408f93534d99ef72281e8e Mon Sep 17 00:00:00 2001 From: einmalmaik Date: Sat, 13 Jun 2026 02:14:48 +0200 Subject: [PATCH 04/10] docs(api): disambiguate migrateToHybrid vs migrateToHybridKeyPair Discrepancy #3 + #4: two different functions share the 'migrate to hybrid' verb across modules. The vault-crypto one rewrites a stored private-key blob to pq-v2:. The post-quantum one re-encodes a hybrid ciphertext from v1/legacy to the current v2 byte layout. Split them with explicit 'when to use which' guidance so a KI does not pick the wrong one. --- docs/api-design.md | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/docs/api-design.md b/docs/api-design.md index ed5005d..34ac32c 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -57,7 +57,41 @@ rotateEncryptionKeys(encryptedUserKey, oldKdf, newKdf, scheme?): Promise // Integrity & migrations verifyPayloadIntegrity(bytes, expectedBase64): Promise -new MigrationRegistry().register(...).migrateToLatest(...) + +// Migration framework — see docs/migrations.md for the full guide. +// DIS provides the framework; the application registers concrete steps. +new MigrationRegistry().register(migration: Migration).migrateToLatest( + subject: string, + payload: string, + detect: VersionDetector, + context: MigrationContext, +): Promise + +// ---- Two functions share a name across modules — read carefully. ----------- +// +// (a) Storage-format migration for a wrapped private key. +// Lives in @dis/shield/vault-crypto. Rewrites a stored +// 'salt:enc' / 'ver:salt:enc' private-key blob to the hybrid +// 'pq-v2:ver:salt:encRsa:encPq' form. +migrateToHybridKeyPair(encryptedPrivateKey: string, masterPassword: string): + Promise<{ publicKey: string; encryptedPrivateKey: string; pqPublicKey: string } | null> + +// (b) Cipher-version migration for an already-hybrid PQ+RSA ciphertext. +// Lives in @dis/shield/post-quantum. Re-encodes a v1 (0x01 RSA-only), +// legacy (0x02), or standard v1 (0x03) hybrid ciphertext into the +// current standard v2 (0x04). Byte-layout preserved. +migrateToHybrid( + legacyCiphertext: string, + rsaPrivateKey: string, // JWK string + pqSecretKey: string | null, // base64; required for 0x02 and 0x03 + pqPublicKey: string, // base64 + rsaPublicKey: string, // JWK string + aad?: string, +): Promise +// +// Use (a) when you are reading a legacy stored private key and want to +// upgrade its storage format. Use (b) when you already have a hybrid +// ciphertext at an old version and want to refresh it in place. ``` ## Design rules From 5d59e86c4540ef40ae4c3c5a8c29a007b180e9ae Mon Sep 17 00:00:00 2001 From: einmalmaik Date: Sat, 13 Jun 2026 02:15:38 +0200 Subject: [PATCH 05/10] docs: add integration guide for new DIS consumers Fehlendes Konzept #7: a step-by-step 'how do I integrate DIS into a new app' guide with end-to-end code for the four common flows (master-password unlock, password change, file attachments, sharing) plus audit-log signatures, TOTP, error handling, and the 'do not do' list. Closes the gap that previously made it impossible for a KI to integrate DIS without reading the source code. --- docs/integration-guide.md | 260 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 docs/integration-guide.md diff --git a/docs/integration-guide.md b/docs/integration-guide.md new file mode 100644 index 0000000..2053481 --- /dev/null +++ b/docs/integration-guide.md @@ -0,0 +1,260 @@ +# Integration Guide + +> **Who is this for?** A new application that wants to consume `@dis/shield` +> for its cryptography. After reading this, you should be able to wire a +> master-password unlock, a vault-item store/load cycle, a wrapped private +> key for sharing, and a chunked file attachment, without reading the +> DIS source code. + +This guide follows the shape of the Singra Vault + Singra Premium cutover, +but the steps are generic. + +## 0. Decide what you actually need + +Most apps only need a subset of DIS. Pick the smallest surface that solves +your problem. + +| If you need… | You want… | +| --- | --- | +| Derive a key from a master password | `deriveMasterKey` (the `kdf` module, surfaced as `deriveAesGcmKey`) | +| Encrypt/decrypt structured data with a key | `encryptVaultEntry` / `decryptVaultEntry` (the `vault-encryption` module) | +| Two-tier key model so password change does not re-encrypt data | `createWrappedUserKey` / `unwrapUserKey` / `rotateWrappedKey` (the `key-management` module) | +| Chunked file attachments with a manifest | `encryptAttachment` / `decryptAttachment` (the `file-encryption` module) | +| Sharing / emergency access with post-quantum forward secrecy | `generateHybridKeyPair` / `hybridWrapKey` / `hybridUnwrapKey` (the `post-quantum` module) | +| ECDSA signatures for an audit log | `generateEcdsaP256KeyPair` / `signEcdsaP256` / `verifyEcdsaP256` (the `signing` module) | +| TOTP for in-app authenticator or for 2FA | `generateTotpSecret` / `verifyTotpCode` / `generateTotpCode` (the `totp` module) | + +For Singra Vault's exact composition of all of the above, see +`docs/architecture.md` (Module boundaries) and `@dis/shield/vault-crypto` +(the application-specific composition Singra uses). + +## 1. Install + +```bash +npm install @dis/shield +``` + +`@dis/shield` pins Node `>=20.19.0` (the same constraint Singra Vault uses). +No other runtime dependencies are required unless you use the +`post-quantum` module, in which case install the optional peer: + +```bash +npm install @noble/post-quantum +``` + +## 2. The core flow: master password → key → vault item + +The pattern is always the same: + +``` +master password + salt ──Argon2id──▶ KEK (raw bytes or CryptoKey) + │ + │ (optional: HKDF-strengthen with device key) + │ + ▼ + content key (UserKey) ◀── rotated on password change, + │ no data re-encryption + │ + ▼ + vault items (sv-vault-v1: envelope, AAD = entry id) +``` + +```ts +import { deriveRawKey, generateSalt, createWrappedUserKey, unwrapUserKey, + encryptVaultEntry, decryptVaultEntry } from '@dis/shield'; + +// 1) Account setup: generate salt and a wrapped user key. Store both. +const salt = generateSalt(); +const kdfBytes = await deriveRawKey(masterPassword, salt); +const { encryptedUserKey, userKey } = await createWrappedUserKey(kdfBytes); +// → persist (salt, encryptedUserKey). Keep kdfBytes in a SecureBuffer; wipe when done. + +// 2) Unlock: re-derive and unwrap the user key. +const kdfBytes = await deriveRawKey(masterPassword, storedSalt); +const userKey = await unwrapUserKey(storedEncryptedUserKey, kdfBytes); + +// 3) Encrypt a vault item. The entryId binds the ciphertext to its row. +const sealed = await encryptVaultEntry( + { title: 'GitHub', username: 'me', password: '...' }, + userKey, + entryId, +); + +// 4) Decrypt it later. The AAD matches the entryId, so swap-attacks fail closed. +const plain = await decryptVaultEntry(sealed, userKey, entryId); +``` + +**Wire-format note.** The `encryptedUserKey` string is `usk-wrap-v2:`. +The `sealed` item is `sv-vault-v1:`. Both prefixes are part of the +format contract — see `docs/crypto-dependency-map.md` for the full list of +frozen constants. **Never change a published prefix.** Add a new version +instead. + +## 3. Password change without re-encrypting data + +```ts +import { deriveRawKey, rotateWrappedKey } from '@dis/shield'; + +const oldKdfBytes = await deriveRawKey(oldPassword, salt, OLD_KDF_VERSION); +const newKdfBytes = await deriveRawKey(newPassword, salt, CURRENT_KDF_VERSION); + +const newEncryptedUserKey = await rotateWrappedKey( + oldEncryptedUserKey, oldKdfBytes, newKdfBytes, +); +// → overwrite encryptedUserKey in the database. Vault data is unchanged. +``` + +If you also use a device key for second-factor strengthening, pass it to +**both** `deriveRawKey` calls. The HKDF info label `SINGRA_DEVICE_KEY_V1` +is the same on both sides (it's the wire-format contract for the +strengthening step — see `crypto-dependency-map.md`). + +## 4. Chunked file attachments + +Use `encryptAttachment` / `decryptAttachment`. You supply the storage; DIS +supplies the cryptography and the manifest. + +```ts +import { encryptAttachment, decryptAttachment } from '@dis/shield/file-encryption'; + +const { manifest, manifestRoot } = await encryptAttachment({ + context: { ownerId, vaultItemId, fileId }, + totalSize: file.byteLength, + readChunk: async (start, end) => new Uint8Array(await file.slice(start, end).arrayBuffer()), + writeChunk: async (index, ciphertextBase64) => { + await objectStore.put(`chunks/${fileId}/${index}`, ciphertextBase64); + return ciphertextBase64.length; // stored size in bytes + }, + wrapFileKey: (fileKeyBytes, aad) => + vaultEncrypt(fileKeyBytes, userKey, aad), // your existing vault AEAD entry + metadata: { original_name: file.name, mime_type: file.type, last_modified: file.lastModified }, +}); +// → persist manifest. chunkSize defaults to 4 MiB. +``` + +For decryption, you pass back the manifest and DIS streams chunks through +your `readChunk` / `writeChunk` callbacks. Each chunk is authenticated by +its AAD (`sv-file-chunk-v1:owner:item:file:rev:manifestRoot:idx:count`). +A storage operator cannot reorder, splice, or swap chunks undetected. + +## 5. Sharing and emergency access (post-quantum hybrid) + +> **Scope:** Use this for keys that need to survive "harvest now, decrypt +> later" adversaries. **Not** the encryption layer for vault item payloads. + +```ts +import { generateHybridKeyPair, hybridWrapKey, hybridUnwrapKey } from '@dis/shield/post-quantum'; + +// Per user (or per device that may act as grantor): +const { rsaPublicKey, rsaPrivateKey, pqPublicKey, pqSecretKey } = await generateHybridKeyPair(); +// → store (rsaPrivateKey, pqSecretKey) wrapped under the user's UserKey. Publish +// (rsaPublicKey, pqPublicKey) so other users can wrap shared keys to you. + +// Per shared key, per recipient: +const wrapped = await hybridWrapKey(sharedKeyJwk, recipientPqPublicKey, recipientRsaPublicKey, + `sv:shared-key:v1:${collectionId}:${senderUserId}:${recipientUserId}:${keyVersion}`); +// → persist the wrapped blob; only the recipient can unwrap it. + +// On the recipient side: +const sharedKeyJwk = await hybridUnwrapKey( + wrapped, recipientPqSecretKey, recipientRsaPrivateKey, + `sv:shared-key:v1:${collectionId}:${senderUserId}:${recipientUserId}:${keyVersion}`, +); +``` + +**Important:** the AAD string on wrap **must equal** the AAD string on +unwrap, byte-for-byte. Use `buildSharedKeyWrapAad({ … })` to construct it +deterministically. + +## 6. Audit-log signatures (ECDSA P-256) + +```ts +import { generateEcdsaP256KeyPair, signEcdsaP256, verifyEcdsaP256, + importEcdsaP256PublicKeySpki } from '@dis/shield/signing'; + +const { privateKey, publicKey, publicKeySpki } = await generateEcdsaP256KeyPair(); +// → privateKey is non-extractable. Store publicKeySpki (base64url) for verifiers. + +const sig = await signEcdsaP256(privateKey, canonicalRecordBytes); +// → 64 bytes (r || s). Persist alongside the record. + +const ok = await verifyEcdsaP256( + await importEcdsaP256PublicKeySpki(storedSpki), + sig, canonicalRecordBytes, +); +``` + +DIS does not define canonicalization. You decide the byte string that gets +signed. The wire format is fixed (raw `r || s`, 64 bytes); the meaning of +the bytes is yours. + +## 7. TOTP for in-app authenticator or 2FA + +```ts +import { generateTotpSecret, buildTotpUri, verifyTotpCode, + generateTotpCode, buildTotpUriWithOptions } from '@dis/shield/totp'; + +// Enroll: generate secret, show QR code from the otpauth:// URI. +const secret = generateTotpSecret(); +const uri = buildTotpUri({ issuer: 'Singra', label: 'me@example.com', secret }); +// → user scans; their authenticator now produces 6-digit codes every 30s. + +// Verify at login. +const ok = await verifyTotpCode(secret, userInputCode); + +// Password-manager authenticator view: code generation for a third-party +// imported entry (algorithm/digits/period may differ from the Singra default). +const code = generateTotpCode(importedSecret, { algorithm: 'SHA256', digits: 8, period: 60 }); +const importedUri = buildTotpUriWithOptions( + { issuer, label, secret: importedSecret }, + { algorithm: 'SHA256', digits: 8, period: 60 }, +); +``` + +Singra's own 2FA enrolment uses `verifyTotpCode` with the pinned default +(`SHA1` / 6 digits / 30 s). Imported entries use `generateTotpCode` with +explicit per-entry options. + +## 8. Error handling — what can go wrong + +Always wrap DIS calls in a try/catch and map to user-facing copy. The +relevant errors are in `@dis/shield/core`: + +| Error | When | What to do | +| --- | --- | --- | +| `DisDecryptionError` | Wrong key, tampered ciphertext, or wrong AAD | Treat as "wrong password" or "data corruption". **Do not** expose the cause. | +| `DisIntegrityError` | SHA-256 verification of a stored payload failed | Treat as integrity violation. Trigger quarantine / re-fetch / log. | +| `DisUnsupportedFormatVersionError` | Ciphertext carries an unknown in-family version prefix | Refuse. Tell the user to update the app. | +| `DisLegacyPayloadError` | Runtime read hit an unversioned / no-AAD payload | Run the explicit migration path (see `docs/migrations.md`) or refuse. | +| `DisInvalidArgumentError` | Caller passed a bad argument (empty string, wrong length, etc.) | Treat as a programming error in the app. | +| `DisError('UNSUPPORTED_KDF_VERSION')` | Stored KDF version not in the registry | Same as `DisUnsupportedFormatVersionError`. | +| `DisError('KEY_DERIVATION_FAILED')` | Argon2id output was the wrong type | Should not happen; report as a bug. | +| `DisError('INVALID_ARGUMENT')` | Migration cycle detected | The migration registry has a bug; report. | + +The contract: **AEAD failures do not reveal cause** (no padding/AAD oracle). +Your error UX must respect that. + +## 9. What you should NOT do + +- **Do not import `hash-wasm`, `otpauth`, or `@noble/post-quantum` directly.** + These are pulled in transitively. Direct imports will be blocked by the + consuming app's ESLint guardrail after the DIS cutover. +- **Do not call `crypto.subtle` directly in app code.** DIS provides a + `getCryptoProvider()` / `setCryptoProvider()` seam if you need to swap the + implementation in tests. +- **Do not store the master password, KDF output, content key, or wrapped + private key on the server.** All of those belong client-side. The server + stores only ciphertext, salts, wrapped keys, verification hashes, manifests. +- **Do not change a published envelope prefix.** Add a new version instead. +- **Do not roll your own KDF parameters.** Use the versioned registry. + +## 10. Where to read more + +- [`architecture.md`](architecture.md) — what DIS is and is not +- [`api-design.md`](api-design.md) — the supported function surface +- [`crypto-dependency-map.md`](crypto-dependency-map.md) — frozen wire-format + constants, what apps still depend on +- [`trust-boundaries.md`](trust-boundaries.md) — what is in scope and out +- [`migrations.md`](migrations.md) — the migration framework +- [`threat-model.md`](threat-model.md), [`risk-analysis.md`](risk-analysis.md), + [`crypto-review.md`](crypto-review.md) — the security analysis From baa38ee529ebd640383157927f16e276f7246aa2 Mon Sep 17 00:00:00 2001 From: einmalmaik Date: Sat, 13 Jun 2026 02:15:57 +0200 Subject: [PATCH 06/10] docs(api): clarify KDF_PARAMS is vault-crypto-only, not SDK Discrepancy #2: api-design.md mentioned KDF_PARAMS as a public symbol, but the SDK facade does not re-export it. KDF_PARAMS is an alias inside @dis/shield/vault-crypto. The SDK exposes CURRENT_KDF_VERSION and DEFAULT_KDF_PARAMS instead. --- docs/api-design.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/api-design.md b/docs/api-design.md index 34ac32c..696f173 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -29,6 +29,13 @@ deriveMasterKey(password, saltBase64, opts?): Promise // == deriveAe deriveRawKey(password, saltBase64, opts?): Promise generateSalt(): string +// Note: `KDF_PARAMS` is **not** exported from the SDK facade. +// The SDK re-exports `CURRENT_KDF_VERSION` and `DEFAULT_KDF_PARAMS` from +// @dis/shield/kdf. The plain-record `KDF_PARAMS` alias lives in +// @dis/shield/vault-crypto and is meant for Singra's call sites that look +// up params by version. For new applications, prefer `DEFAULT_KDF_PARAMS` +// from the SDK facade or @dis/shield/kdf. + // Two KDF paths exist — pick by what you need: // // (1) Generic: takes a typed `opts.strengthen` for HKDF-Expand second-factor binding From cd5f1c48879952aa9de42415b0a7b7a303543e62 Mon Sep 17 00:00:00 2001 From: einmalmaik Date: Sat, 13 Jun 2026 02:16:29 +0200 Subject: [PATCH 07/10] docs(api): note TOTP password-manager surface is subpath-only Discrepancy #8: the SDK facade re-exports only the 2FA-enrolment TOTP surface (generateTotpSecret, buildTotpUri, verifyTotpCode). The password-manager authenticator helpers (generateTotpCode, buildTotpUriWithOptions) live in @dis/shield/totp directly. Document the split and the reason. --- docs/api-design.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/api-design.md b/docs/api-design.md index 696f173..cd0016e 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -62,6 +62,16 @@ createWrappedUserKey(kdfOutputBytes, scheme?): Promise unwrapUserKey(encryptedUserKey, kdfOutputBytes, scheme?): Promise rotateEncryptionKeys(encryptedUserKey, oldKdf, newKdf, scheme?): Promise +// TOTP — the SDK facade re-exports the 2FA-enrolment surface only. +// For password-manager authenticator code (third-party imported entries +// with non-default algorithm / digits / period), import directly: +// import { generateTotpCode, buildTotpUriWithOptions } from '@dis/shield/totp' +// The SDK export intentionally omits these so the 2FA-enrolment contract +// (SHA-1 / 6 digits / 30 s) is the only thing 2FA call sites can reach. +generateTotpSecret(): string +buildTotpUri({ issuer, label, secret }): string +verifyTotpCode(secret, code, window?): boolean + // Integrity & migrations verifyPayloadIntegrity(bytes, expectedBase64): Promise From 7b8c582b9cba90426114b4f5844ad0d2ba4a4407 Mon Sep 17 00:00:00 2001 From: einmalmaik Date: Sat, 13 Jun 2026 02:16:58 +0200 Subject: [PATCH 08/10] docs(api): add error-handling table mapping DIS errors to app UX Qualitaet #9: the old api-design only said 'errors are typed and secret-free' in one bullet. Add a full table of error classes, when they fire, and what the app should do. Mentions the no-oracle contract on DisDecryptionError and the legacy/runtime-vs-migration split. Tested against actual throws in src//index.ts. --- docs/api-design.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/api-design.md b/docs/api-design.md index cd0016e..0a161e6 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -128,6 +128,31 @@ migrateToHybrid( `DisLegacyPayloadError`. 6. **Providers are injectable** via `setCryptoProvider` for tests/hardware. +## Error handling + +DIS throws typed errors from `@dis/shield/core`. Map them to user-facing +copy in your application; do not surface raw `message` to end users (some +include stack-trace-ish details that are not useful to a user and may +leak internal state). Do not switch on the string of the message — switch +on the `code` property or the constructor. + +| Error class | `code` (when applicable) | When it fires | Suggested app behaviour | +| --- | --- | --- | --- | +| `DisDecryptionError` | — | AEAD failure: wrong key, tampered ciphertext, or wrong AAD | Treat as "wrong password" or "data corruption". Do **not** expose the cause — there is no oracle, by design. | +| `DisIntegrityError` | — | SHA-256 verification of a stored payload (manifest, hash chain) failed | Trigger quarantine / re-fetch from a trusted source / log for the security team. | +| `DisUnsupportedFormatVersionError` | — | Ciphertext carries an unknown in-family version prefix (e.g. `sv-vault-v9:`) | Refuse to read. Ask the user to update the app. | +| `DisLegacyPayloadError` | — | Runtime read hit an unversioned / no-AAD payload | Refuse on the runtime path. Re-try through the explicit migration helper (`decryptVaultEntryForMigration` etc.) or refuse. | +| `DisInvalidArgumentError` | — | Caller passed a bad argument (empty string, wrong length, missing required AAD, …) | Programming error in the app. Log and surface a generic error. | +| `DisError` | `'UNSUPPORTED_KDF_VERSION'` | The KDF version stored for an account is not in the registry | Refuse unlock. Ask the user to update (or roll back, but never silently upgrade). | +| `DisError` | `'KEY_DERIVATION_FAILED'` | Argon2id output was the wrong type | Should not happen; report as a bug. | +| `DisError` | `'INVALID_ARGUMENT'` | Migration cycle detected | The migration registry has a bug; report. | +| `DisError` | `'SECURITY_STANDARD_BLOCKED'` (post-quantum) | Hybrid ciphertext at a version not permitted by the security standard | Refuse. Run the migration helper (`migrateToHybrid`) explicitly. | + +**The contract:** AEAD failures do not reveal cause (no padding/AAD oracle). +Your error UX must respect that. Log the type for yourself, but show the +user a generic "could not decrypt — wrong password or corrupted data" +message. + ## Breaking-change policy - Format-frozen constants are append-only (`sv-vault-v1` → add `sv-vault-v2`, From c23f88cb6ce3e74a672baedc5eed5741167c5cf8 Mon Sep 17 00:00:00 2001 From: einmalmaik Date: Sat, 13 Jun 2026 02:17:18 +0200 Subject: [PATCH 09/10] docs(readme): document DIS_BRANDING constant for app badges Fix #9: the DIS_BRANDING constant is exported from the SDK but never mentioned in the README. Apps that want a 'Powered by DIS' badge had to find it by reading src/index.ts. Add a short section with the import snippet and a pointer to docs/licensing.md for the trademark rules. --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 3648c35..09d4afb 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,17 @@ 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). +## Branding + +DIS exports a `DIS_BRANDING` string constant for applications that want to +display a "Powered by DIS — Defensive Integration Shield" badge. This is +optional; the branding requirements live in [`docs/licensing.md`](docs/licensing.md). + +```ts +import { DIS_BRANDING } from '@dis/shield'; +// 'Powered by DIS — Defensive Integration Shield' +``` + ## Install ```bash From c73d876dba51f096654b6a300c22ac2c989060bc Mon Sep 17 00:00:00 2001 From: einmalmaik Date: Sat, 13 Jun 2026 02:17:50 +0200 Subject: [PATCH 10/10] docs: add app-to-module cross-reference Fix #10: a KI that has to figure out 'which Singra app uses which DIS module' had to grep both repos. New file docs/app-to-module.md gives the full mapping table for Vault + Premium, plus a 'for a third app, do not import vault-crypto' note that points new consumers at the integration guide. --- docs/app-to-module.md | 86 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 docs/app-to-module.md diff --git a/docs/app-to-module.md b/docs/app-to-module.md new file mode 100644 index 0000000..9345cc6 --- /dev/null +++ b/docs/app-to-module.md @@ -0,0 +1,86 @@ +# Cross-Reference: Application → DIS Module + +> Which DIS module does each application consume, and for what? This file is +> the map from a Singra product to the underlying primitive it relies on. +> Use it when debugging or when planning to swap an implementation. + +## Singra Vault (`github.com/einmalmaik/singravault`) + +The Vault is the most DIS-heavy application. It is **the reference +consumer**: nearly every DIS module is in use somewhere. + +| Capability | Vault surface | DIS module | +| --- | --- | --- | +| Master-password KDF (Argon2id) | `deriveRawKey` / `deriveKey` | `kdf` | +| Device-key strengthening (HKDF) | `deriveRawKey(..., deviceKey)` | `kdf` | +| Vault item envelope (`sv-vault-v1:`) | `encryptVaultItem` / `decryptVaultItem` | `vault-encryption` | +| Two-tier key model (`usk-wrap-v2:`) | `createEncryptedUserKey` / `unwrapUserKey` / `rewrapUserKey` | `key-management` | +| Private-key wrapping (`usk-v1:`) | `wrapPrivateKeyWithUserKey` / `unwrapPrivateKeyWithUserKey` | `vault-crypto` | +| RSA-OAEP for emergency access | `generateRSAKeyPair` / `encryptRSA` / `decryptRSA` | `vault-crypto` (over `asymmetric`) | +| Hybrid PQ+RSA for sharing / emergency | `generateUserKeyPair(v2)` / `migrateToHybridKeyPair` | `vault-crypto` (over `post-quantum`) | +| OpLog ECDSA device signatures | `signEcdsaP256` / `verifyEcdsaP256` | `signing` | +| OpLog record/snapshot crypto | HKDF + AES-GCM | `kdf` / `aead` / `random` | +| OpLog integrity hashes | `sha256Bytes` / `sha256Hex` / `hmacSha256` | `integrity` | +| 2FA TOTP (Singra enrolment) | `verifyTotpCode` / `buildTotpUri` | `totp` | +| Password-manager authenticator codes | `generateTotpCode` / `buildTotpUriWithOptions` | `totp` (subpath) | +| HIBP password-strength (SHA-1) | `sha1Hex` | `integrity` | +| PKCE for OAuth (Web) | `sha256Bytes` / `randomBytes` | `integrity` / `random` | +| Password generator randomness | `randomInt` | `random` | +| Passkey PRF wrapping | `deriveHkdfAesGcmKey` | `kdf` | +| OPAQUE session binding | `hmacSha256` | `integrity` | +| Canonical-JSON hashing for integrity v2 | `sha256StringBase64` | `integrity` | +| Key material in memory | `SecureBuffer` | `secure-memory` | +| UUIDs in UI / orchestrators | `randomUuid` | `random` | +| Application-specific composition | `services/cryptoService.ts` is now a thin re-export of `@dis/shield/vault-crypto` | `vault-crypto` (Singra profile) | + +## Singra Premium (`github.com/einmalmaik/singra-premium`) + +Premium is a sibling repository that historically aliased Singra Vault's +`src/services/*` for its own crypto. As of the Phase 4 cutover it imports +DIS directly. + +| Capability | Premium surface | DIS module | +| --- | --- | --- | +| Vault item encryption (delegated to UserKey) | re-uses Vault's `@dis/shield/vault-crypto` | `vault-crypto` | +| Chunked file attachments | `encryptAttachment` / `decryptAttachment` | `file-encryption` | +| File-key wrap under UserKey | via `wrapFileKey` callback that calls into `vault-crypto.encrypt` | `file-encryption` + `vault-crypto` | +| PQ hybrid keypair for shared collections | re-uses Vault's surface | `vault-crypto` (over `post-quantum`) | + +Premium is the second consumer and exists to test the "two tightly-coupled +apps adopt DIS" path that the architecture decision in `architecture.md` +calls out. See `docs/migration-plan.md` Phase 4 for the cutover steps. + +## A future, third application + +If you are starting a new application that consumes DIS, you probably do +**not** need `@dis/shield/vault-crypto`. That module is the Singra-Vault +profile; it bakes in the `SINGRA_*` HKDF info labels, the `sv-vault-v1:` +envelope, the `usk-wrap-v2:` user-key envelope, the `pq-v2:` shared-key +envelope, and the Singra `VaultItemData` shape. + +For a new app: + +1. Read [`docs/integration-guide.md`](integration-guide.md) first. +2. Compose from the low-level modules: `kdf`, `aead`, `vault-encryption`, + `key-management`, `file-encryption`, `post-quantum`, `signing`, `totp`, + `integrity`, `random`, `secure-memory`, `core`. +3. Define your **own** envelope prefixes (e.g. `myapp-record-v1:`) and + **own** HKDF info labels. The contract is "pick a prefix, freeze it, + add a new version rather than editing it". See + [`docs/crypto-dependency-map.md`](crypto-dependency-map.md) for what + the format constants are for the Singra profile; your profile will + look the same, but the values are yours. + +## What "Powered by DIS" means in code + +If you display a "Powered by DIS — Defensive Integration Shield" badge, +the import is: + +```ts +import { DIS_BRANDING } from '@dis/shield'; +// DIS_BRANDING === 'Powered by DIS — Defensive Integration Shield' +``` + +Trademark and badge rules are in [`docs/licensing.md`](licensing.md). +Short version: non-commercial use of the badge is fine; commercial +re-branding requires a dual-license — see licensing.md.