Skip to content

Commit 4f6bae0

Browse files
committed
Add Phase 3 sys_secret crypto & audit
Introduce Phase 3 secret splitting and audit plumbing for SettingsService. - Add InMemoryCryptoProvider: an AES-256-GCM in-memory ICryptoProvider used as a default KMS shim for tests/dev (not suitable for production). Supports encrypt/decrypt, rotateKey, and digest with AAD binding to (namespace,key). - Extend manifest to include SysSecret and SysSettingAudit objects. - Wire optional cryptoProvider, secretStore and auditWriter through SettingsServicePlugin and SettingsService.bindEngine so encrypted values can be persisted to a sys_secret row while sys_setting.value_enc stores only the handle id. - Implement secretStore and auditWriter helpers in the plugin for engine-backed persistence and append-only audit writes (failures logged but do not abort setting writes). - Update SettingsService to: route encrypted writes through the wired ICryptoProvider + secretStore (fallback to legacy inline crypto adapter when not provided), compute digests via provider when used, dereference sys_secret on reads when handle ids (sec_...) are present, and emit setting-audit entries via auditWriter. - Add SettingsService types: SettingsSecretStore and SettingsAuditWriter interfaces. - Add unit tests validating sys_secret routing, AAD binding, and rotateKey behavior. This change enables storing ciphertexts in a dedicated sys_secret object, provides a pluggable KMS interface, and records per-setting audit events while preserving backward compatibility with the existing inline crypto adapter path.
1 parent b2d0c0e commit 4f6bae0

6 files changed

Lines changed: 405 additions & 10 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type {
4+
CryptoContext,
5+
CryptoHandle,
6+
ICryptoProvider,
7+
} from '@objectstack/spec/contracts';
8+
import { createHash, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
9+
10+
/**
11+
* InMemoryCryptoProvider — default ICryptoProvider used by the
12+
* SettingsService when the host application does not wire a real KMS.
13+
*
14+
* Encryption: AES-256-GCM with a per-process random data key. The data
15+
* key lives only in memory; restarting the process loses the ability
16+
* to decrypt previously-written rows. This is intentional — operators
17+
* MUST replace this with a KMS-backed provider before relying on
18+
* `sys_secret` for production secrets. The provider's purpose is to:
19+
*
20+
* - exercise the round-trip in unit tests and dev kernels;
21+
* - provide a "real-looking" handle format so consumers don't depend
22+
* on accidental implementation details of a no-op adapter;
23+
* - serve as a reference for what AwsKmsCryptoProvider /
24+
* GcpKmsCryptoProvider implementations need to satisfy.
25+
*
26+
* Handle format:
27+
* id — `sec_` + 32 hex chars (122 bits of entropy)
28+
* kmsKeyId — `local:in-memory:v<version>`
29+
* alg — `aes-256-gcm`
30+
* version — bumps on rotateKey()
31+
* ciphertext— base64(iv (12) || authTag (16) || cipher)
32+
*
33+
* AAD binding: the CryptoContext (namespace + key + tenantId) is
34+
* folded into AES-GCM AAD so a ciphertext rewrapped from a different
35+
* (ns, key) tuple fails decryption — guards against operators
36+
* accidentally copying rows between namespaces.
37+
*/
38+
export class InMemoryCryptoProvider implements ICryptoProvider {
39+
private readonly key: Buffer;
40+
41+
constructor(opts: { key?: Buffer } = {}) {
42+
this.key = opts.key ?? randomBytes(32);
43+
}
44+
45+
async encrypt(plain: string, ctx: CryptoContext): Promise<CryptoHandle> {
46+
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');
52+
return {
53+
id: 'sec_' + randomBytes(16).toString('hex'),
54+
kmsKeyId: 'local:in-memory:v1',
55+
alg: 'aes-256-gcm',
56+
version: 1,
57+
ciphertext: blob,
58+
};
59+
}
60+
61+
async decrypt(handle: CryptoHandle, ctx: CryptoContext): Promise<string> {
62+
const buf = Buffer.from(handle.ciphertext, 'base64');
63+
const iv = buf.subarray(0, 12);
64+
const tag = buf.subarray(12, 28);
65+
const data = buf.subarray(28);
66+
const decipher = createDecipheriv('aes-256-gcm', this.key, iv);
67+
decipher.setAAD(Buffer.from(this.aadOf(ctx), 'utf8'));
68+
decipher.setAuthTag(tag);
69+
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
70+
}
71+
72+
async rotateKey(handle: CryptoHandle, ctx: CryptoContext): Promise<CryptoHandle> {
73+
const plain = await this.decrypt(handle, ctx);
74+
const next = await this.encrypt(plain, ctx);
75+
return {
76+
...next,
77+
id: handle.id,
78+
kmsKeyId: `local:in-memory:v${handle.version + 1}`,
79+
version: handle.version + 1,
80+
};
81+
}
82+
83+
digest(plain: string): string {
84+
return 'sha256:' + createHash('sha256').update(plain, 'utf8').digest('hex');
85+
}
86+
87+
private aadOf(ctx: CryptoContext): string {
88+
// Bind ciphertext to (namespace,key) so a row cannot be moved across
89+
// specifiers. Tenant binding is intentionally omitted because the
90+
// handle is dereferenced from a `sys_setting` row already scoped to
91+
// its tenant — adding tenant here would force the decrypt path to
92+
// re-read that scope.
93+
return [ctx.namespace, ctx.key].join('|');
94+
}
95+
}

packages/services/service-settings/src/manifest.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { SysSetting } from '@objectstack/platform-objects/system';
3+
import { SysSetting, SysSecret, SysSettingAudit } from '@objectstack/platform-objects/system';
44

55
export const SETTINGS_PLUGIN_ID = 'com.objectstack.service.settings';
66
export const SETTINGS_PLUGIN_VERSION = '0.1.0';
77

88
/** Objects owned by service-settings. Currently just the K/V store. */
9-
export const settingsObjects: any[] = [SysSetting];
9+
export const settingsObjects: any[] = [SysSetting, SysSecret, SysSettingAudit];
1010

1111
/** Manifest header shared by compile-time config and runtime registration. */
1212
export const settingsPluginManifestHeader = {

packages/services/service-settings/src/settings-service-plugin.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import type { Plugin, PluginContext } from '@objectstack/core';
44
import type { IHttpServer, IDataEngine } from '@objectstack/spec/contracts';
55
import type { SettingsManifest } from '@objectstack/spec/system';
66
import { SettingsService } from './settings-service.js';
7-
import type { SettingsAuditSink, SettingsEngine } from './settings-service.types.js';
7+
import type { ICryptoProvider } from '@objectstack/spec/contracts';
8+
import type { SettingsAuditSink, SettingsAuditWriter, SettingsEngine, SettingsSecretStore } from './settings-service.types.js';
89
import type { CryptoAdapter } from './crypto-adapter.js';
10+
import { InMemoryCryptoProvider } from './in-memory-crypto-provider.js';
911
import { registerSettingsRoutes } from './settings-routes.js';
1012
import {
1113
settingsObjects,
@@ -30,6 +32,15 @@ export interface SettingsServicePluginOptions {
3032
manifests?: SettingsManifest[];
3133
/** Override the default crypto adapter. */
3234
crypto?: CryptoAdapter;
35+
36+
/**
37+
* Phase 3 KMS hook. When provided, encrypted specifier values are
38+
* routed through this provider into `sys_secret`; `sys_setting.value_enc`
39+
* holds the handle id only. Defaults to `InMemoryCryptoProvider`
40+
* (NOT suitable for production secrets — replace with an AWS / GCP
41+
* KMS-backed implementation).
42+
*/
43+
cryptoProvider?: ICryptoProvider;
3344
/** Override the default base path (`/api/settings`). */
3445
basePath?: string;
3546
/** Disable REST route registration. */
@@ -147,6 +158,11 @@ export class SettingsServicePlugin implements Plugin {
147158
this.service!.bindEngine(
148159
engine as unknown as SettingsEngine,
149160
this.buildAuditSink(ctx, engine),
161+
{
162+
secretStore: this.buildSecretStore(engine),
163+
auditWriter: this.buildAuditWriter(ctx, engine),
164+
cryptoProvider: this.opts.cryptoProvider ?? new InMemoryCryptoProvider(),
165+
},
150166
);
151167
}
152168

@@ -198,4 +214,67 @@ export class SettingsServicePlugin implements Plugin {
198214
},
199215
};
200216
}
217+
218+
/**
219+
* Phase 3: build a `sys_secret`-backed implementation of
220+
* `SettingsSecretStore`. The store bypasses the tenant audit
221+
* warning because secrets are scoped through their owning
222+
* `sys_setting` row (which already carries the tenant context).
223+
*/
224+
private buildSecretStore(engine: IDataEngine): SettingsSecretStore {
225+
const eng: any = engine;
226+
return {
227+
async insert(row) {
228+
await eng.insert('sys_secret', row, { bypassTenantAudit: true });
229+
return { id: row.id };
230+
},
231+
async get(id) {
232+
const rows = await eng.find('sys_secret', {
233+
where: { id },
234+
limit: 1,
235+
bypassTenantAudit: true,
236+
});
237+
const row = Array.isArray(rows) ? rows[0] : rows?.data?.[0];
238+
return row ?? null;
239+
},
240+
async update(id, patch) {
241+
await eng.update('sys_secret', {
242+
where: { id },
243+
data: patch,
244+
bypassTenantAudit: true,
245+
});
246+
},
247+
};
248+
}
249+
250+
/**
251+
* Phase 3: append-only writer for `sys_setting_audit`. Failures here
252+
* MUST NOT abort the settings write, so all calls are wrapped in a
253+
* try/catch and reported through the plugin logger.
254+
*/
255+
private buildAuditWriter(ctx: PluginContext, engine: IDataEngine): SettingsAuditWriter {
256+
const eng: any = engine;
257+
return {
258+
write: async (entry) => {
259+
try {
260+
await eng.insert('sys_setting_audit', {
261+
namespace: entry.namespace,
262+
key: entry.key,
263+
scope: entry.scope,
264+
action: entry.action,
265+
source: entry.source ?? 'api',
266+
actor_id: entry.actorId ?? null,
267+
old_hash: entry.oldHash ?? null,
268+
new_hash: entry.newHash ?? null,
269+
encrypted: !!entry.encrypted,
270+
request_id: entry.requestId ?? null,
271+
reason: entry.reason ?? null,
272+
created_at: new Date().toISOString(),
273+
}, { bypassTenantAudit: true });
274+
} catch (err: any) {
275+
ctx.logger?.warn?.('SettingsServicePlugin: setting-audit write failed: ' + (err?.message ?? err));
276+
}
277+
},
278+
};
279+
}
201280
}

packages/services/service-settings/src/settings-service.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,3 +356,70 @@ describe('SettingsService — Phase 2 cascade chain + lock', () => {
356356
});
357357
});
358358
});
359+
360+
describe('SettingsService — Phase 3 sys_secret + crypto provider + audit', () => {
361+
it('routes encrypted writes through sys_secret when wired', async () => {
362+
const { InMemoryCryptoProvider } = await import('./in-memory-crypto-provider.js');
363+
const secretRows = new Map<string, any>();
364+
const auditRows: any[] = [];
365+
366+
const svc = new SettingsService({
367+
env: {},
368+
cryptoProvider: new InMemoryCryptoProvider(),
369+
secretStore: {
370+
async insert(row) { secretRows.set(row.id, row); return { id: row.id }; },
371+
async get(id) { return secretRows.get(id) ?? null; },
372+
async update(id, patch) { secretRows.set(id, { ...secretRows.get(id), ...patch }); },
373+
},
374+
auditWriter: { write: (e) => { auditRows.push(e); } },
375+
});
376+
svc.registerManifest(mailSettingsManifest);
377+
378+
await svc.set('mail', 'api_key', 'super-secret-key', { tenantId: 't1' });
379+
380+
// sys_secret got the cipher; sys_setting only holds the handle id.
381+
expect(secretRows.size).toBe(1);
382+
const [secret] = [...secretRows.values()];
383+
expect(secret.namespace).toBe('mail');
384+
expect(secret.key).toBe('api_key');
385+
expect(secret.alg).toBe('aes-256-gcm');
386+
expect(secret.ciphertext).not.toContain('super-secret-key');
387+
388+
// Round-trip read returns the plaintext.
389+
const r = await svc.get<string>('mail', 'api_key', { tenantId: 't1' });
390+
expect(r.value).toBe('super-secret-key');
391+
392+
// Audit writer received the set event with a non-leaking digest.
393+
expect(auditRows).toHaveLength(1);
394+
expect(auditRows[0]).toMatchObject({
395+
namespace: 'mail',
396+
key: 'api_key',
397+
action: 'set',
398+
encrypted: true,
399+
});
400+
expect(auditRows[0].newHash).toMatch(/^sha256:/);
401+
expect(auditRows[0].newHash).not.toContain('super-secret-key');
402+
});
403+
404+
it('AAD binding rejects ciphertexts swapped across (namespace,key)', async () => {
405+
const { InMemoryCryptoProvider } = await import('./in-memory-crypto-provider.js');
406+
const provider = new InMemoryCryptoProvider();
407+
const handle = await provider.encrypt('value', { namespace: 'mail', key: 'api_key' });
408+
// Same handle, wrong context → must throw.
409+
await expect(
410+
provider.decrypt(handle, { namespace: 'mail', key: 'smtp_password' }),
411+
).rejects.toThrow();
412+
});
413+
414+
it('rotateKey bumps version while preserving plaintext + handle id', async () => {
415+
const { InMemoryCryptoProvider } = await import('./in-memory-crypto-provider.js');
416+
const provider = new InMemoryCryptoProvider();
417+
const ctx = { namespace: 'mail', key: 'api_key' };
418+
const h1 = await provider.encrypt('hello', ctx);
419+
const h2 = await provider.rotateKey(h1, ctx);
420+
expect(h2.id).toBe(h1.id);
421+
expect(h2.version).toBe(h1.version + 1);
422+
expect(h2.ciphertext).not.toBe(h1.ciphertext);
423+
expect(await provider.decrypt(h2, ctx)).toBe('hello');
424+
});
425+
});

0 commit comments

Comments
 (0)