gitoriaLog in with ident

antcolony

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Branchmain3a4d0324antcolony#37: a too-long report gets up to 3 fix tries, finished work is never thrown away for lengthmremain/plugins/crypto/crypto.zig

29.3 KB

  1. // hl:crypto plugin — native shared library (libcrypto.so under plugins/crypto/,
  2. // dlopen'd by the runtime; NOT to be confused with the system libcrypto this file
  3. // itself dlopens — the loader opens ours by absolute path with RTLD_LOCAL, and no
  4. // RPATH points the plugin's own dlopen at this directory).
  5. //
  6. // The FIRST surface of this plugin is passwords, done the way a password should be
  7. // stored: a memory-hard KDF, a random per-password salt, and a self-describing PHC
  8. // string that carries the algorithm and its parameters so the stored value can be
  9. // read back years later without the code remembering how it was made.
  10. //
  11. // hl_crypto_hash(password, opts?) → "$argon2id$v=19$m=…,t=…,p=…$salt$hash"
  12. // → "$scrypt$ln=…,r=…,p=…$salt$hash"
  13. // hl_crypto_verify(password, stored) → bool, CONSTANT-TIME comparison
  14. // hl_crypto_parse(stored) → the PHC string as an object (or null)
  15. // hl_crypto_kdf() → which KDF *this* engine hashes with
  16. // hl_crypto_sha256(data) → lowercase hex, 64 chars
  17. // hl_crypto_random_bytes(n, enc?) → n random bytes as hex (default) or base64
  18. //
  19. // WHICH KDF. argon2id is the first choice and scrypt is the fallback; the decision
  20. // is made ONCE, at load, by asking the system's libcrypto for the ARGON2ID KDF
  21. // (OpenSSL ≥ 3.2 ships it in the default provider; 3.0/3.1 and 1.1 do not). Nothing
  22. // in the stored string depends on that probe going one way or the other — the PHC
  23. // string names its own algorithm, so `verify` reads BOTH regardless of which one
  24. // `hash` would produce today, and a machine that later gains argon2id keeps reading
  25. // every scrypt string it wrote before.
  26. //
  27. // THE LIBRARY IS RESOLVED AT RUNTIME, the same way `plugins/http/tls_common.zig`
  28. // resolves it (same candidate list, same dlopen flags, same dlsym-into-optionals
  29. // shape). This is that PATTERN reused, not a second loader: tls_common's job is an
  30. // SSL_CTX and it opens libssl beside libcrypto for it, which a password hash has no
  31. // use for. A host with no libcrypto at all gets a loud error from `hash`/`verify`
  32. // rather than a silent weaker hash.
  33. //
  34. // NEVER LOGGED: no function here writes a password, a salt, a derived key or a
  35. // stored string to any stream. The only messages this file can emit are about the
  36. // LIBRARY (missing .so, missing symbol), and they are emitted once.
  37. const std = @import("std");
  38. const api = @import("plugin_api");
  39. const HlValue = api.HlValue;
  40. const HlObject = api.HlObject;
  41. const HlField = api.HlField;
  42. const HlString = api.HlString;
  43. const c_dlfcn = @cImport({
  44. @cInclude("dlfcn.h");
  45. });
  46. const linux = std.os.linux;
  47. // stack_trace_frames = 0 (mission 068): plugin code runs on interpreter FIBER
  48. // stacks; Debug trace capture unwinds off them and segfaults.
  49. var gpa = std.heap.DebugAllocator(.{ .stack_trace_frames = 0 }){};
  50. const allocator = gpa.allocator();
  51. // Direct syscall for stderr — std.debug.print pulls in std.Progress, whose global
  52. // state is ABI-incompatible when a .so is loaded into a differently-built binary.
  53. fn logMsg(msg: []const u8) void {
  54. _ = linux.write(2, msg.ptr, msg.len);
  55. }
  56. // =========================================================================
  57. // Cost — ONE number, the same meaning on both KDFs
  58. //
  59. // `cost` is the base-2 logarithm of the working memory in KiB. cost 15 is 32 MiB
  60. // on argon2id (memcost = 32768 KiB) and 32 MiB on scrypt (N = 2^15, r = 8, p = 1,
  61. // which is 128 · N · r bytes). That is at or above the usual baseline for an
  62. // interactive login on both, and it is one knob rather than two sets of three.
  63. //
  64. // The option is CAPPED at both ends, and the cap is observable: the PHC string
  65. // records the parameters that were actually used, so `parse(hash(pw, {cost=99}))`
  66. // reports the cap rather than 99.
  67. // =========================================================================
  68. const COST_DEFAULT: u32 = 15; // 32 MiB
  69. const COST_MIN: u32 = 10; // 1 MiB — below this a KDF stops being memory-hard
  70. const COST_MAX: u32 = 17; // 128 MiB — the strongest cost anyone recommends for an
  71. // interactive login; past it a burst of sign-ins is a
  72. // denial of service against the machine serving them.
  73. // Fixed shape of everything else. These are recorded in the PHC string too, so
  74. // changing them later does not strand a single stored password.
  75. const SALT_LEN: usize = 16;
  76. const HASH_LEN: usize = 32;
  77. /// The floor a stored string must clear to be READ at all — see `decodePhc`.
  78. /// Not the same numbers as above: those are what this plugin writes today, these
  79. /// are what any string has to carry for a comparison against it to mean anything.
  80. const MIN_SALT_LEN: usize = 8;
  81. const MIN_HASH_LEN: usize = 16;
  82. const ARGON2_TIME: u32 = 2; // t — the OWASP pairing for a memory-heavy argon2id
  83. const ARGON2_LANES: u32 = 1; // p — one lane needs no libctx thread pool
  84. const SCRYPT_R: u32 = 8; // the RFC 7914 block size everyone uses
  85. const SCRYPT_P: u32 = 1;
  86. /// scrypt's memory bound is a SAFETY VALVE inside OpenSSL, not a tuning knob:
  87. /// EVP_PBE_scrypt refuses a request above `maxmem` and its default is 32 MiB,
  88. /// which the default cost sits exactly on. Raised past the cost cap's own ceiling
  89. /// so `cost` is the only limit that decides anything.
  90. const SCRYPT_MAXMEM: u64 = 2 * 1024 * 1024 * 1024;
  91. const Kdf = enum {
  92. argon2id,
  93. scrypt,
  94. fn name(self: Kdf) []const u8 {
  95. return switch (self) {
  96. .argon2id => "argon2id",
  97. .scrypt => "scrypt",
  98. };
  99. }
  100. fn parse(text: []const u8) ?Kdf {
  101. if (std.mem.eql(u8, text, "argon2id")) return .argon2id;
  102. if (std.mem.eql(u8, text, "scrypt")) return .scrypt;
  103. return null;
  104. }
  105. };
  106. // =========================================================================
  107. // libcrypto, resolved at runtime (the tls_common pattern)
  108. // =========================================================================
  109. const EVP_KDF = opaque {};
  110. const EVP_KDF_CTX = opaque {};
  111. /// openssl/core.h. Built by hand rather than through OSSL_PARAM_construct_*,
  112. /// which return this struct BY VALUE across the C ABI — the field layout is
  113. /// public and stable, the by-value return convention is not worth the risk.
  114. const OSSL_PARAM = extern struct {
  115. key: ?[*:0]const u8,
  116. data_type: c_uint,
  117. data: ?*anyopaque,
  118. data_size: usize,
  119. return_size: usize,
  120. };
  121. const OSSL_PARAM_UNSIGNED_INTEGER: c_uint = 2;
  122. const OSSL_PARAM_OCTET_STRING: c_uint = 5;
  123. /// OSSL_PARAM_UNMODIFIED — what the construct helpers put in `return_size` for a
  124. /// parameter being passed IN.
  125. const PARAM_UNMODIFIED: usize = std.math.maxInt(usize);
  126. fn paramEnd() OSSL_PARAM {
  127. return .{ .key = null, .data_type = 0, .data = null, .data_size = 0, .return_size = 0 };
  128. }
  129. fn paramUint(key: [*:0]const u8, value: *u32) OSSL_PARAM {
  130. return .{
  131. .key = key,
  132. .data_type = OSSL_PARAM_UNSIGNED_INTEGER,
  133. .data = @ptrCast(value),
  134. .data_size = @sizeOf(u32),
  135. .return_size = PARAM_UNMODIFIED,
  136. };
  137. }
  138. fn paramOctets(key: [*:0]const u8, bytes: []const u8) OSSL_PARAM {
  139. return .{
  140. .key = key,
  141. .data_type = OSSL_PARAM_OCTET_STRING,
  142. .data = @constCast(@ptrCast(bytes.ptr)),
  143. .data_size = bytes.len,
  144. .return_size = PARAM_UNMODIFIED,
  145. };
  146. }
  147. const EVP_PBE_scrypt_fn = *const fn ([*]const u8, usize, [*]const u8, usize, u64, u64, u64, u64, [*]u8, usize) callconv(.c) c_int;
  148. const EVP_KDF_fetch_fn = *const fn (?*anyopaque, [*:0]const u8, ?[*:0]const u8) callconv(.c) ?*EVP_KDF;
  149. const EVP_KDF_free_fn = *const fn (?*EVP_KDF) callconv(.c) void;
  150. const EVP_KDF_CTX_new_fn = *const fn (?*EVP_KDF) callconv(.c) ?*EVP_KDF_CTX;
  151. const EVP_KDF_CTX_free_fn = *const fn (?*EVP_KDF_CTX) callconv(.c) void;
  152. const EVP_KDF_derive_fn = *const fn (?*EVP_KDF_CTX, [*]u8, usize, ?[*]const OSSL_PARAM) callconv(.c) c_int;
  153. const Backend = struct {
  154. lib: ?*anyopaque = null,
  155. /// The KDF `hash()` produces. argon2id when the probe found it, scrypt otherwise.
  156. preferred: Kdf = .scrypt,
  157. has_argon2id: bool = false,
  158. has_scrypt: bool = false,
  159. fn_scrypt: ?EVP_PBE_scrypt_fn = null,
  160. fn_kdf_fetch: ?EVP_KDF_fetch_fn = null,
  161. fn_kdf_free: ?EVP_KDF_free_fn = null,
  162. fn_kdf_ctx_new: ?EVP_KDF_CTX_new_fn = null,
  163. fn_kdf_ctx_free: ?EVP_KDF_CTX_free_fn = null,
  164. fn_kdf_derive: ?EVP_KDF_derive_fn = null,
  165. };
  166. var backend: Backend = .{};
  167. /// A plain bool, not an atomic: `__native` calls are made from INTERPRETER code,
  168. /// which runs as fibers on one thread. Plugins that own their own threads (the
  169. /// HTTP servers) never call in here, so there is no second writer to guard
  170. /// against — the same reasoning `hl:math`'s lazily-seeded PRNG state relies on.
  171. var backend_ready: bool = false;
  172. fn loadSym(lib: ?*anyopaque, comptime T: type, name: [*:0]const u8) ?T {
  173. const sym = c_dlfcn.dlsym(lib, name) orelse return null;
  174. return @ptrCast(sym);
  175. }
  176. /// Resolve libcrypto and decide the KDF, once. Returns null when the host has no
  177. /// usable libcrypto — every entry point that needs one then fails LOUDLY.
  178. fn ensureBackend() ?*const Backend {
  179. if (backend_ready) {
  180. return if (backend.lib == null) null else &backend;
  181. }
  182. backend_ready = true;
  183. // Same candidate list and flags as plugins/http/tls_common.zig. No bare-name
  184. // ambiguity with our OWN libcrypto.so: this plugin sets no RPATH, so the
  185. // dynamic loader never searches plugins/crypto/ for these.
  186. const crypto_paths = [_][*:0]const u8{ "libcrypto.so.3", "libcrypto.so.1.1", "libcrypto.so" };
  187. for (crypto_paths) |path| {
  188. backend.lib = c_dlfcn.dlopen(path, c_dlfcn.RTLD_NOW | c_dlfcn.RTLD_LOCAL);
  189. if (backend.lib != null) break;
  190. }
  191. if (backend.lib == null) {
  192. logMsg("hl:crypto: no libcrypto.so on this host — password hashing is unavailable\n");
  193. return null;
  194. }
  195. backend.fn_scrypt = loadSym(backend.lib, EVP_PBE_scrypt_fn, "EVP_PBE_scrypt");
  196. backend.has_scrypt = backend.fn_scrypt != null;
  197. backend.fn_kdf_fetch = loadSym(backend.lib, EVP_KDF_fetch_fn, "EVP_KDF_fetch");
  198. backend.fn_kdf_free = loadSym(backend.lib, EVP_KDF_free_fn, "EVP_KDF_free");
  199. backend.fn_kdf_ctx_new = loadSym(backend.lib, EVP_KDF_CTX_new_fn, "EVP_KDF_CTX_new");
  200. backend.fn_kdf_ctx_free = loadSym(backend.lib, EVP_KDF_CTX_free_fn, "EVP_KDF_CTX_free");
  201. backend.fn_kdf_derive = loadSym(backend.lib, EVP_KDF_derive_fn, "EVP_KDF_derive");
  202. // THE PROBE. The symbols exist from OpenSSL 3.0; the ARGON2ID *algorithm*
  203. // only from 3.2, and only a successful fetch proves the provider has it.
  204. if (backend.fn_kdf_fetch != null and backend.fn_kdf_free != null and
  205. backend.fn_kdf_ctx_new != null and backend.fn_kdf_ctx_free != null and
  206. backend.fn_kdf_derive != null)
  207. {
  208. if (backend.fn_kdf_fetch.?(null, "ARGON2ID", null)) |kdf| {
  209. backend.fn_kdf_free.?(kdf);
  210. backend.has_argon2id = true;
  211. }
  212. }
  213. backend.preferred = if (backend.has_argon2id) .argon2id else .scrypt;
  214. if (!backend.has_argon2id and !backend.has_scrypt) {
  215. logMsg("hl:crypto: libcrypto has neither ARGON2ID nor EVP_PBE_scrypt\n");
  216. }
  217. return &backend;
  218. }
  219. // =========================================================================
  220. // Derivation
  221. // =========================================================================
  222. fn deriveArgon2id(b: *const Backend, password: []const u8, salt: []const u8, memcost_kib: u32, out: []u8) bool {
  223. if (!b.has_argon2id) return false;
  224. const kdf = b.fn_kdf_fetch.?(null, "ARGON2ID", null) orelse return false;
  225. defer b.fn_kdf_free.?(kdf);
  226. const ctx = b.fn_kdf_ctx_new.?(kdf) orelse return false;
  227. defer b.fn_kdf_ctx_free.?(ctx);
  228. var m: u32 = memcost_kib;
  229. var t: u32 = ARGON2_TIME;
  230. var lanes: u32 = ARGON2_LANES;
  231. var threads: u32 = 1;
  232. var size: u32 = @intCast(out.len);
  233. // `lanes`/`threads` are both 1 on purpose: argon2id with more than one thread
  234. // needs a thread pool installed on the OSSL_LIB_CTX, and a plugin has no
  235. // business installing one into the process's default library context.
  236. var params = [_]OSSL_PARAM{
  237. paramOctets("pass", password),
  238. paramOctets("salt", salt),
  239. paramUint("memcost", &m),
  240. paramUint("iter", &t),
  241. paramUint("lanes", &lanes),
  242. paramUint("threads", &threads),
  243. paramUint("size", &size),
  244. paramEnd(),
  245. };
  246. return b.fn_kdf_derive.?(ctx, out.ptr, out.len, &params) == 1;
  247. }
  248. fn deriveScrypt(b: *const Backend, password: []const u8, salt: []const u8, ln: u32, r: u32, p: u32, out: []u8) bool {
  249. const f = b.fn_scrypt orelse return false;
  250. if (ln >= 64) return false;
  251. const n: u64 = @as(u64, 1) << @intCast(ln);
  252. return f(
  253. password.ptr,
  254. password.len,
  255. salt.ptr,
  256. salt.len,
  257. n,
  258. r,
  259. p,
  260. SCRYPT_MAXMEM,
  261. out.ptr,
  262. out.len,
  263. ) == 1;
  264. }
  265. // =========================================================================
  266. // The PHC string
  267. //
  268. // $argon2id$v=19$m=32768,t=2,p=1$<salt>$<hash>
  269. // $scrypt$ln=15,r=8,p=1$<salt>$<hash>
  270. //
  271. // salt and hash are base64 with the standard alphabet and NO padding, which is
  272. // what the PHC string format specifies. Nothing here is secret — the whole point
  273. // of the format is that the stored value describes itself.
  274. // =========================================================================
  275. const B64 = std.base64.standard_no_pad;
  276. const Phc = struct {
  277. kdf: Kdf,
  278. /// argon2 only; 19 (0x13) is the only version OpenSSL's ARGON2ID speaks.
  279. version: u32 = 19,
  280. /// argon2: memory in KiB. scrypt: unused.
  281. m: u32 = 0,
  282. /// argon2: iterations. scrypt: unused.
  283. t: u32 = 0,
  284. /// scrypt: log2(N). argon2: unused.
  285. ln: u32 = 0,
  286. /// scrypt: block size. argon2: unused.
  287. r: u32 = 0,
  288. /// lanes (argon2) / parallelism (scrypt).
  289. p: u32 = 0,
  290. salt: [64]u8 = undefined,
  291. salt_len: usize = 0,
  292. hash: [64]u8 = undefined,
  293. hash_len: usize = 0,
  294. };
  295. fn encodePhc(phc: *const Phc) ?[]u8 {
  296. var salt_b64: [128]u8 = undefined;
  297. var hash_b64: [128]u8 = undefined;
  298. const s = B64.Encoder.encode(&salt_b64, phc.salt[0..phc.salt_len]);
  299. const h = B64.Encoder.encode(&hash_b64, phc.hash[0..phc.hash_len]);
  300. return switch (phc.kdf) {
  301. .argon2id => std.fmt.allocPrint(allocator, "$argon2id$v={d}$m={d},t={d},p={d}${s}${s}", .{
  302. phc.version, phc.m, phc.t, phc.p, s, h,
  303. }) catch null,
  304. .scrypt => std.fmt.allocPrint(allocator, "$scrypt$ln={d},r={d},p={d}${s}${s}", .{
  305. phc.ln, phc.r, phc.p, s, h,
  306. }) catch null,
  307. };
  308. }
  309. /// One `key=value` out of a comma-separated parameter field. Absent or unparsable
  310. /// is null, and every caller treats null as "this is not a PHC string I can read".
  311. fn paramValue(field: []const u8, key: []const u8) ?u32 {
  312. var it = std.mem.splitScalar(u8, field, ',');
  313. while (it.next()) |pair| {
  314. const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue;
  315. if (!std.mem.eql(u8, pair[0..eq], key)) continue;
  316. return std.fmt.parseInt(u32, pair[eq + 1 ..], 10) catch null;
  317. }
  318. return null;
  319. }
  320. fn decodeB64Into(text: []const u8, buf: []u8) ?usize {
  321. const n = B64.Decoder.calcSizeForSlice(text) catch return null;
  322. if (n == 0 or n > buf.len) return null;
  323. B64.Decoder.decode(buf[0..n], text) catch return null;
  324. return n;
  325. }
  326. /// Strictly parse a stored string. ANY deviation — a wrong field count, a missing
  327. /// parameter, a base64 body that does not decode — is null, and `verify` turns
  328. /// null into `false`. That is what makes a tampered string fail rather than
  329. /// half-parse into something with a comparable hash.
  330. fn decodePhc(stored: []const u8) ?Phc {
  331. if (stored.len < 2 or stored[0] != '$') return null;
  332. var parts: [8][]const u8 = undefined;
  333. var count: usize = 0;
  334. var it = std.mem.splitScalar(u8, stored[1..], '$');
  335. while (it.next()) |part| {
  336. if (count == parts.len) return null;
  337. parts[count] = part;
  338. count += 1;
  339. }
  340. var phc = Phc{ .kdf = .scrypt };
  341. const kdf = Kdf.parse(parts[0]) orelse return null;
  342. phc.kdf = kdf;
  343. const salt_field: []const u8, const hash_field: []const u8 = switch (kdf) {
  344. .argon2id => blk: {
  345. // $argon2id$v=19$m=..,t=..,p=..$salt$hash
  346. if (count != 5) return null;
  347. if (!std.mem.startsWith(u8, parts[1], "v=")) return null;
  348. phc.version = std.fmt.parseInt(u32, parts[1][2..], 10) catch return null;
  349. phc.m = paramValue(parts[2], "m") orelse return null;
  350. phc.t = paramValue(parts[2], "t") orelse return null;
  351. phc.p = paramValue(parts[2], "p") orelse return null;
  352. break :blk .{ parts[3], parts[4] };
  353. },
  354. .scrypt => blk: {
  355. // $scrypt$ln=..,r=..,p=..$salt$hash
  356. if (count != 4) return null;
  357. phc.version = 0;
  358. phc.ln = paramValue(parts[1], "ln") orelse return null;
  359. phc.r = paramValue(parts[1], "r") orelse return null;
  360. phc.p = paramValue(parts[1], "p") orelse return null;
  361. break :blk .{ parts[2], parts[3] };
  362. },
  363. };
  364. phc.salt_len = decodeB64Into(salt_field, &phc.salt) orelse return null;
  365. phc.hash_len = decodeB64Into(hash_field, &phc.hash) orelse return null;
  366. // MINIMUM LENGTHS, and they are load-bearing rather than tidiness. A KDF
  367. // derives as many bytes as it is asked for, so `verify` on a stored string
  368. // whose hash field had been CUT DOWN to eight base64 characters used to
  369. // derive six bytes and compare six bytes — and six bytes of a correct
  370. // derivation match. Truncating the stored value was therefore a way to make
  371. // a wrong password verify, until this line. (Found by the tamper gate on the
  372. // first run of tests/pass/plugins/005; the JS twin had it too.)
  373. if (phc.salt_len < MIN_SALT_LEN or phc.hash_len < MIN_HASH_LEN) return null;
  374. return phc;
  375. }
  376. // =========================================================================
  377. // Constant-time comparison
  378. //
  379. // The lengths are NOT secret (they are in the stored string, in the clear), so
  380. // comparing them up front leaks nothing. The bytes are: the loop below always
  381. // touches every one of them and branches on nothing.
  382. // =========================================================================
  383. /// Bytes straight off the kernel CSPRNG. `getrandom(2)` rather than any
  384. /// userspace generator: a salt and a token are the two things in this file that
  385. /// must not be predictable, and the http plugins reach for the same syscall.
  386. fn fillRandom(buf: []u8) bool {
  387. return linux.getrandom(buf.ptr, buf.len, 0) == buf.len;
  388. }
  389. fn constantTimeEql(a: []const u8, b: []const u8) bool {
  390. if (a.len != b.len) return false;
  391. var diff: u8 = 0;
  392. for (a, b) |x, y| diff |= x ^ y;
  393. return diff == 0;
  394. }
  395. // =========================================================================
  396. // Value helpers
  397. // =========================================================================
  398. fn hlStr(s: []const u8) HlString {
  399. return .{ .ptr = s.ptr, .len = s.len };
  400. }
  401. fn allocStringDeinit(val: *HlValue) callconv(.c) void {
  402. if (val.type != .hl_string) return;
  403. const s = val.data.string;
  404. if (s.len == 0) return;
  405. allocator.free(@constCast(s.ptr[0..s.len]));
  406. }
  407. /// Hand an owned string to the runtime. The loader copies the bytes into its own
  408. /// tracker and then calls this value's deinit_fn, so the plugin's copy is freed
  409. /// on the same call it was made.
  410. fn ownedString(s: []u8) HlValue {
  411. var result = api.makeString(s);
  412. result.deinit_fn = &allocStringDeinit;
  413. return result;
  414. }
  415. fn objDeinit(obj: *HlObject) callconv(.c) void {
  416. allocator.free(obj.fields[0..obj.field_count]);
  417. allocator.destroy(obj);
  418. }
  419. fn makeObj(fields: []HlField) HlValue {
  420. const owned = allocator.dupe(HlField, fields) catch return api.makeNull();
  421. const obj = allocator.create(HlObject) catch {
  422. allocator.free(owned);
  423. return api.makeNull();
  424. };
  425. obj.* = .{ .fields = owned.ptr, .field_count = owned.len, .deinit_fn = &objDeinit };
  426. return api.makeObject(obj);
  427. }
  428. fn argString(argc: u32, argv: [*]const HlValue, idx: u32) ?[]const u8 {
  429. if (idx >= argc) return null;
  430. if (argv[idx].type != .hl_string) return null;
  431. return argv[idx].data.string.ptr[0..argv[idx].data.string.len];
  432. }
  433. fn argNumber(argc: u32, argv: [*]const HlValue, idx: u32) ?f64 {
  434. if (idx >= argc) return null;
  435. if (argv[idx].type != .hl_number) return null;
  436. return argv[idx].data.number;
  437. }
  438. /// One field out of an options hybrid. Absent object, absent key and a key of the
  439. /// wrong type all read as "not given".
  440. fn optField(argc: u32, argv: [*]const HlValue, idx: u32, key: []const u8) ?HlValue {
  441. if (idx >= argc) return null;
  442. if (argv[idx].type != .hl_object) return null;
  443. const obj = argv[idx].data.object;
  444. for (obj.fields[0..obj.field_count]) |f| {
  445. if (std.mem.eql(u8, f.key.ptr[0..f.key.len], key)) return f.value;
  446. }
  447. return null;
  448. }
  449. // =========================================================================
  450. // Exports
  451. // =========================================================================
  452. /// hash(password, opts?) → PHC string.
  453. /// opts: { cost = <clamped to COST_MIN..COST_MAX>, kdf = "argon2id" | "scrypt" }
  454. export fn hl_crypto_hash(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  455. const password = argString(argc, argv, 0) orelse
  456. return api.makeError("hl:crypto hash() expects a string password");
  457. const b = ensureBackend() orelse
  458. return api.makeError("hl:crypto hash(): no libcrypto.so on this host");
  459. var cost: u32 = COST_DEFAULT;
  460. if (optField(argc, argv, 1, "cost")) |v| {
  461. if (v.type == .hl_number) {
  462. const n = v.data.number;
  463. if (!std.math.isNan(n)) {
  464. // Clamp, do not refuse: `cost` is a capped knob and the PHC string
  465. // it produces reports the value that was actually used.
  466. const clamped = @max(@as(f64, @floatFromInt(COST_MIN)), @min(@as(f64, @floatFromInt(COST_MAX)), n));
  467. cost = @intFromFloat(@trunc(clamped));
  468. }
  469. }
  470. }
  471. var kdf = b.preferred;
  472. if (optField(argc, argv, 1, "kdf")) |v| {
  473. if (v.type == .hl_string) {
  474. const want = v.data.string.ptr[0..v.data.string.len];
  475. kdf = Kdf.parse(want) orelse
  476. return api.makeError("hl:crypto hash(): unknown kdf — expected \"argon2id\" or \"scrypt\"");
  477. }
  478. }
  479. var phc = Phc{ .kdf = kdf, .salt_len = SALT_LEN, .hash_len = HASH_LEN };
  480. if (!fillRandom(phc.salt[0..SALT_LEN])) {
  481. return api.makeError("hl:crypto hash(): the kernel CSPRNG refused a salt");
  482. }
  483. const ok = switch (kdf) {
  484. .argon2id => blk: {
  485. phc.m = @as(u32, 1) << @intCast(cost);
  486. phc.t = ARGON2_TIME;
  487. phc.p = ARGON2_LANES;
  488. break :blk deriveArgon2id(b, password, phc.salt[0..SALT_LEN], phc.m, phc.hash[0..HASH_LEN]);
  489. },
  490. .scrypt => blk: {
  491. phc.ln = cost;
  492. phc.r = SCRYPT_R;
  493. phc.p = SCRYPT_P;
  494. break :blk deriveScrypt(b, password, phc.salt[0..SALT_LEN], phc.ln, phc.r, phc.p, phc.hash[0..HASH_LEN]);
  495. },
  496. };
  497. if (!ok) {
  498. return api.makeError(switch (kdf) {
  499. .argon2id => "hl:crypto hash(): this libcrypto has no ARGON2ID (needs OpenSSL >= 3.2)",
  500. .scrypt => "hl:crypto hash(): this libcrypto has no EVP_PBE_scrypt",
  501. });
  502. }
  503. const out = encodePhc(&phc) orelse
  504. return api.makeError("hl:crypto hash(): could not encode the PHC string");
  505. return ownedString(out);
  506. }
  507. /// verify(password, stored) → bool. Malformed, tampered and non-matching are all
  508. /// `false`; a stored string whose ALGORITHM this engine cannot compute is a loud
  509. /// error, because answering `false` there would read as "wrong password".
  510. export fn hl_crypto_verify(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  511. const password = argString(argc, argv, 0) orelse return api.makeBool(false);
  512. const stored = argString(argc, argv, 1) orelse return api.makeBool(false);
  513. const phc = decodePhc(stored) orelse return api.makeBool(false);
  514. if (phc.hash_len == 0 or phc.hash_len > 64) return api.makeBool(false);
  515. const b = ensureBackend() orelse
  516. return api.makeError("hl:crypto verify(): no libcrypto.so on this host");
  517. var computed: [64]u8 = undefined;
  518. const ok = switch (phc.kdf) {
  519. .argon2id => blk: {
  520. if (!b.has_argon2id) {
  521. return api.makeError("hl:crypto verify(): stored password is argon2id and this libcrypto has none (needs OpenSSL >= 3.2)");
  522. }
  523. if (phc.version != 19 or phc.p != 1) break :blk false;
  524. break :blk deriveArgon2id(b, password, phc.salt[0..phc.salt_len], phc.m, computed[0..phc.hash_len]);
  525. },
  526. .scrypt => blk: {
  527. if (!b.has_scrypt) {
  528. return api.makeError("hl:crypto verify(): stored password is scrypt and this libcrypto has no EVP_PBE_scrypt");
  529. }
  530. if (phc.ln == 0 or phc.ln > 30 or phc.r == 0 or phc.p == 0) break :blk false;
  531. break :blk deriveScrypt(b, password, phc.salt[0..phc.salt_len], phc.ln, phc.r, phc.p, computed[0..phc.hash_len]);
  532. },
  533. };
  534. if (!ok) return api.makeBool(false);
  535. return api.makeBool(constantTimeEql(computed[0..phc.hash_len], phc.hash[0..phc.hash_len]));
  536. }
  537. /// parsePhc(stored) → { kdf, version, params, saltLen, hashLen } or null.
  538. /// Reads a stored string WITHOUT the password — what it is for is looking at what
  539. /// you have stored (which algorithm, at which cost), not for checking anything.
  540. export fn hl_crypto_parse(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  541. const stored = argString(argc, argv, 0) orelse return api.makeNull();
  542. const phc = decodePhc(stored) orelse return api.makeNull();
  543. var params: HlValue = undefined;
  544. switch (phc.kdf) {
  545. .argon2id => {
  546. var pf = [_]HlField{
  547. .{ .key = hlStr("m"), .value = api.makeNumber(@floatFromInt(phc.m)) },
  548. .{ .key = hlStr("t"), .value = api.makeNumber(@floatFromInt(phc.t)) },
  549. .{ .key = hlStr("p"), .value = api.makeNumber(@floatFromInt(phc.p)) },
  550. };
  551. params = makeObj(&pf);
  552. },
  553. .scrypt => {
  554. var pf = [_]HlField{
  555. .{ .key = hlStr("ln"), .value = api.makeNumber(@floatFromInt(phc.ln)) },
  556. .{ .key = hlStr("r"), .value = api.makeNumber(@floatFromInt(phc.r)) },
  557. .{ .key = hlStr("p"), .value = api.makeNumber(@floatFromInt(phc.p)) },
  558. };
  559. params = makeObj(&pf);
  560. },
  561. }
  562. var fields = [_]HlField{
  563. .{ .key = hlStr("kdf"), .value = api.makeString(phc.kdf.name()) },
  564. .{ .key = hlStr("version"), .value = if (phc.kdf == .argon2id)
  565. api.makeNumber(@floatFromInt(phc.version))
  566. else
  567. api.makeNull() },
  568. .{ .key = hlStr("params"), .value = params },
  569. .{ .key = hlStr("saltLen"), .value = api.makeNumber(@floatFromInt(phc.salt_len)) },
  570. .{ .key = hlStr("hashLen"), .value = api.makeNumber(@floatFromInt(phc.hash_len)) },
  571. };
  572. return makeObj(&fields);
  573. }
  574. /// kdf() → the algorithm THIS engine writes with ("argon2id" or "scrypt").
  575. /// Reporting only: nothing needs to ask, because every stored string says so itself.
  576. export fn hl_crypto_kdf(_: u32, _: [*]const HlValue) callconv(.c) HlValue {
  577. const b = ensureBackend() orelse return api.makeNull();
  578. return api.makeString(b.preferred.name());
  579. }
  580. /// sha256(data) → 64 lowercase hex characters.
  581. /// CONTENT hashing, not password hashing — it is deliberately fast, which is
  582. /// exactly why `hash()` above does not use it.
  583. export fn hl_crypto_sha256(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  584. const data = argString(argc, argv, 0) orelse
  585. return api.makeError("hl:crypto sha256() expects a string");
  586. var digest: [32]u8 = undefined;
  587. std.crypto.hash.sha2.Sha256.hash(data, &digest, .{});
  588. const out = std.fmt.allocPrint(allocator, "{x}", .{&digest}) catch
  589. return api.makeError("hl:crypto sha256(): out of memory");
  590. return ownedString(out);
  591. }
  592. const RANDOM_MAX: usize = 1024;
  593. /// randomBytes(n, encoding?) → n bytes from the kernel CSPRNG, "hex" (default) or
  594. /// "base64" (standard alphabet, padded — this is a token, not a PHC field).
  595. export fn hl_crypto_random_bytes(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  596. const n_f = argNumber(argc, argv, 0) orelse
  597. return api.makeError("hl:crypto randomBytes() expects a byte count");
  598. if (!(n_f >= 1) or n_f > @as(f64, @floatFromInt(RANDOM_MAX))) {
  599. return api.makeError("hl:crypto randomBytes(): count must be between 1 and 1024");
  600. }
  601. const n: usize = @intFromFloat(@trunc(n_f));
  602. var buf: [RANDOM_MAX]u8 = undefined;
  603. if (!fillRandom(buf[0..n])) {
  604. return api.makeError("hl:crypto randomBytes(): the kernel CSPRNG refused");
  605. }
  606. const enc = argString(argc, argv, 1) orelse "hex";
  607. if (std.mem.eql(u8, enc, "hex")) {
  608. const out = std.fmt.allocPrint(allocator, "{x}", .{buf[0..n]}) catch
  609. return api.makeError("hl:crypto randomBytes(): out of memory");
  610. return ownedString(out);
  611. }
  612. if (std.mem.eql(u8, enc, "base64")) {
  613. const std64 = std.base64.standard;
  614. const out = allocator.alloc(u8, std64.Encoder.calcSize(n)) catch
  615. return api.makeError("hl:crypto randomBytes(): out of memory");
  616. _ = std64.Encoder.encode(out, buf[0..n]);
  617. return ownedString(out);
  618. }
  619. return api.makeError("hl:crypto randomBytes(): encoding must be \"hex\" or \"base64\"");
  620. }

Branches

  • mainmain branch

Latest commits

  • 3a4d0324antcolony#37: a too-long report gets up to 3 fix tries, finished work is never thrown away for lengthmre
  • a6af7883tracker: worker box sees calendar.worldapi.org (login to copy)mre
  • c613d26btemplates: bridges to external components (login.js for ident's selector) are allowed (creator 2026-09-27)mre
  • 9062978ctracker: worker box sees /media/STORAGE/projects/old-tracker read-only (tracker#2 source data)mre
  • 7f9660eeState of 2026-09-27, before the move to gitoriamre