@sproutboat/runtime 0.37.1 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/package.json +3 -2
- package/src/native-fetch-prelude.js +5051 -0
- package/src/subtle/README.md +62 -0
- package/src/subtle/assemble.ts +152 -0
- package/src/subtle/driver.c +33 -0
- package/src/subtle/prune.test.ts +53 -0
- package/src/subtle/subtle.c +1072 -0
- package/src/subtle/subtle.test.ts +458 -0
- package/src/subtle/vendor/MONOCYPHER_LICENCE.md +167 -0
- package/src/subtle/vendor/monocypher-ed25519.c +500 -0
- package/src/subtle/vendor/monocypher-ed25519.h +140 -0
- package/src/subtle/vendor/monocypher.c +2956 -0
- package/src/subtle/vendor/monocypher.h +321 -0
- package/src/subtle-date.ts +6 -0
- package/src/wrap.ts +40 -0
- package/test/bearssl.ts +70 -0
- package/test/native-crypto.ts +15 -12
- package/test/native-jwt.ts +2 -1
- package/test/native-subtle.ts +182 -0
- package/test/native-web-apis.ts +30 -14
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# crypto.subtle, native half
|
|
2
|
+
|
|
3
|
+
`crypto.subtle` follows workerd's surface from compatibility date `__SB_SUBTLE_DATE` (a
|
|
4
|
+
placeholder constant at the top of the new block in `native-fetch-prelude.js`). The JavaScript
|
|
5
|
+
side (key objects, algorithm and usage checks, JWK, DOMException names) lives in the prelude;
|
|
6
|
+
this directory is the C it calls.
|
|
7
|
+
|
|
8
|
+
## Where the primitives come from
|
|
9
|
+
|
|
10
|
+
Nothing here implements a primitive. The rule is that anything with a vetted C implementation
|
|
11
|
+
uses it.
|
|
12
|
+
|
|
13
|
+
| Need | Source | Licence |
|
|
14
|
+
| --- | --- | --- |
|
|
15
|
+
| SHA-1/256/384/512, HMAC, HKDF | BearSSL 0.6 | MIT |
|
|
16
|
+
| RSA (private and public operations, key generation, PKCS#1 v1.5 padding), RSA-OAEP | BearSSL 0.6 | MIT |
|
|
17
|
+
| ECDSA and ECDH on P-256, P-384, P-521, EC key generation | BearSSL 0.6 | MIT |
|
|
18
|
+
| AES (constant-time), CBC, CTR, GCM | BearSSL 0.6 | MIT |
|
|
19
|
+
| Ed25519, X25519 | Monocypher 4.0.2, vendored in `vendor/` | BSD-2-Clause or CC0-1.0 |
|
|
20
|
+
|
|
21
|
+
BearSSL is already linked into every embedded sprout (standalone binaries and sproutd) for
|
|
22
|
+
TLS, so this adds no dependency there. It has no Ed25519 or X25519 signing, which is why
|
|
23
|
+
Monocypher is vendored: one small library, audited (Cure53, 2017), written for constant time,
|
|
24
|
+
public domain or BSD. The two `.c` files and two headers are verbatim from the 4.0.2 release
|
|
25
|
+
(sha256 of the tarball `38d07179738c0c90677dba3ceb7a7b8496bcfea758ba1a53e803fed30ae0879c`);
|
|
26
|
+
`assemble.ts` wraps each in preprocessor aliases so none of its short names (`u8`, `fe`, `FOR`,
|
|
27
|
+
`MIN`) leak into the shared translation unit.
|
|
28
|
+
|
|
29
|
+
What is written here is composition and checking, each of it tested against BoringSSL (Bun's
|
|
30
|
+
WebCrypto) in `subtle.test.ts`:
|
|
31
|
+
|
|
32
|
+
- RSASSA-PSS encoding and verification (EMSA-PSS with MGF1), since BearSSL has the RSA
|
|
33
|
+
primitive but no PSS.
|
|
34
|
+
- PBKDF2 (RFC 8018) over BearSSL's HMAC.
|
|
35
|
+
- The DER for SPKI and PKCS#8, and the PKCS#8 RSA reader. BearSSL's own PKCS#8 decoder
|
|
36
|
+
recomputes the public exponent and fails for primes that are 1 mod 4, which OpenSSL and
|
|
37
|
+
BoringSSL keys often have, so the structure is read directly and BearSSL gets the CRT parts.
|
|
38
|
+
- Argument and point validation (an EC public point is accepted only if it is on the curve).
|
|
39
|
+
|
|
40
|
+
## Constant time
|
|
41
|
+
|
|
42
|
+
Secret-dependent work is BearSSL's (RSA private exponentiation, ECDSA signing, AES) and
|
|
43
|
+
Monocypher's (Ed25519 signing, X25519), which are written for constant time. Comparisons of
|
|
44
|
+
MACs and signatures use a constant-time compare. Verification of public data (RSA, ECDSA,
|
|
45
|
+
PSS check) is not secret-dependent. ECDSA signatures are deterministic (RFC 6979, BearSSL's
|
|
46
|
+
HMAC-DRBG), which WebCrypto permits.
|
|
47
|
+
|
|
48
|
+
## Layout
|
|
49
|
+
|
|
50
|
+
- `subtle.c`: the C; one entry point, `sbc_op`.
|
|
51
|
+
- `vendor/`: Monocypher, verbatim, with its licence.
|
|
52
|
+
- `assemble.ts`: builds the block that is spliced into `transport-embedded.js` between the
|
|
53
|
+
`SUBTLE:BEGIN` and `SUBTLE:END` markers. Run `bun run gen:subtle` in `packages/runtime` after
|
|
54
|
+
editing `subtle.c`.
|
|
55
|
+
- `driver.c`, `subtle.test.ts`: compile the same C with the host `cc` and BearSSL from the CLI
|
|
56
|
+
cache, and compare with Bun's WebCrypto.
|
|
57
|
+
|
|
58
|
+
## Transports
|
|
59
|
+
|
|
60
|
+
Only the embedded transport has BearSSL and Monocypher. On the broker transport
|
|
61
|
+
`__sbSubtleRaw` returns an empty string and the prelude throws `NotSupportedError` for
|
|
62
|
+
anything that needs the native half (HMAC and digest never do).
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Builds the C that crypto.subtle compiles into an embedded sprout: Monocypher (vendored verbatim in
|
|
2
|
+
// vendor/) plus subtle.c, adapted for the one place it is compiled.
|
|
3
|
+
//
|
|
4
|
+
// Porffor splits inline C into a shared header and per-unit implementation files, and the splitter
|
|
5
|
+
// is a small scanner, not a C parser. Three things follow, and this file handles each:
|
|
6
|
+
// - A comment that opens a top-level chunk makes the chunk look like a variable and breaks the
|
|
7
|
+
// split, so every comment is removed (the licence travels in vendor/ and the README).
|
|
8
|
+
// - A top-level non-const static function becomes a global symbol in its unit, so every file-scope
|
|
9
|
+
// name Monocypher declares static is aliased to a prefixed one by the preprocessor, for the span
|
|
10
|
+
// of the file. Its short typedef names (u8, fe) and macros (FOR, MIN) are aliased or undefined
|
|
11
|
+
// the same way, so none of them reaches the rest of the translation unit, which also holds
|
|
12
|
+
// Porffor's generated code and SQLite glue.
|
|
13
|
+
// - Preprocessor lines are copied to both halves in order, so the aliases hold on both sides.
|
|
14
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
|
|
16
|
+
const here = (name: string) => new URL(name, import.meta.url);
|
|
17
|
+
const read = (name: string) => readFileSync(here(name), "utf8");
|
|
18
|
+
|
|
19
|
+
/** Monocypher's internal typedef names. Each is aliased by the preprocessor across the whole block. */
|
|
20
|
+
const INTERNAL_NAMES = ["i8", "u8", "i16", "u32", "i32", "i64", "u64", "fe", "ge", "ge_cached", "ge_precomp", "blk"];
|
|
21
|
+
|
|
22
|
+
export const SUBTLE_BEGIN =
|
|
23
|
+
"// SUBTLE:BEGIN (generated by `bun run gen:subtle`; edit src/subtle/subtle.c, not this block)";
|
|
24
|
+
export const SUBTLE_END = "// SUBTLE:END";
|
|
25
|
+
|
|
26
|
+
/** Removes C comments, leaving strings and character constants alone. */
|
|
27
|
+
export function stripComments(source: string): string {
|
|
28
|
+
let out = "";
|
|
29
|
+
for (let i = 0; i < source.length;) {
|
|
30
|
+
const c = source[i];
|
|
31
|
+
if (c === '"' || c === "'") {
|
|
32
|
+
let j = i + 1;
|
|
33
|
+
while (source[j] !== c) j += source[j] === "\\" ? 2 : 1;
|
|
34
|
+
out += source.slice(i, j + 1);
|
|
35
|
+
i = j + 1;
|
|
36
|
+
} else if (c === "/" && source[i + 1] === "/") {
|
|
37
|
+
while (i < source.length && source[i] !== "\n") i++;
|
|
38
|
+
} else if (c === "/" && source[i + 1] === "*") {
|
|
39
|
+
i = source.indexOf("*/", i + 2) + 2;
|
|
40
|
+
out += " ";
|
|
41
|
+
} else {
|
|
42
|
+
out += c;
|
|
43
|
+
i++;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const tidy = (source: string) =>
|
|
50
|
+
stripComments(source)
|
|
51
|
+
.split("\n")
|
|
52
|
+
.map((line) => line.replace(/\s+$/, ""))
|
|
53
|
+
.filter((line, index, all) => line !== "" || (index > 0 && all[index - 1] !== ""))
|
|
54
|
+
.join("\n");
|
|
55
|
+
|
|
56
|
+
export function assembleSubtleC(): string {
|
|
57
|
+
// The C++ guards (a namespace, extern "C") would swallow the rest of the block in the splitter.
|
|
58
|
+
const strip = (name: string) =>
|
|
59
|
+
tidy(read(name))
|
|
60
|
+
.replace(/^#include "monocypher[^"]*"\s*$/gm, "")
|
|
61
|
+
.replace(/^#ifdef (?:MONOCYPHER_CPP_NAMESPACE|__cplusplus)\n[\s\S]*?^#endif\n/gm, "");
|
|
62
|
+
// Each vendored .c file was written to be its own translation unit: its macros end with it, and
|
|
63
|
+
// its static names get a prefix of their own (both define `hash_reduce`, differently).
|
|
64
|
+
const unit = (name: string, index: number) => {
|
|
65
|
+
const body = strip(name);
|
|
66
|
+
const macros = [...new Set([...body.matchAll(/^#define\s+(\w+)/gm)].map((m) => m[1]))];
|
|
67
|
+
const statics = [
|
|
68
|
+
...new Set([...body.matchAll(/^static\b([^;{(=]*?)\b(\w+)\s*(?:\(|\[|=|;)/gm)].map((m) => m[2])),
|
|
69
|
+
].filter((n) => !INTERNAL_NAMES.includes(n));
|
|
70
|
+
return [
|
|
71
|
+
...statics.map((n) => `#define ${n} sbm${index}_${n}`),
|
|
72
|
+
body,
|
|
73
|
+
...macros.map((n) => `#undef ${n}`),
|
|
74
|
+
...statics.map((n) => `#undef ${n}`),
|
|
75
|
+
].join("\n");
|
|
76
|
+
};
|
|
77
|
+
const headers = ["vendor/monocypher.h", "vendor/monocypher-ed25519.h"].map(strip).join("\n");
|
|
78
|
+
const alias = INTERNAL_NAMES.map((n) => `#define ${n} sbm_${n}`).join("\n");
|
|
79
|
+
const unalias = INTERNAL_NAMES.map((n) => `#undef ${n}`).join("\n");
|
|
80
|
+
return [
|
|
81
|
+
"#include <bearssl.h>",
|
|
82
|
+
"// Monocypher 4.0.2 (BSD-2-Clause OR CC0-1.0), Loup Vaillant: vendor/monocypher*.[ch] without comments.",
|
|
83
|
+
alias,
|
|
84
|
+
headers,
|
|
85
|
+
unit("vendor/monocypher.c", 1),
|
|
86
|
+
unit("vendor/monocypher-ed25519.c", 2),
|
|
87
|
+
unalias,
|
|
88
|
+
"// End of Monocypher.",
|
|
89
|
+
tidy(read("subtle.c")),
|
|
90
|
+
].join("\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* One call into sbc_op. `op` is a decimal string; a, b, c, d are byte strings (latin1, one char per
|
|
95
|
+
* byte). Returns a byte string whose first byte is the status (0 ok, 1 DataError, 2 OperationError,
|
|
96
|
+
* 3 NotSupportedError) and whose rest is the result. Sits inside the markers so that pruning the
|
|
97
|
+
* block (wrap.ts, for a handler that never touches crypto.subtle) removes it with the C it calls.
|
|
98
|
+
*/
|
|
99
|
+
const SUBTLE_RAW = `// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
|
|
100
|
+
function __sbSubtleRaw(op, a, b, c, d) {
|
|
101
|
+
let out = "";
|
|
102
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c\`...\` is inline C the compiler consumes, not a JS expression.
|
|
103
|
+
Porffor.c\`
|
|
104
|
+
const char* __os; size_t __osl; char* __oso = 0;
|
|
105
|
+
porf_native_fetch_read_value(op, &__os, &__osl, &__oso);
|
|
106
|
+
char __ob[8]; size_t __ok = __osl < 7 ? __osl : 7;
|
|
107
|
+
memcpy(__ob, __os, __ok); __ob[__ok] = 0;
|
|
108
|
+
if (__oso) free(__oso);
|
|
109
|
+
int __op = atoi(__ob);
|
|
110
|
+
|
|
111
|
+
const char* __pa = ""; size_t __pal = 0; porf_native_fetch_read_raw_bytes(a, &__pa, &__pal);
|
|
112
|
+
const char* __pb = ""; size_t __pbl = 0; porf_native_fetch_read_raw_bytes(b, &__pb, &__pbl);
|
|
113
|
+
const char* __pc = ""; size_t __pcl = 0; porf_native_fetch_read_raw_bytes(c, &__pc, &__pcl);
|
|
114
|
+
const char* __pd = ""; size_t __pdl = 0; porf_native_fetch_read_raw_bytes(d, &__pd, &__pdl);
|
|
115
|
+
unsigned char* __res = 0; size_t __resl = 0;
|
|
116
|
+
int __rc = sbc_op(__op, (const unsigned char*)__pa, __pal, (const unsigned char*)__pb, __pbl,
|
|
117
|
+
(const unsigned char*)__pc, __pcl, (const unsigned char*)__pd, __pdl, &__res, &__resl);
|
|
118
|
+
unsigned char* __w = (unsigned char*)malloc(__resl + 1);
|
|
119
|
+
if (__w) {
|
|
120
|
+
__w[0] = (unsigned char)__rc;
|
|
121
|
+
if (__resl) memcpy(__w + 1, __res, __resl);
|
|
122
|
+
out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__w, __resl + 1), 195);
|
|
123
|
+
free(__w);
|
|
124
|
+
}
|
|
125
|
+
free(__res);
|
|
126
|
+
\`;
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
/** The prelude with the generated block (re)written between its markers. */
|
|
132
|
+
export function spliceSubtle(prelude: string): string {
|
|
133
|
+
const start = prelude.indexOf(SUBTLE_BEGIN);
|
|
134
|
+
const end = prelude.indexOf(SUBTLE_END);
|
|
135
|
+
if (start < 0 || end < start) throw new Error("native-fetch-prelude.js has no SUBTLE markers");
|
|
136
|
+
const block = assembleSubtleC();
|
|
137
|
+
if (/`|\$\{/.test(block)) throw new Error("subtle C contains a backtick or ${: it is spliced into a JS template");
|
|
138
|
+
return (
|
|
139
|
+
prelude.slice(0, start) +
|
|
140
|
+
SUBTLE_BEGIN +
|
|
141
|
+
"\n// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.\nPorffor.c`\n" +
|
|
142
|
+
block +
|
|
143
|
+
"\n`;\n" +
|
|
144
|
+
SUBTLE_RAW +
|
|
145
|
+
prelude.slice(end)
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (import.meta.main) {
|
|
150
|
+
const path = new URL("../native-fetch-prelude.js", import.meta.url);
|
|
151
|
+
writeFileSync(path, spliceSubtle(readFileSync(path, "utf8")));
|
|
152
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/* Test driver for subtle.c: reads "op a b c d" lines (hex, "-" for empty), prints "rc hex". */
|
|
2
|
+
#include <stdio.h>
|
|
3
|
+
#include <stdlib.h>
|
|
4
|
+
#include <string.h>
|
|
5
|
+
#include <fcntl.h>
|
|
6
|
+
#include <unistd.h>
|
|
7
|
+
#include <stdint.h>
|
|
8
|
+
#include <bearssl.h>
|
|
9
|
+
#include "assembled.c"
|
|
10
|
+
|
|
11
|
+
static unsigned char* unhex(const char* s, size_t* n) {
|
|
12
|
+
if (!strcmp(s, "-")) { *n = 0; return (unsigned char*)calloc(1, 1); }
|
|
13
|
+
size_t l = strlen(s) / 2; unsigned char* o = (unsigned char*)malloc(l + 1);
|
|
14
|
+
for (size_t i = 0; i < l; i++) { unsigned v; sscanf(s + 2 * i, "%2x", &v); o[i] = (unsigned char)v; }
|
|
15
|
+
*n = l; return o;
|
|
16
|
+
}
|
|
17
|
+
int main(void) {
|
|
18
|
+
static char line[1 << 20];
|
|
19
|
+
while (fgets(line, sizeof line, stdin)) {
|
|
20
|
+
char* t[5]; int k = 0;
|
|
21
|
+
for (char* p = strtok(line, " \n"); p && k < 5; p = strtok(0, " \n")) t[k++] = p;
|
|
22
|
+
if (k < 5) continue;
|
|
23
|
+
size_t l[4]; unsigned char* a[4];
|
|
24
|
+
for (int i = 0; i < 4; i++) a[i] = unhex(t[i + 1], &l[i]);
|
|
25
|
+
unsigned char* out; size_t ol;
|
|
26
|
+
int rc = sbc_op(atoi(t[0]), a[0], l[0], a[1], l[1], a[2], l[2], a[3], l[3], &out, &ol);
|
|
27
|
+
printf("%d ", rc);
|
|
28
|
+
for (size_t i = 0; i < ol; i++) printf("%02x", out[i]);
|
|
29
|
+
printf("\n"); fflush(stdout);
|
|
30
|
+
free(out); for (int i = 0; i < 4; i++) free(a[i]);
|
|
31
|
+
}
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { preludePath, pruneSubtle, wrapNativeFetchHandler } from "../wrap";
|
|
4
|
+
|
|
5
|
+
const prelude = readFileSync(preludePath, "utf8");
|
|
6
|
+
const handler = (body: string) => `export default { async fetch() { ${body} return new Response("x"); } };`;
|
|
7
|
+
|
|
8
|
+
test("pruning removes the workerd-style crypto.subtle, its C and its glue, and nothing else", () => {
|
|
9
|
+
const pruned = pruneSubtle(prelude);
|
|
10
|
+
for (const gone of [
|
|
11
|
+
"__sbSubtleNext",
|
|
12
|
+
"__sbSubtleLegacy",
|
|
13
|
+
"SUBTLE:",
|
|
14
|
+
"const __SB_SUBTLE_DATE",
|
|
15
|
+
"sbc_op",
|
|
16
|
+
"crypto_ed25519_sign",
|
|
17
|
+
"__sbSubtleRaw",
|
|
18
|
+
"class DigestStream",
|
|
19
|
+
])
|
|
20
|
+
expect(pruned).not.toContain(gone);
|
|
21
|
+
for (const kept of ["globalThis.crypto.subtle = {", "sb_sha256_block", "__sbHmacRaw"]) expect(pruned).toContain(kept);
|
|
22
|
+
expect(pruned.length).toBeLessThan(prelude.length - 150_000);
|
|
23
|
+
// What is left is still JavaScript.
|
|
24
|
+
new Bun.Transpiler().transformSync(pruned);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("auto keeps the code for a handler that names crypto.subtle, DigestStream or CryptoKey", () => {
|
|
28
|
+
const wrap = (source: string, mode: "auto" | "always") =>
|
|
29
|
+
wrapNativeFetchHandler(
|
|
30
|
+
source,
|
|
31
|
+
prelude,
|
|
32
|
+
{},
|
|
33
|
+
undefined,
|
|
34
|
+
undefined,
|
|
35
|
+
"2026-11-06",
|
|
36
|
+
"app",
|
|
37
|
+
undefined,
|
|
38
|
+
"embedded",
|
|
39
|
+
undefined,
|
|
40
|
+
mode,
|
|
41
|
+
);
|
|
42
|
+
expect(wrap(handler("await crypto.subtle.digest('SHA-256', new Uint8Array(1));"), "auto")).toContain(
|
|
43
|
+
"__sbSubtleNext",
|
|
44
|
+
);
|
|
45
|
+
expect(wrap(handler("new crypto.DigestStream('SHA-1');"), "auto")).toContain("__sbSubtleNext");
|
|
46
|
+
expect(wrap(handler("if (k instanceof CryptoKey) {}"), "auto")).toContain("__sbSubtleNext");
|
|
47
|
+
expect(wrap(handler(""), "auto")).not.toContain("__sbSubtleNext");
|
|
48
|
+
expect(wrap(handler(""), "always")).toContain("__sbSubtleNext");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a prelude without the markers is an error, not a silent no-op", () => {
|
|
52
|
+
expect(() => pruneSubtle("function f() {}")).toThrow(/no \/\/ SUBTLE-JS:BEGIN/);
|
|
53
|
+
});
|