@fusionkit/plane 0.1.0
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/auth.d.ts +18 -0
- package/dist/auth.js +46 -0
- package/dist/claim-token-service.d.ts +23 -0
- package/dist/claim-token-service.js +54 -0
- package/dist/contract-service.d.ts +14 -0
- package/dist/contract-service.js +39 -0
- package/dist/domain-errors.d.ts +13 -0
- package/dist/domain-errors.js +31 -0
- package/dist/idp.d.ts +26 -0
- package/dist/idp.js +24 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +21 -0
- package/dist/keys.d.ts +60 -0
- package/dist/keys.js +132 -0
- package/dist/logging.d.ts +21 -0
- package/dist/logging.js +42 -0
- package/dist/plane.d.ts +167 -0
- package/dist/plane.js +606 -0
- package/dist/policy.d.ts +23 -0
- package/dist/policy.js +92 -0
- package/dist/ratelimit.d.ts +40 -0
- package/dist/ratelimit.js +94 -0
- package/dist/receipt-service.d.ts +16 -0
- package/dist/receipt-service.js +17 -0
- package/dist/retention.d.ts +33 -0
- package/dist/retention.js +123 -0
- package/dist/run-lifecycle.d.ts +2 -0
- package/dist/run-lifecycle.js +19 -0
- package/dist/secrets.d.ts +25 -0
- package/dist/secrets.js +73 -0
- package/dist/server.d.ts +38 -0
- package/dist/server.js +418 -0
- package/dist/sqlite-store.d.ts +53 -0
- package/dist/sqlite-store.js +401 -0
- package/dist/store.d.ts +107 -0
- package/dist/store.js +9 -0
- package/dist/test/api.test.d.ts +1 -0
- package/dist/test/api.test.js +179 -0
- package/dist/test/hardening.test.d.ts +1 -0
- package/dist/test/hardening.test.js +259 -0
- package/dist/test/policy.test.d.ts +1 -0
- package/dist/test/policy.test.js +78 -0
- package/dist/test/server-hardening.test.d.ts +1 -0
- package/dist/test/server-hardening.test.js +192 -0
- package/dist/test/ui-parity.test.d.ts +1 -0
- package/dist/test/ui-parity.test.js +28 -0
- package/dist/validation.d.ts +326 -0
- package/dist/validation.js +178 -0
- package/package.json +34 -0
- package/ui/app.css +276 -0
- package/ui/app.js +483 -0
- package/ui/index.html +65 -0
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PrincipalRecord, PrincipalRole } from "./store.js";
|
|
2
|
+
/** The authenticated caller behind a request. */
|
|
3
|
+
export type Principal = {
|
|
4
|
+
principalId: string;
|
|
5
|
+
name: string;
|
|
6
|
+
role: PrincipalRole;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Tokens are stored only as their sha256. Every token the plane issues is
|
|
10
|
+
* 256 bits of CSPRNG output, so a plain SHA-256 lookup key is safe: there
|
|
11
|
+
* is nothing to brute-force and no need for a slow password hash. (Short,
|
|
12
|
+
* human-chosen tokens are never issued; if they were, this would need a
|
|
13
|
+
* peppered KDF instead.)
|
|
14
|
+
*/
|
|
15
|
+
export declare function hashToken(token: string): string;
|
|
16
|
+
export declare function toPrincipal(record: PrincipalRecord): Principal;
|
|
17
|
+
export type Capability = "runs:read" | "runs:create" | "runs:approve" | "runs:cancel" | "runners:read" | "policy:read" | "blobs:write" | "export:read" | "principals:manage";
|
|
18
|
+
export declare function principalCan(role: PrincipalRole, capability: Capability): boolean;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { sha256Hex } from "@fusionkit/protocol";
|
|
2
|
+
/**
|
|
3
|
+
* Tokens are stored only as their sha256. Every token the plane issues is
|
|
4
|
+
* 256 bits of CSPRNG output, so a plain SHA-256 lookup key is safe: there
|
|
5
|
+
* is nothing to brute-force and no need for a slow password hash. (Short,
|
|
6
|
+
* human-chosen tokens are never issued; if they were, this would need a
|
|
7
|
+
* peppered KDF instead.)
|
|
8
|
+
*/
|
|
9
|
+
export function hashToken(token) {
|
|
10
|
+
return sha256Hex(token);
|
|
11
|
+
}
|
|
12
|
+
export function toPrincipal(record) {
|
|
13
|
+
return {
|
|
14
|
+
principalId: record.principalId,
|
|
15
|
+
name: record.name,
|
|
16
|
+
role: record.role
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Role capability model, defined in code as the plane's authorization
|
|
21
|
+
* policy. `admin` can do anything; the others are scoped to the actions the
|
|
22
|
+
* spec assigns them. Runner enrollment is gated separately (enroller role
|
|
23
|
+
* or a single-use enroll token), and runner-claim/event/completion
|
|
24
|
+
* endpoints authenticate with runner tokens + claim tokens, not principals.
|
|
25
|
+
* Per-deployment custom roles are intentionally out of scope; this matrix
|
|
26
|
+
* is the contract.
|
|
27
|
+
*/
|
|
28
|
+
const CAPABILITIES = {
|
|
29
|
+
admin: new Set([
|
|
30
|
+
"runs:read",
|
|
31
|
+
"runs:create",
|
|
32
|
+
"runs:approve",
|
|
33
|
+
"runs:cancel",
|
|
34
|
+
"runners:read",
|
|
35
|
+
"policy:read",
|
|
36
|
+
"blobs:write",
|
|
37
|
+
"export:read",
|
|
38
|
+
"principals:manage"
|
|
39
|
+
]),
|
|
40
|
+
requester: new Set(["runs:read", "runs:create", "runs:cancel", "blobs:write", "policy:read"]),
|
|
41
|
+
approver: new Set(["runs:read", "runs:approve", "policy:read"]),
|
|
42
|
+
enroller: new Set(["runners:read"])
|
|
43
|
+
};
|
|
44
|
+
export function principalCan(role, capability) {
|
|
45
|
+
return CAPABILITIES[role].has(capability);
|
|
46
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type ClaimTokenPayload = {
|
|
2
|
+
runId: string;
|
|
3
|
+
runnerId: string;
|
|
4
|
+
nonce: string;
|
|
5
|
+
exp: string;
|
|
6
|
+
};
|
|
7
|
+
export type VerifiedClaimToken = ClaimTokenPayload & {
|
|
8
|
+
expMs: number;
|
|
9
|
+
};
|
|
10
|
+
export type ClaimTokenServiceOptions = {
|
|
11
|
+
planePrivateKeyPem: string;
|
|
12
|
+
planePublicKeyPem: string;
|
|
13
|
+
claimTokenTtlMs: number;
|
|
14
|
+
};
|
|
15
|
+
export declare class ClaimTokenService {
|
|
16
|
+
private readonly options;
|
|
17
|
+
constructor(options: ClaimTokenServiceOptions);
|
|
18
|
+
issue(input: {
|
|
19
|
+
runId: string;
|
|
20
|
+
runnerId: string;
|
|
21
|
+
}): string;
|
|
22
|
+
parse(token: string): VerifiedClaimToken;
|
|
23
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { signData, verifyData } from "@fusionkit/protocol";
|
|
3
|
+
import { badRequest, unauthorized } from "./domain-errors.js";
|
|
4
|
+
export class ClaimTokenService {
|
|
5
|
+
options;
|
|
6
|
+
constructor(options) {
|
|
7
|
+
this.options = options;
|
|
8
|
+
}
|
|
9
|
+
issue(input) {
|
|
10
|
+
const payload = {
|
|
11
|
+
runId: input.runId,
|
|
12
|
+
runnerId: input.runnerId,
|
|
13
|
+
nonce: randomBytes(16).toString("base64url"),
|
|
14
|
+
exp: new Date(Date.now() + this.options.claimTokenTtlMs).toISOString()
|
|
15
|
+
};
|
|
16
|
+
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
17
|
+
const sig = signData(this.options.planePrivateKeyPem, encoded);
|
|
18
|
+
return `${encoded}.${Buffer.from(sig, "base64").toString("base64url")}`;
|
|
19
|
+
}
|
|
20
|
+
parse(token) {
|
|
21
|
+
const [encoded, sigB64url] = token.split(".");
|
|
22
|
+
if (!encoded || !sigB64url)
|
|
23
|
+
throw badRequest("malformed claim token");
|
|
24
|
+
const sig = Buffer.from(sigB64url, "base64url").toString("base64");
|
|
25
|
+
if (!verifyData(this.options.planePublicKeyPem, encoded, sig)) {
|
|
26
|
+
throw unauthorized("claim token signature invalid");
|
|
27
|
+
}
|
|
28
|
+
let payload;
|
|
29
|
+
try {
|
|
30
|
+
payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw badRequest("claim token payload is not valid JSON");
|
|
34
|
+
}
|
|
35
|
+
if (typeof payload.runId !== "string" ||
|
|
36
|
+
typeof payload.runnerId !== "string" ||
|
|
37
|
+
typeof payload.nonce !== "string" ||
|
|
38
|
+
typeof payload.exp !== "string") {
|
|
39
|
+
throw badRequest("claim token payload is missing required fields");
|
|
40
|
+
}
|
|
41
|
+
const expMs = new Date(payload.exp).getTime();
|
|
42
|
+
if (!Number.isFinite(expMs))
|
|
43
|
+
throw badRequest("claim token expiry is invalid");
|
|
44
|
+
if (expMs < Date.now())
|
|
45
|
+
throw unauthorized("claim token expired");
|
|
46
|
+
return {
|
|
47
|
+
runId: payload.runId,
|
|
48
|
+
runnerId: payload.runnerId,
|
|
49
|
+
nonce: payload.nonce,
|
|
50
|
+
exp: payload.exp,
|
|
51
|
+
expMs
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ActorRef, type RunContract } from "@fusionkit/protocol";
|
|
2
|
+
import type { RunRequest } from "./store.js";
|
|
3
|
+
export type ContractServiceOptions = {
|
|
4
|
+
planePrivateKeyPem: string;
|
|
5
|
+
planePublicKeyPem: string;
|
|
6
|
+
policyHash: string;
|
|
7
|
+
contractTtlMs: number;
|
|
8
|
+
buildSecretClaims: (secretNames: string[], pool: string) => RunContract["secrets"];
|
|
9
|
+
};
|
|
10
|
+
export declare class ContractService {
|
|
11
|
+
private readonly options;
|
|
12
|
+
constructor(options: ContractServiceOptions);
|
|
13
|
+
issue(request: RunRequest, approvedBy: ActorRef[]): RunContract;
|
|
14
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { executionFromRunRequest, keyIdFromPublicPem, PROTOCOL_VERSIONS, signContract } from "@fusionkit/protocol";
|
|
2
|
+
export class ContractService {
|
|
3
|
+
options;
|
|
4
|
+
constructor(options) {
|
|
5
|
+
this.options = options;
|
|
6
|
+
}
|
|
7
|
+
issue(request, approvedBy) {
|
|
8
|
+
const now = Date.now();
|
|
9
|
+
const unsigned = {
|
|
10
|
+
version: PROTOCOL_VERSIONS.contract,
|
|
11
|
+
runId: request.runId,
|
|
12
|
+
issuedAt: new Date(now).toISOString(),
|
|
13
|
+
issuer: {
|
|
14
|
+
keyId: keyIdFromPublicPem(this.options.planePublicKeyPem),
|
|
15
|
+
role: "plane"
|
|
16
|
+
},
|
|
17
|
+
requestedBy: request.requestedBy,
|
|
18
|
+
...(approvedBy.length > 0 ? { approvedBy } : {}),
|
|
19
|
+
agent: {
|
|
20
|
+
kind: request.agentKind,
|
|
21
|
+
...(request.agentVersion ? { version: request.agentVersion } : {})
|
|
22
|
+
},
|
|
23
|
+
task: { prompt: request.prompt },
|
|
24
|
+
runner: { pool: request.pool },
|
|
25
|
+
workspace: request.workspace,
|
|
26
|
+
policyHash: this.options.policyHash,
|
|
27
|
+
secrets: this.options.buildSecretClaims(request.secretNames, request.pool),
|
|
28
|
+
network: request.network,
|
|
29
|
+
budget: request.budget,
|
|
30
|
+
disclosure: request.disclosure,
|
|
31
|
+
execution: executionFromRunRequest(request),
|
|
32
|
+
...(request.isolation ? { isolation: request.isolation } : {}),
|
|
33
|
+
...(request.continuation ? { continuation: request.continuation } : {}),
|
|
34
|
+
expiresAt: new Date(now + this.options.contractTtlMs).toISOString(),
|
|
35
|
+
signatures: []
|
|
36
|
+
};
|
|
37
|
+
return signContract(unsigned, this.options.planePrivateKeyPem, this.options.planePublicKeyPem, "plane");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type PlaneErrorCode = "bad_request" | "unauthorized" | "forbidden" | "not_found" | "conflict" | "capability_mismatch";
|
|
2
|
+
export declare class PlaneDomainError extends Error {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
readonly code: PlaneErrorCode;
|
|
5
|
+
constructor(status: number, code: PlaneErrorCode, message: string);
|
|
6
|
+
}
|
|
7
|
+
export declare function badRequest(message: string): PlaneDomainError;
|
|
8
|
+
export declare function unauthorized(message: string): PlaneDomainError;
|
|
9
|
+
export declare function forbidden(message: string): PlaneDomainError;
|
|
10
|
+
export declare function notFound(message: string): PlaneDomainError;
|
|
11
|
+
export declare function conflict(message: string): PlaneDomainError;
|
|
12
|
+
export declare function capabilityMismatch(message: string): PlaneDomainError;
|
|
13
|
+
export declare function isPlaneDomainError(error: unknown): error is PlaneDomainError;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export class PlaneDomainError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
code;
|
|
4
|
+
constructor(status, code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.name = "PlaneDomainError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function badRequest(message) {
|
|
12
|
+
return new PlaneDomainError(400, "bad_request", message);
|
|
13
|
+
}
|
|
14
|
+
export function unauthorized(message) {
|
|
15
|
+
return new PlaneDomainError(401, "unauthorized", message);
|
|
16
|
+
}
|
|
17
|
+
export function forbidden(message) {
|
|
18
|
+
return new PlaneDomainError(403, "forbidden", message);
|
|
19
|
+
}
|
|
20
|
+
export function notFound(message) {
|
|
21
|
+
return new PlaneDomainError(404, "not_found", message);
|
|
22
|
+
}
|
|
23
|
+
export function conflict(message) {
|
|
24
|
+
return new PlaneDomainError(409, "conflict", message);
|
|
25
|
+
}
|
|
26
|
+
export function capabilityMismatch(message) {
|
|
27
|
+
return new PlaneDomainError(422, "capability_mismatch", message);
|
|
28
|
+
}
|
|
29
|
+
export function isPlaneDomainError(error) {
|
|
30
|
+
return error instanceof PlaneDomainError;
|
|
31
|
+
}
|
package/dist/idp.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifies IdP-issued approval assertions so a consent decision is bound to
|
|
3
|
+
* a real, externally-authenticated subject rather than to whoever holds an
|
|
4
|
+
* admin token. The plane is configured with the IdP issuer, audience, and a
|
|
5
|
+
* JWKS (resolved out-of-band by the operator and passed in); approvals
|
|
6
|
+
* present a JWT, which is verified against that JWKS.
|
|
7
|
+
*/
|
|
8
|
+
export type IdpConfig = {
|
|
9
|
+
issuer: string;
|
|
10
|
+
audience: string;
|
|
11
|
+
/** JWKS contents (the operator fetches and pins these). */
|
|
12
|
+
jwks: {
|
|
13
|
+
keys: Record<string, unknown>[];
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
export type VerifiedApproval = {
|
|
17
|
+
subject: string;
|
|
18
|
+
issuer: string;
|
|
19
|
+
};
|
|
20
|
+
export declare class IdpVerifier {
|
|
21
|
+
private readonly issuer;
|
|
22
|
+
private readonly audience;
|
|
23
|
+
private readonly getKey;
|
|
24
|
+
constructor(config: IdpConfig);
|
|
25
|
+
verify(token: string): Promise<VerifiedApproval>;
|
|
26
|
+
}
|
package/dist/idp.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createLocalJWKSet, jwtVerify } from "jose";
|
|
2
|
+
export class IdpVerifier {
|
|
3
|
+
issuer;
|
|
4
|
+
audience;
|
|
5
|
+
getKey;
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.issuer = config.issuer;
|
|
8
|
+
this.audience = config.audience;
|
|
9
|
+
// JWKS is pinned by the operator out-of-band (the plan's design): the
|
|
10
|
+
// plane verifies against exactly the keys it was configured with. Key
|
|
11
|
+
// rotation is an operator action (re-pin the JWKS), which keeps approval
|
|
12
|
+
// verification deterministic and offline-friendly.
|
|
13
|
+
this.getKey = createLocalJWKSet(config.jwks);
|
|
14
|
+
}
|
|
15
|
+
async verify(token) {
|
|
16
|
+
const { payload } = await jwtVerify(token, this.getKey, { issuer: this.issuer, audience: this.audience });
|
|
17
|
+
if (!payload.sub) {
|
|
18
|
+
throw new Error("IdP token has no subject (sub) claim");
|
|
19
|
+
}
|
|
20
|
+
// jwtVerify already rejects any token whose `iss` is not this issuer, so
|
|
21
|
+
// returning the configured issuer reflects the verified value.
|
|
22
|
+
return { subject: payload.sub, issuer: this.issuer };
|
|
23
|
+
}
|
|
24
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fusionkit/plane — control plane: contracts, policy evaluation, approvals,
|
|
3
|
+
* receipt countersignature, secret broker, audit export, durable SQLite
|
|
4
|
+
* storage, identity/auth, rate limiting, retention, and the control panel UI.
|
|
5
|
+
*/
|
|
6
|
+
export { Plane } from "./plane.js";
|
|
7
|
+
export type { PlaneConfig, IssuedPrincipal } from "./plane.js";
|
|
8
|
+
export { startPlaneServer } from "./server.js";
|
|
9
|
+
export type { PlaneServerOptions } from "./server.js";
|
|
10
|
+
export { defaultPolicy, evaluatePolicy } from "./policy.js";
|
|
11
|
+
export type { PolicyDecision, PolicyRequest } from "./policy.js";
|
|
12
|
+
export { badRequest, capabilityMismatch, conflict, forbidden, isPlaneDomainError, notFound, PlaneDomainError, unauthorized } from "./domain-errors.js";
|
|
13
|
+
export type { PlaneErrorCode } from "./domain-errors.js";
|
|
14
|
+
export { ClaimTokenService } from "./claim-token-service.js";
|
|
15
|
+
export type { ClaimTokenPayload, ClaimTokenServiceOptions, VerifiedClaimToken } from "./claim-token-service.js";
|
|
16
|
+
export { ContractService } from "./contract-service.js";
|
|
17
|
+
export type { ContractServiceOptions } from "./contract-service.js";
|
|
18
|
+
export { ReceiptService } from "./receipt-service.js";
|
|
19
|
+
export type { ReceiptServiceConfig } from "./receipt-service.js";
|
|
20
|
+
export { SqliteStore } from "./sqlite-store.js";
|
|
21
|
+
export type { EnrollTokenRecord, PlaneStore, PrincipalRecord, PrincipalRole, RunRecord, RunRequest, RunnerRecord } from "./store.js";
|
|
22
|
+
export { SecretStore } from "./secrets.js";
|
|
23
|
+
export { FileKeyProvider, generateMasterKeyHex, masterKeyFromMaterial, open, openFromFile, resolveMasterKey, seal, sealToFile } from "./keys.js";
|
|
24
|
+
export type { KeyProvider, MasterKey, OrgKeyPair, SealedBlob } from "./keys.js";
|
|
25
|
+
export { hashToken, principalCan, toPrincipal } from "./auth.js";
|
|
26
|
+
export type { Capability, Principal } from "./auth.js";
|
|
27
|
+
export { IdpVerifier } from "./idp.js";
|
|
28
|
+
export type { IdpConfig, VerifiedApproval } from "./idp.js";
|
|
29
|
+
export { DEFAULT_RATE_LIMIT, RateLimiter } from "./ratelimit.js";
|
|
30
|
+
export type { RateLimitConfig } from "./ratelimit.js";
|
|
31
|
+
export { createLogger, Metrics } from "./logging.js";
|
|
32
|
+
export type { Logger } from "./logging.js";
|
|
33
|
+
export { collectReferencedBlobs, RetentionSweeper } from "./retention.js";
|
|
34
|
+
export type { RetentionResult } from "./retention.js";
|
|
35
|
+
export { approveBodySchema, cancelBodySchema, claimBodySchema, completeBodySchema, createRunBodySchema, enrollBodySchema, eventsBodySchema, issuePrincipalBodySchema, parseBody, runRequestSchema, ValidationError } from "./validation.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fusionkit/plane — control plane: contracts, policy evaluation, approvals,
|
|
3
|
+
* receipt countersignature, secret broker, audit export, durable SQLite
|
|
4
|
+
* storage, identity/auth, rate limiting, retention, and the control panel UI.
|
|
5
|
+
*/
|
|
6
|
+
export { Plane } from "./plane.js";
|
|
7
|
+
export { startPlaneServer } from "./server.js";
|
|
8
|
+
export { defaultPolicy, evaluatePolicy } from "./policy.js";
|
|
9
|
+
export { badRequest, capabilityMismatch, conflict, forbidden, isPlaneDomainError, notFound, PlaneDomainError, unauthorized } from "./domain-errors.js";
|
|
10
|
+
export { ClaimTokenService } from "./claim-token-service.js";
|
|
11
|
+
export { ContractService } from "./contract-service.js";
|
|
12
|
+
export { ReceiptService } from "./receipt-service.js";
|
|
13
|
+
export { SqliteStore } from "./sqlite-store.js";
|
|
14
|
+
export { SecretStore } from "./secrets.js";
|
|
15
|
+
export { FileKeyProvider, generateMasterKeyHex, masterKeyFromMaterial, open, openFromFile, resolveMasterKey, seal, sealToFile } from "./keys.js";
|
|
16
|
+
export { hashToken, principalCan, toPrincipal } from "./auth.js";
|
|
17
|
+
export { IdpVerifier } from "./idp.js";
|
|
18
|
+
export { DEFAULT_RATE_LIMIT, RateLimiter } from "./ratelimit.js";
|
|
19
|
+
export { createLogger, Metrics } from "./logging.js";
|
|
20
|
+
export { collectReferencedBlobs, RetentionSweeper } from "./retention.js";
|
|
21
|
+
export { approveBodySchema, cancelBodySchema, claimBodySchema, completeBodySchema, createRunBodySchema, enrollBodySchema, eventsBodySchema, issuePrincipalBodySchema, parseBody, runRequestSchema, ValidationError } from "./validation.js";
|
package/dist/keys.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A master key protects everything the plane stores at rest (the org
|
|
3
|
+
* signing key, the secret store). It is supplied out-of-band via the
|
|
4
|
+
* WARRANT_MASTER_KEY environment variable, or persisted to a 0600 key file
|
|
5
|
+
* generated at init. It is never written into config.json, so config alone
|
|
6
|
+
* is not enough to decrypt anything.
|
|
7
|
+
*/
|
|
8
|
+
export type MasterKey = {
|
|
9
|
+
readonly material: Buffer;
|
|
10
|
+
};
|
|
11
|
+
/** Default env var carrying the master key; override via resolveMasterKey. */
|
|
12
|
+
export declare const DEFAULT_MASTER_KEY_ENV = "WARRANT_MASTER_KEY";
|
|
13
|
+
export declare function generateMasterKeyHex(): string;
|
|
14
|
+
/** Build a MasterKey from raw material (hex/base64/utf8); for tests and CLI. */
|
|
15
|
+
export declare function masterKeyFromMaterial(raw: string): MasterKey;
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the master key: WARRANT_MASTER_KEY if set, otherwise the key file
|
|
18
|
+
* at `keyFilePath`. When `createIfMissing` is true and neither exists, a new
|
|
19
|
+
* key is generated and written to the key file (mode 0600).
|
|
20
|
+
*/
|
|
21
|
+
export declare function resolveMasterKey(keyFilePath: string, options?: {
|
|
22
|
+
createIfMissing?: boolean;
|
|
23
|
+
envVar?: string;
|
|
24
|
+
}): MasterKey;
|
|
25
|
+
export type SealedBlob = {
|
|
26
|
+
version: "warrant.sealed.v1";
|
|
27
|
+
salt: string;
|
|
28
|
+
iv: string;
|
|
29
|
+
tag: string;
|
|
30
|
+
data: string;
|
|
31
|
+
};
|
|
32
|
+
/** AES-256-GCM with a per-blob scrypt-derived key. */
|
|
33
|
+
export declare function seal(master: MasterKey, plaintext: Buffer): SealedBlob;
|
|
34
|
+
export declare function open(master: MasterKey, blob: SealedBlob): Buffer;
|
|
35
|
+
export declare function sealToFile(master: MasterKey, path: string, plaintext: Buffer): void;
|
|
36
|
+
export declare function openFromFile(master: MasterKey, path: string): Buffer;
|
|
37
|
+
export type OrgKeyPair = {
|
|
38
|
+
publicKeyPem: string;
|
|
39
|
+
privateKeyPem: string;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Source of the org signing key pair. The private key is held in memory by
|
|
43
|
+
* the plane but stored encrypted at rest by the provider. External KMS
|
|
44
|
+
* integrations implement this same interface.
|
|
45
|
+
*/
|
|
46
|
+
export interface KeyProvider {
|
|
47
|
+
getOrgKeyPair(): OrgKeyPair;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* File-backed key provider: public key in PEM, private key sealed with the
|
|
51
|
+
* master key. Generates a fresh key pair on first use when allowed.
|
|
52
|
+
*/
|
|
53
|
+
export declare class FileKeyProvider implements KeyProvider {
|
|
54
|
+
private readonly master;
|
|
55
|
+
private readonly publicKeyPath;
|
|
56
|
+
private readonly privateKeySealedPath;
|
|
57
|
+
constructor(master: MasterKey, publicKeyPath: string, privateKeySealedPath: string);
|
|
58
|
+
ensure(): OrgKeyPair;
|
|
59
|
+
getOrgKeyPair(): OrgKeyPair;
|
|
60
|
+
}
|
package/dist/keys.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { generateEd25519KeyPair } from "@fusionkit/protocol";
|
|
5
|
+
/** Default env var carrying the master key; override via resolveMasterKey. */
|
|
6
|
+
export const DEFAULT_MASTER_KEY_ENV = "WARRANT_MASTER_KEY";
|
|
7
|
+
/**
|
|
8
|
+
* Decode master-key material. Canonical form is 64 hex chars (32 bytes) —
|
|
9
|
+
* what generateMasterKeyHex produces and what we recommend. A non-hex value
|
|
10
|
+
* is treated as raw UTF-8 bytes (a passphrase). We deliberately do NOT
|
|
11
|
+
* second-guess with base64 decoding, which is what made the order
|
|
12
|
+
* ambiguous: hex or passphrase, nothing in between.
|
|
13
|
+
*/
|
|
14
|
+
function decodeMaterial(raw) {
|
|
15
|
+
const trimmed = raw.trim();
|
|
16
|
+
if (/^[0-9a-f]{64}$/i.test(trimmed))
|
|
17
|
+
return Buffer.from(trimmed, "hex");
|
|
18
|
+
return Buffer.from(trimmed, "utf8");
|
|
19
|
+
}
|
|
20
|
+
export function generateMasterKeyHex() {
|
|
21
|
+
return randomBytes(32).toString("hex");
|
|
22
|
+
}
|
|
23
|
+
/** Build a MasterKey from raw material (hex/base64/utf8); for tests and CLI. */
|
|
24
|
+
export function masterKeyFromMaterial(raw) {
|
|
25
|
+
return { material: decodeMaterial(raw) };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the master key: WARRANT_MASTER_KEY if set, otherwise the key file
|
|
29
|
+
* at `keyFilePath`. When `createIfMissing` is true and neither exists, a new
|
|
30
|
+
* key is generated and written to the key file (mode 0600).
|
|
31
|
+
*/
|
|
32
|
+
export function resolveMasterKey(keyFilePath, options = {}) {
|
|
33
|
+
const fromEnv = process.env[options.envVar ?? DEFAULT_MASTER_KEY_ENV];
|
|
34
|
+
if (fromEnv && fromEnv.length > 0) {
|
|
35
|
+
return { material: decodeMaterial(fromEnv) };
|
|
36
|
+
}
|
|
37
|
+
if (existsSync(keyFilePath)) {
|
|
38
|
+
return { material: decodeMaterial(readFileSync(keyFilePath, "utf8")) };
|
|
39
|
+
}
|
|
40
|
+
if (options.createIfMissing) {
|
|
41
|
+
mkdirSync(dirname(keyFilePath), { recursive: true });
|
|
42
|
+
const hex = generateMasterKeyHex();
|
|
43
|
+
writeFileSync(keyFilePath, hex, { mode: KEY_FILE_MODE });
|
|
44
|
+
return { material: Buffer.from(hex, "hex") };
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`no master key: set ${options.envVar ?? DEFAULT_MASTER_KEY_ENV} or provide a key file at ${keyFilePath}`);
|
|
47
|
+
}
|
|
48
|
+
// AES-256-GCM sealing parameters. These bind to AES-256 (32-byte key,
|
|
49
|
+
// 12-byte GCM IV) and a 16-byte scrypt salt. scrypt uses Node's defaults
|
|
50
|
+
// (N=16384, r=8, p=1), which are appropriate for sealing small, infrequent
|
|
51
|
+
// payloads (the org key and the secret file). scryptSync is acceptable here
|
|
52
|
+
// precisely because these payloads are small and sealed/opened rarely.
|
|
53
|
+
const SALT_BYTES = 16;
|
|
54
|
+
const IV_BYTES = 12;
|
|
55
|
+
const KEY_BYTES = 32;
|
|
56
|
+
const KEY_FILE_MODE = 0o600;
|
|
57
|
+
/** AES-256-GCM with a per-blob scrypt-derived key. */
|
|
58
|
+
export function seal(master, plaintext) {
|
|
59
|
+
const salt = randomBytes(SALT_BYTES);
|
|
60
|
+
const key = scryptSync(master.material, salt, KEY_BYTES);
|
|
61
|
+
const iv = randomBytes(IV_BYTES);
|
|
62
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
63
|
+
const data = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
64
|
+
return {
|
|
65
|
+
version: "warrant.sealed.v1",
|
|
66
|
+
salt: salt.toString("base64"),
|
|
67
|
+
iv: iv.toString("base64"),
|
|
68
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
69
|
+
data: data.toString("base64")
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function assertSealedBlob(value) {
|
|
73
|
+
const blob = value;
|
|
74
|
+
if (!blob ||
|
|
75
|
+
blob.version !== "warrant.sealed.v1" ||
|
|
76
|
+
typeof blob.salt !== "string" ||
|
|
77
|
+
typeof blob.iv !== "string" ||
|
|
78
|
+
typeof blob.tag !== "string" ||
|
|
79
|
+
typeof blob.data !== "string") {
|
|
80
|
+
throw new Error("sealed blob is malformed or not a warrant.sealed.v1 envelope");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function open(master, blob) {
|
|
84
|
+
assertSealedBlob(blob);
|
|
85
|
+
const key = scryptSync(master.material, Buffer.from(blob.salt, "base64"), KEY_BYTES);
|
|
86
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(blob.iv, "base64"));
|
|
87
|
+
decipher.setAuthTag(Buffer.from(blob.tag, "base64"));
|
|
88
|
+
return Buffer.concat([
|
|
89
|
+
decipher.update(Buffer.from(blob.data, "base64")),
|
|
90
|
+
decipher.final()
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
export function sealToFile(master, path, plaintext) {
|
|
94
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
95
|
+
writeFileSync(path, JSON.stringify(seal(master, plaintext)), { mode: 0o600 });
|
|
96
|
+
}
|
|
97
|
+
export function openFromFile(master, path) {
|
|
98
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
99
|
+
assertSealedBlob(parsed);
|
|
100
|
+
return open(master, parsed);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* File-backed key provider: public key in PEM, private key sealed with the
|
|
104
|
+
* master key. Generates a fresh key pair on first use when allowed.
|
|
105
|
+
*/
|
|
106
|
+
export class FileKeyProvider {
|
|
107
|
+
master;
|
|
108
|
+
publicKeyPath;
|
|
109
|
+
privateKeySealedPath;
|
|
110
|
+
constructor(master, publicKeyPath, privateKeySealedPath) {
|
|
111
|
+
this.master = master;
|
|
112
|
+
this.publicKeyPath = publicKeyPath;
|
|
113
|
+
this.privateKeySealedPath = privateKeySealedPath;
|
|
114
|
+
}
|
|
115
|
+
ensure() {
|
|
116
|
+
if (existsSync(this.publicKeyPath) &&
|
|
117
|
+
existsSync(this.privateKeySealedPath)) {
|
|
118
|
+
return this.getOrgKeyPair();
|
|
119
|
+
}
|
|
120
|
+
const pair = generateEd25519KeyPair();
|
|
121
|
+
mkdirSync(dirname(this.publicKeyPath), { recursive: true });
|
|
122
|
+
writeFileSync(this.publicKeyPath, pair.publicKeyPem, { mode: 0o600 });
|
|
123
|
+
sealToFile(this.master, this.privateKeySealedPath, Buffer.from(pair.privateKeyPem, "utf8"));
|
|
124
|
+
return pair;
|
|
125
|
+
}
|
|
126
|
+
getOrgKeyPair() {
|
|
127
|
+
return {
|
|
128
|
+
publicKeyPem: readFileSync(this.publicKeyPath, "utf8"),
|
|
129
|
+
privateKeyPem: openFromFile(this.master, this.privateKeySealedPath).toString("utf8")
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Logger } from "pino";
|
|
2
|
+
/**
|
|
3
|
+
* Structured plane logger. Level via the `level` argument or LOG_LEVEL,
|
|
4
|
+
* defaulting to "silent" so the library never pollutes a host's stdout
|
|
5
|
+
* unless logging is explicitly requested (operators set LOG_LEVEL=info, or
|
|
6
|
+
* inject a configured logger via PlaneConfig.logger).
|
|
7
|
+
*/
|
|
8
|
+
export declare function createLogger(name?: string, level?: string): Logger;
|
|
9
|
+
/** Counters the plane increments for operational visibility. */
|
|
10
|
+
/**
|
|
11
|
+
* Lightweight in-process counters exposed at /v1/metrics as JSON. This is a
|
|
12
|
+
* deliberately minimal surface (counts, not histograms/labels): a deployment
|
|
13
|
+
* that needs a Prometheus scrape format wraps these counters in its own
|
|
14
|
+
* exporter rather than the plane taking on that dependency.
|
|
15
|
+
*/
|
|
16
|
+
export declare class Metrics {
|
|
17
|
+
private readonly counters;
|
|
18
|
+
inc(name: string, by?: number): void;
|
|
19
|
+
snapshot(): Record<string, number>;
|
|
20
|
+
}
|
|
21
|
+
export type { Logger };
|
package/dist/logging.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { pino } from "pino";
|
|
2
|
+
/**
|
|
3
|
+
* Structured plane logger. Level via the `level` argument or LOG_LEVEL,
|
|
4
|
+
* defaulting to "silent" so the library never pollutes a host's stdout
|
|
5
|
+
* unless logging is explicitly requested (operators set LOG_LEVEL=info, or
|
|
6
|
+
* inject a configured logger via PlaneConfig.logger).
|
|
7
|
+
*/
|
|
8
|
+
export function createLogger(name = "warrant-plane", level = process.env.LOG_LEVEL ?? "silent") {
|
|
9
|
+
return pino({
|
|
10
|
+
name,
|
|
11
|
+
level,
|
|
12
|
+
// The plane handles secrets and tokens; redact common carriers defensively.
|
|
13
|
+
redact: {
|
|
14
|
+
paths: [
|
|
15
|
+
"token",
|
|
16
|
+
"claimToken",
|
|
17
|
+
"runnerToken",
|
|
18
|
+
"enrollToken",
|
|
19
|
+
"idpToken",
|
|
20
|
+
"*.token",
|
|
21
|
+
"req.headers.authorization"
|
|
22
|
+
],
|
|
23
|
+
censor: "[redacted]"
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** Counters the plane increments for operational visibility. */
|
|
28
|
+
/**
|
|
29
|
+
* Lightweight in-process counters exposed at /v1/metrics as JSON. This is a
|
|
30
|
+
* deliberately minimal surface (counts, not histograms/labels): a deployment
|
|
31
|
+
* that needs a Prometheus scrape format wraps these counters in its own
|
|
32
|
+
* exporter rather than the plane taking on that dependency.
|
|
33
|
+
*/
|
|
34
|
+
export class Metrics {
|
|
35
|
+
counters = new Map();
|
|
36
|
+
inc(name, by = 1) {
|
|
37
|
+
this.counters.set(name, (this.counters.get(name) ?? 0) + by);
|
|
38
|
+
}
|
|
39
|
+
snapshot() {
|
|
40
|
+
return Object.fromEntries([...this.counters.entries()].sort());
|
|
41
|
+
}
|
|
42
|
+
}
|