Files
sousa-gecko/tools/netwerk/decode_ssl_tokens_cache.py
T
Lars Eggert 0d6540bb2c Bug 2039802 - Dedupe/compress SSLTokensCache cert chains r=valentin,kershaw,necko-reviewers
Replace the cert fields in TokenCacheRecord with refcounted
CompressedCert objects shared within each record.
The on-disk format advances to version 3.

Differential Revision: https://phabricator.services.mozilla.com/D300715
2026-06-09 11:53:17 +00:00

474 lines
14 KiB
Python
Executable File

#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.11"
# dependencies = ["brotli", "cryptography", "darkdetect", "humanize", "rich"]
# ///
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
"""Decode and display the contents of an ssl_tokens_cache.bin file.
File format:
magic: 4 bytes b"STCF"
version: 1 byte
Version 2:
body: zlib-compressed bincode-1.3 Vec<PersistedRecord>
PersistedRecord contains separate token and cert-chain fields with raw DER.
Version 3:
body: bincode-1.3 Vec<PersistedRecord>
checksum: 4-byte little-endian Adler-32 of body
PersistedRecord contains a single compressed_payload field
(token + cert info packed together and compressed).
Run with ``uv run decode_ssl_tokens_cache.py`` for automatic dependency
installation, or ``pip install brotli cryptography darkdetect humanize rich``
for plain Python.
"""
import argparse
import datetime
import io
import struct
import sys
import warnings
import zlib
from collections.abc import Callable
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import NamedTuple, TypeVar, cast
try:
import darkdetect
import humanize
from rich import box as rich_box
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from rich.theme import Theme
except ImportError as e:
sys.exit(
f"Missing dependency: {e.name}\n"
f"Run with: uv run {sys.argv[0]}\n"
f"Or install manually: pip install darkdetect humanize rich"
)
_DARK_THEME = Theme({"label": "dim", "cert": "cyan", "live": "green"})
_LIGHT_THEME = Theme({"label": "italic", "cert": "dark_cyan", "live": "dark_green"})
try:
from cryptography import x509 as _x509
except ImportError:
_x509 = None # type: ignore[assignment]
try:
import brotli as _brotli
except ImportError:
_brotli = None # type: ignore[assignment]
MAGIC = b"STCF"
SUPPORTED_VERSIONS = (2, 3)
T = TypeVar("T")
def _adler32_le(data: bytes) -> bytes:
return struct.pack("<I", zlib.adler32(data))
class DecodeError(Exception):
pass
@dataclass
class Record:
key: str
expires: datetime.datetime
token: bytes
ev_status: int
ct_status: int
overridable_error: int
server_cert: bytes
succeeded_cert_chain: list[bytes] | None
handshake_certs: list[bytes] | None
built_in_root: bool | None
size: int = 0
class _ParsedPayload(NamedTuple):
token: bytes
ev: int
ct: int
builtin_root: bool | None
server_cert: bytes
succeeded: list[bytes] | None
handshake: list[bytes] | None
_EMPTY_PAYLOAD = _ParsedPayload(b"", 0, 0, None, b"", None, None)
def _decompress_payload(data: bytes) -> bytes | None:
"""Decompress a record payload (4-byte LE original-size prefix + compressed body)."""
if len(data) < 4:
return None
if _brotli is None:
return None
try:
return _brotli.decompress(data[4:])
except Exception: # pylint: disable=broad-exception-caught
return None
def _parse_payload(payload: bytes) -> _ParsedPayload:
buf = io.BytesIO(payload)
def read(n: int) -> bytes:
chunk = buf.read(n)
if len(chunk) != n:
raise DecodeError("truncated payload")
return chunk
def u8() -> int:
return struct.unpack_from("<B", read(1))[0]
def u16() -> int:
return struct.unpack_from("<H", read(2))[0]
def u32() -> int:
return struct.unpack_from("<I", read(4))[0]
def read_bytes() -> bytes:
return read(u32())
def read_chain() -> list[bytes] | None:
if not u8():
return None
return [read_bytes() for _ in range(u8())]
token = read_bytes()
ev = u8()
ct = u16()
u8() # overridable_error — also stored at the PersistedRecord level; use that value
builtin_tag = u8()
builtin_root: bool | None = None if builtin_tag == 0 else (builtin_tag == 2)
server_cert = read_bytes()
succeeded = read_chain()
handshake = read_chain()
if buf.read(1):
raise DecodeError("trailing bytes in payload")
return _ParsedPayload(
token, ev, ct, builtin_root, server_cert, succeeded, handshake
)
class Reader:
"""Minimal bincode-1.3 (little-endian, u64 lengths) stream reader."""
_U8 = struct.Struct("<B")
_U16 = struct.Struct("<H")
_U64 = struct.Struct("<Q")
_I64 = struct.Struct("<q")
def __init__(self, data: bytes, version: int = 2):
self._buf = io.BytesIO(data)
self._len = len(data)
self._version = version
def remaining(self) -> int:
return self._len - self._buf.tell()
def _read(self, n: int) -> bytes:
chunk = self._buf.read(n)
if len(chunk) != n:
raise DecodeError(
f"truncated: need {n} bytes at offset {self._buf.tell()}, "
f"only {len(chunk)} available"
)
return chunk
def _unpack(self, fmt: struct.Struct) -> int:
(value,) = fmt.unpack(self._read(fmt.size))
return cast(int, value)
def u8(self) -> int:
return self._unpack(self._U8)
def u16(self) -> int:
return self._unpack(self._U16)
def u64(self) -> int:
return self._unpack(self._U64)
def i64(self) -> int:
return self._unpack(self._I64)
def read_bool(self) -> bool:
return bool(self.u8())
def bytes_vec(self) -> bytes:
return self._read(self.u64())
def vec_of_bytes(self) -> list[bytes]:
return [self.bytes_vec() for _ in range(self.u64())]
def option(self, f: Callable[[], T]) -> T | None:
tag = self.u8()
if not tag:
return None
if tag == 1:
return f()
raise DecodeError(f"invalid Option discriminant {tag}")
@staticmethod
def _make_record(
key: str, expires: datetime.datetime, p: _ParsedPayload, overridable: int
) -> Record:
return Record(
key=key,
expires=expires,
token=p.token,
ev_status=p.ev,
ct_status=p.ct,
overridable_error=overridable,
server_cert=p.server_cert,
succeeded_cert_chain=p.succeeded,
handshake_certs=p.handshake,
built_in_root=p.builtin_root,
)
def _record_v2(self, key: str, expires: datetime.datetime) -> Record:
token = self.bytes_vec()
ev = self.u8()
ct = self.u16()
overridable = self.u8()
server_cert = self.bytes_vec()
succeeded = self.option(self.vec_of_bytes)
handshake = self.option(self.vec_of_bytes)
builtin_root = self.option(self.read_bool)
return self._make_record(
key,
expires,
_ParsedPayload(
token, ev, ct, builtin_root, server_cert, succeeded, handshake
),
overridable,
)
def _record_v3(self, key: str, expires: datetime.datetime) -> Record:
overridable = self.u8()
raw_payload = self.bytes_vec()
p = _EMPTY_PAYLOAD
payload = _decompress_payload(raw_payload)
if payload is not None:
try:
p = _parse_payload(payload)
except DecodeError:
pass
return self._make_record(key, expires, p, overridable)
def record(self) -> Record:
start = self._buf.tell()
self.u64() # session-internal id, re-assigned on load; not displayed
key = self.bytes_vec().decode("utf-8", errors="replace")
prtime = self.i64()
expires = datetime.datetime.fromtimestamp(
prtime / 1e6, tz=datetime.timezone.utc
)
rec = (self._record_v3 if self._version >= 3 else self._record_v2)(key, expires)
rec.size = self._buf.tell() - start
return rec
def records(self) -> list[Record]:
return [self.record() for _ in range(self.u64())]
@lru_cache(maxsize=256)
def cert_subject(der: bytes) -> str:
if _x509 is not None:
try:
return str(_x509.load_der_x509_certificate(der).subject.rfc4514_string())
except Exception: # pylint: disable=broad-exception-caught
pass
return f"<{len(der)} bytes DER>"
def _hexdump_text(data: bytes) -> Text:
t = Text()
for off in range(0, len(data), 16):
chunk = data[off : off + 16]
hex_part = " ".join(f"{b:02x}" for b in chunk)
asc_part = "".join(chr(b) if 0x20 <= b < 0x7F else "." for b in chunk)
t.append(f"{off:04x}", style="dim")
t.append(f" {hex_part:<47} ")
t.append(asc_part, style="dim")
t.append("\n")
return t
def _chain_text(chain: list[bytes], verbose: int) -> str:
count = f"{len(chain)} cert(s)"
if verbose >= 1:
subjects = " ".join(cert_subject(c) for c in chain)
return f"{count} {subjects}"
return count
def print_record(
idx: int, rec: Record, verbose: int, now: datetime.datetime, console: Console
) -> None:
expired = rec.expires < now
tbl = Table(box=None, show_header=False, show_edge=False, padding=(0, 1, 0, 0))
tbl.add_column(style="label", min_width=11, no_wrap=True)
tbl.add_column()
rel = humanize.naturaltime(rec.expires, when=now)
status = (
Text("EXPIRED", style="bold red") if expired else Text("live", style="live")
)
tbl.add_row(
"expires",
Text.assemble(
rec.expires.strftime("%Y-%m-%d %H:%M:%S UTC"),
(" ", ""),
(f"({rel})", "dim"),
" ",
status,
),
)
tbl.add_row("token", humanize.naturalsize(len(rec.token)))
if verbose >= 2:
tbl.add_row("", _hexdump_text(rec.token))
flag_parts = [
f"ev={rec.ev_status}",
f"ct={rec.ct_status}",
f"ovr_error={rec.overridable_error}",
*([] if rec.built_in_root is None else [f"built_in_root={rec.built_in_root}"]),
]
tbl.add_row("flags", " ".join(flag_parts))
if rec.server_cert:
tbl.add_row("server cert", Text(cert_subject(rec.server_cert), style="cert"))
for label, chain in [
("chain", rec.succeeded_cert_chain),
("hs certs", rec.handshake_certs),
]:
if chain is not None:
tbl.add_row(label, _chain_text(chain, verbose))
console.print(
Panel(
tbl,
title=f"[bold][{idx}][/] {rec.key} [dim]{humanize.naturalsize(rec.size)}[/]",
title_align="left",
box=rich_box.ROUNDED,
border_style="red" if expired else "default",
)
)
def decode(path: str, verbose: int, console: Console) -> None:
data = Path(path).read_bytes()
if len(data) < 5:
raise DecodeError("file too short to contain header")
magic, version = data[:4], data[4]
if magic != MAGIC:
raise DecodeError(f"bad magic: expected {MAGIC!r}, got {magic!r}")
if version not in SUPPORTED_VERSIONS:
raise DecodeError(
f"unsupported version {version} "
f"(this script handles versions {SUPPORTED_VERSIONS})"
)
if version >= 3 and _brotli is None:
warnings.warn(
"brotli package not found; cert info will be unavailable for v3 records",
stacklevel=2,
)
if version >= 3:
if len(data) < 5 + 4:
raise DecodeError("file too short to contain checksum")
payload, stored_checksum = data[5:-4], data[-4:]
computed = _adler32_le(payload)
if computed != stored_checksum:
raise DecodeError(
f"checksum mismatch: file may be corrupt "
f"(stored {stored_checksum.hex()}, computed {computed.hex()})"
)
else:
try:
payload = zlib.decompress(data[5:])
except zlib.error as e:
raise DecodeError(f"zlib decompression failed: {e}") from e
reader = Reader(payload, version)
records = reader.records()
if reader.remaining():
warnings.warn(
f"{reader.remaining()} trailing bytes after records", stacklevel=2
)
now = datetime.datetime.now(datetime.timezone.utc)
n_expired = sum(r.expires < now for r in records)
summary = Table(box=None, show_header=False, show_edge=False, padding=(0, 1, 0, 0))
summary.add_column(style="label")
summary.add_column()
summary.add_row("version", str(version))
summary.add_row("records", f"{len(records)} [dim]({n_expired} expired)[/dim]")
summary.add_row("body", humanize.naturalsize(len(payload)))
console.print(
Panel(
summary, title=f"[bold]{path}[/]", title_align="left", box=rich_box.ROUNDED
)
)
console.print()
for i, rec in enumerate(records):
print_record(i, rec, verbose, now, console)
def main() -> None:
parser = argparse.ArgumentParser(
description="Decode an ssl_tokens_cache.bin file from a Firefox profile."
)
parser.add_argument("file", help="path to ssl_tokens_cache.bin")
parser.add_argument(
"-v",
"--verbose",
action="count",
default=0,
help="-v: show cert chain subjects; -vv: also hexdump token bytes",
)
parser.add_argument(
"--no-color",
action="store_true",
help="disable color output",
)
args = parser.parse_args()
con = (
Console(no_color=True, theme=_LIGHT_THEME)
if args.no_color
else Console(theme=_DARK_THEME if darkdetect.isDark() else _LIGHT_THEME)
)
try:
decode(args.file, args.verbose, con)
except (DecodeError, OSError) as e:
Console(stderr=True).print(f"[bold red]error:[/] {e}")
sys.exit(1)
if __name__ == "__main__":
main()