Merge fix/client-id-insecure-context for client-side ids
This commit is contained in:
commit
80dbc73230
4 changed files with 84 additions and 4 deletions
|
|
@ -177,6 +177,13 @@ The circuit list editor must remain a custom spreadsheet-like component with con
|
||||||
|
|
||||||
Avoid permanent Bootstrap form controls inside table cells. Table cells should show static text by default and switch to inputs only while editing.
|
Avoid permanent Bootstrap form controls inside table cells. Table cells should show static text by default and switch to inputs only while editing.
|
||||||
|
|
||||||
|
## Browser Runtime
|
||||||
|
|
||||||
|
The web UI is also opened over plain HTTP from other machines in the LAN, which
|
||||||
|
is not a secure context. Do not call secure-context-only browser APIs such as
|
||||||
|
`crypto.randomUUID()` directly; create client-side ids with `createClientId()`
|
||||||
|
from `src/frontend/utils/client-id.ts`.
|
||||||
|
|
||||||
## Drag-and-Drop Rules
|
## Drag-and-Drop Rules
|
||||||
|
|
||||||
Dragging from the circuit identifier / circuit handle moves the whole circuit.
|
Dragging from the circuit identifier / circuit handle moves the whole circuit.
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ import {
|
||||||
type MutableDistributionBoardComponentRole,
|
type MutableDistributionBoardComponentRole,
|
||||||
} from "../utils/distribution-board-component-editing";
|
} from "../utils/distribution-board-component-editing";
|
||||||
import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history";
|
import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history";
|
||||||
|
import { createClientId } from "../utils/client-id";
|
||||||
import type {
|
import type {
|
||||||
CellKey,
|
CellKey,
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
|
|
@ -977,7 +978,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
defaultSortOrder: number
|
defaultSortOrder: number
|
||||||
) {
|
) {
|
||||||
return buildCircuitDeviceRowInsertSnapshot({
|
return buildCircuitDeviceRowInsertSnapshot({
|
||||||
id: crypto.randomUUID(),
|
id: createClientId(),
|
||||||
circuitId,
|
circuitId,
|
||||||
values,
|
values,
|
||||||
defaultSortOrder,
|
defaultSortOrder,
|
||||||
|
|
@ -1001,7 +1002,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
),
|
),
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
const circuitId = crypto.randomUUID();
|
const circuitId = createClientId();
|
||||||
const deviceRows = deviceRowValues.map((row, index) =>
|
const deviceRows = deviceRowValues.map((row, index) =>
|
||||||
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
|
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
|
||||||
);
|
);
|
||||||
|
|
@ -1169,7 +1170,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
(section) => section.id === intent.sectionId
|
(section) => section.id === intent.sectionId
|
||||||
)?.components ?? [];
|
)?.components ?? [];
|
||||||
const snapshot = buildDistributionBoardComponentSnapshot({
|
const snapshot = buildDistributionBoardComponentSnapshot({
|
||||||
id: crypto.randomUUID(),
|
id: createClientId(),
|
||||||
circuitListId,
|
circuitListId,
|
||||||
role: intent.role,
|
role: intent.role,
|
||||||
sectionId: intent.sectionId,
|
sectionId: intent.sectionId,
|
||||||
|
|
@ -1234,7 +1235,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
projectId,
|
projectId,
|
||||||
getExpectedProjectRevision(),
|
getExpectedProjectRevision(),
|
||||||
buildNewCircuitGroupSnapshot({
|
buildNewCircuitGroupSnapshot({
|
||||||
id: crypto.randomUUID(),
|
id: createClientId(),
|
||||||
circuitListId,
|
circuitListId,
|
||||||
category: values.category,
|
category: values.category,
|
||||||
displayName: values.displayName,
|
displayName: values.displayName,
|
||||||
|
|
|
||||||
27
src/frontend/utils/client-id.ts
Normal file
27
src/frontend/utils/client-id.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
/**
|
||||||
|
* Creates a random RFC 4122 version 4 UUID for new entities built in the
|
||||||
|
* browser.
|
||||||
|
*
|
||||||
|
* crypto.randomUUID() exists only in secure contexts (HTTPS or localhost).
|
||||||
|
* The app is also opened over plain HTTP from other machines in the LAN, where
|
||||||
|
* it is undefined, so fall back to crypto.getRandomValues(), which is
|
||||||
|
* available in every context.
|
||||||
|
*/
|
||||||
|
export function createClientId(
|
||||||
|
source: Pick<Crypto, "getRandomValues"> & Partial<Pick<Crypto, "randomUUID">> = crypto
|
||||||
|
): string {
|
||||||
|
if (typeof source.randomUUID === "function") {
|
||||||
|
return source.randomUUID();
|
||||||
|
}
|
||||||
|
const bytes = source.getRandomValues(new Uint8Array(16));
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
|
||||||
|
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
return [
|
||||||
|
hex.slice(0, 8),
|
||||||
|
hex.slice(8, 12),
|
||||||
|
hex.slice(12, 16),
|
||||||
|
hex.slice(16, 20),
|
||||||
|
hex.slice(20),
|
||||||
|
].join("-");
|
||||||
|
}
|
||||||
45
tests/client-id.test.ts
Normal file
45
tests/client-id.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { webcrypto } from "node:crypto";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { createClientId } from "../src/frontend/utils/client-id.js";
|
||||||
|
|
||||||
|
const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
|
|
||||||
|
describe("client id", () => {
|
||||||
|
it("uses crypto.randomUUID when the context provides it", () => {
|
||||||
|
const id = createClientId({
|
||||||
|
getRandomValues: () => {
|
||||||
|
throw new Error("fallback must not be used");
|
||||||
|
},
|
||||||
|
randomUUID: () => "11111111-2222-4333-8444-555555555555",
|
||||||
|
});
|
||||||
|
assert.equal(id, "11111111-2222-4333-8444-555555555555");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to getRandomValues outside a secure context", () => {
|
||||||
|
const insecure = {
|
||||||
|
getRandomValues: webcrypto.getRandomValues.bind(webcrypto),
|
||||||
|
} as Pick<Crypto, "getRandomValues">;
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (let index = 0; index < 200; index += 1) {
|
||||||
|
const id = createClientId(insecure);
|
||||||
|
assert.match(id, uuidV4);
|
||||||
|
assert.equal(z.string().uuid().safeParse(id).success, true);
|
||||||
|
ids.add(id);
|
||||||
|
}
|
||||||
|
assert.equal(ids.size, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets version and variant bits regardless of the random bytes", () => {
|
||||||
|
const allOnes = {
|
||||||
|
getRandomValues: <T extends ArrayBufferView | null>(array: T) => {
|
||||||
|
if (array instanceof Uint8Array) {
|
||||||
|
array.fill(0xff);
|
||||||
|
}
|
||||||
|
return array;
|
||||||
|
},
|
||||||
|
} as Pick<Crypto, "getRandomValues">;
|
||||||
|
assert.equal(createClientId(allOnes), "ffffffff-ffff-4fff-bfff-ffffffffffff");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue