feat(stdlib): finish Crypto (CSPRNG + base64) and add Uuid.* library (#19 #16)
All checks were successful
docs / build-and-deploy (push) Successful in 2s

Crypto (#19): add the OS cryptographically-secure random surface
(random_bytes/random_hex/random_u32, reading /dev/urandom) and a standard
base64 encoder, completing the library alongside the existing SHA-256/
HMAC-SHA256/verify_hmac/hex/ct_equal. All pure integer IR, C-free.

Uuid (#16): a new namespace for stable, collision-free IDs — v4 (random) and
v7 (time-ordered) generation, plus parse/is_valid/to_text/equals/nil. UUIDs are
canonical lowercase 36-char strings; v4 and v7's random tail draw from the
crypto CSPRNG, so both carry the documented determinism caveat (mint at the
edges, never inside lockstep simulation). Reuses the crypto prelude's
fn_secure_bytes / fn_hex_encode.

- examples/library/{crypto,uuid}.ludic: known-answer vectors (SHA-256, HMAC,
  base64 per RFC 4231/4648) and structural invariants (uuid version/variant
  bits, parse/equals), wired into `x test` (now 51 passed).
- docs: per-symbol pages for every new method + a new Uuid section; inventory
  and impl-vs-docs coverage check pass.
- seed regenerated; `x bootstrap-cfree` fixpoint holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-30 21:40:51 +03:00
parent a422ef4375
commit 2ddf830f0b
26 changed files with 12185 additions and 10720 deletions

View file

@ -8,4 +8,6 @@ Secure, test-vector-backed hashing for the handful of security-sensitive things
Digests are returned as lowercase hex strings, not raw bytes — a <code>str</code> is null-terminated and a raw digest can contain a zero byte, so hex is the form you can print, store, and compare directly.
Alongside hashing, this library exposes the OS cryptographically-secure random generator — <a href="crypto-random_bytes"><code>random_bytes</code></a>, <a href="crypto-random_hex"><code>random_hex</code></a>, and <a href="crypto-random_u32"><code>random_u32</code></a> — for tokens, nonces, and <a href="ns-Uuid"><code>Uuid</code></a> generation, plus <a href="crypto-base64"><code>base64</code></a> for moving bytes through text-only channels. The secure-random helpers are deliberately non-deterministic and must never seed the lockstep simulation RNG (<a href="ns-Random"><code>Random</code></a>).
What this is not: it is not DRM and it is not unbeatable anti-cheat. A client-side game cannot keep a secret from the machine it runs on — a determined owner can always read the key out of the binary. Use it to make *casual* tampering detectable and to authenticate messages between parties who share a key. To verify a MAC always use <a href="crypto-verify_hmac"><code>Crypto.verify_hmac</code></a> (a constant-time check), never <code>==</code>, which leaks how much of a guessed MAC was correct.

View file

@ -0,0 +1,28 @@
---
id: crypto-base64
name: Crypto.base64
category: crypto
kind: namespace-method
tokens: Crypto.base64
sig: Crypto.base64(s) -> string
tip: Standard base64 (RFC 4648) of a string's bytes.
order: 9
ns: Crypto
member: base64
---
Encodes the bytes of <code>s</code> as standard base64 (RFC 4648, the <code>A–Z a–z 0–9 + /</code> alphabet with <code>=</code> padding). Base64 turns arbitrary bytes into printable ASCII, which is what you want when a digest, key, or binary blob has to travel through a text-only channel — a JSON field, a URL-safe token wrapper, a config file, a log line. It is an *encoding*, not encryption: it hides nothing and adds no integrity. Pair it with <a href="crypto-hmac_sha256"><code>Crypto.hmac_sha256</code></a> when the payload must also be tamper-evident.
The output length is always a multiple of four; a one- or two-byte remainder in the input is padded with <code>=</code>.
Parameters:
- `s` — the string whose bytes are encoded
```ludic
program Encode {
entry {
print(Crypto.base64("foobar")) # Zm9vYmFy
print(Crypto.base64("f")) # Zg==
}
}
```

View file

@ -0,0 +1,28 @@
---
id: crypto-random_bytes
name: Crypto.random_bytes
category: crypto
kind: namespace-method
tokens: Crypto.random_bytes
sig: Crypto.random_bytes(n) -> string
tip: n bytes from the OS CSPRNG, as a 2n-character hex string.
order: 6
ns: Crypto
member: random_bytes
---
Draws <code>n</code> bytes from the operating system's cryptographically-secure random number generator and returns them as a <code>2n</code>-character lowercase hex string. Use it for unguessable tokens, nonces, and session secrets — anything whose whole value is that an attacker cannot predict it. The result is hex rather than raw bytes for the same reason digests are: a <code>str</code> is null-terminated and raw random bytes can contain a zero byte, so hex is the form you can safely store and compare.
This is deliberately **non-deterministic** — it must never seed the lockstep simulation RNG (<a href="ns-Random"><code>Random</code></a>). Two calls return different values. On a platform without an OS CSPRNG (for example a bare wasm target) the draw degrades to zeroes rather than faulting; treat a real CSPRNG there as a platform-layer responsibility.
Parameters:
- `n` — the number of secure random bytes to draw
```ludic
program Token {
entry {
let session = Crypto.random_bytes(16) # 32 hex chars, unguessable
print(len(session)) # 32
}
}
```

View file

@ -0,0 +1,26 @@
---
id: crypto-random_hex
name: Crypto.random_hex
category: crypto
kind: namespace-method
tokens: Crypto.random_hex
sig: Crypto.random_hex(n) -> string
tip: Alias for random_bytes — n secure bytes as a 2n-char hex string.
order: 7
ns: Crypto
member: random_hex
---
Identical to <a href="crypto-random_bytes"><code>Crypto.random_bytes</code></a>: draws <code>n</code> bytes from the OS CSPRNG and returns them as a <code>2n</code>-character lowercase hex string. The two names are interchangeable; <code>random_hex</code> exists so call sites can be explicit that the return value is already hex text (not raw bytes) when that reads more clearly. The same determinism caveat applies — the result is unpredictable by design and must stay out of the reproducible simulation RNG.
Parameters:
- `n` — the number of secure random bytes to draw
```ludic
program Nonce {
entry {
let nonce = Crypto.random_hex(12) # 24 hex chars
print(len(nonce)) # 24
}
}
```

View file

@ -0,0 +1,26 @@
---
id: crypto-random_u32
name: Crypto.random_u32
category: crypto
kind: namespace-method
tokens: Crypto.random_u32
sig: Crypto.random_u32() -> int
tip: One CSPRNG-drawn 32-bit integer.
order: 8
ns: Crypto
member: random_u32
---
Draws four bytes from the OS CSPRNG and assembles them into one 32-bit integer. Use it when you need a single unpredictable number rather than a hex string — a random challenge value, a per-run identifier, or a non-deterministic seed to hand to a *fresh* <a href="ns-Random"><code>Random</code></a> stream at startup. Because the bytes come from the secure generator, the value prints as a signed integer and can be negative.
Like the other secure-random helpers this is **non-deterministic** and must not be called inside the lockstep simulation: doing so desyncs replays and networked peers. Draw it at the edges (startup, on connect) and, if you need reproducible gameplay randomness afterward, seed <a href="random-seed"><code>Random.seed</code></a> with it once.
```ludic
program Challenge {
entry {
let c = Crypto.random_u32()
Random.seed(value: c) # non-deterministic seed, chosen once at startup
print(Random.int(100))
}
}
```

View file

@ -0,0 +1,13 @@
---
id: uuid
title: Uuid
order: 6
---
Universally-unique identifiers — stable IDs that don't collide, generated locally with no central authority handing out numbers. Reach for a UUID whenever something needs an identity that survives being saved, shared, or sent over a network: players and sessions in multiplayer, user-created content (levels, items, mods) that has to merge cleanly across installs, or a per-install / per-run ID for analytics and bug reports.
Two versions are provided. <a href="uuid-new"><code>Uuid.new</code></a> makes a **v4** (random) UUID — 122 bits of entropy, effectively never colliding. <a href="uuid-new_v7"><code>Uuid.new_v7</code></a> makes a **v7** (time-ordered) UUID whose leading bits are a millisecond timestamp, so a batch of v7 IDs sorts by creation time — friendly to database indexes and append logs. Both set the RFC 4122 version and variant bits correctly.
A UUID is represented as its canonical lowercase 36-character text form (<code>8-4-4-4-12</code>), the same shape you store, print, send, and compare — so there is no conversion at each boundary. Validate untrusted input with <a href="uuid-is_valid"><code>Uuid.is_valid</code></a> or normalise it with <a href="uuid-parse"><code>Uuid.parse</code></a>, and compare with <a href="uuid-equals"><code>Uuid.equals</code></a>, which ignores case.
**Determinism caveat.** v4 and the random tail of v7 come from the OS cryptographically-secure RNG (<a href="ns-Crypto"><code>Crypto</code></a>), which is non-deterministic by design. Minting a UUID inside the lockstep simulation will desync replays and networked peers — generate IDs at the edges (on connect, on save, on spawn-from-input), never per tick in reproducible gameplay code.

View file

@ -0,0 +1,27 @@
---
id: uuid-equals
name: Uuid.equals
category: uuid
kind: namespace-method
tokens: Uuid.equals
sig: Uuid.equals(a, b) -> bool
tip: Case-insensitive UUID equality.
order: 8
ns: Uuid
member: equals
---
Compares two UUIDs for equality, ignoring case. UUIDs generated by this library are always lowercase, so a plain <code>==</code> works between them — but a UUID that arrived from another system may be upper- or mixed-case, and <code>equals</code> matches it correctly without you having to normalise first. Values of different length are never equal.
Parameters:
- `a`, `b` — the UUID strings to compare
```ludic
program Eq {
entry {
let lo = "550e8400-e29b-41d4-a716-446655440000"
let up = "550E8400-E29B-41D4-A716-446655440000"
if Uuid.equals(lo, up) { print(1) } # case-insensitive
}
}
```

View file

@ -0,0 +1,28 @@
---
id: uuid-is_valid
name: Uuid.is_valid
category: uuid
kind: namespace-method
tokens: Uuid.is_valid
sig: Uuid.is_valid(s) -> bool
tip: Is s a well-formed UUID string?
order: 6
ns: Uuid
member: is_valid
---
Reports whether <code>s</code> is a well-formed UUID: exactly 36 characters, hyphens at positions 8, 13, 18 and 23, and hexadecimal digits (either case) everywhere else. This is the guard to run on any UUID that came from outside your program — a save file, a network message, a mod, a command-line flag — before you trust it as an identity.
It checks *shape*, not version: both v4 and v7 IDs (and any other conforming UUID) pass. To also fold a malformed value into a safe default in one step, use <a href="uuid-parse"><code>Uuid.parse</code></a> instead.
Parameters:
- `s` — the string to check
```ludic
program Valid {
entry {
if Uuid.is_valid("550e8400-e29b-41d4-a716-446655440000") { print(1) }
if not Uuid.is_valid("not-a-uuid") { print(2) }
}
}
```

View file

@ -0,0 +1,25 @@
---
id: uuid-new
name: Uuid.new
category: uuid
kind: namespace-method
tokens: Uuid.new
sig: Uuid.new() -> string
tip: A new random (v4) UUID as a canonical 36-char string.
order: 1
ns: Uuid
member: new
---
Generates a new **v4** (random) UUID and returns it in canonical lowercase text form, e.g. <code>550e8400-e29b-41d4-a716-446655440000</code>. A v4 UUID carries 122 bits of entropy drawn from the OS secure random generator, so two independently-generated IDs colliding is not something you will ever observe in practice — which is exactly what makes it a good identity for a player, a session, a networked entity, or a piece of user-created content that has no central authority to number it.
This is the default UUID constructor; <a href="uuid-v4"><code>Uuid.v4</code></a> is an explicit alias. Because the value is random it is **non-deterministic** — do not mint UUIDs inside the lockstep simulation, or replays and peers will diverge.
```ludic
program NewId {
entry {
let id = Uuid.new()
print(len(id)) # 36
}
}
```

View file

@ -0,0 +1,25 @@
---
id: uuid-new_v7
name: Uuid.new_v7
category: uuid
kind: namespace-method
tokens: Uuid.new_v7
sig: Uuid.new_v7() -> string
tip: A new time-ordered (v7) UUID; sorts by creation time.
order: 3
ns: Uuid
member: new_v7
---
Generates a new **v7** (time-ordered) UUID. Its leading 48 bits are a Unix-millisecond timestamp, so a batch of v7 IDs sorts lexicographically by creation time — which keeps database indexes and append-only logs tidy in a way random v4 IDs do not. The remaining bits are secure random, so IDs minted in the same millisecond are still distinct, and the RFC 4122 version (7) and variant bits are set.
Sub-second resolution is derived from the wall clock in seconds (multiplied to milliseconds), so ordering is guaranteed at one-second granularity with the random tail breaking ties within a second. Like v4 this reads the non-deterministic wall clock and CSPRNG — generate at the edges, never inside reproducible simulation. <a href="uuid-v7"><code>Uuid.v7</code></a> is an alias.
```ludic
program NewV7 {
entry {
let id = Uuid.new_v7()
print(id[14..15]) # 7 — the version nibble
}
}
```

View file

@ -0,0 +1,23 @@
---
id: uuid-nil
name: Uuid.nil
category: uuid
kind: namespace-method
tokens: Uuid.nil
sig: Uuid.nil() -> string
tip: The all-zero UUID.
order: 9
ns: Uuid
member: nil
---
Returns the nil UUID — <code>00000000-0000-0000-0000-000000000000</code> — the reserved all-zero value that means "no UUID". Use it as a sentinel for an unset or absent identity, and as the value <a href="uuid-parse"><code>Uuid.parse</code></a> returns when it is handed something that is not a valid UUID. It is a well-formed UUID string, so it passes <a href="uuid-is_valid"><code>Uuid.is_valid</code></a>; test for "no id" by comparing against <code>Uuid.nil()</code> explicitly.
```ludic
program Nil {
entry {
let none = Uuid.nil()
print(none) # 00000000-0000-0000-0000-000000000000
}
}
```

View file

@ -0,0 +1,29 @@
---
id: uuid-parse
name: Uuid.parse
category: uuid
kind: namespace-method
tokens: Uuid.parse
sig: Uuid.parse(s) -> string
tip: Normalise an untrusted string to a lowercase UUID, or the nil UUID.
order: 5
ns: Uuid
member: parse
---
Normalises an untrusted string: if <code>s</code> is a well-formed UUID it returns the same value in canonical lowercase form; if it is not, it returns the <a href="uuid-nil"><code>nil</code></a> UUID. This never faults on garbage, so it is safe to call on data that arrived from a file, a network peer, or a mod. When you need to *reject* bad input rather than fold it to nil, gate on <a href="uuid-is_valid"><code>Uuid.is_valid</code></a> first.
Parsing accepts either case and yields the lowercase canonical form, which is what the rest of the library produces — so a parsed ID compares equal (with <code>==</code>) to a freshly generated one of the same value.
Parameters:
- `s` — the string to validate and normalise
```ludic
program Parse {
entry {
let id = Uuid.parse("550E8400-E29B-41D4-A716-446655440000")
print(id[0..8]) # 550e8400 — lowercased
if Uuid.parse("nope") == Uuid.nil() { print(1) }
}
}
```

View file

@ -0,0 +1,26 @@
---
id: uuid-to_text
name: Uuid.to_text
category: uuid
kind: namespace-method
tokens: Uuid.to_text
sig: Uuid.to_text(id) -> string
tip: The canonical text form of a UUID.
order: 7
ns: Uuid
member: to_text
---
Returns the canonical 36-character text form of <code>id</code>. Because this library already represents every UUID as its canonical lowercase text, <code>to_text</code> is the identity function — it exists so intent is explicit at the point where a UUID is turned into a string for display, storage, or a wire payload, and so code stays correct if the internal representation ever changes to a packed 128-bit value.
Parameters:
- `id` — the UUID to render
```ludic
program ToText {
entry {
let id = Uuid.new()
print(len(Uuid.to_text(id))) # 36
}
}
```

View file

@ -0,0 +1,24 @@
---
id: uuid-v4
name: Uuid.v4
category: uuid
kind: namespace-method
tokens: Uuid.v4
sig: Uuid.v4() -> string
tip: Explicit alias for Uuid.new — a random (v4) UUID.
order: 2
ns: Uuid
member: v4
---
An explicit alias for <a href="uuid-new"><code>Uuid.new</code></a>: generates a **v4** (random) UUID in canonical lowercase text form. Use this spelling when the surrounding code also uses <a href="uuid-v7"><code>Uuid.v7</code></a> and naming both by version reads more clearly than <code>new</code> versus <code>new_v7</code>. The behaviour, entropy, and non-determinism caveat are identical to <code>Uuid.new</code>.
```ludic
program V4 {
entry {
let a = Uuid.v4()
let b = Uuid.v4()
if a != b { print(1) } # two draws differ
}
}
```

View file

@ -0,0 +1,23 @@
---
id: uuid-v7
name: Uuid.v7
category: uuid
kind: namespace-method
tokens: Uuid.v7
sig: Uuid.v7() -> string
tip: Explicit alias for Uuid.new_v7 — a time-ordered UUID.
order: 4
ns: Uuid
member: v7
---
An explicit alias for <a href="uuid-new_v7"><code>Uuid.new_v7</code></a>: generates a **v7** (time-ordered) UUID whose leading bits are a millisecond timestamp so the IDs sort by creation time. Use this spelling to sit symmetrically beside <a href="uuid-v4"><code>Uuid.v4</code></a>. Behaviour, timestamp resolution, and the determinism caveat are identical to <code>Uuid.new_v7</code>.
```ludic
program V7 {
entry {
let id = Uuid.v7()
if Uuid.is_valid(id) { print(1) }
}
}
```