기여 목록 / 8426420
[security] Migrate SecurityOriginView certificate transparency section to Lit
DevToolsrefactordevtools/devtools-frontendMerged
SecurityOriginView의 Certificate Transparency section을 Lit으로 전환했습니다. CT compliance 안내
문구, Signed Certificate Timestamp(SCT) 표시, 상세 보기 전환 테스트를 추가했습니다.
문제 설명
Certificate Transparency section은 SCT 요약·상세 정보와 CT compliance 안내 문구를 DOM 생성 코드로
구성하고, 버튼 클릭 시 클래스와 접근성 속성을 직접 변경하고 있었습니다.
이 영역을 Lit 템플릿과 표시 상태에 따라 렌더링하도록 전환하는 작업입니다.
해결 내용
- SCT 요약, 상세 정보, CT compliance 안내 문구를 각각 Lit 템플릿으로 만들고,
renderCertificateTransparencySection()에서 함께 렌더링하도록 했습니다. #createCertificateTransparencySection()에서 상세 표시 상태를 관리하고, 클릭 시 요약·상세 정보의 표시와 버튼 문구·접근성 속성을 함께 갱신합니다.- SCT가 없고 CT compliance가
Unknown이면 영역을 생략하는 기존 조건을 유지했습니다. CT compliance가Unknown인 경우 안내 문구도 표시하지 않습니다. renderDetailsTable()에 추가 클래스를 전달할 수 있도록 확장하고, 더 이상 사용하지 않는SecurityDetailsTable클래스를 제거했습니다.Compliant·NotCompliant두 경우를 포함해 단위 테스트 6개를 추가했습니다.
전체 코드 diff는 다음과 같습니다.
diff --git a/front_end/panels/security/SecurityPanel.test.ts b/front_end/panels/security/SecurityPanel.test.ts
index 799fe03..feb00e8 100644
--- a/front_end/panels/security/SecurityPanel.test.ts
+++ b/front_end/panels/security/SecurityPanel.test.ts
@@ -341,6 +341,138 @@
});
});
+ describe('certificate transparency section', () => {
+ it('renders the heading, SCT summary, and details', () => {
+ const view = new Security.SecurityPanel.SecurityOriginView(
+ urlString`https://foo.bar`, createOriginState({
+ signedCertificateTimestampList: [{
+ logDescription: 'Test log',
+ logId: 'AABB',
+ status: 'Verified',
+ origin: 'Embedded in certificate',
+ timestamp: 1_000,
+ hashAlgorithm: 'SHA-256',
+ signatureAlgorithm: 'ECDSA',
+ signatureData: 'CCDD',
+ }],
+ certificateTransparencyCompliance: Protocol.Network.CertificateTransparencyCompliance.Compliant,
+ }));
+
+ const section = querySelectorErrorOnMissing(view.element, '.certificate-transparency-section');
+ const heading = querySelectorErrorOnMissing(section, '.origin-view-section-title');
+ assert.strictEqual(heading.textContent, 'Certificate Transparency');
+ assert.strictEqual(heading.getAttribute('role'), 'heading');
+ assert.strictEqual(heading.getAttribute('aria-level'), '2');
+
+ const summary = querySelectorErrorOnMissing(section, '.sct-summary');
+ assert.deepEqual(getDetailsTableRows(summary), [['SCT', 'Test log (Embedded in certificate, Verified)']]);
+
+ const details = querySelectorErrorOnMissing(section, '.sct-details');
+ assert.deepEqual(getDetailsTableRows(details), [
+ ['Log name', 'Test log'],
+ ['Log ID', 'AA BB '],
+ ['Validation status', 'Verified'],
+ ['Source', 'Embedded in certificate'],
+ ['Issued at', 'Thu, 01 Jan 1970 00:00:01 GMT'],
+ ['Hash algorithm', 'SHA-256'],
+ ['Signature algorithm', 'ECDSA'],
+ ['Signature data', 'CC DD '],
+ ]);
+ });
+
+ it('does not render when the SCT list is empty and compliance is unknown', () => {
+ const view = new Security.SecurityPanel.SecurityOriginView(
+ urlString`https://foo.bar`, createOriginState({
+ signedCertificateTimestampList: [],
+ certificateTransparencyCompliance: Protocol.Network.CertificateTransparencyCompliance.Unknown,
+ }));
+
+ assert.notExists(view.element.querySelector('.certificate-transparency-section'));
+ assert.notExists(view.element.querySelector('.origin-view-notes'));
+ });
+
+ it('renders without a note when the SCT list is not empty and compliance is unknown', () => {
+ const view = new Security.SecurityPanel.SecurityOriginView(
+ urlString`https://foo.bar`, createOriginState({
+ signedCertificateTimestampList: [{
+ logDescription: 'Test log',
+ logId: '00',
+ status: 'Verified',
+ origin: 'Embedded in certificate',
+ timestamp: 0,
+ hashAlgorithm: 'SHA-256',
+ signatureAlgorithm: 'ECDSA',
+ signatureData: '00',
+ }],
+ certificateTransparencyCompliance: Protocol.Network.CertificateTransparencyCompliance.Unknown,
+ }));
+
+ const section = querySelectorErrorOnMissing(view.element, '.certificate-transparency-section');
+ assert.notExists(section.querySelector('.origin-view-section-notes'));
+ });
+
+ const cases = [
+ {
+ compliance: Protocol.Network.CertificateTransparencyCompliance.Compliant,
+ expectedNote: 'This request complies with Chrome’s Certificate Transparency policy.',
+ },
+ {
+ compliance: Protocol.Network.CertificateTransparencyCompliance.NotCompliant,
+ expectedNote: 'This request doesn’t comply with Chrome’s Certificate Transparency policy.',
+ },
+ ];
+ for (const {compliance, expectedNote} of cases) {
+ it(`renders with a note when compliance is ${compliance}`, () => {
+ const originState = createOriginState({
+ signedCertificateTimestampList: [],
+ certificateTransparencyCompliance: compliance,
+ });
+ const view = new Security.SecurityPanel.SecurityOriginView(urlString`https://foo.bar`, originState);
+
+ const section = querySelectorErrorOnMissing(view.element, '.certificate-transparency-section');
+ const note = querySelectorErrorOnMissing(section, '.origin-view-section-notes');
+ assert.strictEqual(note.textContent, expectedNote);
+ });
+ }
+
+ it('toggles SCT details', () => {
+ const view = new Security.SecurityPanel.SecurityOriginView(
+ urlString`https://foo.bar`, createOriginState({
+ signedCertificateTimestampList: [{
+ logDescription: 'Test log',
+ logId: '00',
+ status: 'Verified',
+ origin: 'Embedded in certificate',
+ timestamp: 0,
+ hashAlgorithm: 'SHA-256',
+ signatureAlgorithm: 'ECDSA',
+ signatureData: '00',
+ }],
+ certificateTransparencyCompliance: Protocol.Network.CertificateTransparencyCompliance.Compliant,
+ }));
+ renderElementIntoDOM(view, {includeCommonStyles: true});
+ const section = querySelectorErrorOnMissing(view.element, '.certificate-transparency-section');
+ const summary = querySelectorErrorOnMissing(section, '.sct-summary');
+ const details = querySelectorErrorOnMissing(section, '.sct-details');
+ const toggle = section.querySelector('devtools-button');
+
+ assert.isTrue(summary.checkVisibility());
+ assert.isFalse(details.checkVisibility());
+ assert.instanceOf(toggle, HTMLElement);
+ assert.strictEqual(toggle.textContent, 'Show full details');
+ assert.strictEqual(toggle.accessibleLabel, 'Show full details');
+ assert.isFalse(toggle.accessibleExpanded);
+
+ toggle.click();
+
+ assert.isFalse(summary.checkVisibility());
+ assert.isTrue(details.checkVisibility());
+ assert.strictEqual(toggle.textContent, 'Hide full details');
+ assert.strictEqual(toggle.accessibleLabel, 'Hide full details');
+ assert.isTrue(toggle.accessibleExpanded);
+ });
+ });
+
it('renders an empty SAN', () => {
const view = new Security.SecurityPanel.SecurityOriginView(urlString`https://foo.bar`, createOriginState());
diff --git a/front_end/panels/security/SecurityPanel.ts b/front_end/panels/security/SecurityPanel.ts
index 45141b3..3b6b978 100644
--- a/front_end/panels/security/SecurityPanel.ts
+++ b/front_end/panels/security/SecurityPanel.ts
@@ -13,7 +13,7 @@
import * as Buttons from '../../ui/components/buttons/buttons.js';
import {createIcon, type Icon} from '../../ui/kit/kit.js';
import * as UI from '../../ui/legacy/legacy.js';
-import {Directives, html, nothing, render, type TemplateResult} from '../../ui/lit/lit.js';
+import {Directives, html, type LitTemplate, nothing, render, type TemplateResult} from '../../ui/lit/lit.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import lockIconStyles from './lockIcon.css.js';
@@ -1347,10 +1347,10 @@
value: string|TemplateResult;
}
-function renderDetailsTable(rows: DetailsTableRow[]): TemplateResult {
+function renderDetailsTable(rows: DetailsTableRow[], additionalClasses: Directives.ClassInfo = {}): TemplateResult {
// clang-format off
return html`
- <table class="details-table">
+ <table class=${Directives.classMap({'details-table': true, ...additionalClasses})}>
${rows.map(row => html`
<tr class="details-table-row">
<td>${row.key ?? nothing}</td>
@@ -1492,6 +1492,74 @@
// clang-format on
}
+interface CertificateTransparencySectionInput {
+ securityDetails: Protocol.Network.SecurityDetails;
+ isDetailsShown: boolean;
+ onToggleDetails: () => void;
+}
+
+function renderSctSummary(scts: Protocol.Network.SignedCertificateTimestamp[], isShown: boolean): TemplateResult {
+ const rows = scts.map(sct => ({
+ key: i18nString(UIStrings.sct),
+ value: `${sct.logDescription} (${sct.origin}, ${sct.status})`,
+ }));
+ return renderDetailsTable(rows, {'sct-summary': true, hidden: !isShown});
+}
+
+function renderSctDetails(scts: Protocol.Network.SignedCertificateTimestamp[], isShown: boolean): TemplateResult {
+ // clang-format off
+ return html`
+ <div class=${Directives.classMap({'sct-details': true, hidden: !isShown})}>
+ ${scts.map(sct => renderDetailsTable([
+ {key: i18nString(UIStrings.logName), value: sct.logDescription},
+ {key: i18nString(UIStrings.logId), value: sct.logId.replace(/(.{2})/g, '$1 ')},
+ {key: i18nString(UIStrings.validationStatus), value: sct.status},
+ {key: i18nString(UIStrings.source), value: sct.origin},
+ {key: i18nString(UIStrings.issuedAt), value: new Date(sct.timestamp).toUTCString()},
+ {key: i18nString(UIStrings.hashAlgorithm), value: sct.hashAlgorithm},
+ {key: i18nString(UIStrings.signatureAlgorithm), value: sct.signatureAlgorithm},
+ {key: i18nString(UIStrings.signatureData), value: sct.signatureData.replace(/(.{2})/g, '$1 ')},
+ ]))}
+ </div>`;
+ // clang-format on
+}
+
+function renderCertificateTransparencyNote(compliance: Protocol.Network.CertificateTransparencyCompliance):
+ LitTemplate {
+ if (compliance === Protocol.Network.CertificateTransparencyCompliance.Unknown) {
+ return nothing;
+ }
+
+ const note = compliance === Protocol.Network.CertificateTransparencyCompliance.Compliant ?
+ i18nString(UIStrings.thisRequestCompliesWithChromes) :
+ i18nString(UIStrings.thisRequestDoesNotComplyWith);
+
+ return html`<div class="origin-view-section-notes">${note}</div>`;
+}
+
+function renderCertificateTransparencySection(input: CertificateTransparencySectionInput): TemplateResult {
+ const {securityDetails, isDetailsShown, onToggleDetails} = input;
+ const scts = securityDetails.signedCertificateTimestampList;
+ const toggleButtonText =
+ isDetailsShown ? i18nString(UIStrings.hideFullDetails) : i18nString(UIStrings.showFullDetails);
+
+ // clang-format off
+ return html`
+ <div class="origin-view-section-title" role="heading" aria-level="2">${i18nString(UIStrings.certificateTransparency)}</div>
+ ${renderSctSummary(scts, !isDetailsShown)}
+ ${renderSctDetails(scts, isDetailsShown)}
+ ${scts.length ? html`
+ <devtools-button
+ class="details-toggle"
+ .variant=${Buttons.Button.Variant.OUTLINED}
+ .accessibleLabel=${toggleButtonText}
+ .accessibleExpanded=${isDetailsShown}
+ .jslogContext=${'security.toggle-scts-details'}
+ @click=${onToggleDetails}>${toggleButtonText}</devtools-button>` : nothing}
+ ${renderCertificateTransparencyNote(securityDetails.certificateTransparencyCompliance)}`;
+ // clang-format on
+}
+
export class SecurityOriginView extends UI.Widget.VBox {
readonly #origin: Platform.DevToolsPath.UrlString;
readonly #titleSection: HTMLElement;
@@ -1516,80 +1584,10 @@
const sctListLength = originState.securityDetails.signedCertificateTimestampList.length;
const ctCompliance = originState.securityDetails.certificateTransparencyCompliance;
- let sctSection;
- if (sctListLength || ctCompliance !== Protocol.Network.CertificateTransparencyCompliance.Unknown) {
- // Create the Certificate Transparency section outside the callback, so that it appears in the right place.
- sctSection = this.element.createChild('div', 'origin-view-section');
- const sctDiv = sctSection.createChild('div', 'origin-view-section-title');
- sctDiv.textContent = i18nString(UIStrings.certificateTransparency);
- UI.ARIAUtils.markAsHeading(sctDiv, 2);
- }
-
- if (!sctSection) {
+ if (!sctListLength && ctCompliance === Protocol.Network.CertificateTransparencyCompliance.Unknown) {
return;
}
-
- // Show summary of SCT(s) of Certificate Transparency.
- const sctSummaryTable = new SecurityDetailsTable();
- sctSummaryTable.element().classList.add('sct-summary');
- sctSection.appendChild(sctSummaryTable.element());
- for (let i = 0; i < sctListLength; i++) {
- const sct = originState.securityDetails.signedCertificateTimestampList[i];
- sctSummaryTable.addRow(
- i18nString(UIStrings.sct), sct.logDescription + ' (' + sct.origin + ', ' + sct.status + ')');
- }
-
- // Show detailed SCT(s) of Certificate Transparency.
- const sctTableWrapper = sctSection.createChild('div', 'sct-details');
- sctTableWrapper.classList.add('hidden');
- for (let i = 0; i < sctListLength; i++) {
- const sctTable = new SecurityDetailsTable();
- sctTableWrapper.appendChild(sctTable.element());
- const sct = originState.securityDetails.signedCertificateTimestampList[i];
- sctTable.addRow(i18nString(UIStrings.logName), sct.logDescription);
- sctTable.addRow(i18nString(UIStrings.logId), sct.logId.replace(/(.{2})/g, '$1 '));
- sctTable.addRow(i18nString(UIStrings.validationStatus), sct.status);
- sctTable.addRow(i18nString(UIStrings.source), sct.origin);
- sctTable.addRow(i18nString(UIStrings.issuedAt), new Date(sct.timestamp).toUTCString());
- sctTable.addRow(i18nString(UIStrings.hashAlgorithm), sct.hashAlgorithm);
- sctTable.addRow(i18nString(UIStrings.signatureAlgorithm), sct.signatureAlgorithm);
- sctTable.addRow(i18nString(UIStrings.signatureData), sct.signatureData.replace(/(.{2})/g, '$1 '));
- }
-
- // Add link to toggle between displaying of the summary of the SCT(s) and the detailed SCT(s).
- if (sctListLength) {
- function toggleSctDetailsDisplay(): void {
- let buttonText;
- const isDetailsShown = !sctTableWrapper.classList.contains('hidden');
- if (isDetailsShown) {
- buttonText = i18nString(UIStrings.showFullDetails);
- } else {
- buttonText = i18nString(UIStrings.hideFullDetails);
- }
- toggleSctsDetailsLink.textContent = buttonText;
- UI.ARIAUtils.setLabel(toggleSctsDetailsLink, buttonText);
- UI.ARIAUtils.setExpanded(toggleSctsDetailsLink, !isDetailsShown);
- sctSummaryTable.element().classList.toggle('hidden');
- sctTableWrapper.classList.toggle('hidden');
- }
- const toggleSctsDetailsLink = UI.UIUtils.createTextButton(
- i18nString(UIStrings.showFullDetails), toggleSctDetailsDisplay,
- {className: 'details-toggle', jslogContext: 'security.toggle-scts-details'});
- sctSection.appendChild(toggleSctsDetailsLink);
- }
-
- switch (ctCompliance) {
- case Protocol.Network.CertificateTransparencyCompliance.Compliant:
- sctSection.createChild('div', 'origin-view-section-notes').textContent =
- i18nString(UIStrings.thisRequestCompliesWithChromes);
- break;
- case Protocol.Network.CertificateTransparencyCompliance.NotCompliant:
- sctSection.createChild('div', 'origin-view-section-notes').textContent =
- i18nString(UIStrings.thisRequestDoesNotComplyWith);
- break;
- case Protocol.Network.CertificateTransparencyCompliance.Unknown:
- break;
- }
+ this.#createCertificateTransparencySection(originState.securityDetails);
const noteSection = this.element.createChild('div', 'origin-view-section origin-view-notes');
if (originState.loadedFromCache) {
@@ -1640,6 +1638,20 @@
updateCertificateSection();
}
+ #createCertificateTransparencySection(securityDetails: Protocol.Network.SecurityDetails): void {
+ const section = this.element.createChild('div', 'origin-view-section certificate-transparency-section');
+ let isDetailsShown = false;
+ const onToggleDetails = (): void => {
+ isDetailsShown = !isDetailsShown;
+ updateSection();
+ };
+ const updateSection = (): void => {
+ // eslint-disable-next-line @devtools/no-lit-render-outside-of-view
+ render(renderCertificateTransparencySection({securityDetails, isDetailsShown, onToggleDetails}), section);
+ };
+ updateSection();
+ }
+
#showCertificateViewer = async(event: Event): Promise<void> => {
event.consume();
const names = await SDK.NetworkManager.MultitargetNetworkManager.instance().getCertificate(this.#origin);
@@ -1667,30 +1679,6 @@
};
}
-export class SecurityDetailsTable {
- readonly #element: HTMLTableElement;
-
- constructor() {
- this.#element = document.createElement('table');
- this.#element.classList.add('details-table');
- }
-
- element(): HTMLTableElement {
- return this.#element;
- }
-
- addRow(key: string, value: string|Node): void {
- const row = this.#element.createChild('tr', 'details-table-row');
- row.createChild('td').textContent = key;
-
- const valueCell = row.createChild('td');
- if (typeof value === 'string') {
- valueCell.textContent = value;
- } else {
- valueCell.appendChild(value);
- }
- }
-}
export interface OriginState {
securityState: Protocol.Security.SecurityState;
securityDetails: Protocol.Network.SecurityDetails|null;테스트 방법
SecurityPanel.test.ts에 다음 내용을 검증하는 단위 테스트 6개를 추가했습니다.
- 제목과 접근성 속성, SCT 요약·상세 정보 및 표시 형식 확인
- SCT가 없고 CT compliance가
Unknown이면CT section과note section이 생략되는지 확인 - SCT가 있고 CT compliance가
Unknown이면CT section은 표시하고 CT compliance 안내 문구는 생략하는지 확인 Compliant와NotCompliant각각에 맞는 안내 문구 확인- 상세 보기 버튼 클릭 시 요약은 숨기고 상세 정보는 표시하며, 버튼 문구와
accessibleLabel·accessibleExpanded가 갱신되는지 확인
Chromium 보안 웹 테스트 2개의 기대값은 CL 8425664에서 임시 조정했습니다. 최종 patchset의 CQ 실행이 통과했으며, 2026년 9월 23일(한국 시간) DevTools main에 병합되었습니다.
배운 점
- 표시 전환을 검증할 때는 텍스트뿐 아니라 실제 표시 여부와 접근성 상태도 함께 확인해야 합니다.
- 리뷰에서
CT section을 생략하면note section도 생략되는 기존 동작을 확인했습니다. 이 CL은 Lit 전환에 집중하고, 해당 동작 수정은 후속 CL로 진행하기로 했습니다.