Files
certspotter/loglist/mozilla/ctlogs.go
2025-07-04 12:37:51 -04:00

276 lines
6.1 KiB
Go

// Copyright (C) 2025 Opsmate, Inc.
//
// 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/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
// Package mozilla contains a parser for Mozilla's CTKnownLogs.h file
package mozilla
import (
"bufio"
"errors"
"io"
"strconv"
"strings"
"time"
)
// CTLogInfo describes a certificate transparency log from Mozilla's CTKnownLogs.h
// file.
type CTLogInfo struct {
Name string
State string // Admissible or Retired
Protocol string // RFC6962 or Tiled
Timestamp time.Time
OperatorIndex int
Key []byte
}
// CTLogOperatorInfo describes a CT log operator from Mozilla's CTKnownLogs.h
// file.
type CTLogOperatorInfo struct {
Name string
ID int
}
// Parse reads the CTKnownLogs.h content from r and returns the parsed logs and
// operators. Blocks enclosed by `#ifdef DEBUG` and `#endif` are ignored.
func Parse(r io.Reader) ([]CTLogInfo, []CTLogOperatorInfo, error) {
scanner := bufio.NewScanner(r)
skip := 0
inLogs := false
inOps := false
var logs []CTLogInfo
var ops []CTLogOperatorInfo
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "#ifdef DEBUG"):
skip++
continue
case strings.HasPrefix(trimmed, "#endif"):
if skip > 0 {
skip--
continue
}
}
if skip > 0 {
continue
}
if strings.HasPrefix(trimmed, "const CTLogInfo kCTLogList[]") {
inLogs = true
continue
}
if strings.HasPrefix(trimmed, "const CTLogOperatorInfo kCTLogOperatorList[]") {
inOps = true
continue
}
if inLogs {
if trimmed == "};" {
inLogs = false
continue
}
if strings.HasPrefix(trimmed, "{") {
log, err := readLogEntry(trimmed, scanner)
if err != nil {
return nil, nil, err
}
logs = append(logs, log)
}
continue
}
if inOps {
if trimmed == "};" {
inOps = false
continue
}
if strings.HasPrefix(trimmed, "{") {
op, err := readOperatorEntry(trimmed)
if err != nil {
return nil, nil, err
}
ops = append(ops, op)
}
}
}
if err := scanner.Err(); err != nil {
return nil, nil, err
}
return logs, ops, nil
}
func readLogEntry(firstLine string, s *bufio.Scanner) (CTLogInfo, error) {
var log CTLogInfo
// Example first line:
// {"Name", CTLogState::Admissible, CTLogFormat::RFC6962,
firstLine = strings.TrimSpace(firstLine)
if !strings.HasPrefix(firstLine, "{") {
return log, errors.New("invalid log entry start")
}
firstLine = strings.TrimPrefix(firstLine, "{")
firstLine = strings.TrimSuffix(firstLine, ",")
parts := splitCSV(firstLine)
for len(parts) < 3 {
if !s.Scan() {
return log, io.ErrUnexpectedEOF
}
next := strings.TrimSpace(strings.TrimSuffix(s.Text(), ","))
parts = append(parts, splitCSV(next)...)
}
log.Name = trimQuotes(parts[0])
log.State = strings.TrimPrefix(strings.TrimSpace(parts[1]), "CTLogState::")
log.Protocol = strings.TrimPrefix(strings.TrimSpace(parts[2]), "CTLogFormat::")
// Next line: timestamp
if !s.Scan() {
return log, io.ErrUnexpectedEOF
}
tsLine := strings.TrimSpace(s.Text())
tsValue := strings.Split(tsLine, ",")[0]
ts, err := strconv.ParseInt(strings.TrimSpace(tsValue), 10, 64)
if err != nil {
return log, err
}
log.Timestamp = time.Unix(0, ts*int64(time.Millisecond))
// Next line: operator index
if !s.Scan() {
return log, io.ErrUnexpectedEOF
}
opLine := strings.TrimSpace(s.Text())
opValue := strings.Split(opLine, ",")[0]
opIndex, err := strconv.Atoi(strings.TrimSpace(opValue))
if err != nil {
return log, err
}
log.OperatorIndex = opIndex
// Key lines
var keyHex strings.Builder
for {
if !s.Scan() {
return log, io.ErrUnexpectedEOF
}
l := strings.TrimSpace(s.Text())
if strings.HasPrefix(l, "\"") {
// remove trailing comma if any
trimmed := strings.TrimSuffix(l, ",")
keyHex.WriteString(trimQuotes(trimmed))
if strings.HasSuffix(l, ",") {
// last key line
break
}
continue
}
return log, errors.New("unexpected line while reading key")
}
key, err := decodeHexEscapes(keyHex.String())
if err != nil {
return log, err
}
// key length line
if !s.Scan() {
return log, io.ErrUnexpectedEOF
}
lenLine := strings.TrimSpace(s.Text())
lenValue := strings.TrimSuffix(lenLine, "},")
keyLen, err := strconv.Atoi(strings.TrimSpace(lenValue))
if err != nil {
return log, err
}
if len(key) != keyLen {
// ignore mismatch but continue
}
log.Key = key
return log, nil
}
func readOperatorEntry(line string) (CTLogOperatorInfo, error) {
var op CTLogOperatorInfo
line = strings.TrimSuffix(strings.TrimSpace(line), ",")
if !strings.HasPrefix(line, "{") || !strings.HasSuffix(line, "}") {
return op, errors.New("invalid operator entry")
}
line = strings.TrimPrefix(line, "{")
line = strings.TrimSuffix(line, "}")
parts := splitCSV(line)
if len(parts) != 2 {
return op, errors.New("invalid operator fields")
}
op.Name = trimQuotes(parts[0])
id, err := strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil {
return op, err
}
op.ID = id
return op, nil
}
func splitCSV(s string) []string {
var parts []string
var cur strings.Builder
inQuote := false
for i := 0; i < len(s); i++ {
c := s[i]
if c == '"' {
inQuote = !inQuote
cur.WriteByte(c)
continue
}
if c == ',' && !inQuote {
parts = append(parts, strings.TrimSpace(cur.String()))
cur.Reset()
continue
}
cur.WriteByte(c)
}
if cur.Len() > 0 {
parts = append(parts, strings.TrimSpace(cur.String()))
}
return parts
}
func trimQuotes(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
return s[1 : len(s)-1]
}
return s
}
func decodeHexEscapes(s string) ([]byte, error) {
var out []byte
for i := 0; i < len(s); {
if i+3 >= len(s) || s[i] != '\\' || s[i+1] != 'x' {
return nil, errors.New("invalid escape")
}
b, err := strconv.ParseUint(s[i+2:i+4], 16, 8)
if err != nil {
return nil, err
}
out = append(out, byte(b))
i += 4
}
return out, nil
}