@botiverse/porthole 0.1.1 → 0.1.2
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/dist/compile/policy.d.ts +3 -2
- package/dist/compile/policy.js +4 -3
- package/dist/config/schema.d.ts +8 -0
- package/dist/config/schema.js +5 -0
- package/dist/config/validate.js +15 -1
- package/dist/host/outcome.d.ts +1 -0
- package/dist/host/outcome.js +19 -6
- package/dist/host/types.d.ts +10 -5
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/presets/presets.d.ts +3 -1
- package/dist/presets/presets.js +1 -0
- package/dist/server/crypto.d.ts +19 -0
- package/dist/server/crypto.js +144 -0
- package/dist/server/index.d.ts +12 -4
- package/dist/server/index.js +11 -3
- package/dist/server/keys.js +12 -9
- package/dist/server/naming.d.ts +17 -0
- package/dist/server/naming.js +34 -0
- package/dist/server/pages.d.ts +1 -1
- package/dist/server/pages.js +13 -5
- package/dist/server/server.js +162 -18
- package/dist/server/tickets.d.ts +24 -0
- package/dist/server/tickets.js +46 -0
- package/dist/server/types.d.ts +76 -5
- package/package.json +2 -1
- package/src/compile/policy.ts +4 -3
- package/src/config/schema.ts +10 -0
- package/src/config/validate.ts +16 -1
- package/src/host/outcome.ts +25 -7
- package/src/host/types.ts +14 -5
- package/src/index.ts +9 -1
- package/src/presets/presets.ts +4 -0
- package/src/server/crypto.ts +166 -0
- package/src/server/index.ts +20 -3
- package/src/server/keys.ts +21 -13
- package/src/server/naming.ts +42 -0
- package/src/server/pages.ts +13 -4
- package/src/server/server.ts +190 -20
- package/src/server/tickets.ts +65 -0
- package/src/server/types.ts +84 -7
package/src/host/outcome.ts
CHANGED
|
@@ -25,6 +25,7 @@ const BLOCKS: readonly FrameBlock[] = [
|
|
|
25
25
|
"cookies",
|
|
26
26
|
"session",
|
|
27
27
|
"upstream",
|
|
28
|
+
"denied",
|
|
28
29
|
"not-found",
|
|
29
30
|
"error",
|
|
30
31
|
];
|
|
@@ -33,6 +34,7 @@ interface FrameStatus {
|
|
|
33
34
|
readonly state: "entering" | "blocked";
|
|
34
35
|
readonly reason?: FrameBlock;
|
|
35
36
|
readonly status?: number;
|
|
37
|
+
readonly code?: string;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
/** A frame-status message, or null for anything else. */
|
|
@@ -44,7 +46,12 @@ export function parseFrameStatus(data: unknown): FrameStatus | null {
|
|
|
44
46
|
if (message.jsonrpc !== "2.0" || message.method !== FRAME_STATUS_METHOD) {
|
|
45
47
|
return null;
|
|
46
48
|
}
|
|
47
|
-
const params = message.params as {
|
|
49
|
+
const params = message.params as {
|
|
50
|
+
state?: unknown;
|
|
51
|
+
reason?: unknown;
|
|
52
|
+
status?: unknown;
|
|
53
|
+
code?: unknown;
|
|
54
|
+
} | null;
|
|
48
55
|
if (params === null || typeof params !== "object") {
|
|
49
56
|
return null;
|
|
50
57
|
}
|
|
@@ -63,6 +70,9 @@ export function parseFrameStatus(data: unknown): FrameStatus | null {
|
|
|
63
70
|
...(typeof params.status === "number" && Number.isInteger(params.status)
|
|
64
71
|
? { status: params.status }
|
|
65
72
|
: {}),
|
|
73
|
+
...(typeof params.code === "string" && /^[a-z0-9-]{1,40}$/u.test(params.code)
|
|
74
|
+
? { code: params.code }
|
|
75
|
+
: {}),
|
|
66
76
|
};
|
|
67
77
|
}
|
|
68
78
|
|
|
@@ -82,7 +92,8 @@ export function watchOutcome(options: {
|
|
|
82
92
|
}): OutcomeWatch {
|
|
83
93
|
const { surface, source, speaks } = options;
|
|
84
94
|
const reports = source.kind === "url" && source.reports === true;
|
|
85
|
-
|
|
95
|
+
// The first outcome; after a load, only a later block (a status page, access revoked) is told.
|
|
96
|
+
let decided: FrameOutcome["state"] | null = null;
|
|
86
97
|
let heard = false;
|
|
87
98
|
let entering = false;
|
|
88
99
|
let loads = 0;
|
|
@@ -94,18 +105,24 @@ export function watchOutcome(options: {
|
|
|
94
105
|
}, ms);
|
|
95
106
|
timers.add(timer);
|
|
96
107
|
};
|
|
97
|
-
const
|
|
108
|
+
const clearTimers = () => {
|
|
98
109
|
for (const timer of timers) {
|
|
99
110
|
clearTimeout(timer);
|
|
100
111
|
}
|
|
101
112
|
timers.clear();
|
|
113
|
+
};
|
|
114
|
+
const stop = () => {
|
|
115
|
+
clearTimers();
|
|
102
116
|
unlisten();
|
|
103
117
|
unload();
|
|
104
118
|
};
|
|
105
119
|
const decide = (outcome: FrameOutcome) => {
|
|
106
|
-
if (
|
|
107
|
-
decided =
|
|
108
|
-
|
|
120
|
+
if (decided === null) {
|
|
121
|
+
decided = outcome.state;
|
|
122
|
+
clearTimers();
|
|
123
|
+
options.onOutcome(outcome);
|
|
124
|
+
} else if (decided === "loaded" && outcome.state === "blocked") {
|
|
125
|
+
decided = "blocked";
|
|
109
126
|
options.onOutcome(outcome);
|
|
110
127
|
}
|
|
111
128
|
};
|
|
@@ -119,6 +136,7 @@ export function watchOutcome(options: {
|
|
|
119
136
|
state: "blocked",
|
|
120
137
|
reason: status.reason ?? "error",
|
|
121
138
|
...(status.status === undefined ? {} : { status: status.status }),
|
|
139
|
+
...(status.code === undefined ? {} : { code: status.code }),
|
|
122
140
|
});
|
|
123
141
|
}
|
|
124
142
|
});
|
|
@@ -130,7 +148,7 @@ export function watchOutcome(options: {
|
|
|
130
148
|
});
|
|
131
149
|
const unload = surface.onLoad(() => {
|
|
132
150
|
loads += 1;
|
|
133
|
-
if (decided) {
|
|
151
|
+
if (decided !== null) {
|
|
134
152
|
return;
|
|
135
153
|
}
|
|
136
154
|
if (speaks) {
|
package/src/host/types.ts
CHANGED
|
@@ -62,18 +62,27 @@ export type FrameBlock =
|
|
|
62
62
|
| "session"
|
|
63
63
|
/** Its app didn't answer. */
|
|
64
64
|
| "upstream"
|
|
65
|
+
/** The host's own check refused it (revoked, no longer a member): `code` says which. */
|
|
66
|
+
| "denied"
|
|
65
67
|
| "not-found"
|
|
66
68
|
| "error";
|
|
67
69
|
|
|
68
70
|
/**
|
|
69
|
-
* How a display's
|
|
70
|
-
*
|
|
71
|
-
* `limits.loadTimeoutMs`.
|
|
72
|
-
* loaded.
|
|
71
|
+
* How a display's document went: it loaded (a shell or view finished its handshake; a url-mode
|
|
72
|
+
* app's document loaded), it was blocked (and why), or nothing settled in
|
|
73
|
+
* `limits.loadTimeoutMs`. Once per run, plus a later `blocked` if the frame shows a status page
|
|
74
|
+
* after it loaded (access revoked mid-session). A host shows its own fallback ("Open in a new
|
|
75
|
+
* tab") on anything but loaded.
|
|
73
76
|
*/
|
|
74
77
|
export type FrameOutcome =
|
|
75
78
|
| { readonly state: "loaded" }
|
|
76
|
-
| {
|
|
79
|
+
| {
|
|
80
|
+
readonly state: "blocked";
|
|
81
|
+
readonly reason: FrameBlock;
|
|
82
|
+
readonly status?: number;
|
|
83
|
+
/** The host's own reason code, for `denied` (`Authorizer`). */
|
|
84
|
+
readonly code?: string;
|
|
85
|
+
}
|
|
77
86
|
| { readonly state: "timeout" };
|
|
78
87
|
|
|
79
88
|
export interface DisplayEvents {
|
package/src/index.ts
CHANGED
|
@@ -38,8 +38,14 @@ export {
|
|
|
38
38
|
originSourceProblem,
|
|
39
39
|
validateConfig,
|
|
40
40
|
} from "./config/validate.ts";
|
|
41
|
-
export {
|
|
41
|
+
export {
|
|
42
|
+
URL_PERMISSIONS,
|
|
43
|
+
URL_SANDBOX_EXTRAS,
|
|
44
|
+
type UrlPermission,
|
|
45
|
+
type UrlSandboxExtra,
|
|
46
|
+
} from "./config/schema.ts";
|
|
42
47
|
export { hostOriginProblem, isHostOrigin } from "./isolation/served.ts";
|
|
48
|
+
export { FRAME_STATUS_METHOD } from "./isolation/types.ts";
|
|
43
49
|
|
|
44
50
|
// Presets.
|
|
45
51
|
export {
|
|
@@ -121,6 +127,8 @@ export { createHost } from "./host/create-host.ts";
|
|
|
121
127
|
export type {
|
|
122
128
|
DisplayControls,
|
|
123
129
|
DisplayEvents,
|
|
130
|
+
FrameBlock,
|
|
131
|
+
FrameOutcome,
|
|
124
132
|
DisplayHandle,
|
|
125
133
|
HostOptions,
|
|
126
134
|
MountOptions,
|
package/src/presets/presets.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
Limits,
|
|
11
11
|
PortholeConfig,
|
|
12
12
|
ThemeValue,
|
|
13
|
+
UrlPermission,
|
|
13
14
|
UrlSandboxExtra,
|
|
14
15
|
} from "../config/schema.ts";
|
|
15
16
|
import { validateConfig } from "../config/validate.ts";
|
|
@@ -51,6 +52,8 @@ export interface UrlPresetOptions {
|
|
|
51
52
|
readonly contentOrigins: readonly string[];
|
|
52
53
|
/** Sandbox tokens to add for the app (popups for an OAuth flow, modals, downloads; P037). */
|
|
53
54
|
readonly sandbox?: readonly UrlSandboxExtra[];
|
|
55
|
+
/** Features to delegate to the app (clipboard writes, full screen; P049). */
|
|
56
|
+
readonly permissions?: readonly UrlPermission[];
|
|
54
57
|
readonly limits?: Partial<Limits>;
|
|
55
58
|
readonly layout?: Partial<LayoutConfig>;
|
|
56
59
|
readonly embedder?: EmbedderConfig;
|
|
@@ -211,6 +214,7 @@ export const presets = {
|
|
|
211
214
|
contentOrigins: options.contentOrigins,
|
|
212
215
|
embedder: options.embedder ?? {},
|
|
213
216
|
...(options.sandbox === undefined ? {} : { sandbox: options.sandbox }),
|
|
217
|
+
...(options.permissions === undefined ? {} : { permissions: options.permissions }),
|
|
214
218
|
}),
|
|
215
219
|
} as const;
|
|
216
220
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hashing `/server` needs, in plain JavaScript over web-standard types (`Uint8Array`,
|
|
3
|
+
* `TextEncoder`, `crypto.getRandomValues`), so it runs the same on Node, Cloudflare Workers and
|
|
4
|
+
* any other runtime (P044). Synchronous on purpose: Web Crypto's HMAC is async, and an origin
|
|
5
|
+
* (`originFor`) is computed synchronously. SHA-256 per FIPS 180-4, HMAC per RFC 2104; checked
|
|
6
|
+
* against the standard vectors and `node:crypto` in `test/crypto.test.ts`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const K = new Uint32Array([
|
|
10
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
11
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
12
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
13
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
14
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
15
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
16
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
17
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n));
|
|
21
|
+
|
|
22
|
+
export function sha256(data: Uint8Array): Uint8Array {
|
|
23
|
+
const length = data.length;
|
|
24
|
+
// Padded to a multiple of 64 bytes: a 1 bit, zeros, then the length in bits (64-bit big-endian).
|
|
25
|
+
const padded = new Uint8Array(Math.ceil((length + 9) / 64) * 64);
|
|
26
|
+
padded.set(data);
|
|
27
|
+
padded[length] = 0x80;
|
|
28
|
+
const view = new DataView(padded.buffer);
|
|
29
|
+
view.setUint32(padded.length - 8, Math.floor(length / 0x20000000), false);
|
|
30
|
+
view.setUint32(padded.length - 4, (length * 8) >>> 0, false);
|
|
31
|
+
|
|
32
|
+
const h = new Uint32Array([
|
|
33
|
+
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
|
34
|
+
]);
|
|
35
|
+
const w = new Uint32Array(64);
|
|
36
|
+
for (let offset = 0; offset < padded.length; offset += 64) {
|
|
37
|
+
for (let i = 0; i < 16; i += 1) {
|
|
38
|
+
w[i] = view.getUint32(offset + i * 4, false);
|
|
39
|
+
}
|
|
40
|
+
for (let i = 16; i < 64; i += 1) {
|
|
41
|
+
const a = w[i - 15] as number;
|
|
42
|
+
const b = w[i - 2] as number;
|
|
43
|
+
const s0 = rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3);
|
|
44
|
+
const s1 = rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10);
|
|
45
|
+
w[i] = ((w[i - 16] as number) + s0 + (w[i - 7] as number) + s1) >>> 0;
|
|
46
|
+
}
|
|
47
|
+
let [a, b, c, d, e, f, g, hh] = h as unknown as number[] as [
|
|
48
|
+
number,
|
|
49
|
+
number,
|
|
50
|
+
number,
|
|
51
|
+
number,
|
|
52
|
+
number,
|
|
53
|
+
number,
|
|
54
|
+
number,
|
|
55
|
+
number,
|
|
56
|
+
];
|
|
57
|
+
for (let i = 0; i < 64; i += 1) {
|
|
58
|
+
const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
|
59
|
+
const ch = (e & f) ^ (~e & g);
|
|
60
|
+
const t1 = (hh + s1 + ch + (K[i] as number) + (w[i] as number)) >>> 0;
|
|
61
|
+
const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
|
62
|
+
const maj = (a & b) ^ (a & c) ^ (b & c);
|
|
63
|
+
const t2 = (s0 + maj) >>> 0;
|
|
64
|
+
hh = g;
|
|
65
|
+
g = f;
|
|
66
|
+
f = e;
|
|
67
|
+
e = (d + t1) >>> 0;
|
|
68
|
+
d = c;
|
|
69
|
+
c = b;
|
|
70
|
+
b = a;
|
|
71
|
+
a = (t1 + t2) >>> 0;
|
|
72
|
+
}
|
|
73
|
+
const next = [a, b, c, d, e, f, g, hh];
|
|
74
|
+
for (let i = 0; i < 8; i += 1) {
|
|
75
|
+
h[i] = ((h[i] as number) + (next[i] as number)) >>> 0;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const out = new Uint8Array(32);
|
|
79
|
+
const outView = new DataView(out.buffer);
|
|
80
|
+
for (let i = 0; i < 8; i += 1) {
|
|
81
|
+
outView.setUint32(i * 4, h[i] as number, false);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const BLOCK = 64;
|
|
87
|
+
|
|
88
|
+
export function hmacSha256(key: Uint8Array, message: Uint8Array): Uint8Array {
|
|
89
|
+
const block = new Uint8Array(BLOCK);
|
|
90
|
+
block.set(key.length > BLOCK ? sha256(key) : key);
|
|
91
|
+
const inner = new Uint8Array(BLOCK + message.length);
|
|
92
|
+
const outer = new Uint8Array(BLOCK + 32);
|
|
93
|
+
for (let i = 0; i < BLOCK; i += 1) {
|
|
94
|
+
inner[i] = (block[i] as number) ^ 0x36;
|
|
95
|
+
outer[i] = (block[i] as number) ^ 0x5c;
|
|
96
|
+
}
|
|
97
|
+
inner.set(message, BLOCK);
|
|
98
|
+
outer.set(sha256(inner), BLOCK);
|
|
99
|
+
return sha256(outer);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const encoder = new TextEncoder();
|
|
103
|
+
const decoder = new TextDecoder();
|
|
104
|
+
|
|
105
|
+
export const utf8 = (text: string): Uint8Array => encoder.encode(text);
|
|
106
|
+
export const fromUtf8 = (bytes: Uint8Array): string => decoder.decode(bytes);
|
|
107
|
+
|
|
108
|
+
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
109
|
+
|
|
110
|
+
/** Base64url without padding (RFC 4648 §5). */
|
|
111
|
+
export function base64url(bytes: Uint8Array): string {
|
|
112
|
+
let out = "";
|
|
113
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
114
|
+
const n = ((bytes[i] as number) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
|
|
115
|
+
out += B64[(n >>> 18) & 63];
|
|
116
|
+
out += B64[(n >>> 12) & 63];
|
|
117
|
+
if (i + 1 < bytes.length) {
|
|
118
|
+
out += B64[(n >>> 6) & 63];
|
|
119
|
+
}
|
|
120
|
+
if (i + 2 < bytes.length) {
|
|
121
|
+
out += B64[n & 63];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Decodes base64url without padding; null for anything else. */
|
|
128
|
+
export function fromBase64url(text: string): Uint8Array | null {
|
|
129
|
+
if (!/^[A-Za-z0-9_-]*$/u.test(text) || text.length % 4 === 1) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const out = new Uint8Array(Math.floor((text.length * 3) / 4));
|
|
133
|
+
let bits = 0;
|
|
134
|
+
let buffer = 0;
|
|
135
|
+
let index = 0;
|
|
136
|
+
for (const char of text) {
|
|
137
|
+
buffer = (buffer << 6) | B64.indexOf(char);
|
|
138
|
+
bits += 6;
|
|
139
|
+
if (bits >= 8) {
|
|
140
|
+
bits -= 8;
|
|
141
|
+
out[index] = (buffer >>> bits) & 0xff;
|
|
142
|
+
index += 1;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// The leftover bits of a canonical encoding are zero.
|
|
146
|
+
return (buffer & ((1 << bits) - 1)) === 0 ? out : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Equal bytes, compared in time that depends only on the length. */
|
|
150
|
+
export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
151
|
+
if (a.length !== b.length) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
let difference = 0;
|
|
155
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
156
|
+
difference |= (a[i] as number) ^ (b[i] as number);
|
|
157
|
+
}
|
|
158
|
+
return difference === 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Random bytes from the runtime's CSPRNG (`crypto.getRandomValues`: Node, Workers, browsers). */
|
|
162
|
+
export function randomBytes(length: number): Uint8Array {
|
|
163
|
+
const bytes = new Uint8Array(length);
|
|
164
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
165
|
+
return bytes;
|
|
166
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -9,12 +9,18 @@
|
|
|
9
9
|
*
|
|
10
10
|
* and calls `issue` from its own authenticated API when a viewer opens served content.
|
|
11
11
|
*
|
|
12
|
+
* Runs on any runtime with web-standard `Request`/`Response` and `crypto.getRandomValues`: Node,
|
|
13
|
+
* Cloudflare Workers (no `nodejs_compat` needed), Deno, Bun (P044). On Workers, keep tickets in D1
|
|
14
|
+
* (`d1TicketStore`, P045).
|
|
15
|
+
*
|
|
12
16
|
* Origins: each storage scope gets `<label>.<domain>`, the label keyed from the scope by the
|
|
13
|
-
* secret (stable, DNS-safe, not guessable from the scope)
|
|
17
|
+
* secret (stable, DNS-safe, not guessable from the scope), or a host's own flat name
|
|
18
|
+
* (`content.name`, `prefixedName("a-")` for `a-<id>`), with other servers' namespaces reserved (P047). The domain is a registrable domain the
|
|
14
19
|
* host uses for nothing else, so no content origin is ever same-site with the host. Documents go
|
|
15
20
|
* out with their CSP (from the config) plus `frame-ancestors` (the host origins) in headers.
|
|
16
21
|
*
|
|
17
|
-
* Tickets: `issue` mints a one-time ticket for an instance and a viewer,
|
|
22
|
+
* Tickets: `issue` mints a one-time ticket for an instance and a viewer (or anonymously, for a
|
|
23
|
+
* public link, P046), good for a short time;
|
|
18
24
|
* the frame's first request carries it, and the content origin takes it (once). A served document
|
|
19
25
|
* comes back in that same answer, with a cookie of that origin only (`__Host-`, `Secure`,
|
|
20
26
|
* `HttpOnly`, `SameSite=None; Partitioned` in a frame) for what follows; a url-mode app's ticket
|
|
@@ -22,11 +28,22 @@
|
|
|
22
28
|
* content without a valid ticket or cookie gets nothing of the content.
|
|
23
29
|
*/
|
|
24
30
|
export { createServer } from "./server.ts";
|
|
25
|
-
export {
|
|
31
|
+
export {
|
|
32
|
+
d1TicketSchema,
|
|
33
|
+
d1TicketStore,
|
|
34
|
+
memoryTicketStore,
|
|
35
|
+
type D1Like,
|
|
36
|
+
type D1StatementLike,
|
|
37
|
+
} from "./tickets.ts";
|
|
38
|
+
export { FRAME_STATUS_METHOD } from "../isolation/types.ts";
|
|
39
|
+
export { isHostLabel, prefixedName } from "./naming.ts";
|
|
26
40
|
export { proxyTo, type ProxyOptions } from "./proxy.ts";
|
|
27
41
|
export type {
|
|
28
42
|
Access,
|
|
29
43
|
ArtifactStore,
|
|
44
|
+
AuthorizeAnswer,
|
|
45
|
+
AuthorizeRequest,
|
|
46
|
+
Authorizer,
|
|
30
47
|
ContentDomain,
|
|
31
48
|
Grant,
|
|
32
49
|
PortholeServer,
|
package/src/server/keys.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
base64url,
|
|
3
|
+
fromBase64url,
|
|
4
|
+
fromUtf8,
|
|
5
|
+
hmacSha256,
|
|
6
|
+
randomBytes,
|
|
7
|
+
sha256,
|
|
8
|
+
timingSafeEqual,
|
|
9
|
+
utf8,
|
|
10
|
+
} from "./crypto.ts";
|
|
2
11
|
|
|
3
12
|
/**
|
|
4
13
|
* The server's secrets, derived from the one it is given: one key per use, so a value made for
|
|
@@ -27,8 +36,6 @@ export function base32(bytes: Uint8Array): string {
|
|
|
27
36
|
return out;
|
|
28
37
|
}
|
|
29
38
|
|
|
30
|
-
const base64url = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url");
|
|
31
|
-
|
|
32
39
|
/** JSON with object keys in a fixed order, so equal values always serialize alike. */
|
|
33
40
|
export function canonicalJson(value: unknown): string {
|
|
34
41
|
if (Array.isArray(value)) {
|
|
@@ -58,19 +65,17 @@ export function createKeys(secret: Uint8Array): Keys {
|
|
|
58
65
|
if (secret.byteLength < 32) {
|
|
59
66
|
throw new TypeError("The server's secret must be at least 32 random bytes");
|
|
60
67
|
}
|
|
61
|
-
const derive = (use: string):
|
|
62
|
-
createHmac("sha256", secret).update(`porthole/${use}`).digest();
|
|
68
|
+
const derive = (use: string): Uint8Array => hmacSha256(secret, utf8(`porthole/${use}`));
|
|
63
69
|
const labelKey = derive("label");
|
|
64
70
|
const instanceKey = derive("instance");
|
|
65
71
|
const signKey = derive("session");
|
|
66
|
-
const mac = (key:
|
|
67
|
-
createHmac("sha256", key).update(text).digest();
|
|
72
|
+
const mac = (key: Uint8Array, text: string): Uint8Array => hmacSha256(key, utf8(text));
|
|
68
73
|
|
|
69
74
|
return {
|
|
70
75
|
label: (scope) => base32(mac(labelKey, scope).subarray(0, 15)),
|
|
71
76
|
instanceKey: (instance) => base32(mac(instanceKey, canonicalJson(instance)).subarray(0, 10)),
|
|
72
77
|
sign: (value) => {
|
|
73
|
-
const payload =
|
|
78
|
+
const payload = base64url(utf8(JSON.stringify(value)));
|
|
74
79
|
return `${payload}.${base64url(mac(signKey, payload))}`;
|
|
75
80
|
},
|
|
76
81
|
verify: (token) => {
|
|
@@ -79,12 +84,16 @@ export function createKeys(secret: Uint8Array): Keys {
|
|
|
79
84
|
return null;
|
|
80
85
|
}
|
|
81
86
|
const expected = mac(signKey, payload);
|
|
82
|
-
const given =
|
|
83
|
-
if (given
|
|
87
|
+
const given = fromBase64url(signature);
|
|
88
|
+
if (given === null || !timingSafeEqual(given, expected)) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const bytes = fromBase64url(payload);
|
|
92
|
+
if (bytes === null) {
|
|
84
93
|
return null;
|
|
85
94
|
}
|
|
86
95
|
try {
|
|
87
|
-
return JSON.parse(
|
|
96
|
+
return JSON.parse(fromUtf8(bytes)) as unknown;
|
|
88
97
|
} catch {
|
|
89
98
|
return null;
|
|
90
99
|
}
|
|
@@ -96,5 +105,4 @@ export function createKeys(secret: Uint8Array): Keys {
|
|
|
96
105
|
export const newTicket = (): string => `pt_${base32(randomBytes(20))}`;
|
|
97
106
|
|
|
98
107
|
/** What a ticket is stored under: its hash, so a leaked store holds no usable ticket. */
|
|
99
|
-
export const ticketId = (ticket: string): string =>
|
|
100
|
-
createHash("sha256").update(ticket).digest("base64url");
|
|
108
|
+
export const ticketId = (ticket: string): string => base64url(sha256(utf8(ticket)));
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names for content origins a host chooses itself (P047): flat labels like `a-<id>` under a
|
|
3
|
+
* shared content domain, next to other servers' namespaces (`<instance>.f.<domain>`).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** One lowercase DNS label (a-z 0-9 -, at most 63, no hyphen at either end). */
|
|
7
|
+
const DNS_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/u;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether a label is one a host may name a content origin with: one lowercase DNS label, and not
|
|
11
|
+
* of the form IDNA reserves (`xn--`, any `??--`), so a name never displays as something else.
|
|
12
|
+
*/
|
|
13
|
+
export function isHostLabel(label: unknown): label is string {
|
|
14
|
+
return typeof label === "string" && DNS_LABEL.test(label) && label.slice(2, 4) !== "--";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PREFIX = /^[a-z][a-z0-9]{0,15}-$/u;
|
|
18
|
+
const ID = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/u;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A naming for `content.name` that puts each scope on `<prefix><scope>` (`prefixedName("a-")`:
|
|
22
|
+
* scope `k3x9` is served on `a-k3x9.<domain>`). One-to-one, so no two scopes share an origin, and
|
|
23
|
+
* no name it makes is a reserved one or a keyed default (those have no hyphen). Scopes must
|
|
24
|
+
* already be label-safe (lowercase letters, digits, inner hyphens); anything else throws rather
|
|
25
|
+
* than being folded into another scope's name.
|
|
26
|
+
*/
|
|
27
|
+
export function prefixedName(prefix: string): (scope: string) => string {
|
|
28
|
+
if (!PREFIX.test(prefix)) {
|
|
29
|
+
throw new TypeError(
|
|
30
|
+
"A name prefix is a lowercase letter, then letters or digits, then '-' (a-)",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return (scope) => {
|
|
34
|
+
const label = `${prefix}${scope}`;
|
|
35
|
+
if (!ID.test(scope) || !isHostLabel(label)) {
|
|
36
|
+
throw new TypeError(
|
|
37
|
+
`Scope ${JSON.stringify(scope)} can't be named under ${prefix}: lowercase letters, digits and inner hyphens, ${63 - prefix.length} at most`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return label;
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/server/pages.ts
CHANGED
|
@@ -30,13 +30,15 @@ const STATUS_SCRIPT = `(() => {
|
|
|
30
30
|
if (main === null) return;
|
|
31
31
|
const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
|
|
32
32
|
${say()}
|
|
33
|
-
|
|
33
|
+
const blocked = { state: "blocked", reason: main.dataset.portholeReason || "error", status: Number(main.dataset.portholeStatus) };
|
|
34
|
+
if (main.dataset.portholeCode) blocked.code = main.dataset.portholeCode;
|
|
35
|
+
say(blocked);
|
|
34
36
|
})();`;
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
39
|
* Enters a framed app: says so, checks that the browser kept the session cookie this page's own
|
|
38
|
-
* answer set (WebKit keeps none in a cross-site frame, P025), then
|
|
39
|
-
* is blocked and why.
|
|
40
|
+
* answer set (WebKit keeps none in a cross-site frame, P025), then, once this page has loaded,
|
|
41
|
+
* goes on to the app, or says it is blocked and why.
|
|
40
42
|
*/
|
|
41
43
|
const ENTER_SCRIPT = `(async () => {
|
|
42
44
|
const main = document.querySelector("[data-porthole-enter]");
|
|
@@ -49,6 +51,12 @@ const ENTER_SCRIPT = `(async () => {
|
|
|
49
51
|
kept = (await fetch(${JSON.stringify(SESSION_PATH)}, { credentials: "same-origin", cache: "no-store" })).ok;
|
|
50
52
|
} catch (_) {}
|
|
51
53
|
if (kept) {
|
|
54
|
+
// The host counts this page's load, then the app's (P040): leave only once this page has
|
|
55
|
+
// loaded, or a fast answer would leave first and its load would never be seen.
|
|
56
|
+
if (document.readyState !== "complete") {
|
|
57
|
+
await new Promise((resolve) => addEventListener("load", resolve, { once: true }));
|
|
58
|
+
}
|
|
59
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
52
60
|
location.replace(main.dataset.portholeTarget || "/");
|
|
53
61
|
return;
|
|
54
62
|
}
|
|
@@ -95,8 +103,9 @@ export function statusPage(
|
|
|
95
103
|
method = "GET",
|
|
96
104
|
reason: FrameBlock = "error",
|
|
97
105
|
hostOrigins: readonly string[] = [],
|
|
106
|
+
code?: string,
|
|
98
107
|
): Response {
|
|
99
|
-
const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${STYLE}</style></head><body><main data-porthole-status="${status}" data-porthole-reason="${escape(reason)}" data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main><script src="${STATUS_SCRIPT_PATH}"></script></body></html>`;
|
|
108
|
+
const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${STYLE}</style></head><body><main data-porthole-status="${status}" data-porthole-reason="${escape(reason)}"${code === undefined ? "" : ` data-porthole-code="${escape(code)}"`} data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main><script src="${STATUS_SCRIPT_PATH}"></script></body></html>`;
|
|
100
109
|
return new Response(method === "HEAD" ? null : body, {
|
|
101
110
|
status,
|
|
102
111
|
headers: pageHeaders(frameAncestors, false),
|