gitoriaLog in with ident

components

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Branchmain3bc905c5components #15 (mission 003) 2/2: docs (README Look, STATUS, LOG), reportmremain/plugins/crypto/crypto.zig

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

Branches

  • mainmain branch

Latest commits

  • 3bc905c5components #15 (mission 003) 2/2: docs (README Look, STATUS, LOG), reportmre
  • 5a98d77ecomponents #15 (mission 003) 1/2: black/white base look — greys for nuances, no WorldAPI fallbacks, no decorative frames; site + icon black/white; gate 230/0mre
  • 3f93d716components code order (mission 002) 4/4: docs, tests/letcount.py, tests/same-output*, reportmre
  • e747cfa0components code order (mission 002) 3/4: let only where reassigned (257 dropped, 0 never-reassigned left); gate 220/0, layouts gate 73/0, same outputmre
  • 172914a3components code order (mission 002) 2/4: lib/uploads.hl (demo upload storage), thin demo handlers, project.hl map; gate 220/0, same outputmre
  • d29f35b1components code order (mission 002) 1/4: styles.hl -> components/styles.hl; gate 220/0, same outputmre
  • 09945750components: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, http1 773de63e); gate 220/0mre
  • ede4d19acomponents: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gate 220/0mre
  • 2bb9a935components: Hybriel master 8efba065 (re-vendor round 069: #126 memory, #48 lambda copy)mre
  • e4f01768antcolony#40: mission references point to the moved missionsmre
  • d32b44e2antcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 0fb80b57components: Hybriel master ff51cf46 (re-vendor round)mre
  • 57408b6ecomponents#14: installable app (manifest, service worker, offline index), own iconmre
  • 317bd09fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • becbd59bcomponents#13: modal dialog (<dialog> based, focus wrap, backdrop/Esc/x close with reason, scroll lock, footer buttons)mre
  • 367dd19adeploy.sh: never send .git or .gitignore to Byrodinmre
  • 4b4dabe5State of 2026-09-27, before the move to gitoriamre