Skip to content

Commit 6728273

Browse files
authored
feat(rsc): encrypt closure bind values (#897)
## todo - [x] no-op - [x] flight encode/decode - [x] use `serverModuleMap` facebook/react#31300 - [x] fix `Failed to serialize an action for progressive enhancement` - ah, it looks like we need base64 string for bind value ``` Failed to serialize an action for progressive enhancement: Error: File/Blob fields are not yet supported in progressive forms. Will fallback to client hydration. at validateAdditionalFormField (/home/hiroshi/code/personal/vite-plugins/node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-server.edge.development.js:1266:15) ... ``` - [x] encryption key
1 parent 7eb11e8 commit 6728273

7 files changed

Lines changed: 213 additions & 0 deletions

File tree

packages/react-server/src/features/server-action/plugin.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ export function vitePluginServerUseServer({
111111
runtime: (value, name) =>
112112
`$$ReactServer.registerServerReference(${value}, ${JSON.stringify(serverId)}, ${JSON.stringify(name)})`,
113113
rejectNonAsyncFunction: true,
114+
// TODO: encryption
115+
encode: (value) => value,
116+
decode: (value) => value,
114117
});
115118
if (output.hasChanged()) {
116119
manager.serverReferenceMap.set(id, serverId);

packages/react-server/src/plugin/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,7 @@ function serverDepsConfigPlugin(): Plugin {
558558
"react/jsx-runtime",
559559
"react/jsx-dev-runtime",
560560
"react-server-dom-webpack/server.edge",
561+
"react-server-dom-webpack/client.edge",
561562
],
562563
},
563564
};

packages/rsc/examples/basic/vite.config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ export default defineConfig({
1919
},
2020
}),
2121
Inspect(),
22+
{
23+
name: "show-encryption-key",
24+
enforce: "post",
25+
configEnvironment(name, config) {
26+
if (name === "rsc") {
27+
console.log(
28+
"[encryption key]",
29+
config.define?.__VITE_RSC_ENCRYPTION_KEY__,
30+
);
31+
}
32+
},
33+
},
2234
{
2335
// test server restart scenario on e2e
2436
name: "test-api",

packages/rsc/src/plugin.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
import type { ModuleRunner } from "vite/module-runner";
2323
import { crawlFrameworkPkgs } from "vitefu";
2424
import vitePluginRscCore from "./core/plugin";
25+
import { generateEncryptionKey, toBase64 } from "./utils/encryption-utils";
2526
import { normalizeViteImportAnalysisUrl } from "./vite-utils";
2627

2728
// state for build orchestration
@@ -123,6 +124,7 @@ export default function vitePluginRsc({
123124
"react/jsx-runtime",
124125
"react/jsx-dev-runtime",
125126
"react-server-dom-webpack/server.edge",
127+
"react-server-dom-webpack/client.edge",
126128
],
127129
exclude: [PKG_NAME],
128130
},
@@ -662,6 +664,19 @@ function vitePluginUseServer(): Plugin[] {
662664
return [
663665
{
664666
name: "rsc:use-server",
667+
async configEnvironment(name, config) {
668+
if (name === "rsc") {
669+
// define default encryption key at build time.
670+
// users can override e.g. by { define: { __VITE_RSC_ENCRYPTION_KEY__: 'process.env.MY_KEY' } }
671+
config.define ??= {};
672+
if (!config.define["__VITE_RSC_ENCRYPTION_KEY__"]) {
673+
const encryptionKey = await generateEncryptionKey();
674+
config.define["__VITE_RSC_ENCRYPTION_KEY__"] = JSON.stringify(
675+
toBase64(encryptionKey),
676+
);
677+
}
678+
}
679+
},
665680
async transform(code, id) {
666681
if (!code.includes("use server")) return;
667682
const ast = await parseAstAsync(code);
@@ -675,6 +690,9 @@ function vitePluginUseServer(): Plugin[] {
675690
runtime: (value, name) =>
676691
`$$ReactServer.registerServerReference(${value}, ${JSON.stringify(normalizedId)}, ${JSON.stringify(name)})`,
677692
rejectNonAsyncFunction: true,
693+
encode: (value) => `$$ReactServer.encryptActionBoundArgs(${value})`,
694+
decode: (value) =>
695+
`await $$ReactServer.decryptActionBoundArgs(${value})`,
678696
});
679697
if (!output.hasChanged()) return;
680698
serverReferences[normalizedId] = id;

packages/rsc/src/react/rsc.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { ReactFormState } from "react-dom/client";
22
// @ts-ignore
3+
import * as ReactClient from "react-server-dom-webpack/client.edge";
4+
// @ts-ignore
35
import * as ReactServer from "react-server-dom-webpack/server.edge";
46
import { createClientManifest, createServerManifest } from "../core/rsc";
57

@@ -16,6 +18,20 @@ export function renderToReadableStream<T>(
1618
);
1719
}
1820

21+
export function createFromReadableStream<T>(
22+
stream: ReadableStream<Uint8Array>,
23+
options: object = {},
24+
): Promise<T> {
25+
return ReactClient.createFromReadableStream(stream, {
26+
serverConsumerManifest: {
27+
// https://github.com/facebook/react/pull/31300
28+
// https://github.com/vercel/next.js/pull/71527
29+
serverModuleMap: createServerManifest(),
30+
},
31+
...options,
32+
});
33+
}
34+
1935
export function registerClientReference<T>(
2036
proxy: T,
2137
id: string,

packages/rsc/src/rsc.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@ import * as assetsManifest from "virtual:vite-rsc/assets-manifest";
22
import * as serverReferences from "virtual:vite-rsc/server-references";
33
import { setRequireModule } from "./core/rsc";
44
import type { AssetsManifest } from "./plugin";
5+
import { createFromReadableStream, renderToReadableStream } from "./rsc";
56
import { withBase } from "./utils/base";
7+
import {
8+
arrayToStream,
9+
concatArrayStream,
10+
decryptBuffer,
11+
encryptBuffer,
12+
fromBase64,
13+
} from "./utils/encryption-utils";
614

715
export {
816
createClientManifest,
@@ -72,3 +80,43 @@ export async function Resources({
7280
</>
7381
);
7482
}
83+
84+
// based on
85+
// https://github.com/parcel-bundler/parcel/blob/9855f558a69edde843b1464f39a6010f6b421efe/packages/transformers/js/src/rsc-utils.js
86+
// https://github.com/vercel/next.js/blob/c10c10daf9e95346c31c24dc49d6b7cda48b5bc8/packages/next/src/server/app-render/encryption.ts
87+
// https://github.com/vercel/next.js/pull/56377
88+
89+
export async function encryptActionBoundArgs(
90+
originalValue: unknown,
91+
): Promise<string> {
92+
const serialized = renderToReadableStream(originalValue);
93+
const serializedBuffer = await concatArrayStream(serialized);
94+
return encryptBuffer(serializedBuffer, await getEncryptionKey());
95+
}
96+
97+
export async function decryptActionBoundArgs(
98+
encrypted: ReturnType<typeof encryptActionBoundArgs>,
99+
): Promise<unknown> {
100+
const serializedBuffer = await decryptBuffer(
101+
await encrypted,
102+
await getEncryptionKey(),
103+
);
104+
const serialized = arrayToStream(new Uint8Array(serializedBuffer));
105+
return createFromReadableStream(serialized);
106+
}
107+
108+
// configurable via `define.__VITE_RSC_ENCRYPTION_KEY__`
109+
declare let __VITE_RSC_ENCRYPTION_KEY__: string;
110+
let keyPromise_: Promise<CryptoKey> | undefined;
111+
112+
function getEncryptionKey() {
113+
return (keyPromise_ ||= crypto.subtle.importKey(
114+
"raw",
115+
fromBase64(__VITE_RSC_ENCRYPTION_KEY__),
116+
{
117+
name: "AES-GCM",
118+
},
119+
true,
120+
["encrypt", "decrypt"],
121+
));
122+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// based on
2+
// https://github.com/vercel/next.js/blob/a0993d90c280690e83a2a1bc7c292e1187429fe8/packages/next/src/server/app-render/encryption-utils.ts
3+
4+
function arrayBufferToString(buffer: ArrayBuffer | Uint8Array): string {
5+
const bytes = new Uint8Array(buffer);
6+
const len = bytes.byteLength;
7+
if (len < 65535) {
8+
return String.fromCharCode.apply(null, bytes as unknown as number[]);
9+
}
10+
let binary = "";
11+
for (let i = 0; i < len; i++) {
12+
binary += String.fromCharCode(bytes[i]!);
13+
}
14+
return binary;
15+
}
16+
17+
function stringToUint8Array(binary: string): Uint8Array {
18+
const len = binary.length;
19+
const arr = new Uint8Array(len);
20+
for (let i = 0; i < len; i++) {
21+
arr[i] = binary.charCodeAt(i);
22+
}
23+
return arr;
24+
}
25+
26+
function concatArray(chunks: Uint8Array[]): Uint8Array {
27+
let total = 0;
28+
for (const chunk of chunks) {
29+
total += chunk.length;
30+
}
31+
const result = new Uint8Array(total);
32+
let offset = 0;
33+
for (const chunk of chunks) {
34+
result.set(chunk, offset);
35+
offset += chunk.length;
36+
}
37+
return result;
38+
}
39+
40+
export async function concatArrayStream(
41+
stream: ReadableStream<Uint8Array>,
42+
): Promise<Uint8Array> {
43+
const chunks: Uint8Array[] = [];
44+
await stream.pipeTo(
45+
new WritableStream({
46+
write(chunk) {
47+
chunks.push(chunk);
48+
},
49+
}),
50+
);
51+
return concatArray(chunks);
52+
}
53+
54+
export function arrayToStream(data: Uint8Array): ReadableStream<Uint8Array> {
55+
return new ReadableStream({
56+
start(controller) {
57+
controller.enqueue(data);
58+
controller.close();
59+
},
60+
});
61+
}
62+
63+
export function toBase64(buffer: Uint8Array): string {
64+
return btoa(arrayBufferToString(buffer));
65+
}
66+
67+
export function fromBase64(data: string): Uint8Array {
68+
return stringToUint8Array(atob(data));
69+
}
70+
71+
export async function generateEncryptionKey(): Promise<Uint8Array> {
72+
const key = await crypto.subtle.generateKey(
73+
{
74+
name: "AES-GCM",
75+
length: 256,
76+
},
77+
true,
78+
["encrypt", "decrypt"],
79+
);
80+
const exported = await crypto.subtle.exportKey("raw", key);
81+
return new Uint8Array(exported);
82+
}
83+
84+
export async function encryptBuffer(
85+
data: BufferSource,
86+
key: CryptoKey,
87+
): Promise<string> {
88+
const iv = crypto.getRandomValues(new Uint8Array(16));
89+
const encrypted = await crypto.subtle.encrypt(
90+
{
91+
name: "AES-GCM",
92+
iv,
93+
},
94+
key,
95+
data,
96+
);
97+
return toBase64(concatArray([iv, new Uint8Array(encrypted)]));
98+
}
99+
100+
export async function decryptBuffer(
101+
encryptedString: string,
102+
key: CryptoKey,
103+
): Promise<ArrayBuffer> {
104+
const concatenated = fromBase64(encryptedString);
105+
const iv = concatenated.slice(0, 16);
106+
const encrypted = concatenated.slice(16);
107+
return crypto.subtle.decrypt(
108+
{
109+
name: "AES-GCM",
110+
iv,
111+
},
112+
key,
113+
encrypted,
114+
);
115+
}

0 commit comments

Comments
 (0)