Skip to content

Commit 97efe3b

Browse files
committed
fix(service-settings): WebContainer AES-GCM support with @noble/ciphers
1 parent 6549382 commit 97efe3b

4 files changed

Lines changed: 101 additions & 6 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/service-settings': minor
3+
---
4+
5+
`InMemoryCryptoProvider` now auto-detects WebContainer (StackBlitz) and swaps `node:crypto`'s AES-256-GCM for a pure-JS implementation from `@noble/ciphers/aes.js`.
6+
7+
**Why:** WebContainer's `node:crypto` ships `createCipheriv`/`createDecipheriv` stubs that throw `TypeError: y.run is not a function` when called with `'aes-256-gcm'`. Any code path that persists an encrypted setting through `sys_secret` would crash on StackBlitz.
8+
9+
**How it works:**
10+
- Detection: `process.versions.webcontainer` / `SHELL=jsh` / `STACKBLITZ` env.
11+
- The ciphertext layout `iv(12) || tag(16) || cipher` is preserved, so handles written on one runtime decrypt cleanly on the other.
12+
- AAD binding (`namespace|key`) and `digest()` are unchanged.
13+
- In non-WebContainer runtimes the code path is identical to before.
14+
15+
If `@noble/ciphers` cannot be loaded for any reason, the provider falls back to `node:crypto` and lets it throw, surfacing the misconfiguration clearly.

packages/services/service-settings/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"test": "vitest run"
1919
},
2020
"dependencies": {
21+
"@noble/ciphers": "^2.2.0",
2122
"@objectstack/core": "workspace:*",
2223
"@objectstack/platform-objects": "workspace:*",
2324
"@objectstack/spec": "workspace:*"

packages/services/service-settings/src/in-memory-crypto-provider.ts

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,21 +34,78 @@ import { createHash, randomBytes, createCipheriv, createDecipheriv } from 'node:
3434
* folded into AES-GCM AAD so a ciphertext rewrapped from a different
3535
* (ns, key) tuple fails decryption — guards against operators
3636
* accidentally copying rows between namespaces.
37+
*
38+
* WebContainer (StackBlitz) note: `node:crypto.createCipheriv('aes-256-gcm', …)`
39+
* is not implemented in WebContainer. When we detect that runtime, we
40+
* swap to a pure-JS AES-GCM from `@noble/ciphers/aes.js`, producing the
41+
* same `iv || tag || ciphertext` byte layout so the handle shape is
42+
* unchanged. The swap is best-effort: if the dependency is missing,
43+
* we fall back to the Node implementation and let it throw, surfacing
44+
* the configuration problem clearly.
3745
*/
46+
const isWebContainerRuntime = (): boolean => {
47+
const g = globalThis as any;
48+
return (
49+
typeof g !== 'undefined' &&
50+
(Boolean(g.process?.versions?.webcontainer) ||
51+
Boolean(g.process?.env?.SHELL?.includes?.('jsh')) ||
52+
Boolean(g.process?.env?.STACKBLITZ))
53+
);
54+
};
55+
56+
type GcmFactory = (key: Uint8Array, nonce: Uint8Array, aad?: Uint8Array) => {
57+
encrypt: (plain: Uint8Array) => Uint8Array;
58+
decrypt: (cipher: Uint8Array) => Uint8Array;
59+
};
60+
61+
let nobleGcmPromise: Promise<GcmFactory | undefined> | undefined;
62+
const loadNobleGcm = (): Promise<GcmFactory | undefined> => {
63+
if (!nobleGcmPromise) {
64+
nobleGcmPromise = (async () => {
65+
try {
66+
const mod = await import('@noble/ciphers/aes.js');
67+
return mod.gcm as unknown as GcmFactory;
68+
} catch (err: any) {
69+
console.warn(
70+
`[InMemoryCryptoProvider] WebContainer detected but @noble/ciphers not installed: ${err?.message ?? err}. Falling back to node:crypto (will throw).`,
71+
);
72+
return undefined;
73+
}
74+
})();
75+
}
76+
return nobleGcmPromise;
77+
};
78+
3879
export class InMemoryCryptoProvider implements ICryptoProvider {
3980
private readonly key: Buffer;
81+
private readonly useNoble: boolean;
4082

4183
constructor(opts: { key?: Buffer } = {}) {
4284
this.key = opts.key ?? randomBytes(32);
85+
this.useNoble = isWebContainerRuntime();
4386
}
4487

4588
async encrypt(plain: string, ctx: CryptoContext): Promise<CryptoHandle> {
4689
const iv = randomBytes(12);
47-
const cipher = createCipheriv('aes-256-gcm', this.key, iv);
48-
cipher.setAAD(Buffer.from(this.aadOf(ctx), 'utf8'));
49-
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
50-
const tag = cipher.getAuthTag();
51-
const blob = Buffer.concat([iv, tag, enc]).toString('base64');
90+
const aad = Buffer.from(this.aadOf(ctx), 'utf8');
91+
const plainBytes = Buffer.from(plain, 'utf8');
92+
93+
let blob: string;
94+
if (this.useNoble) {
95+
const gcm = await loadNobleGcm();
96+
if (gcm) {
97+
const cipher = gcm(this.key, iv, aad);
98+
const ctWithTag = cipher.encrypt(plainBytes); // ciphertext || tag(16)
99+
const ct = ctWithTag.subarray(0, ctWithTag.length - 16);
100+
const tag = ctWithTag.subarray(ctWithTag.length - 16);
101+
blob = Buffer.concat([iv, Buffer.from(tag), Buffer.from(ct)]).toString('base64');
102+
} else {
103+
blob = this.encryptNode(plainBytes, iv, aad);
104+
}
105+
} else {
106+
blob = this.encryptNode(plainBytes, iv, aad);
107+
}
108+
52109
return {
53110
id: 'sec_' + randomBytes(16).toString('hex'),
54111
kmsKeyId: 'local:in-memory:v1',
@@ -63,8 +120,19 @@ export class InMemoryCryptoProvider implements ICryptoProvider {
63120
const iv = buf.subarray(0, 12);
64121
const tag = buf.subarray(12, 28);
65122
const data = buf.subarray(28);
123+
const aad = Buffer.from(this.aadOf(ctx), 'utf8');
124+
125+
if (this.useNoble) {
126+
const gcm = await loadNobleGcm();
127+
if (gcm) {
128+
const cipher = gcm(this.key, iv, aad);
129+
const ctWithTag = Buffer.concat([data, tag]); // noble expects ciphertext || tag
130+
const out = cipher.decrypt(ctWithTag);
131+
return Buffer.from(out).toString('utf8');
132+
}
133+
}
66134
const decipher = createDecipheriv('aes-256-gcm', this.key, iv);
67-
decipher.setAAD(Buffer.from(this.aadOf(ctx), 'utf8'));
135+
decipher.setAAD(aad);
68136
decipher.setAuthTag(tag);
69137
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
70138
}
@@ -84,6 +152,14 @@ export class InMemoryCryptoProvider implements ICryptoProvider {
84152
return 'sha256:' + createHash('sha256').update(plain, 'utf8').digest('hex');
85153
}
86154

155+
private encryptNode(plainBytes: Buffer, iv: Buffer, aad: Buffer): string {
156+
const cipher = createCipheriv('aes-256-gcm', this.key, iv);
157+
cipher.setAAD(aad);
158+
const enc = Buffer.concat([cipher.update(plainBytes), cipher.final()]);
159+
const tag = cipher.getAuthTag();
160+
return Buffer.concat([iv, tag, enc]).toString('base64');
161+
}
162+
87163
private aadOf(ctx: CryptoContext): string {
88164
// Bind ciphertext to (namespace,key) so a row cannot be moved across
89165
// specifiers. Tenant binding is intentionally omitted because the

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)