From 4414972079eea5b50a42746ae994848b00172514 Mon Sep 17 00:00:00 2001 From: udo Date: Mon, 13 Jul 2026 19:35:38 +0000 Subject: [PATCH] feat: add native scrypt password hashing --- docs/setup.md | 18 +++++- scripts/build_linux.sh | 2 +- scripts/run_cli_tests.sh | 1 + scripts/test_password_hashing.sh | 37 +++++++++++ site/tests/io.uce | 4 +- src/lib/hash.cpp | 105 +++++++++++++++++++++++++++++++ src/lib/hash.h | 3 + src/lib/sys.cpp | 17 +++++ src/lib/sys.h | 3 + src/wasm/core_hostcalls.syms | 3 + src/wasm/worker.cpp | 6 ++ 11 files changed, 196 insertions(+), 3 deletions(-) create mode 100755 scripts/test_password_hashing.sh diff --git a/docs/setup.md b/docs/setup.md index 18a6db5..30eb8c6 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -32,7 +32,7 @@ On Debian/Ubuntu-like systems, install the distro packages first: ```bash apt update -apt install -y clang build-essential libpcre2-dev mariadb-client libmariadb-dev curl rsync ca-certificates +apt install -y clang build-essential libpcre2-dev libssl-dev mariadb-client libmariadb-dev curl rsync ca-certificates ``` UCE also requires two non-vendored dependencies. WASI SDK is load-bearing at runtime because UCE compiles units on demand during requests and during proactive startup scans. The `curl` binary is also a pinned runtime package dependency: `http_request()` and `http_request_async()` execute it directly with an explicit argument vector for TLS-capable outbound HTTP. @@ -621,6 +621,22 @@ sock.close() PY ``` +## Password hashing + +Use the native password API for application credentials: + +```cpp +String encoded = password_hash(password); +if(encoded == "") + // fail the write; native hashing did not complete + +bool valid = password_verify(candidate, encoded); +if(valid && password_needs_rehash(encoded)) + encoded = password_hash(candidate); +``` + +`password_hash()` returns a self-contained `$uce$scrypt$...` encoding with a random 16-byte salt and the bounded scrypt parameters `N=65536`, `r=8`, `p=1`. `password_verify()` accepts only structurally valid encodings with bounded cost parameters and compares the derived key in constant time. `password_needs_rehash()` reports malformed, legacy, or non-current parameters so applications can upgrade a credential after a successful legacy verification. Treat an empty hash as an operational failure and never store it. Application-level password length policy, rate limiting, and legacy-format verification remain the application's responsibility. + ## Operational footguns - Keep the FastCGI socket path consistent: `FCGI_SOCKET_PATH` and the web-server `fastcgi_pass` must match exactly. The reference config uses `/run/uce/fastcgi.sock`; if you choose `/run/uce.sock`, use it in both places. diff --git a/scripts/build_linux.sh b/scripts/build_linux.sh index 4269e9f..4e6d467 100755 --- a/scripts/build_linux.sh +++ b/scripts/build_linux.sh @@ -21,7 +21,7 @@ WASMTIME_HOME=${WASMTIME_HOME:-/opt/wasmtime} WASM_FLAGS="-I$WASMTIME_HOME/include" WASM_LIBS="-L$WASMTIME_HOME/lib -Wl,-rpath,$WASMTIME_HOME/lib -lwasmtime" -LIBS="-ldl -lm -lpthread -lpcre2-8 `mysql_config --cflags --libs` $WASM_LIBS" +LIBS="-ldl -lm -lpthread -lpcre2-8 -lcrypto `mysql_config --cflags --libs` $WASM_LIBS" SRCFLAGS="-D EXEC_NAME=\"$GF\" -D PLATFORM_NAME=\"linux\"" # The runtime is split into separately-compiled objects so an edit to one diff --git a/scripts/run_cli_tests.sh b/scripts/run_cli_tests.sh index 9230316..d5f4902 100755 --- a/scripts/run_cli_tests.sh +++ b/scripts/run_cli_tests.sh @@ -52,4 +52,5 @@ curl -sS --fail-with-body --unix-socket "$socket_path" "$url" if [[ "$action" == "run" ]]; then scripts/test_dependency_invalidation.sh scripts/test_cold_component_deadline.sh + scripts/test_password_hashing.sh fi diff --git a/scripts/test_password_hashing.sh b/scripts/test_password_hashing.sh new file mode 100755 index 0000000..17bfc90 --- /dev/null +++ b/scripts/test_password_hashing.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +test_name="password-hash-test-$$" +site_directory="${UCE_TEST_SITE_DIRECTORY:-site}" +source_dir="$site_directory/$test_name" +bin_directory="${BIN_DIRECTORY:-}" +if [[ -z "$bin_directory" && -r /etc/uce/settings.cfg ]]; then + bin_directory=$(awk -F= '/^[[:space:]]*BIN_DIRECTORY[[:space:]]*=/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2); print $2; exit}' /etc/uce/settings.cfg) +fi +bin_directory="${bin_directory:-/tmp/uce/work}" +cache_dir="" + +cleanup() { + rm -rf "$source_dir" + if [[ -n "$cache_dir" ]]; then + rm -rf "$cache_dir" + fi +} +trap cleanup EXIT +mkdir -p "$source_dir" +cache_dir="$bin_directory$(realpath "$source_dir")" + +printf '%s\n' \ + 'CLI(Request& context) {' \ + ' String encoded = password_hash("correct horse battery staple");' \ + ' print(encoded, "\n", password_verify("correct horse battery staple", encoded) ? "valid" : "invalid", "\n", password_verify("wrong password", encoded) ? "wrong-valid" : "wrong-rejected", "\n", password_needs_rehash(encoded) ? "rehash" : "current", "\n", password_verify("password", "$uce$scrypt$999999999$8$1$00$00") ? "malformed-valid" : "malformed-rejected");' \ + '}' >"$source_dir/test.uce" + +output=$(scripts/uce-cli "/$test_name/test.uce") +if [[ "$output" != *'$uce$scrypt$65536$8$1$'* || "$output" != *$'\nvalid\nwrong-rejected\ncurrent\nmalformed-rejected'* ]]; then + echo "native password hashing failed: $output" >&2 + exit 1 +fi + +echo "password hashing passed" diff --git a/site/tests/io.uce b/site/tests/io.uce index dfaf4c4..205ebc2 100644 --- a/site/tests/io.uce +++ b/site/tests/io.uce @@ -163,6 +163,8 @@ RENDER(Request& context) String crypto_b64 = base64_encode("hello"); String crypto_b64_dec = base64_decode(crypto_b64); check("sha256() / hmac_sha256() / base64 / random_bytes() / crypto_equal()", sha256_hex("abc") == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" && hmac_sha256_hex("key", "The quick brown fox jumps over the lazy dog") == "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8" && crypto_b64 == "aGVsbG8=" && crypto_b64_dec == "hello" && crypto_random.length() == 16 && crypto_equal("same", "same") && !crypto_equal("same", "diff"), crypto_b64 + " / " + sha256_hex("abc")); + String password_encoded = password_hash("correct horse battery staple"); + check("password_hash() / password_verify() / password_needs_rehash()", contains(password_encoded, "$uce$scrypt$65536$8$1$") && password_verify("correct horse battery staple", password_encoded) && !password_verify("wrong password", password_encoded) && !password_needs_rehash(password_encoded) && password_needs_rehash("malformed") && !password_verify("correct horse battery staple", "$uce$scrypt$999999999$8$1$00$00"), password_encoded); StringList escaped_keys = memcache_escape_keys({"a b", "line\nkey"}); check("memcache_escape_key() / memcache_escape_keys()", memcache_escape_key("a b") == "a_b" && escaped_keys.size() == 2 && !contains(escaped_keys[1], "\n"), join(escaped_keys, ",")); @@ -170,4 +172,4 @@ RENDER(Request& context) site_tests_summary(passed, failed, skipped, "This page limits writes to /tmp/uce-site-tests-io.txt so it stays disposable."); site_tests_page_end(); -} \ No newline at end of file +} diff --git a/src/lib/hash.cpp b/src/lib/hash.cpp index fc4208d..b9519c5 100644 --- a/src/lib/hash.cpp +++ b/src/lib/hash.cpp @@ -421,3 +421,108 @@ bool crypto_equal_native(String a, String b) for(size_t i=0; i +#include + +namespace { +const u64 UCE_PASSWORD_SCRYPT_N = 65536; +const u64 UCE_PASSWORD_SCRYPT_R = 8; +const u64 UCE_PASSWORD_SCRYPT_P = 1; + +String uce_hex_encode(const unsigned char* bytes, size_t size) +{ + String encoded; + encoded.reserve(size * 2); + for(size_t i = 0; i < size; i++) + encoded += to_hex(bytes[i], 2); + return(to_lower(encoded)); +} + +bool uce_hex_decode(String encoded, String& decoded) +{ + if(encoded.size() % 2 != 0) + return(false); + decoded.clear(); + decoded.reserve(encoded.size() / 2); + for(size_t i = 0; i < encoded.size(); i += 2) + { + u8 value = 0; + for(size_t j = 0; j < 2; j++) + { + char c = encoded[i + j]; + u8 digit = c >= '0' && c <= '9' ? (u8)(c - '0') : c >= 'a' && c <= 'f' ? (u8)(c - 'a' + 10) : c >= 'A' && c <= 'F' ? (u8)(c - 'A' + 10) : 255; + if(digit == 255) + return(false); + value = (u8)((value << 4) | digit); + } + decoded.push_back((char)value); + } + return(true); +} + +bool uce_decimal_u64(String value, u64& parsed) +{ + if(value == "") + return(false); + parsed = 0; + for(char c : value) + { + if(c < '0' || c > '9' || parsed > (UINT64_MAX - (u64)(c - '0')) / 10) + return(false); + parsed = parsed * 10 + (u64)(c - '0'); + } + return(true); +} + +bool uce_password_parts(String encoded, u64& n, u64& r, u64& p, String& salt, String& digest) +{ + const String prefix = "$uce$scrypt$"; + if(encoded.size() <= prefix.size() || encoded.substr(0, prefix.size()) != prefix) + return(false); + auto parts = split(encoded.substr(prefix.size()), "$"); + if(parts.size() != 5 || !uce_decimal_u64(parts[0], n) || !uce_decimal_u64(parts[1], r) || !uce_decimal_u64(parts[2], p)) + return(false); + if(n < 16384 || n > UCE_PASSWORD_SCRYPT_N || (n & (n - 1)) != 0 || r < 1 || r > UCE_PASSWORD_SCRYPT_R || p != UCE_PASSWORD_SCRYPT_P || n * r > UCE_PASSWORD_SCRYPT_N * UCE_PASSWORD_SCRYPT_R) + return(false); + return(uce_hex_decode(parts[3], salt) && salt.size() == 16 && uce_hex_decode(parts[4], digest) && digest.size() == 32); +} + +bool uce_password_derive(String password, String salt, u64 n, u64 r, u64 p, unsigned char* output) +{ + if(password.size() > 1024 * 1024) + return(false); + u64 max_memory = 128 * n * r + 2 * 1024 * 1024; + return(EVP_PBE_scrypt(password.data(), password.size(), (const unsigned char*)salt.data(), salt.size(), n, r, p, max_memory, output, 32) == 1); +} +} + +String password_hash_native(String password) +{ + unsigned char salt[16]; + unsigned char digest[32]; + if(RAND_bytes(salt, sizeof(salt)) != 1 || !uce_password_derive(password, String((const char*)salt, sizeof(salt)), UCE_PASSWORD_SCRYPT_N, UCE_PASSWORD_SCRYPT_R, UCE_PASSWORD_SCRYPT_P, digest)) + return(""); + return("$uce$scrypt$" + std::to_string(UCE_PASSWORD_SCRYPT_N) + "$" + std::to_string(UCE_PASSWORD_SCRYPT_R) + "$" + std::to_string(UCE_PASSWORD_SCRYPT_P) + "$" + uce_hex_encode(salt, sizeof(salt)) + "$" + uce_hex_encode(digest, sizeof(digest))); +} + +bool password_verify_native(String password, String encoded) +{ + u64 n = 0, r = 0, p = 0; + String salt, expected; + if(!uce_password_parts(encoded, n, r, p, salt, expected)) + return(false); + unsigned char digest[32]; + if(!uce_password_derive(password, salt, n, r, p, digest)) + return(false); + return(crypto_equal_native(String((const char*)digest, sizeof(digest)), expected)); +} + +bool password_needs_rehash_native(String encoded) +{ + u64 n = 0, r = 0, p = 0; + String salt, digest; + return(!uce_password_parts(encoded, n, r, p, salt, digest) || n != UCE_PASSWORD_SCRYPT_N || r != UCE_PASSWORD_SCRYPT_R || p != UCE_PASSWORD_SCRYPT_P); +} +#endif diff --git a/src/lib/hash.h b/src/lib/hash.h index 3da63f7..abc61c8 100644 --- a/src/lib/hash.h +++ b/src/lib/hash.h @@ -24,6 +24,9 @@ String sha256_hex_native(String data); String hmac_sha256_native(String key, String data); String hmac_sha256_hex_native(String key, String data); bool crypto_equal_native(String a, String b); +String password_hash_native(String password); +bool password_verify_native(String password, String encoded); +bool password_needs_rehash_native(String encoded); String sha256(String data); String sha256_hex(String data); String hmac_sha256(String key, String data); diff --git a/src/lib/sys.cpp b/src/lib/sys.cpp index f835927..94e008e 100644 --- a/src/lib/sys.cpp +++ b/src/lib/sys.cpp @@ -61,6 +61,9 @@ size_t uce_host_hmac_sha256_hex(const char* key, size_t key_len, const char* dat size_t uce_host_base64_encode(const char* data, size_t data_len, char* out, size_t cap); size_t uce_host_base64_decode(const char* data, size_t data_len, char* out, size_t cap); int uce_host_crypto_equal(const char* a, size_t a_len, const char* b, size_t b_len); +size_t uce_host_password_hash(const char* password, size_t password_len, char* out, size_t cap); +int uce_host_password_verify(const char* password, size_t password_len, const char* encoded, size_t encoded_len); +int uce_host_password_needs_rehash(const char* encoded, size_t encoded_len); size_t uce_host_http_request(const char* in, size_t in_len, char* out, size_t cap); uint64_t uce_host_http_request_async(const char* in, size_t in_len); size_t uce_host_shell_exec_dv(const char* in, size_t in_len, char* out, size_t cap); @@ -270,6 +273,17 @@ String hmac_sha256_hex(String key, String data) size_t required = uce_host_hmac_sha256_hex(key.data(), key.size(), data.data(), data.size(), 0, 0); String out(required, 0); size_t got = required ? uce_host_hmac_sha256_hex(key.data(), key.size(), data.data(), data.size(), &out[0], required) : 0; out.resize(got <= required ? got : 0); return(out); } +String password_hash(String password) +{ + String encoded(256, 0); + size_t got = uce_host_password_hash(password.data(), password.size(), &encoded[0], encoded.size()); + if(got > encoded.size()) + return(""); + encoded.resize(got); + return(encoded); +} +bool password_verify(String password, String encoded) { return(uce_host_password_verify(password.data(), password.size(), encoded.data(), encoded.size()) != 0); } +bool password_needs_rehash(String encoded) { return(uce_host_password_needs_rehash(encoded.data(), encoded.size()) != 0); } String base64_decode(String raw) { return(wasm_string_hostcall_1(uce_host_base64_decode, raw)); } String random_bytes(u64 n) { @@ -714,6 +728,9 @@ String hmac_sha256_hex(String key, String data) { return(hmac_sha256_hex_native( String base64_decode(String raw) { bool ok=false; return(::base64_decode(raw, ok)); } String random_bytes(u64 n) { if(n > 1024*1024) n = 1024*1024; String out(n, 0); int fd=open("/dev/urandom", O_RDONLY); if(fd<0) return(""); size_t off=0; while(off args, Span results) -> Result { String in; self->hostcall_read(args[0].i32(), args[1].i32(), in); bool ok=false; String out=base64_decode(in, ok); if(!ok) out=""; u32 cap=(u32)args[3].i32(); int32_t buf=args[2].i32(); if(buf&&cap>=out.size()) self->hostcall_write(buf,out); results[0]=Val((int32_t)out.size()); return(std::monostate()); })); if(mod == "env" && name == "uce_host_crypto_equal") return(add([self](Caller, Span args, Span results) -> Result { String a,b; self->hostcall_read(args[0].i32(), args[1].i32(), a); self->hostcall_read(args[2].i32(), args[3].i32(), b); results[0]=Val((int32_t)(crypto_equal_native(a,b)?1:0)); return(std::monostate()); })); + if(mod == "env" && name == "uce_host_password_hash") + return(add([self](Caller caller, Span args, Span results) -> Result { String password; self->hostcall_read(args[0].i32(), args[1].i32(), password); String out=password_hash_native(password); u32 cap=(u32)args[3].i32(); int32_t buf=args[2].i32(); if(buf&&cap>=out.size()) self->hostcall_write(buf,out); caller.context().set_epoch_deadline(self->worker.cfg.epoch_deadline_ticks); results[0]=Val((int32_t)out.size()); return(std::monostate()); })); + if(mod == "env" && name == "uce_host_password_verify") + return(add([self](Caller caller, Span args, Span results) -> Result { String password,encoded; self->hostcall_read(args[0].i32(), args[1].i32(), password); self->hostcall_read(args[2].i32(), args[3].i32(), encoded); bool valid=password_verify_native(password,encoded); caller.context().set_epoch_deadline(self->worker.cfg.epoch_deadline_ticks); results[0]=Val((int32_t)(valid?1:0)); return(std::monostate()); })); + if(mod == "env" && name == "uce_host_password_needs_rehash") + return(add([self](Caller, Span args, Span results) -> Result { String encoded; self->hostcall_read(args[0].i32(), args[1].i32(), encoded); results[0]=Val((int32_t)(password_needs_rehash_native(encoded)?1:0)); return(std::monostate()); })); if(mod == "env" && name == "uce_host_log") return(add([self](Caller, Span args, Span) -> Result { String text;