@floegence/flowersec-core 0.21.1 → 0.22.1
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/README.md +12 -2
- package/dist/browser/index.d.ts +2 -2
- package/dist/browser/index.js +1 -1
- package/dist/client-connect/connectCore.js +5 -4
- package/dist/client-connect/transportSecurity.d.ts +10 -0
- package/dist/client-connect/transportSecurity.js +89 -0
- package/dist/controlplane/channelInit.d.ts +27 -0
- package/dist/controlplane/channelInit.js +116 -0
- package/dist/controlplane/http.d.ts +16 -0
- package/dist/controlplane/http.js +59 -0
- package/dist/controlplane/index.d.ts +4 -0
- package/dist/controlplane/index.js +4 -0
- package/dist/controlplane/issuer.d.ts +15 -0
- package/dist/controlplane/issuer.js +53 -0
- package/dist/controlplane/request.js +2 -1
- package/dist/controlplane/token.d.ts +33 -0
- package/dist/controlplane/token.js +119 -0
- package/dist/defaults.d.ts +46 -0
- package/dist/defaults.js +46 -0
- package/dist/endpoint/index.d.ts +83 -0
- package/dist/endpoint/index.js +384 -0
- package/dist/endpoint/node.d.ts +9 -0
- package/dist/endpoint/node.js +51 -0
- package/dist/facade.d.ts +2 -2
- package/dist/facade.js +1 -1
- package/dist/framing/jsonframe.js +2 -1
- package/dist/node/index.d.ts +5 -2
- package/dist/node/index.js +4 -1
- package/dist/proxy/constants.js +4 -3
- package/dist/proxy/headerPolicy.js +1 -0
- package/dist/proxy/server.d.ts +27 -0
- package/dist/proxy/server.js +480 -0
- package/dist/reconnect/index.js +10 -9
- package/dist/rpc/server.d.ts +9 -2
- package/dist/rpc/server.js +33 -10
- package/dist/yamux/session.js +8 -7
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @floegence/flowersec-core
|
|
2
2
|
|
|
3
|
-
Flowersec core TypeScript library for
|
|
3
|
+
Flowersec core TypeScript library for the complete portable Flowersec client/server contract in Node.js, plus the browser and Service Worker runtime owned by TypeScript.
|
|
4
4
|
|
|
5
5
|
Status: experimental; not audited.
|
|
6
6
|
|
|
@@ -60,7 +60,17 @@ const reconnectConfig = createNodeReconnectConfig({
|
|
|
60
60
|
Browser `requestConnectArtifact(...)`, `requestEntryConnectArtifact(...)`, and `ControlplaneRequestError` remain available from `@floegence/flowersec-core/browser` as stable aliases.
|
|
61
61
|
|
|
62
62
|
High-level connects use `RequireTLS` by default. `AllowPlaintextForLoopback` permits only literal loopback
|
|
63
|
-
targets without DNS resolution
|
|
63
|
+
targets without DNS resolution. Deliberate non-loopback `ws://` connections must use
|
|
64
|
+
`createNetworkPlaintextPolicy(...)` with exact canonical IP literals and
|
|
65
|
+
`PlaintextRiskAcceptance.acceptPreE2ECredentialExposure`. The unrestricted `AllowPlaintext` preset is deprecated.
|
|
66
|
+
|
|
67
|
+
## Node endpoint and controlplane
|
|
68
|
+
|
|
69
|
+
`@floegence/flowersec-core/node` exports the high-level endpoint APIs for accepted direct WebSockets and server-role tunnel grants. `@floegence/flowersec-core/endpoint` provides portable session and RPC serving, while `@floegence/flowersec-core/controlplane` provides bounded artifact envelopes, FST2 tokens, issuer rotation, and channel initialization.
|
|
70
|
+
|
|
71
|
+
## Proxy
|
|
72
|
+
|
|
73
|
+
`@floegence/flowersec-core/proxy` contains both portable HTTP/1 and WebSocket proxy protocols and the TypeScript-owned browser runtime. Node endpoint servers use `serveProxySession(...)`; browser applications use the Service Worker or controller bridge helpers without changing the portable stream contract.
|
|
64
74
|
|
|
65
75
|
## Docs
|
|
66
76
|
|
package/dist/browser/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export type { ConnectBrowserOptions, DirectConnectBrowserOptions, TunnelConnectBrowserOptions } from "./connect.js";
|
|
2
2
|
export { connectBrowser, connectDirectBrowser, connectTunnelBrowser } from "./connect.js";
|
|
3
|
-
export { AllowPlaintext, AllowPlaintextForLoopback, RequireTLS, } from "../client-connect/transportSecurity.js";
|
|
4
|
-
export type { TransportSecurityPolicy, TransportSecurityPolicyInput, TransportSecurityPolicyPreset, } from "../client-connect/transportSecurity.js";
|
|
3
|
+
export { AllowPlaintext, AllowPlaintextForLoopback, createNetworkPlaintextPolicy, PlaintextRiskAcceptance, RequireTLS, } from "../client-connect/transportSecurity.js";
|
|
4
|
+
export type { TransportSecurityPolicy, TransportSecurityPolicyInput, TransportSecurityPolicyPreset, NetworkPlaintextPolicyOptions, } from "../client-connect/transportSecurity.js";
|
|
5
5
|
export type { ConnectArtifact, CorrelationContext, CorrelationKV, DirectClientConnectArtifact, ScopeMetadataEntry, TunnelClientConnectArtifact, } from "../connect/artifact.js";
|
|
6
6
|
export { assertConnectArtifact } from "../connect/artifact.js";
|
|
7
7
|
export type { ConnectArtifactRequestConfig, ControlplaneConfig, EntryConnectArtifactRequestConfig, EntryControlplaneConfig, } from "./controlplane.js";
|
package/dist/browser/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { connectBrowser, connectDirectBrowser, connectTunnelBrowser } from "./connect.js";
|
|
2
|
-
export { AllowPlaintext, AllowPlaintextForLoopback, RequireTLS, } from "../client-connect/transportSecurity.js";
|
|
2
|
+
export { AllowPlaintext, AllowPlaintextForLoopback, createNetworkPlaintextPolicy, PlaintextRiskAcceptance, RequireTLS, } from "../client-connect/transportSecurity.js";
|
|
3
3
|
export { assertConnectArtifact } from "../connect/artifact.js";
|
|
4
4
|
export { ControlplaneRequestError, requestChannelGrant, requestConnectArtifact, requestEntryChannelGrant, requestEntryConnectArtifact, } from "./controlplane.js";
|
|
5
5
|
export { createBrowserReconnectConfig, createDirectBrowserReconnectConfig, createTunnelBrowserReconnectConfig, } from "./reconnectConfig.js";
|
|
@@ -2,6 +2,7 @@ import { clientHandshake } from "../e2ee/handshake.js";
|
|
|
2
2
|
import { ByteReader } from "../yamux/byteReader.js";
|
|
3
3
|
import { YamuxSession } from "../yamux/session.js";
|
|
4
4
|
import { DEFAULT_YAMUX_LIMITS } from "../yamux/session.js";
|
|
5
|
+
import { SDK_DEFAULTS } from "../defaults.js";
|
|
5
6
|
import { RpcClient } from "../rpc/client.js";
|
|
6
7
|
import { writeStreamHello } from "../streamhello/streamHello.js";
|
|
7
8
|
import { emitObserverDiagnostic, normalizeObserver, nowSeconds } from "../observability/observer.js";
|
|
@@ -52,11 +53,11 @@ export async function connectCore(args) {
|
|
|
52
53
|
const code = args.path === "tunnel" ? "missing_tunnel_url" : "missing_ws_url";
|
|
53
54
|
throw new FlowersecError({ path: args.path, stage: "validate", code, message: "missing websocket url" });
|
|
54
55
|
}
|
|
55
|
-
const connectTimeoutMs = args.opts.connectTimeoutMs ??
|
|
56
|
+
const connectTimeoutMs = args.opts.connectTimeoutMs ?? SDK_DEFAULTS.transport.connectTimeoutMs;
|
|
56
57
|
if (!Number.isFinite(connectTimeoutMs) || connectTimeoutMs < 0) {
|
|
57
58
|
invalidOption("connectTimeoutMs must be a non-negative number");
|
|
58
59
|
}
|
|
59
|
-
const handshakeTimeoutMs = args.opts.handshakeTimeoutMs ??
|
|
60
|
+
const handshakeTimeoutMs = args.opts.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs;
|
|
60
61
|
if (!Number.isFinite(handshakeTimeoutMs) || handshakeTimeoutMs < 0) {
|
|
61
62
|
invalidOption("handshakeTimeoutMs must be a non-negative number");
|
|
62
63
|
}
|
|
@@ -80,7 +81,7 @@ export async function connectCore(args) {
|
|
|
80
81
|
if (!Number.isSafeInteger(maxOutboundBufferedBytes) || maxOutboundBufferedBytes < 0) {
|
|
81
82
|
invalidOption("maxOutboundBufferedBytes must be a non-negative integer");
|
|
82
83
|
}
|
|
83
|
-
const effectiveMaxRecordBytes = maxRecordBytes > 0 ? maxRecordBytes :
|
|
84
|
+
const effectiveMaxRecordBytes = maxRecordBytes > 0 ? maxRecordBytes : SDK_DEFAULTS.e2ee.maxRecordBytes;
|
|
84
85
|
const outboundRecordChunkBytes = args.opts.outboundRecordChunkBytes ?? 64 * 1024;
|
|
85
86
|
if (!Number.isSafeInteger(outboundRecordChunkBytes) || outboundRecordChunkBytes <= 0 || outboundRecordChunkBytes > maxPlaintextBytes(effectiveMaxRecordBytes)) {
|
|
86
87
|
invalidOption("outboundRecordChunkBytes must be a positive integer within maxRecordBytes");
|
|
@@ -183,7 +184,7 @@ export async function connectCore(args) {
|
|
|
183
184
|
suite,
|
|
184
185
|
psk,
|
|
185
186
|
clientFeatures,
|
|
186
|
-
maxHandshakePayload: maxHandshakePayload > 0 ? maxHandshakePayload :
|
|
187
|
+
maxHandshakePayload: maxHandshakePayload > 0 ? maxHandshakePayload : SDK_DEFAULTS.e2ee.maxHandshakePayloadBytes,
|
|
187
188
|
maxRecordBytes: effectiveMaxRecordBytes,
|
|
188
189
|
outboundRecordChunkBytes,
|
|
189
190
|
...(maxBufferedBytes > 0 ? { maxBufferedBytes } : {}),
|
|
@@ -2,7 +2,16 @@ import type { ClientPath } from "../client.js";
|
|
|
2
2
|
import { type ClientObserverLike } from "../observability/observer.js";
|
|
3
3
|
export declare const RequireTLS: "require_tls";
|
|
4
4
|
export declare const AllowPlaintextForLoopback: "allow_plaintext_for_loopback";
|
|
5
|
+
/** @deprecated Use RequireTLS, AllowPlaintextForLoopback, or createNetworkPlaintextPolicy. */
|
|
5
6
|
export declare const AllowPlaintext: "allow_plaintext";
|
|
7
|
+
export declare const PlaintextRiskAcceptance: {
|
|
8
|
+
readonly acceptPreE2ECredentialExposure: "accept_pre_e2ee_credential_exposure";
|
|
9
|
+
};
|
|
10
|
+
export type PlaintextRiskAcceptance = typeof PlaintextRiskAcceptance[keyof typeof PlaintextRiskAcceptance];
|
|
11
|
+
export type NetworkPlaintextPolicyOptions = Readonly<{
|
|
12
|
+
allowedHosts: readonly string[];
|
|
13
|
+
riskAcceptance: PlaintextRiskAcceptance;
|
|
14
|
+
}>;
|
|
6
15
|
export type TransportSecurityPolicyInput = Readonly<{
|
|
7
16
|
path: ClientPath;
|
|
8
17
|
scheme: "ws" | "wss";
|
|
@@ -11,6 +20,7 @@ export type TransportSecurityPolicyInput = Readonly<{
|
|
|
11
20
|
}>;
|
|
12
21
|
export type TransportSecurityPolicyPreset = typeof RequireTLS | typeof AllowPlaintextForLoopback | typeof AllowPlaintext;
|
|
13
22
|
export type TransportSecurityPolicy = TransportSecurityPolicyPreset | ((input: TransportSecurityPolicyInput) => boolean | Promise<boolean>);
|
|
23
|
+
export declare function createNetworkPlaintextPolicy(options: NetworkPlaintextPolicyOptions): TransportSecurityPolicy;
|
|
14
24
|
export declare function enforceTransportSecurity(args: Readonly<{
|
|
15
25
|
rawUrl: string;
|
|
16
26
|
path: ClientPath;
|
|
@@ -2,7 +2,21 @@ import { FlowersecError } from "../utils/errors.js";
|
|
|
2
2
|
import { emitObserverDiagnostic } from "../observability/observer.js";
|
|
3
3
|
export const RequireTLS = "require_tls";
|
|
4
4
|
export const AllowPlaintextForLoopback = "allow_plaintext_for_loopback";
|
|
5
|
+
/** @deprecated Use RequireTLS, AllowPlaintextForLoopback, or createNetworkPlaintextPolicy. */
|
|
5
6
|
export const AllowPlaintext = "allow_plaintext";
|
|
7
|
+
export const PlaintextRiskAcceptance = {
|
|
8
|
+
acceptPreE2ECredentialExposure: "accept_pre_e2ee_credential_exposure",
|
|
9
|
+
};
|
|
10
|
+
export function createNetworkPlaintextPolicy(options) {
|
|
11
|
+
if (options?.riskAcceptance !== PlaintextRiskAcceptance.acceptPreE2ECredentialExposure) {
|
|
12
|
+
throw new Error("network plaintext policy requires explicit pre-E2EE credential exposure acceptance");
|
|
13
|
+
}
|
|
14
|
+
if (!Array.isArray(options.allowedHosts) || options.allowedHosts.length === 0) {
|
|
15
|
+
throw new Error("network plaintext policy requires at least one allowed host");
|
|
16
|
+
}
|
|
17
|
+
const allowedHosts = new Set(options.allowedHosts.map(canonicalNetworkPlaintextHost));
|
|
18
|
+
return (input) => input.scheme === "wss" || (input.scheme === "ws" && allowedHosts.has(input.host));
|
|
19
|
+
}
|
|
6
20
|
export async function enforceTransportSecurity(args) {
|
|
7
21
|
let target;
|
|
8
22
|
try {
|
|
@@ -105,6 +119,81 @@ function isLiteralLoopbackHost(host) {
|
|
|
105
119
|
}
|
|
106
120
|
return octets[0] === 127;
|
|
107
121
|
}
|
|
122
|
+
function canonicalNetworkPlaintextHost(rawHost) {
|
|
123
|
+
const host = String(rawHost ?? "").trim();
|
|
124
|
+
if (host === "" || host !== host.toLowerCase() || /[@/?#%\[\]]/.test(host)) {
|
|
125
|
+
throw new Error(`invalid network plaintext allowed host ${JSON.stringify(rawHost)}`);
|
|
126
|
+
}
|
|
127
|
+
if (host.includes(":")) {
|
|
128
|
+
return canonicalNetworkIPv6Host(host);
|
|
129
|
+
}
|
|
130
|
+
const octets = canonicalIPv4Octets(host);
|
|
131
|
+
const firstOctet = octets[0];
|
|
132
|
+
const secondOctet = octets[1];
|
|
133
|
+
if (firstOctet == null || secondOctet == null) {
|
|
134
|
+
throw new Error(`network plaintext allowed host must be a canonical IP literal: ${JSON.stringify(rawHost)}`);
|
|
135
|
+
}
|
|
136
|
+
if (firstOctet === 127 || (firstOctet === 169 && secondOctet === 254) || firstOctet >= 224 || octets.every((value) => value === 0)) {
|
|
137
|
+
throw new Error(`network plaintext allowed host must be a non-loopback unicast IP literal: ${JSON.stringify(rawHost)}`);
|
|
138
|
+
}
|
|
139
|
+
return host;
|
|
140
|
+
}
|
|
141
|
+
function canonicalIPv4Octets(host) {
|
|
142
|
+
const parts = host.split(".");
|
|
143
|
+
if (parts.length !== 4) {
|
|
144
|
+
throw new Error(`network plaintext allowed host must be a canonical IP literal: ${JSON.stringify(host)}`);
|
|
145
|
+
}
|
|
146
|
+
const octets = parts.map((part) => {
|
|
147
|
+
if (!/^(0|[1-9]\d{0,2})$/.test(part)) {
|
|
148
|
+
throw new Error(`network plaintext allowed host must be a canonical IP literal: ${JSON.stringify(host)}`);
|
|
149
|
+
}
|
|
150
|
+
const value = Number(part);
|
|
151
|
+
if (value > 255) {
|
|
152
|
+
throw new Error(`network plaintext allowed host must be a canonical IP literal: ${JSON.stringify(host)}`);
|
|
153
|
+
}
|
|
154
|
+
return value;
|
|
155
|
+
});
|
|
156
|
+
return octets;
|
|
157
|
+
}
|
|
158
|
+
function canonicalNetworkIPv6Host(host) {
|
|
159
|
+
let parsed;
|
|
160
|
+
try {
|
|
161
|
+
parsed = new URL(`http://[${host}]/`);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
throw new Error(`network plaintext allowed host must be a canonical IP literal: ${JSON.stringify(host)}`);
|
|
165
|
+
}
|
|
166
|
+
const canonical = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
167
|
+
if (canonical !== host) {
|
|
168
|
+
throw new Error(`network plaintext allowed host must be a canonical IP literal: ${JSON.stringify(host)}`);
|
|
169
|
+
}
|
|
170
|
+
const words = expandIPv6Words(canonical);
|
|
171
|
+
const unspecified = words.every((word) => word === 0);
|
|
172
|
+
const loopback = words.slice(0, 7).every((word) => word === 0) && words[7] === 1;
|
|
173
|
+
const mappedIPv4 = words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff;
|
|
174
|
+
const multicast = (words[0] & 0xff00) === 0xff00;
|
|
175
|
+
const linkLocal = (words[0] & 0xffc0) === 0xfe80;
|
|
176
|
+
if (unspecified || loopback || mappedIPv4 || multicast || linkLocal) {
|
|
177
|
+
throw new Error(`network plaintext allowed host must be a non-loopback unicast IP literal: ${JSON.stringify(host)}`);
|
|
178
|
+
}
|
|
179
|
+
return canonical;
|
|
180
|
+
}
|
|
181
|
+
function expandIPv6Words(host) {
|
|
182
|
+
const halves = host.split("::");
|
|
183
|
+
if (halves.length > 2)
|
|
184
|
+
throw new Error("invalid IPv6 host");
|
|
185
|
+
const left = halves[0] === "" ? [] : halves[0].split(":");
|
|
186
|
+
const right = halves.length === 1 || halves[1] === "" ? [] : halves[1].split(":");
|
|
187
|
+
const missing = 8 - left.length - right.length;
|
|
188
|
+
if ((halves.length === 1 && missing !== 0) || (halves.length === 2 && missing < 1)) {
|
|
189
|
+
throw new Error("invalid IPv6 host");
|
|
190
|
+
}
|
|
191
|
+
const words = [...left, ...Array.from({ length: missing }, () => "0"), ...right].map((part) => Number.parseInt(part, 16));
|
|
192
|
+
if (words.length !== 8 || words.some((word) => !Number.isInteger(word) || word < 0 || word > 0xffff)) {
|
|
193
|
+
throw new Error("invalid IPv6 host");
|
|
194
|
+
}
|
|
195
|
+
return words;
|
|
196
|
+
}
|
|
108
197
|
function detectRuntime() {
|
|
109
198
|
if (typeof window !== "undefined" && typeof window.document !== "undefined")
|
|
110
199
|
return "browser";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ChannelInitGrant, Suite } from "../gen/flowersec/controlplane/v1.gen.js";
|
|
2
|
+
import type { IssuerKeyset } from "./issuer.js";
|
|
3
|
+
export declare const CHANNEL_INIT_WINDOW_SECONDS = 120;
|
|
4
|
+
export declare const DEFAULT_IDLE_TIMEOUT_SECONDS = 60;
|
|
5
|
+
export declare const DEFAULT_TOKEN_EXP_SECONDS = 60;
|
|
6
|
+
export type ChannelInitParams = Readonly<{
|
|
7
|
+
tunnelUrl: string;
|
|
8
|
+
tunnelAudience: string;
|
|
9
|
+
issuerId: string;
|
|
10
|
+
tokenExpSeconds?: number;
|
|
11
|
+
idleTimeoutSeconds?: number;
|
|
12
|
+
clockSkewMs?: number;
|
|
13
|
+
allowedSuites?: readonly Suite[];
|
|
14
|
+
defaultSuite?: Suite;
|
|
15
|
+
}>;
|
|
16
|
+
export declare class ChannelInitService {
|
|
17
|
+
private readonly issuer;
|
|
18
|
+
private readonly params;
|
|
19
|
+
private readonly nowUnixS;
|
|
20
|
+
constructor(issuer: IssuerKeyset, params: ChannelInitParams, nowUnixS?: () => number);
|
|
21
|
+
issue(channelIdInput: string): Readonly<{
|
|
22
|
+
client: ChannelInitGrant;
|
|
23
|
+
server: ChannelInitGrant;
|
|
24
|
+
}>;
|
|
25
|
+
reissue(grant: ChannelInitGrant): ChannelInitGrant;
|
|
26
|
+
private signRoleToken;
|
|
27
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Role } from "../gen/flowersec/controlplane/v1.gen.js";
|
|
2
|
+
import { base64urlEncode } from "../utils/base64url.js";
|
|
3
|
+
export const CHANNEL_INIT_WINDOW_SECONDS = 120;
|
|
4
|
+
export const DEFAULT_IDLE_TIMEOUT_SECONDS = 60;
|
|
5
|
+
export const DEFAULT_TOKEN_EXP_SECONDS = 60;
|
|
6
|
+
export class ChannelInitService {
|
|
7
|
+
issuer;
|
|
8
|
+
params;
|
|
9
|
+
nowUnixS;
|
|
10
|
+
constructor(issuer, params, nowUnixS = () => Math.floor(Date.now() / 1000)) {
|
|
11
|
+
this.issuer = issuer;
|
|
12
|
+
this.params = params;
|
|
13
|
+
this.nowUnixS = nowUnixS;
|
|
14
|
+
}
|
|
15
|
+
issue(channelIdInput) {
|
|
16
|
+
const params = normalizeParams(this.params);
|
|
17
|
+
const channelId = normalizeChannelId(channelIdInput);
|
|
18
|
+
const psk = crypto.getRandomValues(new Uint8Array(32));
|
|
19
|
+
let pskB64u;
|
|
20
|
+
try {
|
|
21
|
+
pskB64u = base64urlEncode(psk);
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
psk.fill(0);
|
|
25
|
+
}
|
|
26
|
+
const now = normalizeNow(this.nowUnixS());
|
|
27
|
+
const initExp = now + CHANNEL_INIT_WINDOW_SECONDS;
|
|
28
|
+
const grant = (role, token) => ({
|
|
29
|
+
tunnel_url: params.tunnelUrl,
|
|
30
|
+
channel_id: channelId,
|
|
31
|
+
channel_init_expire_at_unix_s: initExp,
|
|
32
|
+
idle_timeout_seconds: params.idleTimeoutSeconds,
|
|
33
|
+
role,
|
|
34
|
+
token,
|
|
35
|
+
e2ee_psk_b64u: pskB64u,
|
|
36
|
+
allowed_suites: [...params.allowedSuites],
|
|
37
|
+
default_suite: params.defaultSuite,
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
client: grant(Role.Role_client, this.signRoleToken(channelId, Role.Role_client, initExp, params.idleTimeoutSeconds, params.tokenExpSeconds, now, params)),
|
|
41
|
+
server: grant(Role.Role_server, this.signRoleToken(channelId, Role.Role_server, initExp, params.idleTimeoutSeconds, params.tokenExpSeconds, now, params)),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
reissue(grant) {
|
|
45
|
+
const params = normalizeParams(this.params);
|
|
46
|
+
if (grant.idle_timeout_seconds <= 0 || (grant.role !== Role.Role_client && grant.role !== Role.Role_server))
|
|
47
|
+
throw new Error("invalid grant");
|
|
48
|
+
const now = normalizeNow(this.nowUnixS());
|
|
49
|
+
const skewSeconds = Math.ceil(params.clockSkewMs / 1000);
|
|
50
|
+
if (now > grant.channel_init_expire_at_unix_s + skewSeconds)
|
|
51
|
+
throw new Error("channel init expired");
|
|
52
|
+
return {
|
|
53
|
+
...grant,
|
|
54
|
+
token: this.signRoleToken(normalizeChannelId(grant.channel_id), grant.role, grant.channel_init_expire_at_unix_s, grant.idle_timeout_seconds, params.tokenExpSeconds, now, params),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
signRoleToken(channelId, role, initExp, idleTimeoutSeconds, tokenExpSeconds, now, params) {
|
|
58
|
+
const iat = Math.min(now, initExp);
|
|
59
|
+
const exp = Math.min(initExp, iat + tokenExpSeconds);
|
|
60
|
+
const tokenId = crypto.getRandomValues(new Uint8Array(24));
|
|
61
|
+
try {
|
|
62
|
+
return this.issuer.sign({
|
|
63
|
+
aud: params.tunnelAudience,
|
|
64
|
+
iss: params.issuerId,
|
|
65
|
+
channel_id: channelId,
|
|
66
|
+
role: role,
|
|
67
|
+
token_id: base64urlEncode(tokenId),
|
|
68
|
+
init_exp: initExp,
|
|
69
|
+
idle_timeout_seconds: idleTimeoutSeconds,
|
|
70
|
+
iat,
|
|
71
|
+
exp,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
tokenId.fill(0);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function normalizeParams(params) {
|
|
80
|
+
const tunnelUrl = params.tunnelUrl.trim();
|
|
81
|
+
const tunnelAudience = params.tunnelAudience.trim();
|
|
82
|
+
const issuerId = params.issuerId.trim();
|
|
83
|
+
if (tunnelUrl === "")
|
|
84
|
+
throw new Error("missing tunnel URL");
|
|
85
|
+
if (tunnelAudience === "")
|
|
86
|
+
throw new Error("missing tunnel audience");
|
|
87
|
+
if (issuerId === "")
|
|
88
|
+
throw new Error("missing issuer ID");
|
|
89
|
+
const tokenExpSeconds = normalizeNonNegative("tokenExpSeconds", params.tokenExpSeconds ?? 0) || DEFAULT_TOKEN_EXP_SECONDS;
|
|
90
|
+
const idleTimeoutSeconds = normalizeNonNegative("idleTimeoutSeconds", params.idleTimeoutSeconds ?? 0) || DEFAULT_IDLE_TIMEOUT_SECONDS;
|
|
91
|
+
const clockSkewMs = normalizeNonNegative("clockSkewMs", params.clockSkewMs ?? 0);
|
|
92
|
+
const allowedSuites = [...new Set(params.allowedSuites?.length ? params.allowedSuites : [1])];
|
|
93
|
+
for (const suite of allowedSuites)
|
|
94
|
+
if (suite !== 1 && suite !== 2)
|
|
95
|
+
throw new Error("unsupported suite");
|
|
96
|
+
const defaultSuite = params.defaultSuite ?? allowedSuites[0];
|
|
97
|
+
if (!allowedSuites.includes(defaultSuite))
|
|
98
|
+
throw new Error("default suite not allowed");
|
|
99
|
+
return { tunnelUrl, tunnelAudience, issuerId, tokenExpSeconds, idleTimeoutSeconds, clockSkewMs, allowedSuites, defaultSuite };
|
|
100
|
+
}
|
|
101
|
+
function normalizeChannelId(input) {
|
|
102
|
+
const value = input.trim();
|
|
103
|
+
if (value === "" || new TextEncoder().encode(value).length > 256)
|
|
104
|
+
throw new Error("invalid channel ID");
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
function normalizeNonNegative(name, value) {
|
|
108
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
109
|
+
throw new Error(`${name} must be a non-negative integer`);
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
function normalizeNow(value) {
|
|
113
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
114
|
+
throw new Error("invalid clock");
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type ArtifactRequest = Readonly<{
|
|
2
|
+
endpoint_id: string;
|
|
3
|
+
payload?: Record<string, unknown>;
|
|
4
|
+
correlation?: Readonly<{
|
|
5
|
+
trace_id?: string;
|
|
6
|
+
}>;
|
|
7
|
+
}>;
|
|
8
|
+
export declare class ControlplaneCodecError extends Error {
|
|
9
|
+
readonly status: number;
|
|
10
|
+
readonly code: string;
|
|
11
|
+
constructor(status: number, code: string, message: string);
|
|
12
|
+
}
|
|
13
|
+
export declare function decodeArtifactRequest(contentType: string, body: Uint8Array, maxBodyBytes?: number): ArtifactRequest;
|
|
14
|
+
export declare function encodeArtifactEnvelope(artifact: unknown): Uint8Array;
|
|
15
|
+
export declare function encodeControlplaneError(code: string, message: string): Uint8Array;
|
|
16
|
+
export declare function bearerToken(authorization: string): string | undefined;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { assertConnectArtifact } from "../connect/artifact.js";
|
|
2
|
+
import { SDK_DEFAULTS } from "../defaults.js";
|
|
3
|
+
export class ControlplaneCodecError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
code;
|
|
6
|
+
constructor(status, code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.name = "ControlplaneCodecError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function decodeArtifactRequest(contentType, body, maxBodyBytes = SDK_DEFAULTS.controlplane.maxRequestBodyBytes) {
|
|
14
|
+
if (contentType.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") {
|
|
15
|
+
throw new ControlplaneCodecError(415, "unsupported_media_type", "content type must be application/json");
|
|
16
|
+
}
|
|
17
|
+
if (body.length > maxBodyBytes)
|
|
18
|
+
throw new ControlplaneCodecError(413, "body_too_large", `request body exceeds ${maxBodyBytes} bytes`);
|
|
19
|
+
let value;
|
|
20
|
+
try {
|
|
21
|
+
value = JSON.parse(new TextDecoder().decode(body));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new ControlplaneCodecError(400, "invalid_json", "malformed JSON request body");
|
|
25
|
+
}
|
|
26
|
+
if (typeof value !== "object" || value == null || Array.isArray(value))
|
|
27
|
+
throw new ControlplaneCodecError(400, "invalid_json", "request body must be a JSON object");
|
|
28
|
+
const input = value;
|
|
29
|
+
const endpointId = typeof input["endpoint_id"] === "string" ? input["endpoint_id"].trim() : "";
|
|
30
|
+
if (endpointId === "")
|
|
31
|
+
throw new ControlplaneCodecError(400, "invalid_request", "bad endpoint_id");
|
|
32
|
+
const payload = input["payload"];
|
|
33
|
+
if (payload !== undefined && (typeof payload !== "object" || payload == null || Array.isArray(payload)))
|
|
34
|
+
throw new ControlplaneCodecError(400, "invalid_request", "bad payload");
|
|
35
|
+
const correlation = input["correlation"];
|
|
36
|
+
if (correlation !== undefined && (typeof correlation !== "object" || correlation == null || Array.isArray(correlation)))
|
|
37
|
+
throw new ControlplaneCodecError(400, "invalid_request", "bad correlation");
|
|
38
|
+
const traceId = typeof correlation?.["trace_id"] === "string"
|
|
39
|
+
? String(correlation["trace_id"]).trim()
|
|
40
|
+
: "";
|
|
41
|
+
return {
|
|
42
|
+
endpoint_id: endpointId,
|
|
43
|
+
...(payload === undefined ? {} : { payload: payload }),
|
|
44
|
+
...(traceId === "" ? {} : { correlation: { trace_id: traceId } }),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function encodeArtifactEnvelope(artifact) {
|
|
48
|
+
return new TextEncoder().encode(JSON.stringify({ connect_artifact: assertConnectArtifact(artifact) }));
|
|
49
|
+
}
|
|
50
|
+
export function encodeControlplaneError(code, message) {
|
|
51
|
+
return new TextEncoder().encode(JSON.stringify({ error: { code, message } }));
|
|
52
|
+
}
|
|
53
|
+
export function bearerToken(authorization) {
|
|
54
|
+
const value = authorization.trim();
|
|
55
|
+
if (!value.startsWith("Bearer "))
|
|
56
|
+
return undefined;
|
|
57
|
+
const token = value.slice("Bearer ".length).trim();
|
|
58
|
+
return token === "" ? undefined : token;
|
|
59
|
+
}
|
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
export type { ArtifactRequestCorrelation, ConnectArtifactEnvelope, ControlplaneBaseConfig, ControlplaneErrorEnvelope, RequestConnectArtifactInput, RequestEntryConnectArtifactInput, } from "./request.js";
|
|
2
2
|
export { ControlplaneRequestError, DEFAULT_CONNECT_ARTIFACT_PATH, DEFAULT_ENTRY_CONNECT_ARTIFACT_PATH, requestConnectArtifact, requestEntryConnectArtifact, } from "./request.js";
|
|
3
|
+
export * from "./token.js";
|
|
4
|
+
export * from "./issuer.js";
|
|
5
|
+
export * from "./channelInit.js";
|
|
6
|
+
export * from "./http.js";
|
|
@@ -1 +1,5 @@
|
|
|
1
1
|
export { ControlplaneRequestError, DEFAULT_CONNECT_ARTIFACT_PATH, DEFAULT_ENTRY_CONNECT_ARTIFACT_PATH, requestConnectArtifact, requestEntryConnectArtifact, } from "./request.js";
|
|
2
|
+
export * from "./token.js";
|
|
3
|
+
export * from "./issuer.js";
|
|
4
|
+
export * from "./channelInit.js";
|
|
5
|
+
export * from "./http.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type TokenPayload } from "./token.js";
|
|
2
|
+
export declare class IssuerKeyset {
|
|
3
|
+
private kid;
|
|
4
|
+
private signingSeed;
|
|
5
|
+
constructor(kid: string, signingSeed: Uint8Array);
|
|
6
|
+
static random(kid: string): IssuerKeyset;
|
|
7
|
+
currentKID(): string;
|
|
8
|
+
publicKeys(): ReadonlyMap<string, Uint8Array>;
|
|
9
|
+
sign(payload: Omit<TokenPayload, "kid"> & Readonly<{
|
|
10
|
+
kid?: string;
|
|
11
|
+
}>): string;
|
|
12
|
+
rotate(kid: string, signingSeed: Uint8Array): void;
|
|
13
|
+
exportTunnelKeyset(): Uint8Array;
|
|
14
|
+
dispose(): void;
|
|
15
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { ed25519 } from "@noble/curves/ed25519";
|
|
2
|
+
import { base64urlEncode } from "../utils/base64url.js";
|
|
3
|
+
import { signToken } from "./token.js";
|
|
4
|
+
export class IssuerKeyset {
|
|
5
|
+
kid;
|
|
6
|
+
signingSeed;
|
|
7
|
+
constructor(kid, signingSeed) {
|
|
8
|
+
this.kid = normalizeKid(kid);
|
|
9
|
+
if (signingSeed.length !== 32)
|
|
10
|
+
throw new TypeError("Ed25519 signing seed must be 32 bytes");
|
|
11
|
+
this.signingSeed = signingSeed.slice();
|
|
12
|
+
}
|
|
13
|
+
static random(kid) {
|
|
14
|
+
const seed = crypto.getRandomValues(new Uint8Array(32));
|
|
15
|
+
try {
|
|
16
|
+
return new IssuerKeyset(kid, seed);
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
seed.fill(0);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
currentKID() {
|
|
23
|
+
return this.kid;
|
|
24
|
+
}
|
|
25
|
+
publicKeys() {
|
|
26
|
+
return new Map([[this.kid, ed25519.getPublicKey(this.signingSeed)]]);
|
|
27
|
+
}
|
|
28
|
+
sign(payload) {
|
|
29
|
+
return signToken(this.signingSeed, { ...payload, kid: this.kid });
|
|
30
|
+
}
|
|
31
|
+
rotate(kid, signingSeed) {
|
|
32
|
+
const nextKID = normalizeKid(kid);
|
|
33
|
+
if (signingSeed.length !== 32)
|
|
34
|
+
throw new TypeError("Ed25519 signing seed must be 32 bytes");
|
|
35
|
+
const nextSeed = signingSeed.slice();
|
|
36
|
+
this.signingSeed.fill(0);
|
|
37
|
+
this.kid = nextKID;
|
|
38
|
+
this.signingSeed = nextSeed;
|
|
39
|
+
}
|
|
40
|
+
exportTunnelKeyset() {
|
|
41
|
+
const keys = [...this.publicKeys()].map(([kid, publicKey]) => ({ kid, pubkey_b64u: base64urlEncode(publicKey) }));
|
|
42
|
+
return new TextEncoder().encode(JSON.stringify({ keys }, null, 2));
|
|
43
|
+
}
|
|
44
|
+
dispose() {
|
|
45
|
+
this.signingSeed.fill(0);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function normalizeKid(kid) {
|
|
49
|
+
const value = kid.trim();
|
|
50
|
+
if (value === "")
|
|
51
|
+
throw new TypeError("key ID is required");
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { assertConnectArtifact } from "../connect/artifact.js";
|
|
2
|
+
import { SDK_DEFAULTS } from "../defaults.js";
|
|
2
3
|
export class ControlplaneRequestError extends Error {
|
|
3
4
|
status;
|
|
4
5
|
code;
|
|
@@ -13,7 +14,7 @@ export class ControlplaneRequestError extends Error {
|
|
|
13
14
|
}
|
|
14
15
|
export const DEFAULT_CONNECT_ARTIFACT_PATH = "/v1/connect/artifact";
|
|
15
16
|
export const DEFAULT_ENTRY_CONNECT_ARTIFACT_PATH = "/v1/connect/artifact/entry";
|
|
16
|
-
const DEFAULT_MAX_CONTROLPLANE_RESPONSE_BYTES =
|
|
17
|
+
const DEFAULT_MAX_CONTROLPLANE_RESPONSE_BYTES = SDK_DEFAULTS.controlplane.maxResponseBodyBytes;
|
|
17
18
|
class ControlplaneResponseTooLargeError extends Error {
|
|
18
19
|
constructor(maxBytes) {
|
|
19
20
|
super(`controlplane response exceeded ${maxBytes} bytes`);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export declare const TOKEN_PREFIX = "FST2";
|
|
2
|
+
export type TokenPayload = Readonly<{
|
|
3
|
+
kid: string;
|
|
4
|
+
aud: string;
|
|
5
|
+
iss?: string;
|
|
6
|
+
channel_id: string;
|
|
7
|
+
role: 1 | 2;
|
|
8
|
+
token_id: string;
|
|
9
|
+
init_exp: number;
|
|
10
|
+
idle_timeout_seconds: number;
|
|
11
|
+
iat: number;
|
|
12
|
+
exp: number;
|
|
13
|
+
}>;
|
|
14
|
+
export type ParsedToken = Readonly<{
|
|
15
|
+
payload: TokenPayload;
|
|
16
|
+
signed: Uint8Array;
|
|
17
|
+
signature: Uint8Array;
|
|
18
|
+
}>;
|
|
19
|
+
export type TokenVerifyOptions = Readonly<{
|
|
20
|
+
nowUnixS?: number;
|
|
21
|
+
audience?: string;
|
|
22
|
+
issuer?: string;
|
|
23
|
+
clockSkewMs?: number;
|
|
24
|
+
}>;
|
|
25
|
+
export type TokenKeyLookup = ReadonlyMap<string, Uint8Array> | ((kid: string) => Uint8Array | undefined);
|
|
26
|
+
export declare class TokenError extends Error {
|
|
27
|
+
readonly code: string;
|
|
28
|
+
constructor(code: string, message?: string);
|
|
29
|
+
}
|
|
30
|
+
export declare function signToken(signingSeed: Uint8Array, payload: TokenPayload): string;
|
|
31
|
+
export declare function parseToken(token: string): ParsedToken;
|
|
32
|
+
export declare function verifyToken(token: string, keys: TokenKeyLookup, options?: TokenVerifyOptions): TokenPayload;
|
|
33
|
+
export declare function equalSignedTokenPart(left: string, right: string): boolean;
|