Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions BitMatchTests/ComparePresentationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,17 @@ struct ComparePresentationTests {
#expect(verdict.detail.contains("File contents checked; MHL history and directory structure were not verified."))
}

@Test func validatedASCChainDoesNotClaimDirectoryOrAuthenticityVerification() {
let history = Self.card.appendingPathComponent("ascmhl")
let result = SavedChecksumCheck.Result(root: Self.card, matchingPaths: ["a.mov"], changedPaths: [],
missingPaths: [], newPaths: [], records: [.init(url: history.appendingPathComponent("one.mhl"),
date: Date(), kind: .ascMHL)], validatedHistoryDirectories: [history])
let verdict = CompareVerdictPresentation.makeSaved(result)
#expect(verdict.tone == .verified)
#expect(verdict.detail.contains("File contents and supported MHL inventory chains checked"))
#expect(verdict.detail.contains("directory structure and authenticity were not verified"))
}

@Test func zeroByteFolderUsesEmptyWording() {
let slot = CompareFolderSlot.make(
url: Self.card, infoURL: Self.card, fileCount: 1, totalSize: 0, isFetching: false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import CryptoKit
import Darwin
import Foundation
#if canImport(FoundationXML)
import FoundationXML
#endif

/// Read-only validation of supported ASC directory records. C4 binds the listed
/// inventory bytes; it is not authentication, directory-hash validation, or proof
/// that an unanchored external chain has never had a tail generation removed.
enum ASCMHLChainValidator {
static let maxGenerations = 4096
static let maxChainBytes = 4 * 1024 * 1024

struct FileIdentity: Equatable, Sendable {
let device: Int32
let inode: UInt64
let size: Int64
let modificationSeconds: Int64
let modificationNanoseconds: Int64
let changeSeconds: Int64
let changeNanoseconds: Int64
let sha256: String
}
struct Snapshot: Equatable, Sendable {
let directory: URL
let names: [String]
let device: Int32
let inode: UInt64
let evidence: [String: FileIdentity]
}

static func validate(directory: URL) throws -> Snapshot {
try Task.checkCancellation()
let fd = open(directory.path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)
guard fd >= 0 else { throw SavedChecksumCheck.CheckError.unreadableEvidence }
defer { close(fd) }
var before = stat()
guard fstat(fd, &before) == 0 else { throw SavedChecksumCheck.CheckError.unreadableEvidence }
let (bytes, identity) = try read("ascmhl_chain.xml", directoryFD: fd, maxBytes: maxChainBytes)
guard String(data: bytes, encoding: .utf8)?.contains("<!DOCTYPE") == false else { throw SavedChecksumCheck.CheckError.unsupportedEvidence }
let delegate = ChainParser()
let parser = XMLParser(data: bytes)
parser.shouldProcessNamespaces = true
parser.shouldResolveExternalEntities = false
parser.delegate = delegate
let parsed = parser.parse()
try Task.checkCancellation()
if let error = delegate.failure { throw error }
guard parsed, !delegate.items.isEmpty else { throw SavedChecksumCheck.CheckError.unreadableEvidence }
var evidence = ["ascmhl_chain.xml": identity]
var statusInfo = stat()
if fstatat(fd, ASCMHLGenerator.publicationStatusName, &statusInfo, AT_SYMLINK_NOFOLLOW) == 0 {
evidence[ASCMHLGenerator.publicationStatusName] = try read(ASCMHLGenerator.publicationStatusName, directoryFD: fd, maxBytes: 1024).1
} else if errno != ENOENT { throw SavedChecksumCheck.CheckError.unreadableEvidence }
var names: [String] = []
for (index, item) in delegate.items.enumerated() {
try Task.checkCancellation()
guard item.sequence == index + 1, safeName(item.path), !names.contains(item.path) else { throw SavedChecksumCheck.CheckError.unreadableEvidence }
let (data, fingerprint) = try read(item.path, directoryFD: fd, maxBytes: EvidenceReader.maxReportBytes)
guard ASCMHLGenerator.c4(data) == item.c4 else { throw SavedChecksumCheck.CheckError.unreadableEvidence }
names.append(item.path)
evidence[item.path] = fingerprint
}
// A chain cannot silently leave another inventory outside its scope.
let listed = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
var actual = Set<String>()
for url in listed where url.pathExtension.caseInsensitiveCompare("mhl") == .orderedSame {
try Task.checkCancellation()
if url.lastPathComponent.hasPrefix("._"), names.contains(String(url.lastPathComponent.dropFirst(2))) {
let companionFD = openat(fd, url.lastPathComponent, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC)
guard companionFD >= 0 else { throw SavedChecksumCheck.CheckError.unreadableEvidence }
defer { close(companionFD) }
var info = stat()
guard fstat(companionFD, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG else { throw SavedChecksumCheck.CheckError.unreadableEvidence }
if try AppleDoubleSelection.isMetadataOnlyCompanion(fd: companionFD, size: info.st_size) { continue }
}
actual.insert(url.lastPathComponent)
}
guard actual == Set(names) else { throw SavedChecksumCheck.CheckError.unsupportedEvidence }
var after = stat()
var named = stat()
guard fstat(fd, &after) == 0, lstat(directory.path, &named) == 0,
before.st_dev == after.st_dev, before.st_ino == after.st_ino,
after.st_dev == named.st_dev, after.st_ino == named.st_ino,
(named.st_mode & S_IFMT) == S_IFDIR else { throw SavedChecksumCheck.CheckError.unreadableEvidence }
return Snapshot(directory: directory, names: names, device: after.st_dev, inode: UInt64(after.st_ino), evidence: evidence)
}

private static func safeName(_ name: String) -> Bool {
!name.isEmpty && name != "." && name != ".." && !name.contains("/") && !name.contains("\\")
&& !name.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) })
&& name.lowercased().hasSuffix(".mhl") && !name.hasPrefix("._")
}

private static func read(_ name: String, directoryFD: Int32, maxBytes: Int) throws -> (Data, FileIdentity) {
try Task.checkCancellation()
let fd = openat(directoryFD, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC)
guard fd >= 0 else { throw SavedChecksumCheck.CheckError.unreadableEvidence }
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
defer { try? handle.close() }
var before = stat()
guard fstat(fd, &before) == 0, (before.st_mode & S_IFMT) == S_IFREG,
before.st_size >= 0, before.st_size <= maxBytes else { throw SavedChecksumCheck.CheckError.unreadableEvidence }
var data = Data()
while data.count < Int(before.st_size) {
try Task.checkCancellation()
let count = min(64 * 1024, Int(before.st_size) - data.count)
let chunk = try autoreleasepool { try handle.read(upToCount: count) ?? Data() }
guard !chunk.isEmpty else { throw SavedChecksumCheck.CheckError.unreadableEvidence }
data.append(chunk)
}
guard (try handle.read(upToCount: 1) ?? Data()).isEmpty else { throw SavedChecksumCheck.CheckError.unreadableEvidence }
var after = stat()
var named = stat()
guard fstat(fd, &after) == 0, fstatat(directoryFD, name, &named, AT_SYMLINK_NOFOLLOW) == 0,
identity(before, digest: "") == identity(after, digest: ""),
after.st_dev == named.st_dev, after.st_ino == named.st_ino,
(named.st_mode & S_IFMT) == S_IFREG else { throw SavedChecksumCheck.CheckError.unreadableEvidence }
return (data, identity(after, digest: SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()))
}

private static func identity(_ s: stat, digest: String) -> FileIdentity {
FileIdentity(device: s.st_dev, inode: UInt64(s.st_ino), size: s.st_size,
modificationSeconds: Int64(s.st_mtimespec.tv_sec), modificationNanoseconds: Int64(s.st_mtimespec.tv_nsec),
changeSeconds: Int64(s.st_ctimespec.tv_sec), changeNanoseconds: Int64(s.st_ctimespec.tv_nsec), sha256: digest)
}

private final class ChainParser: NSObject, XMLParserDelegate {
struct Item { var sequence: Int; var path = ""; var c4 = "" }
var items: [Item] = []
var failure: SavedChecksumCheck.CheckError?
private var stack: [String] = []
private var text = ""
private var fields = Set<String>()
private var current: Item?
private func reject(_ parser: XMLParser, _ error: SavedChecksumCheck.CheckError = .unreadableEvidence) {
failure = error; parser.abortParsing()
}
func parser(_ parser: XMLParser, didStartElement name: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) {
if Task.isCancelled { parser.abortParsing(); return }
guard namespaceURI == "urn:ASC:MHL:DIRECTORY:v2.0" else { reject(parser, .unsupportedEvidence); return }
let expected: Bool
switch stack {
case []: expected = name == "ascmhldirectory" && attributes.isEmpty
case ["ascmhldirectory"]:
expected = name == "hashlist" && attributes.count == 1 && attributes["sequencenr"].flatMap(Int.init) != nil
if expected {
guard items.count < maxGenerations else { reject(parser, .unsupportedEvidence); return }
current = Item(sequence: Int(attributes["sequencenr"]!)!); fields.removeAll()
}
case ["ascmhldirectory", "hashlist"]:
expected = (name == "path" && fields.isEmpty || name == "c4" && fields == ["path"]) && attributes.isEmpty && fields.insert(name).inserted
default: expected = false
}
guard expected else { reject(parser, .unsupportedEvidence); return }
stack.append(name); text = ""
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
if stack.count != 3 && !string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { reject(parser); return }
text += string
}
func parser(_ parser: XMLParser, foundCDATA data: Data) { reject(parser, .unsupportedEvidence) }
func parser(_ parser: XMLParser, foundInternalEntityDeclarationWithName name: String, value: String?) { reject(parser, .unsupportedEvidence) }
func parser(_ parser: XMLParser, foundExternalEntityDeclarationWithName name: String, publicID: String?, systemID: String?) { reject(parser, .unsupportedEvidence) }
func parser(_ parser: XMLParser, didEndElement name: String, namespaceURI: String?, qualifiedName: String?) {
defer { if !stack.isEmpty { stack.removeLast() }; text = "" }
if stack.count == 3 {
if name == "path" { current?.path = text }
if name == "c4" { current?.c4 = text }
} else if name == "hashlist" {
guard let current, fields == ["path", "c4"] else { reject(parser); return }
items.append(current); self.current = nil
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@ public enum ASCMHLGenerator: Sendable {
return Data("COMPLETE \(name) \(manifest.count) \(manifestHash) \(chain.count) \(chainHash)\n".utf8)
}

/// Exact v1 writer bytes, also used to verify the original FAT completion
/// witness after a different tool appends supported generations. Equivalent
/// XML is insufficient: size and SHA-256 must match the original witness.
static func initialChain(name: String, manifest: Data) -> String {
"""
<?xml version="1.0" encoding="UTF-8"?>
<ascmhldirectory xmlns="urn:ASC:MHL:DIRECTORY:v2.0">
<hashlist sequencenr="1"><path>\(name)</path><c4>\(c4(manifest))</c4></hashlist>
</ascmhldirectory>

"""
}

private static func isGeneratedFATManifestName(_ name: String) -> Bool {
let match = name.range(
of: #"^0001_BitMatch_FAT_[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{6}Z\.mhl$"#,
Expand Down Expand Up @@ -295,13 +308,7 @@ public enum ASCMHLGenerator: Sendable {
timestamp.dateFormat = "yyyy-MM-dd_HHmmss'Z'"
let fatFamily = ["exfat", "msdos"].contains(destinationFileSystemType?.lowercased() ?? "")
let name = "\(fatFamily ? fatManifestPrefix : "0001_BitMatch_")\(timestamp.string(from: Date())).mhl"
let chain = """
<?xml version="1.0" encoding="UTF-8"?>
<ascmhldirectory xmlns="urn:ASC:MHL:DIRECTORY:v2.0">
<hashlist sequencenr="1"><path>\(name)</path><c4>\(c4(Data(xml.utf8)))</c4></hashlist>
</ascmhldirectory>

"""
let chain = initialChain(name: name, manifest: Data(xml.utf8))
try Task.checkCancellation()
try rejectExistingHistory(root)
var currentIdentity = stat()
Expand Down Expand Up @@ -496,8 +503,12 @@ public enum ASCMHLGenerator: Sendable {

/// Check's reader uses the same exact status format as the writer. A
/// missing, truncated, replaced or mismatching completion stays unsafe.
static func publicationIsComplete(at statusURL: URL) throws -> Bool {
let directoryFD = open(statusURL.deletingLastPathComponent().path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)
static func publicationIsComplete(
at statusURL: URL,
beforeFinalIdentityCheck: (@Sendable () throws -> Void)? = nil
) throws -> Bool {
let directory = statusURL.deletingLastPathComponent()
let directoryFD = open(directory.path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)
guard directoryFD >= 0 else { return false }
defer { close(directoryFD) }
do {
Expand All @@ -516,9 +527,32 @@ public enum ASCMHLGenerator: Sendable {
let manifestName = String(fields[1])
guard let manifest = try boundedFile(manifestName, directoryFD: directoryFD, maxBytes: EvidenceReader.maxReportBytes),
let chain = try boundedFile("ascmhl_chain.xml", directoryFD: directoryFD, maxBytes: EvidenceReader.maxReportBytes),
manifest.count == manifestSize, chain.count == chainSize else { return false }
return hex(SHA256.hash(data: manifest)) == fields[3].lowercased()
&& hex(SHA256.hash(data: chain)) == fields[5].lowercased()
manifest.count == manifestSize,
hex(SHA256.hash(data: manifest)) == fields[3].lowercased() else { return false }
if chain.count != chainSize || hex(SHA256.hash(data: chain)) != fields[5].lowercased() {
let original = Data(initialChain(name: manifestName, manifest: manifest).utf8)
guard original.count == chainSize, hex(SHA256.hash(data: original)) == fields[5].lowercased() else { return false }
let validated = try ASCMHLChainValidator.validate(directory: directory)
var pinnedDirectory = stat()
guard fstat(directoryFD, &pinnedDirectory) == 0,
validated.device == pinnedDirectory.st_dev, validated.inode == UInt64(pinnedDirectory.st_ino),
validated.evidence[manifestName]?.size == Int64(manifestSize),
validated.evidence[manifestName]?.sha256 == fields[3].lowercased(),
validated.evidence["ascmhl_chain.xml"]?.sha256 == hex(SHA256.hash(data: chain)),
validated.evidence[publicationStatusName]?.sha256 == hex(SHA256.hash(data: status)),
validated.names.count > 1, validated.names.first == manifestName else { return false }
}
// Test seam only: production never supplies a hook. Both the
// original-chain and extended-chain paths must still name the
// directory whose descriptors supplied this completion evidence.
try beforeFinalIdentityCheck?()
var pinnedDirectory = stat()
var namedDirectory = stat()
guard fstat(directoryFD, &pinnedDirectory) == 0, lstat(directory.path, &namedDirectory) == 0,
(namedDirectory.st_mode & S_IFMT) == S_IFDIR,
pinnedDirectory.st_dev == namedDirectory.st_dev,
pinnedDirectory.st_ino == namedDirectory.st_ino else { return false }
return true
} catch is CancellationError {
throw CancellationError()
} catch {
Expand Down
Loading
Loading