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