기여 목록 / 8303474
[security] Migrate SecurityOriginView connection section to Lit
DevToolsrefactordevtools/devtools-frontendMerged
SecurityOriginView의 connection section을 Lit으로 전환했습니다. protocol, key exchange,
server signature, cipher, Encrypted ClientHello 표시 테스트를 추가하고,
security details가 없으면 이 영역을 표시하지 않는지 검증했습니다.
문제 설명
SecurityOriginView는 연결 정보의 제목과 표를 DOM 생성·수정 코드로 구성하고
있었습니다. 표시할 값을 만드는 로직을 분리하고, 이 영역을 Lit 템플릿으로
전환하는 작업입니다.
해결 내용
buildConnectionDetailsRows()에서 표시할 행 데이터를 만들고,renderDetailsTable()과renderConnectionSection()에서 표와 제목을 Lit으로 렌더링하도록 했습니다.key exchange알고리즘과 그룹의 표시를formatKeyExchange()로 분리했습니다.server signature, MAC, Encrypted ClientHello의 기존 표시 조건도 유지했습니다.- 영역에
connection-section클래스를 추가하고,security details가 있을 때만 생성하도록 했습니다. 제목의role="heading"과aria-level="2"도 유지했습니다. - 테스트 데이터에서 개별 보안 정보를 지정할 수 있도록 헬퍼를 수정하고,
connection section의 단위 테스트 15개를 추가했습니다.
전체 코드 diff는 다음과 같습니다.
diff --git a/front_end/panels/security/SecurityPanel.test.ts b/front_end/panels/security/SecurityPanel.test.ts
index 987c468..0736fdc 100644
--- a/front_end/panels/security/SecurityPanel.test.ts
+++ b/front_end/panels/security/SecurityPanel.test.ts
@@ -8,7 +8,7 @@
import * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Protocol from '../../generated/protocol.js';
-import {doubleRaf, renderElementIntoDOM} from '../../testing/DOMHelpers.js';
+import {doubleRaf, querySelectorErrorOnMissing, renderElementIntoDOM} from '../../testing/DOMHelpers.js';
import {createTarget, describeWithEnvironment} from '../../testing/EnvironmentHelpers.js';
import {getMainFrame, navigate} from '../../testing/ResourceTreeHelpers.js';
@@ -43,7 +43,8 @@
});
describeWithEnvironment('SecurityOriginView', () => {
- function createOriginState(sanList: string[]): Security.SecurityPanel.OriginState {
+ function createOriginState(securityDetails: Partial<Protocol.Network.SecurityDetails> = {}):
+ Security.SecurityPanel.OriginState {
return {
securityState: Protocol.Security.SecurityState.Secure,
securityDetails: {
@@ -52,20 +53,145 @@
cipher: 'AES_128_GCM',
certificateId: 0 as Protocol.Security.CertificateId,
subjectName: 'example.com',
- sanList,
+ sanList: [],
issuer: 'Test CA',
validFrom: 0,
validTo: 1,
signedCertificateTimestampList: [],
certificateTransparencyCompliance: Protocol.Network.CertificateTransparencyCompliance.Unknown,
encryptedClientHello: false,
+ ...securityDetails,
},
loadedFromCache: false,
};
}
+ function getDetailsTableRows(section: HTMLElement): string[][] {
+ return [...section.querySelectorAll<HTMLTableRowElement>('.details-table-row')].map(
+ row => [...row.cells].map(cell => cell.textContent ?? ''));
+ }
+
+ describe('connection section', () => {
+ function getConnectionDetailsRows(securityDetails: Partial<Protocol.Network.SecurityDetails> = {}): string[][] {
+ const view =
+ new Security.SecurityPanel.SecurityOriginView(urlString`https://foo.bar`, createOriginState(securityDetails));
+ const connectionSection = querySelectorErrorOnMissing(view.element, '.connection-section');
+ return getDetailsTableRows(connectionSection);
+ }
+
+ it('renders the heading', () => {
+ const view = new Security.SecurityPanel.SecurityOriginView(urlString`https://foo.bar`, createOriginState());
+
+ const connectionSection = querySelectorErrorOnMissing(view.element, '.connection-section');
+
+ const heading = querySelectorErrorOnMissing(connectionSection, '.origin-view-section-title');
+ assert.strictEqual(heading.textContent, 'Connection');
+ assert.strictEqual(heading.getAttribute('role'), 'heading');
+ assert.strictEqual(heading.getAttribute('aria-level'), '2');
+ });
+
+ it('does not render without security details', () => {
+ const view = new Security.SecurityPanel.SecurityOriginView(urlString`https://foo.bar`, {
+ securityState: Protocol.Security.SecurityState.Secure,
+ securityDetails: null,
+ loadedFromCache: false,
+ });
+
+ assert.notExists(view.element.querySelector('.connection-section'));
+ });
+
+ it('renders all connection details in order', () => {
+ assert.deepEqual(getConnectionDetailsRows({
+ protocol: 'TLS 1.3',
+ keyExchange: 'ECDHE_RSA',
+ keyExchangeGroup: 'X25519',
+ serverSignatureAlgorithm: 0x0804,
+ cipher: 'AES_128_GCM',
+ mac: 'HMAC-SHA256',
+ encryptedClientHello: true,
+ }),
+ [
+ ['Protocol', 'TLS 1.3'],
+ ['Key exchange', 'ECDHE_RSA with X25519'],
+ ['Server signature', 'RSA-PSS with SHA-256'],
+ ['Cipher', 'AES_128_GCM with HMAC-SHA256'],
+ ['Encrypted ClientHello', 'enabled'],
+ ]);
+ });
+
+ it('renders the protocol', () => {
+ assert.deepInclude(getConnectionDetailsRows({protocol: 'TLS 1.2'}), ['Protocol', 'TLS 1.2']);
+ });
+
+ describe('key exchange', () => {
+ it('renders the algorithm and group', () => {
+ assert.deepInclude(getConnectionDetailsRows({keyExchange: 'ECDHE_RSA', keyExchangeGroup: 'X25519'}),
+ ['Key exchange', 'ECDHE_RSA with X25519']);
+ });
+
+ it('renders only the algorithm', () => {
+ assert.deepInclude(getConnectionDetailsRows({keyExchange: 'ECDHE_RSA', keyExchangeGroup: undefined}),
+ ['Key exchange', 'ECDHE_RSA']);
+ });
+
+ it('renders only the group', () => {
+ assert.deepInclude(getConnectionDetailsRows({keyExchange: '', keyExchangeGroup: 'X25519'}),
+ ['Key exchange', 'X25519']);
+ });
+
+ it('does not render when both are unavailable', () => {
+ const rowNames = getConnectionDetailsRows({
+ keyExchange: '',
+ keyExchangeGroup: undefined,
+ }).map(([key]) => key);
+ assert.notInclude(rowNames, 'Key exchange');
+ });
+ });
+
+ describe('server signature', () => {
+ it('renders a known algorithm', () => {
+ assert.deepInclude(getConnectionDetailsRows({serverSignatureAlgorithm: 0x0804}),
+ ['Server signature', 'RSA-PSS with SHA-256']);
+ });
+
+ it('renders an unknown algorithm', () => {
+ assert.deepInclude(getConnectionDetailsRows({serverSignatureAlgorithm: 0xffff}),
+ ['Server signature', 'unknown (65535)']);
+ });
+
+ it('does not render when unavailable', () => {
+ const rowNames = getConnectionDetailsRows({serverSignatureAlgorithm: undefined}).map(([key]) => key);
+ assert.notInclude(rowNames, 'Server signature');
+ });
+ });
+
+ describe('cipher', () => {
+ it('renders with a MAC', () => {
+ assert.deepInclude(getConnectionDetailsRows({cipher: 'AES_128_GCM', mac: 'HMAC-SHA256'}),
+ ['Cipher', 'AES_128_GCM with HMAC-SHA256']);
+ });
+
+ it('renders without a MAC', () => {
+ assert.deepInclude(getConnectionDetailsRows({cipher: 'AES_128_GCM', mac: undefined}),
+ ['Cipher', 'AES_128_GCM']);
+ });
+ });
+
+ describe('Encrypted ClientHello', () => {
+ it('renders when enabled', () => {
+ assert.deepInclude(getConnectionDetailsRows({encryptedClientHello: true}),
+ ['Encrypted ClientHello', 'enabled']);
+ });
+
+ it('does not render when disabled', () => {
+ const rowNames = getConnectionDetailsRows({encryptedClientHello: false}).map(([key]) => key);
+ assert.notInclude(rowNames, 'Encrypted ClientHello');
+ });
+ });
+ });
+
it('renders an empty SAN', () => {
- const view = new Security.SecurityPanel.SecurityOriginView(urlString`https://foo.bar`, createOriginState([]));
+ const view = new Security.SecurityPanel.SecurityOriginView(urlString`https://foo.bar`, createOriginState());
const sanElement = view.element.querySelector('.san');
assert.instanceOf(sanElement, HTMLElement);
@@ -73,8 +199,8 @@
});
it('renders a SAN without truncation button', () => {
- const view = new Security.SecurityPanel.SecurityOriginView(urlString`https://foo.bar`,
- createOriginState(['a.test', 'b.test', 'c.test']));
+ const view = new Security.SecurityPanel.SecurityOriginView(
+ urlString`https://foo.bar`, createOriginState({sanList: ['a.test', 'b.test', 'c.test']}));
renderElementIntoDOM(view);
const sanElement = view.element.querySelector('.san');
@@ -90,7 +216,7 @@
it('renders a SAN with truncation button and updates on truncation toggle', () => {
const view = new Security.SecurityPanel.SecurityOriginView(
- urlString`https://foo.bar`, createOriginState(['a.test', 'b.test', 'c.test', 'd.test']));
+ urlString`https://foo.bar`, createOriginState({sanList: ['a.test', 'b.test', 'c.test', 'd.test']}));
renderElementIntoDOM(view);
const sanElement = view.element.querySelector('.san');
diff --git a/front_end/panels/security/SecurityPanel.ts b/front_end/panels/security/SecurityPanel.ts
index effa96f..b0371f0 100644
--- a/front_end/panels/security/SecurityPanel.ts
+++ b/front_end/panels/security/SecurityPanel.ts
@@ -1344,6 +1344,86 @@
// clang-format on
}
+interface DetailsTableRow {
+ key: string;
+ value: string;
+}
+
+function renderDetailsTable(rows: DetailsTableRow[]): TemplateResult {
+ // clang-format off
+ return html`
+ <table class="details-table">
+ ${rows.map(row => html`
+ <tr class="details-table-row">
+ <td>${row.key}</td>
+ <td>${row.value}</td>
+ </tr>`)}
+ </table>`;
+ // clang-format on
+}
+
+function formatKeyExchange(securityDetails: Protocol.Network.SecurityDetails): string|null {
+ // A TLS connection negotiates a cipher suite and, when doing an ephemeral
+ // ECDH key exchange, a "named group". In TLS 1.2, the cipher suite is
+ // named like TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256. The DevTools protocol
+ // tried to decompose this name and calls the "ECDHE_RSA" portion the
+ // "keyExchange", because it determined the rough shape of the key
+ // exchange portion of the handshake. (A keyExchange of "RSA" meant a very
+ // different handshake set.) But ECDHE_RSA was still parameterized by a
+ // named group (e.g. X25519), which the DevTools protocol exposes as
+ // "keyExchangeGroup".
+ //
+ // Then, starting TLS 1.3, the cipher suites are named like
+ // TLS_AES_128_GCM_SHA256. The handshake shape is implicit in the
+ // protocol. keyExchange is empty and we only have keyExchangeGroup.
+ //
+ // "Key exchange group" isn't common terminology and, in TLS 1.3,
+ // something like "X25519" is better labelled as "key exchange" than "key
+ // exchange group" anyway. So combine the two fields when displaying in
+ // the UI.
+ if (securityDetails.keyExchange && securityDetails.keyExchangeGroup) {
+ return securityDetails.keyExchange + ' with ' + securityDetails.keyExchangeGroup;
+ }
+ return securityDetails.keyExchange || securityDetails.keyExchangeGroup || null;
+}
+
+function buildConnectionDetailsRows(securityDetails: Protocol.Network.SecurityDetails): DetailsTableRow[] {
+ const rows: DetailsTableRow[] = [{key: i18nString(UIStrings.protocol), value: securityDetails.protocol}];
+
+ const keyExchange = formatKeyExchange(securityDetails);
+ if (keyExchange) {
+ rows.push({key: i18nString(UIStrings.keyExchange), value: keyExchange});
+ }
+
+ if (securityDetails.serverSignatureAlgorithm) {
+ // See https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-signaturescheme
+ let sigString = SignatureSchemeStrings.get(securityDetails.serverSignatureAlgorithm);
+ sigString ??= i18nString(UIStrings.unknownField) + ' (' + securityDetails.serverSignatureAlgorithm + ')';
+ rows.push({key: i18nString(UIStrings.serverSignature), value: sigString});
+ }
+
+ rows.push({
+ key: i18nString(UIStrings.cipher),
+ value: securityDetails.cipher + (securityDetails.mac ? ' with ' + securityDetails.mac : ''),
+ });
+
+ if (securityDetails.encryptedClientHello) {
+ rows.push({key: i18nString(UIStrings.encryptedClientHello), value: i18nString(UIStrings.enabled)});
+ }
+
+ return rows;
+}
+
+function renderConnectionSection(securityDetails: Protocol.Network.SecurityDetails): TemplateResult {
+ const rows = buildConnectionDetailsRows(securityDetails);
+
+ // clang-format off
+ return html`
+ <div class="origin-view-section-title" role="heading" aria-level="2">${i18nString(UIStrings.connection)}</div>
+ ${renderDetailsTable(rows)}`;
+ // clang-format on
+}
+
export class SecurityOriginView extends UI.Widget.VBox {
readonly #origin: Platform.DevToolsPath.UrlString;
readonly #originDisplay: HTMLElement;
@@ -1377,59 +1457,9 @@
UI.ARIAUtils.markAsLink(originNetworkButton);
if (originState.securityDetails) {
- const connectionSection = this.element.createChild('div', 'origin-view-section');
- const connectionDiv = connectionSection.createChild('div', 'origin-view-section-title');
- connectionDiv.textContent = i18nString(UIStrings.connection);
- UI.ARIAUtils.markAsHeading(connectionDiv, 2);
-
- let table: SecurityDetailsTable = new SecurityDetailsTable();
- connectionSection.appendChild(table.element());
- table.addRow(i18nString(UIStrings.protocol), originState.securityDetails.protocol);
-
- // A TLS connection negotiates a cipher suite and, when doing an ephemeral
- // ECDH key exchange, a "named group". In TLS 1.2, the cipher suite is
- // named like TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256. The DevTools protocol
- // tried to decompose this name and calls the "ECDHE_RSA" portion the
- // "keyExchange", because it determined the rough shape of the key
- // exchange portion of the handshake. (A keyExchange of "RSA" meant a very
- // different handshake set.) But ECDHE_RSA was still parameterized by a
- // named group (e.g. X25519), which the DevTools protocol exposes as
- // "keyExchangeGroup".
- //
- // Then, starting TLS 1.3, the cipher suites are named like
- // TLS_AES_128_GCM_SHA256. The handshake shape is implicit in the
- // protocol. keyExchange is empty and we only have keyExchangeGroup.
- //
- // "Key exchange group" isn't common terminology and, in TLS 1.3,
- // something like "X25519" is better labelled as "key exchange" than "key
- // exchange group" anyway. So combine the two fields when displaying in
- // the UI.
- if (originState.securityDetails.keyExchange && originState.securityDetails.keyExchangeGroup) {
- table.addRow(
- i18nString(UIStrings.keyExchange),
- originState.securityDetails.keyExchange + ' with ' + originState.securityDetails.keyExchangeGroup);
- } else if (originState.securityDetails.keyExchange) {
- table.addRow(i18nString(UIStrings.keyExchange), originState.securityDetails.keyExchange);
- } else if (originState.securityDetails.keyExchangeGroup) {
- table.addRow(i18nString(UIStrings.keyExchange), originState.securityDetails.keyExchangeGroup);
- }
-
- if (originState.securityDetails.serverSignatureAlgorithm) {
- // See https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-signaturescheme
- let sigString = SignatureSchemeStrings.get(originState.securityDetails.serverSignatureAlgorithm);
- sigString ??=
- i18nString(UIStrings.unknownField) + ' (' + originState.securityDetails.serverSignatureAlgorithm + ')';
- table.addRow(i18nString(UIStrings.serverSignature), sigString);
- }
-
- table.addRow(
- i18nString(UIStrings.cipher),
- originState.securityDetails.cipher +
- (originState.securityDetails.mac ? ' with ' + originState.securityDetails.mac : ''));
-
- if (originState.securityDetails.encryptedClientHello) {
- table.addRow(i18nString(UIStrings.encryptedClientHello), i18nString(UIStrings.enabled));
- }
+ const connectionSection = this.element.createChild('div', 'origin-view-section connection-section');
+ // eslint-disable-next-line @devtools/no-lit-render-outside-of-view
+ render(renderConnectionSection(originState.securityDetails), connectionSection);
// Create the certificate section outside the callback, so that it appears in the right place.
const certificateSection = this.element.createChild('div', 'origin-view-section');
@@ -1452,7 +1482,7 @@
const validFromString = new Date(1000 * originState.securityDetails.validFrom).toUTCString();
const validUntilString = new Date(1000 * originState.securityDetails.validTo).toUTCString();
- table = new SecurityDetailsTable();
+ const table = new SecurityDetailsTable();
certificateSection.appendChild(table.element());
table.addRow(i18nString(UIStrings.subject), originState.securityDetails.subjectName);
table.addRow(i18n.i18n.lockedString('SAN'), sanDiv);테스트 방법
SecurityPanel.test.ts에 다음 내용을 검증하는 단위 테스트 15개를 추가했습니다.
- 제목과 접근성 속성, 행의 표시 순서,
protocol표시 security details가 없을 때connection section생략key exchange알고리즘과 그룹이 모두 있거나, 하나만 있거나, 모두 없는 경우server signature알고리즘이 알려진 값이거나, 알 수 없는 값이거나, 없는 경우- MAC 유무에 따른
cipher표시와 Encrypted ClientHello 활성화 여부
리뷰 중 Chromium 보안 웹 테스트 2개의 실패가 보고되어, CL 8302454에서 기대값을 임시 조정했습니다. 이후 CQ 실행이 통과했으며, 2026년 8월 28일(한국 시간) DevTools main에 병합되었습니다.
배운 점
- UI를 Lit으로 전환할 때는 값의 표시 형식과 조건부 행의 생략 동작을 함께 검증해야 합니다.
- 테스트 데이터를 필요한 필드만 바꿀 수 있게 구성하면 여러 입력 조합을 간결하게 검증할 수 있습니다.