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 Dir = std.Io.Dir;
13const assert = std.debug.assert;
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),
24bytes: std.ArrayList(u8),
25
26pub const empty: Bundle = .{ .map = .empty, .bytes = .empty };
27
28pub const VerifyError = Certificate.Parsed.VerifyError || error{
29 CertificateIssuerNotFound,
30};
31
32pub 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.
46pub 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
63pub fn deinit(cb: *Bundle, gpa: Allocator) void {
64 cb.map.deinit(gpa);
65 cb.bytes.deinit(gpa);
66 cb.* = undefined;
67}
68
69pub 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.
75pub 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
91const rescanMac = @import("Bundle/macos.zig").rescanMac;
92const RescanMacError = @import("Bundle/macos.zig").RescanMacError;
93
94const RescanLinuxError = AddCertsFromFilePathError || AddCertsFromDirPathError;
95
96fn 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
138const RescanWithPathError = AddCertsFromFilePathError;
139
140fn 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
147const RescanWindowsError = Allocator.Error || ParseCertError || std.posix.UnexpectedError || error{FileNotFound};
148
149fn 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
176pub const AddCertsFromDirPathError = Io.File.OpenError || AddCertsFromDirError;
177
178pub 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
191pub 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
204pub const AddCertsFromDirError = AddCertsFromFilePathError;
205
206pub 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
218pub const AddCertsFromFilePathError = Io.File.OpenError || AddCertsFromFileError;
219
220pub 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
233pub 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
247pub 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
254pub 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
288pub const ParseCertError = Allocator.Error || Certificate.ParseError;
289
290pub 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
318const 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
335test "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
348test "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}