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
8 changes: 6 additions & 2 deletions src/ReleaseRecordDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ const useStyles = makeStyles<Theme, { t: HangarTokens }>(() => ({
colHeadChanged: { borderTop: ({ t }) => `3px solid ${t.sky}` },
colHeadBuilt: { borderTop: ({ t }) => `3px solid ${t.amber}` },
colHeadHappened: { borderTop: ({ t }) => `3px solid ${t.good}` },
// The break-glass panel (phase C, G12): its own strip in the break-glass red, and the same gap the Human context
// panel keeps from the columns above (2026-10-12 bug: it hugged them, with no strip).
colHeadBreakglass: { borderTop: ({ t }) => `3px solid ${t.bad}` },
colBreakglass: { marginTop: 18 },
colTitle: { fontFamily: fontDisplay, fontWeight: 700, fontSize: 13, letterSpacing: '0.06em', textTransform: 'uppercase', color: ({ t }) => t.textHi },
colBody: { padding: '14px 15px', display: 'flex', flexDirection: 'column', gap: 14 },
prItem: { display: 'flex', gap: 8, fontSize: 12.5, alignItems: 'baseline' },
Expand Down Expand Up @@ -657,8 +661,8 @@ export function ReleaseRecordDetail({
</div>

{/* Break-glass sessions while this release ran (gaps phase C, G12): part of the release's history, read live. */}
<div className={classes.col} data-testid="release-breakglass">
<div className={classes.colHead}>
<div className={`${classes.col} ${classes.colBreakglass}`} data-testid="release-breakglass">
<div className={`${classes.colHead} ${classes.colHeadBreakglass}`}>
<span className={classes.colTitle}>Break-glass sessions during this release</span>
</div>
<div className={classes.colBody}>
Expand Down
7 changes: 4 additions & 3 deletions src/tabs/topology/BreakglassPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { BreakglassPanel } from './BreakglassPanel';
import type { EnvironmentSummary } from '../../types';
Expand Down Expand Up @@ -338,16 +339,16 @@ describe('the recording player', () => {
JSON.stringify([0.6, 'o', 'uid=65532\r\n']),
].join('\n');
listBody = {
sessions: [flightRow({ id: 'r1', state: 'ended', recordingKey: 'k', recordingSha256: 'whatever', endedAt: at(-1) })],
sessions: [flightRow({ id: 'r1', state: 'ended', recordingKey: 'k', recordingSha256: createHash('sha256').update(recording).digest('hex'), endedAt: at(-1) })],
user: 'user:default/riley-roooo', recordingsConfigured: true,
};
await renderIt('upper');
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'play' })));
expect(calls.some(c => c.url === 'http://be/api/glidepath/breakglass/sessions/r1/recording?purpose=play')).toBe(true);
const player = screen.getByTestId('breakglass-player');
expect(player.textContent).toContain('1 keystroke events');
// jsdom cannot hash (no crypto.subtle), and says so rather than claiming a match.
expect(player.textContent).toContain('integrity not checked');
// jsdom has no crypto.subtle (nor does a plain-http browser): the plain-JS SHA-256 checks the recording anyway.
expect(player.textContent).toContain('SHA-256 matches the session record');
await act(async () => fireEvent.change(screen.getByLabelText('Seek'), { target: { value: '99' } }));
expect(mockWrite).toHaveBeenLastCalledWith('$ uid=65532\r\n');
});
Expand Down
21 changes: 18 additions & 3 deletions src/tabs/topology/castPlayback.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createHash, webcrypto } from 'node:crypto';
import { clock, eventsUntil, parseCast, parseSize, sha256Hex } from './castPlayback';
import { sha256HexJs } from './castPlayback';

const cast = [
JSON.stringify({ version: 2, width: 100, height: 30, timestamp: 1760000000, title: 'break-glass copy sky-marshall/staging' }),
Expand Down Expand Up @@ -52,19 +53,33 @@ describe('playback helpers', () => {
expect(clock(61.9)).toBe('1:01');
});

it('hashes the bytes as the backend does, and says so when the browser cannot hash', async () => {
it('hashes the bytes as the backend does, with crypto.subtle or without it (plain-HTTP pages)', async () => {
const bytes = Buffer.from(cast);
const buf = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
const saved = Object.getOwnPropertyDescriptor(globalThis, 'crypto');
try {
// jsdom has no crypto.subtle; a browser on https (or localhost) does.
// jsdom has no crypto.subtle, nor does a browser on plain http; one on https (or localhost) does.
Object.defineProperty(globalThis, 'crypto', { value: webcrypto, configurable: true });
expect(await sha256Hex(buf)).toBe(createHash('sha256').update(cast).digest('hex'));
Object.defineProperty(globalThis, 'crypto', { value: {}, configurable: true });
expect(await sha256Hex(buf)).toBeUndefined();
expect(await sha256Hex(buf)).toBe(createHash('sha256').update(cast).digest('hex'));
} finally {
if (saved) Object.defineProperty(globalThis, 'crypto', saved);
else delete (globalThis as { crypto?: unknown }).crypto;
}
});
});

describe('sha256HexJs (the player`s digest without crypto.subtle, over plain HTTP)', () => {
const enc = (s: string) => new TextEncoder().encode(s);
it('matches the FIPS 180-4 vectors, one block and two', () => {
expect(sha256HexJs(enc(''))).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
expect(sha256HexJs(enc('abc'))).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
expect(sha256HexJs(enc('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq'))).toBe('248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1');
});
it('handles a length that lands on the padding boundary and a million characters', () => {
expect(sha256HexJs(enc('a'.repeat(55)))).toBe('9f4390f8d30c2dd92ec9f095b65e2b9ae9b0a925a5258e241c9f1e910f734318');
expect(sha256HexJs(enc('a'.repeat(64)))).toBe('ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb');
expect(sha256HexJs(enc('a'.repeat(1_000_000)))).toBe('cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0');
});
});
49 changes: 48 additions & 1 deletion src/tabs/topology/castPlayback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,54 @@ export function clock(seconds: number): string {
/** Hex SHA-256 of the recording's bytes, to check against the session record. Undefined where Web Crypto is missing. */
export async function sha256Hex(bytes: ArrayBuffer): Promise<string | undefined> {
const subtle = globalThis.crypto?.subtle;
if (!subtle) return undefined;
// crypto.subtle exists only in a secure context (HTTPS or localhost). This Backstage is served over plain HTTP, so
// the player showed "integrity not checked" for every recording (2026-10-12); the pure-JS digest below is the same
// SHA-256, just slower, and a recording is a few hundred KB at most.
if (!subtle) return sha256HexJs(new Uint8Array(bytes));
const digest = await subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('');
}

const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);

/** SHA-256 (FIPS 180-4) in plain JavaScript, for pages without crypto.subtle. */
export function sha256HexJs(data: Uint8Array): string {
const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
const bitLen = data.length * 8;
const padded = new Uint8Array(Math.ceil((data.length + 9) / 64) * 64);
padded.set(data);
padded[data.length] = 0x80;
const view = new DataView(padded.buffer);
view.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000));
view.setUint32(padded.length - 4, bitLen >>> 0);
const w = new Uint32Array(64);
const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
for (let off = 0; off < padded.length; off += 64) {
for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4);
for (let i = 16; i < 64; i++) {
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
}
let [a, b, c, d, e, f, g, hh] = h;
for (let i = 0; i < 64; i++) {
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
const ch = (e & f) ^ (~e & g);
const t1 = (hh + S1 + ch + K[i] + w[i]) >>> 0;
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const t2 = (S0 + maj) >>> 0;
hh = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
}
h[0] = (h[0] + a) >>> 0; h[1] = (h[1] + b) >>> 0; h[2] = (h[2] + c) >>> 0; h[3] = (h[3] + d) >>> 0;
h[4] = (h[4] + e) >>> 0; h[5] = (h[5] + f) >>> 0; h[6] = (h[6] + g) >>> 0; h[7] = (h[7] + hh) >>> 0;
}
return Array.from(h, x => x.toString(16).padStart(8, '0')).join('');
}
Loading