gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit1ed5457e1ed5457etracker#20 + #21 (mission 068): typed headings "<Type> | <name>" in type colours; TV titles split into Series (/series) and Shows (/shows) by TMDB type + Reality/Talk/News genres — kind stored by sync/import/adult backfill + new kind backfill (resumes), /movies/<slug>, /shows/<slug> of a series/movie → 301, /my/series + /my/shows, home 5 tiles + 3 rows, search/filmography labels; gates kinds 32 + browser 266, tests/realdata-068.mjs, tools/count-kinds.hl, docs/kinds.md, README + STATUSmre1ed5457e/plugins/crypto/crypto.zig

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

Branches

Latest commits

  • 1ed5457etracker#20 + #21 (mission 068): typed headings "<Type> | <name>" in type colours; TV titles split into Series (/series) and Shows (/shows) by TMDB type + Reality/Talk/News genres — kind stored by sync/import/adult backfill + new kind backfill (resumes), /movies/<slug>, /shows/<slug> of a series/movie → 301, /my/series + /my/shows, home 5 tiles + 3 rows, search/filmography labels; gates kinds 32 + browser 266, tests/realdata-068.mjs, tools/count-kinds.hl, docs/kinds.md, README + STATUSmre
  • fa1f9dfatracker#29 (mission 063): unwatched check muted grey outline + check (accent only on hover), watched stays solid — no code regression, the accent outline read as ticked; gate checks real checks visibly (computed style + screenshot pixel) on /my/unwatched, show, movie, /my/movies; gate 266, tests/realdata-063.mjs, README + STATUSmre
  • d8b12d67tracker#27 (mission 060): short ids for movies, series and persons — old 702 kept (data/old-short-ids.json), new random [a-z0-9]{5} unique across both, claimed at creation, background backfill (resumes), shown under poster/photo, /<shortId> → 301; gate 259, tests/realdata-060*, README + STATUSmre
  • f14db671tracker#22-#25 (mission 058): episode air dates, season check = all episodes watched, movie watched check (+ /my/movies count), /genres/<genre> pages (movies + series, newest first, paginated); gate 238, tests/realdata-058.mjs, README + STATUSmre
  • 1704ec45tracker#17 (mission 057): season caret down/up, skeleton rows while a season loads, sessionless showSeasonEpisodes face (no page re-mount), client-only close; gate 214, tests/realdata-057.mjs, README + STATUSmre
  • f2fe3e36mission 056: README + STATUS (merge, fixes, Hybriel 8590df63, real-data check), tests/realdata-056.mjs, tools/check-public-slugs.hlmre
  • f40c250emission 056: re-vendor hybriel master 8590df63 (#121, #122); an adult title's page is Not found for non-followers; gate: leave the page before stopping the servermre
  • 2b7fdd6cmission 056: signed-out header one row on phones ("Log in", nowrap), backfill skips adult titles' posters, gate checksmre
  • 2c53d5efMerge branch 't16-person' (tracker#16 person pages) into main; filmography shows only public titles (054 adult flag), gate race fix (backfill start line)mre
  • c171227emission 054: hide adult/unknown titles from the public lists and the search; in-app adult-flag backfill (TMDB details + poster per title, resumes), gate + real-data proofmre
  • 139fafd8tracker#16: short bio (4 lines, click = all), real-data check script, README + STATUSmre
  • 93be9476tracker#16: person pages /person/<slug> with the filmography fetched from TMDB on the first visit (step by step), gatemre
  • 47a3cae6STATUS: mission 053 merge commit idsmre
  • dcc5eecaMerge branch 't14-search'mre
  • 03edc783Merge branch 't15-tvmaze'mre
  • 71b46345tracker#15: numbering check by date or title, placeholder titles in other languages, docs + real-data proofmre
  • 6bb2daf1tracker#13: homepage (tiles, intro, latest movies/shows), /shows, /movies/page/N, /my/movies; lists cached in memorymre
  • b8bd1157tracker#14: README + STATUS (search, real-data numbers, gate, merge notes)mre
  • 65c694a8tracker#14: search — header magnifier, /search/<text> (in-memory word-prefix index over titles + people), Fetch from web (TMDB search/multi, ours left out), Add = import via syncShow; gate +25 checks, real-data scriptmre
  • 34f2c15btracker#15: TVmaze merge in the sync (gaps only: new episodes/seasons, empty titles/air dates; numbering check), fake TVmaze episodes + gatemre