@nylorun/runtime 0.21.1-beta → 0.23.0-beta
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 +32 -0
- package/README.md +17 -3
- package/dist/adapters/execution/restate.d.ts +15 -3
- package/dist/adapters/execution/restate.js +77 -83
- package/dist/adapters/sandbox/local.js +1 -1
- package/dist/adapters/sandbox/virtual.js +1 -1
- package/dist/adapters/streams/s2.js +1 -13
- package/dist/api/components.d.ts +42 -2
- package/dist/core/flow-host.d.ts +1 -19
- package/dist/core/flow-host.js +8 -37
- package/dist/execution/types.d.ts +10 -8
- package/dist/execution/types.js +5 -7
- package/dist/gates/egress-credentials.d.ts +48 -0
- package/dist/gates/egress-credentials.js +147 -0
- package/dist/gates/egress.d.ts +3 -0
- package/dist/gates/egress.js +14 -1
- package/dist/gates/tenant-vaults.d.ts +4 -0
- package/dist/gates/tenant-vaults.js +2 -0
- package/dist/harness/calls.d.ts +2 -0
- package/dist/harness/calls.js +1 -0
- package/dist/harness/executors.js +1 -0
- package/dist/harness/main.js +4 -1
- package/dist/harness/pod.d.ts +2 -0
- package/dist/harness/pod.js +36 -1
- package/dist/harness-api/start.d.ts +2 -0
- package/dist/harness-api/start.js +4 -2
- package/dist/harness-api/ws-server.js +2 -2
- package/dist/host/execution.d.ts +8 -6
- package/dist/host/gates.js +11 -0
- package/dist/host/main.js +10 -16
- package/dist/host/stack-config.d.ts +6 -0
- package/dist/host/stack-config.js +6 -0
- package/dist/infra/execution.d.ts +18 -1
- package/dist/infra/execution.js +28 -4
- package/dist/infra/index.d.ts +1 -1
- package/dist/keys/egress-ca.d.ts +15 -0
- package/dist/keys/egress-ca.js +66 -0
- package/dist/keys/keys.d.ts +8 -1
- package/dist/keys/keys.js +6 -0
- package/dist/keys/x509.d.ts +29 -0
- package/dist/keys/x509.js +160 -0
- package/dist/management-openapi.json +195 -9
- package/dist/openapi.json +2 -1
- package/dist/retry.d.ts +21 -0
- package/dist/retry.js +34 -0
- package/dist/sandbox/join.d.ts +2 -0
- package/dist/sandbox/join.js +14 -10
- package/dist/sandbox/manager.d.ts +2 -0
- package/dist/sandbox/manager.js +1 -1
- package/dist/sandbox/pods/reconcile.js +3 -3
- package/dist/sandbox/tools.d.ts +3 -1
- package/dist/sandbox/tools.js +4 -2
- package/dist/sandbox/types.d.ts +5 -0
- package/dist/store/postgres/control.js +8 -1
- package/dist/store/postgres/drizzle/0018_wake_outbox_retries.sql +3 -0
- package/dist/store/postgres/drizzle/0019_sandbox_signal_outbox.sql +18 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +14 -0
- package/dist/store/postgres/schema.d.ts +265 -4
- package/dist/store/postgres/schema.js +35 -4
- package/dist/store/postgres/store.js +165 -52
- package/dist/store/types.d.ts +96 -39
- package/dist/streams/relay/core.js +1 -13
- package/dist/tenant/advance.js +6 -7
- package/dist/tenant/commands.js +1 -2
- package/dist/tenant/context.d.ts +19 -11
- package/dist/tenant/control.d.ts +8 -1
- package/dist/tenant/control.js +2 -0
- package/dist/tenant/runtime.js +23 -3
- package/dist/tenant/sandboxes.d.ts +2 -1
- package/dist/tenant/sandboxes.js +23 -19
- package/dist/tenant/scheduler.d.ts +7 -0
- package/dist/tenant/scheduler.js +38 -7
- package/dist/tenant/session-streams.d.ts +0 -1
- package/dist/tenant/session-streams.js +1 -13
- package/dist/tenant/sessions.js +26 -0
- package/dist/tenant/streams.js +14 -29
- package/dist/tenant/sweep.d.ts +39 -12
- package/dist/tenant/sweep.js +157 -58
- package/dist/tenant/transcript.d.ts +8 -8
- package/dist/vault/service.d.ts +34 -0
- package/dist/vault/service.js +148 -15
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -3
|
@@ -29,7 +29,24 @@ export declare function executionKind(config: Pick<StackConfig, "endpoints">): "
|
|
|
29
29
|
export declare function validateExecutionConfig(config: Pick<StackConfig, "endpoints"> & {
|
|
30
30
|
services?: RuntimeServices;
|
|
31
31
|
}): void;
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* The deployment a Worker registers with Restate (§14.6): `<NYLORUN_WORKER_URL>/nylorun/<version>`,
|
|
34
|
+
* the version being `NYLORUN_WORKER_VERSION` or the Runtime's. Restate sends new invocations to
|
|
35
|
+
* the latest deployment of a service and keeps each running invocation on the deployment it
|
|
36
|
+
* started on, so during a rolling upgrade the Workers of one version share that version's
|
|
37
|
+
* deployment (behind one load-balanced `NYLORUN_WORKER_URL`) and the old one finishes what it
|
|
38
|
+
* started; the endpoint answers on any path, so an older deployment's URL still reaches it.
|
|
39
|
+
* `force` replaces a deployment already registered at the URL with different code: only
|
|
40
|
+
* outside a container (a development Host, whose code changes under one version). Without
|
|
41
|
+
* it Restate keeps the deployment it already has at the URL, and a Worker whose services that
|
|
42
|
+
* deployment lacks refuses to start: a build whose code changed under the same version sets
|
|
43
|
+
* its own `NYLORUN_WORKER_VERSION`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function workerDeployment(config: Pick<StackConfig, "endpoints" | "listen">): {
|
|
46
|
+
url: string;
|
|
47
|
+
force: boolean;
|
|
48
|
+
} | undefined;
|
|
49
|
+
export declare function createExecution(config: Pick<StackConfig, "endpoints" | "listen"> & {
|
|
33
50
|
services?: RuntimeServices;
|
|
34
51
|
}, options?: CreateExecutionOptions): DurableExecution;
|
|
35
52
|
/**
|
package/dist/infra/execution.js
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* - wakes, timers and sweep arming go through the ingress, so every process can
|
|
9
9
|
* call them;
|
|
10
10
|
* - a Worker (a process running the loop service) serves the Restate endpoint on
|
|
11
|
-
* `0.0.0.0:9080` and registers
|
|
12
|
-
* `
|
|
11
|
+
* `0.0.0.0:9080` and registers it with the admin API when `infra/workers.ts` starts it,
|
|
12
|
+
* as a versioned deployment (`workerDeployment`);
|
|
13
13
|
* - with `NYLORUN_RESTATE_IDENTITY_KEY` the endpoint accepts only requests
|
|
14
14
|
* signed by the Restate server holding the matching private key.
|
|
15
15
|
*
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* only unit tests and a local development Host use.
|
|
18
18
|
*/
|
|
19
19
|
import { createRestateExecution } from "../adapters/execution/restate.js";
|
|
20
|
+
import { RUNTIME_VERSION } from "../version.js";
|
|
20
21
|
import { MemoryExecution } from "../execution/memory.js";
|
|
21
22
|
import { DEFAULT_SERVICES, describeServices, } from "../host/stack-config.js";
|
|
22
23
|
/** Where a Worker serves the Restate endpoint (architecture §14.3). */
|
|
@@ -46,16 +47,39 @@ export function validateExecutionConfig(config) {
|
|
|
46
47
|
if (services.has("loop") && !workerUrl)
|
|
47
48
|
throw new Error(`NYLORUN_WORKER_URL is required for --service ${describeServices(services)}: the Worker endpoint URL Restate calls`);
|
|
48
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* The deployment a Worker registers with Restate (§14.6): `<NYLORUN_WORKER_URL>/nylorun/<version>`,
|
|
52
|
+
* the version being `NYLORUN_WORKER_VERSION` or the Runtime's. Restate sends new invocations to
|
|
53
|
+
* the latest deployment of a service and keeps each running invocation on the deployment it
|
|
54
|
+
* started on, so during a rolling upgrade the Workers of one version share that version's
|
|
55
|
+
* deployment (behind one load-balanced `NYLORUN_WORKER_URL`) and the old one finishes what it
|
|
56
|
+
* started; the endpoint answers on any path, so an older deployment's URL still reaches it.
|
|
57
|
+
* `force` replaces a deployment already registered at the URL with different code: only
|
|
58
|
+
* outside a container (a development Host, whose code changes under one version). Without
|
|
59
|
+
* it Restate keeps the deployment it already has at the URL, and a Worker whose services that
|
|
60
|
+
* deployment lacks refuses to start: a build whose code changed under the same version sets
|
|
61
|
+
* its own `NYLORUN_WORKER_VERSION`.
|
|
62
|
+
*/
|
|
63
|
+
export function workerDeployment(config) {
|
|
64
|
+
const { workerUrl, workerVersion } = config.endpoints;
|
|
65
|
+
if (!workerUrl)
|
|
66
|
+
return undefined;
|
|
67
|
+
const version = encodeURIComponent(workerVersion ?? RUNTIME_VERSION);
|
|
68
|
+
return { url: `${workerUrl.replace(/\/+$/, "")}/nylorun/${version}`, force: !config.listen };
|
|
69
|
+
}
|
|
49
70
|
export function createExecution(config, options = {}) {
|
|
50
71
|
validateExecutionConfig(config);
|
|
51
|
-
const { restateIngressUrl, restateAdminUrl,
|
|
72
|
+
const { restateIngressUrl, restateAdminUrl, restateIdentityKeys } = config.endpoints;
|
|
52
73
|
if (!restateIngressUrl || !restateAdminUrl)
|
|
53
74
|
return new MemoryExecution();
|
|
75
|
+
const deployment = workerDeployment(config);
|
|
54
76
|
return createRestateExecution({
|
|
55
77
|
ingressUrl: restateIngressUrl,
|
|
56
78
|
adminUrl: restateAdminUrl,
|
|
57
79
|
workerListen: options.workerListen ?? { ...WORKER_LISTEN },
|
|
58
|
-
...(
|
|
80
|
+
...(deployment
|
|
81
|
+
? { workerAdvertisedUrl: deployment.url, forceRegistration: deployment.force }
|
|
82
|
+
: {}),
|
|
59
83
|
...(restateIdentityKeys?.length ? { identityKeys: [...restateIdentityKeys] } : {}),
|
|
60
84
|
...(options.servicePrefix ? { servicePrefix: options.servicePrefix } : {}),
|
|
61
85
|
...(options.logger ? { logger: options.logger } : {}),
|
package/dist/infra/index.d.ts
CHANGED
|
@@ -36,7 +36,7 @@ export interface Infra {
|
|
|
36
36
|
* Builds the configured clients. Throws naming the variable when endpoints are
|
|
37
37
|
* incomplete (e.g. a Restate ingress without an admin URL).
|
|
38
38
|
*/
|
|
39
|
-
export declare function createInfra(config: Pick<StackConfig, "services" | "endpoints">, options?: {
|
|
39
|
+
export declare function createInfra(config: Pick<StackConfig, "services" | "endpoints" | "listen">, options?: {
|
|
40
40
|
logger?: Logger;
|
|
41
41
|
execution?: CreateExecutionOptions;
|
|
42
42
|
streams?: CreateStreamsOptions;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SessionStore } from "../store/types.js";
|
|
2
|
+
/** How long a leaf egress-gate presents is valid: one connection rarely lasts this long. */
|
|
3
|
+
export declare const EGRESS_LEAF_MINUTES = 60;
|
|
4
|
+
export declare class EgressAuthority {
|
|
5
|
+
private readonly store;
|
|
6
|
+
private readonly kek;
|
|
7
|
+
/** The CA with its key, decrypted once per process, as signing keys are. */
|
|
8
|
+
private authority;
|
|
9
|
+
constructor(store: SessionStore, kek: () => Buffer);
|
|
10
|
+
/** The CA certificate (PEM), creating the CA when the Tenant has none. */
|
|
11
|
+
certificate(): Promise<string>;
|
|
12
|
+
/** A leaf for `host` and the caller's P-256 public key (SPKI PEM); the caller keeps its key. */
|
|
13
|
+
signLeaf(host: string, publicKey: string): Promise<string>;
|
|
14
|
+
private load;
|
|
15
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The installation's egress CA (R2c, D50, E12): one long-lived certificate authority whose
|
|
3
|
+
* certificate every pod sandbox trusts, and whose key signs the short-lived leaf egress-gate
|
|
4
|
+
* presents when it terminates TLS for a host an `environment_secret` is bound to. Only keys holds
|
|
5
|
+
* the key: it lives in the Tenant setting `egress.ca`, sealed under the vault KEK like a signing
|
|
6
|
+
* key, beside the certificate in the clear. Created on first use under the signing keys' lock.
|
|
7
|
+
* No rotation yet (R8, with the KeyRoot seam).
|
|
8
|
+
*/
|
|
9
|
+
import { X509Certificate } from "node:crypto";
|
|
10
|
+
import { decryptSecret, encryptSecret } from "../vault/crypto.js";
|
|
11
|
+
import { createCertificateAuthority, issueLeafCertificate } from "./x509.js";
|
|
12
|
+
const SETTING = "egress.ca";
|
|
13
|
+
/** How long a leaf egress-gate presents is valid: one connection rarely lasts this long. */
|
|
14
|
+
export const EGRESS_LEAF_MINUTES = 60;
|
|
15
|
+
function aad(certificate) {
|
|
16
|
+
return Buffer.from(`nylorun.egress-ca:${new X509Certificate(certificate).fingerprint256}`);
|
|
17
|
+
}
|
|
18
|
+
export class EgressAuthority {
|
|
19
|
+
store;
|
|
20
|
+
kek;
|
|
21
|
+
/** The CA with its key, decrypted once per process, as signing keys are. */
|
|
22
|
+
authority;
|
|
23
|
+
constructor(store, kek) {
|
|
24
|
+
this.store = store;
|
|
25
|
+
this.kek = kek;
|
|
26
|
+
}
|
|
27
|
+
/** The CA certificate (PEM), creating the CA when the Tenant has none. */
|
|
28
|
+
async certificate() {
|
|
29
|
+
return (await this.load()).certificate;
|
|
30
|
+
}
|
|
31
|
+
/** A leaf for `host` and the caller's P-256 public key (SPKI PEM); the caller keeps its key. */
|
|
32
|
+
async signLeaf(host, publicKey) {
|
|
33
|
+
return issueLeafCertificate({ authority: await this.load(), host, publicKey, minutes: EGRESS_LEAF_MINUTES });
|
|
34
|
+
}
|
|
35
|
+
async load() {
|
|
36
|
+
if (this.authority)
|
|
37
|
+
return this.authority;
|
|
38
|
+
const kek = this.kek();
|
|
39
|
+
const stored = await this.store.tx(async (t) => (await read(t)) ?? (await create(t, kek)));
|
|
40
|
+
const privateKey = decryptSecret(kek, aad(stored.certificate), Buffer.from(stored.nonce, "base64"), Buffer.from(stored.ciphertext, "base64"), Buffer.from(stored.wrappedDek, "base64"), stored.kekId).toString("utf8");
|
|
41
|
+
this.authority = { certificate: stored.certificate, privateKey };
|
|
42
|
+
return this.authority;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function read(t) {
|
|
46
|
+
const value = await t.getSetting(SETTING);
|
|
47
|
+
return value === undefined ? undefined : JSON.parse(value);
|
|
48
|
+
}
|
|
49
|
+
async function create(t, kek) {
|
|
50
|
+
// Concurrent first uses would both create a CA: lock, then read what the winner made.
|
|
51
|
+
await t.lockSigningKeys();
|
|
52
|
+
const existing = await read(t);
|
|
53
|
+
if (existing)
|
|
54
|
+
return existing;
|
|
55
|
+
const authority = createCertificateAuthority({ commonName: "Nylorun egress CA" });
|
|
56
|
+
const sealed = encryptSecret(kek, aad(authority.certificate), Buffer.from(authority.privateKey, "utf8"));
|
|
57
|
+
const stored = {
|
|
58
|
+
certificate: authority.certificate,
|
|
59
|
+
kekId: sealed.kekId,
|
|
60
|
+
nonce: sealed.nonce.toString("base64"),
|
|
61
|
+
ciphertext: sealed.ciphertext.toString("base64"),
|
|
62
|
+
wrappedDek: sealed.wrappedDek.toString("base64"),
|
|
63
|
+
};
|
|
64
|
+
await t.putSetting(SETTING, JSON.stringify(stored));
|
|
65
|
+
return stored;
|
|
66
|
+
}
|
package/dist/keys/keys.d.ts
CHANGED
|
@@ -32,9 +32,16 @@ export interface Keys {
|
|
|
32
32
|
* (R2b C12, `mcp/preview.ts`); never calls a tool.
|
|
33
33
|
*/
|
|
34
34
|
previewMcp(body: McpPreviewRequest): Promise<McpPreview>;
|
|
35
|
+
/** The egress CA's certificate (PEM), creating the CA when the Tenant has none (R2c, D50). */
|
|
36
|
+
egressCa(): Promise<string>;
|
|
37
|
+
/**
|
|
38
|
+
* A short-lived leaf for `host` and the caller's P-256 public key (SPKI PEM), signed by the
|
|
39
|
+
* egress CA (R2c). The leaf's private key never reaches keys; the CA's never leaves it.
|
|
40
|
+
*/
|
|
41
|
+
signEgressLeaf(host: string, publicKey: string): Promise<string>;
|
|
35
42
|
}
|
|
36
43
|
/** The operations, by the name the HTTP route carries. */
|
|
37
|
-
export declare const KEYS_OPERATIONS: readonly ["createCredential", "rotateCredential", "putHostModel", "selectHostModel", "sign", "rotateSigningKeys", "ensureSigningKeys", "previewMcp"];
|
|
44
|
+
export declare const KEYS_OPERATIONS: readonly ["createCredential", "rotateCredential", "putHostModel", "selectHostModel", "sign", "rotateSigningKeys", "ensureSigningKeys", "previewMcp", "egressCa", "signEgressLeaf"];
|
|
38
45
|
export type KeysOperation = (typeof KEYS_OPERATIONS)[number];
|
|
39
46
|
export interface InProcessKeysOptions {
|
|
40
47
|
readonly store: SessionStore;
|
package/dist/keys/keys.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* service itself, which serves the HTTP routes with it), and `httpKeys` (`client.ts`), the
|
|
13
13
|
* runtime container's client of the `keys` service in the gateway.
|
|
14
14
|
*/
|
|
15
|
+
import { EgressAuthority } from "./egress-ca.js";
|
|
15
16
|
import { SignJWT } from "jose";
|
|
16
17
|
import { previewMcpServer } from "../mcp/preview.js";
|
|
17
18
|
import { signingKeyView } from "../tenant/signing-keys.js";
|
|
@@ -25,9 +26,12 @@ export const KEYS_OPERATIONS = [
|
|
|
25
26
|
"rotateSigningKeys",
|
|
26
27
|
"ensureSigningKeys",
|
|
27
28
|
"previewMcp",
|
|
29
|
+
"egressCa",
|
|
30
|
+
"signEgressLeaf",
|
|
28
31
|
];
|
|
29
32
|
export function inProcessKeys(options) {
|
|
30
33
|
const { store, vault, signingKeys, kek } = options;
|
|
34
|
+
const egress = new EgressAuthority(store, kek);
|
|
31
35
|
return {
|
|
32
36
|
createCredential: (vaultId, body) => vault.createCredential(vaultId, body),
|
|
33
37
|
rotateCredential: (vaultId, id, body) => vault.rotateCredential(vaultId, id, body),
|
|
@@ -52,5 +56,7 @@ export function inProcessKeys(options) {
|
|
|
52
56
|
await store.tx((t) => signingKeys.ensure(t, key));
|
|
53
57
|
},
|
|
54
58
|
previewMcp: (body) => previewMcpServer(body, { vault, policy: options.policy ?? {} }),
|
|
59
|
+
egressCa: () => egress.certificate(),
|
|
60
|
+
signEgressLeaf: (host, publicKey) => egress.signLeaf(host, publicKey),
|
|
55
61
|
};
|
|
56
62
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface CertificateAuthority {
|
|
2
|
+
/** PEM of the CA certificate: the only part a sandbox ever receives. */
|
|
3
|
+
readonly certificate: string;
|
|
4
|
+
/** PKCS#8 PEM of the CA key: sealed in the vault, used only by keys. */
|
|
5
|
+
readonly privateKey: string;
|
|
6
|
+
}
|
|
7
|
+
/** A new P-256 CA, valid from an hour ago for `days` days. */
|
|
8
|
+
export declare function createCertificateAuthority(options: {
|
|
9
|
+
readonly commonName: string;
|
|
10
|
+
readonly days?: number;
|
|
11
|
+
readonly now?: Date;
|
|
12
|
+
}): CertificateAuthority;
|
|
13
|
+
/**
|
|
14
|
+
* A leaf for `host` and the caller's `publicKey` (SPKI PEM), signed by the CA, valid from five
|
|
15
|
+
* minutes ago for `minutes` minutes. The caller keeps the leaf's private key: only the
|
|
16
|
+
* certificate comes back, so no private key leaves whoever holds it.
|
|
17
|
+
*/
|
|
18
|
+
export declare function issueLeafCertificate(options: {
|
|
19
|
+
readonly authority: CertificateAuthority;
|
|
20
|
+
readonly host: string;
|
|
21
|
+
readonly publicKey: string;
|
|
22
|
+
readonly minutes?: number;
|
|
23
|
+
readonly now?: Date;
|
|
24
|
+
}): string;
|
|
25
|
+
/** A P-256 key pair for a leaf, as PEM: SPKI for the public key, PKCS#8 for the private key. */
|
|
26
|
+
export declare function leafKeyPair(): {
|
|
27
|
+
readonly publicKey: string;
|
|
28
|
+
readonly privateKey: string;
|
|
29
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two certificates R2c needs (D50), built without a dependency: the installation's egress
|
|
3
|
+
* CA, and a short-lived leaf for one host name that egress-gate presents when it terminates TLS
|
|
4
|
+
* for a host an `environment_secret` is bound to. Node can parse X.509 but not build it, so this
|
|
5
|
+
* writes the DER by hand. Both use ECDSA P-256 with SHA-256.
|
|
6
|
+
*
|
|
7
|
+
* Only what clients check is written: a CA with basicConstraints CA:TRUE (critical), keyUsage
|
|
8
|
+
* keyCertSign and cRLSign (critical) and a subject key identifier; a leaf with the host as its
|
|
9
|
+
* only subjectAltName, keyUsage digitalSignature (critical), extKeyUsage serverAuth, and the
|
|
10
|
+
* CA's key identifier. Go (`gh`), OpenSSL (`curl`, `git`), Node and Python accept that shape.
|
|
11
|
+
*/
|
|
12
|
+
import { isIP } from "node:net";
|
|
13
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign, X509Certificate, } from "node:crypto";
|
|
14
|
+
const OID = {
|
|
15
|
+
ecdsaWithSha256: "1.2.840.10045.4.3.2",
|
|
16
|
+
commonName: "2.5.4.3",
|
|
17
|
+
organization: "2.5.4.10",
|
|
18
|
+
basicConstraints: "2.5.29.19",
|
|
19
|
+
keyUsage: "2.5.29.15",
|
|
20
|
+
extKeyUsage: "2.5.29.37",
|
|
21
|
+
serverAuth: "1.3.6.1.5.5.7.3.1",
|
|
22
|
+
subjectAltName: "2.5.29.17",
|
|
23
|
+
subjectKeyIdentifier: "2.5.29.14",
|
|
24
|
+
authorityKeyIdentifier: "2.5.29.35",
|
|
25
|
+
};
|
|
26
|
+
function length(n) {
|
|
27
|
+
if (n < 0x80)
|
|
28
|
+
return Buffer.from([n]);
|
|
29
|
+
const bytes = [];
|
|
30
|
+
for (let v = n; v > 0; v = Math.floor(v / 256))
|
|
31
|
+
bytes.unshift(v & 0xff);
|
|
32
|
+
return Buffer.from([0x80 | bytes.length, ...bytes]);
|
|
33
|
+
}
|
|
34
|
+
function tlv(tag, content) {
|
|
35
|
+
return Buffer.concat([Buffer.from([tag]), length(content.length), content]);
|
|
36
|
+
}
|
|
37
|
+
const seq = (...items) => tlv(0x30, Buffer.concat(items));
|
|
38
|
+
const set = (...items) => tlv(0x31, Buffer.concat(items));
|
|
39
|
+
const octets = (content) => tlv(0x04, content);
|
|
40
|
+
const bits = (content, unused = 0) => tlv(0x03, Buffer.concat([Buffer.from([unused]), content]));
|
|
41
|
+
const bool = (value) => tlv(0x01, Buffer.from([value ? 0xff : 0x00]));
|
|
42
|
+
const explicit = (n, content) => tlv(0xa0 + n, content);
|
|
43
|
+
function integer(value) {
|
|
44
|
+
let v = value;
|
|
45
|
+
while (v.length > 1 && v[0] === 0 && (v[1] & 0x80) === 0)
|
|
46
|
+
v = v.subarray(1);
|
|
47
|
+
if (v[0] & 0x80)
|
|
48
|
+
v = Buffer.concat([Buffer.from([0]), v]);
|
|
49
|
+
return tlv(0x02, v);
|
|
50
|
+
}
|
|
51
|
+
function oid(dotted) {
|
|
52
|
+
const parts = dotted.split(".").map(Number);
|
|
53
|
+
const out = [40 * parts[0] + parts[1]];
|
|
54
|
+
for (const part of parts.slice(2)) {
|
|
55
|
+
const chunk = [part & 0x7f];
|
|
56
|
+
for (let v = Math.floor(part / 128); v > 0; v = Math.floor(v / 128))
|
|
57
|
+
chunk.unshift((v & 0x7f) | 0x80);
|
|
58
|
+
out.push(...chunk);
|
|
59
|
+
}
|
|
60
|
+
return tlv(0x06, Buffer.from(out));
|
|
61
|
+
}
|
|
62
|
+
function name(fields) {
|
|
63
|
+
const rdns = [set(seq(oid(OID.commonName), tlv(0x0c, Buffer.from(fields.commonName, "utf8"))))];
|
|
64
|
+
if (fields.organization)
|
|
65
|
+
rdns.unshift(set(seq(oid(OID.organization), tlv(0x0c, Buffer.from(fields.organization, "utf8")))));
|
|
66
|
+
return seq(...rdns);
|
|
67
|
+
}
|
|
68
|
+
/** UTCTime before 2050, GeneralizedTime from then on (RFC 5280 §4.1.2.5). */
|
|
69
|
+
function time(at) {
|
|
70
|
+
const iso = at.toISOString().replace(/[-:T]/g, "").slice(0, 14) + "Z";
|
|
71
|
+
return at.getUTCFullYear() < 2050 ? tlv(0x17, Buffer.from(iso.slice(2), "ascii")) : tlv(0x18, Buffer.from(iso, "ascii"));
|
|
72
|
+
}
|
|
73
|
+
function extension(id, critical, value) {
|
|
74
|
+
return critical ? seq(oid(id), bool(true), octets(value)) : seq(oid(id), octets(value));
|
|
75
|
+
}
|
|
76
|
+
function spki(key) {
|
|
77
|
+
return key.export({ type: "spki", format: "der" });
|
|
78
|
+
}
|
|
79
|
+
/** SHA-1 of the subjectPublicKey bits (RFC 5280 §4.2.1.2, method 1). */
|
|
80
|
+
function keyIdentifier(publicKey) {
|
|
81
|
+
const der = spki(publicKey);
|
|
82
|
+
// The SPKI is SEQUENCE { AlgorithmIdentifier, BIT STRING }; for P-256 the key is its last 65 bytes.
|
|
83
|
+
return createHash("sha1").update(der.subarray(der.length - 65)).digest();
|
|
84
|
+
}
|
|
85
|
+
function serial() {
|
|
86
|
+
const value = randomBytes(16);
|
|
87
|
+
value[0] = value[0] & 0x7f;
|
|
88
|
+
return integer(value);
|
|
89
|
+
}
|
|
90
|
+
function certificate(tbs, issuerKey) {
|
|
91
|
+
const algorithm = seq(oid(OID.ecdsaWithSha256));
|
|
92
|
+
const signature = sign("sha256", tbs, issuerKey);
|
|
93
|
+
return seq(tbs, algorithm, bits(signature));
|
|
94
|
+
}
|
|
95
|
+
function pem(der) {
|
|
96
|
+
const body = der.toString("base64").replace(/(.{64})/g, "$1\n").replace(/\n$/, "");
|
|
97
|
+
return `-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----\n`;
|
|
98
|
+
}
|
|
99
|
+
/** A new P-256 CA, valid from an hour ago for `days` days. */
|
|
100
|
+
export function createCertificateAuthority(options) {
|
|
101
|
+
const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
102
|
+
const now = options.now ?? new Date();
|
|
103
|
+
const subject = name({ commonName: options.commonName, organization: "Nylorun" });
|
|
104
|
+
const ski = keyIdentifier(publicKey);
|
|
105
|
+
const tbs = seq(explicit(0, integer(Buffer.from([2]))), serial(), seq(oid(OID.ecdsaWithSha256)), subject, seq(time(new Date(now.getTime() - 3_600_000)), time(new Date(now.getTime() + (options.days ?? 3650) * 86_400_000))), subject, spki(publicKey), explicit(3, seq(extension(OID.basicConstraints, true, seq(bool(true), integer(Buffer.from([0])))), extension(OID.keyUsage, true, bits(Buffer.from([0x06]), 1)), extension(OID.subjectKeyIdentifier, false, octets(ski)))));
|
|
106
|
+
return {
|
|
107
|
+
certificate: pem(certificate(tbs, privateKey)),
|
|
108
|
+
privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A leaf for `host` and the caller's `publicKey` (SPKI PEM), signed by the CA, valid from five
|
|
113
|
+
* minutes ago for `minutes` minutes. The caller keeps the leaf's private key: only the
|
|
114
|
+
* certificate comes back, so no private key leaves whoever holds it.
|
|
115
|
+
*/
|
|
116
|
+
export function issueLeafCertificate(options) {
|
|
117
|
+
if (isIP(options.host) !== 0 || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(options.host))
|
|
118
|
+
throw new Error(`Not a host name: ${options.host}`);
|
|
119
|
+
const caKey = createPrivateKey(options.authority.privateKey);
|
|
120
|
+
const ca = new X509Certificate(options.authority.certificate);
|
|
121
|
+
const publicKey = createPublicKey(options.publicKey);
|
|
122
|
+
if (publicKey.asymmetricKeyType !== "ec" || publicKey.asymmetricKeyDetails?.namedCurve !== "prime256v1")
|
|
123
|
+
throw new Error("A leaf key must be ECDSA P-256");
|
|
124
|
+
const now = options.now ?? new Date();
|
|
125
|
+
const issuer = Buffer.from(ca.raw);
|
|
126
|
+
const tbs = seq(explicit(0, integer(Buffer.from([2]))), serial(), seq(oid(OID.ecdsaWithSha256)), subjectOf(issuer), seq(time(new Date(now.getTime() - 300_000)), time(new Date(now.getTime() + (options.minutes ?? 60) * 60_000))), name({ commonName: options.host }), spki(publicKey), explicit(3, seq(extension(OID.keyUsage, true, bits(Buffer.from([0x80]), 7)), extension(OID.extKeyUsage, false, seq(oid(OID.serverAuth))), extension(OID.subjectAltName, false, seq(tlv(0x82, Buffer.from(options.host, "ascii")))), extension(OID.authorityKeyIdentifier, false, seq(tlv(0x80, keyIdentifier(ca.publicKey)))))));
|
|
127
|
+
return pem(certificate(tbs, caKey));
|
|
128
|
+
}
|
|
129
|
+
/** A P-256 key pair for a leaf, as PEM: SPKI for the public key, PKCS#8 for the private key. */
|
|
130
|
+
export function leafKeyPair() {
|
|
131
|
+
const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
132
|
+
return {
|
|
133
|
+
publicKey: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
134
|
+
privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/** The subject Name of a DER certificate, byte for byte, to use as a leaf's issuer. */
|
|
138
|
+
function subjectOf(der) {
|
|
139
|
+
// Certificate ::= SEQUENCE { tbs SEQUENCE { [0] version, serial, sigAlg, issuer, validity, subject, … } }
|
|
140
|
+
const read = (buf, at) => {
|
|
141
|
+
let len = buf[at + 1];
|
|
142
|
+
let header = 2;
|
|
143
|
+
if (len & 0x80) {
|
|
144
|
+
const n = len & 0x7f;
|
|
145
|
+
len = 0;
|
|
146
|
+
for (let i = 0; i < n; i++)
|
|
147
|
+
len = len * 256 + buf[at + 2 + i];
|
|
148
|
+
header += n;
|
|
149
|
+
}
|
|
150
|
+
return { start: at, header, end: at + header + len };
|
|
151
|
+
};
|
|
152
|
+
const cert = read(der, 0);
|
|
153
|
+
const tbs = read(der, cert.start + cert.header);
|
|
154
|
+
// TBSCertificate fields: [0] version, serial, signature, issuer, validity, subject.
|
|
155
|
+
let at = tbs.start + tbs.header;
|
|
156
|
+
for (let field = 0; field < 5; field++)
|
|
157
|
+
at = read(der, at).end;
|
|
158
|
+
const subject = read(der, at);
|
|
159
|
+
return der.subarray(subject.start, subject.end);
|
|
160
|
+
}
|