diff --git a/src/ReleaseRecordDetail.tsx b/src/ReleaseRecordDetail.tsx index 94a1e96..7b151b7 100644 --- a/src/ReleaseRecordDetail.tsx +++ b/src/ReleaseRecordDetail.tsx @@ -136,6 +136,10 @@ const useStyles = makeStyles(() => ({ 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' }, @@ -657,8 +661,8 @@ export function ReleaseRecordDetail({ {/* Break-glass sessions while this release ran (gaps phase C, G12): part of the release's history, read live. */} -
-
+
+
Break-glass sessions during this release
diff --git a/src/tabs/topology/BreakglassPanel.test.tsx b/src/tabs/topology/BreakglassPanel.test.tsx index 1b54c61..d06d083 100644 --- a/src/tabs/topology/BreakglassPanel.test.tsx +++ b/src/tabs/topology/BreakglassPanel.test.tsx @@ -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'; @@ -338,7 +339,7 @@ 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'); @@ -346,8 +347,8 @@ describe('the recording player', () => { 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'); }); diff --git a/src/tabs/topology/castPlayback.test.ts b/src/tabs/topology/castPlayback.test.ts index d24d1e9..f4d5fe5 100644 --- a/src/tabs/topology/castPlayback.test.ts +++ b/src/tabs/topology/castPlayback.test.ts @@ -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' }), @@ -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'); + }); +}); diff --git a/src/tabs/topology/castPlayback.ts b/src/tabs/topology/castPlayback.ts index 49adf69..b14f259 100644 --- a/src/tabs/topology/castPlayback.ts +++ b/src/tabs/topology/castPlayback.ts @@ -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 { 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(''); +}