diff --git a/packages/core/references.d.ts b/packages/core/references.d.ts
index bc9c7f1150..d7376ab769 100644
--- a/packages/core/references.d.ts
+++ b/packages/core/references.d.ts
@@ -2,6 +2,7 @@
///
///
///
+///
///
///
///
diff --git a/packages/core/vitest.setup.ts b/packages/core/vitest.setup.ts
index 8577513a2e..53f72a70f8 100644
--- a/packages/core/vitest.setup.ts
+++ b/packages/core/vitest.setup.ts
@@ -53,6 +53,14 @@ global.NSData = {
},
};
global.NSMutableData = { ...global.NSData };
+// Security's CSPRNG entry point, which the crypto shim feeds a typed array directly. The stub
+// fills it so specs can prove the bytes reach the caller's own view, and records the call.
+global.errSecSuccess = 0;
+global.kSecRandomDefault = { native: 'kSecRandomDefault' };
+global.SecRandomCopyBytes = (rnd: any, count: number, bytes: Uint8Array) => {
+ bytes.fill(0xab);
+ return 0;
+};
global.NSCCrypto = {
randomUUID() {
return 'native-uuid';
diff --git a/packages/core/wgc/crypto/index.spec.ts b/packages/core/wgc/crypto/index.spec.ts
index 976e4c91a6..ff1bb6a668 100644
--- a/packages/core/wgc/crypto/index.spec.ts
+++ b/packages/core/wgc/crypto/index.spec.ts
@@ -30,39 +30,82 @@ describe('Crypto.getRandomValues', () => {
});
describe('Crypto.getRandomValues (iOS)', () => {
- // The bytes belong to V8's BackingStore. freeWhenDone:NO is what keeps Foundation from
- // freeing an allocation that V8's ArrayBufferSweeper also frees.
- it('wraps the buffer without donating ownership to Foundation', () => {
+ /** Runs `fn` and returns the arguments SecRandomCopyBytes received. */
+ function captureSecRandom(fn: () => void) {
+ const spy = vi.spyOn(globalThis as any, 'SecRandomCopyBytes');
+
+ try {
+ fn();
+ expect(spy).toHaveBeenCalledTimes(1);
+
+ return spy.mock.calls[0] as any[];
+ } finally {
+ spy.mockRestore();
+ }
+ }
+
+ // The view itself goes to Security: the runtime resolves it to V8's backing store at the
+ // view's byte offset, so the fill lands in the caller's array with nothing in between.
+ it('hands a byte view straight to SecRandomCopyBytes and the bytes land in it', () => {
const bytes = new Uint8Array(16);
- const data = captureNativeCall((globalThis as any).NSCCrypto, () => crypto.getRandomValues(bytes));
+ const [rnd, count, target] = captureSecRandom(() => crypto.getRandomValues(bytes));
- expect(data.selector).toBe('dataWithBytesNoCopyLengthFreeWhenDone');
- expect(data.args[0]).toBe(bytes);
- expect(data.args[1]).toBe(16);
- expect(data.args[2]).toBe(false);
+ expect(rnd).toBe((globalThis as any).kSecRandomDefault);
+ expect(count).toBe(16);
+ expect(target).toBe(bytes);
+ expect(Array.from(bytes)).toEqual(new Array(16).fill(0xab));
});
- it('wraps only the view window of an offset byte view', () => {
- const view = new Uint8Array(new ArrayBuffer(32), 4, 10);
- const data = captureNativeCall((globalThis as any).NSCCrypto, () => crypto.getRandomValues(view));
-
- expect(data.selector).toBe('dataWithBytesNoCopyLengthFreeWhenDone');
- expect(data.args[0]).toBe(view);
- expect(data.args[1]).toBe(10);
- expect(data.args[2]).toBe(false);
+ it('fills only the window of an offset byte view', () => {
+ const buffer = new ArrayBuffer(32);
+ const view = new Uint8Array(buffer, 4, 10);
+ const [, count, target] = captureSecRandom(() => crypto.getRandomValues(view));
+
+ expect(count).toBe(10);
+ expect(target).toBe(view);
+ const all = new Uint8Array(buffer);
+ expect(Array.from(all.subarray(0, 4))).toEqual([0, 0, 0, 0]);
+ expect(Array.from(all.subarray(4, 14))).toEqual(new Array(10).fill(0xab));
+ expect(Array.from(all.subarray(14))).toEqual(new Array(18).fill(0));
});
- it('wraps only the view window of a non-byte typed array', () => {
+ it('reinterprets a non-byte typed array over its window only', () => {
const buffer = new ArrayBuffer(32);
- const data = captureNativeCall((globalThis as any).NSCCrypto, () => crypto.getRandomValues(new Uint32Array(buffer, 8, 2)));
- const wrapped = data.args[0] as Uint8Array;
-
- expect(data.selector).toBe('dataWithBytesNoCopyLengthFreeWhenDone');
- expect(wrapped.buffer).toBe(buffer);
- expect(wrapped.byteOffset).toBe(8);
- expect(wrapped.byteLength).toBe(8);
- expect(data.args[1]).toBe(8);
- expect(data.args[2]).toBe(false);
+ const words = new Uint32Array(buffer, 8, 2);
+ const [, count, target] = captureSecRandom(() => crypto.getRandomValues(words));
+
+ expect(count).toBe(8);
+ expect(target).toBeInstanceOf(Uint8Array);
+ expect(target.buffer).toBe(buffer);
+ expect(target.byteOffset).toBe(8);
+ expect(target.byteLength).toBe(8);
+ expect(Array.from(words)).toEqual([0xabababab, 0xabababab]);
+ });
+
+ it('throws when Security reports a failure', () => {
+ const spy = vi.spyOn(globalThis as any, 'SecRandomCopyBytes').mockReturnValue(-50);
+
+ try {
+ expect(() => crypto.getRandomValues(new Uint8Array(8))).toThrow(/SecRandomCopyBytes failed \(-50\)/);
+ } finally {
+ spy.mockRestore();
+ }
+ });
+
+ // NSMutableData copies bytes it does not own and frees ones it does, so no NSData wrapper
+ // can ever alias V8's memory: the shim must not route through the framework at all.
+ it('never wraps the bytes in NSData or calls the framework', () => {
+ const crypto_ = vi.spyOn((globalThis as any).NSCCrypto, 'getRandomValues');
+ const mutable = vi.spyOn((globalThis as any).NSMutableData, 'dataWithBytesNoCopyLengthFreeWhenDone');
+
+ try {
+ crypto.getRandomValues(new Uint8Array(8));
+ expect(crypto_).not.toHaveBeenCalled();
+ expect(mutable).not.toHaveBeenCalled();
+ } finally {
+ crypto_.mockRestore();
+ mutable.mockRestore();
+ }
});
});
diff --git a/packages/core/wgc/crypto/index.ts b/packages/core/wgc/crypto/index.ts
index 19b024fb58..8bc9fe9c83 100644
--- a/packages/core/wgc/crypto/index.ts
+++ b/packages/core/wgc/crypto/index.ts
@@ -26,11 +26,14 @@ export class Crypto {
(org).nativescript.winter_tc.Crypto.getRandomValues(bytes);
}
if (__IOS__) {
- // The pointer is V8-owned: freeWhenDone must stay NO, or Foundation and V8's
- // ArrayBufferSweeper both free the same allocation.
- const data = NSMutableData.dataWithBytesNoCopyLengthFreeWhenDone(bytes as never, bytes.byteLength, false);
-
- NSCCrypto.getRandomValues(data);
+ // The view goes to Security directly: the runtime hands over V8's backing store at
+ // the view's byte offset, so the bytes land in the caller's array. No NSData may
+ // sit in between — NSMutableData copies bytes it does not own, so a no-copy wrapper
+ // fills a private copy, and one that owns them frees V8's allocation.
+ const status = SecRandomCopyBytes(kSecRandomDefault, bytes.byteLength, bytes);
+ if (status !== errSecSuccess) {
+ throw new Error(`getRandomValues: SecRandomCopyBytes failed (${status})`);
+ }
}
return typedArray;