From 1d9415b1a70a8f83afe285ad750e66deb4bb9da0 Mon Sep 17 00:00:00 2001 From: Max Howell Date: Fri, 31 Jul 2026 23:08:43 -0400 Subject: [PATCH 1/7] Add Sigstore TUF trust foundation --- Attestation/Primitives.swift | 371 ++++++++++++ Attestation/TUF.swift | 546 ++++++++++++++++++ Package.swift | 6 +- Resources/README.txt | 1 + Resources/trusted-root.json | 126 ++++ Resources/tuf-root.json | 141 +++++ .../AttestationPrimitiveTests.swift | 54 ++ Tests/AppUpdaterTests/TUFTests.swift | 162 ++++++ Tests/Fixtures/tuf-14.root.json | 141 +++++ Tests/Fixtures/tuf-14.targets.json | 173 ++++++ Tests/Fixtures/tuf-15.root.json | 141 +++++ Tests/Fixtures/tuf-165.snapshot.json | 54 ++ Tests/Fixtures/tuf-742.timestamp.json | 19 + Tests/Fixtures/tuf-timestamp.json | 19 + Tests/Fixtures/tuf-trusted-root.json | 126 ++++ 15 files changed, 2078 insertions(+), 2 deletions(-) create mode 100644 Attestation/Primitives.swift create mode 100644 Attestation/TUF.swift create mode 100644 Resources/README.txt create mode 100644 Resources/trusted-root.json create mode 100644 Resources/tuf-root.json create mode 100644 Tests/AppUpdaterTests/AttestationPrimitiveTests.swift create mode 100644 Tests/AppUpdaterTests/TUFTests.swift create mode 100644 Tests/Fixtures/tuf-14.root.json create mode 100644 Tests/Fixtures/tuf-14.targets.json create mode 100644 Tests/Fixtures/tuf-15.root.json create mode 100644 Tests/Fixtures/tuf-165.snapshot.json create mode 100644 Tests/Fixtures/tuf-742.timestamp.json create mode 100644 Tests/Fixtures/tuf-timestamp.json create mode 100644 Tests/Fixtures/tuf-trusted-root.json diff --git a/Attestation/Primitives.swift b/Attestation/Primitives.swift new file mode 100644 index 0000000..e6a19dc --- /dev/null +++ b/Attestation/Primitives.swift @@ -0,0 +1,371 @@ +import CryptoKit +import Foundation + +enum AttestationFailure: Error, Equatable { + case invalid(String) +} + +extension Data { + init?(hex: String) { + guard hex.count.isMultiple(of: 2) else { return nil } + var bytes = [UInt8]() + bytes.reserveCapacity(hex.count / 2) + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + guard let byte = UInt8(hex[index.. Data { + try canonicalize(value, tuf: false) + } + + static func canonicalizeTUF(_ value: Any) throws -> Data { + try canonicalize(value, tuf: true) + } + + private static func canonicalize(_ value: Any, tuf: Bool) throws -> Data { + var output = Data() + try append(value, tuf: tuf, to: &output) + return output + } + + private static func append(_ value: Any, tuf: Bool, to output: inout Data) throws { + switch value { + case let dictionary as [String: Any]: + output.append(UInt8(ascii: "{")) + for (offset, key) in dictionary.keys.sorted().enumerated() { + if offset > 0 { output.append(UInt8(ascii: ",")) } + try appendString(key, tuf: tuf, to: &output) + output.append(UInt8(ascii: ":")) + guard let item = dictionary[key] else { + throw AttestationFailure.invalid("missing JSON value") + } + try append(item, tuf: tuf, to: &output) + } + output.append(UInt8(ascii: "}")) + case let array as [Any]: + output.append(UInt8(ascii: "[")) + for (offset, item) in array.enumerated() { + if offset > 0 { output.append(UInt8(ascii: ",")) } + try append(item, tuf: tuf, to: &output) + } + output.append(UInt8(ascii: "]")) + case let string as String: + try appendString(string, tuf: tuf, to: &output) + case let number as NSNumber: + if CFGetTypeID(number) == CFBooleanGetTypeID() { + output.append(contentsOf: number.boolValue ? Data("true".utf8) : Data("false".utf8)) + } else { + let double = number.doubleValue + guard double.isFinite, double.rounded() == double else { + throw AttestationFailure.invalid("non-integer JSON number") + } + output.append(contentsOf: Data(String(number.int64Value).utf8)) + } + case _ as NSNull: + output.append(contentsOf: Data("null".utf8)) + default: + throw AttestationFailure.invalid("unsupported JSON value") + } + } + + private static func appendString(_ string: String, tuf: Bool, to output: inout Data) throws { + output.append(UInt8(ascii: "\"")) + for scalar in string.unicodeScalars { + if tuf { + if scalar.value == 0x22 || scalar.value == 0x5c { + output.append(UInt8(ascii: "\\")) + } + output.append(contentsOf: Data(String(scalar).utf8)) + continue + } + switch scalar.value { + case 0x08: output.append(contentsOf: Data(#"\b"#.utf8)) + case 0x09: output.append(contentsOf: Data(#"\t"#.utf8)) + case 0x0a: output.append(contentsOf: Data(#"\n"#.utf8)) + case 0x0c: output.append(contentsOf: Data(#"\f"#.utf8)) + case 0x0d: output.append(contentsOf: Data(#"\r"#.utf8)) + case 0x22: output.append(contentsOf: Data(#"\""#.utf8)) + case 0x5c: output.append(contentsOf: Data(#"\\"#.utf8)) + case 0..<0x20: + output.append(contentsOf: Data(String(format: #"\u%04x"#, scalar.value).utf8)) + default: + output.append(contentsOf: Data(String(scalar).utf8)) + } + } + output.append(UInt8(ascii: "\"")) + } +} + +enum SignatureVerifier { + static func verifyP256(spkiDER: Data, signatureDER: Data, message: Data) throws { + let key = try P256.Signing.PublicKey(derRepresentation: spkiDER) + let signature = if signatureDER.count == 64 { + try P256.Signing.ECDSASignature(rawRepresentation: signatureDER) + } else { + try P256.Signing.ECDSASignature(derRepresentation: signatureDER) + } + guard key.isValidSignature(signature, for: message) else { + throw AttestationFailure.invalid("invalid P-256 signature") + } + } + + static func verifyEd25519(spkiDER: Data, signature: Data, message: Data) throws { + let node = try DER.parse(spkiDER) + let children = try node.children(in: spkiDER) + guard children.count == 2, + children[1].tag == 0x03, + children[1].content(in: spkiDER).first == 0, + children[1].content(in: spkiDER).count == 33 + else { throw AttestationFailure.invalid("invalid Ed25519 SPKI") } + let rawKey = children[1].content(in: spkiDER).dropFirst() + let key = try Curve25519.Signing.PublicKey(rawRepresentation: rawKey) + guard key.isValidSignature(signature, for: message) else { + throw AttestationFailure.invalid("invalid Ed25519 signature") + } + } +} + +struct DER { + struct Node { + let tag: UInt8 + let encodedRange: Range + let contentRange: Range + + func encoded(in data: Data) -> Data { data.subdata(in: encodedRange) } + func content(in data: Data) -> Data { data.subdata(in: contentRange) } + + func children(in data: Data) throws -> [Node] { + var offset = contentRange.lowerBound + var result = [Node]() + while offset < contentRange.upperBound { + let node = try DER.parse(data, at: &offset) + guard node.encodedRange.upperBound <= contentRange.upperBound else { + throw AttestationFailure.invalid("DER child exceeds parent") + } + result.append(node) + } + guard offset == contentRange.upperBound else { + throw AttestationFailure.invalid("invalid DER children") + } + return result + } + } + + static func parse(_ data: Data) throws -> Node { + var offset = 0 + let node = try parse(data, at: &offset) + guard offset == data.count else { + throw AttestationFailure.invalid("trailing DER data") + } + return node + } + + static func parse(_ data: Data, at offset: inout Int) throws -> Node { + let start = offset + guard offset < data.count else { throw AttestationFailure.invalid("truncated DER tag") } + let tag = data[offset] + offset += 1 + guard tag & 0x1f != 0x1f, offset < data.count else { + throw AttestationFailure.invalid("unsupported DER tag") + } + let first = data[offset] + offset += 1 + let length: Int + if first & 0x80 == 0 { + length = Int(first) + } else { + let count = Int(first & 0x7f) + guard count > 0, count <= MemoryLayout.size, offset + count <= data.count, + data[offset] != 0 + else { throw AttestationFailure.invalid("invalid DER length") } + var value = 0 + for byte in data[offset..<(offset + count)] { + guard value <= (Int.max - Int(byte)) / 256 else { + throw AttestationFailure.invalid("DER length overflow") + } + value = value * 256 + Int(byte) + } + offset += count + length = value + } + let contentStart = offset + guard length >= 0, contentStart <= data.count - length else { + throw AttestationFailure.invalid("truncated DER content") + } + offset += length + return Node( + tag: tag, + encodedRange: start.. Data { + var result = Data([tag]) + if content.count < 128 { + result.append(UInt8(content.count)) + } else { + var length = content.count + var bytes = [UInt8]() + while length > 0 { + bytes.append(UInt8(length & 0xff)) + length >>= 8 + } + result.append(0x80 | UInt8(bytes.count)) + result.append(contentsOf: bytes.reversed()) + } + result.append(content) + return result + } + + static func oid(_ data: Data) throws -> String { + guard let first = data.first else { throw AttestationFailure.invalid("empty OID") } + var parts = [Int(first) / 40, Int(first) % 40] + var value = 0 + var active = false + for byte in data.dropFirst() { + active = true + guard value <= (Int.max - Int(byte & 0x7f)) / 128 else { + throw AttestationFailure.invalid("OID overflow") + } + value = value * 128 + Int(byte & 0x7f) + if byte & 0x80 == 0 { + parts.append(value) + value = 0 + active = false + } + } + guard !active else { throw AttestationFailure.invalid("truncated OID") } + return parts.map(String.init).joined(separator: ".") + } +} + +enum Snappy { + static func decompress(_ input: Data, maximumOutputBytes: Int) throws -> Data { + var cursor = 0 + let expected = try readVarint(input, cursor: &cursor) + guard expected <= maximumOutputBytes else { + throw AttestationFailure.invalid("Snappy output exceeds limit") + } + var output = Data() + output.reserveCapacity(expected) + while cursor < input.count, output.count < expected { + let tag = input[cursor] + cursor += 1 + switch tag & 0x03 { + case 0: + var length = Int(tag >> 2) + if length < 60 { + length += 1 + } else { + let count = length - 59 + guard count <= 4, cursor + count <= input.count else { + throw AttestationFailure.invalid("invalid Snappy literal") + } + length = 1 + for index in 0..> 2) & 0x7) + guard cursor < input.count else { throw AttestationFailure.invalid("truncated Snappy copy") } + let offset = Int(tag & 0xe0) << 3 | Int(input[cursor]) + cursor += 1 + try copy(length: length, offset: offset, into: &output, expected: expected) + case 2: + guard cursor + 2 <= input.count else { throw AttestationFailure.invalid("truncated Snappy copy") } + let offset = Int(input[cursor]) | Int(input[cursor + 1]) << 8 + cursor += 2 + try copy(length: 1 + Int(tag >> 2), offset: offset, into: &output, expected: expected) + default: + guard cursor + 4 <= input.count else { throw AttestationFailure.invalid("truncated Snappy copy") } + var offset = 0 + for index in 0..<4 { offset |= Int(input[cursor + index]) << (8 * index) } + cursor += 4 + try copy(length: 1 + Int(tag >> 2), offset: offset, into: &output, expected: expected) + } + } + guard output.count == expected, cursor == input.count else { + throw AttestationFailure.invalid("invalid Snappy stream") + } + return output + } + + private static func readVarint(_ input: Data, cursor: inout Int) throws -> Int { + var value = 0 + var shift = 0 + while cursor < input.count, shift <= 28 { + let byte = input[cursor] + cursor += 1 + value |= Int(byte & 0x7f) << shift + if byte & 0x80 == 0 { return value } + shift += 7 + } + throw AttestationFailure.invalid("invalid Snappy length") + } + + private static func copy(length: Int, offset: Int, into output: inout Data, expected: Int) throws { + guard offset > 0, offset <= output.count, length <= expected - output.count else { + throw AttestationFailure.invalid("invalid Snappy copy") + } + for _ in 0..> UInt64(inner)).nonzeroBitCount + guard hashes.count == inner + border else { + throw AttestationFailure.invalid("invalid Merkle proof length") + } + var hash = Data([0]).appending(body).sha256 + for (level, sibling) in hashes.enumerated() { + guard sibling.count == 32 else { + throw AttestationFailure.invalid("invalid Merkle hash") + } + if level < inner, (leafIndex >> UInt64(level)) & 1 == 0 { + hash = Data([1]).appending(hash).appending(sibling).sha256 + } else { + hash = Data([1]).appending(sibling).appending(hash).sha256 + } + } + guard hash == expectedRoot else { + throw AttestationFailure.invalid("invalid Merkle inclusion proof") + } + } +} + +private extension Data { + func appending(_ other: Data) -> Data { + var result = self + result.append(other) + return result + } +} diff --git a/Attestation/TUF.swift b/Attestation/TUF.swift new file mode 100644 index 0000000..6569688 --- /dev/null +++ b/Attestation/TUF.swift @@ -0,0 +1,546 @@ +import CryptoKit +import Foundation + +struct SigstoreTrustedRoot: Decodable, Sendable { + struct Validity: Decodable, Sendable { + let start: Date? + let end: Date? + + enum CodingKeys: String, CodingKey { case start, end } + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + start = try values.decodeIfPresent(String.self, forKey: .start).map(DateParser.parse) + end = try values.decodeIfPresent(String.self, forKey: .end).map(DateParser.parse) + } + + func contains(_ date: Date) -> Bool { + start.map { $0 <= date } ?? true && end.map { date <= $0 } ?? true + } + } + + struct PublicKey: Decodable, Sendable { + let rawBytes: Data + let keyDetails: String + let validFor: Validity? + + enum CodingKeys: String, CodingKey { case rawBytes, keyDetails, validFor } + } + + struct Log: Decodable, Sendable { + struct ID: Decodable, Sendable { let keyId: Data } + let publicKey: PublicKey + let logId: ID + let baseUrl: String + } + + struct Certificate: Decodable, Sendable { let rawBytes: Data } + struct Chain: Decodable, Sendable { let certificates: [Certificate] } + struct Authority: Decodable, Sendable { + let uri: String + let certChain: Chain + let validFor: Validity? + } + + let mediaType: String + let certificateAuthorities: [Authority] + let tlogs: [Log] + let ctlogs: [Log] + + func logKey(id: Data, at date: Date, certificateTransparency: Bool = false) throws -> PublicKey { + let logs = certificateTransparency ? ctlogs : tlogs + guard let log = logs.first(where: { + guard $0.logId.keyId == id, + $0.publicKey.validFor?.contains(date) ?? true, + let host = URL(string: $0.baseUrl)?.host?.lowercased() + else { return false } + return certificateTransparency + ? host == "ctfe.sigstore.dev" + : host == "rekor.sigstore.dev" || host.hasSuffix(".rekor.sigstore.dev") + }) else { throw AttestationFailure.invalid("unknown or inactive transparency log") } + return log.publicKey + } +} + +enum DateParser { + static func parse(_ string: String) throws -> Date { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: string) { return date } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + guard let date = plain.date(from: string) else { + throw AttestationFailure.invalid("invalid timestamp") + } + return date + } +} + +actor TUFClient { + typealias Fetch = @Sendable (URL, Int) async throws -> Data + + private static let baseURL = URL(string: "https://tuf-repo-cdn.sigstore.dev/")! + private static let metadataLimit = 1 * 1024 * 1024 + private static let targetLimit = 2 * 1024 * 1024 + private let fetch: Fetch + private let now: @Sendable () -> Date + private let cacheDirectory: URL? + private let bootstrapRoot: Data? + private let fallbackTarget: Data? + + init( + session: URLSession, + timeout: TimeInterval, + cacheDirectory: URL? = TUFClient.defaultCacheDirectory, + now: @escaping @Sendable () -> Date = { .now } + ) { + fetch = { url, maximumBytes in + var request = URLRequest(url: url) + request.timeoutInterval = timeout + request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") + return try await NetworkTransfer.data( + for: request, + with: session, + maximumBytes: Int64(maximumBytes) + ) + } + self.now = now + self.cacheDirectory = cacheDirectory + bootstrapRoot = nil + fallbackTarget = nil + } + + init( + fetch: @escaping Fetch, + cacheDirectory: URL? = nil, + now: @escaping @Sendable () -> Date, + bootstrapRoot: Data? = nil, + fallbackTarget: Data? = nil + ) { + self.fetch = fetch + self.cacheDirectory = cacheDirectory + self.now = now + self.bootstrapRoot = bootstrapRoot + self.fallbackTarget = fallbackTarget + } + + func trustedRoot() async throws -> SigstoreTrustedRoot { + let embeddedRoot = try bootstrapRoot ?? resource("tuf-root") + let embeddedTarget = try fallbackTarget ?? resource("trusted-root") + do { + let target = try await refresh(embeddedRoot: embeddedRoot) + return try decodeTrustedRoot(target) + } catch where Self.isNetworkFailure(error) { + if let root = try? verifiedCachedTrustedRoot(embeddedRoot: embeddedRoot) { + return root + } + return try decodeTrustedRoot(embeddedTarget) + } + } + + private func verifiedCachedTrustedRoot(embeddedRoot: Data) throws -> SigstoreTrustedRoot { + var root = try decodeRoot(embeddedRoot) + var next = root.signed.version + 1 + while let data = try? cachedData("root-\(next).json") { + root = try verifiedRootUpdate(data, previous: root, expectedVersion: next) + next += 1 + } + try requireUnexpired(root.signed.expires) + + let timestampData = try cachedData("timestamp.json") + let timestamp: TUFEnvelope = try verify( + timestampData, role: "timestamp", root: root, expectedType: "timestamp" + ) + guard let snapshotMeta = timestamp.signed.meta["snapshot.json"] else { + throw AttestationFailure.invalid("missing cached snapshot metadata") + } + let snapshotData = try cachedData("snapshot.json") + try verifyFile(snapshotData, metadata: snapshotMeta) + let snapshot: TUFEnvelope = try verify( + snapshotData, role: "snapshot", root: root, expectedType: "snapshot" + ) + guard snapshot.signed.version == snapshotMeta.version, + let targetsMeta = snapshot.signed.meta["targets.json"] + else { throw AttestationFailure.invalid("invalid cached snapshot") } + + let targetsData = try cachedData("targets.json") + try verifyFile(targetsData, metadata: targetsMeta) + let targets: TUFEnvelope = try verify( + targetsData, role: "targets", root: root, expectedType: "targets" + ) + guard targets.signed.version == targetsMeta.version, + let targetMeta = targets.signed.targets["trusted_root.json"] + else { throw AttestationFailure.invalid("invalid cached targets") } + + let targetData = try cachedData("trusted-root.json") + try verifyFile(targetData, metadata: targetMeta, requireIntegrity: true) + return try decodeTrustedRoot(targetData) + } + + private func refresh(embeddedRoot: Data) async throws -> Data { + var rootData = embeddedRoot + var root = try decodeRoot(rootData) + var rotations = 0 + if let cacheDirectory { + var next = root.signed.version + 1 + while let cached = try? Data(contentsOf: cacheDirectory.appendingPathComponent("root-\(next).json")) { + guard rotations < 64 else { + throw AttestationFailure.invalid("too many cached TUF roots") + } + let candidate = try verifiedRootUpdate(cached, previous: root, expectedVersion: next) + rootData = cached + root = candidate + next += 1 + rotations += 1 + } + } + + while true { + guard rotations < 64 else { + throw AttestationFailure.invalid("too many TUF root rotations") + } + let next = root.signed.version + 1 + let url = Self.baseURL.appendingPathComponent("\(next).root.json") + let data: Data + do { + data = try await fetch(url, Self.metadataLimit) + } catch where Self.isNetworkFailure(error) { + break + } + let candidate = try verifiedRootUpdate(data, previous: root, expectedVersion: next) + rootData = data + root = candidate + try cache(data, name: "root-\(next).json") + rotations += 1 + } + try requireUnexpired(root.signed.expires) + + let timestampData = try await fetch(Self.baseURL.appendingPathComponent("timestamp.json"), Self.metadataLimit) + let timestamp: TUFEnvelope = try verify( + timestampData, + role: "timestamp", + root: root, + expectedType: "timestamp" + ) + try rejectRollback( + timestamp.signed.version, + cached: "timestamp.json", + role: "timestamp", + root: root, + as: TUFTimestamp.self + ) + guard let snapshotMeta = timestamp.signed.meta["snapshot.json"] else { + throw AttestationFailure.invalid("missing snapshot metadata") + } + let snapshotData = try await fetch( + Self.baseURL.appendingPathComponent("\(snapshotMeta.version).snapshot.json"), + Self.metadataLimit + ) + try verifyFile(snapshotData, metadata: snapshotMeta) + let snapshot: TUFEnvelope = try verify( + snapshotData, + role: "snapshot", + root: root, + expectedType: "snapshot" + ) + guard snapshot.signed.version == snapshotMeta.version else { + throw AttestationFailure.invalid("snapshot version mismatch") + } + try rejectRollback( + snapshot.signed.version, + cached: "snapshot.json", + role: "snapshot", + root: root, + as: TUFSnapshot.self + ) + + guard let targetsMeta = snapshot.signed.meta["targets.json"] else { + throw AttestationFailure.invalid("missing targets metadata") + } + let targetsData = try await fetch( + Self.baseURL.appendingPathComponent("\(targetsMeta.version).targets.json"), + Self.metadataLimit + ) + try verifyFile(targetsData, metadata: targetsMeta) + let targets: TUFEnvelope = try verify( + targetsData, + role: "targets", + root: root, + expectedType: "targets" + ) + guard targets.signed.version == targetsMeta.version else { + throw AttestationFailure.invalid("targets version mismatch") + } + try rejectRollback( + targets.signed.version, + cached: "targets.json", + role: "targets", + root: root, + as: TUFTargets.self + ) + + guard let targetMeta = targets.signed.targets["trusted_root.json"], + let hash = targetMeta.hashes?["sha256"], + hash.count == 64 + else { throw AttestationFailure.invalid("missing trusted root target") } + let targetData = try await fetch( + Self.baseURL.appendingPathComponent("targets/\(hash).trusted_root.json"), + Self.targetLimit + ) + try verifyFile(targetData, metadata: targetMeta, requireIntegrity: true) + _ = try decodeTrustedRoot(targetData) + + try cache(rootData, name: "root.json") + try cache(timestampData, name: "timestamp.json") + try cache(snapshotData, name: "snapshot.json") + try cache(targetsData, name: "targets.json") + try cache(targetData, name: "trusted-root.json") + return targetData + } + + private func verifiedRootUpdate( + _ data: Data, + previous: TUFEnvelope, + expectedVersion: Int + ) throws -> TUFEnvelope { + let candidate: TUFEnvelope = try decode(data) + guard candidate.signed.type == "root", candidate.signed.version == expectedVersion else { + throw AttestationFailure.invalid("invalid root rotation") + } + try verifySignatures(data, role: "root", root: previous) + try verifySignatures(data, role: "root", root: candidate) + try requireUnexpired(candidate.signed.expires) + return candidate + } + + private func verify( + _ data: Data, + role: String, + root: TUFEnvelope, + expectedType: String + ) throws -> TUFEnvelope { + let envelope: TUFEnvelope = try decode(data) + guard envelope.signed.type == expectedType else { + throw AttestationFailure.invalid("unexpected TUF metadata type") + } + try requireUnexpired(envelope.signed.expires) + try verifySignatures(data, role: role, root: root) + return envelope + } + + private func verifySignatures( + _ data: Data, + role: String, + root: TUFEnvelope + ) throws { + let raw = try rawEnvelope(data) + guard let roleDefinition = root.signed.roles[role] else { + throw AttestationFailure.invalid("unknown TUF role") + } + let envelope = try JSONDecoder().decode(TUFSignaturesEnvelope.self, from: data) + let message = try JSONCanonicalizer.canonicalizeTUF(raw.signed) + var verified = Set() + for signature in envelope.signatures where roleDefinition.keyids.contains(signature.keyid) { + guard !verified.contains(signature.keyid), + let key = root.signed.keys[signature.keyid], + key.keytype == "ecdsa", + key.scheme == "ecdsa-sha2-nistp256", + let signatureData = Data(hex: signature.sig), + let publicKey = key.keyval.publicKeyDER + else { continue } + if (try? SignatureVerifier.verifyP256( + spkiDER: publicKey, + signatureDER: signatureData, + message: message + )) != nil { + verified.insert(signature.keyid) + } + } + guard verified.count >= roleDefinition.threshold else { + throw AttestationFailure.invalid("TUF signature threshold not met for \(role)") + } + } + + private func rawEnvelope(_ data: Data) throws -> (signed: Any, signatures: Any) { + guard data.count <= Self.metadataLimit, + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let signed = object["signed"], let signatures = object["signatures"] + else { throw AttestationFailure.invalid("invalid TUF envelope") } + return (signed, signatures) + } + + private func decode(_ data: Data) throws -> TUFEnvelope { + guard data.count <= Self.metadataLimit else { + throw AttestationFailure.invalid("TUF metadata exceeds limit") + } + return try JSONDecoder().decode(TUFEnvelope.self, from: data) + } + + private func decodeRoot(_ data: Data) throws -> TUFEnvelope { try decode(data) } + + private func decodeTrustedRoot(_ data: Data) throws -> SigstoreTrustedRoot { + guard data.count <= Self.targetLimit else { + throw AttestationFailure.invalid("trusted root exceeds limit") + } + let root = try JSONDecoder().decode(SigstoreTrustedRoot.self, from: data) + guard root.mediaType == "application/vnd.dev.sigstore.trustedroot+json;version=0.1" else { + throw AttestationFailure.invalid("unsupported trusted root") + } + return root + } + + private func requireUnexpired(_ string: String) throws { + guard try DateParser.parse(string) > now() else { + throw AttestationFailure.invalid("expired TUF metadata") + } + } + + private func verifyFile( + _ data: Data, + metadata: TUFFileMetadata, + requireIntegrity: Bool = false + ) throws { + if requireIntegrity && (metadata.length == nil || metadata.hashes?["sha256"] == nil) { + throw AttestationFailure.invalid("missing TUF file integrity metadata") + } + if let length = metadata.length, data.count != length { + throw AttestationFailure.invalid("TUF file length mismatch") + } + if let expected = metadata.hashes?["sha256"], + (expected.count != 64 || data.sha256.hex != expected) + { + throw AttestationFailure.invalid("TUF file hash mismatch") + } + } + + private func rejectRollback( + _ version: Int, + cached name: String, + role: String, + root: TUFEnvelope, + as type: T.Type + ) throws { + guard let data = try? cachedData(name) else { return } + let envelope: TUFEnvelope + do { + envelope = try decode(data) + try verifySignatures(data, role: role, root: root) + } catch { return } + guard version >= envelope.signed.version else { + throw AttestationFailure.invalid("TUF metadata rollback") + } + } + + private func resource(_ name: String) throws -> Data { + guard let url = Bundle.module.url(forResource: name, withExtension: "json") else { + throw AttestationFailure.invalid("missing trust bootstrap") + } + return try Data(contentsOf: url, options: .mappedIfSafe) + } + + private func cachedData(_ name: String) throws -> Data { + guard let cacheDirectory else { throw CocoaError(.fileNoSuchFile) } + return try Data(contentsOf: cacheDirectory.appendingPathComponent(name), options: .mappedIfSafe) + } + + private func cache(_ data: Data, name: String) throws { + guard let cacheDirectory else { return } + try FileManager.default.createDirectory( + at: cacheDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let url = cacheDirectory.appendingPathComponent(name) + try data.write(to: url, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } + + private static var defaultCacheDirectory: URL? { + FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? + .appendingPathComponent("AppUpdater/SigstoreTUF", isDirectory: true) + } + + private static func isNetworkFailure(_ error: Error) -> Bool { + if error is CancellationError { return false } + if let error = error as? URLError { return error.code != .cancelled } + guard let error = error as? AppUpdaterError else { return false } + return error == .invalidHTTPResponse || error == .operationTimedOut + } +} + +private protocol TUFMetadata { + var type: String { get } + var expires: String { get } + var version: Int { get } +} + +private struct TUFEnvelope: Decodable { + let signed: T + let signatures: [TUFSignature] +} + +private struct TUFSignature: Decodable { let keyid: String; let sig: String } + +private struct TUFSignaturesEnvelope: Decodable { let signatures: [TUFSignature] } + +private struct TUFRoot: TUFMetadata, Decodable { + let type: String + let expires: String + let version: Int + let consistentSnapshot: Bool + let keys: [String: TUFKey] + let roles: [String: TUFRole] + + enum CodingKeys: String, CodingKey { + case type = "_type", expires, version, consistentSnapshot = "consistent_snapshot", keys, roles + } +} + +private struct TUFKey: Decodable { + struct Value: Decodable { + let `public`: String + var publicKeyDER: Data? { + let lines = `public`.split(whereSeparator: \.isNewline) + .filter { !$0.hasPrefix("-----") } + .joined() + return Data(base64Encoded: lines) + } + } + let keytype: String + let scheme: String + let keyval: Value +} + +private struct TUFRole: Decodable { let keyids: [String]; let threshold: Int } + +private struct TUFTimestamp: TUFMetadata, Decodable { + let type: String; let expires: String; let version: Int + let meta: [String: TUFFileMetadata] + enum CodingKeys: String, CodingKey { case type = "_type", expires, version, meta } +} + +private struct TUFSnapshot: TUFMetadata, Decodable { + let type: String; let expires: String; let version: Int + let meta: [String: TUFFileMetadata] + enum CodingKeys: String, CodingKey { case type = "_type", expires, version, meta } +} + +private struct TUFTargets: TUFMetadata, Decodable { + let type: String; let expires: String; let version: Int + let targets: [String: TUFFileMetadata] + enum CodingKeys: String, CodingKey { case type = "_type", expires, version, targets } +} + +private struct TUFFileMetadata: Decodable { + let version: Int + let length: Int? + let hashes: [String: String]? + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + version = try values.decodeIfPresent(Int.self, forKey: .version) ?? 0 + length = try values.decodeIfPresent(Int.self, forKey: .length) + hashes = try values.decodeIfPresent([String: String].self, forKey: .hashes) + } + + enum CodingKeys: String, CodingKey { case version, length, hashes } +} diff --git a/Package.swift b/Package.swift index e34a919..bc430c8 100644 --- a/Package.swift +++ b/Package.swift @@ -18,13 +18,15 @@ let package = Package( dependencies: ["Version"], path: ".", exclude: ["LICENSE.md", "README.md", "Tests"], - sources: ["AppUpdater.swift"] + sources: ["AppUpdater.swift", "Attestation"], + resources: [.process("Resources")] ), .testTarget( name: "AppUpdaterTests", dependencies: ["AppUpdater"], path: "Tests", - exclude: ["MANUAL.md"] + exclude: ["MANUAL.md"], + resources: [.process("Fixtures")] ), ], swiftLanguageModes: [.v6] diff --git a/Resources/README.txt b/Resources/README.txt new file mode 100644 index 0000000..d01cc76 --- /dev/null +++ b/Resources/README.txt @@ -0,0 +1 @@ +Embedded Sigstore trust bootstrap resources. diff --git a/Resources/trusted-root.json b/Resources/trusted-root.json new file mode 100644 index 0000000..effb0a1 --- /dev/null +++ b/Resources/trusted-root.json @@ -0,0 +1,126 @@ +{ + "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1", + "tlogs": [ + { + "baseUrl": "https://rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-01-12T11:53:27Z" + } + }, + "logId": { + "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" + } + }, + { + "baseUrl": "https://log2025-1.rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MCowBQYDK2VwAyEAt8rlp1knGwjfbcXAYPYAkn0XiLz1x8O4t0YkEhie244=", + "keyDetails": "PKIX_ED25519", + "validFor": { + "start": "2025-09-23T00:00:00Z" + } + }, + "logId": { + "keyId": "zxGZFVvd0FEmjR8WrFwMdcAJ9vtaY/QXf44Y1wUeP6A=" + } + } + ], + "certificateAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ==" + } + ] + }, + "validFor": { + "start": "2021-03-07T03:20:29Z", + "end": "2022-12-31T23:59:59.999Z" + } + }, + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow=" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ" + } + ] + }, + "validFor": { + "start": "2022-04-13T20:06:15Z" + } + } + ], + "ctlogs": [ + { + "baseUrl": "https://ctfe.sigstore.dev/test", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-03-14T00:00:00Z", + "end": "2022-10-31T23:59:59.999Z" + } + }, + "logId": { + "keyId": "CGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3I=" + } + }, + { + "baseUrl": "https://ctfe.sigstore.dev/2022", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNKAaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2022-10-20T00:00:00Z" + } + }, + "logId": { + "keyId": "3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4=" + } + } + ], + "timestampAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore-tsa-selfsigned" + }, + "uri": "https://timestamp.sigstore.dev/api/v1/timestamp", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICEDCCAZagAwIBAgIUOhNULwyQYe68wUMvy4qOiyojiwwwCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMC4xFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEVMBMGA1UEAxMMc2lnc3RvcmUtdHNhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE4ra2Z8hKNig2T9kFjCAToGG30jky+WQv3BzL+mKvh1SKNR/UwuwsfNCg4sryoYAd8E6isovVA3M4aoNdm9QDi50Z8nTEyvqgfDPtTIwXItfiW/AFf1V7uwkbkAoj0xxco2owaDAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0OBBYEFIn9eUOHz9BlRsMCRscsc1t9tOsDMB8GA1UdIwQYMBaAFJjsAe9/u1H/1JUeb4qImFMHic6/MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMAoGCCqGSM49BAMDA2gAMGUCMDtpsV/6KaO0qyF/UMsX2aSUXKQFdoGTptQGc0ftq1csulHPGG6dsmyMNd3JB+G3EQIxAOajvBcjpJmKb4Nv+2Taoj8Uc5+b6ih6FXCCKraSqupe07zqswMcXJTe1cExvHvvlw==" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUV7f0GLDOoEzIh8LXSW80OJiUp14wCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQUQNtfRT/ou3YATa6wB/kKTe70cfJwyRIBovMnt8RcJph/COE82uyS6FmppLLL1VBPGcPfpQPYJNXzWwi8icwhKQ6W/Qe2h3oebBb2FHpwNJDqo+TMaC/tdfkv/ElJB72jRTBDMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSY7AHvf7tR/9SVHm+KiJhTB4nOvzAKBggqhkjOPQQDAwNpADBmAjEAwGEGrfGZR1cen1R8/DTVMI943LssZmJRtDp/i7SfGHmGRP6gRbuj9vOK3b67Z0QQAjEAuT2H673LQEaHTcyQSZrkp4mX7WwkmF+sVbkYY5mXN+RMH13KUEHHOqASaemYWK/E" + } + ] + }, + "validFor": { + "start": "2025-07-04T00:00:00Z" + } + } + ] +} diff --git a/Resources/tuf-root.json b/Resources/tuf-root.json new file mode 100644 index 0000000..5cdd56a --- /dev/null +++ b/Resources/tuf-root.json @@ -0,0 +1,141 @@ +{ + "signatures": [ + { + "keyid": "e71a54d543835ba86adad9460379c7641fb8726d164ea766801a1c522aba7ea2", + "sig": "3045022100ea2f374f409810e2db950749d9cfed09a15b6a5e25f3d5ffd0799459d7bee167022028d3acdde6dbd5034cfad222d31b41090ee21894e2c46cb8974198ab0377db44" + }, + { + "keyid": "22f4caec6d8e6f9555af66b3d4c3cb06a3bb23fdc7e39c916c61f462e6f52b06", + "sig": "304402207ebb24e3237e470691d7875903a7754d0ef2ae7e7b5024a7888c9a38a52deecd02206ed5ad1c6f4fab46995843ab6b23f9420c5a4cf6ce1cb2cb2a6fc2e87e2ef3e1" + }, + { + "keyid": "61643838125b440b40db6942f5cb5a31c0dc04368316eb2aaa58b95904a58222", + "sig": "304602210089d9dfd8e106cc958088a4da3c8cf7254ab6f65a9647d37ada730ef4763c5163022100d882ee744615be79861e214e1eeb9e1eddf6a1e203a201b4c5d03f5224d71d16" + }, + { + "keyid": "a687e5bf4fab82b0ee58d46e05c9535145a2c9afb458f43d42b45ca0fdce2a70", + "sig": "304502210088bd4b88e83f586ce568d27d04214c4ab3fd1894178ef015303d56afa939205302205538ebab93876abb9075ad77114bff28a0d79a7cc229b534a0c5ced5526b48e7" + }, + { + "keyid": "183e64f37670dc13ca0d28995a3053f3740954ddce44321a41e46534cf44e632", + "sig": "3045022100f35b07e938d4949caf82e69e86cc9db3b69b6dbc6740c1f343d06893f996fbeb022001e847d816259a96a49e42779a2350dab97b71c8ae7e26b2380c6fa7f58131b3" + } + ], + "signed": { + "_type": "root", + "consistent_snapshot": true, + "expires": "2026-11-20T13:58:18Z", + "keys": { + "0c87432c3bf09fd99189fdc32fa5eaedf4e4a5fac7bab73fa04a2e0fc64af6f5": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWRiGr5+j+3J5SsH+Ztr5nE2H2wO7\nBV+nO3s93gLca18qTOzHY1oWyAGDykMSsGTUBSt9D+An0KfKsD2mfSM42Q==\n-----END PUBLIC KEY-----\n" + }, + "scheme": "ecdsa-sha2-nistp256", + "x-tuf-on-ci-online-uri": "gcpkms:projects/sigstore-root-signing/locations/global/keyRings/root/cryptoKeys/timestamp/cryptoKeyVersions/1" + }, + "183e64f37670dc13ca0d28995a3053f3740954ddce44321a41e46534cf44e632": { + "keytype": "ecdsa", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMxpPOJCIZ5otG4106fGJseEQi3V9\npkMYQ4uyV9Tj1M7WHXIyLG+jkfvuG0glQ1JZbRZZBV3gAR4sojdGHISeow==\n-----END PUBLIC KEY-----\n" + }, + "scheme": "ecdsa-sha2-nistp256", + "x-tuf-on-ci-keyowner": "@lance" + }, + "22f4caec6d8e6f9555af66b3d4c3cb06a3bb23fdc7e39c916c61f462e6f52b06": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEzBzVOmHCPojMVLSI364WiiV8NPrD\n6IgRxVliskz/v+y3JER5mcVGcONliDcWMC5J2lfHmjPNPhb4H7xm8LzfSA==\n-----END PUBLIC KEY-----\n" + }, + "scheme": "ecdsa-sha2-nistp256", + "x-tuf-on-ci-keyowner": "@santiagotorres" + }, + "61643838125b440b40db6942f5cb5a31c0dc04368316eb2aaa58b95904a58222": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEinikSsAQmYkNeH5eYq/CnIzLaacO\nxlSaawQDOwqKy/tCqxq5xxPSJc21K4WIhs9GyOkKfzueY3GILzcMJZ4cWw==\n-----END PUBLIC KEY-----\n" + }, + "scheme": "ecdsa-sha2-nistp256", + "x-tuf-on-ci-keyowner": "@bobcallaway" + }, + "a687e5bf4fab82b0ee58d46e05c9535145a2c9afb458f43d42b45ca0fdce2a70": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE0ghrh92Lw1Yr3idGV5WqCtMDB8Cx\n+D8hdC4w2ZLNIplVRoVGLskYa3gheMyOjiJ8kPi15aQ2//7P+oj7UvJPGw==\n-----END PUBLIC KEY-----\n" + }, + "scheme": "ecdsa-sha2-nistp256", + "x-tuf-on-ci-keyowner": "@joshuagl" + }, + "e71a54d543835ba86adad9460379c7641fb8726d164ea766801a1c522aba7ea2": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEXsz3SZXFb8jMV42j6pJlyjbjR8K\nN3Bwocexq6LMIb5qsWKOQvLN16NUefLc4HswOoumRsVVaajSpQS6fobkRw==\n-----END PUBLIC KEY-----\n" + }, + "scheme": "ecdsa-sha2-nistp256", + "x-tuf-on-ci-keyowner": "@mnm678" + } + }, + "roles": { + "root": { + "keyids": [ + "e71a54d543835ba86adad9460379c7641fb8726d164ea766801a1c522aba7ea2", + "22f4caec6d8e6f9555af66b3d4c3cb06a3bb23fdc7e39c916c61f462e6f52b06", + "61643838125b440b40db6942f5cb5a31c0dc04368316eb2aaa58b95904a58222", + "a687e5bf4fab82b0ee58d46e05c9535145a2c9afb458f43d42b45ca0fdce2a70", + "183e64f37670dc13ca0d28995a3053f3740954ddce44321a41e46534cf44e632" + ], + "threshold": 3 + }, + "snapshot": { + "keyids": [ + "0c87432c3bf09fd99189fdc32fa5eaedf4e4a5fac7bab73fa04a2e0fc64af6f5" + ], + "threshold": 1, + "x-tuf-on-ci-expiry-period": 3650, + "x-tuf-on-ci-signing-period": 365 + }, + "targets": { + "keyids": [ + "e71a54d543835ba86adad9460379c7641fb8726d164ea766801a1c522aba7ea2", + "22f4caec6d8e6f9555af66b3d4c3cb06a3bb23fdc7e39c916c61f462e6f52b06", + "61643838125b440b40db6942f5cb5a31c0dc04368316eb2aaa58b95904a58222", + "a687e5bf4fab82b0ee58d46e05c9535145a2c9afb458f43d42b45ca0fdce2a70", + "183e64f37670dc13ca0d28995a3053f3740954ddce44321a41e46534cf44e632" + ], + "threshold": 3 + }, + "timestamp": { + "keyids": [ + "0c87432c3bf09fd99189fdc32fa5eaedf4e4a5fac7bab73fa04a2e0fc64af6f5" + ], + "threshold": 1, + "x-tuf-on-ci-expiry-period": 7, + "x-tuf-on-ci-signing-period": 6 + } + }, + "spec_version": "1.0", + "version": 15, + "x-tuf-on-ci-expiry-period": 197, + "x-tuf-on-ci-signing-period": 46 + } +} diff --git a/Tests/AppUpdaterTests/AttestationPrimitiveTests.swift b/Tests/AppUpdaterTests/AttestationPrimitiveTests.swift new file mode 100644 index 0000000..41898ad --- /dev/null +++ b/Tests/AppUpdaterTests/AttestationPrimitiveTests.swift @@ -0,0 +1,54 @@ +@testable import AppUpdater +import CryptoKit +import XCTest + +final class AttestationPrimitiveTests: XCTestCase { + func testCanonicalJSON() throws { + let value = try JSONSerialization.jsonObject(with: Data(#"{"z":1,"a":[true,"x"]}"#.utf8)) + XCTAssertEqual( + String(data: try JSONCanonicalizer.canonicalize(value), encoding: .utf8), + #"{"a":[true,"x"],"z":1}"# + ) + } + + func testDERRejectsTrailingAndTruncatedData() throws { + XCTAssertThrowsError(try DER.parse(Data([0x30, 0x01]))) + XCTAssertThrowsError(try DER.parse(Data([0x05, 0x00, 0x00]))) + XCTAssertEqual(try DER.parse(Data([0x05, 0x00])).tag, 0x05) + } + + func testSnappyLiteralAndOverlappingCopy() throws { + let encoded = Data([0x09, 0x08]) + Data("abc".utf8) + Data([0x16, 0x03, 0x00]) + XCTAssertEqual(try Snappy.decompress(encoded, maximumOutputBytes: 9), Data("abcabcabc".utf8)) + XCTAssertThrowsError(try Snappy.decompress(encoded, maximumOutputBytes: 8)) + } + + func testP256SignatureVerification() throws { + let key = P256.Signing.PrivateKey() + let message = Data("signed".utf8) + let signature = try key.signature(for: message) + XCTAssertNoThrow( + try SignatureVerifier.verifyP256( + spkiDER: key.publicKey.derRepresentation, + signatureDER: signature.derRepresentation, + message: message + ) + ) + XCTAssertThrowsError( + try SignatureVerifier.verifyP256( + spkiDER: key.publicKey.derRepresentation, + signatureDER: signature.derRepresentation, + message: Data("tampered".utf8) + ) + ) + } + + func testMerkleSingleLeaf() throws { + let body = Data("entry".utf8) + let root = (Data([0]) + body).sha256 + XCTAssertNoThrow( + try Merkle.verify(body: body, leafIndex: 0, treeSize: 1, hashes: [], expectedRoot: root) + ) + } + +} diff --git a/Tests/AppUpdaterTests/TUFTests.swift b/Tests/AppUpdaterTests/TUFTests.swift new file mode 100644 index 0000000..1f29c15 --- /dev/null +++ b/Tests/AppUpdaterTests/TUFTests.swift @@ -0,0 +1,162 @@ +@testable import AppUpdater +import XCTest + +final class TUFTests: XCTestCase { + private let validDate = Date(timeIntervalSince1970: 1_785_531_075) + + func testSequentialRootRotationAndMetadataChain() async throws { + let files = try fixtureFiles() + let root = try await client(files: files, bootstrap: files["14.root.json"]!).trustedRoot() + XCTAssertFalse(root.certificateAuthorities.isEmpty) + XCTAssertFalse(root.tlogs.isEmpty) + } + + func testRootRotationRequiresSignatureThreshold() async throws { + var files = try fixtureFiles() + var root = try json(files["15.root.json"]!) + root["signatures"] = [] + files["15.root.json"] = try JSONSerialization.data(withJSONObject: root) + await XCTAssertThrowsErrorAsync { + _ = try await self.client(files: files, bootstrap: files["14.root.json"]!).trustedRoot() + } + } + + func testTamperedTruncatedAndUnknownKeyMetadataAreRejected() async throws { + let originals = try fixtureFiles() + var cases = [[String: Data]]() + + var truncated = originals + truncated["timestamp.json"] = Data(#"{"signed":"#.utf8) + cases.append(truncated) + + var hashMismatch = originals + hashMismatch[ + "6494e21ea73fa7ee769f85f57d5a3e6a08725eae1e38c755fc3517c9e6bc0b66.trusted_root.json" + ]!.append(0x20) + cases.append(hashMismatch) + + var snapshotMismatch = originals + var snapshot = try json(snapshotMismatch["165.snapshot.json"]!) + var snapshotSigned = snapshot["signed"] as! [String: Any] + snapshotSigned["version"] = 1 + snapshot["signed"] = snapshotSigned + snapshotMismatch["165.snapshot.json"] = try JSONSerialization.data(withJSONObject: snapshot) + cases.append(snapshotMismatch) + + var targetsMismatch = originals + var targets = try json(targetsMismatch["14.targets.json"]!) + var targetsSigned = targets["signed"] as! [String: Any] + targetsSigned["version"] = 1 + targets["signed"] = targetsSigned + targetsMismatch["14.targets.json"] = try JSONSerialization.data(withJSONObject: targets) + cases.append(targetsMismatch) + + var unknownKey = originals + var timestamp = try json(unknownKey["timestamp.json"]!) + var signatures = timestamp["signatures"] as! [[String: Any]] + signatures[0]["keyid"] = String(repeating: "0", count: 64) + timestamp["signatures"] = signatures + unknownKey["timestamp.json"] = try JSONSerialization.data(withJSONObject: timestamp) + cases.append(unknownKey) + + for files in cases { + await XCTAssertThrowsErrorAsync { + _ = try await self.client(files: files).trustedRoot() + } + } + } + + func testExpiredMetadataIsRejected() async throws { + await XCTAssertThrowsErrorAsync { + _ = try await self.client( + files: try self.fixtureFiles(), + now: Date(timeIntervalSince1970: 1_786_160_000) + ).trustedRoot() + } + } + + func testRollbackIsRejected() async throws { + let files = try fixtureFiles(timestamp: "742.timestamp.json") + let cache = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: cache, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: cache) } + try fixture("timestamp.json").write( + to: cache.appendingPathComponent("timestamp.json"), + options: .atomic + ) + await XCTAssertThrowsErrorAsync { + _ = try await self.client(files: files, cache: cache).trustedRoot() + } + } + + func testOversizedMetadataFailureDoesNotUseFallback() async throws { + let bootstrap = try fixture("15.root.json") + let fallback = try fixture("trusted-root.json") + let date = validDate + let client = TUFClient( + fetch: { _, _ in throw AppUpdaterError.resourceLimitExceeded("TUF metadata") }, + now: { date }, + bootstrapRoot: bootstrap, + fallbackTarget: fallback + ) + await XCTAssertThrowsErrorAsync { _ = try await client.trustedRoot() } + } + + private func client( + files: [String: Data], + bootstrap: Data? = nil, + cache: URL? = nil, + now: Date? = nil + ) -> TUFClient { + let date = now ?? validDate + return TUFClient( + fetch: { url, maximumBytes in + let name = url.lastPathComponent + guard let data = files[name] else { throw URLError(.fileDoesNotExist) } + guard data.count <= maximumBytes else { + throw AppUpdaterError.resourceLimitExceeded("TUF metadata") + } + return data + }, + cacheDirectory: cache, + now: { date }, + bootstrapRoot: bootstrap ?? (try? fixture("15.root.json")), + fallbackTarget: try? fixture("trusted-root.json") + ) + } + + private func fixtureFiles(timestamp: String = "timestamp.json") throws -> [String: Data] { + [ + "14.root.json": try fixture("14.root.json"), + "15.root.json": try fixture("15.root.json"), + "timestamp.json": try fixture(timestamp), + "165.snapshot.json": try fixture("165.snapshot.json"), + "14.targets.json": try fixture("14.targets.json"), + "6494e21ea73fa7ee769f85f57d5a3e6a08725eae1e38c755fc3517c9e6bc0b66.trusted_root.json": + try fixture("trusted-root.json"), + ] + } + + private func fixture(_ suffix: String) throws -> Data { + let name = suffix == "trusted-root.json" ? "tuf-trusted-root" : "tuf-\(suffix)" + let base = (name as NSString).deletingPathExtension + return try Data(contentsOf: XCTUnwrap( + Bundle.module.url(forResource: base, withExtension: "json") + )) + } + + private func json(_ data: Data) throws -> [String: Any] { + try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } +} + +private func XCTAssertThrowsErrorAsync( + _ expression: () async throws -> Void, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + try await expression() + XCTFail("expected error", file: file, line: line) + } catch {} +} diff --git a/Tests/Fixtures/tuf-14.root.json b/Tests/Fixtures/tuf-14.root.json new file mode 100644 index 0000000..f661a5b --- /dev/null +++ b/Tests/Fixtures/tuf-14.root.json @@ -0,0 +1,141 @@ +{ + "signatures": [ + { + "keyid": "e71a54d543835ba86adad9460379c7641fb8726d164ea766801a1c522aba7ea2", + "sig": "3046022100e04c9706299be5d8c2b14fb50bcd5b9c241f10597153dfe22f943efe896b5150022100cfd7b9f06a5900784e312d02b8e336edbb3b2fab61ac14550b3112b4f9e33df4" + }, + { + "keyid": "22f4caec6d8e6f9555af66b3d4c3cb06a3bb23fdc7e39c916c61f462e6f52b06", + "sig": "" + }, + { + "keyid": "61643838125b440b40db6942f5cb5a31c0dc04368316eb2aaa58b95904a58222", + "sig": "3045022100cc308ae7d390fa782ee3376ddfaa929835016e86dad81f69e2de7ec1e174432e02205fb19906a31cce146c29624443c0d0c2f33ee80dac39d72114f939607cc22937" + }, + { + "keyid": "a687e5bf4fab82b0ee58d46e05c9535145a2c9afb458f43d42b45ca0fdce2a70", + "sig": "304502203f8aff7a30e05a8c3d904b671ab1a6e4e8a6f508b7cfa0c780e72976bee7a227022100f64c9b765526f34d9ea16339cf238893e1c3368b4f0910a61a1af27dda01ebb9" + }, + { + "keyid": "183e64f37670dc13ca0d28995a3053f3740954ddce44321a41e46534cf44e632", + "sig": "304502202363ca249aefa6d5f61c408a32cdd079b034a7888ddf2136dc4515ed4a728418022100b04eca42bc510ccbbf5d30783aaa936b1f137ca7a017ee9d90d3710432da0427" + } + ], + "signed": { + "_type": "root", + "consistent_snapshot": true, + "expires": "2026-06-22T13:27:01Z", + "keys": { + "0c87432c3bf09fd99189fdc32fa5eaedf4e4a5fac7bab73fa04a2e0fc64af6f5": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWRiGr5+j+3J5SsH+Ztr5nE2H2wO7\nBV+nO3s93gLca18qTOzHY1oWyAGDykMSsGTUBSt9D+An0KfKsD2mfSM42Q==\n-----END PUBLIC KEY-----\n" + }, + "scheme": "ecdsa-sha2-nistp256", + "x-tuf-on-ci-online-uri": "gcpkms:projects/sigstore-root-signing/locations/global/keyRings/root/cryptoKeys/timestamp/cryptoKeyVersions/1" + }, + "183e64f37670dc13ca0d28995a3053f3740954ddce44321a41e46534cf44e632": { + "keytype": "ecdsa", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMxpPOJCIZ5otG4106fGJseEQi3V9\npkMYQ4uyV9Tj1M7WHXIyLG+jkfvuG0glQ1JZbRZZBV3gAR4sojdGHISeow==\n-----END PUBLIC KEY-----\n" + }, + "scheme": "ecdsa-sha2-nistp256", + "x-tuf-on-ci-keyowner": "@lance" + }, + "22f4caec6d8e6f9555af66b3d4c3cb06a3bb23fdc7e39c916c61f462e6f52b06": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEzBzVOmHCPojMVLSI364WiiV8NPrD\n6IgRxVliskz/v+y3JER5mcVGcONliDcWMC5J2lfHmjPNPhb4H7xm8LzfSA==\n-----END PUBLIC KEY-----\n" + }, + "scheme": "ecdsa-sha2-nistp256", + "x-tuf-on-ci-keyowner": "@santiagotorres" + }, + "61643838125b440b40db6942f5cb5a31c0dc04368316eb2aaa58b95904a58222": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEinikSsAQmYkNeH5eYq/CnIzLaacO\nxlSaawQDOwqKy/tCqxq5xxPSJc21K4WIhs9GyOkKfzueY3GILzcMJZ4cWw==\n-----END PUBLIC KEY-----\n" + }, + "scheme": "ecdsa-sha2-nistp256", + "x-tuf-on-ci-keyowner": "@bobcallaway" + }, + "a687e5bf4fab82b0ee58d46e05c9535145a2c9afb458f43d42b45ca0fdce2a70": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE0ghrh92Lw1Yr3idGV5WqCtMDB8Cx\n+D8hdC4w2ZLNIplVRoVGLskYa3gheMyOjiJ8kPi15aQ2//7P+oj7UvJPGw==\n-----END PUBLIC KEY-----\n" + }, + "scheme": "ecdsa-sha2-nistp256", + "x-tuf-on-ci-keyowner": "@joshuagl" + }, + "e71a54d543835ba86adad9460379c7641fb8726d164ea766801a1c522aba7ea2": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEXsz3SZXFb8jMV42j6pJlyjbjR8K\nN3Bwocexq6LMIb5qsWKOQvLN16NUefLc4HswOoumRsVVaajSpQS6fobkRw==\n-----END PUBLIC KEY-----\n" + }, + "scheme": "ecdsa-sha2-nistp256", + "x-tuf-on-ci-keyowner": "@mnm678" + } + }, + "roles": { + "root": { + "keyids": [ + "e71a54d543835ba86adad9460379c7641fb8726d164ea766801a1c522aba7ea2", + "22f4caec6d8e6f9555af66b3d4c3cb06a3bb23fdc7e39c916c61f462e6f52b06", + "61643838125b440b40db6942f5cb5a31c0dc04368316eb2aaa58b95904a58222", + "a687e5bf4fab82b0ee58d46e05c9535145a2c9afb458f43d42b45ca0fdce2a70", + "183e64f37670dc13ca0d28995a3053f3740954ddce44321a41e46534cf44e632" + ], + "threshold": 3 + }, + "snapshot": { + "keyids": [ + "0c87432c3bf09fd99189fdc32fa5eaedf4e4a5fac7bab73fa04a2e0fc64af6f5" + ], + "threshold": 1, + "x-tuf-on-ci-expiry-period": 3650, + "x-tuf-on-ci-signing-period": 365 + }, + "targets": { + "keyids": [ + "e71a54d543835ba86adad9460379c7641fb8726d164ea766801a1c522aba7ea2", + "22f4caec6d8e6f9555af66b3d4c3cb06a3bb23fdc7e39c916c61f462e6f52b06", + "61643838125b440b40db6942f5cb5a31c0dc04368316eb2aaa58b95904a58222", + "a687e5bf4fab82b0ee58d46e05c9535145a2c9afb458f43d42b45ca0fdce2a70", + "183e64f37670dc13ca0d28995a3053f3740954ddce44321a41e46534cf44e632" + ], + "threshold": 3 + }, + "timestamp": { + "keyids": [ + "0c87432c3bf09fd99189fdc32fa5eaedf4e4a5fac7bab73fa04a2e0fc64af6f5" + ], + "threshold": 1, + "x-tuf-on-ci-expiry-period": 7, + "x-tuf-on-ci-signing-period": 6 + } + }, + "spec_version": "1.0", + "version": 14, + "x-tuf-on-ci-expiry-period": 197, + "x-tuf-on-ci-signing-period": 46 + } +} diff --git a/Tests/Fixtures/tuf-14.targets.json b/Tests/Fixtures/tuf-14.targets.json new file mode 100644 index 0000000..72f30b0 --- /dev/null +++ b/Tests/Fixtures/tuf-14.targets.json @@ -0,0 +1,173 @@ +{ + "signatures": [ + { + "keyid": "e71a54d543835ba86adad9460379c7641fb8726d164ea766801a1c522aba7ea2", + "sig": "30440220560777f2e8093ef4e6289975f0ad5fbfb85dbb03e6bf98bff935737ddeeca80802206a85e2904843a842c7e651e46e7fe49874de99fa94f5051639a6dc6b5aee4f29" + }, + { + "keyid": "22f4caec6d8e6f9555af66b3d4c3cb06a3bb23fdc7e39c916c61f462e6f52b06", + "sig": "30450221008c1befccad604a4818eccc59d670f74f66b69f4bab38aca744777f304d4c176c022062d56c5c6a50504b73145ec08a2e102a0a5fd0476b097d32766f6580110348ff" + }, + { + "keyid": "61643838125b440b40db6942f5cb5a31c0dc04368316eb2aaa58b95904a58222", + "sig": "3046022100d298094a3195317c1c3329ee31468f129d4ea28a7f049a410d4f443f01953472022100dfcc5b8bd15db6fb0e7c857d8c53d71706381c22f76f5c951ce8b411d0b08bc0" + }, + { + "keyid": "a687e5bf4fab82b0ee58d46e05c9535145a2c9afb458f43d42b45ca0fdce2a70", + "sig": "3046022100c1d91fe98b8a0a63dd2e98eb1048ff579990c29dad890fe8061a293e90a26113022100f843667831e0c69a7ba41f2a974e082e457ff8d5aebef06bd7be6d34bc2ac092" + }, + { + "keyid": "183e64f37670dc13ca0d28995a3053f3740954ddce44321a41e46534cf44e632", + "sig": "304402206e1d9520e65d9074ea5bd5e20dca600aeb3beb3f93f1699a9b1897101f7f3f01022018335bb010edacb9f8f0a9101438b53d4481324ada04bf656718beed11d21c53" + } + ], + "signed": { + "_type": "targets", + "delegations": { + "keys": { + "5e3a4021b11a425fd0a444f1670457ce5b15bbe036144f2417426f7f4b9721da": { + "keytype": "ecdsa", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEVfei1dXQRVeArCMcTDgxJtYg+Fs7\nV87DjhQbGlRJPyC7SW5TbNNkmvpmi4LeTv6moLVZ7T2nVqiRZbSkD+cf8w==\n-----END PUBLIC KEY-----\n" + }, + "scheme": "ecdsa-sha2-nistp256", + "x-tuf-on-ci-online-uri": "azurekms://npm-tuf-delegate.vault.azure.net/keys/npm-tuf-delegate-2024-08/e2772c1d01ca400da571096889f1660e" + } + }, + "roles": [ + { + "keyids": [ + "5e3a4021b11a425fd0a444f1670457ce5b15bbe036144f2417426f7f4b9721da" + ], + "name": "registry.npmjs.org", + "paths": [ + "registry.npmjs.org/*" + ], + "terminating": true, + "threshold": 1 + } + ] + }, + "expires": "2036-05-09T09:00:52Z", + "spec_version": "1.0", + "targets": { + "artifact.pub": { + "custom": { + "sigstore": { + "status": "Active", + "usage": "Unknown" + } + }, + "hashes": { + "sha256": "59ebf97a9850aecec4bc39c1f5c1dc46e6490a6b5fd2a6cacdcac0c3a6fc4cbf" + }, + "length": 177 + }, + "ctfe.pub": { + "custom": { + "sigstore": { + "status": "Active", + "uri": "https://ctfe.sigstore.dev/test", + "usage": "CTFE" + } + }, + "hashes": { + "sha256": "7fcb94a5d0ed541260473b990b99a6c39864c1fb16f3f3e594a5a3cebbfe138a" + }, + "length": 177 + }, + "ctfe_2022.pub": { + "custom": { + "sigstore": { + "status": "Active", + "uri": "https://ctfe.sigstore.dev/2022", + "usage": "CTFE" + } + }, + "hashes": { + "sha256": "270488a309d22e804eeb245493e87c667658d749006b9fee9cc614572d4fbbdc" + }, + "length": 178 + }, + "fulcio.crt.pem": { + "custom": { + "sigstore": { + "status": "Expired", + "uri": "https://fulcio.sigstore.dev", + "usage": "Fulcio" + } + }, + "hashes": { + "sha256": "f360c53b2e13495a628b9b8096455badcb6d375b185c4816d95a5d746ff29908" + }, + "length": 744 + }, + "fulcio_intermediate_v1.crt.pem": { + "custom": { + "sigstore": { + "status": "Active", + "uri": "https://fulcio.sigstore.dev", + "usage": "Fulcio" + } + }, + "hashes": { + "sha256": "f8cbecf186db7714624a5f4e99da31a917cbef70a94dd6921f5c3ca969dfe30a" + }, + "length": 789 + }, + "fulcio_v1.crt.pem": { + "custom": { + "sigstore": { + "status": "Active", + "uri": "https://fulcio.sigstore.dev", + "usage": "Fulcio" + } + }, + "hashes": { + "sha256": "f989aa23def87c549404eadba767768d2a3c8d6d30a8b793f9f518a8eafd2cf5" + }, + "length": 740 + }, + "rekor.pub": { + "custom": { + "sigstore": { + "status": "Active", + "uri": "https://rekor.sigstore.dev", + "usage": "Rekor" + } + }, + "hashes": { + "sha256": "dce5ef715502ec9f3cdfd11f8cc384b31a6141023d3e7595e9908a81cb6241bd" + }, + "length": 178 + }, + "signing_config.json": { + "hashes": { + "sha256": "d358c75d032833f4193500f5b01b5760409410558fac962c599439adbb268b0f" + }, + "length": 219 + }, + "signing_config.v0.2.json": { + "hashes": { + "sha256": "9711a6d5375706957a4859af31c5866a4474f81f0544f9f4b76c9c4f4c8a539c" + }, + "length": 1034 + }, + "signing_config_rekor_v2.v0.2.json": { + "hashes": { + "sha256": "0f5f38554e29e770d4d5d6f0e1b51fcbf84f61dc6934530a09b7a901eaad5bee" + }, + "length": 1230 + }, + "trusted_root.json": { + "hashes": { + "sha256": "6494e21ea73fa7ee769f85f57d5a3e6a08725eae1e38c755fc3517c9e6bc0b66" + }, + "length": 6787 + } + }, + "version": 14, + "x-tuf-on-ci-expiry-period": 3650, + "x-tuf-on-ci-signing-period": 31 + } +} diff --git a/Tests/Fixtures/tuf-15.root.json b/Tests/Fixtures/tuf-15.root.json new file mode 100644 index 0000000..5cdd56a --- /dev/null +++ b/Tests/Fixtures/tuf-15.root.json @@ -0,0 +1,141 @@ +{ + "signatures": [ + { + "keyid": "e71a54d543835ba86adad9460379c7641fb8726d164ea766801a1c522aba7ea2", + "sig": "3045022100ea2f374f409810e2db950749d9cfed09a15b6a5e25f3d5ffd0799459d7bee167022028d3acdde6dbd5034cfad222d31b41090ee21894e2c46cb8974198ab0377db44" + }, + { + "keyid": "22f4caec6d8e6f9555af66b3d4c3cb06a3bb23fdc7e39c916c61f462e6f52b06", + "sig": "304402207ebb24e3237e470691d7875903a7754d0ef2ae7e7b5024a7888c9a38a52deecd02206ed5ad1c6f4fab46995843ab6b23f9420c5a4cf6ce1cb2cb2a6fc2e87e2ef3e1" + }, + { + "keyid": "61643838125b440b40db6942f5cb5a31c0dc04368316eb2aaa58b95904a58222", + "sig": "304602210089d9dfd8e106cc958088a4da3c8cf7254ab6f65a9647d37ada730ef4763c5163022100d882ee744615be79861e214e1eeb9e1eddf6a1e203a201b4c5d03f5224d71d16" + }, + { + "keyid": "a687e5bf4fab82b0ee58d46e05c9535145a2c9afb458f43d42b45ca0fdce2a70", + "sig": "304502210088bd4b88e83f586ce568d27d04214c4ab3fd1894178ef015303d56afa939205302205538ebab93876abb9075ad77114bff28a0d79a7cc229b534a0c5ced5526b48e7" + }, + { + "keyid": "183e64f37670dc13ca0d28995a3053f3740954ddce44321a41e46534cf44e632", + "sig": "3045022100f35b07e938d4949caf82e69e86cc9db3b69b6dbc6740c1f343d06893f996fbeb022001e847d816259a96a49e42779a2350dab97b71c8ae7e26b2380c6fa7f58131b3" + } + ], + "signed": { + "_type": "root", + "consistent_snapshot": true, + "expires": "2026-11-20T13:58:18Z", + "keys": { + "0c87432c3bf09fd99189fdc32fa5eaedf4e4a5fac7bab73fa04a2e0fc64af6f5": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWRiGr5+j+3J5SsH+Ztr5nE2H2wO7\nBV+nO3s93gLca18qTOzHY1oWyAGDykMSsGTUBSt9D+An0KfKsD2mfSM42Q==\n-----END PUBLIC KEY-----\n" + }, + "scheme": "ecdsa-sha2-nistp256", + "x-tuf-on-ci-online-uri": "gcpkms:projects/sigstore-root-signing/locations/global/keyRings/root/cryptoKeys/timestamp/cryptoKeyVersions/1" + }, + "183e64f37670dc13ca0d28995a3053f3740954ddce44321a41e46534cf44e632": { + "keytype": "ecdsa", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMxpPOJCIZ5otG4106fGJseEQi3V9\npkMYQ4uyV9Tj1M7WHXIyLG+jkfvuG0glQ1JZbRZZBV3gAR4sojdGHISeow==\n-----END PUBLIC KEY-----\n" + }, + "scheme": "ecdsa-sha2-nistp256", + "x-tuf-on-ci-keyowner": "@lance" + }, + "22f4caec6d8e6f9555af66b3d4c3cb06a3bb23fdc7e39c916c61f462e6f52b06": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEzBzVOmHCPojMVLSI364WiiV8NPrD\n6IgRxVliskz/v+y3JER5mcVGcONliDcWMC5J2lfHmjPNPhb4H7xm8LzfSA==\n-----END PUBLIC KEY-----\n" + }, + "scheme": "ecdsa-sha2-nistp256", + "x-tuf-on-ci-keyowner": "@santiagotorres" + }, + "61643838125b440b40db6942f5cb5a31c0dc04368316eb2aaa58b95904a58222": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEinikSsAQmYkNeH5eYq/CnIzLaacO\nxlSaawQDOwqKy/tCqxq5xxPSJc21K4WIhs9GyOkKfzueY3GILzcMJZ4cWw==\n-----END PUBLIC KEY-----\n" + }, + "scheme": "ecdsa-sha2-nistp256", + "x-tuf-on-ci-keyowner": "@bobcallaway" + }, + "a687e5bf4fab82b0ee58d46e05c9535145a2c9afb458f43d42b45ca0fdce2a70": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE0ghrh92Lw1Yr3idGV5WqCtMDB8Cx\n+D8hdC4w2ZLNIplVRoVGLskYa3gheMyOjiJ8kPi15aQ2//7P+oj7UvJPGw==\n-----END PUBLIC KEY-----\n" + }, + "scheme": "ecdsa-sha2-nistp256", + "x-tuf-on-ci-keyowner": "@joshuagl" + }, + "e71a54d543835ba86adad9460379c7641fb8726d164ea766801a1c522aba7ea2": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEXsz3SZXFb8jMV42j6pJlyjbjR8K\nN3Bwocexq6LMIb5qsWKOQvLN16NUefLc4HswOoumRsVVaajSpQS6fobkRw==\n-----END PUBLIC KEY-----\n" + }, + "scheme": "ecdsa-sha2-nistp256", + "x-tuf-on-ci-keyowner": "@mnm678" + } + }, + "roles": { + "root": { + "keyids": [ + "e71a54d543835ba86adad9460379c7641fb8726d164ea766801a1c522aba7ea2", + "22f4caec6d8e6f9555af66b3d4c3cb06a3bb23fdc7e39c916c61f462e6f52b06", + "61643838125b440b40db6942f5cb5a31c0dc04368316eb2aaa58b95904a58222", + "a687e5bf4fab82b0ee58d46e05c9535145a2c9afb458f43d42b45ca0fdce2a70", + "183e64f37670dc13ca0d28995a3053f3740954ddce44321a41e46534cf44e632" + ], + "threshold": 3 + }, + "snapshot": { + "keyids": [ + "0c87432c3bf09fd99189fdc32fa5eaedf4e4a5fac7bab73fa04a2e0fc64af6f5" + ], + "threshold": 1, + "x-tuf-on-ci-expiry-period": 3650, + "x-tuf-on-ci-signing-period": 365 + }, + "targets": { + "keyids": [ + "e71a54d543835ba86adad9460379c7641fb8726d164ea766801a1c522aba7ea2", + "22f4caec6d8e6f9555af66b3d4c3cb06a3bb23fdc7e39c916c61f462e6f52b06", + "61643838125b440b40db6942f5cb5a31c0dc04368316eb2aaa58b95904a58222", + "a687e5bf4fab82b0ee58d46e05c9535145a2c9afb458f43d42b45ca0fdce2a70", + "183e64f37670dc13ca0d28995a3053f3740954ddce44321a41e46534cf44e632" + ], + "threshold": 3 + }, + "timestamp": { + "keyids": [ + "0c87432c3bf09fd99189fdc32fa5eaedf4e4a5fac7bab73fa04a2e0fc64af6f5" + ], + "threshold": 1, + "x-tuf-on-ci-expiry-period": 7, + "x-tuf-on-ci-signing-period": 6 + } + }, + "spec_version": "1.0", + "version": 15, + "x-tuf-on-ci-expiry-period": 197, + "x-tuf-on-ci-signing-period": 46 + } +} diff --git a/Tests/Fixtures/tuf-165.snapshot.json b/Tests/Fixtures/tuf-165.snapshot.json new file mode 100644 index 0000000..babdebb --- /dev/null +++ b/Tests/Fixtures/tuf-165.snapshot.json @@ -0,0 +1,54 @@ +{ + "signatures": [ + { + "keyid": "0c87432c3bf09fd99189fdc32fa5eaedf4e4a5fac7bab73fa04a2e0fc64af6f5", + "sig": "3045022044d1fb58328b16c750f8edc29c7734bace9c96b56896dfbdecce3a14cf640bab0221008bf05b104c3c34b3f8c28de8d555225fbccc47b5d5b46dd1bf0557c2a3c45d7a" + } + ], + "signed": { + "_type": "snapshot", + "expires": "2036-05-15T08:09:16Z", + "meta": { + "registry.npmjs.org.json": { + "version": 8 + }, + "rekor.json": { + "hashes": { + "sha256": "9d2e1a5842937d8e0d3e3759170b0ad15c56c5df36afc5cf73583ddd283a463b", + "sha512": "176e9e710ddddd1b357a7d7970831bae59763395a0c18976110cbd35b25e5412dc50f356ec421a7a30265670cf7aec9ed84ee944ba700ec2394b9c876645b960" + }, + "length": 797, + "version": 3 + }, + "revocation.json": { + "hashes": { + "sha256": "6f60848ba8fb0955a02abfd1232fb3845dc9ee9f418bf03521a7ddb48217e040", + "sha512": "a965dddd0d0edef6c59e84cf02ecf5a53299f633fd339b2b61814a4219ab4df672a6390f265b8b29e1c8cea9368ea3440df013790759d50231a30df1c1f02551" + }, + "length": 800, + "version": 2 + }, + "root.json": { + "hashes": { + "sha256": "f5ad897c9414cca99629f400ac3585e41bd8ebb44c5af07fb08dd636a9eced9c", + "sha512": "7445ddfdd338ef786c324fc3d68f75be28cb95b7fb581d2a383e3e5dde18aa17029a5636ec0a22e9631931bbcb34057788311718ea41e21e7cdd3c0de13ede42" + }, + "length": 5297, + "version": 2 + }, + "staging.json": { + "hashes": { + "sha256": "cda57759abac5375397eea3531d7ca51e3a67da9a2dc93f2cdab749e2ae73149", + "sha512": "e9e59587bde453144c7079884a880c706f1d43f26e8bb23fac2b96a99569a2a30ae6cf51ec51c2454f760ce83d4c20915e062aede7f319b3da6a6ed1d26ca281" + }, + "length": 401, + "version": 2 + }, + "targets.json": { + "version": 14 + } + }, + "spec_version": "1.0", + "version": 165 + } +} diff --git a/Tests/Fixtures/tuf-742.timestamp.json b/Tests/Fixtures/tuf-742.timestamp.json new file mode 100644 index 0000000..c7fae8b --- /dev/null +++ b/Tests/Fixtures/tuf-742.timestamp.json @@ -0,0 +1,19 @@ +{ + "signatures": [ + { + "keyid": "0c87432c3bf09fd99189fdc32fa5eaedf4e4a5fac7bab73fa04a2e0fc64af6f5", + "sig": "3045022100c6e3e0b08df5ab86d441fe32f8d10a42a364bdcddaccb6001b876195094f9f600220283c25a109e7c2fbdaf68b6eb109a2b856d41625e6dfffbc1b7ab11a05f61ed0" + } + ], + "signed": { + "_type": "timestamp", + "expires": "2026-08-06T07:59:03Z", + "meta": { + "snapshot.json": { + "version": 165 + } + }, + "spec_version": "1.0", + "version": 742 + } +} diff --git a/Tests/Fixtures/tuf-timestamp.json b/Tests/Fixtures/tuf-timestamp.json new file mode 100644 index 0000000..785a97b --- /dev/null +++ b/Tests/Fixtures/tuf-timestamp.json @@ -0,0 +1,19 @@ +{ + "signatures": [ + { + "keyid": "0c87432c3bf09fd99189fdc32fa5eaedf4e4a5fac7bab73fa04a2e0fc64af6f5", + "sig": "30440220603aabf9ea2c0bb935862726e58e392433055dffeeef7fa23519f5f6b39c110602206f108320cc7b7c4f37863674e1cda2184a035991c46f59a0e7582d6f7368ca7a" + } + ], + "signed": { + "_type": "timestamp", + "expires": "2026-08-07T08:13:18Z", + "meta": { + "snapshot.json": { + "version": 165 + } + }, + "spec_version": "1.0", + "version": 743 + } +} diff --git a/Tests/Fixtures/tuf-trusted-root.json b/Tests/Fixtures/tuf-trusted-root.json new file mode 100644 index 0000000..effb0a1 --- /dev/null +++ b/Tests/Fixtures/tuf-trusted-root.json @@ -0,0 +1,126 @@ +{ + "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1", + "tlogs": [ + { + "baseUrl": "https://rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-01-12T11:53:27Z" + } + }, + "logId": { + "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" + } + }, + { + "baseUrl": "https://log2025-1.rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MCowBQYDK2VwAyEAt8rlp1knGwjfbcXAYPYAkn0XiLz1x8O4t0YkEhie244=", + "keyDetails": "PKIX_ED25519", + "validFor": { + "start": "2025-09-23T00:00:00Z" + } + }, + "logId": { + "keyId": "zxGZFVvd0FEmjR8WrFwMdcAJ9vtaY/QXf44Y1wUeP6A=" + } + } + ], + "certificateAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ==" + } + ] + }, + "validFor": { + "start": "2021-03-07T03:20:29Z", + "end": "2022-12-31T23:59:59.999Z" + } + }, + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow=" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ" + } + ] + }, + "validFor": { + "start": "2022-04-13T20:06:15Z" + } + } + ], + "ctlogs": [ + { + "baseUrl": "https://ctfe.sigstore.dev/test", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-03-14T00:00:00Z", + "end": "2022-10-31T23:59:59.999Z" + } + }, + "logId": { + "keyId": "CGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3I=" + } + }, + { + "baseUrl": "https://ctfe.sigstore.dev/2022", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNKAaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2022-10-20T00:00:00Z" + } + }, + "logId": { + "keyId": "3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4=" + } + } + ], + "timestampAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore-tsa-selfsigned" + }, + "uri": "https://timestamp.sigstore.dev/api/v1/timestamp", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICEDCCAZagAwIBAgIUOhNULwyQYe68wUMvy4qOiyojiwwwCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMC4xFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEVMBMGA1UEAxMMc2lnc3RvcmUtdHNhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE4ra2Z8hKNig2T9kFjCAToGG30jky+WQv3BzL+mKvh1SKNR/UwuwsfNCg4sryoYAd8E6isovVA3M4aoNdm9QDi50Z8nTEyvqgfDPtTIwXItfiW/AFf1V7uwkbkAoj0xxco2owaDAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0OBBYEFIn9eUOHz9BlRsMCRscsc1t9tOsDMB8GA1UdIwQYMBaAFJjsAe9/u1H/1JUeb4qImFMHic6/MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMAoGCCqGSM49BAMDA2gAMGUCMDtpsV/6KaO0qyF/UMsX2aSUXKQFdoGTptQGc0ftq1csulHPGG6dsmyMNd3JB+G3EQIxAOajvBcjpJmKb4Nv+2Taoj8Uc5+b6ih6FXCCKraSqupe07zqswMcXJTe1cExvHvvlw==" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUV7f0GLDOoEzIh8LXSW80OJiUp14wCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQUQNtfRT/ou3YATa6wB/kKTe70cfJwyRIBovMnt8RcJph/COE82uyS6FmppLLL1VBPGcPfpQPYJNXzWwi8icwhKQ6W/Qe2h3oebBb2FHpwNJDqo+TMaC/tdfkv/ElJB72jRTBDMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSY7AHvf7tR/9SVHm+KiJhTB4nOvzAKBggqhkjOPQQDAwNpADBmAjEAwGEGrfGZR1cen1R8/DTVMI943LssZmJRtDp/i7SfGHmGRP6gRbuj9vOK3b67Z0QQAjEAuT2H673LQEaHTcyQSZrkp4mX7WwkmF+sVbkYY5mXN+RMH13KUEHHOqASaemYWK/E" + } + ] + }, + "validFor": { + "start": "2025-07-04T00:00:00Z" + } + } + ] +} From a980d05d115020587754ec4380c5ed14c53cd362 Mon Sep 17 00:00:00 2001 From: Max Howell Date: Fri, 31 Jul 2026 23:21:58 -0400 Subject: [PATCH 2/7] Verify GitHub Actions provenance before mounting updates --- AppUpdater.swift | 177 ++++++++++- Attestation/Sigstore.swift | 248 +++++++++++++++ Attestation/Verification.swift | 281 +++++++++++++++++ Attestation/X509.swift | 289 ++++++++++++++++++ Tests/AppUpdaterTests/AppUpdaterTests.swift | 49 +++ .../SigstoreVerificationTests.swift | 193 ++++++++++++ Tests/Fixtures/AutomicVault-2.8.0.bundle.json | 1 + Tests/Fixtures/README.md | 4 + 8 files changed, 1227 insertions(+), 15 deletions(-) create mode 100644 Attestation/Sigstore.swift create mode 100644 Attestation/Verification.swift create mode 100644 Attestation/X509.swift create mode 100644 Tests/AppUpdaterTests/SigstoreVerificationTests.swift create mode 100644 Tests/Fixtures/AutomicVault-2.8.0.bundle.json create mode 100644 Tests/Fixtures/README.md diff --git a/AppUpdater.swift b/AppUpdater.swift index 670a389..3ad2489 100644 --- a/AppUpdater.swift +++ b/AppUpdater.swift @@ -14,7 +14,7 @@ public final class AppUpdater { private let hasExecutable: @Sendable () -> Bool private let currentVersion: @Sendable () throws -> Version private let fetchReleases: @Sendable () async throws -> [Release] - private let prepareAsset: @MainActor @Sendable (Release.Asset) async throws -> PreparedUpdate + private let prepareAsset: @MainActor @Sendable (Release.Asset, String) async throws -> PreparedUpdate public var allowPrereleases = false @@ -23,12 +23,14 @@ public final class AppUpdater { public var maximumMountedBytes: Int64 public var maximumEntries: Int public var timeout: TimeInterval + public var attestationPolicy: GitHubAttestationPolicy? public init( maximumDownloadBytes: Int64 = 2 * 1024 * 1024 * 1024, maximumMountedBytes: Int64 = 4 * 1024 * 1024 * 1024, maximumEntries: Int = 100_000, - timeout: TimeInterval = 10 * 60 + timeout: TimeInterval = 10 * 60, + attestationPolicy: GitHubAttestationPolicy? = nil ) { precondition(maximumDownloadBytes > 0) precondition(maximumMountedBytes > 0) @@ -38,6 +40,7 @@ public final class AppUpdater { self.maximumMountedBytes = maximumMountedBytes self.maximumEntries = maximumEntries self.timeout = timeout + self.attestationPolicy = attestationPolicy } } @@ -62,9 +65,12 @@ public final class AppUpdater { configuration: configuration ) } - prepareAsset = { asset in + prepareAsset = { asset, commit in try await Self.prepareUpdate( with: asset, + targetCommit: commit, + owner: owner, + repo: repo, replacing: .main, session: session, configuration: configuration @@ -88,7 +94,7 @@ public final class AppUpdater { self.hasExecutable = hasExecutable self.currentVersion = currentVersion self.fetchReleases = fetchReleases - self.prepareAsset = prepareAsset + self.prepareAsset = { asset, _ in try await prepareAsset(asset) } } public func check() async throws -> Update? { @@ -123,7 +129,7 @@ public final class AppUpdater { return Update( version: update.version.description, assetName: update.asset.name, - prepare: { try await prepareAsset(update.asset) } + prepare: { try await prepareAsset(update.asset, update.targetCommit) } ) } @@ -156,11 +162,31 @@ public final class AppUpdater { private static func prepareUpdate( with asset: Release.Asset, + targetCommit: String, + owner: String, + repo: String, replacing installedAppBundle: Bundle, session: URLSession, configuration: Configuration ) async throws -> PreparedUpdate { try validateAssetMetadata(asset, configuration: configuration) + let attestation: (GitHubAttestationPolicy, Data, String)? + if let policy = configuration.attestationPolicy { + do { + guard targetCommit.isFullGitCommit else { + throw AttestationFailure.invalid("release target is not a full commit") + } + attestation = ( + policy, + try asset.requiredSHA256Digest(), + targetCommit.lowercased() + ) + } catch { + throw AppUpdaterError.attestationVerificationFailed + } + } else { + attestation = nil + } let tmpdir = try Self.stagingDirectory() do { @@ -175,6 +201,39 @@ public final class AppUpdater { timeout: configuration.timeout ) + let verifiedDigest: Data? + if let (policy, digest, sourceCommit) = attestation { + do { + let downloadedDigest = try FileHasher.sha256( + downloadURL, + maximumBytes: configuration.maximumDownloadBytes + ) + guard downloadedDigest == digest else { + throw AttestationFailure.invalid("release digest mismatch") + } + try await GitHubAttestationVerifier( + owner: owner, + repository: repo, + policy: policy, + session: session, + timeout: configuration.timeout + ).verify( + assetName: asset.name, + digest: digest, + sourceCommit: sourceCommit + ) + verifiedDigest = digest + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + throw AppUpdaterError.attestationVerificationFailed + } + } else { + verifiedDigest = nil + } + let limits = ArchiveExtractor.Limits(configuration) let mount = try await ArchiveExtractor.mount( downloadURL, @@ -202,7 +261,9 @@ public final class AppUpdater { assetName: asset.name, lease: lease, installedBundle: installedAppBundle, - limits: limits + limits: limits, + verifiedDigest: verifiedDigest, + maximumImageBytes: configuration.maximumDownloadBytes ) } catch { try? FileManager.default.removeItem(at: tmpdir) @@ -313,6 +374,7 @@ public final class PreparedUpdate { public enum AppUpdaterError: LocalizedError, Equatable { case bundleExecutableURL + case attestationVerificationFailed case invalidAppVersion(String) case invalidArchiveEntry(String) case invalidDownloadedBundle @@ -336,6 +398,8 @@ public enum AppUpdaterError: LocalizedError, Equatable { public var errorDescription: String? { switch self { + case .attestationVerificationFailed: + "The update’s GitHub Actions provenance could not be verified." case .bundleExecutableURL: "The running bundle has no executable URL." case .invalidAppVersion(let version): @@ -386,19 +450,35 @@ enum NetworkTransfer { static func data( for request: URLRequest, with session: URLSession, - maximumBytes: Int64 + maximumBytes: Int64, + allowedContentTypes: Set? = nil, + permitsCompression: Bool = true ) async throws -> Data { let delegate = TransferDelegate(maximumBytes: maximumBytes) do { - let (data, response) = try await session.data( + let (bytes, response) = try await session.bytes( for: request, delegate: delegate ) - if let error = delegate.error { throw error } - try validate(response) - guard data.count <= maximumBytes else { + try validate( + response, + allowedContentTypes: allowedContentTypes, + permitsCompression: permitsCompression + ) + if response.expectedContentLength > maximumBytes { throw AppUpdaterError.resourceLimitExceeded("response size") } + var data = Data() + if response.expectedContentLength > 0 { + data.reserveCapacity(Int(min(response.expectedContentLength, maximumBytes))) + } + for try await byte in bytes { + guard data.count < maximumBytes else { + throw AppUpdaterError.resourceLimitExceeded("response size") + } + data.append(byte) + } + if let error = delegate.error { throw error } return data } catch { throw delegate.error ?? mapped(error) @@ -444,13 +524,30 @@ enum NetworkTransfer { return error } - private static func validate(_ response: URLResponse) throws { + private static func validate( + _ response: URLResponse, + allowedContentTypes: Set? = nil, + permitsCompression: Bool = true + ) throws { guard response.url?.scheme?.lowercased() == "https", let response = response as? HTTPURLResponse, 200..<300 ~= response.statusCode else { throw AppUpdaterError.invalidHTTPResponse } + if let allowedContentTypes { + guard let contentType = response.value(forHTTPHeaderField: "Content-Type")? + .split(separator: ";", maxSplits: 1).first? + .trimmingCharacters(in: .whitespaces).lowercased(), + allowedContentTypes.contains(contentType) + else { throw AppUpdaterError.invalidHTTPResponse } + } + if !permitsCompression, + let encoding = response.value(forHTTPHeaderField: "Content-Encoding"), + encoding.lowercased() != "identity" + { + throw AppUpdaterError.invalidHTTPResponse + } } final class TransferDelegate: NSObject, @@ -461,6 +558,7 @@ enum NetworkTransfer { private let maximumBytes: Int64 private let lock = NSLock() private var storedError: AppUpdaterError? + private var redirectCount = 0 init(maximumBytes: Int64) { self.maximumBytes = maximumBytes @@ -477,9 +575,13 @@ enum NetworkTransfer { newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void ) { - guard request.url?.scheme?.lowercased() == "https" else { + let allowed = lock.withLock { () -> Bool in + redirectCount += 1 + return redirectCount <= 5 + } + guard allowed, request.url?.scheme?.lowercased() == "https" else { lock.withLock { - storedError = .insecureDownloadURL + storedError = allowed ? .insecureDownloadURL : .invalidHTTPResponse } completionHandler(nil) return @@ -511,26 +613,38 @@ enum NetworkTransfer { struct Release: Decodable, Comparable { let tagName: Version + let targetCommitish: String let prerelease: Bool let assets: [Asset] enum CodingKeys: String, CodingKey { case tagName = "tag_name" + case targetCommitish = "target_commitish" case prerelease case assets } + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + tagName = try values.decode(Version.self, forKey: .tagName) + targetCommitish = try values.decodeIfPresent(String.self, forKey: .targetCommitish) ?? "" + prerelease = try values.decode(Bool.self, forKey: .prerelease) + assets = try values.decode([Asset].self, forKey: .assets) + } + struct Asset: Decodable, Equatable { let name: String let browserDownloadURL: URL let contentType: ContentType? let size: Int64 + let digest: String? enum CodingKeys: String, CodingKey { case name case browserDownloadURL = "browser_download_url" case contentType = "content_type" case size + case digest } init(from decoder: Decoder) throws { @@ -549,6 +663,15 @@ struct Release: Decodable, Comparable { assetName: name ) size = try container.decodeIfPresent(Int64.self, forKey: .size) ?? 0 + digest = try container.decodeIfPresent(String.self, forKey: .digest) + } + + func requiredSHA256Digest() throws -> Data { + guard let digest, digest.hasPrefix("sha256:"), + digest.count == 71, + let value = Data(hex: String(digest.dropFirst(7))), value.count == 32 + else { throw AttestationFailure.invalid("missing SHA-256 asset digest") } + return value } } @@ -596,6 +719,7 @@ enum ContentType: Decodable, Equatable { struct AvailableUpdate { let version: Version let asset: Release.Asset + let targetCommit: String } extension Array where Element == Release { @@ -610,7 +734,11 @@ extension Array where Element == Release { return nil } if let asset = release.viableAsset(forRepo: repo) { - return AvailableUpdate(version: release.tagName, asset: asset) + return AvailableUpdate( + version: release.tagName, + asset: asset, + targetCommit: release.targetCommitish + ) } } return nil @@ -1255,6 +1383,8 @@ enum Installation { lease: StagingLease, installedBundle: Bundle, limits: ArchiveExtractor.Limits, + verifiedDigest: Data? = nil, + maximumImageBytes: Int64 = 2 * 1024 * 1024 * 1024, driver: InstallationDriver = .live ) async throws -> PreparedUpdate { let installedURL = installedBundle.bundleURL @@ -1280,6 +1410,13 @@ enum Installation { expectedParent: parent, protected: protected ) + if let verifiedDigest { + try validatePromotedDigest( + promotedImage, + expected: verifiedDigest, + maximumBytes: maximumImageBytes + ) + } let mount = try await ArchiveExtractor.mount( promotedImage, at: mountRoot.appendingPathComponent("mounted", isDirectory: true), @@ -1345,6 +1482,16 @@ enum Installation { } } + static func validatePromotedDigest( + _ image: URL, + expected: Data, + maximumBytes: Int64 + ) throws { + guard try FileHasher.sha256(image, maximumBytes: maximumBytes) == expected else { + throw AppUpdaterError.attestationVerificationFailed + } + } + static func hasSymlinkComponent(_ url: URL) -> Bool { var current = URL(fileURLWithPath: "/", isDirectory: true) for component in url.standardizedFileURL.pathComponents.dropFirst() { diff --git a/Attestation/Sigstore.swift b/Attestation/Sigstore.swift new file mode 100644 index 0000000..9a2bea6 --- /dev/null +++ b/Attestation/Sigstore.swift @@ -0,0 +1,248 @@ +import CryptoKit +import Foundation +import Security + +public struct GitHubAttestationPolicy: Sendable, Equatable { + public let workflow: String + public let sourceRef: String + + public init(workflow: String, sourceRef: String) { + self.workflow = workflow + self.sourceRef = sourceRef + } +} + +enum FileHasher { + static func sha256(_ url: URL, maximumBytes: Int64) throws -> Data { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + var hash = SHA256() + var total: Int64 = 0 + while true { + let chunk = try handle.read(upToCount: 1024 * 1024) ?? Data() + if chunk.isEmpty { break } + guard Int64(chunk.count) <= maximumBytes - total else { + throw AttestationFailure.invalid("file exceeds hashing limit") + } + total += Int64(chunk.count) + hash.update(data: chunk) + } + guard total > 0 else { throw AttestationFailure.invalid("empty artifact") } + return Data(hash.finalize()) + } +} + +extension String { + var isFullGitCommit: Bool { + count == 40 && unicodeScalars.allSatisfy { + (48...57).contains($0.value) || (65...70).contains($0.value) || (97...102).contains($0.value) + } + } +} + +struct GitHubAttestationVerifier: Sendable { + private static let predicateType = "https://slsa.dev/provenance/v1" + private static let maximumResponseBytes: Int64 = 2 * 1024 * 1024 + private static let maximumBundleBytes = 8 * 1024 * 1024 + private static let maximumAttestations = 16 + + let owner: String + let repository: String + let policy: GitHubAttestationPolicy + let session: URLSession + let timeout: TimeInterval + + func verify(assetName: String, digest: Data, sourceCommit: String) async throws { + try validatePolicy() + let digestText = digest.hex + var components = URLComponents( + string: "https://api.github.com/repos/\(owner)/\(repository)/attestations/sha256:\(digestText)" + )! + components.queryItems = [.init(name: "predicate_type", value: Self.predicateType)] + var request = URLRequest(url: components.url!) + request.timeoutInterval = timeout + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") + let data = try await NetworkTransfer.data( + for: request, + with: session, + maximumBytes: Self.maximumResponseBytes, + allowedContentTypes: ["application/json", "application/vnd.github+json"], + permitsCompression: false + ) + let response = try JSONDecoder().decode(AttestationResponse.self, from: data) + guard !response.attestations.isEmpty, + response.attestations.count <= Self.maximumAttestations + else { throw AttestationFailure.invalid("invalid attestation count") } + + let trustedRoot = try await TUFClient(session: session, timeout: timeout).trustedRoot() + var lastError: Error? + for item in response.attestations { + do { + guard item.repositoryID > 0 else { + throw AttestationFailure.invalid("invalid repository id") + } + let bundle = try await fetchBundle(item.bundleURL) + try BundleVerifier( + owner: owner, + repository: repository, + policy: policy, + trustedRoot: trustedRoot + ).verify( + bundle, + assetName: assetName, + digest: digest, + sourceCommit: sourceCommit, + repositoryID: String(item.repositoryID) + ) + return + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + lastError = error + } + } + throw lastError ?? AttestationFailure.invalid("no matching provenance") + } + + private func fetchBundle(_ url: URL) async throws -> SigstoreBundle { + guard url.scheme?.lowercased() == "https" else { + throw AttestationFailure.invalid("insecure bundle URL") + } + var request = URLRequest(url: url) + request.timeoutInterval = timeout + request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") + let compressed = try await NetworkTransfer.data( + for: request, + with: session, + maximumBytes: Self.maximumResponseBytes, + allowedContentTypes: ["application/x-snappy"], + permitsCompression: false + ) + let raw = try Snappy.decompress(compressed, maximumOutputBytes: Self.maximumBundleBytes) + return try JSONDecoder().decode(SigstoreBundle.self, from: raw) + } + + private func validatePolicy() throws { + guard owner.range(of: #"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$"#, options: .regularExpression) != nil, + repository.range(of: #"^[A-Za-z0-9_.-]+$"#, options: .regularExpression) != nil, + policy.workflow.hasPrefix(".github/workflows/"), + !policy.workflow.contains(".."), + policy.workflow.hasSuffix(".yml") || policy.workflow.hasSuffix(".yaml"), + policy.sourceRef.hasPrefix("refs/heads/"), + !policy.sourceRef.contains("..") + else { throw AttestationFailure.invalid("invalid attestation policy") } + } +} + +private struct AttestationResponse: Decodable { + struct Item: Decodable { + let repositoryID: Int64 + let bundleURL: URL + enum CodingKeys: String, CodingKey { + case repositoryID = "repository_id" + case bundleURL = "bundle_url" + } + } + let attestations: [Item] +} + +struct SigstoreBundle: Decodable { + struct VerificationMaterial: Decodable { + struct Certificate: Decodable { let rawBytes: Data } + let certificate: Certificate + let tlogEntries: [TransparencyEntry] + } + struct Envelope: Decodable { + struct Signature: Decodable { let sig: Data } + let payload: Data + let payloadType: String + let signatures: [Signature] + } + let mediaType: String + let verificationMaterial: VerificationMaterial + let dsseEnvelope: Envelope +} + +struct TransparencyEntry: Decodable { + struct LogID: Decodable { let keyId: Data } + struct KindVersion: Decodable { let kind: String; let version: String } + struct Promise: Decodable { let signedEntryTimestamp: Data } + struct Proof: Decodable { + struct Checkpoint: Decodable { let envelope: String } + let logIndex: String + let rootHash: Data + let treeSize: String + let hashes: [Data] + let checkpoint: Checkpoint + } + let logIndex: String + let logId: LogID + let kindVersion: KindVersion + let integratedTime: String + let inclusionPromise: Promise + let inclusionProof: Proof + let canonicalizedBody: Data +} + +struct Statement: Decodable { + struct Subject: Decodable { let name: String; let digest: [String: String] } + struct Predicate: Decodable { + struct BuildDefinition: Decodable { + struct ExternalParameters: Decodable { + struct Workflow: Decodable { let ref: String; let repository: String; let path: String } + let workflow: Workflow + } + struct InternalParameters: Decodable { + struct GitHub: Decodable { + let eventName: String + let repositoryID: String + let repositoryOwnerID: String + let runnerEnvironment: String + enum CodingKeys: String, CodingKey { + case eventName = "event_name" + case repositoryID = "repository_id" + case repositoryOwnerID = "repository_owner_id" + case runnerEnvironment = "runner_environment" + } + } + let github: GitHub + } + struct Dependency: Decodable { let uri: String; let digest: [String: String] } + let buildType: String + let externalParameters: ExternalParameters + let internalParameters: InternalParameters + let resolvedDependencies: [Dependency] + } + struct RunDetails: Decodable { + struct Builder: Decodable { let id: String } + struct Metadata: Decodable { let invocationId: String } + let builder: Builder + let metadata: Metadata + } + let buildDefinition: BuildDefinition + let runDetails: RunDetails + } + let type: String + let subject: [Subject] + let predicateType: String + let predicate: Predicate + enum CodingKeys: String, CodingKey { + case type = "_type", subject, predicateType, predicate + } +} + +struct RekorBody: Decodable { + struct Spec: Decodable { + struct Hash: Decodable { let algorithm: String; let value: String } + struct Signature: Decodable { let signature: String; let verifier: String } + let envelopeHash: Hash + let payloadHash: Hash + let signatures: [Signature] + } + let apiVersion: String + let kind: String + let spec: Spec +} diff --git a/Attestation/Verification.swift b/Attestation/Verification.swift new file mode 100644 index 0000000..f3984c7 --- /dev/null +++ b/Attestation/Verification.swift @@ -0,0 +1,281 @@ +import Foundation + +struct BundleVerifier { + private static let bundleMediaType = "application/vnd.dev.sigstore.bundle.v0.3+json" + private static let payloadType = "application/vnd.in-toto+json" + private static let statementType = "https://in-toto.io/Statement/v1" + private static let predicateType = "https://slsa.dev/provenance/v1" + private static let buildType = "https://actions.github.io/buildtypes/workflow/v1" + private static let issuer = "https://token.actions.githubusercontent.com" + + let owner: String + let repository: String + let policy: GitHubAttestationPolicy + let trustedRoot: SigstoreTrustedRoot + + func verify( + _ bundle: SigstoreBundle, + assetName: String, + digest: Data, + sourceCommit: String, + repositoryID: String + ) throws { + guard bundle.mediaType == Self.bundleMediaType, + bundle.dsseEnvelope.payloadType == Self.payloadType, + bundle.dsseEnvelope.signatures.count == 1, + bundle.verificationMaterial.tlogEntries.count == 1 + else { throw AttestationFailure.invalid("unsupported Sigstore bundle") } + + let entry = bundle.verificationMaterial.tlogEntries[0] + let integratedSeconds = try decimal(entry.integratedTime) + guard integratedSeconds <= 253_402_300_799 else { + throw AttestationFailure.invalid("invalid signing time") + } + let integratedDate = Date(timeIntervalSince1970: TimeInterval(integratedSeconds)) + let certificate = try X509Certificate(bundle.verificationMaterial.certificate.rawBytes) + try certificate.requireSigningUsage() + let issuerSPKI = try trustedRoot.certificateAuthorities.lazy.compactMap { authority in + try? certificate.verifyChain(using: authority, at: integratedDate) + }.first.unwrap("certificate chain is not trusted") + try certificate.verifySCT(issuerSPKI: issuerSPKI, trustedRoot: trustedRoot) + + let repoURL = "https://github.com/\(owner)/\(repository)" + let workflowIdentity = "\(repoURL)/\(policy.workflow)@\(policy.sourceRef)" + let statement = try JSONDecoder().decode(Statement.self, from: bundle.dsseEnvelope.payload) + try verifyStatement( + statement, + assetName: assetName, + digest: digest, + sourceCommit: sourceCommit, + repositoryID: repositoryID, + repositoryURL: repoURL, + workflowIdentity: workflowIdentity + ) + + let github = statement.predicate.buildDefinition.internalParameters.github + let invocation = statement.predicate.runDetails.metadata.invocationId + try certificate.requireIdentity( + [ + "1.3.6.1.4.1.57264.1.1": Self.issuer, + "1.3.6.1.4.1.57264.1.2": github.eventName, + "1.3.6.1.4.1.57264.1.3": sourceCommit, + "1.3.6.1.4.1.57264.1.5": "\(owner)/\(repository)", + "1.3.6.1.4.1.57264.1.6": policy.sourceRef, + "1.3.6.1.4.1.57264.1.8": Self.issuer, + "1.3.6.1.4.1.57264.1.9": workflowIdentity, + "1.3.6.1.4.1.57264.1.11": "github-hosted", + "1.3.6.1.4.1.57264.1.12": repoURL, + "1.3.6.1.4.1.57264.1.13": sourceCommit, + "1.3.6.1.4.1.57264.1.14": policy.sourceRef, + "1.3.6.1.4.1.57264.1.15": repositoryID, + "1.3.6.1.4.1.57264.1.16": "https://github.com/\(owner)", + "1.3.6.1.4.1.57264.1.17": github.repositoryOwnerID, + "1.3.6.1.4.1.57264.1.18": workflowIdentity, + "1.3.6.1.4.1.57264.1.19": sourceCommit, + "1.3.6.1.4.1.57264.1.20": github.eventName, + "1.3.6.1.4.1.57264.1.21": invocation, + "1.3.6.1.4.1.57264.1.22": "public", + ], + subjectAlternativeName: workflowIdentity + ) + + let pae = dssePAE(type: bundle.dsseEnvelope.payloadType, payload: bundle.dsseEnvelope.payload) + try certificate.verifySignature(bundle.dsseEnvelope.signatures[0].sig, message: pae) + try verifyTransparencyEntry( + entry, + envelope: bundle.dsseEnvelope, + certificate: bundle.verificationMaterial.certificate.rawBytes, + integratedDate: integratedDate + ) + } + + private func verifyStatement( + _ statement: Statement, + assetName: String, + digest: Data, + sourceCommit: String, + repositoryID: String, + repositoryURL: String, + workflowIdentity: String + ) throws { + guard statement.type == Self.statementType, + statement.predicateType == Self.predicateType, + statement.subject.count == 1, + statement.subject[0].name == assetName, + statement.subject[0].digest.count == 1, + statement.subject[0].digest["sha256"] == digest.hex + else { throw AttestationFailure.invalid("artifact subject mismatch") } + + let definition = statement.predicate.buildDefinition + let workflow = definition.externalParameters.workflow + let github = definition.internalParameters.github + let expectedGitURI = "git+\(repositoryURL)@\(policy.sourceRef)" + let sourceMatches = definition.resolvedDependencies.filter { + $0.uri == expectedGitURI && $0.digest.count == 1 + && $0.digest["gitCommit"]?.lowercased() == sourceCommit + } + guard definition.buildType == Self.buildType, + workflow.repository == repositoryURL, + workflow.path == policy.workflow, + workflow.ref == policy.sourceRef, + github.repositoryID == repositoryID, + !github.repositoryOwnerID.isEmpty, + github.runnerEnvironment == "github-hosted", + sourceMatches.count == 1, + statement.predicate.runDetails.builder.id == workflowIdentity, + statement.predicate.runDetails.metadata.invocationId + .hasPrefix("\(repositoryURL)/actions/runs/") + else { throw AttestationFailure.invalid("provenance identity mismatch") } + } + + private func verifyTransparencyEntry( + _ entry: TransparencyEntry, + envelope: SigstoreBundle.Envelope, + certificate: Data, + integratedDate: Date + ) throws { + guard entry.kindVersion.kind == "dsse", entry.kindVersion.version == "0.0.1" else { + throw AttestationFailure.invalid("unsupported Rekor entry") + } + let logIndex = try decimal(entry.logIndex) + let proofIndex = try decimal(entry.inclusionProof.logIndex) + let treeSize = try decimal(entry.inclusionProof.treeSize) + guard proofIndex < treeSize else { + throw AttestationFailure.invalid("invalid Rekor proof index") + } + let logKey = try trustedRoot.logKey(id: entry.logId.keyId, at: integratedDate) + + let object = try JSONSerialization.jsonObject(with: entry.canonicalizedBody) + guard try JSONCanonicalizer.canonicalize(object) == entry.canonicalizedBody else { + throw AttestationFailure.invalid("non-canonical Rekor body") + } + let body = try JSONDecoder().decode(RekorBody.self, from: entry.canonicalizedBody) + guard body.apiVersion == "0.0.1", body.kind == "dsse", + body.spec.envelopeHash.algorithm == "sha256", + body.spec.payloadHash.algorithm == "sha256", + body.spec.signatures.count == 1, + body.spec.payloadHash.value == envelope.payload.sha256.hex, + Data(base64Encoded: body.spec.signatures[0].signature) == envelope.signatures[0].sig, + try certificateFromPEMVerifier(body.spec.signatures[0].verifier) == certificate + else { throw AttestationFailure.invalid("Rekor body mismatch") } + + let envelopeObject: [String: Any] = [ + "payload": envelope.payload.base64EncodedString(), + "payloadType": envelope.payloadType, + "signatures": [["sig": envelope.signatures[0].sig.base64EncodedString()]], + ] + guard body.spec.envelopeHash.value + == (try JSONCanonicalizer.canonicalize(envelopeObject)).sha256.hex + else { throw AttestationFailure.invalid("Rekor envelope hash mismatch") } + + do { + let setPayload: [String: Any] = [ + "body": entry.canonicalizedBody.base64EncodedString(), + "integratedTime": try decimal(entry.integratedTime), + "logID": entry.logId.keyId.hex, + "logIndex": logIndex, + ] + try verifyLogSignature( + entry.inclusionPromise.signedEntryTimestamp, + message: try JSONCanonicalizer.canonicalize(setPayload), + key: logKey + ) + } catch { + throw AttestationFailure.invalid("invalid Rekor SET") + } + try Merkle.verify( + body: entry.canonicalizedBody, + leafIndex: proofIndex, + treeSize: treeSize, + hashes: entry.inclusionProof.hashes, + expectedRoot: entry.inclusionProof.rootHash + ) + try verifyCheckpoint( + entry.inclusionProof.checkpoint.envelope, + treeSize: treeSize, + rootHash: entry.inclusionProof.rootHash, + logID: entry.logId.keyId, + key: logKey + ) + } + + private func verifyCheckpoint( + _ checkpoint: String, + treeSize: UInt64, + rootHash: Data, + logID: Data, + key: SigstoreTrustedRoot.PublicKey + ) throws { + let parts = checkpoint.split(separator: "\n", omittingEmptySubsequences: false) + guard parts.count == 6, parts[0].hasPrefix("rekor.sigstore.dev - "), + parts[1] == String(treeSize), + Data(base64Encoded: String(parts[2])) == rootHash, + parts[3].isEmpty, parts[5].isEmpty, + parts[4].hasPrefix("— rekor.sigstore.dev ") + else { throw AttestationFailure.invalid("invalid Rekor checkpoint") } + let encodedSignature = parts[4].dropFirst("— rekor.sigstore.dev ".count) + guard let signatureWithHint = Data(base64Encoded: String(encodedSignature)), + signatureWithHint.count > 4, + signatureWithHint.prefix(4) == logID.prefix(4) + else { throw AttestationFailure.invalid("invalid checkpoint signature") } + let message = Data("\(parts[0])\n\(parts[1])\n\(parts[2])\n".utf8) + try verifyLogSignature(Data(signatureWithHint.dropFirst(4)), message: message, key: key) + } + + private func verifyLogSignature( + _ signature: Data, + message: Data, + key: SigstoreTrustedRoot.PublicKey + ) throws { + switch key.keyDetails { + case "PKIX_ECDSA_P256_SHA_256": + try SignatureVerifier.verifyP256( + spkiDER: key.rawBytes, + signatureDER: signature, + message: message + ) + case "PKIX_ED25519": + try SignatureVerifier.verifyEd25519( + spkiDER: key.rawBytes, + signature: signature, + message: message + ) + default: + throw AttestationFailure.invalid("unsupported transparency log key") + } + } + + private func certificateFromPEMVerifier(_ encoded: String) throws -> Data { + guard let pemData = Data(base64Encoded: encoded), + let pem = String(data: pemData, encoding: .ascii) + else { throw AttestationFailure.invalid("invalid Rekor verifier") } + let base64 = pem.split(whereSeparator: \.isNewline) + .filter { !$0.hasPrefix("-----") } + .joined() + guard let certificate = Data(base64Encoded: base64) else { + throw AttestationFailure.invalid("invalid Rekor certificate") + } + return certificate + } + + private func decimal(_ string: String) throws -> UInt64 { + guard !string.isEmpty, string.allSatisfy(\.isNumber), + string == "0" || !string.hasPrefix("0"), + let value = UInt64(string) + else { throw AttestationFailure.invalid("invalid integer encoding") } + return value + } + + private func dssePAE(type: String, payload: Data) -> Data { + var result = Data("DSSEv1 \(type.utf8.count) \(type) \(payload.count) ".utf8) + result.append(payload) + return result + } +} + +private extension Optional { + func unwrap(_ message: String) throws -> Wrapped { + guard let self else { throw AttestationFailure.invalid(message) } + return self + } +} diff --git a/Attestation/X509.swift b/Attestation/X509.swift new file mode 100644 index 0000000..d02f01f --- /dev/null +++ b/Attestation/X509.swift @@ -0,0 +1,289 @@ +import CryptoKit +import Foundation +import Security + +struct X509Certificate { + private static let subjectAlternativeName = "2.5.29.17" + private static let keyUsage = "2.5.29.15" + private static let extendedKeyUsage = "2.5.29.37" + private static let sctList = "1.3.6.1.4.1.11129.2.4.2" + + let raw: Data + let tbs: Data + let spki: Data + let extensions: [String: Data] + private let tbsChildren: [DER.Node] + private let extensionsNodeIndex: Int + + init(_ raw: Data) throws { + self.raw = raw + let certificate = try DER.parse(raw) + let certificateChildren = try certificate.children(in: raw) + guard certificate.tag == 0x30, certificateChildren.count == 3, + certificateChildren[0].tag == 0x30 + else { throw AttestationFailure.invalid("invalid X.509 certificate") } + tbs = certificateChildren[0].encoded(in: raw) + let children = try certificateChildren[0].children(in: raw) + tbsChildren = children + let offset = children.first?.tag == 0xa0 ? 1 : 0 + guard children.count > offset + 5, children[offset + 5].tag == 0x30 else { + throw AttestationFailure.invalid("missing certificate public key") + } + spki = children[offset + 5].encoded(in: raw) + guard let index = children.firstIndex(where: { $0.tag == 0xa3 }) else { + throw AttestationFailure.invalid("missing certificate extensions") + } + extensionsNodeIndex = index + let wrapper = try children[index].children(in: raw) + guard wrapper.count == 1, wrapper[0].tag == 0x30 else { + throw AttestationFailure.invalid("invalid certificate extensions") + } + var parsed = [String: Data]() + for item in try wrapper[0].children(in: raw) { + let fields = try item.children(in: raw) + guard item.tag == 0x30, fields.count == 2 || fields.count == 3, + fields[0].tag == 0x06, + let value = fields.last, value.tag == 0x04 + else { throw AttestationFailure.invalid("invalid X.509 extension") } + let oid = try DER.oid(fields[0].content(in: raw)) + guard parsed.updateValue(value.content(in: raw), forKey: oid) == nil else { + throw AttestationFailure.invalid("duplicate X.509 extension") + } + } + extensions = parsed + } + + func requireIdentity(_ expected: [String: String], subjectAlternativeName expectedSAN: String) throws { + guard try uriSubjectAlternativeNames() == [expectedSAN] else { + throw AttestationFailure.invalid("certificate workflow identity mismatch") + } + for (oid, expectedValue) in expected { + guard try extensionString(oid) == expectedValue else { + throw AttestationFailure.invalid("certificate claim mismatch") + } + } + } + + func requireSigningUsage() throws { + guard let usage = extensions[Self.keyUsage] else { + throw AttestationFailure.invalid("missing certificate key usage") + } + let bits = try DER.parse(usage) + let content = bits.content(in: usage) + guard bits.tag == 0x03, content.count >= 2, content[0] <= 7, + content[1] & 0x80 != 0 + else { throw AttestationFailure.invalid("certificate cannot sign") } + + guard let extended = extensions[Self.extendedKeyUsage] else { + throw AttestationFailure.invalid("missing code-signing EKU") + } + let sequence = try DER.parse(extended) + let oids = try sequence.children(in: extended).map { + guard $0.tag == 0x06 else { throw AttestationFailure.invalid("invalid EKU") } + return try DER.oid($0.content(in: extended)) + } + guard oids.contains("1.3.6.1.5.5.7.3.3") else { + throw AttestationFailure.invalid("missing code-signing EKU") + } + } + + func verifyChain(using authority: SigstoreTrustedRoot.Authority, at date: Date) throws -> Data { + guard authority.uri == "https://fulcio.sigstore.dev", + authority.validFor?.contains(date) ?? true, + let leaf = SecCertificateCreateWithData(nil, raw as CFData) + else { throw AttestationFailure.invalid("inactive certificate authority") } + let chain = try authority.certChain.certificates.map { certificate -> SecCertificate in + guard let value = SecCertificateCreateWithData(nil, certificate.rawBytes as CFData) else { + throw AttestationFailure.invalid("invalid trust certificate") + } + return value + } + guard let anchor = chain.last else { + throw AttestationFailure.invalid("empty certificate chain") + } + var trust: SecTrust? + guard SecTrustCreateWithCertificates( + [leaf] + chain as CFArray, + SecPolicyCreateBasicX509(), + &trust + ) == errSecSuccess, let trust else { + throw AttestationFailure.invalid("could not create certificate trust") + } + SecTrustSetAnchorCertificates(trust, [anchor] as CFArray) + SecTrustSetAnchorCertificatesOnly(trust, true) + SecTrustSetVerifyDate(trust, date as CFDate) + var error: CFError? + guard SecTrustEvaluateWithError(trust, &error) else { + throw AttestationFailure.invalid("invalid certificate chain") + } + guard chain.count >= 2 else { + throw AttestationFailure.invalid("missing issuing certificate") + } + return try X509Certificate(authority.certChain.certificates[0].rawBytes).spki + } + + func verifySignature(_ signature: Data, message: Data) throws { + guard let certificate = SecCertificateCreateWithData(nil, raw as CFData), + let key = SecCertificateCopyKey(certificate), + SecKeyVerifySignature( + key, + .ecdsaSignatureMessageX962SHA256, + message as CFData, + signature as CFData, + nil + ) + else { throw AttestationFailure.invalid("invalid DSSE signature") } + } + + func verifySCT(issuerSPKI: Data, trustedRoot: SigstoreTrustedRoot) throws { + guard let encoded = extensions[Self.sctList] else { + throw AttestationFailure.invalid("missing SCT") + } + let octet = try DER.parse(encoded) + guard octet.tag == 0x04 else { throw AttestationFailure.invalid("invalid SCT list") } + let list = octet.content(in: encoded) + var cursor = 0 + let declared = try TLS.readUInt16(list, cursor: &cursor) + guard declared == list.count - cursor else { + throw AttestationFailure.invalid("invalid SCT list length") + } + var verified = false + while cursor < list.count { + let length = try TLS.readUInt16(list, cursor: &cursor) + guard length > 0, cursor <= list.count - length else { + throw AttestationFailure.invalid("truncated SCT") + } + let item = list.subdata(in: cursor..<(cursor + length)) + cursor += length + if (try? verifySCTItem(item, issuerSPKI: issuerSPKI, trustedRoot: trustedRoot)) != nil { + verified = true + } + } + guard verified else { throw AttestationFailure.invalid("no valid SCT") } + } + + private func verifySCTItem( + _ data: Data, + issuerSPKI: Data, + trustedRoot: SigstoreTrustedRoot + ) throws { + var cursor = 0 + guard try TLS.readUInt8(data, cursor: &cursor) == 0 else { + throw AttestationFailure.invalid("unsupported SCT version") + } + let logID = try TLS.read(data, count: 32, cursor: &cursor) + let timestamp = try TLS.readUInt64(data, cursor: &cursor) + let extensions = try TLS.readVector16(data, cursor: &cursor) + guard try TLS.readUInt8(data, cursor: &cursor) == 4, + try TLS.readUInt8(data, cursor: &cursor) == 3 + else { throw AttestationFailure.invalid("unsupported SCT signature") } + let signature = try TLS.readVector16(data, cursor: &cursor) + guard cursor == data.count else { throw AttestationFailure.invalid("trailing SCT data") } + let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000) + let key = try trustedRoot.logKey(id: logID, at: date, certificateTransparency: true) + guard key.keyDetails == "PKIX_ECDSA_P256_SHA_256" else { + throw AttestationFailure.invalid("unsupported CT log key") + } + let precertificate = try tbsWithoutSCT() + var message = Data([0, 0]) + message.append(TLS.uint64(timestamp)) + message.append(contentsOf: [0, 1]) + message.append(issuerSPKI.sha256) + message.append(TLS.uint24(precertificate.count)) + message.append(precertificate) + message.append(TLS.uint16(extensions.count)) + message.append(extensions) + try SignatureVerifier.verifyP256( + spkiDER: key.rawBytes, + signatureDER: signature, + message: message + ) + } + + private func tbsWithoutSCT() throws -> Data { + let extensionWrapper = tbsChildren[extensionsNodeIndex] + let sequence = try extensionWrapper.children(in: raw)[0] + let retained = try sequence.children(in: raw).filter { item in + let fields = try item.children(in: raw) + return try DER.oid(fields[0].content(in: raw)) != Self.sctList + } + guard retained.count < (try sequence.children(in: raw)).count else { + throw AttestationFailure.invalid("SCT extension not found") + } + let rebuiltSequence = DER.encode( + tag: 0x30, + content: retained.reduce(into: Data()) { $0.append($1.encoded(in: raw)) } + ) + let rebuiltWrapper = DER.encode(tag: 0xa3, content: rebuiltSequence) + var content = Data() + for (index, child) in tbsChildren.enumerated() { + content.append(index == extensionsNodeIndex ? rebuiltWrapper : child.encoded(in: raw)) + } + return DER.encode(tag: 0x30, content: content) + } + + private func extensionString(_ oid: String) throws -> String? { + guard let encoded = extensions[oid] else { return nil } + if let value = try? DER.parse(encoded), value.tag == 0x0c || value.tag == 0x16 { + return String(data: value.content(in: encoded), encoding: .utf8) + } + guard let string = String(data: encoded, encoding: .utf8), + string.unicodeScalars.allSatisfy({ $0.value >= 0x20 && $0.value != 0x7f }) + else { throw AttestationFailure.invalid("invalid certificate claim") } + return string + } + + private func uriSubjectAlternativeNames() throws -> [String] { + guard let encoded = extensions[Self.subjectAlternativeName] else { return [] } + let sequence = try DER.parse(encoded) + return try sequence.children(in: encoded).compactMap { item in + guard item.tag == 0x86 else { return nil } + guard let value = String(data: item.content(in: encoded), encoding: .ascii) else { + throw AttestationFailure.invalid("invalid URI SAN") + } + return value + } + } +} + +private enum TLS { + static func readUInt8(_ data: Data, cursor: inout Int) throws -> UInt8 { + guard cursor < data.count else { throw AttestationFailure.invalid("truncated TLS data") } + defer { cursor += 1 } + return data[cursor] + } + + static func readUInt16(_ data: Data, cursor: inout Int) throws -> Int { + let bytes = try read(data, count: 2, cursor: &cursor) + return Int(bytes[0]) << 8 | Int(bytes[1]) + } + + static func readUInt64(_ data: Data, cursor: inout Int) throws -> UInt64 { + let bytes = try read(data, count: 8, cursor: &cursor) + return bytes.reduce(0) { $0 << 8 | UInt64($1) } + } + + static func readVector16(_ data: Data, cursor: inout Int) throws -> Data { + try read(data, count: readUInt16(data, cursor: &cursor), cursor: &cursor) + } + + static func read(_ data: Data, count: Int, cursor: inout Int) throws -> Data { + guard count >= 0, cursor <= data.count - count else { + throw AttestationFailure.invalid("truncated TLS data") + } + defer { cursor += count } + return data.subdata(in: cursor..<(cursor + count)) + } + + static func uint16(_ value: Int) -> Data { + Data([UInt8((value >> 8) & 0xff), UInt8(value & 0xff)]) + } + + static func uint24(_ value: Int) -> Data { + Data([UInt8((value >> 16) & 0xff), UInt8((value >> 8) & 0xff), UInt8(value & 0xff)]) + } + + static func uint64(_ value: UInt64) -> Data { + Data((0..<8).reversed().map { UInt8((value >> UInt64($0 * 8)) & 0xff) }) + } +} diff --git a/Tests/AppUpdaterTests/AppUpdaterTests.swift b/Tests/AppUpdaterTests/AppUpdaterTests.swift index 4aaa251..ec16a01 100644 --- a/Tests/AppUpdaterTests/AppUpdaterTests.swift +++ b/Tests/AppUpdaterTests/AppUpdaterTests.swift @@ -8,6 +8,29 @@ final class AppUpdaterTests: XCTestCase { let updater = AppUpdater(owner: "mxcl", repo: "AppUpdater") XCTAssertFalse(updater.allowPrereleases) + XCTAssertNil(AppUpdater.Configuration().attestationPolicy) + } + + func testReleaseDecodesAttestationMetadata() throws { + let expected = String(repeating: "a", count: 64) + let json = """ + { + "tag_name": "4.1.0", + "target_commitish": "3678ec46fe9e5fde87bcafcbc131e499feef5d5a", + "prerelease": false, + "assets": [{ + "name": "AppUpdater-4.1.0.dmg", + "browser_download_url": "https://example.com/update.dmg", + "content_type": "application/x-apple-diskimage", + "size": 42, + "digest": "sha256:\(expected)" + }] + } + """.data(using: .utf8)! + + let release = try JSONDecoder().decode(Release.self, from: json) + XCTAssertEqual(release.targetCommitish, "3678ec46fe9e5fde87bcafcbc131e499feef5d5a") + XCTAssertEqual(try release.assets[0].requiredSHA256Digest().hex, expected) } func testFetchReleasesUsesGitHubAPIAndTolerantVersionDecoding() async throws { @@ -1159,6 +1182,32 @@ final class AppUpdaterTests: XCTestCase { XCTAssertEqual(try Data(contentsOf: destination), Data("validated".utf8)) } + func testPromotedDigestRejectsSubstitution() throws { + let root = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let image = root.appendingPathComponent("promoted.dmg") + let original = Data("validated".utf8) + try original.write(to: image) + XCTAssertNoThrow( + try Installation.validatePromotedDigest( + image, + expected: original.sha256, + maximumBytes: 100 + ) + ) + + try Data("substituted".utf8).write(to: image) + XCTAssertThrowsError( + try Installation.validatePromotedDigest( + image, + expected: original.sha256, + maximumBytes: 100 + ) + ) { error in + XCTAssertEqual(error as? AppUpdaterError, .attestationVerificationFailed) + } + } + func testProtectedPromotionRejectsUserOwnedFileEvenWhenReadOnly() throws { let root = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: root) } diff --git a/Tests/AppUpdaterTests/SigstoreVerificationTests.swift b/Tests/AppUpdaterTests/SigstoreVerificationTests.swift new file mode 100644 index 0000000..48afb9d --- /dev/null +++ b/Tests/AppUpdaterTests/SigstoreVerificationTests.swift @@ -0,0 +1,193 @@ +@testable import AppUpdater +import XCTest + +final class SigstoreVerificationTests: XCTestCase { + private let digest = Data( + hex: "e21622e6fdf7a7a05a1d4166c83689929b30337e442fb9fc8ea49331a0d90723" + )! + private let commit = "3678ec46fe9e5fde87bcafcbc131e499feef5d5a" + + func testAutomicVault280ProvenanceFixture() async throws { + let (data, root) = try await fixture() + try verify(data, root: root) + } + + func testMutationsAreRejected() async throws { + let (fixture, root) = try await fixture() + let mutations: [(String, (inout [String: Any]) throws -> Void)] = [ + ("artifact name", { try Self.mutatePayload(&$0) { payload in + var subjects = payload["subject"] as! [[String: Any]] + subjects[0]["name"] = "Other.dmg" + payload["subject"] = subjects + }}), + ("artifact digest", { try Self.mutatePayload(&$0) { payload in + var subjects = payload["subject"] as! [[String: Any]] + subjects[0]["digest"] = ["sha256": String(repeating: "0", count: 64)] + payload["subject"] = subjects + }}), + ("DSSE signature", { root in + var envelope = root["dsseEnvelope"] as! [String: Any] + var signatures = envelope["signatures"] as! [[String: Any]] + signatures[0]["sig"] = Data("bad".utf8).base64EncodedString() + envelope["signatures"] = signatures + root["dsseEnvelope"] = envelope + }), + ("certificate", { root in + var material = root["verificationMaterial"] as! [String: Any] + material["certificate"] = ["rawBytes": Data("bad".utf8).base64EncodedString()] + root["verificationMaterial"] = material + }), + ("certificate claim", { try Self.mutateCertificate(&$0) { certificate in + let claim = Data("automic-vault/automic-vault".utf8) + let range = try XCTUnwrap(certificate.range(of: claim)) + certificate[range.lowerBound] ^= 1 + }}), + ("SCT", { try Self.mutateCertificate(&$0) { certificate in + let oid = Data([0x06, 0x0a, 0x2b, 0x06, 0x01, 0x04, 0x01, 0xd6, 0x79, 0x02, 0x04, 0x02]) + let range = try XCTUnwrap(certificate.range(of: oid)) + certificate[range.upperBound + 8] ^= 1 + }}), + ("workflow", { try Self.mutateWorkflow(&$0, key: "path", value: "other.yml") }), + ("ref", { try Self.mutateWorkflow(&$0, key: "ref", value: "refs/heads/dev") }), + ("repository", { try Self.mutateWorkflow(&$0, key: "repository", value: "https://github.com/other/repo") }), + ("source commit", { try Self.mutatePayload(&$0) { payload in + var predicate = payload["predicate"] as! [String: Any] + var definition = predicate["buildDefinition"] as! [String: Any] + var dependencies = definition["resolvedDependencies"] as! [[String: Any]] + dependencies[0]["digest"] = ["gitCommit": String(repeating: "0", count: 40)] + definition["resolvedDependencies"] = dependencies + predicate["buildDefinition"] = definition + payload["predicate"] = predicate + }}), + ("predicate type", { try Self.mutatePayload(&$0) { $0["predicateType"] = "other" } }), + ("build type", { try Self.mutatePayload(&$0) { payload in + var predicate = payload["predicate"] as! [String: Any] + var definition = predicate["buildDefinition"] as! [String: Any] + definition["buildType"] = "other" + predicate["buildDefinition"] = definition + payload["predicate"] = predicate + }}), + ("runner", { try Self.mutatePayload(&$0) { payload in + var predicate = payload["predicate"] as! [String: Any] + var definition = predicate["buildDefinition"] as! [String: Any] + var internalParameters = definition["internalParameters"] as! [String: Any] + var github = internalParameters["github"] as! [String: Any] + github["runner_environment"] = "self-hosted" + internalParameters["github"] = github + definition["internalParameters"] = internalParameters + predicate["buildDefinition"] = definition + payload["predicate"] = predicate + }}), + ("builder", { try Self.mutatePayload(&$0) { payload in + var predicate = payload["predicate"] as! [String: Any] + var details = predicate["runDetails"] as! [String: Any] + details["builder"] = ["id": "https://github.com/other/repo"] + predicate["runDetails"] = details + payload["predicate"] = predicate + }}), + ("signing time", { try Self.mutateEntry(&$0) { $0["integratedTime"] = "1" } }), + ("SET", { try Self.mutateEntry(&$0) { entry in + entry["inclusionPromise"] = ["signedEntryTimestamp": Data("bad".utf8).base64EncodedString()] + }}), + ("inclusion proof", { try Self.mutateEntry(&$0) { entry in + var proof = entry["inclusionProof"] as! [String: Any] + proof["rootHash"] = Data(repeating: 0, count: 32).base64EncodedString() + entry["inclusionProof"] = proof + }}), + ("checkpoint", { try Self.mutateEntry(&$0) { entry in + var proof = entry["inclusionProof"] as! [String: Any] + proof["checkpoint"] = ["envelope": "bad"] + entry["inclusionProof"] = proof + }}), + ("Rekor body", { try Self.mutateEntry(&$0) { $0["canonicalizedBody"] = Data("{}".utf8).base64EncodedString() } }), + ] + + for (name, mutation) in mutations { + var object = try XCTUnwrap( + JSONSerialization.jsonObject(with: fixture) as? [String: Any] + ) + try mutation(&object) + let data = try JSONSerialization.data(withJSONObject: object) + XCTAssertThrowsError(try verify(data, root: root), name) + } + } + + private func fixture() async throws -> (Data, SigstoreTrustedRoot) { + let url = try XCTUnwrap( + Bundle.module.url(forResource: "AutomicVault-2.8.0.bundle", withExtension: "json") + ) + let root = try await TUFClient( + fetch: { _, _ in throw URLError(.notConnectedToInternet) }, + now: { Date(timeIntervalSince1970: 1_785_531_075) } + ).trustedRoot() + return (try Data(contentsOf: url), root) + } + + private func verify(_ data: Data, root: SigstoreTrustedRoot) throws { + try BundleVerifier( + owner: "automic-vault", + repository: "automic-vault", + policy: .init( + workflow: ".github/workflows/release.yml", + sourceRef: "refs/heads/main" + ), + trustedRoot: root + ).verify( + try JSONDecoder().decode(SigstoreBundle.self, from: data), + assetName: "Automic-Vault-2.8.0.dmg", + digest: digest, + sourceCommit: commit, + repositoryID: "1228916972" + ) + } + + private static func mutatePayload( + _ root: inout [String: Any], + mutation: (inout [String: Any]) -> Void + ) throws { + var envelope = root["dsseEnvelope"] as! [String: Any] + let data = try XCTUnwrap(Data(base64Encoded: envelope["payload"] as! String)) + var payload = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + mutation(&payload) + envelope["payload"] = try JSONSerialization.data(withJSONObject: payload).base64EncodedString() + root["dsseEnvelope"] = envelope + } + + private static func mutateWorkflow( + _ root: inout [String: Any], key: String, value: String + ) throws { + try mutatePayload(&root) { payload in + var predicate = payload["predicate"] as! [String: Any] + var definition = predicate["buildDefinition"] as! [String: Any] + var external = definition["externalParameters"] as! [String: Any] + var workflow = external["workflow"] as! [String: Any] + workflow[key] = value + external["workflow"] = workflow + definition["externalParameters"] = external + predicate["buildDefinition"] = definition + payload["predicate"] = predicate + } + } + + private static func mutateEntry( + _ root: inout [String: Any], mutation: (inout [String: Any]) -> Void + ) throws { + var material = root["verificationMaterial"] as! [String: Any] + var entries = material["tlogEntries"] as! [[String: Any]] + mutation(&entries[0]) + material["tlogEntries"] = entries + root["verificationMaterial"] = material + } + + private static func mutateCertificate( + _ root: inout [String: Any], mutation: (inout Data) throws -> Void + ) throws { + var material = root["verificationMaterial"] as! [String: Any] + var certificate = material["certificate"] as! [String: Any] + var bytes = try XCTUnwrap(Data(base64Encoded: certificate["rawBytes"] as! String)) + try mutation(&bytes) + certificate["rawBytes"] = bytes.base64EncodedString() + material["certificate"] = certificate + root["verificationMaterial"] = material + } +} diff --git a/Tests/Fixtures/AutomicVault-2.8.0.bundle.json b/Tests/Fixtures/AutomicVault-2.8.0.bundle.json new file mode 100644 index 0000000..982a38c --- /dev/null +++ b/Tests/Fixtures/AutomicVault-2.8.0.bundle.json @@ -0,0 +1 @@ +{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","verificationMaterial":{"tlogEntries":[{"logIndex":"2306671630","logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="},"kindVersion":{"kind":"dsse","version":"0.0.1"},"integratedTime":"1785531075","inclusionPromise":{"signedEntryTimestamp":"MEYCIQDoa0gGvcxyfL2zX8ol6j2YppRWRWGTsEPFiTXQPUYeFAIhAI47Tkd7qQ+909bkp4Ycq/tSWabNasFbIsSio0YrSL73"},"inclusionProof":{"logIndex":"2184767368","rootHash":"1qN3YUUioKtXUooqavdRUAs8fzco33HlDoBU397zMCI=","treeSize":"2184767396","hashes":["FJ/0bePearDKBmezmcVlu0XmbnDrUwrbTXWstB1ZKBk=","hAjHqcH/uX+XeDz0mDO8ynUpCkWypJUd3xH5ZrVIV5M=","Gq8FeqoHlSAmVxFAe0MYvtT3o3mkv8V9mxxPW+pzBOo=","BXTuawsJNeO1A5k4u47mMjsXXXaQyHlw2KDQt3hMItA=","B3tZKB5cvoHhoPfOFLKF5jCYK6Pmk4iQQsrxLsE1U9o=","B5CtYAGesCHGVebmsZi6aF1ukA2j+fAfc8VS6WmvY+E=","JUxRT57TpijPA7sfGHJua2npU2UccDJXzjePt1E6qtw=","Jk6D8cNeMI9KZGaO0HXXQT28QnnTgZ/d5fPYvVlAIzs=","+oT2YcqrA+erQU3M9w1eKPIZWKVsL4Ix1D5wgoxWgj0=","5imBbaIn5XiaBuYXnT0MhMrIXPsEoG9mNtaeKHZZmMo=","QfPjmBqzjQIS8dwPMdlsr0vMzPp7YDTAZcxCwfY2Dzw=","SZQaDUSIO0F4gfc8Znygaw3u3y+nR5Z7SldQzJmNDRs=","+tcM6D4VFQTFKmzwFWVCtg78tXyDn+Zy7dhiHWuW1G0=","iZ3WNRD0FWZLXFYTfhzcVGMOoW1hYf6sPY9DdjZ0QjA=","aEGHHaFIgxSfCmL2fHhrn/ioNMmzj2wgjVfB6LaeU14=","meOCuDr9k5OHX+u1gJFDOy0wAVuS0AEdkOzzAwfleEs=","N2cxe8veqi3iG/EGf1zAagt4SqsWKwgyxNCegSDc60s=","xH/DCseLHr9eKoYT8qsORZK7zVdEGYWHuVtsVrD95wY="],"checkpoint":{"envelope":"rekor.sigstore.dev - 1193050959916656506\n2184767396\n1qN3YUUioKtXUooqavdRUAs8fzco33HlDoBU397zMCI=\n\n— rekor.sigstore.dev wNI9ajBGAiEAmw1F7Cmn4K68kW3G2vGnhTPTCfUh2MxKZzmcoqc+LtcCIQD+K8RTCDMbLVGkQxjUMC53GDN4T9WnTOsCNfY5legK5Q==\n"}},"canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiMjBmNTNhZjNlYmFlMGRhOWY1NzU5MGE3YmNiZmM2Njg1NzEwOTM5MGE5N2VlMmMxYzYyODhmZDE2YjNlZmIzYiJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6ImUzMTE0MjIwMGVlNGU4NWRjMjA2NWM2ZGQ0NTdlMzY2MWZhNzVmNDNmNzFhZTgzNjFkOWQ4Y2Q1YjhiZDNmZjMifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVZQ0lRRFZ5OHdaZ0E5SU51MmFUVnRWZ25OS3QwSTFpR2VSWDNzT2RqbXRqbUEvMXdJaEFLZWQzNlM0Q1h4K09EL1lrUmRCVDJzNzU4VW8yZVg1NkxKQkNZZ2FLZGc0IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VoWlJFTkRRblZYWjBGM1NVSkJaMGxWV1ZGUlVGWmtURnBCT1hOelYweHBaWEp5VFVkYWMyMUxWSGhSZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwWmQwNTZUWGhOYWtFeFRWUkZNVmRvWTA1TmFsbDNUbnBOZUUxcVJYZE5WRVV4VjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVmpUbmw0TjJZeU1FcE1UV2xGY3pCNU5EUm9SbUV5V1dObFdTOVNSVEY1THpsRVRtb0tOMWN6TjBZeFkwcEZRV0ZyZG5FNGIwOXNTRk5vZW00NVltMUZkRlpoTmtkWGExWjVSU3RhVm1Oak5XaDJhMU5ZWmpaUFEwSm5VWGRuWjFsQlRVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdSQ1owNVdTRkUwUlVablVWVmxRVXRKQ2pOVGFXWnBiU3R5Y2tKd2FHWmxkRUZtTnpCRGFrWm5kMGgzV1VSV1VqQnFRa0puZDBadlFWVXpPVkJ3ZWpGWmEwVmFZalZ4VG1wd1MwWlhhWGhwTkZrS1drUTRkMkZuV1VSV1VqQlNRVkZJTDBKSFFYZFliMXBqWVVoU01HTklUVFpNZVRsdVlWaFNiMlJYU1hWWk1qbDBUREpHTVdSSE9YUmhWMDEwWkcxR01RcGlTRkYyV1ZoV01HSXlNWEJaZVRFeVdWaFdjMlJET0hWYU1td3dZVWhXYVV3elpIWmpiWFJ0WWtjNU0yTjVPWGxhVjNoc1dWaE9iRXh1YkhSaVJVSjVDbHBYV25wTU1taHNXVmRTZWt3eU1XaGhWelIzVDFGWlMwdDNXVUpDUVVkRWRucEJRa0ZSVVhKaFNGSXdZMGhOTmt4NU9UQmlNblJzWW1rMWFGa3pVbkFLWWpJMWVreHRaSEJrUjJneFdXNVdlbHBZU21waU1qVXdXbGMxTUV4dFRuWmlWRUZtUW1kdmNrSm5SVVZCV1U4dlRVRkZRMEpDUmpOaU0wcHlXbTE0ZGdwa01UbHJZVmhPZDFsWVVtcGhSRUV5UW1kdmNrSm5SVVZCV1U4dlRVRkZSRUpEWjNwT2FtTTBXbGROTUU1dFdteFBWMVV4V20xU2JFOUVaR2xaTWtadENsa3lTbXBOVkUxNFdsUlJOVTlYV214YVYxa3hXa1JXYUUxQ1ZVZERhWE5IUVZGUlFtYzNPSGRCVVZGRlFqRktiR0pIVm1oak1sVjNTMUZaUzB0M1dVSUtRa0ZIUkhaNlFVSkNVVkZpV1ZoV01HSXlNWEJaZVRFeVdWaFdjMlJET1doa1dGSjJZbGRzYWt4WVdtaGtWM2d3VFVJd1IwTnBjMGRCVVZGQ1p6YzRkd3BCVVZsRlJETktiRnB1VFhaaFIxWm9Xa2hOZG1KWFJuQmlha0UzUW1kdmNrSm5SVVZCV1U4dlRVRkZTVUpETUUxTE1tZ3daRWhDZWs5cE9IWmtSemx5Q2xwWE5IVlpWMDR3WVZjNWRXTjVOVzVoV0ZKdlpGZEtNV015Vm5sWk1qbDFaRWRXZFdSRE5XcGlNakIzWWtGWlMwdDNXVUpDUVVkRWRucEJRa05SVW1VS1JFWjRiMlJJVW5kamVtOTJUREprY0dSSGFERlphVFZxWWpJd2RsbFlWakJpTWpGd1dYa3hNbGxZVm5Oa1F6bG9aRmhTZG1KWGJHcE1XRnBvWkZkNE1BcE1lVFZ1WVZoU2IyUlhTWFprTWpsNVlUSmFjMkl6WkhwTU0wcHNZa2RXYUdNeVZYVmxWekZ6VVVoS2JGcHVUWFpoUjFab1draE5kbUpYUm5CaWFrRTBDa0puYjNKQ1owVkZRVmxQTDAxQlJVdENRMjlOUzBSTk1rNTZhR3haZWxFeVdtMVZOVnBVVm0xYVIxVTBUakpLYWxsWFdtcFpiVTE0VFhwR2JFNUVhelVLV20xV2JGcHFWbXRPVjBWM1NGRlpTMHQzV1VKQ1FVZEVkbnBCUWtOM1VWQkVRVEZ1WVZoU2IyUlhTWFJoUnpsNlpFZFdhMDFFTkVkRGFYTkhRVkZSUWdwbk56aDNRVkYzUlUxQmQzVmhTRkl3WTBoTk5reDVPVzVoV0ZKdlpGZEpkVmt5T1hSTU1rWXhaRWM1ZEdGWFRYUmtiVVl4WWtoUmRsbFlWakJpTWpGd0NsbDVNVEpaV0ZaelpFUkJORUpuYjNKQ1owVkZRVmxQTDAxQlJVNUNRMjlOUzBSTk1rNTZhR3haZWxFeVdtMVZOVnBVVm0xYVIxVTBUakpLYWxsWFdtb0tXVzFOZUUxNlJteE9SR3MxV20xV2JGcHFWbXRPVjBWM1NIZFpTMHQzV1VKQ1FVZEVkbnBCUWtSblVWSkVRVGw1V2xkYWVrd3lhR3haVjFKNlRESXhhQXBoVnpSM1IyZFpTMHQzV1VKQ1FVZEVkbnBCUWtSM1VVMUVRVzk0VFdwSk5FOVVSVEpQVkdONVRVUkJSME5wYzBkQlVWRkNaemM0ZDBGU1FVVkpaM2RuQ21GSVVqQmpTRTAyVEhrNWJtRllVbTlrVjBsMVdUSTVkRXd5UmpGa1J6bDBZVmROZEdSdFJqRmlTRkYzUjFGWlMwdDNXVUpDUVVkRWRucEJRa1ZSVVV3S1JFRnJlVTU2VVRWTlZGbDZUVVJGZDJKQldVdExkMWxDUWtGSFJIWjZRVUpGWjFKbFJFWjRiMlJJVW5kamVtOTJUREprY0dSSGFERlphVFZxWWpJd2RncFpXRll3WWpJeGNGbDVNVEpaV0ZaelpFTTVhR1JZVW5aaVYyeHFURmhhYUdSWGVEQk1lVFZ1WVZoU2IyUlhTWFprTWpsNVlUSmFjMkl6WkhwTU0wcHNDbUpIVm1oak1sVjFaVmN4YzFGSVNteGFiazEyWVVkV2FGcElUWFppVjBad1ltcEJORUpuYjNKQ1owVkZRVmxQTDAxQlJWUkNRMjlOUzBSTk1rNTZhR3dLV1hwUk1scHRWVFZhVkZadFdrZFZORTR5U21wWlYxcHFXVzFOZUUxNlJteE9SR3MxV20xV2JGcHFWbXRPVjBWM1NWRlpTMHQzV1VKQ1FVZEVkbnBCUWdwR1FWRlVSRUpHTTJJelNuSmFiWGgyWkRFNWEyRllUbmRaV0ZKcVlVUkNhVUpuYjNKQ1owVkZRVmxQTDAxQlJWWkNSbEZOVlcxb01HUklRbnBQYVRoMkNsb3liREJoU0ZacFRHMU9kbUpUT1doa1dGSjJZbGRzYWt4WVdtaGtWM2d3VERKR01XUkhPWFJoVjAxMFpHMUdNV0pJVVhaWlYwNHdZVmM1ZFdONU9Ya0taRmMxZWt4NlRYZE9hbGt3VFVSRk5FNUVZekZNTWtZd1pFZFdkR05JVW5wTWVrVjNSbWRaUzB0M1dVSkNRVWRFZG5wQlFrWm5VVWxFUVZwM1pGZEtjd3BoVjAxM1JuZFpTMHQzV1VKQ1FVZEVkbnBCUWtaM1VVcEVRV1I1V2xkNGJGbFlUbXhOUlZGSFEybHpSMEZSVVVKbk56aDNRVkpuUlU1bmR6QmpiVlozQ21KNmNHaGtXRkoyWWxkc2FreFlXbWhrVjNnd1RESkdNV1JIT1hSaFYwMTBaRzFHTVdKSVVUWmFWelV5WVZoS2RtSnRNV3hpYmxFMlkyMVdjMXBYUm5vS1dsUkRRbWxSV1V0TGQxbENRa0ZJVjJWUlNVVkJaMUkzUWtoclFXUjNRakZCVGpBNVRVZHlSM2g0UlhsWmVHdGxTRXBzYms1M1MybFRiRFkwTTJwNWRBb3ZOR1ZMWTI5QmRrdGxOazlCUVVGQ2JqZHVlVU0zT0VGQlFWRkVRVVZaZDFKQlNXZEJWbWQyZW5GRU1GTnpZa1JOZW5WRmNWcHRTVkJ2U0haNk9HOWxDbE5OSzNka015OHdUR1p6VFVvMlowTkpTSFExY1hkRldHVk5ObmxPV2poM2VrOHJkR2tyTVZkVVVsRlpUMU5pWm1OeGJubEdTRzFXVjJ0VmNFMUJiMGNLUTBOeFIxTk5ORGxDUVUxRVFUSnJRVTFIV1VOTlVVUmpVME5tWldvM1UxRkthbmxRVTBWbFpHWkpaRGNyVmpWaWJWUmphalp4VG5GMFlVdExhMk5rVmdwQ05FZ3lWbEpWUVVweVEwbERaRVpVU1V4aFVFbFRTVU5OVVVReWFHUnBLM2xZY0RGVlIzRmFWek5rZEdkdlRHOTJiMnBtTmtwT01XUktaekJEZFVoM0NsUmxVVkpCYUc1d04ySkVhMHRFVTBkT2Nra3hXbXh3WjFGMFNUMEtMUzB0TFMxRlRrUWdRMFZTVkVsR1NVTkJWRVV0TFMwdExRbz0ifV19fQ=="}],"timestampVerificationData":{},"certificate":{"rawBytes":"MIIHYDCCBuWgAwIBAgIUYQQPVdLZA9ssWLierrMGZsmKTxQwCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjYwNzMxMjA1MTE1WhcNMjYwNzMxMjEwMTE1WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEcNyx7f20JLMiEs0y44hFa2YceY/RE1y/9DNj7W37F1cJEAakvq8oOlHShzn9bmEtVa6GWkVyE+ZVcc5hvkSXf6OCBgQwggYAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUeAKI3Sifim+rrBphfetAf70CjFgwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wagYDVR0RAQH/BGAwXoZcaHR0cHM6Ly9naXRodWIuY29tL2F1dG9taWMtdmF1bHQvYXV0b21pYy12YXVsdC8uZ2l0aHViL3dvcmtmbG93cy9yZWxlYXNlLnltbEByZWZzL2hlYWRzL21haW4wOQYKKwYBBAGDvzABAQQraHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbTAfBgorBgEEAYO/MAECBBF3b3JrZmxvd19kaXNwYXRjaDA2BgorBgEEAYO/MAEDBCgzNjc4ZWM0NmZlOWU1ZmRlODdiY2FmY2JjMTMxZTQ5OWZlZWY1ZDVhMBUGCisGAQQBg78wAQQEB1JlbGVhc2UwKQYKKwYBBAGDvzABBQQbYXV0b21pYy12YXVsdC9hdXRvbWljLXZhdWx0MB0GCisGAQQBg78wAQYED3JlZnMvaGVhZHMvbWFpbjA7BgorBgEEAYO/MAEIBC0MK2h0dHBzOi8vdG9rZW4uYWN0aW9ucy5naXRodWJ1c2VyY29udGVudC5jb20wbAYKKwYBBAGDvzABCQReDFxodHRwczovL2dpdGh1Yi5jb20vYXV0b21pYy12YXVsdC9hdXRvbWljLXZhdWx0Ly5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sQHJlZnMvaGVhZHMvbWFpbjA4BgorBgEEAYO/MAEKBCoMKDM2NzhlYzQ2ZmU5ZTVmZGU4N2JjYWZjYmMxMzFlNDk5ZmVlZjVkNWEwHQYKKwYBBAGDvzABCwQPDA1naXRodWItaG9zdGVkMD4GCisGAQQBg78wAQwEMAwuaHR0cHM6Ly9naXRodWIuY29tL2F1dG9taWMtdmF1bHQvYXV0b21pYy12YXVsdDA4BgorBgEEAYO/MAENBCoMKDM2NzhlYzQ2ZmU5ZTVmZGU4N2JjYWZjYmMxMzFlNDk5ZmVlZjVkNWEwHwYKKwYBBAGDvzABDgQRDA9yZWZzL2hlYWRzL21haW4wGgYKKwYBBAGDvzABDwQMDAoxMjI4OTE2OTcyMDAGCisGAQQBg78wARAEIgwgaHR0cHM6Ly9naXRodWIuY29tL2F1dG9taWMtdmF1bHQwGQYKKwYBBAGDvzABEQQLDAkyNzQ5MTYzMDEwbAYKKwYBBAGDvzABEgReDFxodHRwczovL2dpdGh1Yi5jb20vYXV0b21pYy12YXVsdC9hdXRvbWljLXZhdWx0Ly5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sQHJlZnMvaGVhZHMvbWFpbjA4BgorBgEEAYO/MAETBCoMKDM2NzhlYzQ2ZmU5ZTVmZGU4N2JjYWZjYmMxMzFlNDk5ZmVlZjVkNWEwIQYKKwYBBAGDvzABFAQTDBF3b3JrZmxvd19kaXNwYXRjaDBiBgorBgEEAYO/MAEVBFQMUmh0dHBzOi8vZ2l0aHViLmNvbS9hdXRvbWljLXZhdWx0L2F1dG9taWMtdmF1bHQvYWN0aW9ucy9ydW5zLzMwNjY0MDE4NDc1L2F0dGVtcHRzLzEwFgYKKwYBBAGDvzABFgQIDAZwdWJsaWMwFwYKKwYBBAGDvzABFwQJDAdyZWxlYXNlMEQGCisGAQQBg78wARgENgw0cmVwbzphdXRvbWljLXZhdWx0L2F1dG9taWMtdmF1bHQ6ZW52aXJvbm1lbnQ6cmVsZWFzZTCBiQYKKwYBBAHWeQIEAgR7BHkAdwB1AN09MGrGxxEyYxkeHJlnNwKiSl643jyt/4eKcoAvKe6OAAABn7nyC78AAAQDAEYwRAIgAVgvzqD0SsbDMzuEqZmIPoHvz8oeSM+wd3/0LfsMJ6gCIHt5qwEXeM6yNZ8wzO+ti+1WTRQYOSbfcqnyFHmVWkUpMAoGCCqGSM49BAMDA2kAMGYCMQDcSCfej7SQJjyPSEedfId7+V5bmTcj6qNqtaKKkcdVB4H2VRUAJrCICdFTILaPISICMQD2hdi+yXp1UGqZW3dtgoLovojf6JN1dJg0CuHwTeQRAhnp7bDkKDSGNrI1ZlpgQtI="}},"dsseEnvelope":{"payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiQXV0b21pYy1WYXVsdC0yLjguMC5kbWciLCJkaWdlc3QiOnsic2hhMjU2IjoiZTIxNjIyZTZmZGY3YTdhMDVhMWQ0MTY2YzgzNjg5OTI5YjMwMzM3ZTQ0MmZiOWZjOGVhNDkzMzFhMGQ5MDcyMyJ9fV0sInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3Nsc2EuZGV2L3Byb3ZlbmFuY2UvdjEiLCJwcmVkaWNhdGUiOnsiYnVpbGREZWZpbml0aW9uIjp7ImJ1aWxkVHlwZSI6Imh0dHBzOi8vYWN0aW9ucy5naXRodWIuaW8vYnVpbGR0eXBlcy93b3JrZmxvdy92MSIsImV4dGVybmFsUGFyYW1ldGVycyI6eyJ3b3JrZmxvdyI6eyJyZWYiOiJyZWZzL2hlYWRzL21haW4iLCJyZXBvc2l0b3J5IjoiaHR0cHM6Ly9naXRodWIuY29tL2F1dG9taWMtdmF1bHQvYXV0b21pYy12YXVsdCIsInBhdGgiOiIuZ2l0aHViL3dvcmtmbG93cy9yZWxlYXNlLnltbCJ9fSwiaW50ZXJuYWxQYXJhbWV0ZXJzIjp7ImdpdGh1YiI6eyJldmVudF9uYW1lIjoid29ya2Zsb3dfZGlzcGF0Y2giLCJyZXBvc2l0b3J5X2lkIjoiMTIyODkxNjk3MiIsInJlcG9zaXRvcnlfb3duZXJfaWQiOiIyNzQ5MTYzMDEiLCJydW5uZXJfZW52aXJvbm1lbnQiOiJnaXRodWItaG9zdGVkIn19LCJyZXNvbHZlZERlcGVuZGVuY2llcyI6W3sidXJpIjoiZ2l0K2h0dHBzOi8vZ2l0aHViLmNvbS9hdXRvbWljLXZhdWx0L2F1dG9taWMtdmF1bHRAcmVmcy9oZWFkcy9tYWluIiwiZGlnZXN0Ijp7ImdpdENvbW1pdCI6IjM2NzhlYzQ2ZmU5ZTVmZGU4N2JjYWZjYmMxMzFlNDk5ZmVlZjVkNWEifX1dfSwicnVuRGV0YWlscyI6eyJidWlsZGVyIjp7ImlkIjoiaHR0cHM6Ly9naXRodWIuY29tL2F1dG9taWMtdmF1bHQvYXV0b21pYy12YXVsdC8uZ2l0aHViL3dvcmtmbG93cy9yZWxlYXNlLnltbEByZWZzL2hlYWRzL21haW4ifSwibWV0YWRhdGEiOnsiaW52b2NhdGlvbklkIjoiaHR0cHM6Ly9naXRodWIuY29tL2F1dG9taWMtdmF1bHQvYXV0b21pYy12YXVsdC9hY3Rpb25zL3J1bnMvMzA2NjQwMTg0NzUvYXR0ZW1wdHMvMSJ9fX19","payloadType":"application/vnd.in-toto+json","signatures":[{"sig":"MEYCIQDVy8wZgA9INu2aTVtVgnNKt0I1iGeRX3sOdjmtjmA/1wIhAKed36S4CXx+OD/YkRdBT2s758Uo2eX56LJBCYgaKdg4"}]}} diff --git a/Tests/Fixtures/README.md b/Tests/Fixtures/README.md new file mode 100644 index 0000000..da957b3 --- /dev/null +++ b/Tests/Fixtures/README.md @@ -0,0 +1,4 @@ +`AutomicVault-2.8.0.bundle.json` is a decompressed GitHub SLSA provenance +bundle for the Automic Vault 2.8.0 DMG. Automic Vault is Apache-2.0 licensed; +the fixture is retained under that license and was obtained from GitHub's +public artifact attestations API. From 6a6975b05f4ffcdebe16307dbd2b5419ca6592bf Mon Sep 17 00:00:00 2001 From: Max Howell Date: Fri, 31 Jul 2026 23:22:03 -0400 Subject: [PATCH 3/7] Release AppUpdater 4.1.0 --- README.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 397cf00..0df9d82 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ from downloading and preparing an installation. ```swift package.dependencies.append( - .package(url: "https://github.com/mxcl/AppUpdater.git", from: "4.0.0") + .package(url: "https://github.com/mxcl/AppUpdater.git", from: "4.1.0") ) ``` @@ -119,6 +119,35 @@ let updater = AppUpdater( ) ``` +### GitHub Actions provenance + +AppUpdater 4.1 can additionally require GitHub Artifact Attestation provenance: + +```swift +let updater = AppUpdater( + owner: "example", + repo: "MyApp", + configuration: .init( + attestationPolicy: GitHubAttestationPolicy( + workflow: ".github/workflows/release.yml", + sourceRef: "refs/heads/main" + ) + ) +) +``` + +This is opt-in. The default `attestationPolicy == nil` retains AppUpdater 4.0 +behavior. When enabled, the GitHub release must expose its exact 40-character +target commit and a `sha256:` digest for the DMG. The configured workflow must +publish SLSA provenance v1 with GitHub's workflow build type, use a GitHub-hosted +runner, and attest the DMG filename, digest, repository, workflow, ref, and +resolved source commit. + +Only public GitHub Actions bundles using Sigstore's public-good Fulcio, Rekor, +and CT logs are supported. GitHub release attestations, private-repository RFC +3161 bundles, SBOM predicates, generic Sigstore identities, other build types, +and other trust domains are rejected. + ## Security model AppUpdater aims to prevent privilege amplification. A same-user attacker must @@ -136,6 +165,30 @@ code, strict sealed resources, app-like bundle structure, and restricted symlinks. AppUpdater rejects ad-hoc, development, self-signed, and broad custom requirements such as `designated => true`. +With an attestation policy, provenance verification is an additional mandatory +gate; Developer ID validation is never used as a fallback. AppUpdater streams +and hashes the downloaded DMG, matches GitHub's release digest, and verifies the +Sigstore v0.3 DSSE bundle before mounting. Verification covers the Fulcio chain +and signing time, code-signing EKU, GitHub OIDC certificate claims, SCT, DSSE +signature, Rekor SET, RFC 6962 inclusion proof, signed checkpoint, and the +supported in-toto/SLSA assertions. After Finder promotion, AppUpdater hashes +the protected copy again and refuses to mount it unless the verified digest is +unchanged. + +Sigstore trust is bootstrapped from the root shipped with AppUpdater and +refreshed from Sigstore's public TUF repository. Refresh performs sequential +root rotation and threshold verification, then verifies timestamp, snapshot, +targets, hashes, lengths, expiry, and rollback state. Cache writes are atomic; +an offline cache is used only when its complete signed metadata chain remains +valid, otherwise the shipped trusted-root snapshot is used. Malformed or +cryptographically invalid online metadata is never treated as an offline +failure. + +Missing, malformed, expired, unsupported, oversized, or unverifiable +attestation material fails `prepareInstallation()` with +`AppUpdaterError.attestationVerificationFailed`. No DMG is mounted and no +installation change is attempted. + AppUpdater downloads into a private directory, mounts the DMG read-only, and keeps the validated mount alive. For a protected installation, Finder copies the DMG itself into a randomized hidden sibling of the installed app. AppUpdater @@ -149,10 +202,12 @@ that user's control. AppUpdater still uses a private same-parent DMG copy and performs the same validation and rollback transaction, but it cannot protect that path from another process running as the same user. -The signature check does not bind a GitHub release version to the version inside -the app. AppUpdater provides no rollback protection and performs no Gatekeeper -or notarization assessment. A compromised Developer ID signing key can produce -an update that passes the identity checks. +Neither mode binds a GitHub release version to the version inside the app or +performs a Gatekeeper/notarization assessment. Without an attestation policy, +AppUpdater provides no release provenance or rollback protection and a +compromised Developer ID key can produce an update that passes identity checks. +With a policy, a candidate must also originate from the configured GitHub +workflow/ref and resolve to the release's exact source commit. ## Alternatives From edf5f5be16800de6cadbd63367262f8ed6d32e5d Mon Sep 17 00:00:00 2001 From: Max Howell Date: Sat, 1 Aug 2026 17:55:58 -0400 Subject: [PATCH 4/7] Address attestation review findings --- AppUpdater.swift | 2 +- Attestation/TUF.swift | 16 +++++++- Attestation/Verification.swift | 35 ++++++++++++----- .../SigstoreVerificationTests.swift | 38 +++++++++++++++++++ 4 files changed, 79 insertions(+), 12 deletions(-) diff --git a/AppUpdater.swift b/AppUpdater.swift index 3ad2489..0b757d3 100644 --- a/AppUpdater.swift +++ b/AppUpdater.swift @@ -473,7 +473,7 @@ enum NetworkTransfer { data.reserveCapacity(Int(min(response.expectedContentLength, maximumBytes))) } for try await byte in bytes { - guard data.count < maximumBytes else { + guard Int64(data.count) < maximumBytes else { throw AppUpdaterError.resourceLimitExceeded("response size") } data.append(byte) diff --git a/Attestation/TUF.swift b/Attestation/TUF.swift index 6569688..4287435 100644 --- a/Attestation/TUF.swift +++ b/Attestation/TUF.swift @@ -46,7 +46,11 @@ struct SigstoreTrustedRoot: Decodable, Sendable { let tlogs: [Log] let ctlogs: [Log] - func logKey(id: Data, at date: Date, certificateTransparency: Bool = false) throws -> PublicKey { + func transparencyLog( + id: Data, + at date: Date, + certificateTransparency: Bool = false + ) throws -> Log { let logs = certificateTransparency ? ctlogs : tlogs guard let log = logs.first(where: { guard $0.logId.keyId == id, @@ -57,7 +61,15 @@ struct SigstoreTrustedRoot: Decodable, Sendable { ? host == "ctfe.sigstore.dev" : host == "rekor.sigstore.dev" || host.hasSuffix(".rekor.sigstore.dev") }) else { throw AttestationFailure.invalid("unknown or inactive transparency log") } - return log.publicKey + return log + } + + func logKey(id: Data, at date: Date, certificateTransparency: Bool = false) throws -> PublicKey { + try transparencyLog( + id: id, + at: date, + certificateTransparency: certificateTransparency + ).publicKey } } diff --git a/Attestation/Verification.swift b/Attestation/Verification.swift index f3984c7..e70b6ba 100644 --- a/Attestation/Verification.swift +++ b/Attestation/Verification.swift @@ -143,7 +143,11 @@ struct BundleVerifier { guard proofIndex < treeSize else { throw AttestationFailure.invalid("invalid Rekor proof index") } - let logKey = try trustedRoot.logKey(id: entry.logId.keyId, at: integratedDate) + let log = try trustedRoot.transparencyLog(id: entry.logId.keyId, at: integratedDate) + let logKey = log.publicKey + guard let logHost = URL(string: log.baseUrl)?.host?.lowercased() else { + throw AttestationFailure.invalid("invalid transparency log URL") + } let object = try JSONSerialization.jsonObject(with: entry.canonicalizedBody) guard try JSONCanonicalizer.canonicalize(object) == entry.canonicalizedBody else { @@ -175,7 +179,7 @@ struct BundleVerifier { "logID": entry.logId.keyId.hex, "logIndex": logIndex, ] - try verifyLogSignature( + try Self.verifyLogSignature( entry.inclusionPromise.signedEntryTimestamp, message: try JSONCanonicalizer.canonicalize(setPayload), key: logKey @@ -190,30 +194,43 @@ struct BundleVerifier { hashes: entry.inclusionProof.hashes, expectedRoot: entry.inclusionProof.rootHash ) - try verifyCheckpoint( + try Self.verifyCheckpoint( entry.inclusionProof.checkpoint.envelope, treeSize: treeSize, rootHash: entry.inclusionProof.rootHash, logID: entry.logId.keyId, + expectedHost: logHost, key: logKey ) } - private func verifyCheckpoint( + static func verifyCheckpoint( _ checkpoint: String, treeSize: UInt64, rootHash: Data, logID: Data, + expectedHost: String, key: SigstoreTrustedRoot.PublicKey ) throws { let parts = checkpoint.split(separator: "\n", omittingEmptySubsequences: false) - guard parts.count == 6, parts[0].hasPrefix("rekor.sigstore.dev - "), + let header = parts.first?.split( + separator: " ", + maxSplits: 2, + omittingEmptySubsequences: true + ) ?? [] + guard parts.count == 6, header.count == 3, header[1] == "-", !header[2].isEmpty, + header[0].lowercased() == expectedHost, + expectedHost == "rekor.sigstore.dev" + || expectedHost.hasSuffix(".rekor.sigstore.dev"), parts[1] == String(treeSize), Data(base64Encoded: String(parts[2])) == rootHash, - parts[3].isEmpty, parts[5].isEmpty, - parts[4].hasPrefix("— rekor.sigstore.dev ") + parts[3].isEmpty, parts[5].isEmpty else { throw AttestationFailure.invalid("invalid Rekor checkpoint") } - let encodedSignature = parts[4].dropFirst("— rekor.sigstore.dev ".count) + let signaturePrefix = "— \(header[0]) " + guard parts[4].hasPrefix(signaturePrefix) else { + throw AttestationFailure.invalid("invalid Rekor checkpoint") + } + let encodedSignature = parts[4].dropFirst(signaturePrefix.count) guard let signatureWithHint = Data(base64Encoded: String(encodedSignature)), signatureWithHint.count > 4, signatureWithHint.prefix(4) == logID.prefix(4) @@ -222,7 +239,7 @@ struct BundleVerifier { try verifyLogSignature(Data(signatureWithHint.dropFirst(4)), message: message, key: key) } - private func verifyLogSignature( + private static func verifyLogSignature( _ signature: Data, message: Data, key: SigstoreTrustedRoot.PublicKey diff --git a/Tests/AppUpdaterTests/SigstoreVerificationTests.swift b/Tests/AppUpdaterTests/SigstoreVerificationTests.swift index 48afb9d..c19f3f3 100644 --- a/Tests/AppUpdaterTests/SigstoreVerificationTests.swift +++ b/Tests/AppUpdaterTests/SigstoreVerificationTests.swift @@ -1,4 +1,5 @@ @testable import AppUpdater +import CryptoKit import XCTest final class SigstoreVerificationTests: XCTestCase { @@ -12,6 +13,43 @@ final class SigstoreVerificationTests: XCTestCase { try verify(data, root: root) } + func testShardedRekorCheckpointHost() throws { + let host = "log2025-1.rekor.sigstore.dev" + let privateKey = Curve25519.Signing.PrivateKey() + let key = SigstoreTrustedRoot.PublicKey( + rawBytes: Data(hex: "302a300506032b6570032100")! + + privateKey.publicKey.rawRepresentation, + keyDetails: "PKIX_ED25519", + validFor: nil + ) + let logID = Data(repeating: 0x42, count: 32) + let rootHash = Data(repeating: 0x24, count: 32) + let signed = "\(host) - 1234\n42\n\(rootHash.base64EncodedString())\n" + var signature = Data(logID.prefix(4)) + signature.append(try privateKey.signature(for: Data(signed.utf8))) + let checkpoint = "\(signed)\n— \(host) \(signature.base64EncodedString())\n" + + try BundleVerifier.verifyCheckpoint( + checkpoint, + treeSize: 42, + rootHash: rootHash, + logID: logID, + expectedHost: host, + key: key + ) + XCTAssertThrowsError(try BundleVerifier.verifyCheckpoint( + checkpoint.replacingOccurrences( + of: "— \(host) ", + with: "— rekor.sigstore.dev " + ), + treeSize: 42, + rootHash: rootHash, + logID: logID, + expectedHost: host, + key: key + )) + } + func testMutationsAreRejected() async throws { let (fixture, root) = try await fixture() let mutations: [(String, (inout [String: Any]) throws -> Void)] = [ From adb40efb234c322027ac79388b49297e11e80397 Mon Sep 17 00:00:00 2001 From: Max Howell Date: Sun, 2 Aug 2026 12:16:39 -0400 Subject: [PATCH 5/7] Add live attestation smoke test --- Tests/live-attestation-smoke.sh | 131 ++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100755 Tests/live-attestation-smoke.sh diff --git a/Tests/live-attestation-smoke.sh b/Tests/live-attestation-smoke.sh new file mode 100755 index 0000000..5a46219 --- /dev/null +++ b/Tests/live-attestation-smoke.sh @@ -0,0 +1,131 @@ +#!/bin/bash +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +identity="$( + security find-identity -v -p codesigning | + awk -F '"' '/Developer ID Application: Max Howell \(ZU76A67LGU\)/ { print $2; exit }' +)" +if [[ -z "$identity" ]]; then + echo "missing Developer ID Application identity for ZU76A67LGU" >&2 + exit 1 +fi + +work="$(mktemp -d "$HOME/Library/Caches/appupdater-live-attestation.XXXXXX")" +keep=0 +cleanup() { + status=$? + if [[ "$status" -ne 0 || "$keep" -eq 0 ]]; then + rm -rf "$work" + fi +} +trap cleanup EXIT +package="$work/package" +app="$work/Automic Vault.app" +mkdir -p "$package/Sources/Smoke" "$app/Contents/MacOS" "$app/Contents/Resources" + +cat >"$package/Package.swift" <"$package/Sources/Smoke/main.swift" <<'EOF' +import AppKit +import AppUpdater +import Foundation + +enum SmokeFailure: Error { + case noUpdate + case invalidNegativeControl +} + +@main +struct Smoke { + @MainActor + static func main() async throws { + _ = NSApplication.shared + + let rejected = AppUpdater( + owner: "automic-vault", + repo: "automic-vault", + configuration: .init( + attestationPolicy: .init( + workflow: ".github/workflows/release.yml", + sourceRef: "refs/heads/not-main" + ) + ) + ) + guard let rejectedUpdate = try await rejected.check() else { + throw SmokeFailure.noUpdate + } + do { + let prepared = try await rejectedUpdate.prepareInstallation() + await prepared.discard() + throw SmokeFailure.invalidNegativeControl + } catch AppUpdaterError.attestationVerificationFailed { + print("PASS: incorrect source ref rejected") + } + + let updater = AppUpdater( + owner: "automic-vault", + repo: "automic-vault", + configuration: .init( + attestationPolicy: .init( + workflow: ".github/workflows/release.yml", + sourceRef: "refs/heads/main" + ) + ) + ) + guard let update = try await updater.check() else { + throw SmokeFailure.noUpdate + } + let prepared = try await update.prepareInstallation() + print("PASS: prepared attested \(update.assetName)") + + if ProcessInfo.processInfo.environment["APPUPDATER_SMOKE_INSTALL"] == "1" { + print("Installing into disposable bundle at \(Bundle.main.bundlePath)") + try await prepared.installAndRelaunch() + } else { + await prepared.discard() + } + } +} +EOF + +swift build -c release --package-path "$package" +bin="$(swift build -c release --package-path "$package" --show-bin-path)" +cp "$bin/Smoke" "$app/Contents/MacOS/Smoke" +find "$bin" -maxdepth 1 -name '*.bundle' -exec cp -R {} "$app/Contents/Resources/" \; + +plutil -create xml1 "$app/Contents/Info.plist" +plutil -insert CFBundleExecutable -string Smoke "$app/Contents/Info.plist" +plutil -insert CFBundleIdentifier -string com.automicvault "$app/Contents/Info.plist" +plutil -insert CFBundleName -string 'Automic Vault' "$app/Contents/Info.plist" +plutil -insert CFBundlePackageType -string APPL "$app/Contents/Info.plist" +plutil -insert CFBundleShortVersionString -string 0.0.1 "$app/Contents/Info.plist" +plutil -insert CFBundleVersion -string 1 "$app/Contents/Info.plist" +plutil -insert LSMinimumSystemVersion -string 12.0 "$app/Contents/Info.plist" +plutil -insert LSUIElement -bool true "$app/Contents/Info.plist" + +codesign --force --sign "$identity" --options runtime --timestamp "$app" +codesign --verify --deep --strict "$app" + +echo "Disposable app: $app" +"$app/Contents/MacOS/Smoke" + +if [[ "${APPUPDATER_SMOKE_INSTALL:-}" == "1" ]]; then + version="$(plutil -extract CFBundleShortVersionString raw "$app/Contents/Info.plist")" + [[ "$version" != "0.0.1" ]] + codesign --verify --deep --strict "$app" + echo "PASS: installed and verified Automic Vault $version at $app" + keep=1 +fi From 9f885c31b1c651e6dba8081b7422c2ed5df8eb7a Mon Sep 17 00:00:00 2001 From: Max Howell Date: Sun, 2 Aug 2026 12:17:52 -0400 Subject: [PATCH 6/7] Match smoke app executable identity --- Tests/live-attestation-smoke.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/live-attestation-smoke.sh b/Tests/live-attestation-smoke.sh index 5a46219..919b142 100755 --- a/Tests/live-attestation-smoke.sh +++ b/Tests/live-attestation-smoke.sh @@ -103,11 +103,11 @@ EOF swift build -c release --package-path "$package" bin="$(swift build -c release --package-path "$package" --show-bin-path)" -cp "$bin/Smoke" "$app/Contents/MacOS/Smoke" +cp "$bin/Smoke" "$app/Contents/MacOS/AutomicVaultMenubar" find "$bin" -maxdepth 1 -name '*.bundle' -exec cp -R {} "$app/Contents/Resources/" \; plutil -create xml1 "$app/Contents/Info.plist" -plutil -insert CFBundleExecutable -string Smoke "$app/Contents/Info.plist" +plutil -insert CFBundleExecutable -string AutomicVaultMenubar "$app/Contents/Info.plist" plutil -insert CFBundleIdentifier -string com.automicvault "$app/Contents/Info.plist" plutil -insert CFBundleName -string 'Automic Vault' "$app/Contents/Info.plist" plutil -insert CFBundlePackageType -string APPL "$app/Contents/Info.plist" @@ -120,7 +120,7 @@ codesign --force --sign "$identity" --options runtime --timestamp "$app" codesign --verify --deep --strict "$app" echo "Disposable app: $app" -"$app/Contents/MacOS/Smoke" +"$app/Contents/MacOS/AutomicVaultMenubar" if [[ "${APPUPDATER_SMOKE_INSTALL:-}" == "1" ]]; then version="$(plutil -extract CFBundleShortVersionString raw "$app/Contents/Info.plist")" From f013456e4b057334dda5c38afaa152a31efa7b74 Mon Sep 17 00:00:00 2001 From: Max Howell Date: Sun, 2 Aug 2026 12:19:31 -0400 Subject: [PATCH 7/7] Exclude live smoke script from test target --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index bc430c8..c5a6b9f 100644 --- a/Package.swift +++ b/Package.swift @@ -25,7 +25,7 @@ let package = Package( name: "AppUpdaterTests", dependencies: ["AppUpdater"], path: "Tests", - exclude: ["MANUAL.md"], + exclude: ["MANUAL.md", "live-attestation-smoke.sh"], resources: [.process("Fixtures")] ), ],