1616 * expected digests are computed here with `node:crypto`, independently of the
1717 * implementation.
1818 *
19- * A secret-valued key is the CONTROL: its keyed fingerprint path
20- * (`hmac-sha256:…`, `secretAuditDigest`) is untouched, so its rows keep a null
21- * `old_hash` and a keyed `new_hash`.
19+ * [#26534] A secret-valued key records the SAME keyed digest on both columns
20+ * (`hmac-sha256:…`, `secretAuditDigest`): `old_hash` is the keyed digest of
21+ * the previous value, read back and decrypted inside the write, so it equals
22+ * the previous row's `new_hash`. No row ever carries an unkeyed digest of a
23+ * secret.
2224 */
2325
2426import { createHash } from 'node:crypto' ;
@@ -182,19 +184,27 @@ class MockHttp implements IHttpServer {
182184}
183185
184186/** The pieces the running server binds: real engine, real objects, the plugin's audit writer, real routes. */
185- async function boot ( ) {
187+ async function boot ( opts : { cryptoProvider ?: LocalCryptoProvider } = { } ) {
186188 const engine = new ObjectQL ( ) ;
187189 const { driver, rowsOf } = makeMemoryDriver ( ) ;
188190 engine . registerDriver ( driver , true ) ;
189191 await engine . init ( ) ;
190192 for ( const o of [ SysSetting , SysPlatformSetting , SysSecret , SysSettingAudit , SysPlatformSettingAudit ] ) {
191193 engine . registry . registerObject ( o as any , OWNER_PACKAGE ) ;
192194 }
195+ // The plugin's own `sys_secret` store, with its reads recorded: which
196+ // handles a write dereferences is what the cost cases measure.
197+ const store = ( new SettingsServicePlugin ( ) as any ) . buildSecretStore ( engine ) ;
198+ const secretReads : string [ ] = [ ] ;
199+ const secretStore = {
200+ ...store ,
201+ get : async ( id : string ) => { secretReads . push ( id ) ; return store . get ( id ) ; } ,
202+ } ;
193203 const svc = new SettingsService ( {
194204 env : { } ,
195205 engine : withEngineRegistry ( wrapEngineAsSettingsEngine ( engine as any ) , engine ) ,
196- cryptoProvider : new LocalCryptoProvider ( ) ,
197- secretStore : ( new SettingsServicePlugin ( ) as any ) . buildSecretStore ( engine ) ,
206+ cryptoProvider : opts . cryptoProvider ?? new LocalCryptoProvider ( ) ,
207+ secretStore,
198208 auditWriter : buildSettingAuditWriter ( engine as any ) ,
199209 } ) ;
200210 for ( const m of [ TENANT_MANIFEST , GLOBAL_MANIFEST , USER_MANIFEST ] ) svc . registerManifest ( m ) ;
@@ -238,7 +248,19 @@ async function boot() {
238248 const auditOf = ( object : 'sys_setting_audit' | 'sys_platform_setting_audit' , key : string ) =>
239249 [ ...rowsOf ( object ) . values ( ) ] . filter ( ( r ) => r . key === key ) ;
240250
241- return { put, auditOf, asUser : ( id : string ) => { userId = id ; } } ;
251+ /** The `sys_secret` handle the global row of `key` names now. */
252+ const handleOf = ( key : string ) =>
253+ [ ...rowsOf ( 'sys_platform_setting' ) . values ( ) ] . find ( ( r ) => r . key === key ) ?. value_enc as string | undefined ;
254+
255+ return {
256+ put,
257+ auditOf,
258+ asUser : ( id : string ) => { userId = id ; } ,
259+ svc,
260+ rowsOf,
261+ secretReads,
262+ handleOf,
263+ } ;
242264}
243265
244266describe ( '#26465 — old_hash / new_hash on the tenant rung (sys_setting_audit)' , ( ) => {
@@ -330,8 +352,36 @@ describe('#26465 — the global rung (sys_platform_setting_audit) records the sa
330352 [ sha256Of ( 'smtp2.example.com' ) , sha256Of ( 'smtp2.example.com' ) ] ,
331353 ] ) ;
332354 } ) ;
355+ } ) ;
356+
357+ /** The unkeyed digests a leak of `value` would carry, for the "never in any row" check. */
358+ const unkeyedOf = ( value : string ) => [
359+ sha256Of ( value ) . slice ( 'sha256:' . length ) ,
360+ createHash ( 'sha256' ) . update ( value , 'utf8' ) . digest ( 'hex' ) ,
361+ ] ;
362+ const expectNoUnkeyedDigest = ( rows : Record < string , unknown > [ ] , values : string [ ] ) => {
363+ for ( const row of rows ) {
364+ for ( const v of values ) {
365+ expect ( JSON . stringify ( row ) ) . not . toContain ( v ) ;
366+ for ( const d of unkeyedOf ( v ) ) expect ( JSON . stringify ( row ) ) . not . toContain ( d ) ;
367+ }
368+ }
369+ } ;
370+
371+ describe ( '#26534 — a secret value records the keyed digest of its previous value as old_hash' , ( ) => {
372+ it ( 'first write: old_hash null, keyed new_hash' , async ( ) => {
373+ const { put, auditOf } = await boot ( ) ;
374+
375+ await put ( 'hash_global' , { api_key : 'pw-alpha' } ) ;
376+
377+ const rows = auditOf ( 'sys_platform_setting_audit' , 'api_key' ) ;
378+ expect ( rows ) . toHaveLength ( 1 ) ;
379+ expect ( rows [ 0 ] ) . toMatchObject ( { action : 'set' , encrypted : true , old_hash : null } ) ;
380+ expect ( rows [ 0 ] . new_hash ) . toMatch ( KEYED_SHAPE ) ;
381+ expectNoUnkeyedDigest ( rows , [ 'pw-alpha' ] ) ;
382+ } ) ;
333383
334- it ( 'CONTROL — a secret value keeps its keyed path: keyed new_hash, null old_hash, no unkeyed digest anywhere' , async ( ) => {
384+ it ( 'overwrite: old_hash is the previous row\'s keyed new_hash, and no unkeyed digest is anywhere in a row ' , async ( ) => {
335385 const { put, auditOf } = await boot ( ) ;
336386
337387 await put ( 'hash_global' , { api_key : 'pw-alpha' } ) ;
@@ -341,11 +391,135 @@ describe('#26465 — the global rung (sys_platform_setting_audit) records the sa
341391 expect ( rows ) . toHaveLength ( 2 ) ;
342392 for ( const row of rows ) {
343393 expect ( row . encrypted ) . toBe ( true ) ;
344- expect ( row . old_hash ) . toBeNull ( ) ;
345394 expect ( row . new_hash ) . toMatch ( KEYED_SHAPE ) ;
346395 }
347- expect ( rows [ 1 ] . new_hash ) . not . toBe ( rows [ 0 ] . new_hash ) ;
348- const unkeyed = [ sha256Of ( 'pw-alpha' ) , sha256Of ( 'pw-beta' ) , 'sha256:' + createHash ( 'sha256' ) . update ( 'pw-alpha' ) . digest ( 'hex' ) ] ;
349- for ( const row of rows ) for ( const d of unkeyed ) expect ( JSON . stringify ( row ) ) . not . toContain ( d . slice ( 'sha256:' . length ) ) ;
396+ expect ( rows [ 1 ] . old_hash ) . toMatch ( KEYED_SHAPE ) ;
397+ expect ( rows [ 1 ] . old_hash ) . toBe ( rows [ 0 ] . new_hash ) ;
398+ expect ( rows [ 1 ] . new_hash ) . not . toBe ( rows [ 1 ] . old_hash ) ;
399+ expectNoUnkeyedDigest ( rows , [ 'pw-alpha' , 'pw-beta' ] ) ;
400+ } ) ;
401+
402+ it ( 'same-value rewrite: old_hash equals new_hash, so a rotation that changed nothing is visible' , async ( ) => {
403+ const { put, auditOf } = await boot ( ) ;
404+
405+ await put ( 'hash_global' , { api_key : 'pw-alpha' } ) ;
406+ await put ( 'hash_global' , { api_key : 'pw-alpha' } ) ;
407+
408+ const rows = auditOf ( 'sys_platform_setting_audit' , 'api_key' ) ;
409+ expect ( rows ) . toHaveLength ( 2 ) ;
410+ expect ( rows [ 1 ] . old_hash ) . toMatch ( KEYED_SHAPE ) ;
411+ expect ( rows [ 1 ] . old_hash ) . toBe ( rows [ 1 ] . new_hash ) ;
412+ expect ( rows [ 1 ] . old_hash ) . toBe ( rows [ 0 ] . new_hash ) ;
413+ expectNoUnkeyedDigest ( rows , [ 'pw-alpha' ] ) ;
414+ } ) ;
415+
416+ it ( 'set → reset → set: the reset carries the previous keyed digest and no new one; the next set starts from unset' , async ( ) => {
417+ const { put, auditOf } = await boot ( ) ;
418+
419+ await put ( 'hash_global' , { api_key : 'pw-alpha' } ) ;
420+ await put ( 'hash_global' , { api_key : null } ) ;
421+ await put ( 'hash_global' , { api_key : 'pw-beta' } ) ;
422+
423+ const rows = auditOf ( 'sys_platform_setting_audit' , 'api_key' ) ;
424+ expect ( rows . map ( ( r ) => r . action ) ) . toEqual ( [ 'set' , 'reset' , 'set' ] ) ;
425+ expect ( rows [ 1 ] ) . toMatchObject ( { old_hash : rows [ 0 ] . new_hash , new_hash : null } ) ;
426+ expect ( rows [ 1 ] . old_hash ) . toMatch ( KEYED_SHAPE ) ;
427+ expect ( rows [ 2 ] . old_hash ) . toBeNull ( ) ;
428+ expect ( rows [ 2 ] . new_hash ) . toMatch ( KEYED_SHAPE ) ;
429+ expectNoUnkeyedDigest ( rows , [ 'pw-alpha' , 'pw-beta' ] ) ;
430+ } ) ;
431+
432+ it ( 'cost: only a secret overwrite pays one more read of the previous handle, and that secret is still reaped after it' , async ( ) => {
433+ /** The reads of the previous handle one overwrite makes, on a fresh boot. */
434+ const overwriteReads = async ( provider ?: LocalCryptoProvider ) => {
435+ const { put, auditOf, rowsOf, secretReads, handleOf } = await boot ( { cryptoProvider : provider } ) ;
436+ // A non-secret write never touches sys_secret.
437+ await put ( 'hash_global' , { host : 'smtp.example.com' } ) ;
438+ expect ( secretReads ) . toEqual ( [ ] ) ;
439+ // A first write has no previous handle to read: every read is of the
440+ // handle it just wrote (the response's re-resolve).
441+ await put ( 'hash_global' , { api_key : 'pw-alpha' } ) ;
442+ const first = handleOf ( 'api_key' ) ! ;
443+ expect ( first ) . toMatch ( / ^ s e c _ / ) ;
444+ expect ( secretReads . every ( ( id ) => id === first ) ) . toBe ( true ) ;
445+ secretReads . length = 0 ;
446+ await put ( 'hash_global' , { api_key : 'pw-beta' } ) ;
447+ // The rotated-away secret is reaped AFTER the old_hash read (#8030).
448+ expect ( rowsOf ( 'sys_secret' ) . has ( first ) ) . toBe ( false ) ;
449+ expect ( rowsOf ( 'sys_secret' ) . has ( handleOf ( 'api_key' ) ! ) ) . toBe ( true ) ;
450+ return {
451+ reads : secretReads . filter ( ( id ) => id === first ) . length ,
452+ oldHash : auditOf ( 'sys_platform_setting_audit' , 'api_key' ) [ 1 ] . old_hash ,
453+ } ;
454+ } ;
455+ // The baseline: with no keyed digest the write skips the old_hash read,
456+ // and what is left is the read the write path already made before this
457+ // rule (`validatePatch` resolves the key's current value).
458+ const unkeyed = new LocalCryptoProvider ( ) ;
459+ ( unkeyed as any ) . keyedDigest = undefined ;
460+ const baseline = await overwriteReads ( unkeyed ) ;
461+ const keyed = await overwriteReads ( ) ;
462+
463+ expect ( baseline . oldHash ) . toBeNull ( ) ;
464+ expect ( keyed . oldHash ) . toMatch ( KEYED_SHAPE ) ;
465+ expect ( keyed . reads - baseline . reads ) . toBe ( 1 ) ;
466+ } ) ;
467+
468+ it ( 'no keyed digest available: old_hash null like new_hash, and the write lands' , async ( ) => {
469+ const provider = new LocalCryptoProvider ( ) ;
470+ ( provider as any ) . keyedDigest = undefined ;
471+ const { put, auditOf, svc } = await boot ( { cryptoProvider : provider } ) ;
472+
473+ await put ( 'hash_global' , { api_key : 'pw-alpha' } ) ;
474+ await put ( 'hash_global' , { api_key : 'pw-beta' } ) ;
475+
476+ const rows = auditOf ( 'sys_platform_setting_audit' , 'api_key' ) ;
477+ expect ( rows . map ( ( r ) => [ r . old_hash , r . new_hash ] ) ) . toEqual ( [ [ null , null ] , [ null , null ] ] ) ;
478+ expect ( ( await svc . get ( 'hash_global' , 'api_key' ) ) . value ) . toBe ( 'pw-beta' ) ;
479+ expectNoUnkeyedDigest ( rows , [ 'pw-alpha' , 'pw-beta' ] ) ;
480+ } ) ;
481+
482+ it ( 'a previous handle that no longer resolves records old_hash null and the write still lands' , async ( ) => {
483+ const { put, auditOf, rowsOf, handleOf, svc } = await boot ( ) ;
484+
485+ await put ( 'hash_global' , { api_key : 'pw-alpha' } ) ;
486+ rowsOf ( 'sys_secret' ) . delete ( handleOf ( 'api_key' ) ! ) ;
487+ await put ( 'hash_global' , { api_key : 'pw-beta' } ) ;
488+
489+ const rows = auditOf ( 'sys_platform_setting_audit' , 'api_key' ) ;
490+ expect ( rows ) . toHaveLength ( 2 ) ;
491+ expect ( rows [ 1 ] . old_hash ) . toBeNull ( ) ;
492+ expect ( rows [ 1 ] . new_hash ) . toMatch ( KEYED_SHAPE ) ;
493+ expect ( ( await svc . get ( 'hash_global' , 'api_key' ) ) . value ) . toBe ( 'pw-beta' ) ;
494+ } ) ;
495+
496+ it ( 'a key whose manifest changes between non-secret and secret never records an unkeyed digest of a secret' , async ( ) => {
497+ // Not reachable through PUT: `encrypted` is the manifest's, and only a
498+ // re-registration (a deploy that edits the manifest) changes it.
499+ const { put, auditOf, svc } = await boot ( ) ;
500+ const asSecret = ( secret : boolean ) : SettingsManifest => ( {
501+ ...GLOBAL_MANIFEST ,
502+ specifiers : [
503+ secret
504+ ? { type : 'password' , key : 'host' , label : 'Host' , required : false , encrypted : true }
505+ : { type : 'text' , key : 'host' , label : 'Host' , required : false } ,
506+ ] ,
507+ } ) ;
508+
509+ await put ( 'hash_global' , { host : 'plain-value' } ) ;
510+ svc . registerManifest ( asSecret ( true ) ) ;
511+ await put ( 'hash_global' , { host : 'secret-value' } ) ;
512+ svc . registerManifest ( asSecret ( false ) ) ;
513+ await put ( 'hash_global' , { host : 'plain-again' } ) ;
514+
515+ const rows = auditOf ( 'sys_platform_setting_audit' , 'host' ) ;
516+ expect ( rows . map ( ( r ) => r . encrypted ) ) . toEqual ( [ false , true , false ] ) ;
517+ // non-secret → secret: the previous row held no secret, so no keyed digest of it.
518+ expect ( rows [ 1 ] . old_hash ) . toBeNull ( ) ;
519+ expect ( rows [ 1 ] . new_hash ) . toMatch ( KEYED_SHAPE ) ;
520+ // secret → non-secret: the previous value was a secret, so no unkeyed digest of it.
521+ expect ( rows [ 2 ] . old_hash ) . toBeNull ( ) ;
522+ expect ( rows [ 2 ] . new_hash ) . toBe ( sha256Of ( 'plain-again' ) ) ;
523+ expectNoUnkeyedDigest ( rows , [ 'secret-value' ] ) ;
350524 } ) ;
351525} ) ;
0 commit comments