Skip to content

feat(stdlib): Crypto.affine — HMAC-SHA256, RS256, base64url for Deno-ESM - #408

Closed
hyperpolymath wants to merge 1 commit into
mainfrom
feat/crypto-stdlib-hmac-rs256-base64url
Closed

feat(stdlib): Crypto.affine — HMAC-SHA256, RS256, base64url for Deno-ESM#408
hyperpolymath wants to merge 1 commit into
mainfrom
feat/crypto-stdlib-hmac-rs256-base64url

Conversation

@hyperpolymath

Copy link
Copy Markdown
Owner

Summary

Adds the host-binding surface needed to express the GitHub-App webhook signature + JWT pattern in AffineScript. The motivating consumer is the Oikos eco/econ analysis bot (currently ReScript+Deno; the port to AffineScript was blocked on these primitives — see oikos PR #32 for the ReScript implementation that defines the contract).

Three families of externs in stdlib/Crypto.affine:

Extern Purpose Effect row
hmac_sha256_verify(secret, body, hex_sig) -> Bool Constant-time HMAC-SHA256 verify via crypto.subtle.verify. Hex signature input matches GitHub's X-Hub-Signature-256 after sha256= is stripped. Fail-closed on malformed hex. / Async
rs256_sign(pkcs8_pem, payload) -> Bytes RSASSA-PKCS1-v1_5 over SHA-256 (RS256 for JWT). PKCS#8 PEM input as produced by openssl pkcs8 -topk8 -nocrypt. PKCS#1 is rejected at the runtime shim's PEM decoder with a clear error. / Async
base64url_encode_string, base64url_encode_bytes, base64url_decode JOSE-style base64url for JWT header/payload/signature assembly. pure

Design notes

  • Lowering: lib/codegen_deno.ml adds 5 entries to deno_builtins plus runtime helpers in the inlined prelude. HMAC/RS256 emit (await __as_*(...)) hooking into the existing fd_is_async async-fn detection (same shape as the issue stdlib: portable Http.fetch (body/headers/methods/response) #160 http_request lowering). Base64url emits plain expressions.
  • Portability: all shims route through globalThis.crypto.subtle so they run unmodified on Deno, Node 18+, and browser ESM.
  • Effect row: Async only, not a bespoke Crypto effect. Random is reserved (lib/effect.ml:139) but not yet wired, and these primitives are deterministic — introducing a fresh effect would have no consumer. Following the precedent set by Http.affine ("a bespoke effect Http; would not be visible to importing modules' effect checker"), Async is both correct and the only cross-module-sound choice.
  • hmac_sha256_verify returns Bool, not Result: GitHub webhook verification has exactly two outcomes (accept / reject) and the caller treats them as boolean. Returning Result would force callers into a match arm that always discards the error type. Malformed hex / wrong length / non-hex character all fail-closed to false.
  • rs256_sign PKCS#1 rejection at the boundary: PKCS#1 (-----BEGIN RSA PRIVATE KEY-----) is what GitHub ships; the user has to convert to PKCS#8. Rejecting PKCS#1 with "expected PKCS#8 PEM" at the shim is much friendlier than the opaque subtle.importKey "unsupported key" error that would otherwise reach the caller.

Test plan

  • dune build — clean, only pre-existing menhir warnings (per justfile masking policy)
  • dune runtest — 340 tests, 2 pre-existing failures unchanged (walker-phase2c-parity, E2E Node-CJS Codegen; neither touches Crypto or the Deno-ESM path; confirmed by stashing the diff and re-running on plain main)
  • ./tools/run_codegen_deno_tests.sh — 7/7 harnesses pass including the new crypto_primitives one
  • New tests/codegen-deno/crypto_primitives.{affine,harness.mjs} covers: happy-path HMAC verify, wrong-key reject, malformed-hex fail-closed, non-hex char fail-closed, RS256 sign with arg threading, PKCS#1 rejection with clear message, JWT-header b64u known fixture (eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9), >>>> -> Pj4-Pg for the +// -> -/_ rewrite + padding strip, non-aligned-length round-trip.

Downstream

Once this lands, the planned Oikos bot port (~2 weeks, scoped in oikos/bot-integration/ROADMAP.md) becomes mechanical translation. Any future GitHub/GitLab webhook receiver or OAuth2 / JWT client in AffineScript will reuse the same surface.

🤖 Generated with Claude Code

Adds the host-binding surface needed to express the GitHub-App webhook
signature + JWT pattern in AffineScript. The motivating consumer is the
Oikos eco/econ analysis bot (hyperpolymath/oikos PR #32, currently
ReScript+Deno), whose port to AffineScript blocks on these primitives.

stdlib/Crypto.affine grows three families of externs:

  hmac_sha256_verify(secret, body, hex_sig) -> Bool / Async
    Constant-time HMAC-SHA256 verification via crypto.subtle.verify.
    Hex-encoded signature input matches GitHub's X-Hub-Signature-256
    after the `sha256=` prefix is stripped. Fail-closed on malformed
    hex or non-hex characters (no exceptions thrown — the bot path
    treats false as "reject the webhook").

  rs256_sign(pkcs8_pem, payload) -> Bytes / Async
    RSASSA-PKCS1-v1_5 over SHA-256 (i.e. RS256). PKCS#8 PEM input
    matches what `openssl pkcs8 -topk8 -nocrypt` produces; PKCS#1
    is rejected at the runtime shim's PEM-decoder boundary with a
    clear error rather than an opaque subtle.importKey failure.

  base64url_encode_string / base64url_encode_bytes / base64url_decode
    JOSE-style base64url: `=` stripped, `+` -> `-`, `/` -> `_`.
    Pure (no Async); needed for JWT header/payload/signature assembly.

Lowerings in lib/codegen_deno.ml add five entries to `deno_builtins`
and the corresponding runtime helpers to the inlined prelude. The
HMAC and RS256 lowerings emit `(await __as_*(...))`, hooking into
the existing `Async` -> `async function` detection (`fd_is_async`).
The base64url lowerings emit plain expressions. All shims route
through `globalThis.crypto.subtle`, so they run unmodified on Deno,
Node 18+, and browser ESM (same portability story as the issue #160
http_request lowering).

Effect-row decision: `Async` only, not a bespoke `Crypto` effect.
A `Random` effect is reserved (lib/effect.ml) but not yet wired,
and these primitives are deterministic (signing/verifying with a
given key+message), so introducing a fresh effect to mark them
would have no consumer. Following the precedent set by Http.affine
("a bespoke `effect Http;` would not be visible to importing
modules' effect checker"), Async is both correct and the only
cross-module-sound choice for the JWT use case.

Test fixture tests/codegen-deno/crypto_primitives.{affine,harness.mjs}
exercises every new extern through compile -> Deno-ESM codegen ->
Node harness, with crypto.subtle stubbed via Object.defineProperty
(Node 20 makes globalThis.crypto a read-only getter). Covers happy
path, wrong-key reject, malformed-hex fail-closed, PKCS#1 reject,
JWT-header b64u fixture, and a non-aligned-length round-trip.

`dune runtest`: 340 tests, 2 pre-existing failures unchanged
(walker-phase2c-parity, E2E Node-CJS Codegen — neither touches
Crypto or the Deno-ESM path).

`./tools/run_codegen_deno_tests.sh`: 7/7 harnesses pass including
the new crypto_primitives one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hyperpolymath

Copy link
Copy Markdown
Owner Author

Closing per architectural decision (see oikos thread): the inline-JS shim approach in this PR conflicts with the Hyperpolymath RSR Standard (Idris2 ABI + Zig FFI + C ABI consumed cross-language).

A replacement will be filed once the survey of existing RSR crypto libraries is complete. The replacement will:

  • Declare HMAC-SHA256 / RS256 / base64url externs in stdlib/Crypto.affine pointing at a C ABI (hpm-crypto-rsr or whichever library the survey identifies)
  • Have Idris2 ABI definitions with safety proofs
  • Have a Zig FFI implementation using std.crypto

Branch left in place at feat/crypto-stdlib-hmac-rs256-base64url for reference / cherry-pickable test fixtures. Will delete after the replacement lands.

@github-actions

Copy link
Copy Markdown

🔍 Hypatia Security Scan

Findings: 82 issues detected

Severity Count
🔴 Critical 4
🟠 High 11
🟡 Medium 67

⚠️ Action Required: Critical security issues found!

View findings
[
  {
    "reason": "Action actions/checkout@v6 needs attention",
    "type": "unpinned_action",
    "file": "publish-jsr.yml",
    "action": "pin_sha",
    "rule_module": "workflow_audit",
    "severity": "medium"
  },
  {
    "reason": "Action denoland/setup-deno@v2 needs attention",
    "type": "unpinned_action",
    "file": "publish-jsr.yml",
    "action": "pin_sha",
    "rule_module": "workflow_audit",
    "severity": "medium"
  },
  {
    "reason": "Issue in affine-vscode-publish.yml",
    "type": "unknown",
    "file": "affine-vscode-publish.yml",
    "action": "flag",
    "rule_module": "workflow_audit",
    "severity": "medium"
  },
  {
    "reason": "Issue in casket-pages.yml",
    "type": "unknown",
    "file": "casket-pages.yml",
    "action": "flag",
    "rule_module": "workflow_audit",
    "severity": "medium"
  },
  {
    "reason": "Issue in casket-pages.yml",
    "type": "unknown",
    "file": "casket-pages.yml",
    "action": "flag",
    "rule_module": "workflow_audit",
    "severity": "medium"
  },
  {
    "reason": "Issue in ci.yml",
    "type": "unknown",
    "file": "ci.yml",
    "action": "flag",
    "rule_module": "workflow_audit",
    "severity": "medium"
  },
  {
    "reason": "Issue in ci.yml",
    "type": "unknown",
    "file": "ci.yml",
    "action": "flag",
    "rule_module": "workflow_audit",
    "severity": "medium"
  },
  {
    "reason": "Issue in ci.yml",
    "type": "unknown",
    "file": "ci.yml",
    "action": "flag",
    "rule_module": "workflow_audit",
    "severity": "medium"
  },
  {
    "reason": "Issue in ci.yml",
    "type": "unknown",
    "file": "ci.yml",
    "action": "flag",
    "rule_module": "workflow_audit",
    "severity": "medium"
  },
  {
    "reason": "Issue in ci.yml",
    "type": "unknown",
    "file": "ci.yml",
    "action": "flag",
    "rule_module": "workflow_audit",
    "severity": "medium"
  }
]

Powered by Hypatia Neurosymbolic CI/CD Intelligence

@hyperpolymath
hyperpolymath deleted the feat/crypto-stdlib-hmac-rs256-base64url branch May 28, 2026 20:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant