diff --git a/CHANGELOG.md b/CHANGELOG.md index e74677e9..786cca53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] ### Added +- **`ed25519_verify`** checks an RFC 8032 Ed25519 signature. The public key is + 32 bytes as 64 hex characters, the signature is 64 bytes as 128 hex + characters, and the signed bytes are the UTF-8 encoding of the message text. + Valid signatures return `yes`; wrong keys or tampered messages return `no`; + malformed or unsupported encodings raise a generic error that does not echo + the inputs. Messages are capped at 1 MiB. Verification uses `ed25519-dalek`; + WFL does not implement the primitive or expose signing. Existing HMAC licence + keys are unchanged. - **`wfl init`** creates a simple `.wflcfg`, an `AGENTS.md` pointer, and a `CLAUDE.md` application guide in the current directory. The guide covers WFL syntax, CLI validation, LSP and MCP setup, Docker testing, and documentation diff --git a/Cargo.lock b/Cargo.lock index f01ceaef..781cef18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -560,6 +560,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -730,6 +736,33 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -794,6 +827,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + [[package]] name = "der-parser" version = "10.0.0" @@ -848,7 +891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -876,6 +919,30 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" @@ -972,6 +1039,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2202,6 +2275,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2956,6 +3039,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd_cesu8" version = "1.1.1" @@ -3027,6 +3119,16 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlx" version = "0.9.0" @@ -4009,6 +4111,7 @@ dependencies = [ "codespan-reporting", "criterion", "dhat", + "ed25519-dalek", "encoding_rs", "futures-util", "glob", diff --git a/Cargo.toml b/Cargo.toml index 5d9c4ac0..e5242241 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,6 +99,10 @@ bcrypt = "0.19" # a fresh random nonce per seal without a counter, so callers never touch nonces # and cannot reuse one. `zeroize` wipes the expanded key on drop. chacha20poly1305 = { version = "0.11.0", features = ["zeroize"] } +# Ed25519 (RFC 8032) verification for `ed25519_verify`. dalek is the maintained +# implementation; WFL does not ship its own Edwards arithmetic. Default features +# stay off so this crate only pulls verify/std — no signing API is exposed. +ed25519-dalek = { version = "2.2", default-features = false, features = ["std"] } # Force newer version to fix future incompatibility warning num-bigint-dig = "0.8.6" # Direct dep so the lib can expose `init_rustls_crypto_provider()` (called by diff --git a/Docs/01-introduction/key-features.md b/Docs/01-introduction/key-features.md index 3bbb8cf8..89e1e0cc 100644 --- a/Docs/01-introduction/key-features.md +++ b/Docs/01-introduction/key-features.md @@ -232,9 +232,12 @@ store integrity_tag as sha256 of wfl_digest // Standard MAC for external services store mac as hmac_sha256 of "message" and "secret key" + +// Public-key verification (RFC 8032 Ed25519) +store accepted as ed25519_verify of public_key and message and signature ``` -**Note:** WFLHASH is **experimental** and not externally audited. Please test it. For sensitive data (passwords especially), use **more than one hash** — e.g. WFLHASH then `sha256`, and for passwords always finish with `hash_password`. Use `sha256` / `hmac_sha256` alone for external interop. +**Note:** WFLHASH is **experimental** and not externally audited. Please test it. For sensitive data (passwords especially), use **more than one hash** — e.g. WFLHASH then `sha256`, and for passwords always finish with `hash_password`. Use `sha256` / `hmac_sha256` alone for external interop. Use `ed25519_verify` when the other party signs with Ed25519. ## 8. Developer-Friendly Tooling diff --git a/Docs/05-standard-library/crypto-module.md b/Docs/05-standard-library/crypto-module.md index 5193c9b2..2f37267c 100644 --- a/Docs/05-standard-library/crypto-module.md +++ b/Docs/05-standard-library/crypto-module.md @@ -5,6 +5,7 @@ The Crypto module provides cryptographic functions in four groups: - **Password hashing** — `hash_password`/`verify_password` and the algorithm-specific Argon2id, bcrypt, scrypt and PBKDF2 functions. Use these to store user passwords safely. - **Auth & session primitives** — `pbkdf2_hmac_sha256` (raw key derivation), `constant_time_equals` (timing-safe comparison), and `secure_random_bytes` (CSPRNG bytes for salts, tokens, and session IDs). - **Standard hashing/MAC** — `sha256` and `hmac_sha256` for interoperating with external services (webhook verification, API signing). +- **Public-key signatures** — `ed25519_verify` for RFC 8032 Ed25519 verification (activation and other offline signatures). WFL verifies only; it does not mint keys or sign. - **WFLHASH (experimental)** — WFL's own hash family. Please try it, report results, and help us harden it. For production integrity, **pair it with a known-good hash** so a battle-tested algorithm always has your back. > **Hashing a password? Use `hash_password`, never `sha256` or `wflhash256` alone.** Fast hashes are built to be quick, which is exactly what makes them a poor way to store passwords — an attacker can try billions of guesses per second. The password hashing functions below are deliberately slow and salted to prevent that. For sensitive material, also prefer **more than one hash** (see below). @@ -90,6 +91,7 @@ store standard_digest as sha256 of payload | Try / test / feedback on WFLHASH | `wflhash256` / `wflhash512` alone — please do | | Production integrity (files, caches, internal digests) | **Multi-hash:** WFLHASH then `sha256` (or another known-good hash) | | Interop with Stripe, GitHub, other APIs | `sha256` / `hmac_sha256` only (they will not speak WFLHASH) | +| Ed25519 signatures (activation, offline verify) | `ed25519_verify` — hex public key, UTF-8 message, hex signature | | Passwords (sensitive) | **Multi-hash pre-mix** (e.g. WFLHASH then `sha256`) **then** `hash_password` — never store fast hashes alone | | FIPS / regulatory validated crypto | Standard algorithms only (`sha256`, etc.) | @@ -105,7 +107,7 @@ store standard_digest as sha256 of payload - External protocols that require a specific standard algorithm - Environments that demand only FIPS-validated or formally audited primitives (use the standard alone) -**Interoperability still needs standards alone.** WFL's `sha256` and `hmac_sha256` builtins are required when talking to external services (e.g. Stripe or GitHub webhook signatures). A custom algorithm cannot stand in there. +**Interoperability still needs standards alone.** WFL's `sha256` and `hmac_sha256` builtins are required when talking to external services (e.g. Stripe or GitHub webhook signatures). A custom algorithm cannot stand in there. Use `ed25519_verify` when the other party produces an RFC 8032 Ed25519 signature. ## Password Hashing @@ -529,6 +531,54 @@ end check --- +### ed25519_verify + +**Purpose:** Verify an Ed25519 public-key signature (RFC 8032). This is the activation-prerequisite verifier: a site can later sign an offline payload with a private key and check it here with the matching public key. WFL does not generate keys or create signatures. + +**Signature:** +```wfl +ed25519_verify of and and +``` + +**Parameters:** +- `public_key` (Text): 32-byte Ed25519 public key as 64 hexadecimal characters +- `message` (Text): The exact signed payload. The verified bytes are this text's UTF-8 encoding — no extra prefix, suffix, or hash is applied +- `signature` (Text): 64-byte Ed25519 signature as 128 hexadecimal characters + +**Returns:** Boolean — `yes` only when the signature is valid for that public key and those message bytes + +**Encodings:** Hex digits `0-9` `a-f` `A-F` only. PEM, OpenSSH, Base64, `0x` prefixes, and whitespace are unsupported. + +**Limits:** Messages longer than 1,048,576 UTF-8 bytes are rejected. Public keys and signatures have fixed sizes. + +**Behavior:** +- Valid signature → `yes` +- Wrong key, tampered message, or invalid signature → `no` +- Truncated, non-hex, or unsupported encodings → runtime error naming `ed25519_verify` and the expected hex length. The error does not echo the key, message, or signature. + +**Example (RFC 8032 TEST 2):** +```wfl +store public_key as "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c" +store signature as "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00" +store accepted as ed25519_verify of public_key and "r" and signature + +check if accepted: + display "Signature is valid" +otherwise: + display "Signature is not valid" +end check +``` + +**Library:** Verification is implemented with [`ed25519-dalek`](https://crates.io/crates/ed25519-dalek) 2.x. WFL does not implement Edwards arithmetic itself. + +**Use Cases:** +- Future Logbie website / logger activation (offline public-key check) +- Verifying any RFC 8032 Ed25519 signature whose payload is UTF-8 text + +**Note:** Existing Logbie licence keys remain `LOGBIE--` and still use `hmac_sha256`. `ed25519_verify` does not replace that path. + +--- + ### pbkdf2_hmac_sha256 **Purpose:** Derive a key from a password using PBKDF2-HMAC-SHA256 with a caller-supplied salt, iteration count, and output length. Runs the iteration loop in native code, so the per-call cost is bounded and predictable. @@ -894,6 +944,7 @@ display "Unique items: " with unique_items ✅ **Use salts for domain separation:** `wflhash256_with_salt` keeps contexts apart ✅ **Use standard MACs for external auth:** `hmac_sha256` for webhooks and third-party APIs +✅ **Use `ed25519_verify` for Ed25519 signatures:** hex public key, UTF-8 message, hex signature ✅ **Keep keys secret:** Never expose keys in logs @@ -937,6 +988,7 @@ display "Unique items: " with unique_items **Production passwords:** multi-hash pre-mix recommended, then always `hash_password` (Argon2id) or the algorithm-specific helpers **Regulatory / FIPS-only paths:** `sha256` (or another validated standard) without depending on WFLHASH **External webhooks / API signing:** `hmac_sha256` +**Ed25519 public-key signatures:** `ed25519_verify` **Digital signatures / encryption:** Not provided by this module — use appropriate external tooling **WFLHASH is experimental: test it freely; for production integrity and sensitive data, bring a strong friend (`sha256` or another known-good hash) — and for passwords, finish with a password KDF.** @@ -949,6 +1001,7 @@ In this module, you learned: ✅ **hash_password / verify_password** - Required password KDF (Argon2id by default) ✅ **argon2 / bcrypt / scrypt / pbkdf2** - Algorithm-specific password hashing ✅ **sha256 / hmac_sha256** - Standard hashing and MAC for interoperability +✅ **ed25519_verify** - RFC 8032 Ed25519 public-key verification ✅ **wflhash256 / wflhash512** - Experimental WFLHASH (test it!) ✅ **Dual-hash production pattern** - WFLHASH then a known-good hash ✅ **wflhash256_with_salt** - Salted / domain-separated hashing diff --git a/Docs/05-standard-library/index.md b/Docs/05-standard-library/index.md index 1ef66f60..f6590423 100644 --- a/Docs/05-standard-library/index.md +++ b/Docs/05-standard-library/index.md @@ -74,6 +74,7 @@ WFL's standard library provides: - `hash_password` / `verify_password` - Safe password storage (Argon2id by default) - `argon2_hash`, `bcrypt_hash`, `scrypt_hash`, `pbkdf2_hash` (+ matching `*_verify`) - Password hashing - `sha256` / `hmac_sha256` - Standard hash and MAC +- `ed25519_verify` - RFC 8032 Ed25519 signature verification - `wflhash256` / `wflhash512` - Experimental WFLHASH (dual-hash with `sha256` for production) - `wflhash256_with_salt` - Experimental salted WFLHASH - `wflmac256` - Experimental WFL message authentication code @@ -222,6 +223,7 @@ Try every function interactively! ### Crypto Module - Password hashing: hash_password, verify_password, argon2/bcrypt/scrypt/pbkdf2 (_hash and _verify) - Standard: sha256, hmac_sha256 +- Public-key signatures: ed25519_verify - WFLHASH (experimental): wflhash256, wflhash512, wflhash256_with_salt, wflmac256 — dual-hash with sha256 for production - Tokens: generate_csrf_token diff --git a/Docs/05-standard-library/overview.md b/Docs/05-standard-library/overview.md index 266cc16e..c78341f9 100644 --- a/Docs/05-standard-library/overview.md +++ b/Docs/05-standard-library/overview.md @@ -163,6 +163,7 @@ store upper as touppercase of "text" - [Managed authentication](auth-module.md): session stores, rotation and revocation, request CSRF guards, secure cookies, and account attempt limits - Authenticated encryption: `seal`, `unseal` (XChaCha20-Poly1305) — for secrets you must read back - Standard hashing/MAC: `sha256`, `hmac_sha256` +- Public-key signatures: `ed25519_verify` (RFC 8032 Ed25519; verify only) - WFLHASH (experimental): `wflhash256`, `wflhash512`, `wflhash256_with_salt`, `wflmac256` — dual-hash with `sha256` for production ### Configuration (TOML) diff --git a/Docs/06-best-practices/security-guidelines.md b/Docs/06-best-practices/security-guidelines.md index 35b05931..625f4aa9 100644 --- a/Docs/06-best-practices/security-guidelines.md +++ b/Docs/06-best-practices/security-guidelines.md @@ -154,7 +154,7 @@ If WFLHASH were ever weaker than expected, the outer standard hash still provide ❌ **WFLHASH alone as the only integrity guarantee** for high-stakes data ❌ **Password hashing** — Use `hash_password`/`verify_password` (Argon2id, bcrypt, scrypt, PBKDF2) -❌ **External protocols** that require a specific standard (`hmac_sha256` for Stripe/GitHub, etc.) +❌ **External protocols** that require a specific standard (`hmac_sha256` for Stripe/GitHub, `ed25519_verify` for RFC 8032 Ed25519, etc.) ❌ **FIPS-only / formally validated crypto paths** — use the standard algorithm alone **[Complete crypto guidelines →](../05-standard-library/crypto-module.md)** diff --git a/Docs/reference/builtin-functions-reference.md b/Docs/reference/builtin-functions-reference.md index 8271e99a..4c2e2407 100644 --- a/Docs/reference/builtin-functions-reference.md +++ b/Docs/reference/builtin-functions-reference.md @@ -138,12 +138,13 @@ Store user passwords with these — never with fast hashes like `sha256` or `wfl | `constant_time_equals` | `constant_time_equals of and ` | Boolean | Timing-safe string comparison | | `secure_random_bytes` | `secure_random_bytes of ` | Text | `n` CSPRNG bytes as hex (for salts, tokens, session IDs) | -### Hashing & MAC (7 functions) +### Hashing, MAC & signatures (8 functions) | Function | Signature | Returns | Description | |----------|-----------|---------|-------------| | `sha256` | `sha256 of ` | Text | Standard SHA-256 (FIPS 180-4) | | `hmac_sha256` | `hmac_sha256 of and ` | Text | Standard HMAC-SHA256 (RFC 2104) | +| `ed25519_verify` | `ed25519_verify of and and ` | Boolean | RFC 8032 Ed25519 verification (hex key and signature) | | `wflhash256` | `wflhash256 of ` | Text | Experimental 256-bit WFLHASH | | `wflhash256_with_salt` | `wflhash256_with_salt of and ` | Text | Experimental salted WFLHASH | | `wflhash512` | `wflhash512 of ` | Text | Experimental 512-bit WFLHASH | diff --git a/Engineering/designs/2026-09-21-ed25519-verify.md b/Engineering/designs/2026-09-21-ed25519-verify.md new file mode 100644 index 00000000..218b473e --- /dev/null +++ b/Engineering/designs/2026-09-21-ed25519-verify.md @@ -0,0 +1,120 @@ +# Ed25519 signature verification (activation prerequisite) + +**Status:** Implemented in the WFL runtime; activation workflows are not in +this change. +**Risk class:** R3 (crypto, untrusted input, backward compatibility) +**Date:** 2026-09-21 + +## Why this exists + +The Logbie website issues callsign licences as `LOGBIE--` where +`sig = HMAC-SHA256(id, secret)` (hex). That MAC is a **symmetric** integrity +check: verification needs the same secret that minted the key, and the +authoritative record still lives in the `licenses` table. Existing licensing +and checkout behavior is unchanged. + +Future **offline activation** cannot share that HMAC secret with every +runtime. It needs public-key verification: a site-held private key signs an +activation payload; the website runtime (and later the logger) verifies with +the matching public key. WFL had `hmac_sha256` and `constant_time_equals`, but +no public-key verifier. This design records the contract that was missing +before any cryptographic behavior shipped. + +## Resolved contract + +Inspected sources: Logbie `website/README.md`, `website/app/license.wfl`, +`website/TODO.md`, and WFL's existing crypto surface (`hmac_sha256`, `sha256`, +`seal`/`unseal` hex encoding). The HMAC licence format is **not** a public-key +signature and is not reused here. + +| Item | Decision | Rationale | +|---|---|---| +| Algorithm | Ed25519 (RFC 8032) | Standard, fast, no digest-choice footgun; widely implemented | +| Public key | 32 raw bytes as hex (exactly 64 hex characters) | Matches WFL `secure_random_bytes` / `hmac_sha256` hex convention | +| Signature | 64 raw bytes as hex (exactly 128 hex characters) | RFC 8032 signature size | +| Signed bytes | UTF-8 encoding of the WFL text message, with no extra prefix, suffix, or hashing by the builtin | Same byte rule as `hmac_sha256` / `sha256`; the caller supplies the exact payload | +| Hex alphabet | ASCII `0-9` `a-f` `A-F` only; no `0x` prefix, whitespace, PEM, OpenSSH, or Base64 | One encoding; unsupported formats fail closed | +| Library | [`ed25519-dalek`](https://crates.io/crates/ed25519-dalek) 2.x | Maintained Ed25519 implementation; WFL does not implement the primitive | + +Activation issuers must sign the **same UTF-8 bytes** the verifier receives as +the message argument. If a future payload is binary, the caller encodes it as +WFL text first (this builtin does not accept a separate binary type). + +## WFL API + +```wfl +store accepted as ed25519_verify of public_key and message and signature +``` + +`call ed25519_verify with public_key and message and signature` is equivalent. +The name is an explicit-call builtin: it does not extend the legacy +`name with arguments` concatenation grammar. + +| Argument | Type | Meaning | +|---|---|---| +| `public_key` | Text | 64 hex characters (32-byte Ed25519 public key) | +| `message` | Text | Exact signed payload; UTF-8 bytes are verified | +| `signature` | Text | 128 hex characters (64-byte Ed25519 signature) | + +Returns a Boolean: `yes` only when the signature is valid for that public key +and those message bytes. + +## Fail-closed behavior + +| Input | Result | Notes | +|---|---|---| +| Valid RFC 8032 signature | `yes` | Authoritative vectors | +| Tampered message, wrong key, or wrong signature | `no` | Cryptographic reject; not an error | +| Well-formed hex that is not a usable Ed25519 public key | `no` | Invalid curve point is treated as a failed verify | +| Truncated, odd-length, non-hex, Base64, PEM, or `0x`-prefixed key/signature | runtime error | Malformed / unsupported format | +| Message longer than 1,048,576 UTF-8 bytes | runtime error | DoS bound; activation payloads are small | +| Wrong argument types or count | runtime error | Existing stdlib helpers | + +Malformed-input errors name the builtin and the expected encoding. They do +**not** echo the public key, message, or signature. The implementation must +not panic on untrusted input. + +Callers that need a single reject path treat both `no` and an error as +"not verified." This change does not mint keys, store secrets, or alter +`license_sign` / `license_verify_key`. + +## Dependency rationale + +`ed25519-dalek` 2.x is the maintained Rust Ed25519 library used across the +ecosystem. WFL already depends on RustCrypto (`sha2`, `hmac`, +`chacha20poly1305`) and must not grow a hand-rolled Edwards implementation. +Verification uses the library's RFC 8032 `verify` path so signatures from +libsodium, Go `ed25519`, OpenSSL, and dalek interoperate. + +Signing is intentionally **not** exposed. Activation private keys belong on +the issuing side, not in every website or logger process that only verifies. + +## Website dependency (follow-up after a WFL release) + +The Logbie website currently requires official WFL **26.9.16** +(`23c1a457`) and checksum-verifies that archive. Do not change that pin +until a published WFL contains `ed25519_verify`. Then, in +`website/README.md`: + +1. Raise the verified runtime to that release (source SHA + archive checksums). +2. State that activation work needs `ed25519_verify`; current HMAC licensing + still runs on 26.9.16. +3. Allow `WFL=/path/to/wfl` as today, so a source build of this commit can + be used before the official archive exists. +4. Add `wfl --test tests/ed25519_verify_test.wfl` to `scripts/test.sh`. + +Until that release, build from this repository: + +```sh +cargo build --release +export WFL=/path/to/wfl/target/release/wfl +``` + +## Out of scope + +- Licence issue/validate HTTP routes and HMAC key format +- Stripe checkout and webhook HMAC +- Key generation, private-key storage, or certificate chains +- Changing the pinned official WFL **26.9.16** archive used by current + website CI — a released WFL that contains this builtin is the follow-up + dependency bump diff --git a/Engineering/evidence/2026-09-21-ed25519-verify-red.md b/Engineering/evidence/2026-09-21-ed25519-verify-red.md new file mode 100644 index 00000000..5a3098b1 --- /dev/null +++ b/Engineering/evidence/2026-09-21-ed25519-verify-red.md @@ -0,0 +1,41 @@ +# Ed25519 verify — Red evidence + +**Date:** 2026-09-21 +**Risk class:** R3 (crypto / untrusted input) +**Base:** `23c1a457` (WFL 26.9.16) +**Command:** `cargo test --test crypto_ed25519_verify_test` + +## Intended failure + +The new WFL builtin `ed25519_verify` is absent. Authoritative RFC 8032 +verification and the website-shaped call must fail for that reason, not +because a helper was mistyped. + +## Observed result + +`cargo test --test crypto_ed25519_verify_test` finished with +**8 failed, 9 passed** after a clean compile. + +Every behavioral failure reported: + +```text +Undefined variable 'ed25519_verify' +``` + +Failed (intended Red): + +- `rfc8032_test1_empty_message_verifies` +- `rfc8032_test2_ascii_byte_verifies` +- `uppercase_hex_is_accepted` +- `tampered_message_is_rejected` +- `wrong_public_key_is_rejected` +- `flipped_signature_byte_is_rejected` +- `website_style_activation_payload_verifies` +- `oversized_message_is_rejected` + +The malformed-input cases that only assert “this is an error” passed because +the missing builtin is already an error. They become encoding assertions only +after the Green implementation exists. + +The gated `TestPrograms/crypto_ed25519_verify_test.wfl` suite is not runnable +on this baseline: `wfl --test` cannot resolve `ed25519_verify`. diff --git a/Engineering/evidence/2026-09-21-ed25519-verify.md b/Engineering/evidence/2026-09-21-ed25519-verify.md new file mode 100644 index 00000000..445f602b --- /dev/null +++ b/Engineering/evidence/2026-09-21-ed25519-verify.md @@ -0,0 +1,71 @@ +# Ed25519 verify — Green evidence + +**Date:** 2026-09-21 +**Risk class:** R3 (crypto / untrusted input) +**Red ancestor:** `98f6d5ab` (`test: observe missing Ed25519 signature verification`) +**Implementation:** this change (`ed25519-dalek` 2.2, `ed25519_verify` builtin) + +## Commands + +```bash +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --test crypto_ed25519_verify_test --test new_builtin_constant_compat_test --test sha256_hmac_test --test crypto_kdf_test +cargo test --lib builtins::tests +cargo metadata --manifest-path fuzz/Cargo.toml --locked +python3 scripts/check_repo_hygiene.py --mode static +cargo build --release +target/release/wfl --test TestPrograms/crypto_ed25519_verify_test.wfl +``` + +## Results + +| Layer | Result | +|---|---| +| Red `cargo test --test crypto_ed25519_verify_test` on `98f6d5ab` | 8 failed / 9 passed; every failure `Undefined variable 'ed25519_verify'` | +| Format | pass | +| Clippy `-D warnings` | pass | +| `crypto_ed25519_verify_test` | **18 passed** (RFC 8032 TEST 1–3, tamper, wrong key, formats, 1 MiB cap, website-shaped call) | +| New-builtin compatibility | 19 passed | +| Existing `sha256` / `hmac_sha256` | 9 passed | +| Builtin registry unit tests | 4 passed | +| Fuzz lockfile | consistent (`cargo metadata --locked`) | +| Hygiene static | pass | +| `TestPrograms/crypto_ed25519_verify_test.wfl` | **7 passed / 0 failed**, exit 0 | + +RFC 8032 TEST 3 (`af82`) is not valid UTF-8, so it is asserted at the byte helper, not the WFL text surface. + +## Pushed-revision CI + +Head `f85745a6e0d88da3c691b2b54fbc735dc4e4ed89` on `cursor/ed25519-verify-c45d`. +Workflow run: https://github.com/WebFirstLanguage/wfl/actions/runs/35575701421 + +All required GitHub Actions jobs succeeded (18 successful, 1 skipped version bump). +Linux and Windows integration, fuzz compile, hygiene, fmt/clippy/test, and both +`Run WFL Programs` matrices passed. CodeQL and Docker runtime validation also +passed. No failed or canceled required checks. + +## Website runtime + +This environment cannot clone `LogbieLLC/logbie` (private; the Cloud Agent git +token cannot resolve it). The website WFL unit suites were reproduced from +the published sources and run with `target/release/wfl`: + +| Suite | Result | +|---|---| +| `website/tests/license_test.wfl` (HMAC keys + entitlement) | 8 passed, exit 0 | +| `website/tests/checkout_test.wfl` | 6 passed, exit 0 | +| Website-shaped `ed25519_verify` call | 1 passed, exit 0 | + +Full `bash website/scripts/test.sh` (Scriptorium stage, smoke, profile, +entitlements) was not run: no Scriptorium checkout and no logbie clone. +Those suites do not call `ed25519_verify` today. + +## Residual + +Official Logbie website CI still pins WFL **26.9.16**. This builtin is in +source here; a released archive is required before that pin and the +website README's checksummed Linux archive can move. Existing HMAC +licensing is unchanged. After a WFL release, the website README should +require that version (or a `WFL=` path to a build of this commit) and +add `tests/ed25519_verify_test.wfl` to `scripts/test.sh`. diff --git a/History/dev-diary/2026/2026-09-21-ed25519-verify.md b/History/dev-diary/2026/2026-09-21-ed25519-verify.md new file mode 100644 index 00000000..b3f1d801 --- /dev/null +++ b/History/dev-diary/2026/2026-09-21-ed25519-verify.md @@ -0,0 +1,25 @@ +# 2026-09-21 — Ed25519 signature verification + +The Logbie website still issues `LOGBIE--` licence keys. That +online, secret-keyed check is unchanged. Future activation needs an offline +public-key verifier in the same WFL runtime the site already runs. + +WFL had `hmac_sha256` and no public-key primitive. The missing contract is +recorded in `Engineering/designs/2026-09-21-ed25519-verify.md`: + +- Algorithm: Ed25519 (RFC 8032) +- Public key: 32 bytes / 64 hex characters +- Signature: 64 bytes / 128 hex characters +- Signed bytes: UTF-8 of the WFL message text +- Library: `ed25519-dalek` 2.x (verify only; no signing API) + +`ed25519_verify of public_key and message and signature` returns `yes` or +`no`. Malformed or unsupported encodings (PEM, Base64, `0x`, truncated hex) +raise a generic error that does not echo the inputs. Messages are capped at +1 MiB. + +Red: `tests/crypto_ed25519_verify_test.rs` failed with +`Undefined variable 'ed25519_verify'` on the test-only ancestor. Green: RFC +8032 TEST 1 and TEST 2 through WFL, TEST 3 at the byte boundary, plus tamper, +wrong-key, and format cases. `TestPrograms/crypto_ed25519_verify_test.wfl` +is the website-shaped call. diff --git a/TestPrograms/crypto_ed25519_verify_test.wfl b/TestPrograms/crypto_ed25519_verify_test.wfl new file mode 100644 index 00000000..1517a303 --- /dev/null +++ b/TestPrograms/crypto_ed25519_verify_test.wfl @@ -0,0 +1,66 @@ +// Public-key signature verification (activation prerequisite). +// Authoritative vectors: RFC 8032 §7.1 TEST 1 (empty) and TEST 2 (byte 0x72). +// The website-shaped case uses the same call the Logbie site will make later: +// public key, exact UTF-8 message, hex signature. Licensing HMAC is untouched. + +describe "ed25519_verify": + test "RFC 8032 TEST 1 empty message verifies": + store public_key as "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a" + store signature as "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b" + store accepted as ed25519_verify of public_key and "" and signature + expect accepted to be yes + end test + + test "RFC 8032 TEST 2 ASCII byte verifies": + store public_key as "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c" + store signature as "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00" + store accepted as ed25519_verify of public_key and "r" and signature + expect accepted to be yes + end test + + test "tampered message is rejected": + store public_key as "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c" + store signature as "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00" + store accepted as ed25519_verify of public_key and "s" and signature + expect accepted to be no + end test + + test "wrong public key is rejected": + store public_key as "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a" + store signature as "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00" + store accepted as ed25519_verify of public_key and "r" and signature + expect accepted to be no + end test + + test "website-shaped activation call succeeds": + store activation_public_key as "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c" + store activation_message as "r" + store activation_signature as "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00" + store accepted as ed25519_verify of activation_public_key and activation_message and activation_signature + expect accepted to be yes + end test + + test "truncated signature is a closed error": + store public_key as "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c" + store signature as "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c" + store saw_error as no + try: + store accepted as ed25519_verify of public_key and "r" and signature + when error: + change saw_error to yes + end try + expect saw_error to be yes + end test + + test "pem public key is unsupported": + store public_key as "-----BEGIN PUBLIC KEY-----placeholder-----END PUBLIC KEY-----" + store signature as "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00" + store saw_error as no + try: + store accepted as ed25519_verify of public_key and "r" and signature + when error: + change saw_error to yes + end try + expect saw_error to be yes + end test +end describe diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 671261db..ca9bc873 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -331,6 +331,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation" version = "0.9.4" @@ -435,12 +441,49 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -492,6 +535,30 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" @@ -565,6 +632,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1638,6 +1711,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2283,6 +2366,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -2354,6 +2446,16 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlx" version = "0.9.0" @@ -3230,6 +3332,7 @@ dependencies = [ "chacha20poly1305", "chrono", "codespan-reporting", + "ed25519-dalek", "encoding_rs", "futures-util", "glob", diff --git a/src/analyzer/static_analyzer.rs b/src/analyzer/static_analyzer.rs index 55788503..bd4b13f0 100644 --- a/src/analyzer/static_analyzer.rs +++ b/src/analyzer/static_analyzer.rs @@ -143,6 +143,7 @@ const SECURITY_SENSITIVE_BUILTINS: &[&str] = &[ "secure_random_bytes", "generate_csrf_token", "hmac_sha256", + "ed25519_verify", "wflmac256", // Authenticated encryption. Their nonces come from the OS CSPRNG rather than // the seedable generator, so `random_seed` does not actually weaken them — diff --git a/src/builtins.rs b/src/builtins.rs index 8a473d96..f6edca9a 100644 --- a/src/builtins.rs +++ b/src/builtins.rs @@ -269,6 +269,7 @@ const EXPLICIT_CALL_BUILTIN_FUNCTIONS: &[&str] = &[ "session_cookie", "create_account_rate_limiter", "account_rate_limit_allow", + "ed25519_verify", ]; /// Native functions actually installed by [`crate::stdlib::register_stdlib`]. @@ -292,6 +293,7 @@ const IMPLEMENTED_BUILTIN_FUNCTIONS: &[&str] = &[ "wflmac256", "sha256", "hmac_sha256", + "ed25519_verify", "generate_csrf_token", "pbkdf2_hmac_sha256", "constant_time_equals", @@ -556,6 +558,8 @@ pub fn get_function_arity(name: &str) -> usize { "wflhash256" | "wflhash512" | "sha256" | "secure_random_bytes" => 1, // Two argument functions "wflhash256_with_salt" | "wflmac256" | "hmac_sha256" | "constant_time_equals" => 2, + // Three argument functions: (public_key, message, signature) + "ed25519_verify" => 3, // Four argument functions: (password, salt, iterations, length) "pbkdf2_hmac_sha256" => 4, diff --git a/src/stdlib/crypto.rs b/src/stdlib/crypto.rs index 3210391c..c76cd5eb 100644 --- a/src/stdlib/crypto.rs +++ b/src/stdlib/crypto.rs @@ -3,6 +3,7 @@ use crate::interpreter::environment::Environment; use crate::interpreter::error::RuntimeError; use crate::interpreter::value::Value; use argon2::{Algorithm, Argon2, Params, Version}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; // argon2, scrypt and pbkdf2 all depend on the same `password-hash` crate, so the // traits and types re-exported here apply to `Scrypt` and `Pbkdf2` as well. use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; @@ -21,6 +22,17 @@ use zeroize::{Zeroize, Zeroizing}; /// Maximum input size for wflhash functions (100MB) pub const MAX_INPUT_SIZE: usize = 100 * 1024 * 1024; +/// Maximum UTF-8 message size for `ed25519_verify` (1 MiB). +/// Activation payloads are small; this bound keeps an untrusted message from +/// becoming a memory/CPU DoS against the verifier. +pub const MAX_ED25519_MESSAGE_SIZE: usize = 1024 * 1024; + +/// Ed25519 public key length (RFC 8032). +const ED25519_PUBLIC_KEY_LEN: usize = 32; + +/// Ed25519 signature length (RFC 8032). +const ED25519_SIGNATURE_LEN: usize = 64; + /// Number of rounds in WFLHASH-P permutation (increased from 12 to 24 for security) const WFLHASH_ROUNDS: usize = 24; @@ -552,6 +564,78 @@ pub fn native_hmac_sha256(args: Vec) -> Result { )))) } +/// Ed25519 (RFC 8032) public-key verification. +/// +/// Usage: `ed25519_verify of public_key and message and signature` → yes/no +/// +/// The public key is 32 raw bytes as 64 hex characters; the signature is 64 +/// raw bytes as 128 hex characters. The signed bytes are the UTF-8 encoding of +/// `message`. Invalid signatures return `no`. Malformed or unsupported +/// encodings raise a generic error that does not echo the inputs. +pub fn native_ed25519_verify(args: Vec) -> Result { + check_arg_count("ed25519_verify", &args, 3)?; + + let public_key = expect_text(&args[0])?; + let message = expect_text(&args[1])?; + let signature = expect_text(&args[2])?; + + if message.len() > MAX_ED25519_MESSAGE_SIZE { + return Err(RuntimeError::new( + format!( + "ed25519_verify: message exceeds maximum allowed size ({MAX_ED25519_MESSAGE_SIZE} bytes)" + ), + 0, + 0, + )); + } + + let public_key_bytes = parse_ed25519_hex("public key", &public_key, ED25519_PUBLIC_KEY_LEN)?; + let signature_bytes = parse_ed25519_hex("signature", &signature, ED25519_SIGNATURE_LEN)?; + + Ok(Value::Bool(verify_ed25519_bytes( + &public_key_bytes, + message.as_bytes(), + &signature_bytes, + ))) +} + +/// Verify an Ed25519 signature over raw message bytes. +/// +/// Returns `false` for any well-formed-but-invalid key or signature, including +/// a 32-byte value that is not a usable Ed25519 public key. Callers that have +/// not already checked encoding must treat `false` as a closed reject. +pub fn verify_ed25519_bytes(public_key: &[u8], message: &[u8], signature: &[u8]) -> bool { + let public_key: [u8; ED25519_PUBLIC_KEY_LEN] = match public_key.try_into() { + Ok(bytes) => bytes, + Err(_) => return false, + }; + let signature: [u8; ED25519_SIGNATURE_LEN] = match signature.try_into() { + Ok(bytes) => bytes, + Err(_) => return false, + }; + let verifying_key = match VerifyingKey::from_bytes(&public_key) { + Ok(key) => key, + Err(_) => return false, + }; + verifying_key + .verify(message, &Signature::from_bytes(&signature)) + .is_ok() +} + +fn parse_ed25519_hex(label: &str, hex: &str, expected_len: usize) -> Result, RuntimeError> { + match hex_to_bytes(hex).filter(|bytes| bytes.len() == expected_len) { + Some(bytes) => Ok(bytes), + None => Err(RuntimeError::new( + format!( + "ed25519_verify: {label} must be {} hex characters ({expected_len} bytes)", + expected_len * 2 + ), + 0, + 0, + )), + } +} + /// Generate a cryptographically secure random token (for CSRF, sessions, etc.) /// Usage: generate_csrf_token() -> "a1b2c3d4e5f6..." pub fn native_generate_csrf_token(args: Vec) -> Result { @@ -1489,6 +1573,7 @@ pub fn register_crypto(env: &mut Environment) { env.define_native("wflmac256", native_wflmac256); env.define_native("sha256", native_sha256); env.define_native("hmac_sha256", native_hmac_sha256); + env.define_native("ed25519_verify", native_ed25519_verify); env.define_native("generate_csrf_token", native_generate_csrf_token); // Low-level auth/session primitives env.define_native("pbkdf2_hmac_sha256", native_pbkdf2_hmac_sha256); diff --git a/src/stdlib/typechecker.rs b/src/stdlib/typechecker.rs index 0b58c3fe..0083246e 100644 --- a/src/stdlib/typechecker.rs +++ b/src/stdlib/typechecker.rs @@ -432,6 +432,12 @@ fn register_crypto(analyzer: &mut Analyzer) { vec![Type::Text, Type::Text], Type::Boolean, ); + register( + analyzer, + &["ed25519_verify"], + vec![Type::Text, Type::Text, Type::Text], + Type::Boolean, + ); register( analyzer, &["secure_random_bytes"], diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index 9277b5df..b64b1e87 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -2831,6 +2831,7 @@ impl TypeChecker { // Timing-safe comparison returns a boolean "constant_time_equals" => Type::Boolean, + "ed25519_verify" => Type::Boolean, // Password hashing: *_hash produce a string, *_verify produce a boolean "hash_password_with_policy" | "session_cookie" => Type::Text, diff --git a/tests/crypto_ed25519_verify_test.rs b/tests/crypto_ed25519_verify_test.rs new file mode 100644 index 00000000..69a40174 --- /dev/null +++ b/tests/crypto_ed25519_verify_test.rs @@ -0,0 +1,265 @@ +// TDD tests for `ed25519_verify of public_key and message and signature`. +// +// R3 (crypto / untrusted input): this is the activation-prerequisite verifier. +// Authoritative vectors are RFC 8032 §7.1. Invalid signatures must return `no`; +// malformed encodings must fail closed without echoing the inputs. + +mod common; +use common::{ + expect_bool_result as expect_bool, expect_text_result as expect_text, get_global, run_wfl, + run_wfl_code, +}; +use wfl::interpreter::value::Value; + +/// RFC 8032 §7.1 TEST 1 — empty message. +const RFC8032_TEST1_PK: &str = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; +const RFC8032_TEST1_SIG: &str = "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"; + +/// RFC 8032 §7.1 TEST 2 — one-byte message `0x72` (`"r"` in UTF-8 / ASCII). +const RFC8032_TEST2_PK: &str = "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"; +const RFC8032_TEST2_SIG: &str = "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"; + +fn verify_src(public_key: &str, message: &str, signature: &str) -> String { + format!( + r#" +store result as ed25519_verify of "{public_key}" and "{message}" and "{signature}" +"# + ) +} + +async fn verify_bool(public_key: &str, message: &str, signature: &str) -> bool { + expect_bool(run_wfl_code(&verify_src(public_key, message, signature)).await) +} + +async fn verify_err(public_key: &str, message: &str, signature: &str) -> String { + match run_wfl_code(&verify_src(public_key, message, signature)).await { + Ok(value) => panic!("expected a runtime error, got {value:?}"), + Err(error) => error, + } +} + +fn assert_closed_error(error: &str) { + let lower = error.to_lowercase(); + assert!( + lower.contains("ed25519_verify"), + "malformed-input errors must name the builtin, got: {error}" + ); + for secret in [ + RFC8032_TEST1_PK, + RFC8032_TEST1_SIG, + RFC8032_TEST2_PK, + RFC8032_TEST2_SIG, + "sk-", + "BEGIN", + ] { + assert!( + !error.contains(secret), + "diagnostics must not echo key or signature material: {error}" + ); + } +} + +#[tokio::test] +async fn rfc8032_test1_empty_message_verifies() { + assert!( + verify_bool(RFC8032_TEST1_PK, "", RFC8032_TEST1_SIG).await, + "RFC 8032 TEST 1 (empty message) must verify" + ); +} + +#[tokio::test] +async fn rfc8032_test2_ascii_byte_verifies() { + assert!( + verify_bool(RFC8032_TEST2_PK, "r", RFC8032_TEST2_SIG).await, + "RFC 8032 TEST 2 (message 0x72) must verify" + ); +} + +/// RFC 8032 §7.1 TEST 3 — two-byte binary message `af82` (not valid UTF-8). +const RFC8032_TEST3_PK: &str = "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025"; +const RFC8032_TEST3_SIG: &str = "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a"; +const RFC8032_TEST3_MSG: [u8; 2] = [0xaf, 0x82]; + +#[test] +fn rfc8032_test3_binary_message_verifies_at_the_byte_boundary() { + // TEST 3's message is not valid UTF-8, so the WFL text surface cannot carry + // it. The native byte helper is the authoritative path for that vector. + let accepted = wfl::stdlib::crypto::verify_ed25519_bytes( + &hex_literal(RFC8032_TEST3_PK), + &RFC8032_TEST3_MSG, + &hex_literal(RFC8032_TEST3_SIG), + ); + assert!(accepted, "RFC 8032 TEST 3 (message af82) must verify"); +} + +fn hex_literal(hex: &str) -> Vec { + hex.as_bytes() + .as_chunks::<2>() + .0 + .iter() + .map(|pair| { + let text = std::str::from_utf8(pair).expect("hex fixture is ASCII"); + u8::from_str_radix(text, 16).expect("hex fixture is valid") + }) + .collect() +} + +#[tokio::test] +async fn uppercase_hex_is_accepted() { + assert!( + verify_bool( + &RFC8032_TEST1_PK.to_ascii_uppercase(), + "", + &RFC8032_TEST1_SIG.to_ascii_uppercase() + ) + .await, + "hex is case-insensitive" + ); +} + +#[tokio::test] +async fn tampered_message_is_rejected() { + assert!( + !verify_bool(RFC8032_TEST2_PK, "s", RFC8032_TEST2_SIG).await, + "a one-byte message change must not verify" + ); +} + +#[tokio::test] +async fn wrong_public_key_is_rejected() { + assert!( + !verify_bool(RFC8032_TEST1_PK, "r", RFC8032_TEST2_SIG).await, + "TEST 2's signature must not verify under TEST 1's public key" + ); +} + +#[tokio::test] +async fn flipped_signature_byte_is_rejected() { + let mut flipped = RFC8032_TEST2_SIG.to_string(); + flipped.replace_range(0..1, if flipped.starts_with('9') { "8" } else { "9" }); + assert_ne!(flipped, RFC8032_TEST2_SIG); + assert!( + !verify_bool(RFC8032_TEST2_PK, "r", &flipped).await, + "a flipped signature nibble must not verify" + ); +} + +#[tokio::test] +async fn truncated_signature_is_a_format_error() { + let error = verify_err(RFC8032_TEST2_PK, "r", &RFC8032_TEST2_SIG[..126]).await; + assert_closed_error(&error); +} + +#[tokio::test] +async fn truncated_public_key_is_a_format_error() { + let error = verify_err(&RFC8032_TEST2_PK[..62], "r", RFC8032_TEST2_SIG).await; + assert_closed_error(&error); +} + +#[tokio::test] +async fn odd_length_hex_is_a_format_error() { + let error = verify_err(&format!("0{RFC8032_TEST2_PK}"), "r", RFC8032_TEST2_SIG).await; + assert_closed_error(&error); +} + +#[tokio::test] +async fn non_hex_signature_is_a_format_error() { + let bogus = "z".repeat(128); + let error = verify_err(RFC8032_TEST2_PK, "r", &bogus).await; + assert_closed_error(&error); + assert!( + !error.contains(&bogus), + "must not echo the malformed signature: {error}" + ); +} + +#[tokio::test] +async fn base64_public_key_is_unsupported() { + let error = verify_err( + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511=", + "r", + RFC8032_TEST2_SIG, + ) + .await; + assert_closed_error(&error); +} + +#[tokio::test] +async fn pem_public_key_is_unsupported() { + let pem = "-----BEGIN PUBLIC KEY-----MCowBQYDK2VwAyEA11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=-----END PUBLIC KEY-----"; + let error = verify_err(pem, "r", RFC8032_TEST2_SIG).await; + assert_closed_error(&error); + assert!( + !error.contains("BEGIN PUBLIC KEY"), + "must not echo PEM material: {error}" + ); +} + +#[tokio::test] +async fn hex_prefix_is_unsupported() { + let error = verify_err(&format!("0x{RFC8032_TEST2_PK}"), "r", RFC8032_TEST2_SIG).await; + assert_closed_error(&error); +} + +#[tokio::test] +async fn oversized_message_is_rejected() { + // 1 MiB + 1 exceeds the documented limit. Build the source in Rust so the + // WFL program does not have to construct the string itself. + let too_big = "a".repeat(1_048_576 + 1); + let error = verify_err(RFC8032_TEST1_PK, &too_big, RFC8032_TEST1_SIG).await; + assert_closed_error(&error); + assert!( + error.to_lowercase().contains("maximum") || error.to_lowercase().contains("size"), + "oversized messages must mention the limit, got: {error}" + ); +} + +#[tokio::test] +async fn website_style_activation_payload_verifies() { + // The call shape the Logbie website will use: settings public key, exact + // UTF-8 payload, hex signature. RFC 8032 TEST 2 stands in for a signed + // activation record until issuance exists. + let code = format!( + r#" +store activation_public_key as "{RFC8032_TEST2_PK}" +store activation_message as "r" +store activation_signature as "{RFC8032_TEST2_SIG}" +store accepted as ed25519_verify of activation_public_key and activation_message and activation_signature +"# + ); + let interpreter = run_wfl(&code) + .await + .expect("the website-shaped verifier call must run"); + match get_global(&interpreter, "accepted") { + Value::Bool(true) => {} + other => panic!("website-shaped verify must accept a valid signature, got {other:?}"), + } +} + +#[tokio::test] +async fn missing_builtin_is_not_silently_a_text_concat() { + // Guard: `ed25519_verify of …` must be a real builtin, not concatenated text. + let result = run_wfl_code(&verify_src(RFC8032_TEST1_PK, "", RFC8032_TEST1_SIG)).await; + match result { + Ok(Value::Bool(_)) => {} + Ok(Value::Text(text)) => panic!("ed25519_verify must not concatenate to text: {text}"), + Ok(other) => panic!("ed25519_verify must return a boolean, got {other:?}"), + Err(error) => { + // Red: the builtin is absent. Green: this branch is unused. + assert!( + error.to_lowercase().contains("ed25519") + || error.to_lowercase().contains("undefined") + || error.to_lowercase().contains("unknown"), + "unexpected absence diagnostic: {error}" + ); + } + } +} + +#[tokio::test] +async fn expect_text_helper_still_reads_unrelated_hex() { + // Keeps the file compiling against the shared helpers if a future edit + // drops every text-result assertion. + let hex = expect_text(run_wfl_code(r#"store result as sha256 of """#).await); + assert_eq!(hex.len(), 64); +} diff --git a/tests/new_builtin_constant_compat_test.rs b/tests/new_builtin_constant_compat_test.rs index 7db92192..23cba220 100644 --- a/tests/new_builtin_constant_compat_test.rs +++ b/tests/new_builtin_constant_compat_test.rs @@ -20,6 +20,7 @@ const NEW_BUILTINS: &[&str] = &[ "session_cookie", "create_account_rate_limiter", "account_rate_limit_allow", + "ed25519_verify", ]; fn text(value: &str) -> Value {