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>
156 lines
5.3 KiB
TypeScript
156 lines
5.3 KiB
TypeScript
/**
|
|
* Node.js secure memory implementation.
|
|
*
|
|
* When the optional native addon (nomyo-native) is installed and built,
|
|
* this implementation provides true OS-level memory locking (mlock) and
|
|
* secure zeroing via explicit_bzero / SecureZeroMemory.
|
|
*
|
|
* Without the native addon it falls back to pure-JS zeroing only.
|
|
*
|
|
* To build the native addon:
|
|
* cd native && npm install && npm run build
|
|
*/
|
|
|
|
import { SecureMemory } from './secure';
|
|
import { ProtectionInfo } from '../../types/crypto';
|
|
|
|
interface NativeAddon {
|
|
mlockBuffer(buf: Buffer): boolean;
|
|
munlockBuffer(buf: Buffer): boolean;
|
|
secureZeroBuffer(buf: Buffer): void;
|
|
getPageSize(): number;
|
|
}
|
|
|
|
// Try to load the optional native addon once at module init time.
|
|
let nativeAddon: NativeAddon | null = null;
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
const loaded = require('nomyo-native') as NativeAddon | null;
|
|
if (loaded && typeof loaded.mlockBuffer === 'function') {
|
|
nativeAddon = loaded;
|
|
}
|
|
} catch (_e) {
|
|
// 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;
|
|
|
|
constructor() {
|
|
// Deliberately silent: a library must not write to stdout on construction.
|
|
// Callers surface this via getProtectionInfo() under their own debug flag.
|
|
this.hasNative = nativeAddon !== null;
|
|
}
|
|
|
|
/**
|
|
* Zero memory.
|
|
* With native addon: uses explicit_bzero / SecureZeroMemory (not optimized away).
|
|
* Without native addon: fills ArrayBuffer with zeros via Uint8Array (best effort).
|
|
*/
|
|
zeroMemory(data: ArrayBuffer): void {
|
|
if (this.hasNative && nativeAddon) {
|
|
// Buffer.from(arrayBuffer) shares the underlying memory (no copy)
|
|
const buf = Buffer.from(data);
|
|
nativeAddon.secureZeroBuffer(buf);
|
|
}
|
|
// Always also zero via JS for defence-in-depth
|
|
const view = new Uint8Array(data);
|
|
view.fill(0);
|
|
}
|
|
|
|
/**
|
|
* Attempt to lock an ArrayBuffer in physical memory (prevent swapping).
|
|
* Best-effort: fails gracefully without CAP_IPC_LOCK / elevated privileges.
|
|
*/
|
|
lockMemory(data: ArrayBuffer): boolean {
|
|
if (!this.hasNative || !nativeAddon) return false;
|
|
const buf = Buffer.from(data);
|
|
return nativeAddon.mlockBuffer(buf);
|
|
}
|
|
|
|
/**
|
|
* Unlock previously locked memory.
|
|
*/
|
|
unlockMemory(data: ArrayBuffer): boolean {
|
|
if (!this.hasNative || !nativeAddon) return false;
|
|
const buf = Buffer.from(data);
|
|
return nativeAddon.munlockBuffer(buf);
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
return {
|
|
canLock: false,
|
|
isPlatformSecure: false,
|
|
method: 'zero-only',
|
|
platform: process.platform,
|
|
hasSecureZeroing: false,
|
|
details:
|
|
'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: true,
|
|
isPlatformSecure: true,
|
|
method: 'mlock',
|
|
platform: process.platform,
|
|
hasSecureZeroing: true,
|
|
pageSize: nativeAddon!.getPageSize(),
|
|
details:
|
|
'Node.js with nomyo-native addon: mlock + explicit_bzero/SecureZeroMemory active.',
|
|
};
|
|
}
|
|
}
|