feat(stdlib): Crypto.affine — HMAC-SHA256, RS256, base64url for Deno-ESM by hyperpolymath · Pull Request #408 · hyperpolymath/affinescript · GitHub
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 103 additions & 1 deletion lib/codegen_deno.ml
52 changes: 50 additions & 2 deletions stdlib/Crypto.affine
Original file line number Diff line number Diff line change
@@ -1,11 +1,59 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
//
// Crypto.affine — extern bindings for crypto primitives and wall-clock time.
// The Node-CJS shim wraps Node's built-in node:crypto and Date.now().
// Crypto.affine — host bindings for crypto primitives and wall-clock time.
//
// Lowered by lib/codegen_deno.ml. The Web Crypto SubtleCrypto surface is
// async, so the HMAC verify and RS256 sign primitives carry the `Async`
// effect; callers compile to `async function` and the call site receives
// an awaited result (mirroring the Http.affine pattern, ADR-013).
//
// `hmac_sha256_verify` uses `crypto.subtle.verify`, which is implemented
// in constant time by spec — do not replace with a `compare(hex_a, hex_b)`
// shape, as constant-time hex compare is the user's responsibility and is
// easy to get wrong. The signature input is the lowercase-hex digest as
// GitHub delivers it in `X-Hub-Signature-256` (after the `sha256=` prefix
// is stripped by the caller).
//
// `rs256_sign` takes a PEM-encoded PKCS#8 private key — the format
// produced by `openssl pkcs8 -topk8 -nocrypt`. GitHub Apps download keys
// in PKCS#1; the conversion is documented in
// `oikos/docs/GITHUB_APP_SETUP.md`. Returns the raw signature bytes;
// callers typically pass it through `base64url_encode_bytes` for JWT
// assembly.

module Crypto;

use Deno::{Bytes};

// ── Existing (pre-2026-05) ──────────────────────────────────────────
pub extern fn random_string(n: Int) -> String;
pub extern fn random_f64() -> Float;
pub extern fn time_ms() -> Int;

// ── HMAC-SHA256 ─────────────────────────────────────────────────────
//
// Constant-time verification of an HMAC-SHA256 signature.
// `signature_hex` is the lowercase-hex digest. Returns false on any
// error (malformed hex, length mismatch, signature mismatch) —
// fail-closed.
pub extern fn hmac_sha256_verify(secret: String,
body: String,
signature_hex: String) -> Bool / Async;

// ── RS256 / RSASSA-PKCS1-v1_5 over SHA-256 ──────────────────────────
//
// Sign `payload` (UTF-8 bytes) with the PKCS#8 RSA private key in
// `pkcs8_pem`. Returns the raw signature bytes. Throws (via Web
// Crypto's own surface) on a malformed key — defensible behaviour, the
// bot should not be running with a broken key.
pub extern fn rs256_sign(pkcs8_pem: String,
payload: String) -> Bytes / Async;

// ── Base64URL (RFC 4648 §5, no padding) ──────────────────────────────
//
// JWT and OAuth2 token formats are base64url, not base64. These match
// the JOSE base64url-encode (`=` stripped, `+` -> `-`, `/` -> `_`).
pub extern fn base64url_encode_string(s: String) -> String;
pub extern fn base64url_encode_bytes(b: Bytes) -> String;
pub extern fn base64url_decode(s: String) -> Bytes;
32 changes: 32 additions & 0 deletions tests/codegen-deno/crypto_primitives.affine
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: MPL-2.0
// crypto-stdlib-2026-05 — HMAC-SHA256, RS256, base64url end-to-end on the
// Deno-ESM backend. Exercises stdlib/Crypto.affine + the lowering rules
// added to lib/codegen_deno.ml.
//
// The harness (crypto_primitives.harness.mjs) stubs `globalThis.crypto`
// so the assertions are deterministic and the test stays offline.

use Crypto::{hmac_sha256_verify, rs256_sign, base64url_encode_string, base64url_encode_bytes, base64url_decode};

// HMAC-SHA256 verify — both the happy path and the fail-closed return.
pub fn verify_ok(secret: String, body: String, sig: String) -> Bool / Async {
hmac_sha256_verify(secret, body, sig)
}

// RS256 sign returns Bytes; we round-trip through base64url so the harness
// can assert on a string (Bytes cross the boundary as Uint8Array, but the
// harness side checks the b64u-encoded shape — easier to read).
pub fn sign_b64u(pem: String, payload: String) -> String / Async {
base64url_encode_bytes(rs256_sign(pem, payload))
}

// Pure (no Async, no Net) — base64url encode/decode round-trip.
pub fn b64u_encode(s: String) -> String {
base64url_encode_string(s)
}

pub fn b64u_decode_roundtrip(s: String) -> String / Async {
// decode -> bytes -> re-encode through bytes path, to exercise both
// string and bytes encoders. The .harness asserts the round-trip.
base64url_encode_bytes(base64url_decode(s))
}
167 changes: 167 additions & 0 deletions tests/codegen-deno/crypto_primitives.harness.mjs
Loading