OSSCA Chromium

기여 목록 / 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으로 전환할 때는 값의 표시 형식과 조건부 행의 생략 동작을 함께 검증해야 합니다.
  • 테스트 데이터를 필요한 필드만 바꿀 수 있게 구성하면 여러 입력 조합을 간결하게 검증할 수 있습니다.

참고 자료