Skip to content

Use crypo RNG for auto ID generation to reduce conflicts. #2764

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Mar 21, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion packages/firestore/src/api/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2442,7 +2442,7 @@ export class CollectionReference<T = firestore.DocumentData> extends Query<T>
// We allow omission of 'pathString' but explicitly prohibit passing in both
// 'undefined' and 'null'.
if (arguments.length === 0) {
pathString = AutoId.newId();
pathString = AutoId.newId(PlatformSupport.getPlatform());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AutoId.newId should just call PlatformSupport.getPlatform() for itself. If you need a version of this that takes the platform as an argument for testing, it's fine to have AutoId.newId() delegate to that version. We shouldn't have to update all callers to fix this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

}
validateArgType(
'CollectionReference.doc',
Expand Down
4 changes: 2 additions & 2 deletions packages/firestore/src/core/firestore_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
import { Document, MaybeDocument, NoDocument } from '../model/document';
import { DocumentKey } from '../model/document_key';
import { Mutation } from '../model/mutation';
import { Platform } from '../platform/platform';
import { Platform, PlatformSupport } from '../platform/platform';
import { Datastore } from '../remote/datastore';
import { RemoteStore } from '../remote/remote_store';
import { AsyncQueue } from '../util/async_queue';
Expand Down Expand Up @@ -91,7 +91,7 @@ export class FirestoreClient {
// PORTING NOTE: SharedClientState is only used for multi-tab web.
private sharedClientState!: SharedClientState;

private readonly clientId = AutoId.newId();
private readonly clientId = AutoId.newId(PlatformSupport.getPlatform());

constructor(
private platform: Platform,
Expand Down
6 changes: 6 additions & 0 deletions packages/firestore/src/platform/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ export interface Platform {
/** Converts a binary string to a Base64 encoded string. */
btoa(raw: string): string;

/**
* Generates a random byte `b` with `0 <= b <= max`. If `max` is negative,
* it is the same as setting it to byte max (255).
*/
randomByte(max: number): number;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The platforms both provide ways to get an array of random bytes. It's better to keep this interface structured in terms of exposing common platform primitives and than we can implement any derived concerns (like limiting to a max value just once).

Concretely, I think this should be randomBytes that takes a number of bytes to generate and returns a Uint8Array of those contents. (Note that Node's crypto.randomBytes returns a Buffer but that's a subclass of Uint8Array so it's a suitable return value).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.


/** The Platform's 'window' implementation or null if not available. */
readonly window: Window | null;

Expand Down
13 changes: 13 additions & 0 deletions packages/firestore/src/platform_browser/browser_platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,17 @@ export class BrowserPlatform implements Platform {
btoa(raw: string): string {
return btoa(raw);
}

randomByte(max: number): number {
if (max < 0) {
max = 255;
}

const v = new Uint8Array(1);
do {
crypto.getRandomValues(v);
} while (v[0] > max);

return v[0];
}
}
15 changes: 15 additions & 0 deletions packages/firestore/src/platform_node/node_platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* limitations under the License.
*/

import { randomBytes } from 'crypto';
import * as util from 'util';

import { DatabaseId, DatabaseInfo } from '../core/database_info';
Expand Down Expand Up @@ -74,4 +75,18 @@ export class NodePlatform implements Platform {
btoa(raw: string): string {
return new Buffer(raw, 'binary').toString('base64');
}

randomByte(max: number): number {
if (max < 0) {
max = 255;
}

while (true) {
const b = randomBytes(1);
const v = b.readUInt8(0);
if (v <= max) {
return v;
}
}
}
}
10 changes: 8 additions & 2 deletions packages/firestore/src/util/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,26 @@
*/

import { assert } from './assert';
import { Platform } from '../platform/platform';

export type EventHandler<E> = (value: E) => void;
export interface Indexable {
[k: string]: unknown;
}

export class AutoId {
static newId(): string {
static newId(platform: Platform): string {
// Alphanumeric characters
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let autoId = '';
for (let i = 0; i < 20; i++) {
autoId += chars.charAt(Math.floor(Math.random() * chars.length));
// Length of `chars` is 62. We generate random values between 0 and 247.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mark this explicitly inclusive. Usually "between" implies one or both end points are excluded from the set. That is, use "Random values between 0 and 247 (inclusive)."

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

// Each value maps to an 4-element interval of `chars`, for example:
// 0..3 -> 0, 4..7 -> 1, etc. This ensures a uniform distribution of
// probability for all candidates.
const v = platform.randomByte(247);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A possible improvement here is to request 20-40 random bytes at a time from platform.getBytes() to save the overhead of individual calls. At the very least this would save the overhead of allocating an array for every call.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

autoId += chars.charAt(Math.floor(v / 4));
}
assert(autoId.length === 20, 'Invalid auto ID: ' + autoId);
return autoId;
Expand Down
11 changes: 8 additions & 3 deletions packages/firestore/test/unit/local/persistence_test_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export async function testIndexedDbPersistence(
lruParams: LruParams = LruParams.DEFAULT
): Promise<IndexedDbPersistence> {
const queue = options.queue || new AsyncQueue();
const clientId = AutoId.newId();
const clientId = AutoId.newId(PlatformSupport.getPlatform());
const prefix = `${TEST_PERSISTENCE_PREFIX}/`;
if (!options.dontPurgeData) {
await SimpleDb.delete(prefix + IndexedDbPersistence.MAIN_DATABASE);
Expand All @@ -122,13 +122,18 @@ export async function testIndexedDbPersistence(

/** Creates and starts a MemoryPersistence instance for testing. */
export async function testMemoryEagerPersistence(): Promise<MemoryPersistence> {
return MemoryPersistence.createEagerPersistence(AutoId.newId());
return MemoryPersistence.createEagerPersistence(
AutoId.newId(PlatformSupport.getPlatform())
);
}

export async function testMemoryLruPersistence(
params: LruParams = LruParams.DEFAULT
): Promise<MemoryPersistence> {
return MemoryPersistence.createLruPersistence(AutoId.newId(), params);
return MemoryPersistence.createLruPersistence(
AutoId.newId(PlatformSupport.getPlatform()),
params
);
}

/** Clears the persistence in tests */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ describe('WebStorageSharedClientState', () => {
};
window.removeEventListener = () => {};

primaryClientId = AutoId.newId();
primaryClientId = AutoId.newId(PlatformSupport.getPlatform());
queue = new AsyncQueue();
sharedClientState = new WebStorageSharedClientState(
queue,
Expand Down Expand Up @@ -381,11 +381,11 @@ describe('WebStorageSharedClientState', () => {
});

describe('combines client state', () => {
const secondaryClientId = AutoId.newId();
const secondaryClientId = AutoId.newId(PlatformSupport.getPlatform());
const secondaryClientStateKey = `firestore_clients_${TEST_PERSISTENCE_PREFIX}_${secondaryClientId}`;

beforeEach(() => {
const existingClientId = AutoId.newId();
const existingClientId = AutoId.newId(PlatformSupport.getPlatform());

return populateWebStorage(
AUTHENTICATED_USER,
Expand Down Expand Up @@ -515,7 +515,9 @@ describe('WebStorageSharedClientState', () => {
});

it('ignores invalid data', async () => {
const secondaryClientStateKey = `firestore_clients_${TEST_PERSISTENCE_PREFIX}_${AutoId.newId()}`;
const secondaryClientStateKey = `firestore_clients_${TEST_PERSISTENCE_PREFIX}_${AutoId.newId(
PlatformSupport.getPlatform()
)}`;

const invalidState = {
activeTargetIds: [5, 'invalid']
Expand Down Expand Up @@ -656,8 +658,12 @@ describe('WebStorageSharedClientState', () => {
const firstClientTargetId: TargetId = 1;
const secondClientTargetId: TargetId = 2;

const firstClientStorageKey = `firestore_clients_${TEST_PERSISTENCE_PREFIX}_${AutoId.newId()}`;
const secondClientStorageKey = `firestore_clients_${TEST_PERSISTENCE_PREFIX}_${AutoId.newId()}`;
const firstClientStorageKey = `firestore_clients_${TEST_PERSISTENCE_PREFIX}_${AutoId.newId(
PlatformSupport.getPlatform()
)}`;
const secondClientStorageKey = `firestore_clients_${TEST_PERSISTENCE_PREFIX}_${AutoId.newId(
PlatformSupport.getPlatform()
)}`;

let firstClient: LocalClientState;
let secondClientState: LocalClientState;
Expand Down
6 changes: 5 additions & 1 deletion packages/firestore/test/util/test_platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,10 +262,14 @@ export class TestPlatform implements Platform {
btoa(raw: string): string {
return this.basePlatform.btoa(raw);
}

randomByte(max: number): number {
return this.basePlatform.randomByte(max);
}
}

/** Returns true if we are running under Node. */
export function isNode() : boolean {
export function isNode(): boolean {
return (
typeof process !== 'undefined' &&
process.title !== undefined &&
Expand Down