calendar
All repositories: gitoria
32.8 KB
// hl:crypto plugin — native shared library (libcrypto.so under plugins/crypto/,// dlopen'd by the runtime; NOT to be confused with the system libcrypto this file// itself dlopens — the loader opens ours by absolute path with RTLD_LOCAL, and no// RPATH points the plugin's own dlopen at this directory).//// The FIRST surface of this plugin is passwords, done the way a password should be// stored: a memory-hard KDF, a random per-password salt, and a self-describing PHC// string that carries the algorithm and its parameters so the stored value can be// read back years later without the code remembering how it was made.//// hl_crypto_hash(password, opts?) → "$argon2id$v=19$m=…,t=…,p=…$salt$hash"// → "$scrypt$ln=…,r=…,p=…$salt$hash"// hl_crypto_verify(password, stored) → bool, CONSTANT-TIME comparison// hl_crypto_parse(stored) → the PHC string as an object (or null)// hl_crypto_kdf() → which KDF *this* engine hashes with// hl_crypto_sha256(data) → lowercase hex, 64 chars// hl_crypto_random_bytes(n, enc?) → n random bytes as hex (default) or base64// hl_crypto_base64_encode(text) → base64 of a String's bytes (ticket #90)// hl_crypto_base64_encode_hex(hex) → base64 of a Bytes, which crosses as its hex// hl_crypto_base64_decode(text) → the decoded bytes as a raw String, or null//// WHICH KDF. argon2id is the first choice and scrypt is the fallback; the decision// is made ONCE, at load, by asking the system's libcrypto for the ARGON2ID KDF// (OpenSSL ≥ 3.2 ships it in the default provider; 3.0/3.1 and 1.1 do not). Nothing// in the stored string depends on that probe going one way or the other — the PHC// string names its own algorithm, so `verify` reads BOTH regardless of which one// `hash` would produce today, and a machine that later gains argon2id keeps reading// every scrypt string it wrote before.//// THE LIBRARY IS RESOLVED AT RUNTIME, the same way `plugins/http/tls_common.zig`// resolves it (same candidate list, same dlopen flags, same dlsym-into-optionals// shape). This is that PATTERN reused, not a second loader: tls_common's job is an// SSL_CTX and it opens libssl beside libcrypto for it, which a password hash has no// use for. A host with no libcrypto at all gets a loud error from `hash`/`verify`// rather than a silent weaker hash.//// NEVER LOGGED: no function here writes a password, a salt, a derived key or a// stored string to any stream. The only messages this file can emit are about the// LIBRARY (missing .so, missing symbol), and they are emitted once.const std = @import("std");const api = @import("plugin_api");const HlValue = api.HlValue;const HlObject = api.HlObject;const HlField = api.HlField;const HlString = api.HlString;const c_dlfcn = @cImport({@cInclude("dlfcn.h");});const linux = std.os.linux;// stack_trace_frames = 0 (mission 068): plugin code runs on interpreter FIBER// stacks; Debug trace capture unwinds off them and segfaults.var gpa = std.heap.DebugAllocator(.{ .stack_trace_frames = 0 }){};const allocator = gpa.allocator();// Direct syscall for stderr — std.debug.print pulls in std.Progress, whose global// state is ABI-incompatible when a .so is loaded into a differently-built binary.fn logMsg(msg: []const u8) void {_ = linux.write(2, msg.ptr, msg.len);}// =========================================================================// Cost — ONE number, the same meaning on both KDFs//// `cost` is the base-2 logarithm of the working memory in KiB. cost 15 is 32 MiB// on argon2id (memcost = 32768 KiB) and 32 MiB on scrypt (N = 2^15, r = 8, p = 1,// which is 128 · N · r bytes). That is at or above the usual baseline for an// interactive login on both, and it is one knob rather than two sets of three.//// The option is CAPPED at both ends, and the cap is observable: the PHC string// records the parameters that were actually used, so `parse(hash(pw, {cost=99}))`// reports the cap rather than 99.// =========================================================================const COST_DEFAULT: u32 = 15; // 32 MiBconst COST_MIN: u32 = 10; // 1 MiB — below this a KDF stops being memory-hardconst COST_MAX: u32 = 17; // 128 MiB — the strongest cost anyone recommends for an// interactive login; past it a burst of sign-ins is a// denial of service against the machine serving them.// Fixed shape of everything else. These are recorded in the PHC string too, so// changing them later does not strand a single stored password.const SALT_LEN: usize = 16;const HASH_LEN: usize = 32;/// The floor a stored string must clear to be READ at all — see `decodePhc`./// Not the same numbers as above: those are what this plugin writes today, these/// are what any string has to carry for a comparison against it to mean anything.const MIN_SALT_LEN: usize = 8;const MIN_HASH_LEN: usize = 16;const ARGON2_TIME: u32 = 2; // t — the OWASP pairing for a memory-heavy argon2idconst ARGON2_LANES: u32 = 1; // p — one lane needs no libctx thread poolconst SCRYPT_R: u32 = 8; // the RFC 7914 block size everyone usesconst SCRYPT_P: u32 = 1;/// scrypt's memory bound is a SAFETY VALVE inside OpenSSL, not a tuning knob:/// EVP_PBE_scrypt refuses a request above `maxmem` and its default is 32 MiB,/// which the default cost sits exactly on. Raised past the cost cap's own ceiling/// so `cost` is the only limit that decides anything.const SCRYPT_MAXMEM: u64 = 2 * 1024 * 1024 * 1024;const Kdf = enum {argon2id,scrypt,fn name(self: Kdf) []const u8 {return switch (self) {.argon2id => "argon2id",.scrypt => "scrypt",};}fn parse(text: []const u8) ?Kdf {if (std.mem.eql(u8, text, "argon2id")) return .argon2id;if (std.mem.eql(u8, text, "scrypt")) return .scrypt;return null;}};// =========================================================================// libcrypto, resolved at runtime (the tls_common pattern)// =========================================================================const EVP_KDF = opaque {};const EVP_KDF_CTX = opaque {};/// openssl/core.h. Built by hand rather than through OSSL_PARAM_construct_*,/// which return this struct BY VALUE across the C ABI — the field layout is/// public and stable, the by-value return convention is not worth the risk.const OSSL_PARAM = extern struct {key: ?[*:0]const u8,data_type: c_uint,data: ?*anyopaque,data_size: usize,return_size: usize,};const OSSL_PARAM_UNSIGNED_INTEGER: c_uint = 2;const OSSL_PARAM_OCTET_STRING: c_uint = 5;/// OSSL_PARAM_UNMODIFIED — what the construct helpers put in `return_size` for a/// parameter being passed IN.const PARAM_UNMODIFIED: usize = std.math.maxInt(usize);fn paramEnd() OSSL_PARAM {return .{ .key = null, .data_type = 0, .data = null, .data_size = 0, .return_size = 0 };}fn paramUint(key: [*:0]const u8, value: *u32) OSSL_PARAM {return .{.key = key,.data_type = OSSL_PARAM_UNSIGNED_INTEGER,.data = @ptrCast(value),.data_size = @sizeOf(u32),.return_size = PARAM_UNMODIFIED,};}fn paramOctets(key: [*:0]const u8, bytes: []const u8) OSSL_PARAM {return .{.key = key,.data_type = OSSL_PARAM_OCTET_STRING,.data = @constCast(@ptrCast(bytes.ptr)),.data_size = bytes.len,.return_size = PARAM_UNMODIFIED,};}const EVP_PBE_scrypt_fn = *const fn ([*]const u8, usize, [*]const u8, usize, u64, u64, u64, u64, [*]u8, usize) callconv(.c) c_int;const EVP_KDF_fetch_fn = *const fn (?*anyopaque, [*:0]const u8, ?[*:0]const u8) callconv(.c) ?*EVP_KDF;const EVP_KDF_free_fn = *const fn (?*EVP_KDF) callconv(.c) void;const EVP_KDF_CTX_new_fn = *const fn (?*EVP_KDF) callconv(.c) ?*EVP_KDF_CTX;const EVP_KDF_CTX_free_fn = *const fn (?*EVP_KDF_CTX) callconv(.c) void;const EVP_KDF_derive_fn = *const fn (?*EVP_KDF_CTX, [*]u8, usize, ?[*]const OSSL_PARAM) callconv(.c) c_int;const Backend = struct {lib: ?*anyopaque = null,/// The KDF `hash()` produces. argon2id when the probe found it, scrypt otherwise.preferred: Kdf = .scrypt,has_argon2id: bool = false,has_scrypt: bool = false,fn_scrypt: ?EVP_PBE_scrypt_fn = null,fn_kdf_fetch: ?EVP_KDF_fetch_fn = null,fn_kdf_free: ?EVP_KDF_free_fn = null,fn_kdf_ctx_new: ?EVP_KDF_CTX_new_fn = null,fn_kdf_ctx_free: ?EVP_KDF_CTX_free_fn = null,fn_kdf_derive: ?EVP_KDF_derive_fn = null,};var backend: Backend = .{};/// A plain bool, not an atomic: `__native` calls are made from INTERPRETER code,/// which runs as fibers on one thread. Plugins that own their own threads (the/// HTTP servers) never call in here, so there is no second writer to guard/// against — the same reasoning `hl:math`'s lazily-seeded PRNG state relies on.var backend_ready: bool = false;fn loadSym(lib: ?*anyopaque, comptime T: type, name: [*:0]const u8) ?T {const sym = c_dlfcn.dlsym(lib, name) orelse return null;return @ptrCast(sym);}/// Resolve libcrypto and decide the KDF, once. Returns null when the host has no/// usable libcrypto — every entry point that needs one then fails LOUDLY.fn ensureBackend() ?*const Backend {if (backend_ready) {return if (backend.lib == null) null else &backend;}backend_ready = true;// Same candidate list and flags as plugins/http/tls_common.zig. No bare-name// ambiguity with our OWN libcrypto.so: this plugin sets no RPATH, so the// dynamic loader never searches plugins/crypto/ for these.const crypto_paths = [_][*:0]const u8{ "libcrypto.so.3", "libcrypto.so.1.1", "libcrypto.so" };for (crypto_paths) |path| {backend.lib = c_dlfcn.dlopen(path, c_dlfcn.RTLD_NOW | c_dlfcn.RTLD_LOCAL);if (backend.lib != null) break;}if (backend.lib == null) {logMsg("hl:crypto: no libcrypto.so on this host — password hashing is unavailable\n");return null;}backend.fn_scrypt = loadSym(backend.lib, EVP_PBE_scrypt_fn, "EVP_PBE_scrypt");backend.has_scrypt = backend.fn_scrypt != null;backend.fn_kdf_fetch = loadSym(backend.lib, EVP_KDF_fetch_fn, "EVP_KDF_fetch");backend.fn_kdf_free = loadSym(backend.lib, EVP_KDF_free_fn, "EVP_KDF_free");backend.fn_kdf_ctx_new = loadSym(backend.lib, EVP_KDF_CTX_new_fn, "EVP_KDF_CTX_new");backend.fn_kdf_ctx_free = loadSym(backend.lib, EVP_KDF_CTX_free_fn, "EVP_KDF_CTX_free");backend.fn_kdf_derive = loadSym(backend.lib, EVP_KDF_derive_fn, "EVP_KDF_derive");// THE PROBE. The symbols exist from OpenSSL 3.0; the ARGON2ID *algorithm*// only from 3.2, and only a successful fetch proves the provider has it.if (backend.fn_kdf_fetch != null and backend.fn_kdf_free != null andbackend.fn_kdf_ctx_new != null and backend.fn_kdf_ctx_free != null andbackend.fn_kdf_derive != null){if (backend.fn_kdf_fetch.?(null, "ARGON2ID", null)) |kdf| {backend.fn_kdf_free.?(kdf);backend.has_argon2id = true;}}backend.preferred = if (backend.has_argon2id) .argon2id else .scrypt;if (!backend.has_argon2id and !backend.has_scrypt) {logMsg("hl:crypto: libcrypto has neither ARGON2ID nor EVP_PBE_scrypt\n");}return &backend;}// =========================================================================// Derivation// =========================================================================fn deriveArgon2id(b: *const Backend, password: []const u8, salt: []const u8, memcost_kib: u32, out: []u8) bool {if (!b.has_argon2id) return false;const kdf = b.fn_kdf_fetch.?(null, "ARGON2ID", null) orelse return false;defer b.fn_kdf_free.?(kdf);const ctx = b.fn_kdf_ctx_new.?(kdf) orelse return false;defer b.fn_kdf_ctx_free.?(ctx);var m: u32 = memcost_kib;var t: u32 = ARGON2_TIME;var lanes: u32 = ARGON2_LANES;var threads: u32 = 1;var size: u32 = @intCast(out.len);// `lanes`/`threads` are both 1 on purpose: argon2id with more than one thread// needs a thread pool installed on the OSSL_LIB_CTX, and a plugin has no// business installing one into the process's default library context.var params = [_]OSSL_PARAM{paramOctets("pass", password),paramOctets("salt", salt),paramUint("memcost", &m),paramUint("iter", &t),paramUint("lanes", &lanes),paramUint("threads", &threads),paramUint("size", &size),paramEnd(),};return b.fn_kdf_derive.?(ctx, out.ptr, out.len, ¶ms) == 1;}fn deriveScrypt(b: *const Backend, password: []const u8, salt: []const u8, ln: u32, r: u32, p: u32, out: []u8) bool {const f = b.fn_scrypt orelse return false;if (ln >= 64) return false;const n: u64 = @as(u64, 1) << @intCast(ln);return f(password.ptr,password.len,salt.ptr,salt.len,n,r,p,SCRYPT_MAXMEM,out.ptr,out.len,) == 1;}// =========================================================================// The PHC string//// $argon2id$v=19$m=32768,t=2,p=1$<salt>$<hash>// $scrypt$ln=15,r=8,p=1$<salt>$<hash>//// salt and hash are base64 with the standard alphabet and NO padding, which is// what the PHC string format specifies. Nothing here is secret — the whole point// of the format is that the stored value describes itself.// =========================================================================const B64 = std.base64.standard_no_pad;const Phc = struct {kdf: Kdf,/// argon2 only; 19 (0x13) is the only version OpenSSL's ARGON2ID speaks.version: u32 = 19,/// argon2: memory in KiB. scrypt: unused.m: u32 = 0,/// argon2: iterations. scrypt: unused.t: u32 = 0,/// scrypt: log2(N). argon2: unused.ln: u32 = 0,/// scrypt: block size. argon2: unused.r: u32 = 0,/// lanes (argon2) / parallelism (scrypt).p: u32 = 0,salt: [64]u8 = undefined,salt_len: usize = 0,hash: [64]u8 = undefined,hash_len: usize = 0,};fn encodePhc(phc: *const Phc) ?[]u8 {var salt_b64: [128]u8 = undefined;var hash_b64: [128]u8 = undefined;const s = B64.Encoder.encode(&salt_b64, phc.salt[0..phc.salt_len]);const h = B64.Encoder.encode(&hash_b64, phc.hash[0..phc.hash_len]);return switch (phc.kdf) {.argon2id => std.fmt.allocPrint(allocator, "$argon2id$v={d}$m={d},t={d},p={d}${s}${s}", .{phc.version, phc.m, phc.t, phc.p, s, h,}) catch null,.scrypt => std.fmt.allocPrint(allocator, "$scrypt$ln={d},r={d},p={d}${s}${s}", .{phc.ln, phc.r, phc.p, s, h,}) catch null,};}/// One `key=value` out of a comma-separated parameter field. Absent or unparsable/// is null, and every caller treats null as "this is not a PHC string I can read".fn paramValue(field: []const u8, key: []const u8) ?u32 {var it = std.mem.splitScalar(u8, field, ',');while (it.next()) |pair| {const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue;if (!std.mem.eql(u8, pair[0..eq], key)) continue;return std.fmt.parseInt(u32, pair[eq + 1 ..], 10) catch null;}return null;}fn decodeB64Into(text: []const u8, buf: []u8) ?usize {const n = B64.Decoder.calcSizeForSlice(text) catch return null;if (n == 0 or n > buf.len) return null;B64.Decoder.decode(buf[0..n], text) catch return null;return n;}/// Strictly parse a stored string. ANY deviation — a wrong field count, a missing/// parameter, a base64 body that does not decode — is null, and `verify` turns/// null into `false`. That is what makes a tampered string fail rather than/// half-parse into something with a comparable hash.fn decodePhc(stored: []const u8) ?Phc {if (stored.len < 2 or stored[0] != '$') return null;var parts: [8][]const u8 = undefined;var count: usize = 0;var it = std.mem.splitScalar(u8, stored[1..], '$');while (it.next()) |part| {if (count == parts.len) return null;parts[count] = part;count += 1;}var phc = Phc{ .kdf = .scrypt };const kdf = Kdf.parse(parts[0]) orelse return null;phc.kdf = kdf;const salt_field: []const u8, const hash_field: []const u8 = switch (kdf) {.argon2id => blk: {// $argon2id$v=19$m=..,t=..,p=..$salt$hashif (count != 5) return null;if (!std.mem.startsWith(u8, parts[1], "v=")) return null;phc.version = std.fmt.parseInt(u32, parts[1][2..], 10) catch return null;phc.m = paramValue(parts[2], "m") orelse return null;phc.t = paramValue(parts[2], "t") orelse return null;phc.p = paramValue(parts[2], "p") orelse return null;break :blk .{ parts[3], parts[4] };},.scrypt => blk: {// $scrypt$ln=..,r=..,p=..$salt$hashif (count != 4) return null;phc.version = 0;phc.ln = paramValue(parts[1], "ln") orelse return null;phc.r = paramValue(parts[1], "r") orelse return null;phc.p = paramValue(parts[1], "p") orelse return null;break :blk .{ parts[2], parts[3] };},};phc.salt_len = decodeB64Into(salt_field, &phc.salt) orelse return null;phc.hash_len = decodeB64Into(hash_field, &phc.hash) orelse return null;// MINIMUM LENGTHS, and they are load-bearing rather than tidiness. A KDF// derives as many bytes as it is asked for, so `verify` on a stored string// whose hash field had been CUT DOWN to eight base64 characters used to// derive six bytes and compare six bytes — and six bytes of a correct// derivation match. Truncating the stored value was therefore a way to make// a wrong password verify, until this line. (Found by the tamper gate on the// first run of tests/pass/plugins/005; the JS twin had it too.)if (phc.salt_len < MIN_SALT_LEN or phc.hash_len < MIN_HASH_LEN) return null;return phc;}// =========================================================================// Constant-time comparison//// The lengths are NOT secret (they are in the stored string, in the clear), so// comparing them up front leaks nothing. The bytes are: the loop below always// touches every one of them and branches on nothing.// =========================================================================/// Bytes straight off the kernel CSPRNG. `getrandom(2)` rather than any/// userspace generator: a salt and a token are the two things in this file that/// must not be predictable, and the http plugins reach for the same syscall.fn fillRandom(buf: []u8) bool {return linux.getrandom(buf.ptr, buf.len, 0) == buf.len;}fn constantTimeEql(a: []const u8, b: []const u8) bool {if (a.len != b.len) return false;var diff: u8 = 0;for (a, b) |x, y| diff |= x ^ y;return diff == 0;}// =========================================================================// Value helpers// =========================================================================fn hlStr(s: []const u8) HlString {return .{ .ptr = s.ptr, .len = s.len };}fn allocStringDeinit(val: *HlValue) callconv(.c) void {if (val.type != .hl_string) return;const s = val.data.string;if (s.len == 0) return;allocator.free(@constCast(s.ptr[0..s.len]));}/// Hand an owned string to the runtime. The loader copies the bytes into its own/// tracker and then calls this value's deinit_fn, so the plugin's copy is freed/// on the same call it was made.fn ownedString(s: []u8) HlValue {var result = api.makeString(s);result.deinit_fn = &allocStringDeinit;return result;}fn objDeinit(obj: *HlObject) callconv(.c) void {allocator.free(obj.fields[0..obj.field_count]);allocator.destroy(obj);}fn makeObj(fields: []HlField) HlValue {const owned = allocator.dupe(HlField, fields) catch return api.makeNull();const obj = allocator.create(HlObject) catch {allocator.free(owned);return api.makeNull();};obj.* = .{ .fields = owned.ptr, .field_count = owned.len, .deinit_fn = &objDeinit };return api.makeObject(obj);}fn argString(argc: u32, argv: [*]const HlValue, idx: u32) ?[]const u8 {if (idx >= argc) return null;if (argv[idx].type != .hl_string) return null;return argv[idx].data.string.ptr[0..argv[idx].data.string.len];}fn argNumber(argc: u32, argv: [*]const HlValue, idx: u32) ?f64 {if (idx >= argc) return null;if (argv[idx].type != .hl_number) return null;return argv[idx].data.number;}/// One field out of an options hybrid. Absent object, absent key and a key of the/// wrong type all read as "not given".fn optField(argc: u32, argv: [*]const HlValue, idx: u32, key: []const u8) ?HlValue {if (idx >= argc) return null;if (argv[idx].type != .hl_object) return null;const obj = argv[idx].data.object;for (obj.fields[0..obj.field_count]) |f| {if (std.mem.eql(u8, f.key.ptr[0..f.key.len], key)) return f.value;}return null;}// =========================================================================// Exports// =========================================================================/// hash(password, opts?) → PHC string./// opts: { cost = <clamped to COST_MIN..COST_MAX>, kdf = "argon2id" | "scrypt" }export fn hl_crypto_hash(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {const password = argString(argc, argv, 0) orelsereturn api.makeError("hl:crypto hash() expects a string password");const b = ensureBackend() orelsereturn api.makeError("hl:crypto hash(): no libcrypto.so on this host");var cost: u32 = COST_DEFAULT;if (optField(argc, argv, 1, "cost")) |v| {if (v.type == .hl_number) {const n = v.data.number;if (!std.math.isNan(n)) {// Clamp, do not refuse: `cost` is a capped knob and the PHC string// it produces reports the value that was actually used.const clamped = @max(@as(f64, @floatFromInt(COST_MIN)), @min(@as(f64, @floatFromInt(COST_MAX)), n));cost = @intFromFloat(@trunc(clamped));}}}var kdf = b.preferred;if (optField(argc, argv, 1, "kdf")) |v| {if (v.type == .hl_string) {const want = v.data.string.ptr[0..v.data.string.len];kdf = Kdf.parse(want) orelsereturn api.makeError("hl:crypto hash(): unknown kdf — expected \"argon2id\" or \"scrypt\"");}}var phc = Phc{ .kdf = kdf, .salt_len = SALT_LEN, .hash_len = HASH_LEN };if (!fillRandom(phc.salt[0..SALT_LEN])) {return api.makeError("hl:crypto hash(): the kernel CSPRNG refused a salt");}const ok = switch (kdf) {.argon2id => blk: {phc.m = @as(u32, 1) << @intCast(cost);phc.t = ARGON2_TIME;phc.p = ARGON2_LANES;break :blk deriveArgon2id(b, password, phc.salt[0..SALT_LEN], phc.m, phc.hash[0..HASH_LEN]);},.scrypt => blk: {phc.ln = cost;phc.r = SCRYPT_R;phc.p = SCRYPT_P;break :blk deriveScrypt(b, password, phc.salt[0..SALT_LEN], phc.ln, phc.r, phc.p, phc.hash[0..HASH_LEN]);},};if (!ok) {return api.makeError(switch (kdf) {.argon2id => "hl:crypto hash(): this libcrypto has no ARGON2ID (needs OpenSSL >= 3.2)",.scrypt => "hl:crypto hash(): this libcrypto has no EVP_PBE_scrypt",});}const out = encodePhc(&phc) orelsereturn api.makeError("hl:crypto hash(): could not encode the PHC string");return ownedString(out);}/// verify(password, stored) → bool. Malformed, tampered and non-matching are all/// `false`; a stored string whose ALGORITHM this engine cannot compute is a loud/// error, because answering `false` there would read as "wrong password".export fn hl_crypto_verify(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {const password = argString(argc, argv, 0) orelse return api.makeBool(false);const stored = argString(argc, argv, 1) orelse return api.makeBool(false);const phc = decodePhc(stored) orelse return api.makeBool(false);if (phc.hash_len == 0 or phc.hash_len > 64) return api.makeBool(false);const b = ensureBackend() orelsereturn api.makeError("hl:crypto verify(): no libcrypto.so on this host");var computed: [64]u8 = undefined;const ok = switch (phc.kdf) {.argon2id => blk: {if (!b.has_argon2id) {return api.makeError("hl:crypto verify(): stored password is argon2id and this libcrypto has none (needs OpenSSL >= 3.2)");}if (phc.version != 19 or phc.p != 1) break :blk false;break :blk deriveArgon2id(b, password, phc.salt[0..phc.salt_len], phc.m, computed[0..phc.hash_len]);},.scrypt => blk: {if (!b.has_scrypt) {return api.makeError("hl:crypto verify(): stored password is scrypt and this libcrypto has no EVP_PBE_scrypt");}if (phc.ln == 0 or phc.ln > 30 or phc.r == 0 or phc.p == 0) break :blk false;break :blk deriveScrypt(b, password, phc.salt[0..phc.salt_len], phc.ln, phc.r, phc.p, computed[0..phc.hash_len]);},};if (!ok) return api.makeBool(false);return api.makeBool(constantTimeEql(computed[0..phc.hash_len], phc.hash[0..phc.hash_len]));}/// parsePhc(stored) → { kdf, version, params, saltLen, hashLen } or null./// Reads a stored string WITHOUT the password — what it is for is looking at what/// you have stored (which algorithm, at which cost), not for checking anything.export fn hl_crypto_parse(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {const stored = argString(argc, argv, 0) orelse return api.makeNull();const phc = decodePhc(stored) orelse return api.makeNull();var params: HlValue = undefined;switch (phc.kdf) {.argon2id => {var pf = [_]HlField{.{ .key = hlStr("m"), .value = api.makeNumber(@floatFromInt(phc.m)) },.{ .key = hlStr("t"), .value = api.makeNumber(@floatFromInt(phc.t)) },.{ .key = hlStr("p"), .value = api.makeNumber(@floatFromInt(phc.p)) },};params = makeObj(&pf);},.scrypt => {var pf = [_]HlField{.{ .key = hlStr("ln"), .value = api.makeNumber(@floatFromInt(phc.ln)) },.{ .key = hlStr("r"), .value = api.makeNumber(@floatFromInt(phc.r)) },.{ .key = hlStr("p"), .value = api.makeNumber(@floatFromInt(phc.p)) },};params = makeObj(&pf);},}var fields = [_]HlField{.{ .key = hlStr("kdf"), .value = api.makeString(phc.kdf.name()) },.{ .key = hlStr("version"), .value = if (phc.kdf == .argon2id)api.makeNumber(@floatFromInt(phc.version))elseapi.makeNull() },.{ .key = hlStr("params"), .value = params },.{ .key = hlStr("saltLen"), .value = api.makeNumber(@floatFromInt(phc.salt_len)) },.{ .key = hlStr("hashLen"), .value = api.makeNumber(@floatFromInt(phc.hash_len)) },};return makeObj(&fields);}/// kdf() → the algorithm THIS engine writes with ("argon2id" or "scrypt")./// Reporting only: nothing needs to ask, because every stored string says so itself.export fn hl_crypto_kdf(_: u32, _: [*]const HlValue) callconv(.c) HlValue {const b = ensureBackend() orelse return api.makeNull();return api.makeString(b.preferred.name());}/// sha256(data) → 64 lowercase hex characters./// CONTENT hashing, not password hashing — it is deliberately fast, which is/// exactly why `hash()` above does not use it.export fn hl_crypto_sha256(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {const data = argString(argc, argv, 0) orelsereturn api.makeError("hl:crypto sha256() expects a string");var digest: [32]u8 = undefined;std.crypto.hash.sha2.Sha256.hash(data, &digest, .{});const out = std.fmt.allocPrint(allocator, "{x}", .{&digest}) catchreturn api.makeError("hl:crypto sha256(): out of memory");return ownedString(out);}const RANDOM_MAX: usize = 1024;/// randomBytes(n, encoding?) → n bytes from the kernel CSPRNG, "hex" (default) or/// "base64" (standard alphabet, padded — this is a token, not a PHC field).export fn hl_crypto_random_bytes(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {const n_f = argNumber(argc, argv, 0) orelsereturn api.makeError("hl:crypto randomBytes() expects a byte count");if (!(n_f >= 1) or n_f > @as(f64, @floatFromInt(RANDOM_MAX))) {return api.makeError("hl:crypto randomBytes(): count must be between 1 and 1024");}const n: usize = @intFromFloat(@trunc(n_f));var buf: [RANDOM_MAX]u8 = undefined;if (!fillRandom(buf[0..n])) {return api.makeError("hl:crypto randomBytes(): the kernel CSPRNG refused");}const enc = argString(argc, argv, 1) orelse "hex";if (std.mem.eql(u8, enc, "hex")) {const out = std.fmt.allocPrint(allocator, "{x}", .{buf[0..n]}) catchreturn api.makeError("hl:crypto randomBytes(): out of memory");return ownedString(out);}if (std.mem.eql(u8, enc, "base64")) {const std64 = std.base64.standard;const out = allocator.alloc(u8, std64.Encoder.calcSize(n)) catchreturn api.makeError("hl:crypto randomBytes(): out of memory");_ = std64.Encoder.encode(out, buf[0..n]);return ownedString(out);}return api.makeError("hl:crypto randomBytes(): encoding must be \"hex\" or \"base64\"");}// ── base64 (ticket #90) ─────────────────────────────────────────────────────// The standard alphabet (RFC 4648 §4), what an `Authorization: Basic` header// and most of the web speak. Encoding pads; decoding takes the padded and the// unpadded form alike and answers null for anything else — a stray character,// a length no encoder writes, or non-zero bits in the last character's unused// tail (a string that is not what encoding its own result would give). The// JavaScript twin applies the same test, so both engines refuse the same text.fn base64Encode(raw: []const u8, comptime what: []const u8) HlValue {const enc = std.base64.standard.Encoder;const out = allocator.alloc(u8, enc.calcSize(raw.len)) catchreturn api.makeError("hl:crypto " ++ what ++ "(): out of memory");_ = enc.encode(out, raw);return ownedString(out);}/// toBase64(String) — the String's bytes, as they are.export fn hl_crypto_base64_encode(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {const data = argString(argc, argv, 0) orelsereturn api.makeError("hl:crypto toBase64() expects a String or a Bytes");return base64Encode(data, "toBase64");}/// toBase64(Bytes) — the ABI has no Bytes: it crosses as its hex text.export fn hl_crypto_base64_encode_hex(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {const hex = argString(argc, argv, 0) orelsereturn api.makeError("hl:crypto toBase64() expects a String or a Bytes");const raw = allocator.alloc(u8, hex.len / 2) catchreturn api.makeError("hl:crypto toBase64(): out of memory");defer allocator.free(raw);_ = std.fmt.hexToBytes(raw, hex) catch return api.makeError("hl:crypto toBase64(): not a Bytes");return base64Encode(raw, "toBase64");}/// fromBase64(text) → the bytes as a raw String (server.hl makes it a Bytes),/// or null when `text` is not base64.export fn hl_crypto_base64_decode(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {const text = argString(argc, argv, 0) orelsereturn api.makeError("hl:crypto fromBase64() expects a String");// the padding goes, when the length says it is paddingvar core = text;if (core.len % 4 == 0) {var pad: usize = 0;while (pad < 2 and core.len > 0 and core[core.len - 1] == '=') : (pad += 1) core = core[0 .. core.len - 1];}if (core.len % 4 == 1) return api.makeNull();const dec = std.base64.standard_no_pad.Decoder;const n = dec.calcSizeForSlice(core) catch return api.makeNull();const out = allocator.alloc(u8, n) catchreturn api.makeError("hl:crypto fromBase64(): out of memory");dec.decode(out, core) catch {allocator.free(out);return api.makeNull();};// canonical: encoding the result must give `core` back (the unused bits are zero)const enc = std.base64.standard_no_pad.Encoder;var chk: [4]u8 = undefined;const tail = out.len % 3;if (tail != 0) {const again = enc.encode(&chk, out[out.len - tail ..]);if (!std.mem.eql(u8, again, core[core.len - again.len ..])) {allocator.free(out);return api.makeNull();}}return ownedString(out);}
Branches
- mainmain branch
Latest commits
- 14ba08c7antcolony#40: mission references point to the moved missionsmre
- 99c73346antcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- 76edaa62calendar: Hybriel master ff51cf46 (re-vendor round, static workaround removed)mre
- 90a3fc2cdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- 6722b72ddeploy.sh: never send .git or .gitignore to Byrodinmre
- be099807State of 2026-09-27, before the move to gitoriamre