@botiverse/porthole 0.1.0 → 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/bridge/bridge.js +20 -6
- package/dist/bridge/types.d.ts +19 -3
- package/dist/compile/policy.d.ts +6 -5
- package/dist/compile/policy.js +10 -12
- package/dist/config/schema.d.ts +29 -0
- package/dist/config/schema.js +16 -1
- package/dist/config/validate.d.ts +3 -1
- package/dist/config/validate.js +55 -10
- package/dist/conformance/behavior.js +50 -0
- package/dist/conformance/index.d.ts +11 -5
- package/dist/conformance/index.js +2 -2
- package/dist/conformance/probes.d.ts +26 -2
- package/dist/conformance/probes.js +51 -40
- package/dist/conformance/served.d.ts +7 -0
- package/dist/conformance/served.js +31 -7
- package/dist/guide/guide.js +6 -2
- package/dist/host/create-host.js +46 -2
- package/dist/host/locate.js +6 -1
- package/dist/host/outcome.d.ts +25 -0
- package/dist/host/outcome.js +147 -0
- package/dist/host/types.d.ts +39 -2
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -1
- package/dist/isolation/served.d.ts +3 -0
- package/dist/isolation/served.js +20 -0
- package/dist/isolation/types.d.ts +9 -0
- package/dist/isolation/types.js +2 -1
- package/dist/platform/types.d.ts +7 -0
- package/dist/platform/web.js +15 -2
- package/dist/presets/presets.d.ts +11 -3
- package/dist/presets/presets.js +17 -1
- package/dist/react/frame.d.ts +16 -0
- package/dist/react/frame.js +44 -10
- 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 +11 -6
- package/dist/server/pages.js +86 -12
- package/dist/server/server.js +191 -23
- package/dist/server/tickets.d.ts +24 -0
- package/dist/server/tickets.js +46 -0
- package/dist/server/types.d.ts +76 -5
- package/dist/shell/document.d.ts +3 -2
- package/dist/shell/document.js +4 -3
- package/dist/shell/plan.d.ts +2 -0
- package/dist/shell/plan.js +1 -0
- package/dist/shell/runtime.js +20 -4
- package/package.json +2 -9
- package/src/bridge/bridge.ts +28 -6
- package/src/bridge/types.ts +21 -3
- package/src/compile/policy.ts +10 -12
- package/src/config/schema.ts +38 -0
- package/src/config/validate.ts +63 -14
- package/src/conformance/behavior.ts +55 -0
- package/src/conformance/index.ts +18 -4
- package/src/conformance/probes.ts +88 -52
- package/src/conformance/served.ts +37 -8
- package/src/guide/guide.ts +8 -2
- package/src/host/create-host.ts +47 -2
- package/src/host/locate.ts +6 -1
- package/src/host/outcome.ts +177 -0
- package/src/host/types.ts +42 -2
- package/src/index.ts +19 -1
- package/src/isolation/served.ts +24 -0
- package/src/isolation/types.ts +15 -1
- package/src/platform/types.ts +7 -0
- package/src/platform/web.ts +15 -2
- package/src/presets/presets.ts +31 -3
- package/src/react/frame.tsx +78 -14
- 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 +106 -11
- package/src/server/server.ts +233 -23
- package/src/server/tickets.ts +65 -0
- package/src/server/types.ts +84 -7
- package/src/shell/document.ts +7 -6
- package/src/shell/plan.ts +3 -0
- package/src/shell/runtime.ts +20 -4
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
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import { FRAME_STATUS_METHOD } from "../isolation/types.ts";
|
|
2
|
+
import type { FrameBlock } from "../host/types.ts";
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
|
-
* The server's own pages on a content origin: why it shows nothing of the content
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
+
* The server's own pages on a content origin: why it shows nothing of the content, and the page a
|
|
6
|
+
* framed app is entered through. Plain, framable only by the host origins, never cached. Each tells
|
|
7
|
+
* the page that frames it how loading went (`porthole/notifications/frame-status`, P040), posted to
|
|
8
|
+
* the host origins only, so a host can show its own fallback ("Open in a new tab"). Their words are
|
|
9
|
+
* the host-agnostic mechanism's; a host that wants its own wording serves its own page in front.
|
|
5
10
|
*/
|
|
6
11
|
|
|
7
12
|
const escape = (text: string): string =>
|
|
@@ -10,22 +15,112 @@ const escape = (text: string): string =>
|
|
|
10
15
|
const STYLE =
|
|
11
16
|
":root{color-scheme:light dark;font-family:system-ui,sans-serif}body{margin:0;min-height:100vh;display:grid;place-items:center;background:Canvas;color:CanvasText}main{max-width:420px;padding:24px}h1{font-size:17px;margin:0 0 8px}p{margin:0;opacity:.75;line-height:1.5}";
|
|
12
17
|
|
|
18
|
+
/** Where the pages' scripts are served, on every content origin. */
|
|
19
|
+
export const STATUS_SCRIPT_PATH = "/__porthole/status.js";
|
|
20
|
+
export const ENTER_SCRIPT_PATH = "/__porthole/enter.js";
|
|
21
|
+
/** Answers 204 when the request carries a good session for its origin's app, 401 otherwise. */
|
|
22
|
+
export const SESSION_PATH = "/__porthole/session";
|
|
23
|
+
|
|
24
|
+
const say = (): string =>
|
|
25
|
+
`const say = (params) => { if (window.parent === window) return; for (const origin of hosts) window.parent.postMessage({ jsonrpc: "2.0", method: ${JSON.stringify(FRAME_STATUS_METHOD)}, params }, origin); };`;
|
|
26
|
+
|
|
27
|
+
/** Tells the host the page is blocked, and why (from the page's data attributes). */
|
|
28
|
+
const STATUS_SCRIPT = `(() => {
|
|
29
|
+
const main = document.querySelector("[data-porthole-status]");
|
|
30
|
+
if (main === null) return;
|
|
31
|
+
const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
|
|
32
|
+
${say()}
|
|
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);
|
|
36
|
+
})();`;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Enters a framed app: says so, checks that the browser kept the session cookie this page's own
|
|
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.
|
|
42
|
+
*/
|
|
43
|
+
const ENTER_SCRIPT = `(async () => {
|
|
44
|
+
const main = document.querySelector("[data-porthole-enter]");
|
|
45
|
+
if (main === null) return;
|
|
46
|
+
const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
|
|
47
|
+
${say()}
|
|
48
|
+
say({ state: "entering" });
|
|
49
|
+
let kept = false;
|
|
50
|
+
try {
|
|
51
|
+
kept = (await fetch(${JSON.stringify(SESSION_PATH)}, { credentials: "same-origin", cache: "no-store" })).ok;
|
|
52
|
+
} catch (_) {}
|
|
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));
|
|
60
|
+
location.replace(main.dataset.portholeTarget || "/");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
say({ state: "blocked", reason: "cookies", status: 401 });
|
|
64
|
+
main.querySelector("h1").textContent = "Open this app in a new tab";
|
|
65
|
+
main.querySelector("p").textContent = "This browser keeps no sign-in for it inside a frame.";
|
|
66
|
+
})();`;
|
|
67
|
+
|
|
68
|
+
/** The pages' scripts, by path: served with the pages' own policy in mind (`script-src 'self'`). */
|
|
69
|
+
export function pageScript(pathname: string): Response | null {
|
|
70
|
+
const body =
|
|
71
|
+
pathname === STATUS_SCRIPT_PATH
|
|
72
|
+
? STATUS_SCRIPT
|
|
73
|
+
: pathname === ENTER_SCRIPT_PATH
|
|
74
|
+
? ENTER_SCRIPT
|
|
75
|
+
: null;
|
|
76
|
+
return body === null
|
|
77
|
+
? null
|
|
78
|
+
: new Response(body, {
|
|
79
|
+
headers: {
|
|
80
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
81
|
+
"cache-control": "no-store",
|
|
82
|
+
"x-content-type-options": "nosniff",
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const policy = (frameAncestors: string, connect: boolean): string =>
|
|
88
|
+
`default-src 'none'; style-src 'unsafe-inline'; script-src 'self'; ${connect ? "connect-src 'self'; " : ""}base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`;
|
|
89
|
+
|
|
90
|
+
const pageHeaders = (frameAncestors: string, connect: boolean) => ({
|
|
91
|
+
"content-type": "text/html; charset=utf-8",
|
|
92
|
+
"cache-control": "no-store",
|
|
93
|
+
"content-security-policy": policy(frameAncestors, connect),
|
|
94
|
+
"referrer-policy": "no-referrer",
|
|
95
|
+
"x-content-type-options": "nosniff",
|
|
96
|
+
});
|
|
97
|
+
|
|
13
98
|
export function statusPage(
|
|
14
99
|
status: number,
|
|
15
100
|
title: string,
|
|
16
101
|
detail: string,
|
|
17
102
|
frameAncestors: string,
|
|
18
103
|
method = "GET",
|
|
104
|
+
reason: FrameBlock = "error",
|
|
105
|
+
hostOrigins: readonly string[] = [],
|
|
106
|
+
code?: string,
|
|
19
107
|
): Response {
|
|
20
|
-
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}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main></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>`;
|
|
21
109
|
return new Response(method === "HEAD" ? null : body, {
|
|
22
110
|
status,
|
|
23
|
-
headers:
|
|
24
|
-
"content-type": "text/html; charset=utf-8",
|
|
25
|
-
"cache-control": "no-store",
|
|
26
|
-
"content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`,
|
|
27
|
-
"referrer-policy": "no-referrer",
|
|
28
|
-
"x-content-type-options": "nosniff",
|
|
29
|
-
},
|
|
111
|
+
headers: pageHeaders(frameAncestors, false),
|
|
30
112
|
});
|
|
31
113
|
}
|
|
114
|
+
|
|
115
|
+
/** The page a framed app is entered through (its answer also sets the session cookie). */
|
|
116
|
+
export function enterPage(
|
|
117
|
+
target: string,
|
|
118
|
+
frameAncestors: string,
|
|
119
|
+
hostOrigins: readonly string[],
|
|
120
|
+
cookie: string,
|
|
121
|
+
): Response {
|
|
122
|
+
const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Opening the app</title><style>${STYLE}</style></head><body><main data-porthole-enter="" data-porthole-target="${escape(target)}" data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>Opening the app…</h1><p></p></main><script src="${ENTER_SCRIPT_PATH}"></script></body></html>`;
|
|
123
|
+
const headers = new Headers(pageHeaders(frameAncestors, true));
|
|
124
|
+
headers.set("set-cookie", cookie);
|
|
125
|
+
return new Response(body, { status: 200, headers });
|
|
126
|
+
}
|