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;