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
474 lines
14 KiB
Python
Executable File
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()
|