diff --git a/.nyx/triage.json b/.nyx/triage.json new file mode 100644 index 0000000..45d79f8 --- /dev/null +++ b/.nyx/triage.json @@ -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()." + } + ] +} diff --git a/src/core/attestation/index.ts b/src/core/attestation/index.ts index aaaaee2..b6a7f67 100644 --- a/src/core/attestation/index.ts +++ b/src/core/attestation/index.ts @@ -10,4 +10,4 @@ export { JwtQuoteVerifier, DEFAULT_CLAIM_MAP, } from './verifiers'; -export type { VerifierCallable, JwtQuoteVerifierOptions } from './verifiers'; +export type { VerifierCallable, JwtQuoteVerifierOptions, JoseModule } from './verifiers'; diff --git a/src/core/attestation/verifiers.ts b/src/core/attestation/verifiers.ts index f6c8e25..86396d3 100644 --- a/src/core/attestation/verifiers.ts +++ b/src/core/attestation/verifiers.ts @@ -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 { +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 = 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 { - const jose = await requireJose(); + const jose = resolveJose(this.injectedJose); let response: { statusCode: number; body: ArrayBuffer }; try { diff --git a/src/core/memory/browser.ts b/src/core/memory/browser.ts index 3b2046c..82472c6 100644 --- a/src/core/memory/browser.ts +++ b/src/core/memory/browser.ts @@ -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.', }; } } diff --git a/src/core/memory/node.ts b/src/core/memory/node.ts index bb20e4e..a2a434d 100644 --- a/src/core/memory/node.ts +++ b/src/core/memory/node.ts @@ -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.', }; } } diff --git a/src/core/memory/secure.ts b/src/core/memory/secure.ts index 9d6e0d7..458c3d0 100644 --- a/src/core/memory/secure.ts +++ b/src/core/memory/secure.ts @@ -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(fn: (data: ArrayBuffer) => Promise): Promise { + 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; + } } } } diff --git a/src/types/crypto.ts b/src/types/crypto.ts index 9ee2d5d..26d34b7 100644 --- a/src/types/crypto.ts +++ b/src/types/crypto.ts @@ -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 { diff --git a/tests/unit/attestation.test.ts b/tests/unit/attestation.test.ts index fc37987..217aa7e 100644 --- a/tests/unit/attestation.test.ts +++ b/tests/unit/attestation.test.ts @@ -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 ------------------------------ diff --git a/tests/unit/memory.test.ts b/tests/unit/memory.test.ts new file mode 100644 index 0000000..0d17f10 --- /dev/null +++ b/tests/unit/memory.test.ts @@ -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 = {}) { + 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'); + }); +});