feat: lock sensitive buffers in memory; drop new Function from jose loading
All checks were successful
NYX Security Scan / nyx-scan (pull_request) Successful in 5m49s

Memory protection (step 3 of the Python parity work):

NodeSecureMemory has implemented lockMemory/unlockMemory since the
native addon landed, but nothing ever called them — mlock was dead code
while getProtectionInfo() reported method: 'mlock' and canLock: true.
The library claimed a protection it was not applying.

  - SecureByteContext now locks on entry and unlocks on exit, mirroring
    Python's secure_bytearray(lock=True). Zeroing happens *before*
    unlocking, so cleartext cannot reach swap in between.
  - lockMemory/unlockMemory are part of the SecureMemory interface, so
    the browser implementation must answer for them explicitly (false).
  - Locking is best-effort throughout: a refused or throwing lock
    degrades to zeroing only, which still has value.

getProtectionInfo() now probes rather than assumes. Having the addon
loaded is not the same as being allowed to lock: mlock is routinely
refused by RLIMIT_MEMLOCK, which is small by default and 0 in some
containers. canLock reflects a real mlock attempt, and 'mlock' is only
claimed when locking genuinely works — otherwise it reports zero-only
and says why. ProtectionInfo also carries platform, hasSecureZeroing
and pageSize, closer to Python's get_protection_info().

SAST (ts.code_exec.new_function, ERROR):

JwtQuoteVerifier loaded ESM-only jose via new Function('s', 'return
import(s)'). No user input reached it, so it was not code injection —
but new Function is blocked by any CSP without 'unsafe-eval', and this
package ships a browser bundle, so the failure would land in the
attestation path. Removed in favour of injection: pass the module as
options.jose when require('jose') cannot work. The error message says
so. No eval-equivalent remains in src/.

.nyx/triage.json records the two native/src/mlock.cc cfg-resource-leak
warnings as false positives, scoped to that file rather than the rule,
so a genuine leak in future C++ still surfaces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Alpha Nerd 2026-07-19 11:34:18 +02:00
parent 987acf8816
commit 00388ccb01
Signed by: alpha-nerd
SSH key fingerprint: SHA256:QkkAgVoYi9TQ0UKPkiKSfnerZy2h4qhi3SVPXJmBN+M
9 changed files with 507 additions and 49 deletions

12
.nyx/triage.json Normal file
View file

@ -0,0 +1,12 @@
{
"version": 1,
"decisions": [],
"suppression_rules": [
{
"by": "rule_in_file",
"value": "cfg-resource-leak:native/src/mlock.cc",
"state": "false_positive",
"note": "false_positive: flagged sites are `return Napi::Boolean::New(env, ...)` in MlockBuffer/MunlockBuffer. Napi::Boolean is a stack value wrapping a napi_value handle owned by the callback's N-API HandleScope, which the runtime destroys when the native method returns; there is no acquire/release pair, no delete and no Release() to call. The rule applies a generic 'X::New() acquires heap memory' CFG pattern to an N-API value factory. The flagged lines are themselves return statements, so 'not all exit paths release it' does not apply. The real OS resources here (mlock/VirtualLock) are released by munlockBuffer, which the JS layer pairs with every successful lock in SecureByteContext.use()."
}
]
}

View file

@ -10,4 +10,4 @@ export {
JwtQuoteVerifier,
DEFAULT_CLAIM_MAP,
} from './verifiers';
export type { VerifierCallable, JwtQuoteVerifierOptions } from './verifiers';
export type { VerifierCallable, JwtQuoteVerifierOptions, JoseModule } from './verifiers';

View file

@ -78,10 +78,22 @@ export interface JwtQuoteVerifierOptions {
/** Request timeout in milliseconds (default: 30000) */
timeout?: number;
/**
* The `jose` module to use for JWT validation.
*
* Normally omitted it is loaded via require('jose'). Supply it explicitly
* when that cannot work: jose v5+ is ESM-only, so a CommonJS bundle cannot
* require() it. In that case import it yourself and pass it in:
*
* import * as jose from 'jose';
* new JwtQuoteVerifier(verifyUrl, jwksUrl, { jose });
*/
jose?: JoseModule;
}
/** Minimal surface of the optional `jose` dependency that we rely on. */
interface JoseModule {
export interface JoseModule {
createRemoteJWKSet(url: URL): unknown;
jwtVerify(
token: string,
@ -93,43 +105,35 @@ interface JoseModule {
let joseModule: JoseModule | null = null;
const JOSE_MISSING =
"JwtQuoteVerifier requires the 'jose' package. Install it alongside nomyo-js: npm install jose";
"JwtQuoteVerifier requires the 'jose' package. Install it alongside nomyo-js " +
'(npm install jose). If your project is CommonJS and the installed jose is ' +
"ESM-only (v5+), import it yourself and pass it in: new JwtQuoteVerifier(url, jwks, { jose }).";
/**
* Load the optional `jose` package.
* Resolve the optional `jose` package.
*
* Resolution is checked first so a genuinely missing package reports cleanly.
* Only when `jose` IS installed but cannot be require()d v5+ is ESM-only do
* we fall back to a dynamic import. The `new Function` wrapper stops TypeScript
* rewriting `import()` back into `require()` for CommonJS builds.
* Deliberately does NOT fall back to a dynamic `import()` for ESM-only builds.
* Reaching `import()` from CommonJS output requires constructing the call at
* runtime (`new Function`/`eval`), which is blocked by any Content-Security-
* Policy without 'unsafe-eval'. This library ships a browser bundle, and that
* failure would land squarely in the attestation path so callers under
* CommonJS pass the module in via `options.jose` instead.
*/
async function requireJose(): Promise<JoseModule> {
function resolveJose(injected?: JoseModule): JoseModule {
if (injected) return injected;
if (joseModule) return joseModule;
if (typeof require === 'undefined' || typeof require.resolve !== 'function') {
throw new AttestationError(JOSE_MISSING);
}
try {
require.resolve('jose');
} catch (_notInstalled) {
throw new AttestationError(JOSE_MISSING);
}
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
joseModule = require('jose') as JoseModule;
return joseModule;
} catch (_cjsError) {
if (typeof require === 'function') {
try {
const dynamicImport = new Function('s', 'return import(s)') as (s: string) => Promise<JoseModule>;
joseModule = await dynamicImport('jose');
// eslint-disable-next-line @typescript-eslint/no-var-requires
joseModule = require('jose') as JoseModule;
return joseModule;
} catch (esmError) {
throw new AttestationError(
`could not load the 'jose' package: ${esmError instanceof Error ? esmError.message : 'unknown error'}`
);
} catch (_notLoadable) {
// Missing, or ESM-only under CommonJS — both need explicit injection
}
}
throw new AttestationError(JOSE_MISSING);
}
/**
@ -151,24 +155,26 @@ export class JwtQuoteVerifier implements QuoteVerifier {
private readonly algorithms: string[];
private readonly timeout: number;
private readonly httpClient: HttpClient;
private readonly injectedJose?: JoseModule;
constructor(
private readonly verifyUrl: string,
private readonly jwksUrl: string,
options: JwtQuoteVerifierOptions = {}
) {
const { issuer, audience, claimMap, algorithms, timeout = 30000 } = options;
const { issuer, audience, claimMap, algorithms, timeout = 30000, jose } = options;
this.issuer = issuer;
this.audience = audience;
this.claimMap = { ...DEFAULT_CLAIM_MAP, ...claimMap };
this.algorithms = algorithms ?? ['RS256', 'ES256', 'ES384'];
this.timeout = timeout;
this.injectedJose = jose;
this.httpClient = createHttpClient();
}
async verify(quote: Uint8Array): Promise<VerifiedQuote> {
const jose = await requireJose();
const jose = resolveJose(this.injectedJose);
let response: { statusCode: number; body: ArrayBuffer };
try {

View file

@ -20,6 +20,18 @@ export class BrowserSecureMemory implements SecureMemory {
view.fill(0);
}
/**
* Locking is impossible in a browser: there is no mlock equivalent exposed
* to page scripts. Always false, so callers degrade to zeroing only.
*/
lockMemory(_data: ArrayBuffer): boolean {
return false;
}
unlockMemory(_data: ArrayBuffer): boolean {
return false;
}
/**
* Get protection information
*/
@ -28,7 +40,9 @@ export class BrowserSecureMemory implements SecureMemory {
canLock: false,
isPlatformSecure: false,
method: 'zero-only',
details: 'Browser environment: memory locking not available. Using immediate zeroing only.',
platform: 'browser',
hasSecureZeroing: false,
details: 'Browser environment: memory locking not available. Using best-effort zeroing only.',
};
}
}

View file

@ -33,6 +33,35 @@ try {
// Native addon not installed — degrade gracefully
}
/**
* Whether mlock actually succeeds in this process, determined once by trying it.
*
* Having the addon loaded is not the same as being allowed to lock: mlock is
* routinely refused by RLIMIT_MEMLOCK (often only a few MB, and 0 in some
* containers) without any elevated privileges. Reporting the capability rather
* than the reality would overstate the protection actually in force.
*/
let lockProbeResult: boolean | null = null;
function canActuallyLock(): boolean {
if (lockProbeResult !== null) return lockProbeResult;
if (nativeAddon === null) {
lockProbeResult = false;
return false;
}
try {
const probe = Buffer.alloc(nativeAddon.getPageSize());
const locked = nativeAddon.mlockBuffer(probe);
if (locked) {
nativeAddon.munlockBuffer(probe);
}
lockProbeResult = locked;
} catch (_e) {
lockProbeResult = false;
}
return lockProbeResult;
}
export class NodeSecureMemory implements SecureMemory {
private readonly hasNative: boolean;
@ -79,26 +108,49 @@ export class NodeSecureMemory implements SecureMemory {
/**
* Get memory protection information.
*
* Reports what is actually in force, not what is theoretically supported
* `canLock` reflects a real mlock attempt.
*/
getProtectionInfo(): ProtectionInfo {
if (this.hasNative) {
if (!this.hasNative) {
return {
canLock: true,
isPlatformSecure: true,
method: 'mlock',
canLock: false,
isPlatformSecure: false,
method: 'zero-only',
platform: process.platform,
hasSecureZeroing: false,
details:
'Node.js with nomyo-native addon: mlock + explicit_bzero/SecureZeroMemory available.',
'Node.js (pure JS): memory locking not available without the native addon. ' +
'Using best-effort zeroing only. ' +
'Build the optional addon for mlock support: cd native && npm install && npm run build',
};
}
if (!canActuallyLock()) {
return {
canLock: false,
isPlatformSecure: false,
method: 'zero-only',
platform: process.platform,
hasSecureZeroing: true,
pageSize: nativeAddon!.getPageSize(),
details:
'Node.js with nomyo-native addon: secure zeroing available, but mlock was ' +
'refused by the OS (typically RLIMIT_MEMLOCK). Sensitive buffers may still ' +
'be swapped to disk. Raise the memlock limit or grant CAP_IPC_LOCK to enable locking.',
};
}
return {
canLock: false,
isPlatformSecure: false,
method: 'zero-only',
canLock: true,
isPlatformSecure: true,
method: 'mlock',
platform: process.platform,
hasSecureZeroing: true,
pageSize: nativeAddon!.getPageSize(),
details:
'Node.js (pure JS): memory locking not available without native addon. ' +
'Using immediate zeroing only. ' +
'Build the optional native addon for mlock support: cd native && npm install && npm run build',
'Node.js with nomyo-native addon: mlock + explicit_bzero/SecureZeroMemory active.',
};
}
}

View file

@ -48,6 +48,19 @@ export interface SecureMemory {
*/
zeroMemory(data: ArrayBuffer): void;
/**
* Attempt to lock the buffer into physical memory so it cannot be swapped
* to disk. Best-effort: returns false where locking is unavailable (no
* native addon, browsers) or refused (RLIMIT_MEMLOCK).
*/
lockMemory(data: ArrayBuffer): boolean;
/**
* Release a previously acquired lock. Returns false if nothing was locked
* or the call failed.
*/
unlockMemory(data: ArrayBuffer): boolean;
/**
* Get memory protection information
*/
@ -66,28 +79,66 @@ export class SecureByteContext {
private data: ArrayBuffer;
private secureMemory: SecureMemory;
private useSecure: boolean;
private shouldLock: boolean;
private isLocked = false;
constructor(data: ArrayBuffer, useSecure: boolean = _globalSecureMemoryEnabled) {
/**
* @param data Buffer to protect
* @param useSecure Apply protection at all (defaults to the global flag)
* @param lock Attempt to lock the buffer into physical memory
* (default true, mirroring Python's `secure_bytearray(lock=True)`)
*/
constructor(
data: ArrayBuffer,
useSecure: boolean = _globalSecureMemoryEnabled,
lock: boolean = true
) {
this.data = data;
this.useSecure = useSecure;
this.shouldLock = lock;
this.secureMemory = createSecureMemory();
}
/** Whether this buffer is currently locked into physical memory. */
get locked(): boolean {
return this.isLocked;
}
/**
* Use the secure data within a function
* Ensures memory is zeroed after use, even if an exception occurs
* Use the secure data within a function.
*
* Locks the buffer for the duration where the platform supports it, and
* always zeroes afterwards even if the callback throws. Locking is
* best-effort: failure downgrades to zeroing only, which still has value.
*/
async use<T>(fn: (data: ArrayBuffer) => Promise<T>): Promise<T> {
if (this.useSecure && this.shouldLock) {
try {
this.isLocked = this.secureMemory.lockMemory(this.data);
} catch (_lockErr) {
this.isLocked = false;
}
}
try {
return await fn(this.data);
} finally {
// Always zero memory, even if exception occurred
if (this.useSecure) {
// Zero first, while the pages are still locked and resident, so
// the cleartext cannot reach swap between zeroing and unlocking.
try {
this.secureMemory.zeroMemory(this.data);
} catch (_zeroErr) {
// zeroMemory failure must not mask the original error
}
if (this.isLocked) {
try {
this.secureMemory.unlockMemory(this.data);
} catch (_unlockErr) {
// ditto
}
this.isLocked = false;
}
}
}
}

View file

@ -31,17 +31,31 @@ export interface EncryptedPackage {
}
export interface ProtectionInfo {
/** Whether memory can be locked (mlock) */
/**
* Whether memory locking actually works in this process.
*
* This is probed, not assumed: mlock is commonly refused by RLIMIT_MEMLOCK
* even when the native addon is loaded and the syscall is available.
*/
canLock: boolean;
/** Whether the platform provides secure memory protection */
isPlatformSecure: boolean;
/** Method used for memory protection */
/** Method actually in use for memory protection */
method: 'mlock' | 'zero-only' | 'none';
/** Additional information about protection status */
details?: string;
/** Platform identifier, e.g. "linux", "win32", "browser" */
platform?: string;
/** Whether a zeroing primitive the compiler cannot elide is available */
hasSecureZeroing?: boolean;
/** OS page size in bytes (Node.js with the native addon only) */
pageSize?: number;
}
export interface SecureMemoryConfig {

View file

@ -414,6 +414,66 @@ describe('JwtQuoteVerifier', () => {
await expect(verifier.verify(new Uint8Array([1, 2, 3])))
.rejects.toThrow(/requires the 'jose' package/);
});
test('accepts an injected jose module (CommonJS + ESM-only jose)', async () => {
const claims = {
sgx_mrenclave: MRENCLAVE.toUpperCase(),
sgx_mrsigner: MRSIGNER,
sgx_isvprodid: 1,
sgx_isvsvn: '4',
tcb_status: 'UpToDate',
sgx_report_data: bytesToHex(new Uint8Array(64).fill(7)),
};
const jose = {
createRemoteJWKSet: jest.fn(() => ({ kid: 'stub' })),
jwtVerify: jest.fn(async () => ({ payload: claims })),
};
const verifier = new JwtQuoteVerifier(
'https://verify.test/quote',
'https://verify.test/jwks',
{ jose, issuer: 'https://verify.test', algorithms: ['ES256'] }
);
(verifier as unknown as { httpClient: unknown }).httpClient = {
post: jest.fn(async () => jsonResponse(200, { token: 'header.payload.sig' })),
get: jest.fn(),
};
const v = await verifier.verify(new Uint8Array([1, 2, 3]));
expect(v.mrenclave).toBe(MRENCLAVE); // normalised to lowercase
expect(v.isvSvn).toBe(4); // string claim coerced to int
expect(v.reportData).toHaveLength(64);
expect(jose.jwtVerify).toHaveBeenCalledWith(
'header.payload.sig',
expect.anything(),
expect.objectContaining({ issuer: 'https://verify.test', algorithms: ['ES256'] })
);
});
test('rejects a verifier response without a token', async () => {
const verifier = new JwtQuoteVerifier('https://verify.test/quote', 'https://verify.test/jwks', {
jose: { createRemoteJWKSet: jest.fn(), jwtVerify: jest.fn() },
});
(verifier as unknown as { httpClient: unknown }).httpClient = {
post: jest.fn(async () => jsonResponse(200, { nope: true })),
get: jest.fn(),
};
await expect(verifier.verify(new Uint8Array([1]))).rejects.toThrow("missing JWT 'token'");
});
test('surfaces a non-200 from the verification service', async () => {
const verifier = new JwtQuoteVerifier('https://verify.test/quote', 'https://verify.test/jwks', {
jose: { createRemoteJWKSet: jest.fn(), jwtVerify: jest.fn() },
});
(verifier as unknown as { httpClient: unknown }).httpClient = {
post: jest.fn(async () => jsonResponse(503, { detail: 'down' })),
get: jest.fn(),
};
await expect(verifier.verify(new Uint8Array([1]))).rejects.toThrow('returned HTTP 503');
});
});
// ---- end-to-end through SecureCompletionClient ------------------------------

249
tests/unit/memory.test.ts Normal file
View file

@ -0,0 +1,249 @@
/**
* Unit tests for secure memory handling.
*
* Mirrors Python's SecureBuffer/secure_bytearray lifecycle: lock on entry,
* then zero *before* unlocking on exit, with every step best-effort so that a
* platform without mlock still gets zeroing.
*/
import {
SecureByteContext,
createSecureMemory,
getMemoryProtectionInfo,
disableSecureMemory,
enableSecureMemory,
SecureMemory,
} from '../../src/core/memory/secure';
import { NodeSecureMemory } from '../../src/core/memory/node';
import { BrowserSecureMemory } from '../../src/core/memory/browser';
/** A SecureMemory stub that records the order of operations. */
function recordingMemory(overrides: Partial<SecureMemory> = {}) {
const calls: string[] = [];
const impl: SecureMemory = {
zeroMemory: jest.fn((data: ArrayBuffer) => {
calls.push('zero');
new Uint8Array(data).fill(0);
}),
lockMemory: jest.fn(() => {
calls.push('lock');
return true;
}),
unlockMemory: jest.fn(() => {
calls.push('unlock');
return true;
}),
getProtectionInfo: jest.fn(() => ({
canLock: true,
isPlatformSecure: true,
method: 'mlock' as const,
})),
...overrides,
};
return { impl, calls };
}
function contextWith(data: ArrayBuffer, memory: SecureMemory, useSecure = true, lock = true) {
const ctx = new SecureByteContext(data, useSecure, lock);
(ctx as unknown as { secureMemory: SecureMemory }).secureMemory = memory;
return ctx;
}
function filledBuffer(byte = 0xab, size = 32): ArrayBuffer {
const buf = new ArrayBuffer(size);
new Uint8Array(buf).fill(byte);
return buf;
}
describe('SecureByteContext lifecycle', () => {
test('locks before use and zeroes before unlocking', async () => {
const { impl, calls } = recordingMemory();
const buf = filledBuffer();
await contextWith(buf, impl).use(async (data) => {
calls.push('use');
// Still intact while in scope
expect(new Uint8Array(data).every(b => b === 0xab)).toBe(true);
});
// Zeroing must happen while the pages are still locked
expect(calls).toEqual(['lock', 'use', 'zero', 'unlock']);
expect(new Uint8Array(buf).every(b => b === 0)).toBe(true);
});
test('zeroes and unlocks even when the callback throws', async () => {
const { impl, calls } = recordingMemory();
const buf = filledBuffer();
await expect(
contextWith(buf, impl).use(async () => {
throw new Error('boom');
})
).rejects.toThrow('boom');
expect(calls).toEqual(['lock', 'zero', 'unlock']);
expect(new Uint8Array(buf).every(b => b === 0)).toBe(true);
});
test('a failed lock still zeroes, and does not unlock', async () => {
const { impl, calls } = recordingMemory({
lockMemory: jest.fn(() => false),
});
const buf = filledBuffer();
const ctx = contextWith(buf, impl);
await ctx.use(async () => undefined);
expect(ctx.locked).toBe(false);
expect(calls).toEqual(['zero']);
expect(impl.unlockMemory).not.toHaveBeenCalled();
expect(new Uint8Array(buf).every(b => b === 0)).toBe(true);
});
test('a throwing lockMemory degrades to zeroing', async () => {
const { impl } = recordingMemory({
lockMemory: jest.fn(() => { throw new Error('mlock exploded'); }),
});
const buf = filledBuffer();
await expect(contextWith(buf, impl).use(async () => 'ok')).resolves.toBe('ok');
expect(impl.zeroMemory).toHaveBeenCalled();
expect(new Uint8Array(buf).every(b => b === 0)).toBe(true);
});
test('a throwing zeroMemory does not mask the callback error', async () => {
const { impl } = recordingMemory({
zeroMemory: jest.fn(() => { throw new Error('zero exploded'); }),
});
await expect(
contextWith(filledBuffer(), impl).use(async () => {
throw new Error('original');
})
).rejects.toThrow('original');
});
test('lock: false skips locking but still zeroes', async () => {
const { impl, calls } = recordingMemory();
const buf = filledBuffer();
await contextWith(buf, impl, true, false).use(async () => undefined);
expect(calls).toEqual(['zero']);
expect(impl.lockMemory).not.toHaveBeenCalled();
expect(new Uint8Array(buf).every(b => b === 0)).toBe(true);
});
test('useSecure: false leaves the buffer untouched', async () => {
const { impl, calls } = recordingMemory();
const buf = filledBuffer();
await contextWith(buf, impl, false).use(async () => undefined);
expect(calls).toEqual([]);
expect(new Uint8Array(buf).every(b => b === 0xab)).toBe(true);
});
test('locked reflects state across the lifecycle', async () => {
const { impl } = recordingMemory();
const ctx = contextWith(filledBuffer(), impl);
expect(ctx.locked).toBe(false);
await ctx.use(async () => {
expect(ctx.locked).toBe(true);
});
expect(ctx.locked).toBe(false);
});
test('honours the global disable/enable flag', async () => {
disableSecureMemory();
try {
const buf = filledBuffer();
// No explicit useSecure: falls back to the global flag
const ctx = new SecureByteContext(buf);
const { impl } = recordingMemory();
(ctx as unknown as { secureMemory: SecureMemory }).secureMemory = impl;
await ctx.use(async () => undefined);
expect(new Uint8Array(buf).every(b => b === 0xab)).toBe(true);
} finally {
enableSecureMemory();
}
});
});
describe('NodeSecureMemory', () => {
const memory = new NodeSecureMemory();
test('lock/unlock round-trips or degrades cleanly', () => {
const buf = filledBuffer(0x11, 4096);
const locked = memory.lockMemory(buf);
// Whatever the platform allows, the result must agree with what we report
expect(locked).toBe(getMemoryProtectionInfo().canLock);
if (locked) {
expect(memory.unlockMemory(buf)).toBe(true);
}
});
test('zeroMemory clears the buffer', () => {
const buf = filledBuffer(0xff, 64);
memory.zeroMemory(buf);
expect(new Uint8Array(buf).every(b => b === 0)).toBe(true);
});
test('reported protection info is self-consistent', () => {
const info = memory.getProtectionInfo();
// canLock is probed, so it must never disagree with the method claimed
expect(info.method).toBe(info.canLock ? 'mlock' : 'zero-only');
expect(info.isPlatformSecure).toBe(info.canLock);
expect(info.platform).toBe(process.platform);
if (info.canLock) {
expect(info.hasSecureZeroing).toBe(true);
expect(info.pageSize).toBeGreaterThan(0);
}
});
test('claims mlock only when locking genuinely works', () => {
const info = memory.getProtectionInfo();
const buf = filledBuffer(0x22, 4096);
const locked = memory.lockMemory(buf);
if (locked) memory.unlockMemory(buf);
expect(info.method === 'mlock').toBe(locked);
});
});
describe('BrowserSecureMemory', () => {
const memory = new BrowserSecureMemory();
test('never claims to lock', () => {
expect(memory.lockMemory(filledBuffer())).toBe(false);
expect(memory.unlockMemory(filledBuffer())).toBe(false);
const info = memory.getProtectionInfo();
expect(info.canLock).toBe(false);
expect(info.method).toBe('zero-only');
expect(info.platform).toBe('browser');
});
test('still zeroes', () => {
const buf = filledBuffer(0x7f);
memory.zeroMemory(buf);
expect(new Uint8Array(buf).every(b => b === 0)).toBe(true);
});
});
describe('createSecureMemory', () => {
test('returns an implementation satisfying the full interface', () => {
const memory = createSecureMemory();
expect(typeof memory.zeroMemory).toBe('function');
expect(typeof memory.lockMemory).toBe('function');
expect(typeof memory.unlockMemory).toBe('function');
expect(typeof memory.getProtectionInfo).toBe('function');
});
});