From 3d155203bd9b4c15bc5f9fe5e83c01ec078fbd0f Mon Sep 17 00:00:00 2001 From: udo Date: Wed, 22 Jul 2026 13:43:21 +0000 Subject: [PATCH] Add bounded structured crypto operations --- docs/setup.md | 14 ++ scripts/api_coverage_manifest.py | 1 + scripts/run_cli_tests.sh | 1 + scripts/test_crypto_operation_native.sh | 103 ++++++++ site/doc/areas/noise.txt | 1 + site/doc/pages/crypto_operation.txt | 28 +++ site/tests/crypto_operation.uce | 61 +++++ site/tests/manifest.txt | 1 + src/lib/hash.cpp | 303 ++++++++++++++++++++++++ src/lib/hash.h | 4 + src/lib/sys.cpp | 21 ++ src/lib/sys.h | 1 + src/wasm/core.cpp | 2 + src/wasm/core_hostcalls.syms | 1 + src/wasm/worker.cpp | 22 ++ 15 files changed, 564 insertions(+) create mode 100755 scripts/test_crypto_operation_native.sh create mode 100644 site/doc/pages/crypto_operation.txt create mode 100644 site/tests/crypto_operation.uce diff --git a/docs/setup.md b/docs/setup.md index e728d2f..83712b8 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -745,6 +745,20 @@ if(valid && password_needs_rehash(encoded)) `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. +## Structured cryptographic operations + +`crypto_operation(request)` is the bounded, algorithm-selected API for structured +asymmetric operations. The initial allowlist supports `operation=key_generate` +and `operation=jwt_sign` with `algorithm=ES256`. Key generation returns P-256 +public/private JWKs plus the RFC 7638 thumbprint (`kid`). JWT signing accepts only +a consistent P-256 private JWK, forces `alg=ES256`, and emits a compact JWT with +a 64-byte JOSE signature. Unknown operations and algorithms fail closed. + +Existing typed digest, HMAC, password, randomness, and constant-time comparison +functions remain separate. `crypto_operation()` exposes no raw signing, arbitrary +digest/curve selection, encryption, network fetch, or token exchange. Store +private JWKs as secrets. + ## 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/api_coverage_manifest.py b/scripts/api_coverage_manifest.py index a4e73a1..755eabf 100755 --- a/scripts/api_coverage_manifest.py +++ b/scripts/api_coverage_manifest.py @@ -39,6 +39,7 @@ PUBLIC_APIS = [ ("hmac_sha256", True, "public"), ("hmac_sha256_hex", True, "public"), ("random_bytes", True, "public"), ("crypto_equal", True, "public"), ("password_hash", True, "public"), ("password_verify", True, "public"), ("password_needs_rehash", True, "public"), + ("crypto_operation", True, "public"), ("gen_noise32", True, "public"), ("gen_noise64", True, "public"), ("gen_noise01", True, "public"), ("gen_int", True, "public"), ("gen_float", True, "public"), ("draw_int", True, "public"), ("draw_float", True, "public"), diff --git a/scripts/run_cli_tests.sh b/scripts/run_cli_tests.sh index c73688b..f2f142e 100755 --- a/scripts/run_cli_tests.sh +++ b/scripts/run_cli_tests.sh @@ -77,6 +77,7 @@ if [[ "$action" == "run" ]]; then scripts/test_component_once_prefetch.sh scripts/test_relative_component_cache.sh scripts/test_password_hashing.sh + scripts/test_crypto_operation_native.sh scripts/test_mysql_epoch_refresh.sh scripts/test_mysql_persistent_pool.sh scripts/test_mysql_persistent_pool_idle.sh diff --git a/scripts/test_crypto_operation_native.sh b/scripts/test_crypto_operation_native.sh new file mode 100755 index 0000000..f326cb2 --- /dev/null +++ b/scripts/test_crypto_operation_native.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +test_source="/tmp/uce-oauth-es256-native-$$.cpp" +test_binary="/tmp/uce-oauth-es256-native-$$" +cleanup() { rm -f "$test_source" "$test_binary"; } +trap cleanup EXIT + +cat >"$test_source" <<'EOF' +#include "src/lib/types.cpp" +#include "src/lib/dvalue.cpp" +#include "src/lib/functionlib.cpp" +#include +#include +#include +#include +#include +#include + +String base64_encode(String raw) +{ + if(raw.empty()) return(""); + String out(4 * ((raw.size() + 2) / 3), 0); + int size = EVP_EncodeBlock((unsigned char*)out.data(), (const unsigned char*)raw.data(), (int)raw.size()); + out.resize(size > 0 ? (size_t)size : 0); + return(out); +} + +String base64_decode(String raw, bool& ok) +{ + ok = false; + if(raw.empty() || raw.size() % 4) return(""); + String out(3 * raw.size() / 4, 0); + int size = EVP_DecodeBlock((unsigned char*)out.data(), (const unsigned char*)raw.data(), (int)raw.size()); + if(size < 0) return(""); + while(!raw.empty() && raw.back() == '=') { size--; raw.pop_back(); } + out.resize((size_t)size); ok = true; return(out); +} + +#include "src/lib/hash.cpp" + +static String b64url_decode(String text) +{ + text = replace(replace(text, "-", "+"), "_", "/"); + while(text.size() % 4) text += "="; + bool ok = false; + String result = base64_decode(text, ok); + return(ok ? result : String("")); +} + +static bool verify(DValue public_jwk, String jwt) +{ + StringList parts = split(jwt, "."); + if(parts.size() != 3) return(false); + String x = b64url_decode(public_jwk["x"].to_string()); + String y = b64url_decode(public_jwk["y"].to_string()); + String raw = b64url_decode(parts[2]); + if(x.size() != 32 || y.size() != 32 || raw.size() != 64) return(false); + unsigned char point[65] = {4}; memcpy(point + 1, x.data(), 32); memcpy(point + 33, y.data(), 32); + OSSL_PARAM params[] = { OSSL_PARAM_construct_utf8_string(OSSL_PKEY_PARAM_GROUP_NAME, (char*)"prime256v1", 0), OSSL_PARAM_construct_octet_string(OSSL_PKEY_PARAM_PUB_KEY, point, sizeof(point)), OSSL_PARAM_construct_end() }; + EVP_PKEY_CTX* build = EVP_PKEY_CTX_new_from_name(0, "EC", 0); EVP_PKEY* key = 0; + if(!build || EVP_PKEY_fromdata_init(build) <= 0 || EVP_PKEY_fromdata(build, &key, EVP_PKEY_PUBLIC_KEY, params) <= 0) { EVP_PKEY_CTX_free(build); return(false); } + EVP_PKEY_CTX_free(build); + BIGNUM* r = BN_bin2bn((const unsigned char*)raw.data(), 32, 0); BIGNUM* s = BN_bin2bn((const unsigned char*)raw.data() + 32, 32, 0); + ECDSA_SIG* sig = ECDSA_SIG_new(); int der_size = r && s && sig && ECDSA_SIG_set0(sig, r, s) ? i2d_ECDSA_SIG(sig, 0) : 0; r = s = 0; + String der(der_size > 0 ? (size_t)der_size : 0, 0); unsigned char* out = (unsigned char*)der.data(); + bool ok = der_size > 0 && i2d_ECDSA_SIG(sig, &out) == der_size; + EVP_MD_CTX* verify_ctx = EVP_MD_CTX_new(); + String signing_input = parts[0] + "." + parts[1]; + ok = ok && verify_ctx && EVP_DigestVerifyInit(verify_ctx, 0, EVP_sha256(), 0, key) > 0 && EVP_DigestVerify(verify_ctx, (const unsigned char*)der.data(), der.size(), (const unsigned char*)signing_input.data(), signing_input.size()) == 1; + EVP_MD_CTX_free(verify_ctx); ECDSA_SIG_free(sig); EVP_PKEY_free(key); return(ok); +} + +int main() +{ + DValue key_request; key_request["operation"] = "key_generate"; key_request["algorithm"] = "ES256"; + DValue key = crypto_operation_native(key_request); + DValue header; header["alg"] = "none"; header["kid"] = key["kid"]; DValue claims; claims["iss"] = "https://client.example"; + auto sign = [&](DValue private_jwk) { DValue request; request["operation"] = "jwt_sign"; request["algorithm"] = "ES256"; request["private_jwk"] = private_jwk; request["protected_header"] = header; request["claims"] = claims; return(crypto_operation_native(request)); }; + DValue signed_result = sign(key["private_jwk"]); String jwt = signed_result["jwt"].to_string(); + DValue wrong_curve = key["private_jwk"]; wrong_curve["crv"] = "P-384"; + DValue malformed = key["private_jwk"]; malformed["x"] = "bad="; + DValue mismatch = key["private_jwk"]; String d = mismatch["d"].to_string(); d[0] = d[0] == 'A' ? 'B' : 'A'; mismatch["d"] = d; + DValue unsupported; unsupported["operation"] = "encrypt"; unsupported["algorithm"] = "ES256"; + DValue unknown_algorithm; unknown_algorithm["operation"] = "key_generate"; unknown_algorithm["algorithm"] = "none"; + DValue untyped_algorithm = key_request; untyped_algorithm["algorithm"] = (f64)256; + DValue list_header; list_header.set_array(); DValue list_item; list_item = "not-an-object"; list_header.push(list_item); DValue list_request; list_request["operation"] = "jwt_sign"; list_request["algorithm"] = "ES256"; list_request["private_jwk"] = key["private_jwk"]; list_request["protected_header"] = list_header; list_request["claims"] = claims; + DValue control_request = list_request; control_request["protected_header"] = header; control_request["claims"] = claims; control_request["claims"]["bad"] = String("control\nbyte"); + DValue oversized = key_request; oversized["ignored"] = String(17000, 'x'); + DValue nonfinite = key_request; nonfinite["ignored"] = std::numeric_limits::quiet_NaN(); + String tampered = jwt; if(!tampered.empty()) tampered[tampered.size() - 1] = tampered.back() == 'A' ? 'B' : 'A'; + bool kid_ok = key["ok"].to_bool() && key["kid"].to_string() == key["thumbprint"].to_string(); + bool signed_ok = signed_result["ok"].to_bool() && jwt != "" && verify(key["public_jwk"], jwt); + bool tamper_ok = !verify(key["public_jwk"], tampered); + bool negatives_ok = !sign(wrong_curve)["ok"].to_bool() && !sign(malformed)["ok"].to_bool() && !sign(mismatch)["ok"].to_bool() && crypto_operation_native(unsupported)["error"].to_string() == "unsupported_operation" && crypto_operation_native(unknown_algorithm)["error"].to_string() == "unsupported_algorithm" && crypto_operation_native(untyped_algorithm)["error"].to_string() == "invalid_request" && crypto_operation_native(list_request)["error"].to_string() == "invalid_key_or_payload" && crypto_operation_native(control_request)["error"].to_string() == "invalid_request" && crypto_operation_native(oversized)["error"].to_string() == "invalid_request" && crypto_operation_native(nonfinite)["error"].to_string() == "invalid_request"; + if(!(kid_ok && signed_ok && tamper_ok && negatives_ok)) std::cerr << "kid=" << kid_ok << " signed=" << signed_ok << " tamper=" << tamper_ok << " negatives=" << negatives_ok << " jwt_size=" << jwt.size() << "\\n"; + return(kid_ok && signed_ok && tamper_ok && negatives_ok ? 0 : 1); +} +EOF +clang++ -std=c++20 -fpermissive -I. "$test_source" -lpcre2-8 -lcrypto -o "$test_binary" +"$test_binary" +echo "native structured crypto operation passed" diff --git a/site/doc/areas/noise.txt b/site/doc/areas/noise.txt index 081448f..bf7293b 100644 --- a/site/doc/areas/noise.txt +++ b/site/doc/areas/noise.txt @@ -14,6 +14,7 @@ hmac_sha256 hmac_sha256_hex random_bytes crypto_equal +crypto_operation password_hash password_verify password_needs_rehash diff --git a/site/doc/pages/crypto_operation.txt b/site/doc/pages/crypto_operation.txt new file mode 100644 index 0000000..f3e6a38 --- /dev/null +++ b/site/doc/pages/crypto_operation.txt @@ -0,0 +1,28 @@ +:sig +DValue crypto_operation(DValue request) + +:params +request : structured operation, algorithm, and operation-specific fields +return value : map with ok, bounded error code, and operation-specific output + +:content +Runs one explicitly supported structured asymmetric cryptographic operation. The initial allowlist is `key_generate` with `ES256` and `jwt_sign` with `ES256`. Unknown operations and algorithms fail closed. + +ES256 signing validates that `x`, `y`, and `d` form one P-256 key, forces the protected `alg` to `ES256`, and emits a compact JWT with a 64-byte JOSE signature. Requests, nesting, values, and output are bounded. Header and claims roots must be JSON objects containing valid UTF-8 without raw control bytes. + +This function does not replace typed digest, HMAC, password, randomness, or constant-time comparison APIs. It exposes no raw signing, arbitrary curve/digest selection, encryption, or generic OpenSSL access. Keep returned private JWKs secret. + +:example +DValue key_request; +key_request["operation"] = "key_generate"; +key_request["algorithm"] = "ES256"; +DValue key = crypto_operation(key_request); + +DValue sign_request; +sign_request["operation"] = "jwt_sign"; +sign_request["algorithm"] = "ES256"; +sign_request["private_jwk"] = key["private_jwk"]; +sign_request["protected_header"]["typ"] = "JWT"; +sign_request["claims"]["iss"] = "https://client.example"; +DValue signed_jwt = crypto_operation(sign_request); +print(signed_jwt["ok"].to_bool() ? "signed" : "failed", "\n"); diff --git a/site/tests/crypto_operation.uce b/site/tests/crypto_operation.uce new file mode 100644 index 0000000..485e923 --- /dev/null +++ b/site/tests/crypto_operation.uce @@ -0,0 +1,61 @@ +#include "testlib.h" + +RENDER(Request& context) +{ + u64 passed = 0; + u64 failed = 0; + u64 skipped = 0; + auto check = [&](String name, bool ok, String detail) + { + site_tests_case(name, ok ? "pass" : "fail", detail); + if(ok) passed++; else failed++; + }; + site_tests_page_start("Structured crypto operations", "Algorithm-selected P-256 JWK creation and ES256 JWT signing."); + DValue key_request; + key_request["operation"] = "key_generate"; + key_request["algorithm"] = "ES256"; + DValue key = crypto_operation(key_request); + DValue header; + header["alg"] = "none"; + header["kid"] = key["kid"]; + DValue claims; + claims["iss"] = "https://client.example"; + claims["sub"] = "client.example"; + DValue sign_request; + sign_request["operation"] = "jwt_sign"; + sign_request["algorithm"] = "ES256"; + sign_request["private_jwk"] = key["private_jwk"]; + sign_request["protected_header"] = header; + sign_request["claims"] = claims; + DValue signed_result = crypto_operation(sign_request); + String jwt = signed_result["jwt"].to_string(); + StringList parts = split(jwt, "."); + String header_b64 = parts.size() == 3 ? parts[0] : ""; + while(header_b64.size() % 4) header_b64 += "="; + String decoded_header = base64_decode(header_b64); + DValue wrong_curve = key["private_jwk"]; + wrong_curve["crv"] = "P-384"; + DValue malformed = key["private_jwk"]; + malformed["x"] = "not_base64url="; + DValue wrong_request = sign_request; + wrong_request["private_jwk"] = wrong_curve; + DValue malformed_request = sign_request; + malformed_request["private_jwk"] = malformed; + DValue unsupported; + unsupported["operation"] = "encrypt"; + unsupported["algorithm"] = "ES256"; + DValue list_header; + list_header.set_array(); + DValue list_item; + list_item = "not-an-object"; + list_header.push(list_item); + DValue list_request = sign_request; + list_request["protected_header"] = list_header; + DValue control_request = sign_request; + control_request["claims"]["bad"] = String("control\nbyte"); + DValue oversized = key_request; + oversized["ignored"] = String(17000, 'x'); + check("crypto_operation() ES256 key generation and JWT signing", key["ok"].to_bool() && signed_result["ok"].to_bool() && key["private_jwk"]["kty"].to_string() == "EC" && key["private_jwk"]["crv"].to_string() == "P-256" && key["public_jwk"]["d"].to_string() == "" && key["kid"].to_string() == key["thumbprint"].to_string() && parts.size() == 3 && parts[2].size() == 86 && decoded_header.find("ES256") != String::npos && !crypto_operation(wrong_request)["ok"].to_bool() && !crypto_operation(malformed_request)["ok"].to_bool() && crypto_operation(unsupported)["error"].to_string() == "unsupported_operation" && !crypto_operation(list_request)["ok"].to_bool() && crypto_operation(control_request)["error"].to_string() == "invalid_request" && crypto_operation(oversized)["error"].to_string() == "invalid_request", key["kid"].to_string()); + site_tests_summary(passed, failed, skipped, "Structured crypto tests generate ephemeral P-256 keys and retain no key material."); + site_tests_page_end(); +} diff --git a/site/tests/manifest.txt b/site/tests/manifest.txt index 028c595..3a10767 100644 --- a/site/tests/manifest.txt +++ b/site/tests/manifest.txt @@ -8,6 +8,7 @@ markdown.uce|Markdown|Markdown parsing, rendering, and component hooks.|http sui units.uce|Units|unit_call(), lifecycle hooks, and unit metadata.|http suite uce public|Units|1|1 websockets.ws.uce|WebSockets|Browser-driven WebSocket helper checks.|http suite uce public websocket|WebSockets|1|1 io.uce|Filesystem|Filesystem helpers that are restricted outside trusted networks.|http suite uce internal|Filesystem|1|1 +crypto_operation.uce|Structured Crypto|Algorithm-selected key generation and JWT signing coverage.|http suite uce public crypto|Structured crypto operations|1|1 sqlite.uce|SQLite|SQLite connector with prepared named parameters and DValue rows.|http suite uce internal sqlite|SQLite|1|1 zip.uce|ZIP|Archive helpers that create and extract temporary server-side files.|http suite uce internal|ZIP|1|1 services.uce|Sockets And Services|Network/service helpers that are restricted outside trusted networks.|http suite uce internal|Sockets And Services|1|1 diff --git a/src/lib/hash.cpp b/src/lib/hash.cpp index b9519c5..81e6a4b 100644 --- a/src/lib/hash.cpp +++ b/src/lib/hash.cpp @@ -41,6 +41,7 @@ void SHA1Final(unsigned char digest[20], SHA1_CTX* context); #include #include /* for u_int*_t */ #include "hash.h" +#include "uri.h" #ifndef BYTE_ORDER #if (BSD >= 199103) @@ -422,8 +423,63 @@ bool crypto_equal_native(String a, String b) return(diff == 0); } +static bool uce_crypto_utf8_json_string(String value) +{ + for(size_t i = 0; i < value.size();) + { + u8 c = (u8)value[i]; + if(c < 0x20) return(false); + if(c < 0x80) { i++; continue; } + size_t need = c >= 0xC2 && c <= 0xDF ? 1 : (c >= 0xE0 && c <= 0xEF ? 2 : (c >= 0xF0 && c <= 0xF4 ? 3 : 0)); + if(need == 0 || i + need >= value.size()) return(false); + for(size_t j = 1; j <= need; j++) if(((u8)value[i + j] & 0xC0) != 0x80) return(false); + u8 second = (u8)value[i + 1]; + if((c == 0xE0 && second < 0xA0) || (c == 0xED && second >= 0xA0) || (c == 0xF0 && second < 0x90) || (c == 0xF4 && second >= 0x90)) return(false); + i += need + 1; + } + return(true); +} + +static bool uce_crypto_value_valid(const DValue& value, size_t depth, size_t& nodes, size_t& bytes) +{ + if(depth > 16 || ++nodes > 256) return(false); + const DValue& item = value.deref(); + if(item.type == 'S') + { + bytes += item._String.size(); + return(bytes <= 16384 && uce_crypto_utf8_json_string(item._String)); + } + if(item.type == 'F') return(std::isfinite(item._float)); + if(item.type == 'B') return(true); + if(item.type != 'M') return(false); + bool list = item.is_list(); + for(const auto& child : item._map) + { + if(!list) + { + bytes += child.first.size(); + if(bytes > 16384 || !uce_crypto_utf8_json_string(child.first)) return(false); + } + if(!uce_crypto_value_valid(child.second, depth + 1, nodes, bytes)) return(false); + } + return(true); +} + +bool crypto_operation_request_valid(DValue request) +{ + const DValue& root = request.deref(); + if(root.type != 'M' || root.is_list()) return(false); + size_t nodes = 0, bytes = 0; + return(uce_crypto_value_valid(root, 0, nodes, bytes)); +} + #ifndef __UCE_WASM_CORE__ +#include +#include +#include #include +#include +#include #include namespace { @@ -525,4 +581,251 @@ bool password_needs_rehash_native(String encoded) 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); } + +namespace { +static constexpr size_t UCE_ES256_COORDINATE_BYTES = 32; +static constexpr size_t UCE_ES256_SIGNATURE_BYTES = 64; +static constexpr size_t UCE_ES256_JSON_MAX = 16 * 1024; +static constexpr size_t UCE_ES256_VALUE_MAX = 256; +static constexpr size_t UCE_ES256_DEPTH_MAX = 16; + +struct UcePkeyDeleter { void operator()(EVP_PKEY* value) const { EVP_PKEY_free(value); } }; +struct UcePkeyCtxDeleter { void operator()(EVP_PKEY_CTX* value) const { EVP_PKEY_CTX_free(value); } }; +struct UceEcdsaSigDeleter { void operator()(ECDSA_SIG* value) const { ECDSA_SIG_free(value); } }; +struct UceBnDeleter { void operator()(BIGNUM* value) const { BN_clear_free(value); } }; +struct UceParamBldDeleter { void operator()(OSSL_PARAM_BLD* value) const { OSSL_PARAM_BLD_free(value); } }; +struct UceParamsDeleter { void operator()(OSSL_PARAM* value) const { OSSL_PARAM_free(value); } }; + +static String uce_base64url_encode(const unsigned char* bytes, size_t size) +{ + String encoded = base64_encode(String((const char*)bytes, size)); + encoded = replace(replace(encoded, "+", "-"), "/", "_"); + while(!encoded.empty() && encoded.back() == '=') encoded.pop_back(); + return(encoded); +} + +static bool uce_base64url_decode(String encoded, String& decoded) +{ + if(encoded.empty() || encoded.size() > 128 || encoded.find('=') != String::npos || encoded.size() % 4 == 1) + return(false); + for(char c : encoded) + if(!(c >= 'A' && c <= 'Z') && !(c >= 'a' && c <= 'z') && !(c >= '0' && c <= '9') && c != '-' && c != '_') + return(false); + String padded = replace(replace(encoded, "-", "+"), "_", "/"); + while(padded.size() % 4) padded += "="; + bool ok = false; + decoded = base64_decode(padded, ok); + return(ok && uce_base64url_encode((const unsigned char*)decoded.data(), decoded.size()) == encoded); +} + +static bool uce_es256_json_value(const DValue& value, size_t depth, size_t& values, size_t& bytes) +{ + if(depth > UCE_ES256_DEPTH_MAX || ++values > UCE_ES256_VALUE_MAX) + return(false); + const DValue& item = value.deref(); + if(item.type == 'S') + return((bytes += item._String.size()) <= UCE_ES256_JSON_MAX); + if(item.type == 'F' || item.type == 'B') + return(true); + if(item.type != 'M') + return(false); + for(const auto& child : item._map) + { + if((bytes += child.first.size()) > UCE_ES256_JSON_MAX || !uce_es256_json_value(child.second, depth + 1, values, bytes)) + return(false); + } + return(true); +} + +static bool uce_es256_json_map(const DValue& value) +{ + if(value.deref().type != 'M' || value.deref().is_list()) + return(false); + size_t values = 0, bytes = 0; + return(uce_es256_json_value(value, 0, values, bytes)); +} + +static bool uce_es256_jwk_string(const DValue& jwk, const String& field, String& value) +{ + const DValue* found = jwk.key(field); + if(!found) + return(false); + const DValue& item = found->deref(); + if(item.type != 'S' || item._String.empty() || item._String.size() > 128) + return(false); + value = item._String; + return(true); +} + +static std::unique_ptr uce_es256_key_from_jwk(const DValue& jwk) +{ + if(jwk.deref().type != 'M') + return(nullptr); + String kty, crv, x64, y64, d64, x, y, d; + if(!uce_es256_jwk_string(jwk, "kty", kty) || !uce_es256_jwk_string(jwk, "crv", crv) || !uce_es256_jwk_string(jwk, "x", x64) || !uce_es256_jwk_string(jwk, "y", y64) || !uce_es256_jwk_string(jwk, "d", d64) || + kty != "EC" || crv != "P-256" || !uce_base64url_decode(x64, x) || !uce_base64url_decode(y64, y) || !uce_base64url_decode(d64, d) || + x.size() != UCE_ES256_COORDINATE_BYTES || y.size() != UCE_ES256_COORDINATE_BYTES || d.size() != UCE_ES256_COORDINATE_BYTES) + return(nullptr); + unsigned char public_key[65]; + public_key[0] = 4; + memcpy(public_key + 1, x.data(), x.size()); + memcpy(public_key + 33, y.data(), y.size()); + std::unique_ptr private_bn(BN_bin2bn((const unsigned char*)d.data(), d.size(), 0)); + std::unique_ptr builder(OSSL_PARAM_BLD_new()); + if(!private_bn || !builder || OSSL_PARAM_BLD_push_utf8_string(builder.get(), OSSL_PKEY_PARAM_GROUP_NAME, "prime256v1", 0) <= 0 || + OSSL_PARAM_BLD_push_octet_string(builder.get(), OSSL_PKEY_PARAM_PUB_KEY, public_key, sizeof(public_key)) <= 0 || + OSSL_PARAM_BLD_push_BN(builder.get(), OSSL_PKEY_PARAM_PRIV_KEY, private_bn.get()) <= 0) + return(nullptr); + std::unique_ptr params(OSSL_PARAM_BLD_to_param(builder.get())); + std::unique_ptr ctx(EVP_PKEY_CTX_new_from_name(0, "EC", 0)); + EVP_PKEY* raw = 0; + if(!params || !ctx || EVP_PKEY_fromdata_init(ctx.get()) <= 0 || EVP_PKEY_fromdata(ctx.get(), &raw, EVP_PKEY_KEYPAIR, params.get()) <= 0) + return(nullptr); + std::unique_ptr key(raw); + std::unique_ptr check(EVP_PKEY_CTX_new(key.get(), 0)); + return(check && EVP_PKEY_public_check(check.get()) > 0 && EVP_PKEY_private_check(check.get()) > 0 && EVP_PKEY_pairwise_check(check.get()) > 0 ? std::move(key) : nullptr); +} + +static bool uce_es256_key_coordinates(EVP_PKEY* key, String& x, String& y, String& d) +{ + unsigned char public_key[65]; size_t public_key_size = sizeof(public_key); + BIGNUM* private_bn = 0; + if(EVP_PKEY_get_octet_string_param(key, OSSL_PKEY_PARAM_PUB_KEY, public_key, sizeof(public_key), &public_key_size) <= 0 || public_key_size != sizeof(public_key) || public_key[0] != 4 || + EVP_PKEY_get_bn_param(key, OSSL_PKEY_PARAM_PRIV_KEY, &private_bn) <= 0) + return(false); + std::unique_ptr private_key(private_bn); + if(BN_bn2binpad(private_key.get(), (unsigned char*)d.data(), UCE_ES256_COORDINATE_BYTES) != UCE_ES256_COORDINATE_BYTES) + return(false); + x.assign((const char*)public_key + 1, UCE_ES256_COORDINATE_BYTES); + y.assign((const char*)public_key + 33, UCE_ES256_COORDINATE_BYTES); + return(true); +} + +static DValue uce_es256_jwk(String x, String y, String d = "") +{ + DValue jwk; + jwk["kty"] = "EC"; + jwk["crv"] = "P-256"; + jwk["x"] = uce_base64url_encode((const unsigned char*)x.data(), x.size()); + jwk["y"] = uce_base64url_encode((const unsigned char*)y.data(), y.size()); + if(d != "") jwk["d"] = uce_base64url_encode((const unsigned char*)d.data(), d.size()); + return(jwk); +} + +static String uce_es256_thumbprint(const DValue& public_jwk) +{ + const DValue* x = public_jwk.key("x"); + const DValue* y = public_jwk.key("y"); + if(!x || !y) + return(""); + String canonical = "{\"crv\":\"P-256\",\"kty\":\"EC\",\"x\":\"" + x->to_string() + "\",\"y\":\"" + y->to_string() + "\"}"; + String digest = sha256_native(canonical); + return(uce_base64url_encode((const unsigned char*)digest.data(), digest.size())); +} +} + +static DValue uce_es256_key_create() +{ + std::unique_ptr ctx(EVP_PKEY_CTX_new_from_name(0, "EC", 0)); + EVP_PKEY* raw = 0; + OSSL_PARAM params[] = { OSSL_PARAM_construct_utf8_string(OSSL_PKEY_PARAM_GROUP_NAME, (char*)"prime256v1", 0), OSSL_PARAM_construct_end() }; + if(!ctx || EVP_PKEY_keygen_init(ctx.get()) <= 0 || EVP_PKEY_CTX_set_params(ctx.get(), params) <= 0 || EVP_PKEY_generate(ctx.get(), &raw) <= 0) + return(DValue()); + std::unique_ptr key(raw); + String x(UCE_ES256_COORDINATE_BYTES, 0), y(UCE_ES256_COORDINATE_BYTES, 0), d(UCE_ES256_COORDINATE_BYTES, 0); + if(!uce_es256_key_coordinates(key.get(), x, y, d)) + return(DValue()); + DValue result; + result["public_jwk"] = uce_es256_jwk(x, y); + String kid = uce_es256_thumbprint(result["public_jwk"]); + result["public_jwk"]["kid"] = kid; + result["private_jwk"] = uce_es256_jwk(x, y, d); + result["private_jwk"]["kid"] = kid; + result["kid"] = kid; + result["thumbprint"] = kid; + return(result); +} + +static String uce_es256_jwt(DValue private_jwk, DValue protected_header, DValue claims) +{ + if(!uce_es256_json_map(protected_header) || !uce_es256_json_map(claims)) + return(""); + std::unique_ptr key = uce_es256_key_from_jwk(private_jwk); + if(!key) + return(""); + protected_header["alg"] = "ES256"; + String header_json = json_encode(protected_header); + String claims_json = json_encode(claims); + if(header_json.size() > UCE_ES256_JSON_MAX || claims_json.size() > UCE_ES256_JSON_MAX) + return(""); + String signing_input = uce_base64url_encode((const unsigned char*)header_json.data(), header_json.size()) + "." + uce_base64url_encode((const unsigned char*)claims_json.data(), claims_json.size()); + std::unique_ptr ctx(EVP_MD_CTX_new(), EVP_MD_CTX_free); + size_t der_size = 0; + if(!ctx || EVP_DigestSignInit(ctx.get(), 0, EVP_sha256(), 0, key.get()) <= 0 || EVP_DigestSign(ctx.get(), 0, &der_size, (const unsigned char*)signing_input.data(), signing_input.size()) <= 0 || der_size == 0 || der_size > 256) + return(""); + String der(der_size, 0); + if(EVP_DigestSign(ctx.get(), (unsigned char*)der.data(), &der_size, (const unsigned char*)signing_input.data(), signing_input.size()) <= 0) + return(""); + const unsigned char* cursor = (const unsigned char*)der.data(); + std::unique_ptr signature(d2i_ECDSA_SIG(0, &cursor, der_size)); + const BIGNUM *r = 0, *s = 0; + if(!signature || cursor != (const unsigned char*)der.data() + der_size) + return(""); + ECDSA_SIG_get0(signature.get(), &r, &s); + String jose(UCE_ES256_SIGNATURE_BYTES, 0); + if(!r || !s || BN_bn2binpad(r, (unsigned char*)jose.data(), UCE_ES256_COORDINATE_BYTES) != UCE_ES256_COORDINATE_BYTES || BN_bn2binpad(s, (unsigned char*)jose.data() + UCE_ES256_COORDINATE_BYTES, UCE_ES256_COORDINATE_BYTES) != UCE_ES256_COORDINATE_BYTES) + return(""); + return(signing_input + "." + uce_base64url_encode((const unsigned char*)jose.data(), jose.size())); +} + +DValue crypto_operation_native(DValue request) +{ + DValue result; + result["ok"].set_bool(false); + if(!crypto_operation_request_valid(request)) + { + result["error"] = "invalid_request"; + return(result); + } + String operation, algorithm; + if(!uce_es256_jwk_string(request, "operation", operation) || !uce_es256_jwk_string(request, "algorithm", algorithm)) + { + result["error"] = "invalid_request"; + return(result); + } + if(algorithm != "ES256") + { + result["error"] = "unsupported_algorithm"; + return(result); + } + if(operation == "key_generate") + { + DValue key = uce_es256_key_create(); + if(key["private_jwk"]["d"].to_string() == "") + { + result["error"] = "operation_failed"; + return(result); + } + key["ok"].set_bool(true); + key["operation"] = operation; + key["algorithm"] = algorithm; + return(key); + } + if(operation == "jwt_sign") + { + String jwt = uce_es256_jwt(request["private_jwk"], request["protected_header"], request["claims"]); + if(jwt == "") + { + result["error"] = "invalid_key_or_payload"; + return(result); + } + result["ok"].set_bool(true); + result["operation"] = operation; + result["algorithm"] = algorithm; + result["jwt"] = jwt; + return(result); + } + result["error"] = "unsupported_operation"; + return(result); +} #endif diff --git a/src/lib/hash.h b/src/lib/hash.h index abc61c8..57fef4a 100644 --- a/src/lib/hash.h +++ b/src/lib/hash.h @@ -1,5 +1,7 @@ #pragma once +struct DValue; + /* ================ sha1.h ================ */ /* SHA-1 in C @@ -27,6 +29,8 @@ 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); +bool crypto_operation_request_valid(DValue request); +DValue crypto_operation_native(DValue request); 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 a34ed21..e7ed4c7 100644 --- a/src/lib/sys.cpp +++ b/src/lib/sys.cpp @@ -64,6 +64,7 @@ int uce_host_crypto_equal(const char* a, size_t a_len, const char* b, size_t b_l 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_crypto_operation(const char* in, size_t in_len, char* out, size_t cap); 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); @@ -284,6 +285,25 @@ String password_hash(String password) } 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); } +DValue crypto_operation(DValue request) +{ + if(!crypto_operation_request_valid(request)) + { + DValue result; + result["ok"].set_bool(false); + result["error"] = "invalid_request"; + return(result); + } + String encoded = ucb_encode(request); + size_t required = uce_host_crypto_operation(encoded.data(), encoded.size(), 0, 0); + if(required == 0 || required > 64 * 1024) + return(DValue()); + String out(required, 0); + size_t got = uce_host_crypto_operation(encoded.data(), encoded.size(), &out[0], required); + DValue result; + String error; + return(got <= required && ucb_decode(String(out.data(), got), result, &error) ? result : DValue()); +} String base64_decode(String raw) { return(wasm_string_hostcall_1(uce_host_base64_decode, raw)); } String random_bytes(u64 n) { @@ -753,6 +773,7 @@ bool crypto_equal(String a, String b) { return(crypto_equal_native(a, b)); } String password_hash(String password) { return(password_hash_native(password)); } bool password_verify(String password, String encoded) { return(password_verify_native(password, encoded)); } bool password_needs_rehash(String encoded) { return(password_needs_rehash_native(encoded)); } +DValue crypto_operation(DValue request) { return(crypto_operation_native(request)); } // Single definitions for the native split build (declared extern in sys.h). pid_t parent_pid = 0; diff --git a/src/lib/sys.h b/src/lib/sys.h index f86f53d..31fc39b 100644 --- a/src/lib/sys.h +++ b/src/lib/sys.h @@ -51,6 +51,7 @@ bool crypto_equal(String a, String b); String password_hash(String password); bool password_verify(String password, String encoded); bool password_needs_rehash(String encoded); +DValue crypto_operation(DValue request); String shell_escape(String raw); String basename(String fn); String dirname(String fn); diff --git a/src/wasm/core.cpp b/src/wasm/core.cpp index 8e71255..ffa5513 100644 --- a/src/wasm/core.cpp +++ b/src/wasm/core.cpp @@ -461,6 +461,8 @@ extern "C" void uce_wasm_link_anchors() (void*)(double (*)(double))&cos, (void*)(double (*)(double))&sin, (void*)(double (*)(double))&round, + // structured crypto dispatcher is hostcall-backed and otherwise unreferenced by core + (void*)&crypto_operation, }; (void)libc_anchors; } diff --git a/src/wasm/core_hostcalls.syms b/src/wasm/core_hostcalls.syms index 5f51d8a..a699300 100644 --- a/src/wasm/core_hostcalls.syms +++ b/src/wasm/core_hostcalls.syms @@ -13,6 +13,7 @@ uce_host_crypto_equal uce_host_password_hash uce_host_password_verify uce_host_password_needs_rehash +uce_host_crypto_operation uce_host_task_spawn uce_host_task_pid uce_host_task_kill diff --git a/src/wasm/worker.cpp b/src/wasm/worker.cpp index e591758..11f58d2 100644 --- a/src/wasm/worker.cpp +++ b/src/wasm/worker.cpp @@ -3674,6 +3674,28 @@ private: return(add([self](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); 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_crypto_operation") + return(add([self](Caller, Span args, Span results) -> Result { + String encoded, out; + DValue request, response; + String error; + int32_t input_size = args[1].i32(); + u32 cap = (u32)args[3].i32(); + int32_t buf = args[2].i32(); + String stage_key = "crypto_operation"; + if(!self->hostcall_staged(stage_key, out)) + { + if(input_size > 0 && input_size <= 64 * 1024 && self->hostcall_read(args[0].i32(), input_size, encoded) == "" && ucb_decode(encoded, request, &error)) + response = crypto_operation_native(request); + else + response["error"] = "invalid_request"; + out = ucb_encode(response); + if(buf == 0) self->hostcall_stage(stage_key, out); + } + 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_log") return(add([self](Caller, Span args, Span) -> Result { String text;