Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/app-owned-storage-composition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@nestm/crypto": minor
---

Remove the storage-specific `@nestm/crypto/storage-workspace` integration and
its optional `@nestm/storage` peer. Applications now compose storage policy,
artifact record formats, authenticated path context, and searchable projections
from the generic primitives exposed by `@nestm/crypto/core`.
130 changes: 15 additions & 115 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

Authenticated, versioned encryption for Node.js and NestJS 12. The package provides an
AES-256-GCM core, envelope key providers, explicit field traversal, cloud KMS adapters, a
fail-closed bridge to `@nestm/tenant`, an optional protected-workspace bridge to `@nestm/storage`,
and opt-in HTTP and Prisma field adapters.
fail-closed bridge to `@nestm/tenant`, and opt-in HTTP and Prisma field adapters.

> This package is an alpha. Pin an exact version, retain every key needed by stored ciphertext, and
> test rotation and recovery before using it for production data.
Expand All @@ -25,9 +24,6 @@ pnpm add @nestjs/common@next @nestjs/core@next reflect-metadata rxjs
# Optional tenant bridge
pnpm add @nestm/tenant

# Optional protected StorageWorkspace bridge (pin prerelease integrations exactly)
pnpm add @nestm/storage@0.1.0-alpha.9

# Optional HTTP DTO adapter
pnpm add class-transformer class-validator

Expand All @@ -37,7 +33,7 @@ pnpm add @google-cloud/kms
pnpm add @azure/core-auth @azure/keyvault-keys
```

Cloud SDKs, NestJS, `@nestm/storage`, `@nestm/tenant`, and `class-transformer` are optional peers.
Cloud SDKs, NestJS, `@nestm/tenant`, and `class-transformer` are optional peers.
`class-validator` is application-owned and needed only when the app uses Nest's `ValidationPipe`
validation. The Prisma adapter is schema-agnostic and uses the consuming application's Prisma client,
so it adds no Prisma dependency. A consumer that imports only `@nestm/crypto/core` installs none of
Expand Down Expand Up @@ -183,101 +179,6 @@ An authenticated full frame may be emitted before a later frame or EOF fails. A
that authenticated prefix and abort on failure, but agents, previews, archive/skill validation, and
other side-effecting consumers must stage all plaintext until `verification` resolves.

## Protected storage workspaces

`@nestm/crypto/storage-workspace` composes an already-authorized `StorageWorkspace` from
`@nestm/storage/workspace` with an application-supplied authenticated cipher. The adapter imports
both libraries only from this optional subpath: `@nestm/crypto/core` and the root entry point remain
independent of storage, while `@nestm/storage` remains independent of key providers and crypto
policy.

The built-in `createCipherEngineWorkspaceCipher()` adapter uses `CipherEngine` and the bounded
`nmc1` format. It strictly encrypts the logical metadata record and body independently, and it rejects
plaintext, malformed records, wrong scope/policy/path context, and unauthenticated envelopes on read.
The backing workspace stores one versioned outer JSON record whose `metadata` and `content` members
are `nmc1` envelopes; it never receives the original body or logical content type.

```ts
import type { StorageWorkspace } from "@nestm/storage/workspace";
import type { CipherEngine } from "@nestm/crypto/core";
import {
createCipherEngineWorkspaceCipher,
protectStorageWorkspace,
} from "@nestm/crypto/storage-workspace";

declare const mountedWorkspace: StorageWorkspace;
declare const engine: CipherEngine;
declare const canonicalOrganizationId: string;
declare const canonicalWorkspaceId: string;

const logicalLimits = {
...mountedWorkspace.limits,
maxReadBytes: 1024 * 1024,
maxWriteBytes: 1024 * 1024,
};
const protectedWorkspace = protectStorageWorkspace({
storage: mountedWorkspace,
cipher: createCipherEngineWorkspaceCipher(engine, {
allowedProviders: ["workspace-current", "workspace-previous"],
}),
scopeContext: `organization:${canonicalOrganizationId}/workspace:${canonicalWorkspaceId}`,
policyRevision: "artifact-body:v1",
limits: logicalLimits,
// This is a separate ceiling for the expanded outer record held by the backing workspace.
maxCiphertextBytes: 2 * 1024 * 1024,
// Explicitly accepts that the backing provider can search visible object paths.
pathSearch: "provider-visible",
});

await protectedWorkspace.writeFile("reports/result.md", "# Verified\n", {
mode: "create",
contentType: "text/markdown; charset=utf-8",
});
const restored = await protectedWorkspace.readText("reports/result.md");
```

On the initial protected view, the path, page, search, cursor, and cursor-TTL limits must match the
already-mounted backing workspace because that capability owns their enforcement. Logical
`maxReadBytes` and `maxWriteBytes` may be lower. A derived `mount()` creates a correspondingly narrowed
backing capability, so every child limit can be reduced safely. Configure `CipherEngine.maxPayloadBytes`
to cover the larger logical read/write ceiling and size `maxCiphertextBytes` for `nmc1` plus outer-record
expansion; a plaintext value within its logical limit can still exceed an undersized physical ceiling.

`scopeContext` and `policyRevision` are authenticated domain inputs. Derive the scope from trusted,
canonical application context and keep both values reproducible for the lifetime of stored objects.
Do not change `policyRevision` merely because the active wrapping key rotates; use an explicit
read/migrate policy when changing the authenticated policy domain. The cipher adapter does not take
ownership of `CipherEngine`, so the application that created the engine must close it.

`AuthenticatedWorkspaceCipher` is a trusted composition contract for alternative implementations.
Implementations must remain bounded, authenticate both supplied contexts, honor cancellation, and not
retain borrowed plaintext or context buffers after a call settles. The built-in adapter supplies those
properties through `CipherEngine`; registered cipher algorithms and key providers remain part of the
application's crypto trust boundary.

The canonical logical path and record purpose are also bound automatically, so copying a raw backing
record to another path does not produce valid plaintext there. Protected `copyFile()` and `moveFile()`
therefore fail with `NOT_SUPPORTED`. An authorized application workflow must read/authenticate the
source and write a newly encrypted destination; a move may then conditionally delete the source, with
the same multi-object transactional caveats as any application-managed move.

The bridge deliberately does not hide all storage metadata. Object paths, directory shape, existence,
ciphertext length, provider ETag/timestamps, and access patterns remain visible to the backing store.
Path search is disabled unless `pathSearch: "provider-visible"` explicitly accepts that leakage.
Applications such as artifact catalogs should keep only deliberately searchable fields—safe IDs,
titles, descriptions, status, and other chosen projections—in their own authorized catalog and store
the sensitive artifact body through this protected workspace. The bridge does not encrypt arbitrary
database columns or decide which catalog fields are safe.

Protected list, stat, and enabled search operations authenticate file metadata before returning it.
Synthetic directories, provider-level existence, cursors, and access patterns remain storage signals,
and the file body is authenticated only by a protected read.

`protectedWorkspace.protection` exposes a frozen, non-secret integration descriptor containing the
outer format/version, `nmc1` envelope, path binding, metadata/body protection, path-search mode, and
policy revision. It describes the configured boundary; it does not reveal provider keys, storage
coordinates, plaintext, or ciphertext.

## Operations

Both `CipherService` and `CipherEngine` provide:
Expand Down Expand Up @@ -750,20 +651,19 @@ plaintext with `@nestm/crypto`; relabeling or importing the old ciphertext is no

## Entry points

| Entry point | Purpose |
| --------------------------------- | ----------------------------------------------------------------------------- |
| `@nestm/crypto/core` | AES-256-GCM engine, envelope codec, local AES KEK ring, contracts, and errors |
| `@nestm/crypto/files` | NMF1 bounded-memory streaming file encryption with detached wrapped keys |
| `@nestm/crypto/storage-workspace` | strict authenticated body/metadata protection for `StorageWorkspace` |
| `@nestm/crypto/fields` | purpose-decorated class traversal |
| `@nestm/crypto/tenant` | tenant-bound cipher and field services |
| `@nestm/crypto/http` | request-encryption pipe and opt-in response-decryption interceptor |
| `@nestm/crypto/prisma` | schema-agnostic tenant Prisma write processor |
| `@nestm/crypto/key-wrap/rsa` | RSA-OAEP-SHA256 wrapping with named public/private keys |
| `@nestm/crypto/kms/aws` | AWS KMS data-key generation and decrypt |
| `@nestm/crypto/kms/gcp` | Google Cloud KMS encrypt/decrypt with AAD and CRC32C validation |
| `@nestm/crypto/kms/azure` | Azure Key Vault/Managed HSM wrap/unwrap |
| `@nestm/crypto/testing` | dependency-free deterministic nonce source for tests |
| Entry point | Purpose |
| ---------------------------- | ----------------------------------------------------------------------------- |
| `@nestm/crypto/core` | AES-256-GCM engine, envelope codec, local AES KEK ring, contracts, and errors |
| `@nestm/crypto/files` | NMF1 bounded-memory streaming file encryption with detached wrapped keys |
| `@nestm/crypto/fields` | purpose-decorated class traversal |
| `@nestm/crypto/tenant` | tenant-bound cipher and field services |
| `@nestm/crypto/http` | request-encryption pipe and opt-in response-decryption interceptor |
| `@nestm/crypto/prisma` | schema-agnostic tenant Prisma write processor |
| `@nestm/crypto/key-wrap/rsa` | RSA-OAEP-SHA256 wrapping with named public/private keys |
| `@nestm/crypto/kms/aws` | AWS KMS data-key generation and decrypt |
| `@nestm/crypto/kms/gcp` | Google Cloud KMS encrypt/decrypt with AAD and CRC32C validation |
| `@nestm/crypto/kms/azure` | Azure Key Vault/Managed HSM wrap/unwrap |
| `@nestm/crypto/testing` | dependency-free deterministic nonce source for tests |

Adapters accept caller-owned SDK clients as well as configuration-created clients. Caller-owned clients
are never closed; owned clients are closed or released where the SDK exposes that lifecycle. Each cloud
Expand Down
47 changes: 0 additions & 47 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,53 +78,6 @@ Local AES and RSA providers shift key custody into the application. Load their s
secret source, keep active wrapping keys separate from legacy decrypt-only keys, and retain old keys
until all dependent ciphertext has been re-encrypted and verified.

## Protected-storage-workspace boundary

`@nestm/crypto/storage-workspace` is an optional composition layer over an already-mounted,
application-authorized `StorageWorkspace`. It does not select a provider, bucket, filesystem root,
tenant, mount prefix, or key route. The application remains responsible for constructing the storage
capability and authenticated cipher independently and for deriving `scopeContext` and
`policyRevision` from stable, trusted policy state.

The bridge stores one strict, versioned outer JSON record. Logical metadata and file content are
separate `nmc1` envelopes with distinct authenticated purposes. Their AAD binds the workspace scope,
policy revision, canonical relative path, record version, and purpose. Plaintext records, unknown
members or versions, malformed envelopes, swapped metadata/content envelopes, and a raw record copied
to another path fail closed during protected reads. Protected copy and move are intentionally denied.
An authorized application must read/authenticate the source, write/re-encrypt the destination, and—if
moving—conditionally delete the source while accounting for the lack of a cross-object transaction.
Raw backing-store mutations bypass the bridge and can cause an availability failure.

This boundary encrypts the logical body and its protected metadata record, not every storage signal.
The backing store still observes physical keys, directory shape, object existence, ciphertext sizes,
provider ETags/timestamps, operation timing, and access patterns. Provider-visible path search is an
explicit opt-in; keep it disabled when path disclosure or equality is unacceptable. Applications that
need searchable titles, descriptions, statuses, or other projections should maintain a separate,
authorized catalog and choose those plaintext fields deliberately. This package neither encrypts
arbitrary catalog/database fields nor decides which projections are safe to expose.

File metadata returned by protected list, stat, and search operations is authenticated. Synthetic
directories, provider-level existence, and continuation behavior still describe backing-store state,
and none of those operations proves that a body authenticates; body authentication occurs on protected
read. The read-only protection descriptor reports the configured bridge contract but is not a
remote-attestation mechanism and must not be used as authorization evidence.

The `CipherEngine` workspace adapter is buffered. Configure independent logical plaintext and outer
ciphertext ceilings, ensure the backing workspace can hold the expanded record, and propagate abort
signals and deadlines. Use NMF1 `@nestm/crypto/files` behind a purpose-built integration when immutable
multi-gigabyte streaming objects are required; do not weaken the bounded `nmc1` bridge with an
application-side plaintext fallback. As with the core envelope, deletion and same-path rollback to an
older valid record are not prevented; applications needing freshness must enforce it in an
authenticated catalog or version policy.

At initial composition, non-buffer limits must match the already-mounted backing workspace; only the
logical read/write limits may be lower. Derived mounts create narrowed backing capabilities and may
reduce every limit. The injected `AuthenticatedWorkspaceCipher` is trusted to enforce its own bounds,
honor the operation signal, and stop using borrowed buffers when its promise settles. Prefer the built-in
`CipherEngine` adapter unless an alternative has equivalent conformance coverage. Short invalid paths
are byte-bounded before crypto but receive their complete canonical path validation from the backing
workspace, so a rejected create/overwrite may perform cryptographic work without persisting an object.

## Tenant boundary

`@nestm/crypto/tenant` derives the canonical tenant only from `TENANT_CONTEXT_READER`. Its ordinary
Expand Down
10 changes: 0 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,6 @@
"import": "./dist/files/index.mjs",
"default": "./dist/files/index.mjs"
},
"./storage-workspace": {
"types": "./dist/storage-workspace/index.d.mts",
"import": "./dist/storage-workspace/index.mjs",
"default": "./dist/storage-workspace/index.mjs"
},
"./fields": {
"types": "./dist/fields/index.d.mts",
"import": "./dist/fields/index.mjs",
Expand Down Expand Up @@ -149,7 +144,6 @@
"@azure/core-auth": ">=1.11.0 <2",
"@azure/keyvault-keys": ">=4.10.2 <5",
"@google-cloud/kms": ">=5.7.0 <6",
"@nestm/storage": ">=0.1.0-alpha.9 <0.2.0",
"@nestm/tenant": ">=0.1.0-alpha.3 <0.2.0",
"@nestjs/common": "^12.0.0-alpha.5",
"@nestjs/core": "^12.0.0-alpha.5",
Expand All @@ -170,9 +164,6 @@
"@google-cloud/kms": {
"optional": true
},
"@nestm/storage": {
"optional": true
},
"@nestm/tenant": {
"optional": true
},
Expand All @@ -199,7 +190,6 @@
"@azure/keyvault-keys": "4.10.2",
"@changesets/cli": "2.31.1",
"@google-cloud/kms": "5.7.0",
"@nestm/storage": "0.1.0-alpha.9",
"@nestm/tenant": "0.1.0-alpha.5",
"@nestjs/common": "12.0.0-alpha.5",
"@nestjs/core": "12.0.0-alpha.5",
Expand Down
Loading
Loading