|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The fingerprint the settings audit trail records for a write. |
| 5 | + * |
| 6 | + * Contract (`ICryptoProvider` in `@objectstack/spec/contracts`): a |
| 7 | + * secret-valued setting (an `encryptedKeys` member) is recorded on BOTH |
| 8 | + * ledgers — the generic `sys_audit_log` row (`SettingsAuditSink.valueDigest`) |
| 9 | + * and the per-key `sys_setting_audit` row (`SettingsAuditWriter.newHash`) — |
| 10 | + * with the provider's KEYED digest, never an unkeyed one; with no keyed digest |
| 11 | + * available it records no fingerprint at all. Non-secret settings keep the |
| 12 | + * adapter's unkeyed `digest` of the canonical JSON, unchanged. |
| 13 | + */ |
| 14 | + |
| 15 | +import { createHash, randomBytes } from 'node:crypto'; |
| 16 | +import { describe, expect, it, vi } from 'vitest'; |
| 17 | +import type { ICryptoProvider } from '@objectstack/spec/contracts'; |
| 18 | +import { SettingsService } from './settings-service.js'; |
| 19 | +import type { CryptoAdapter } from './crypto-adapter.js'; |
| 20 | +import { NoopCryptoAdapter } from './crypto-adapter.js'; |
| 21 | +import { LocalCryptoProvider } from './local-crypto-provider.js'; |
| 22 | +import { mailSettingsManifest } from './manifests/mail.manifest.js'; |
| 23 | +import type { SettingsSecretStore } from './settings-service.types.js'; |
| 24 | + |
| 25 | +const KEYED_SHAPE = /^hmac-sha256:[0-9a-f]{64}$/; |
| 26 | +const sha256 = (s: string) => 'sha256:' + createHash('sha256').update(s, 'utf8').digest('hex'); |
| 27 | + |
| 28 | +function memorySecretStore(): SettingsSecretStore { |
| 29 | + const rows = new Map<string, any>(); |
| 30 | + return { |
| 31 | + async insert(row) { rows.set(row.id, row); return { id: row.id }; }, |
| 32 | + async get(id) { return rows.get(id) ?? null; }, |
| 33 | + async update(id, patch) { rows.set(id, { ...rows.get(id), ...patch }); }, |
| 34 | + }; |
| 35 | +} |
| 36 | + |
| 37 | +/** A confidential stand-in for an injected KMS adapter (legacy inline path). */ |
| 38 | +class ConfidentialTestAdapter implements CryptoAdapter { |
| 39 | + readonly confidential = true; |
| 40 | + async encrypt(plaintext: string): Promise<string> { |
| 41 | + return 'kms:' + Buffer.from(plaintext, 'utf8').toString('base64'); |
| 42 | + } |
| 43 | + async decrypt(ciphertext: string): Promise<string> { |
| 44 | + return Buffer.from(ciphertext.replace(/^kms:/, ''), 'base64').toString('utf8'); |
| 45 | + } |
| 46 | + digest(plaintext: string): string { |
| 47 | + return sha256(plaintext); |
| 48 | + } |
| 49 | +} |
| 50 | + |
| 51 | +function boot(opts: { |
| 52 | + cryptoProvider?: ICryptoProvider; |
| 53 | + secretStore?: SettingsSecretStore; |
| 54 | + crypto?: CryptoAdapter; |
| 55 | + logger?: { error: (m: string) => void; warn?: (m: string) => void }; |
| 56 | +}) { |
| 57 | + const ledger: any[] = []; |
| 58 | + const settingAudit: any[] = []; |
| 59 | + const svc = new SettingsService({ |
| 60 | + env: {}, |
| 61 | + ...opts, |
| 62 | + audit: { record: (e) => { ledger.push(e); } }, |
| 63 | + auditWriter: { write: (e) => { settingAudit.push(e); } }, |
| 64 | + }); |
| 65 | + svc.registerManifest(mailSettingsManifest); |
| 66 | + const forKey = (k: string) => ({ |
| 67 | + ledger: ledger.filter((e) => e.key === k), |
| 68 | + settingAudit: settingAudit.filter((e) => e.key === k), |
| 69 | + }); |
| 70 | + return { svc, forKey }; |
| 71 | +} |
| 72 | + |
| 73 | +const writeSecret = (svc: SettingsService, value: string) => |
| 74 | + svc.setMany('mail', { provider: 'resend', api_key: value, from_email: 'ops@example.com' }); |
| 75 | + |
| 76 | +describe('settings audit trail — secret-valued settings', () => { |
| 77 | + it('records the provider keyed digest on both ledgers, not the unkeyed hash of the value', async () => { |
| 78 | + const provider = new LocalCryptoProvider({ key: randomBytes(32) }); |
| 79 | + const { svc, forKey } = boot({ cryptoProvider: provider, secretStore: memorySecretStore() }); |
| 80 | + |
| 81 | + await writeSecret(svc, 'pw-123456'); |
| 82 | + |
| 83 | + const { ledger, settingAudit } = forKey('api_key'); |
| 84 | + const keyed = await provider.keyedDigest('pw-123456'); |
| 85 | + expect(keyed).toMatch(KEYED_SHAPE); |
| 86 | + |
| 87 | + expect(settingAudit).toHaveLength(1); |
| 88 | + expect(settingAudit[0]).toMatchObject({ encrypted: true, newHash: keyed }); |
| 89 | + expect(settingAudit[0].newHash).not.toBe(provider.digest('pw-123456')); |
| 90 | + expect(settingAudit[0].newHash).not.toBe(sha256('pw-123456')); |
| 91 | + |
| 92 | + expect(ledger).toHaveLength(1); |
| 93 | + expect(ledger[0]).toMatchObject({ encrypted: true, valueDigest: '<encrypted:' + keyed + '>' }); |
| 94 | + expect(JSON.stringify([ledger, settingAudit])).not.toContain(sha256('pw-123456')); |
| 95 | + expect(JSON.stringify([ledger, settingAudit])).not.toContain('pw-123456'); |
| 96 | + }); |
| 97 | + |
| 98 | + it('is stable for equal values and distinct for different ones', async () => { |
| 99 | + const provider = new LocalCryptoProvider({ key: randomBytes(32) }); |
| 100 | + const { svc, forKey } = boot({ cryptoProvider: provider, secretStore: memorySecretStore() }); |
| 101 | + |
| 102 | + await writeSecret(svc, 'same-value'); |
| 103 | + await writeSecret(svc, 'other-value'); |
| 104 | + await writeSecret(svc, 'same-value'); |
| 105 | + |
| 106 | + const hashes = forKey('api_key').settingAudit.map((e) => e.newHash); |
| 107 | + expect(hashes).toHaveLength(3); |
| 108 | + expect(hashes[0]).toBe(hashes[2]); |
| 109 | + expect(hashes[1]).not.toBe(hashes[0]); |
| 110 | + const digests = forKey('api_key').ledger.map((e) => e.valueDigest); |
| 111 | + expect(digests[0]).toBe(digests[2]); |
| 112 | + expect(digests[1]).not.toBe(digests[0]); |
| 113 | + }); |
| 114 | + |
| 115 | + it('records no fingerprint on a reset', async () => { |
| 116 | + const provider = new LocalCryptoProvider({ key: randomBytes(32) }); |
| 117 | + const { svc, forKey } = boot({ cryptoProvider: provider, secretStore: memorySecretStore() }); |
| 118 | + |
| 119 | + await writeSecret(svc, 'pw-1'); |
| 120 | + await svc.set('mail', 'api_key', null); |
| 121 | + |
| 122 | + const reset = forKey('api_key').settingAudit[1]; |
| 123 | + expect(reset).toMatchObject({ action: 'reset', newHash: null }); |
| 124 | + }); |
| 125 | + |
| 126 | + it('legacy inline-adapter path with a provider wired records the keyed digest, not the adapter digest', async () => { |
| 127 | + const provider = new LocalCryptoProvider({ key: randomBytes(32) }); |
| 128 | + // No secret store: the write takes the inline `crypto.encrypt` branch. |
| 129 | + const { svc, forKey } = boot({ cryptoProvider: provider, crypto: new ConfidentialTestAdapter() }); |
| 130 | + |
| 131 | + await writeSecret(svc, 'pw-legacy'); |
| 132 | + |
| 133 | + const { ledger, settingAudit } = forKey('api_key'); |
| 134 | + const keyed = await provider.keyedDigest('pw-legacy'); |
| 135 | + expect(settingAudit[0].newHash).toBe(keyed); |
| 136 | + expect(ledger[0].valueDigest).toBe('<encrypted:' + keyed + '>'); |
| 137 | + expect(JSON.stringify([ledger, settingAudit])).not.toContain(sha256('pw-legacy')); |
| 138 | + }); |
| 139 | + |
| 140 | + it('legacy inline-adapter path with no provider records no fingerprint and reports it once', async () => { |
| 141 | + const warn = vi.fn(); |
| 142 | + const { svc, forKey } = boot({ crypto: new ConfidentialTestAdapter(), logger: { error: vi.fn(), warn } }); |
| 143 | + |
| 144 | + await writeSecret(svc, 'pw-a'); |
| 145 | + await writeSecret(svc, 'pw-b'); |
| 146 | + |
| 147 | + const { ledger, settingAudit } = forKey('api_key'); |
| 148 | + expect(settingAudit.map((e) => e.newHash)).toEqual([null, null]); |
| 149 | + expect(settingAudit.map((e) => e.action)).toEqual(['set', 'set']); |
| 150 | + expect(ledger.map((e) => e.valueDigest)).toEqual(['<encrypted>', '<encrypted>']); |
| 151 | + expect(JSON.stringify([ledger, settingAudit])).not.toContain(sha256('pw-a')); |
| 152 | + // The write itself still landed. |
| 153 | + expect((await svc.get<string>('mail', 'api_key')).value).toBe('pw-b'); |
| 154 | + expect(warn.mock.calls.filter(([m]) => String(m).includes('mail.api_key'))).toHaveLength(1); |
| 155 | + }); |
| 156 | + |
| 157 | + it('a provider that refuses a keyed digest leaves the write intact and records no fingerprint', async () => { |
| 158 | + const real = new LocalCryptoProvider({ key: randomBytes(32) }); |
| 159 | + const refusing: ICryptoProvider = { |
| 160 | + encrypt: real.encrypt.bind(real), |
| 161 | + decrypt: real.decrypt.bind(real), |
| 162 | + rotateKey: real.rotateKey.bind(real), |
| 163 | + digest: real.digest.bind(real), |
| 164 | + keyedDigest: async () => { throw new Error('no key material'); }, |
| 165 | + }; |
| 166 | + const { svc, forKey } = boot({ |
| 167 | + cryptoProvider: refusing, |
| 168 | + secretStore: memorySecretStore(), |
| 169 | + logger: { error: vi.fn(), warn: vi.fn() }, |
| 170 | + }); |
| 171 | + |
| 172 | + await writeSecret(svc, 'pw-x'); |
| 173 | + |
| 174 | + expect(forKey('api_key').settingAudit[0].newHash).toBeNull(); |
| 175 | + expect(forKey('api_key').ledger[0].valueDigest).toBe('<encrypted>'); |
| 176 | + expect((await svc.get<string>('mail', 'api_key')).value).toBe('pw-x'); |
| 177 | + }); |
| 178 | +}); |
| 179 | + |
| 180 | +describe('settings audit trail — non-secret settings', () => { |
| 181 | + it('keep the unkeyed adapter digest of the canonical JSON, unchanged', async () => { |
| 182 | + const provider = new LocalCryptoProvider({ key: randomBytes(32) }); |
| 183 | + const { svc, forKey } = boot({ cryptoProvider: provider, secretStore: memorySecretStore() }); |
| 184 | + |
| 185 | + await writeSecret(svc, 'pw-1'); |
| 186 | + |
| 187 | + const expected = new NoopCryptoAdapter().digest(JSON.stringify('ops@example.com')); |
| 188 | + const { ledger, settingAudit } = forKey('from_email'); |
| 189 | + expect(settingAudit[0]).toMatchObject({ encrypted: false, newHash: expected }); |
| 190 | + expect(ledger[0]).toMatchObject({ encrypted: false, valueDigest: expected }); |
| 191 | + }); |
| 192 | +}); |
0 commit comments