Enhance architecture documentation and update project dependencies. Introduce new features for browser recording, page callables, and transform capabilities. Improve build scripts and permissions for better functionality.

This commit is contained in:
go0p
2026-07-23 15:16:50 +08:00
parent 726a97849b
commit c7891a6a9d
125 changed files with 25018 additions and 1675 deletions
+61 -14
View File
@@ -4,15 +4,37 @@ import { gzipSync } from 'node:zlib';
const root = resolve(import.meta.dirname, '..');
const MIB = 1024 * 1024;
// Extension Service Workers do not support runtime import(). Bridge v3 identity verification must stay in the startup bundle.
const BRIDGE_BACKGROUND_BUDGET = 144 * 1024;
const BRIDGE_BACKGROUND_GZIP_BUDGET = 44 * 1024;
const TOTAL_PACKAGE_BUDGET = Math.floor(1.25 * MIB);
// Bundle sizes remain visible in the audit report, but are advisory. Product
// acceptance is based on runtime behavior, security boundaries and measured
// responsiveness rather than a fixed package-size gate.
const BRIDGE_BACKGROUND_BUDGET = 204 * 1024;
const BRIDGE_BACKGROUND_GZIP_BUDGET = 60 * 1024;
const ENTERPRISE_BACKGROUND_GZIP_BUDGET = 61 * 1024;
// Recorder, callable registry and Pipeline runtime are installed only for an
// explicitly selected document. Keep their budget separate from the always-on
// Service Worker so moving work out of startup code remains measurable.
const MAIN_WORLD_PIPELINE_BUDGET = 36 * 1024;
const FIXTURE_LEAK_SIGNATURES = [
'127.0.0.1:82',
'192.168.3.3:8080',
'/encrypt/aes.php',
'/encrypt/rsa.php',
'/semantic-adapter-submit',
'/opaque-worker-submit',
'recorder-webcrypto-envelope-474',
'worker-boundary-holdout-811',
'semantic-sm-plaintext-821',
'semantic-forge-plaintext-822',
'module-recording-',
'"password":"123456"',
];
const targets = [
{ name: 'store', dir: '.output/chrome-mv3-store', contentBudget: 12 * 1024, backgroundBudget: BRIDGE_BACKGROUND_BUDGET, backgroundGzipBudget: BRIDGE_BACKGROUND_GZIP_BUDGET, totalBudget: MIB, directEval: false, userScripts: true, execution: 'user-scripts' },
{ name: 'enterprise', dir: '.output/chrome-mv3-enterprise', contentBudget: 16 * 1024, backgroundBudget: BRIDGE_BACKGROUND_BUDGET, backgroundGzipBudget: BRIDGE_BACKGROUND_GZIP_BUDGET, totalBudget: MIB, directEval: true, userScripts: true, execution: 'user-scripts+injected-fallback' },
{ name: 'firefox', dir: '.output/firefox-mv2', contentBudget: 16 * 1024, backgroundBudget: BRIDGE_BACKGROUND_BUDGET, backgroundGzipBudget: BRIDGE_BACKGROUND_GZIP_BUDGET, totalBudget: MIB, directEval: true, userScripts: false, execution: 'injected-bridge' },
{ name: 'firefox-amo', dir: '.output/firefox-mv3-store', contentBudget: 12 * 1024, backgroundBudget: BRIDGE_BACKGROUND_BUDGET, backgroundGzipBudget: BRIDGE_BACKGROUND_GZIP_BUDGET, totalBudget: MIB, directEval: false, userScripts: false, execution: 'invoke-only' },
{ name: 'store', dir: '.output/chrome-mv3-store', contentBudget: 12 * 1024, backgroundBudget: BRIDGE_BACKGROUND_BUDGET, backgroundGzipBudget: BRIDGE_BACKGROUND_GZIP_BUDGET, totalBudget: TOTAL_PACKAGE_BUDGET, directEval: false, userScripts: true, execution: 'user-scripts' },
{ name: 'enterprise', dir: '.output/chrome-mv3-enterprise', contentBudget: 16 * 1024, backgroundBudget: BRIDGE_BACKGROUND_BUDGET, backgroundGzipBudget: ENTERPRISE_BACKGROUND_GZIP_BUDGET, totalBudget: TOTAL_PACKAGE_BUDGET, directEval: true, userScripts: true, execution: 'user-scripts+injected-fallback' },
{ name: 'firefox', dir: '.output/firefox-mv2', contentBudget: 16 * 1024, backgroundBudget: BRIDGE_BACKGROUND_BUDGET, backgroundGzipBudget: BRIDGE_BACKGROUND_GZIP_BUDGET, totalBudget: TOTAL_PACKAGE_BUDGET, directEval: true, userScripts: false, execution: 'injected-bridge' },
{ name: 'firefox-amo', dir: '.output/firefox-mv3-store', contentBudget: 12 * 1024, backgroundBudget: BRIDGE_BACKGROUND_BUDGET, backgroundGzipBudget: BRIDGE_BACKGROUND_GZIP_BUDGET, totalBudget: TOTAL_PACKAGE_BUDGET, directEval: false, userScripts: false, execution: 'invoke-only' },
];
async function fileSize(path) {
@@ -42,27 +64,50 @@ async function directorySize(path) {
return total;
}
async function assertNoFixtureLeakage(path, targetName) {
const { readdir } = await import('node:fs/promises');
const findings = [];
async function visit(directory) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const child = join(directory, entry.name);
if (entry.isDirectory()) {
await visit(child);
continue;
}
if (!/\.(?:css|html|js|json|map)$/i.test(entry.name)) continue;
const source = await readFile(child, 'utf8');
for (const signature of FIXTURE_LEAK_SIGNATURES) {
if (source.includes(signature)) findings.push(`${child.slice(path.length + 1)} -> ${signature}`);
}
}
}
await visit(path);
assert(findings.length === 0, `${targetName} 生产产物混入靶场 fixture:${findings.join(', ')}`);
}
const report = [];
for (const target of targets) {
const isFirefox = target.name.startsWith('firefox');
const output = resolve(root, target.dir);
assert(await exists(output), `${target.name} 产物不存在,请先运行对应构建命令`);
await assertNoFixtureLeakage(output, target.name);
const manifest = JSON.parse(await readFile(join(output, 'manifest.json'), 'utf8'));
const contentBytes = await fileSize(join(output, 'content-scripts/agent.js'));
const backgroundSource = await readFile(join(output, 'background.js'));
const backgroundBytes = backgroundSource.byteLength;
const backgroundGzipBytes = gzipSync(backgroundSource).byteLength;
const observerBytes = await fileSize(join(output, 'page-observer-main-world.js'));
const recorderBytes = await fileSize(join(output, 'page-recorder-main-world.js'));
const totalBytes = await directorySize(output);
const sizeAdvisories = [];
const directEvalExists = await exists(join(output, 'page-main-world.js'));
const resources = (manifest.web_accessible_resources || []).flatMap((entry) => typeof entry === 'string' ? [entry] : entry.resources || []);
const dynamicResourceGroup = (manifest.web_accessible_resources || []).find((entry) => typeof entry !== 'string' && entry.resources?.includes('floating.html'));
assert(contentBytes <= target.contentBudget, `${target.name} 常驻 content script ${contentBytes}B 超过预算 ${target.contentBudget}B`);
assert(backgroundBytes <= target.backgroundBudget, `${target.name} background ${backgroundBytes}B 超过 ${target.backgroundBudget / 1024}KiB 原始预算`);
assert(backgroundGzipBytes <= target.backgroundGzipBudget, `${target.name} background gzip ${backgroundGzipBytes}B 超过 ${target.backgroundGzipBudget / 1024}KiB 预算`);
assert(observerBytes <= 12 * 1024, `${target.name} MAIN-world observer ${observerBytes}B 超过 12KiB 预算`);
assert(totalBytes <= target.totalBudget, `${target.name} 总产物 ${totalBytes}B 超过 ${target.totalBudget / MIB}MiB 预算`);
if (contentBytes > target.contentBudget) sizeAdvisories.push(`content script ${contentBytes}B > ${target.contentBudget}B reference`);
if (backgroundBytes > target.backgroundBudget) sizeAdvisories.push(`background ${backgroundBytes}B > ${target.backgroundBudget}B reference`);
if (backgroundGzipBytes > target.backgroundGzipBudget) sizeAdvisories.push(`background gzip ${backgroundGzipBytes}B > ${target.backgroundGzipBudget}B reference`);
if (recorderBytes > MAIN_WORLD_PIPELINE_BUDGET) sizeAdvisories.push(`MAIN-world runtime ${recorderBytes}B > ${MAIN_WORLD_PIPELINE_BUDGET}B reference`);
if (totalBytes > target.totalBudget) sizeAdvisories.push(`package ${totalBytes}B > ${target.totalBudget}B reference`);
assert(directEvalExists === target.directEval, `${target.name} page-main-world.js 存在状态不符合构建策略`);
assert(resources.includes('page-main-world.js') === target.directEval, `${target.name} page-main-world.js 暴露状态不符合构建策略`);
assert((manifest.permissions || []).includes('userScripts') === target.userScripts, `${target.name} userScripts 权限不符合构建策略`);
@@ -71,6 +116,7 @@ for (const target of targets) {
}
assert((manifest.permissions || []).includes('webRequest'), `${target.name} 缺少网络捕获所需 webRequest 权限`);
assert((manifest.permissions || []).includes('webNavigation'), `${target.name} 缺少 frame/document 生命周期所需 webNavigation 权限`);
assert((manifest.permissions || []).includes('debugger') === !isFirefox, `${target.name} debugger 权限不符合 Chromium-only 深度捕获策略`);
assert(!(manifest.permissions || []).includes('activeTab'), `${target.name} 不应申请未使用的 activeTab 权限`);
assert(!(manifest.permissions || []).includes('nativeMessaging') && (manifest.optional_permissions || []).includes('nativeMessaging'), `${target.name} Native Messaging 必须按需授权`);
assert((manifest.permissions || []).includes(isFirefox ? 'webRequestBlocking' : 'webRequestAuthProvider'), `${target.name} 缺少代理认证权限`);
@@ -85,8 +131,9 @@ for (const target of targets) {
contentScriptKiB: Number((contentBytes / 1024).toFixed(2)),
backgroundKiB: Number((backgroundBytes / 1024).toFixed(2)),
backgroundGzipKiB: Number((backgroundGzipBytes / 1024).toFixed(2)),
observerKiB: Number((observerBytes / 1024).toFixed(2)),
recorderKiB: Number((recorderBytes / 1024).toFixed(2)),
totalKiB: Number((totalBytes / 1024).toFixed(2)),
sizeAdvisories,
execution: target.execution,
});
}
+224
View File
@@ -0,0 +1,224 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { chromium } from 'playwright-core'
import { resolveChromiumPath } from './resolve-chromium.mjs'
const root = resolve(import.meta.dirname, '..')
const extensionPath = resolve(root, process.env.EXTENSION_PATH || '.output/chrome-mv3-enterprise')
const targetURL = process.env.AESRSA_TARGET || 'http://127.0.0.1:82/'
const manifest = JSON.parse(await readFile(resolve(extensionPath, 'manifest.json'), 'utf8'))
const executablePath = await resolveChromiumPath()
const userDataDir = await mkdtemp(join(tmpdir(), 'yakit-aesrsa-'))
let context
async function extensionRequest(page, action, payload = {}) {
return page.evaluate(async ({ requestAction, requestPayload }) => {
const response = await chrome.runtime.sendMessage({ action: requestAction, payload: requestPayload })
if (!response?.ok) throw new Error(response?.error?.message || response?.error || requestAction)
return response.data
}, { requestAction: action, requestPayload: payload })
}
async function waitFor(page, action, payload, predicate, timeoutMs = 15_000) {
const deadline = Date.now() + timeoutMs
let value
while (Date.now() < deadline) {
value = await extensionRequest(page, action, payload)
if (predicate(value)) return value
await page.waitForTimeout(150)
}
throw new Error(`Timed out waiting for ${action}: ${JSON.stringify(value)}`)
}
try {
context = await chromium.launchPersistentContext(userDataDir, {
executablePath,
headless: true,
viewport: { width: 1280, height: 760 },
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
'--no-first-run',
'--no-default-browser-check',
],
})
let serviceWorker = context.serviceWorkers()[0]
if (!serviceWorker) serviceWorker = await context.waitForEvent('serviceworker', { timeout: 15_000 })
const extensionId = new URL(serviceWorker.url()).host
if (manifest.permissions?.includes('userScripts')) {
const extensionsPage = await context.newPage()
await extensionsPage.goto(`chrome://extensions/?id=${extensionId}`)
const toggle = extensionsPage.locator('#allow-user-scripts cr-toggle')
await toggle.waitFor({ state: 'visible', timeout: 10_000 })
if (!await toggle.evaluate((element) => Boolean(element.checked))) await toggle.click()
await extensionsPage.close()
}
const targetPage = await context.newPage()
targetPage.on('dialog', (dialog) => void dialog.dismiss())
let browserRequestCount = 0
targetPage.on('request', (request) => {
if (new URL(request.url()).pathname === '/encrypt/aesrsa.php') browserRequestCount += 1
})
await targetPage.goto(targetURL)
const controlPage = await context.newPage()
await controlPage.goto(`chrome-extension://${extensionId}/options.html`)
const tabId = await controlPage.evaluate(async (url) => {
const tabs = await chrome.tabs.query({})
return tabs.find((tab) => tab.url === url)?.id
}, targetPage.url())
if (!tabId) throw new Error('Could not resolve the AES+RSA target tab')
await extensionRequest(controlPage, 'recording.start', {
tabId, frameId: 0, captureValues: true, maxEntries: 120, maxValueBytes: 8_192,
})
await targetPage.locator('#username').fill('admin')
await targetPage.locator('#password').fill('wrong-password')
await targetPage.getByRole('button', { name: '登录', exact: true }).click()
await targetPage.getByRole('button', { name: 'AES+Rsa加密', exact: true }).click()
await targetPage.waitForTimeout(500)
if (browserRequestCount !== 1) throw new Error(`Initial recording expected one real request, received ${browserRequestCount}`)
const snapshot = await extensionRequest(controlPage, 'recording.get', { tabId, frameId: 0, limit: 120 })
const candidate = snapshot.profileCandidates?.find((item) => (
item.status === 'capture-required'
&& item.sources?.length === 3
&& new URL(item.request?.url, targetURL).pathname === '/encrypt/aesrsa.php'
))
if (!candidate) throw new Error(`AES+RSA request-level candidate was not inferred: ${JSON.stringify(snapshot)}`)
const matcherEvent = snapshot.events?.find((event) => event.id === candidate.capturePlan?.matcherEventId)
if (!matcherEvent?.crypto?.adapterId || !matcherEvent.wrapperHandleId) {
throw new Error(`AES+RSA candidate has no deep-capture matcher: ${JSON.stringify(candidate)}`)
}
await extensionRequest(controlPage, 'deep.capture.start', {
tabId,
frameId: 0,
matcher: {
kind: 'crypto',
adapterId: matcherEvent.crypto.adapterId,
operation: matcherEvent.crypto.operation,
wrapperHandleId: matcherEvent.wrapperHandleId,
scriptUrl: matcherEvent.scriptUrl,
frameHints: candidate.capturePlan.frameHints,
},
})
await targetPage.locator('#username').fill('admin')
await targetPage.locator('#password').fill('wrong-password')
await targetPage.getByRole('button', { name: '登录', exact: true }).click()
let replayClickFailure
const replayClick = targetPage.getByRole('button', { name: 'AES+Rsa加密', exact: true })
.click({ noWaitAfter: true, timeout: 20_000 })
.catch((reason) => { replayClickFailure = reason })
const paused = await waitFor(controlPage, 'deep.capture.status', { tabId, frameId: 0 }, (value) => (
value?.state === 'paused' && value.pause?.collecting !== true
), 20_000)
const automatic = paused.pause?.automaticCapture
const frame = paused.pause?.frames?.find((item) => item.id === automatic?.frameId)
if (automatic?.state !== 'ready' || automatic.strategy !== 'request-transaction'
|| frame?.functionName !== 'sendDataAesRsa') {
throw new Error(`Deep capture did not select sendDataAesRsa as a request transaction: ${JSON.stringify(paused)}`)
}
const callable = await extensionRequest(controlPage, 'callable.create', {
tabId,
frameId: 0,
source: 'deep-capture',
strategy: 'request-transaction',
callFrameId: frame.id,
name: 'sendDataAesRsa 请求事务',
transaction: {
request: {
method: candidate.request.method,
url: candidate.request.url,
expectedDestinations: candidate.sources.map((source) => source.destination).filter(Boolean),
},
inputMode: 'auto',
boundaries: ['fetch', 'xhr', 'beacon', 'form'],
},
})
if (callable.kind !== 'request-transaction' || callable.inputSlots?.[0]?.name !== 'body') {
throw new Error(`Captured callable is not a request transaction: ${JSON.stringify(callable)}`)
}
await replayClick
if (replayClickFailure) throw replayClickFailure
await targetPage.waitForTimeout(300)
if (browserRequestCount !== 1) {
throw new Error(`Deep-capture replay leaked a real request; observed ${browserRequestCount}`)
}
let execution
try {
execution = await extensionRequest(controlPage, 'callable.execute', {
tabId,
frameId: 0,
callableId: callable.id,
args: [{ username: 'admin', password: '123456' }],
})
} catch (reason) {
const diagnostics = await controlPage.evaluate(async ({ targetTabId, callableId }) => {
const tab = await chrome.tabs.get(targetTabId).catch(() => undefined)
const [injection] = await chrome.scripting.executeScript({
target: { tabId: targetTabId, frameIds: [0] },
world: 'MAIN',
func: async (registryKey, protocolVersion, requestedCallableId) => {
const controller = globalThis[registryKey]
let directExecution
try {
directExecution = {
ok: true,
value: await controller?.command('callable.execute', {
callableId: requestedCallableId,
args: [{ username: 'admin', password: '123456' }],
}),
}
} catch (error) {
directExecution = {
ok: false,
error: error instanceof Error ? `${error.name}: ${error.message}\n${error.stack || ''}` : String(error),
}
}
return {
href: location.href,
controllerVersion: controller?.version,
expectedVersion: protocolVersion,
callables: typeof controller?.command === 'function' ? controller.command('callable.list', {}) : [],
directExecution,
}
},
args: ['__YAKIT_PAGE_RECORDER_V8__', 8, callableId],
}).catch(() => [])
return { tabUrl: tab?.url, page: injection?.result }
}, { targetTabId: tabId, callableId: callable.id })
const selectedFrame = {
functionName: frame.functionName,
functionInspection: frame.functionInspection,
scopes: frame.scopes,
}
throw new Error(`Callable execution failed: ${reason instanceof Error ? reason.message : String(reason)}; browserRequests=${browserRequestCount}; frame=${JSON.stringify(selectedFrame)}; diagnostics=${JSON.stringify(diagnostics)}`)
}
const envelope = execution.value
for (const field of ['encryptedData', 'encryptedKey', 'encryptedIv']) {
if (typeof envelope?.[field] !== 'string' || !envelope[field]) {
throw new Error(`Transaction output is missing ${field}: ${JSON.stringify(execution)}`)
}
}
if (browserRequestCount !== 1) {
throw new Error(`Transaction execution leaked a real browser request; observed ${browserRequestCount}`)
}
const response = await fetch(new URL('/encrypt/aesrsa.php', targetURL), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(envelope),
})
const result = await response.json()
if (!result.success) throw new Error(`Target server rejected the transaction envelope: ${JSON.stringify(result)}`)
process.stdout.write('AES+RSA request transaction verified: no browser request leaked and the target server accepted the captured envelope.\n')
} finally {
await context?.close().catch(() => undefined)
await rm(userDataDir, { recursive: true, force: true })
}
+212
View File
@@ -0,0 +1,212 @@
import { createServer } from 'node:http';
import { createRequire } from 'node:module';
import { readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright-core';
import { KEYUTIL, KJUR } from 'jsrsasign';
import { compactDecrypt, jwtVerify } from 'jose';
const require = createRequire(import.meta.url);
const root = resolve(import.meta.dirname, '..');
const recorderPath = resolve(root, '.output/chrome-mv3/page-recorder-main-world.js');
const jsrsasignPath = resolve(dirname(require.resolve('jsrsasign')), 'jsrsasign-all-min.js');
const joseIndexPath = fileURLToPath(import.meta.resolve('jose'));
const joseRoot = dirname(joseIndexPath);
const executablePath = process.env.CHROME_PATH || '/usr/bin/google-chrome';
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function listen(server) {
return new Promise((resolveListen, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolveListen(server.address()));
});
}
function close(server) {
return new Promise((resolveClose) => server.close(() => resolveClose()));
}
function readBody(request) {
return new Promise((resolveBody, reject) => {
const chunks = [];
let bytes = 0;
request.on('data', (chunk) => {
bytes += chunk.length;
if (bytes > 1024 * 1024) {
reject(new Error('G4 browser fixture body exceeded 1 MiB'));
request.destroy();
return;
}
chunks.push(chunk);
});
request.on('end', () => resolveBody(Buffer.concat(chunks).toString('utf8')));
request.on('error', reject);
});
}
let capturedRequest;
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url || '/', 'http://127.0.0.1');
if (url.pathname === '/') {
response.setHeader('Content-Type', 'text/html; charset=utf-8');
response.end('<!doctype html><html><body><button id="run">Run G4 fixture</button><script src="/jsrsasign.js"></script><script type="module">import * as jose from "/jose/index.js"; window.jose = {...jose}; window.__g4Ready = true;</script></body></html>');
return;
}
if (url.pathname === '/jsrsasign.js') {
response.setHeader('Content-Type', 'text/javascript; charset=utf-8');
response.end(await readFile(jsrsasignPath));
return;
}
if (url.pathname.startsWith('/jose/')) {
const relative = url.pathname.slice('/jose/'.length);
const path = resolve(joseRoot, relative);
assert(path.startsWith(`${joseRoot}/`) || path === joseRoot, 'Invalid jose module path');
response.setHeader('Content-Type', 'text/javascript; charset=utf-8');
response.end(await readFile(path));
return;
}
if (url.pathname === '/g4-submit') {
capturedRequest = {
method: request.method,
signature: String(request.headers['x-signature'] || ''),
body: JSON.parse(await readBody(request)),
};
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify({ ok: true }));
return;
}
response.statusCode = 404;
response.end('not found');
} catch (error) {
response.statusCode = 500;
response.end(error instanceof Error ? error.message : String(error));
}
});
let browser;
try {
const address = await listen(server);
const origin = `http://127.0.0.1:${address.port}`;
const keypair = KEYUTIL.generateKeypair('RSA', 1024);
const privateKey = KEYUTIL.getPEM(keypair.prvKeyObj, 'PKCS8PRV');
const publicKey = KEYUTIL.getPEM(keypair.pubKeyObj);
const secret = crypto.getRandomValues(new Uint8Array(32));
const secretBase64 = Buffer.from(secret).toString('base64');
browser = await chromium.launch({ executablePath, headless: true, args: ['--no-sandbox', '--disable-gpu'] });
const page = await browser.newPage();
await page.goto(origin, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__g4Ready === true);
await page.evaluate(({ privateKey, secretBase64 }) => {
const bytes = Uint8Array.from(atob(secretBase64), (character) => character.charCodeAt(0));
class Axios {
async request(config) {
const canonical = JSON.stringify(config.data);
const signer = new window.KJUR.crypto.Signature({ alg: 'SHA256withRSA' });
signer.init(privateKey);
signer.updateString(canonical);
const signature = signer.sign();
const jwt = await new window.jose.SignJWT({ account: config.data.account })
.setProtectedHeader({ alg: 'HS256' })
.sign(bytes);
const jwe = await new window.jose.CompactEncrypt(new TextEncoder().encode(canonical))
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.encrypt(bytes);
const response = await fetch('/g4-submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Signature': signature },
body: JSON.stringify({ canonical, jwt, jwe }),
});
return await response.json();
}
}
window.axios = { Axios };
window.__g4Fixture = { privateKey, bytes };
window.__g4Originals = {
stringify: JSON.stringify,
axiosRequest: Axios.prototype.request,
signature: window.KJUR.crypto.Signature,
signJwt: window.jose.SignJWT,
compactEncrypt: window.jose.CompactEncrypt,
};
}, { privateKey, secretBase64 });
await page.addScriptTag({ path: recorderPath });
await page.evaluate(() => {
window.__YAKIT_PAGE_RECORDER_V9__.command('start', {
captureValues: false,
maxEntries: 200,
maxValueBytes: 2_048,
});
});
await page.click('#run');
const result = await page.evaluate(async () => {
const client = new window.axios.Axios();
return await client.request({ data: { account: 'admin', nonce: '1700000000' } });
});
assert(result?.ok === true, 'Browser G4 request did not complete');
await page.waitForTimeout(50);
const snapshot = await page.evaluate(() => window.__YAKIT_PAGE_RECORDER_V9__.command('get', { limit: 200 }));
const events = snapshot.events || [];
const jsrsasignEvents = events.filter((event) => event.crypto?.adapterId === 'jsrsasign');
const joseEvents = events.filter((event) => event.crypto?.adapterId === 'jose');
const transformOperations = new Set(events.filter((event) => event.kind === 'transform').map((event) => event.operation));
const requestEvent = events.find((event) => event.kind === 'fetch' && event.url?.includes('/g4-submit'));
for (const phase of ['create', 'init', 'update', 'final']) {
assert(jsrsasignEvents.some((event) => event.crypto?.state?.phase === phase), `Browser jsrsasign missed ${phase}`);
}
const jsrsasignCorrelation = new Set(jsrsasignEvents.map((event) => event.crypto?.state?.correlationId).filter(Boolean));
assert(jsrsasignCorrelation.size === 1, 'Browser jsrsasign stages did not share one correlation ID');
for (const operation of ['SignJWT.create', 'SignJWT.sign', 'CompactEncrypt.create', 'CompactEncrypt.encrypt']) {
assert(joseEvents.some((event) => event.crypto?.operation === operation), `Browser jose missed ${operation}`);
}
assert(joseEvents.filter((event) => ['SignJWT.sign', 'CompactEncrypt.encrypt'].includes(event.crypto?.operation)).every((event) => event.durationMs >= 0), 'Browser jose Promise results did not settle');
assert(transformOperations.has('JSON.stringify'), 'Browser fixture missed JSON serialization evidence');
assert(transformOperations.has('axios.request'), 'Browser fixture missed Axios request-builder evidence');
assert(requestEvent?.inputs?.some((item) => item.path === '$headers.x-signature'), 'Browser fixture missed Header signature evidence');
const metadata = JSON.stringify(snapshot);
assert(!metadata.includes(privateKey), 'Recorder metadata leaked the private key');
assert(!metadata.includes(capturedRequest.body.canonical), 'Metadata-only recording leaked the canonical plaintext');
const verifier = new KJUR.crypto.Signature({ alg: 'SHA256withRSA' });
verifier.init(publicKey);
verifier.updateString(capturedRequest.body.canonical);
assert(verifier.verify(capturedRequest.signature), 'Independent jsrsasign verifier rejected the browser signature');
const jwt = await jwtVerify(capturedRequest.body.jwt, secret, { algorithms: ['HS256'] });
assert(jwt.payload.account === 'admin', 'Independent jose verifier rejected the browser JWT');
const decrypted = await compactDecrypt(capturedRequest.body.jwe, secret, {
keyManagementAlgorithms: ['dir'],
contentEncryptionAlgorithms: ['A256GCM'],
});
assert(new TextDecoder().decode(decrypted.plaintext) === capturedRequest.body.canonical, 'Independent jose decrypt did not recover the canonical request');
const restored = await page.evaluate(() => {
window.__YAKIT_PAGE_RECORDER_V9__.command('stop');
return {
stringify: JSON.stringify === window.__g4Originals.stringify,
axiosRequest: window.axios.Axios.prototype.request === window.__g4Originals.axiosRequest,
signature: window.KJUR.crypto.Signature === window.__g4Originals.signature,
signJwt: window.jose.SignJWT === window.__g4Originals.signJwt,
compactEncrypt: window.jose.CompactEncrypt === window.__g4Originals.compactEncrypt,
};
});
assert(Object.values(restored).every(Boolean), `G4 runtime did not restore page methods: ${JSON.stringify(restored)}`);
console.log(JSON.stringify({
jsrsasignEvents: jsrsasignEvents.length,
joseEvents: joseEvents.length,
transforms: [...transformOperations],
requestHeaderLinked: true,
independentVerification: true,
restored: true,
}, null, 2));
} finally {
await browser?.close();
await close(server);
}
+1825 -100
View File
File diff suppressed because it is too large Load Diff