| 1 | //! A set of certificates. Typically pre-installed on every operating system, |
| 2 | //! these are "Certificate Authorities" used to validate SSL certificates. |
| 3 | //! This data structure stores certificates in DER-encoded form, all of them |
| 4 | //! concatenated together in the `bytes` array. The `map` field contains an |
| 5 | //! index from the DER-encoded subject name to the index of the containing |
| 6 | //! certificate within `bytes`. |
| 7 | const Bundle = @This(); |
| 8 | const builtin = @import("builtin"); |
| 9 | |
| 10 | const std = @import("../../std.zig"); |
| 11 | const Io = std.Io; |
| 12 | const Dir = std.Io.Dir; |
| 13 | const assert = std.debug.assert; |
| 14 | const mem = std.mem; |
| 15 | const crypto = std.crypto; |
| 16 | const Allocator = std.mem.Allocator; |
| 17 | const Certificate = std.crypto.Certificate; |
| 18 | const der = Certificate.der; |
| 19 | |
| 20 | const base64 = std.base64.standard.decoderWithIgnore(" \t\r\n"); |
| 21 | |
| 22 | /// The key is the contents slice of the subject. |
| 23 | map: std.HashMapUnmanaged(der.Element.Slice, u32, MapContext, std.hash_map.default_max_load_percentage), |
| 24 | bytes: std.ArrayList(u8), |
| 25 | |
| 26 | pub const empty: Bundle = .{ .map = .empty, .bytes = .empty }; |
| 27 | |
| 28 | pub const VerifyError = Certificate.Parsed.VerifyError || error{ |
| 29 | CertificateIssuerNotFound, |
| 30 | }; |
| 31 | |
| 32 | pub fn verify(cb: Bundle, subject: Certificate.Parsed, now_sec: i64) VerifyError!void { |
| 33 | const bytes_index = cb.find(subject.issuer()) orelse return error.CertificateIssuerNotFound; |
| 34 | const issuer_cert: Certificate = .{ |
| 35 | .buffer = cb.bytes.items, |
| 36 | .index = bytes_index, |
| 37 | }; |
| 38 | // Every certificate in the bundle is pre-parsed before adding it, ensuring |
| 39 | // that parsing will succeed here. |
| 40 | const issuer = issuer_cert.parse() catch unreachable; |
| 41 | try subject.verify(issuer, now_sec); |
| 42 | } |
| 43 | |
| 44 | /// The returned bytes become invalid after calling any of the rescan functions |
| 45 | /// or add functions. |
| 46 | pub fn find(cb: Bundle, subject_name: []const u8) ?u32 { |
| 47 | const Adapter = struct { |
| 48 | cb: Bundle, |
| 49 | |
| 50 | pub fn hash(ctx: @This(), k: []const u8) u64 { |
| 51 | _ = ctx; |
| 52 | return std.hash_map.hashString(k); |
| 53 | } |
| 54 | |
| 55 | pub fn eql(ctx: @This(), a: []const u8, b_key: der.Element.Slice) bool { |
| 56 | const b = ctx.cb.bytes.items[b_key.start..b_key.end]; |
| 57 | return mem.eql(u8, a, b); |
| 58 | } |
| 59 | }; |
| 60 | return cb.map.getAdapted(subject_name, Adapter{ .cb = cb }); |
| 61 | } |
| 62 | |
| 63 | pub fn deinit(cb: *Bundle, gpa: Allocator) void { |
| 64 | cb.map.deinit(gpa); |
| 65 | cb.bytes.deinit(gpa); |
| 66 | cb.* = undefined; |
| 67 | } |
| 68 | |
| 69 | pub const RescanError = RescanLinuxError || RescanMacError || RescanWithPathError || RescanWindowsError; |
| 70 | |
| 71 | /// Clears the set of certificates and then scans the host operating system |
| 72 | /// file system standard locations for certificates. |
| 73 | /// For operating systems that do not have standard CA installations to be |
| 74 | /// found, this function clears the set of certificates. |
| 75 | pub fn rescan(cb: *Bundle, gpa: Allocator, io: Io, now: Io.Timestamp) RescanError!void { |
| 76 | switch (builtin.os.tag) { |
| 77 | .linux => return rescanLinux(cb, gpa, io, now), |
| 78 | .maccatalyst, .macos => return rescanMac(cb, gpa, io, now), |
| 79 | .freebsd, .openbsd => return rescanWithPath(cb, gpa, io, now, "/etc/ssl/cert.pem"), |
| 80 | .netbsd => return rescanWithPath(cb, gpa, io, now, "/etc/openssl/certs/ca-certificates.crt"), |
| 81 | .dragonfly => return rescanWithPath(cb, gpa, io, now, "/usr/local/etc/ssl/cert.pem"), |
| 82 | .illumos => return rescanWithPath(cb, gpa, io, now, "/etc/ssl/cacert.pem"), |
| 83 | .haiku => return rescanWithPath(cb, gpa, io, now, "/boot/system/data/ssl/CARootCertificates.pem"), |
| 84 | // https://github.com/SerenityOS/serenity/blob/222acc9d389bc6b490d4c39539761b043a4bfcb0/Ports/ca-certificates/package.sh#L19 |
| 85 | .serenity => return rescanWithPath(cb, gpa, io, now, "/etc/ssl/certs/ca-certificates.crt"), |
| 86 | .windows => return rescanWindows(cb, gpa, io, now), |
| 87 | else => {}, |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | const rescanMac = @import("Bundle/macos.zig").rescanMac; |
| 92 | const RescanMacError = @import("Bundle/macos.zig").RescanMacError; |
| 93 | |
| 94 | const RescanLinuxError = AddCertsFromFilePathError || AddCertsFromDirPathError; |
| 95 | |
| 96 | fn rescanLinux(cb: *Bundle, gpa: Allocator, io: Io, now: Io.Timestamp) RescanLinuxError!void { |
| 97 | // Possible certificate files; stop after finding one. |
| 98 | const cert_file_paths = [_][]const u8{ |
| 99 | "/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu/Gentoo etc. |
| 100 | "/etc/pki/tls/certs/ca-bundle.crt", // Fedora/RHEL 6 |
| 101 | "/etc/ssl/ca-bundle.pem", // OpenSUSE |
| 102 | "/etc/pki/tls/cacert.pem", // OpenELEC |
| 103 | "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", // CentOS/RHEL 7 |
| 104 | "/etc/ssl/cert.pem", // Alpine Linux |
| 105 | }; |
| 106 | |
| 107 | // Possible directories with certificate files; all will be read. |
| 108 | const cert_dir_paths = [_][]const u8{ |
| 109 | "/etc/ssl/certs", // SLES10/SLES11 |
| 110 | "/etc/pki/tls/certs", // Fedora/RHEL |
| 111 | "/system/etc/security/cacerts", // Android |
| 112 | }; |
| 113 | |
| 114 | cb.bytes.clearRetainingCapacity(); |
| 115 | cb.map.clearRetainingCapacity(); |
| 116 | |
| 117 | scan: { |
| 118 | for (cert_file_paths) |cert_file_path| { |
| 119 | if (addCertsFromFilePathAbsolute(cb, gpa, io, now, cert_file_path)) |_| { |
| 120 | break :scan; |
| 121 | } else |err| switch (err) { |
| 122 | error.FileNotFound => continue, |
| 123 | else => |e| return e, |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | for (cert_dir_paths) |cert_dir_path| { |
| 128 | addCertsFromDirPathAbsolute(cb, gpa, io, now, cert_dir_path) catch |err| switch (err) { |
| 129 | error.FileNotFound => continue, |
| 130 | else => |e| return e, |
| 131 | }; |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | cb.bytes.shrinkAndFree(gpa, cb.bytes.items.len); |
| 136 | } |
| 137 | |
| 138 | const RescanWithPathError = AddCertsFromFilePathError; |
| 139 | |
| 140 | fn rescanWithPath(cb: *Bundle, gpa: Allocator, io: Io, now: Io.Timestamp, cert_file_path: []const u8) RescanWithPathError!void { |
| 141 | cb.bytes.clearRetainingCapacity(); |
| 142 | cb.map.clearRetainingCapacity(); |
| 143 | try addCertsFromFilePathAbsolute(cb, gpa, io, now, cert_file_path); |
| 144 | cb.bytes.shrinkAndFree(gpa, cb.bytes.items.len); |
| 145 | } |
| 146 | |
| 147 | const RescanWindowsError = Allocator.Error || ParseCertError || std.posix.UnexpectedError || error{FileNotFound}; |
| 148 | |
| 149 | fn rescanWindows(cb: *Bundle, gpa: Allocator, io: Io, now: Io.Timestamp) RescanWindowsError!void { |
| 150 | cb.bytes.clearRetainingCapacity(); |
| 151 | cb.map.clearRetainingCapacity(); |
| 152 | |
| 153 | _ = io; |
| 154 | |
| 155 | const w = std.os.windows; |
| 156 | const GetLastError = w.GetLastError; |
| 157 | const root = [4:0]u16{ 'R', 'O', 'O', 'T' }; |
| 158 | const store = w.crypt32.CertOpenSystemStoreW(.NULL, &root) orelse switch (GetLastError()) { |
| 159 | .FILE_NOT_FOUND => return error.FileNotFound, |
| 160 | else => |err| return w.unexpectedError(err), |
| 161 | }; |
| 162 | defer assert(w.crypt32.CertCloseStore(store, .{ .CHECK = std.debug.runtime_safety }).toBool()); |
| 163 | |
| 164 | const now_sec = now.toSeconds(); |
| 165 | |
| 166 | var ctx = w.crypt32.CertEnumCertificatesInStore(store, null); |
| 167 | while (ctx) |context| : (ctx = w.crypt32.CertEnumCertificatesInStore(store, ctx)) { |
| 168 | const decoded_start = @as(u32, @intCast(cb.bytes.items.len)); |
| 169 | const encoded_cert = context.pbCertEncoded[0..context.cbCertEncoded]; |
| 170 | try cb.bytes.appendSlice(gpa, encoded_cert); |
| 171 | try cb.parseCert(gpa, decoded_start, now_sec); |
| 172 | } |
| 173 | cb.bytes.shrinkAndFree(gpa, cb.bytes.items.len); |
| 174 | } |
| 175 | |
| 176 | pub const AddCertsFromDirPathError = Io.File.OpenError || AddCertsFromDirError; |
| 177 | |
| 178 | pub fn addCertsFromDirPath( |
| 179 | cb: *Bundle, |
| 180 | gpa: Allocator, |
| 181 | io: Io, |
| 182 | dir: Io.Dir, |
| 183 | sub_dir_path: []const u8, |
| 184 | ) AddCertsFromDirPathError!void { |
| 185 | var iterable_dir = try dir.openDir(io, sub_dir_path, .{ .iterate = true }); |
| 186 | defer iterable_dir.close(io); |
| 187 | const now = Io.Clock.real.now(io); |
| 188 | return addCertsFromDir(cb, gpa, io, now, iterable_dir); |
| 189 | } |
| 190 | |
| 191 | pub fn addCertsFromDirPathAbsolute( |
| 192 | cb: *Bundle, |
| 193 | gpa: Allocator, |
| 194 | io: Io, |
| 195 | now: Io.Timestamp, |
| 196 | abs_dir_path: []const u8, |
| 197 | ) AddCertsFromDirPathError!void { |
| 198 | assert(Dir.path.isAbsolute(abs_dir_path)); |
| 199 | var iterable_dir = try Dir.openDirAbsolute(io, abs_dir_path, .{ .iterate = true }); |
| 200 | defer iterable_dir.close(io); |
| 201 | return addCertsFromDir(cb, gpa, io, now, iterable_dir); |
| 202 | } |
| 203 | |
| 204 | pub const AddCertsFromDirError = AddCertsFromFilePathError; |
| 205 | |
| 206 | pub fn addCertsFromDir(cb: *Bundle, gpa: Allocator, io: Io, now: Io.Timestamp, iterable_dir: Io.Dir) AddCertsFromDirError!void { |
| 207 | var it = iterable_dir.iterate(); |
| 208 | while (try it.next(io)) |entry| { |
| 209 | switch (entry.kind) { |
| 210 | .file, .sym_link => {}, |
| 211 | else => continue, |
| 212 | } |
| 213 | |
| 214 | try addCertsFromFilePath(cb, gpa, io, now, iterable_dir, entry.name); |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | pub const AddCertsFromFilePathError = Io.File.OpenError || AddCertsFromFileError; |
| 219 | |
| 220 | pub fn addCertsFromFilePathAbsolute( |
| 221 | cb: *Bundle, |
| 222 | gpa: Allocator, |
| 223 | io: Io, |
| 224 | now: Io.Timestamp, |
| 225 | abs_file_path: []const u8, |
| 226 | ) AddCertsFromFilePathError!void { |
| 227 | var file = try Io.Dir.openFileAbsolute(io, abs_file_path, .{}); |
| 228 | defer file.close(io); |
| 229 | var file_reader = file.reader(io, &.{}); |
| 230 | return addCertsFromFile(cb, gpa, &file_reader, now.toSeconds()); |
| 231 | } |
| 232 | |
| 233 | pub fn addCertsFromFilePath( |
| 234 | cb: *Bundle, |
| 235 | gpa: Allocator, |
| 236 | io: Io, |
| 237 | now: Io.Timestamp, |
| 238 | dir: Io.Dir, |
| 239 | sub_file_path: []const u8, |
| 240 | ) AddCertsFromFilePathError!void { |
| 241 | var file = try dir.openFile(io, sub_file_path, .{}); |
| 242 | defer file.close(io); |
| 243 | var file_reader = file.reader(io, &.{}); |
| 244 | return addCertsFromFile(cb, gpa, &file_reader, now.toSeconds()); |
| 245 | } |
| 246 | |
| 247 | pub const AddCertsFromFileError = Allocator.Error || |
| 248 | Io.File.Reader.Error || |
| 249 | Io.File.Reader.SizeError || |
| 250 | ParseCertError || |
| 251 | std.base64.Error || |
| 252 | error{ CertificateAuthorityBundleTooBig, MissingEndCertificateMarker, Streaming }; |
| 253 | |
| 254 | pub fn addCertsFromFile(cb: *Bundle, gpa: Allocator, file_reader: *Io.File.Reader, now_sec: i64) AddCertsFromFileError!void { |
| 255 | const size = try file_reader.getSize(); |
| 256 | |
| 257 | // We borrow `bytes` as a temporary buffer for the base64-encoded data. |
| 258 | // This is possible by computing the decoded length and reserving the space |
| 259 | // for the decoded bytes first. |
| 260 | const decoded_size_upper_bound = size / 4 * 3; |
| 261 | const needed_capacity = std.math.cast(u32, decoded_size_upper_bound + size) orelse |
| 262 | return error.CertificateAuthorityBundleTooBig; |
| 263 | try cb.bytes.ensureUnusedCapacity(gpa, needed_capacity); |
| 264 | const end_reserved: u32 = @intCast(cb.bytes.items.len + decoded_size_upper_bound); |
| 265 | const buffer = cb.bytes.allocatedSlice()[end_reserved..]; |
| 266 | const end_index = file_reader.interface.readSliceShort(buffer) catch |err| switch (err) { |
| 267 | error.ReadFailed => return file_reader.err.?, |
| 268 | }; |
| 269 | const encoded_bytes = buffer[0..end_index]; |
| 270 | |
| 271 | const begin_marker = "-----BEGIN CERTIFICATE-----"; |
| 272 | const end_marker = "-----END CERTIFICATE-----"; |
| 273 | |
| 274 | var start_index: usize = 0; |
| 275 | while (mem.findPos(u8, encoded_bytes, start_index, begin_marker)) |begin_marker_start| { |
| 276 | const cert_start = begin_marker_start + begin_marker.len; |
| 277 | const cert_end = mem.findPos(u8, encoded_bytes, cert_start, end_marker) orelse |
| 278 | return error.MissingEndCertificateMarker; |
| 279 | start_index = cert_end + end_marker.len; |
| 280 | const encoded_cert = mem.trim(u8, encoded_bytes[cert_start..cert_end], " \t\r\n"); |
| 281 | const decoded_start: u32 = @intCast(cb.bytes.items.len); |
| 282 | const dest_buf = cb.bytes.allocatedSlice()[decoded_start..]; |
| 283 | cb.bytes.items.len += try base64.decode(dest_buf, encoded_cert); |
| 284 | try cb.parseCert(gpa, decoded_start, now_sec); |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | pub const ParseCertError = Allocator.Error || Certificate.ParseError; |
| 289 | |
| 290 | pub fn parseCert(cb: *Bundle, gpa: Allocator, decoded_start: u32, now_sec: i64) ParseCertError!void { |
| 291 | // Even though we could only partially parse the certificate to find |
| 292 | // the subject name, we pre-parse all of them to make sure and only |
| 293 | // include in the bundle ones that we know will parse. This way we can |
| 294 | // use `catch unreachable` later. |
| 295 | const parsed_cert = Certificate.parse(.{ |
| 296 | .buffer = cb.bytes.items, |
| 297 | .index = decoded_start, |
| 298 | }) catch |err| switch (err) { |
| 299 | error.CertificateHasUnrecognizedObjectId => { |
| 300 | cb.bytes.items.len = decoded_start; |
| 301 | return; |
| 302 | }, |
| 303 | else => |e| return e, |
| 304 | }; |
| 305 | if (now_sec > parsed_cert.validity.not_after) { |
| 306 | // Ignore expired cert. |
| 307 | cb.bytes.items.len = decoded_start; |
| 308 | return; |
| 309 | } |
| 310 | const gop = try cb.map.getOrPutContext(gpa, parsed_cert.subject_slice, .{ .cb = cb }); |
| 311 | if (gop.found_existing) { |
| 312 | cb.bytes.items.len = decoded_start; |
| 313 | } else { |
| 314 | gop.value_ptr.* = decoded_start; |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | const MapContext = struct { |
| 319 | cb: *const Bundle, |
| 320 | |
| 321 | pub fn hash(ctx: MapContext, k: der.Element.Slice) u64 { |
| 322 | return std.hash_map.hashString(ctx.cb.bytes.items[k.start..k.end]); |
| 323 | } |
| 324 | |
| 325 | pub fn eql(ctx: MapContext, a: der.Element.Slice, b: der.Element.Slice) bool { |
| 326 | const bytes = ctx.cb.bytes.items; |
| 327 | return mem.eql( |
| 328 | u8, |
| 329 | bytes[a.start..a.end], |
| 330 | bytes[b.start..b.end], |
| 331 | ); |
| 332 | } |
| 333 | }; |
| 334 | |
| 335 | test "addCertsFromDirPath compiles and accepts an empty directory" { |
| 336 | const io = std.testing.io; |
| 337 | const gpa = std.testing.allocator; |
| 338 | |
| 339 | var tmp = std.testing.tmpDir(.{ .iterate = true }); |
| 340 | defer tmp.cleanup(); |
| 341 | |
| 342 | var bundle: Bundle = .empty; |
| 343 | defer bundle.deinit(gpa); |
| 344 | |
| 345 | try bundle.addCertsFromDirPath(gpa, io, tmp.dir, "."); |
| 346 | } |
| 347 | |
| 348 | test "scan for OS-provided certificates" { |
| 349 | if (builtin.os.tag == .wasi) return error.SkipZigTest; |
| 350 | |
| 351 | const io = std.testing.io; |
| 352 | const gpa = std.testing.allocator; |
| 353 | |
| 354 | var bundle: Bundle = .empty; |
| 355 | defer bundle.deinit(gpa); |
| 356 | |
| 357 | const now = Io.Clock.real.now(io); |
| 358 | |
| 359 | try bundle.rescan(gpa, io, now); |
| 360 | } |