Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/references.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/// <reference path="../types-ios/src/lib/ios/objc-x86_64/objc!CFNetwork.d.ts" />
/// <reference path="../types-ios/src/lib/ios/objc-x86_64/objc!CoreText.d.ts" />
/// <reference path="../types-ios/src/lib/ios/objc-x86_64/objc!Darwin.d.ts" />
/// <reference path="../types-ios/src/lib/ios/objc-x86_64/objc!Security.d.ts" />
/// <reference path="../types-ios/src/lib/ios/objc-x86_64/objc!_DarwinFoundation1.d.ts" />
/// <reference path="../types-ios/src/lib/ios/objc-x86_64/objc!_DarwinFoundation2.d.ts" />
/// <reference path="../types-ios/src/lib/ios/objc-x86_64/objc!_DarwinFoundation3.d.ts" />
Expand Down
8 changes: 8 additions & 0 deletions packages/core/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
95 changes: 69 additions & 26 deletions packages/core/wgc/crypto/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
});

Expand Down
13 changes: 8 additions & 5 deletions packages/core/wgc/crypto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,14 @@ export class Crypto {
(<any>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;
Expand Down
Loading