@nimbus-dev/sdk 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +34 -0
  3. package/dist/audit-logger.d.ts +6 -0
  4. package/dist/audit-logger.d.ts.map +1 -0
  5. package/dist/audit-logger.js +18 -0
  6. package/dist/audit-logger.js.map +1 -0
  7. package/dist/contract-tests.d.ts +45 -0
  8. package/dist/contract-tests.d.ts.map +1 -0
  9. package/dist/contract-tests.js +191 -0
  10. package/dist/contract-tests.js.map +1 -0
  11. package/dist/crypto/app-store-connect-jwt.d.ts +19 -0
  12. package/dist/crypto/app-store-connect-jwt.d.ts.map +1 -0
  13. package/dist/crypto/app-store-connect-jwt.js +30 -0
  14. package/dist/crypto/app-store-connect-jwt.js.map +1 -0
  15. package/dist/crypto/canonical-json.d.ts +36 -0
  16. package/dist/crypto/canonical-json.d.ts.map +1 -0
  17. package/dist/crypto/canonical-json.js +75 -0
  18. package/dist/crypto/canonical-json.js.map +1 -0
  19. package/dist/crypto/jwt.d.ts +30 -0
  20. package/dist/crypto/jwt.d.ts.map +1 -0
  21. package/dist/crypto/jwt.js +30 -0
  22. package/dist/crypto/jwt.js.map +1 -0
  23. package/dist/crypto/service-account-token.d.ts +36 -0
  24. package/dist/crypto/service-account-token.d.ts.map +1 -0
  25. package/dist/crypto/service-account-token.js +96 -0
  26. package/dist/crypto/service-account-token.js.map +1 -0
  27. package/dist/crypto/verify-signature.d.ts +57 -0
  28. package/dist/crypto/verify-signature.d.ts.map +1 -0
  29. package/dist/crypto/verify-signature.js +102 -0
  30. package/dist/crypto/verify-signature.js.map +1 -0
  31. package/dist/distribution-channel.d.ts +34 -0
  32. package/dist/distribution-channel.d.ts.map +1 -0
  33. package/dist/distribution-channel.js +73 -0
  34. package/dist/distribution-channel.js.map +1 -0
  35. package/dist/hitl-request.d.ts +7 -0
  36. package/dist/hitl-request.d.ts.map +1 -0
  37. package/dist/hitl-request.js +15 -0
  38. package/dist/hitl-request.js.map +1 -0
  39. package/dist/index.d.ts +23 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +19 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/ipc/index.d.ts +2 -0
  44. package/dist/ipc/index.d.ts.map +1 -0
  45. package/dist/ipc/index.js +2 -0
  46. package/dist/ipc/index.js.map +1 -0
  47. package/dist/ipc/ndjson-line-reader.d.ts +20 -0
  48. package/dist/ipc/ndjson-line-reader.d.ts.map +1 -0
  49. package/dist/ipc/ndjson-line-reader.js +56 -0
  50. package/dist/ipc/ndjson-line-reader.js.map +1 -0
  51. package/dist/server.d.ts +29 -0
  52. package/dist/server.d.ts.map +1 -0
  53. package/dist/server.js +23 -0
  54. package/dist/server.js.map +1 -0
  55. package/dist/testing/index.d.ts +15 -0
  56. package/dist/testing/index.d.ts.map +1 -0
  57. package/dist/testing/index.js +17 -0
  58. package/dist/testing/index.js.map +1 -0
  59. package/dist/testing/sandbox-contract.d.ts +83 -0
  60. package/dist/testing/sandbox-contract.d.ts.map +1 -0
  61. package/dist/testing/sandbox-contract.js +105 -0
  62. package/dist/testing/sandbox-contract.js.map +1 -0
  63. package/dist/testing/sandbox-probe.d.ts +23 -0
  64. package/dist/testing/sandbox-probe.d.ts.map +1 -0
  65. package/dist/testing/sandbox-probe.js +78 -0
  66. package/dist/testing/sandbox-probe.js.map +1 -0
  67. package/dist/types.d.ts +41 -0
  68. package/dist/types.d.ts.map +1 -0
  69. package/dist/types.js +5 -0
  70. package/dist/types.js.map +1 -0
  71. package/package.json +55 -0
  72. package/src/audit-logger.test.ts +33 -0
  73. package/src/audit-logger.ts +23 -0
  74. package/src/contract-tests.test.ts +203 -0
  75. package/src/contract-tests.ts +220 -0
  76. package/src/crypto/app-store-connect-jwt.test.ts +80 -0
  77. package/src/crypto/app-store-connect-jwt.ts +42 -0
  78. package/src/crypto/canonical-json.test.ts +121 -0
  79. package/src/crypto/canonical-json.ts +73 -0
  80. package/src/crypto/jwt.test.ts +62 -0
  81. package/src/crypto/jwt.ts +45 -0
  82. package/src/crypto/service-account-token.test.ts +128 -0
  83. package/src/crypto/service-account-token.ts +116 -0
  84. package/src/crypto/verify-signature.test.ts +118 -0
  85. package/src/crypto/verify-signature.ts +138 -0
  86. package/src/distribution-channel.test.ts +107 -0
  87. package/src/distribution-channel.ts +105 -0
  88. package/src/hitl-request.ts +22 -0
  89. package/src/index.ts +59 -0
  90. package/src/ipc/index.ts +5 -0
  91. package/src/ipc/ndjson-line-reader.test.ts +64 -0
  92. package/src/ipc/ndjson-line-reader.ts +70 -0
  93. package/src/plugin-api-v1.test.ts +50 -0
  94. package/src/sdk.test.ts +23 -0
  95. package/src/server.test.ts +96 -0
  96. package/src/server.ts +39 -0
  97. package/src/testing/index.ts +18 -0
  98. package/src/testing/sandbox-contract.test.ts +146 -0
  99. package/src/testing/sandbox-contract.ts +155 -0
  100. package/src/testing/sandbox-probe.ts +87 -0
  101. package/src/types.ts +42 -0
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Deterministic JSON canonicalization for extension manifests.
3
+ *
4
+ * Used as the input to Ed25519 manifest signing (T2 PR 2 / I16). The signed
5
+ * bytes are the manifest with the `signature` field stripped, re-serialized
6
+ * via the rules below. Signing and verifying both call into this module so
7
+ * the byte sequences match exactly.
8
+ *
9
+ * Rules:
10
+ * - Object keys sorted in lexicographic UTF-16 code-unit order (JS `Array.sort` default).
11
+ * - String VALUES Unicode-normalized to NFC so semantically-equal strings
12
+ * yield identical bytes regardless of how the source editor encoded them.
13
+ * Object KEYS are NOT normalized — the publisher signs them byte-for-byte
14
+ * as serialized.
15
+ * - Integer numbers only (manifests have no floating-point fields).
16
+ * - No whitespace; UTF-8 encoded.
17
+ * - Recursion capped at MAX_DEPTH (32) — real manifests have depth ≤ 4; the
18
+ * cap defends against a maliciously crafted manifest blowing the stack.
19
+ *
20
+ * Preconditions: callers pass values produced by `JSON.parse`. That domain
21
+ * guarantees no cycles, no undefined / function / symbol values, no NaN /
22
+ * Infinity. The thrown error classes below are defensive for callers that
23
+ * violate the precondition (e.g. constructing the input in-memory).
24
+ */
25
+
26
+ export class NonIntegerNumberInManifest extends Error {
27
+ override readonly name = "NonIntegerNumberInManifest";
28
+ }
29
+ export class UnsupportedManifestValueType extends Error {
30
+ override readonly name = "UnsupportedManifestValueType";
31
+ }
32
+ export class ManifestNestedTooDeep extends Error {
33
+ override readonly name = "ManifestNestedTooDeep";
34
+ }
35
+
36
+ const MAX_DEPTH = 32;
37
+
38
+ export function canonicalize(value: unknown, depth = 0): string {
39
+ if (depth > MAX_DEPTH) throw new ManifestNestedTooDeep();
40
+ if (value === null) return "null";
41
+ if (value === true) return "true";
42
+ if (value === false) return "false";
43
+ if (typeof value === "string") {
44
+ return JSON.stringify(value.normalize("NFC"));
45
+ }
46
+ if (typeof value === "number") {
47
+ if (!Number.isInteger(value)) throw new NonIntegerNumberInManifest();
48
+ return String(value);
49
+ }
50
+ if (Array.isArray(value)) {
51
+ return `[${value.map((v) => canonicalize(v, depth + 1)).join(",")}]`;
52
+ }
53
+ if (typeof value === "object") {
54
+ const obj = value as Record<string, unknown>;
55
+ const keys = Object.keys(obj).sort((a, b) => {
56
+ if (a < b) return -1;
57
+ if (a > b) return 1;
58
+ return 0;
59
+ });
60
+ return (
61
+ "{" +
62
+ keys.map((k) => `${JSON.stringify(k)}:${canonicalize(obj[k], depth + 1)}`).join(",") +
63
+ "}"
64
+ );
65
+ }
66
+ throw new UnsupportedManifestValueType();
67
+ }
68
+
69
+ export function canonicalizeManifest(manifest: object): Uint8Array {
70
+ const clone: Record<string, unknown> = { ...(manifest as Record<string, unknown>) };
71
+ delete clone["signature"];
72
+ return new TextEncoder().encode(canonicalize(clone));
73
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import crypto from "node:crypto";
3
+
4
+ import { base64UrlJson, signJwt } from "./jwt";
5
+
6
+ function decode(segment: string): Record<string, unknown> {
7
+ return JSON.parse(Buffer.from(segment, "base64url").toString("utf8")) as Record<string, unknown>;
8
+ }
9
+
10
+ describe("base64UrlJson", () => {
11
+ test("round-trips a JSON value through base64url", () => {
12
+ const enc = base64UrlJson({ a: 1, b: "x" });
13
+ expect(decode(enc)).toEqual({ a: 1, b: "x" });
14
+ // base64url uses no +,/,= characters
15
+ expect(enc).not.toMatch(/[+/=]/);
16
+ });
17
+ });
18
+
19
+ describe("signJwt", () => {
20
+ test("RS256 (no dsaEncoding) produces a DER signature verifiable under the RSA key", () => {
21
+ const { privateKey, publicKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
22
+ const pem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
23
+ const token = signJwt({
24
+ header: { alg: "RS256", typ: "JWT" },
25
+ payload: { iss: "a" },
26
+ privateKeyPem: pem,
27
+ });
28
+ const [h, p, sig] = token.split(".");
29
+ expect(decode(h as string)).toEqual({ alg: "RS256", typ: "JWT" });
30
+ expect(decode(p as string)).toEqual({ iss: "a" });
31
+ const ok = crypto.verify(
32
+ "sha256",
33
+ Buffer.from(`${h}.${p}`, "utf8"),
34
+ crypto.createPublicKey(publicKey.export({ type: "spki", format: "pem" }).toString()),
35
+ Buffer.from(sig as string, "base64url"), // NOSONAR S4325: sig is string|undefined from the JWT split under noUncheckedIndexedAccess
36
+ );
37
+ expect(ok).toBe(true);
38
+ });
39
+
40
+ test("ES256 (ieee-p1363) produces a raw r||s signature verifiable under the EC key", () => {
41
+ const { privateKey, publicKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
42
+ const pem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
43
+ const token = signJwt({
44
+ header: { alg: "ES256", kid: "K", typ: "JWT" },
45
+ payload: { iss: "b" },
46
+ privateKeyPem: pem,
47
+ dsaEncoding: "ieee-p1363",
48
+ });
49
+ const [h, p, sig] = token.split(".");
50
+ expect(token.split(".")).toHaveLength(3);
51
+ const ok = crypto.verify(
52
+ "sha256",
53
+ Buffer.from(`${h}.${p}`, "utf8"),
54
+ {
55
+ key: crypto.createPublicKey(publicKey.export({ type: "spki", format: "pem" }).toString()),
56
+ dsaEncoding: "ieee-p1363",
57
+ },
58
+ Buffer.from(sig as string, "base64url"), // NOSONAR S4325: sig is string|undefined from the JWT split under noUncheckedIndexedAccess
59
+ );
60
+ expect(ok).toBe(true);
61
+ });
62
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Compact JWS (JWT) signing primitive shared by connector auth flows.
3
+ *
4
+ * A first-party connector that authenticates with a signed JWT must sign in
5
+ * TWO places that cannot import each other across the package boundary — the
6
+ * gateway-side sync handler and the connector's own MCP server. Hosting the
7
+ * signer here (the SDK, which both may import) keeps a single source of truth.
8
+ *
9
+ * Uses `node:crypto` only — no runtime dependency, so the SDK stays dep-free.
10
+ */
11
+
12
+ import crypto from "node:crypto";
13
+
14
+ /** base64url-encode the JSON serialization of `value` (a JWT header/payload). */
15
+ export function base64UrlJson(value: unknown): string {
16
+ return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
17
+ }
18
+
19
+ export interface SignJwtOptions {
20
+ readonly header: Record<string, unknown>;
21
+ readonly payload: Record<string, unknown>;
22
+ /** Full PEM text of the signing private key (`-----BEGIN PRIVATE KEY----- …`). */
23
+ readonly privateKeyPem: string;
24
+ /**
25
+ * ECDSA (e.g. ES256) requires `"ieee-p1363"` — the raw `r||s` encoding JWS
26
+ * mandates. Omit for RSA (e.g. RS256), which uses PKCS#1 v1.5 / DER.
27
+ */
28
+ readonly dsaEncoding?: "ieee-p1363" | "der";
29
+ }
30
+
31
+ /**
32
+ * Sign a compact JWS — `base64url(header).base64url(payload).base64url(sig)` —
33
+ * over SHA-256. The digest name is passed explicitly ("sha256") rather than
34
+ * `null` so the signer works on Bun's BoringSSL, which has no default digest.
35
+ */
36
+ export function signJwt(opts: SignJwtOptions): string {
37
+ const signingInput = `${base64UrlJson(opts.header)}.${base64UrlJson(opts.payload)}`;
38
+ const data = Buffer.from(signingInput, "utf8");
39
+ const key = crypto.createPrivateKey(opts.privateKeyPem);
40
+ const signature =
41
+ opts.dsaEncoding === undefined
42
+ ? crypto.sign("sha256", data, key)
43
+ : crypto.sign("sha256", data, { key, dsaEncoding: opts.dsaEncoding });
44
+ return `${signingInput}.${signature.toString("base64url")}`;
45
+ }
@@ -0,0 +1,128 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import crypto from "node:crypto";
3
+
4
+ import {
5
+ type FetchLike,
6
+ mintGoogleAccessToken,
7
+ parseServiceAccountJson,
8
+ signServiceAccountAssertion,
9
+ } from "./service-account-token";
10
+
11
+ function generateRsa(): { privateKey: string; publicKey: string } {
12
+ const { privateKey, publicKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
13
+ return {
14
+ privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
15
+ publicKey: publicKey.export({ type: "spki", format: "pem" }).toString(),
16
+ };
17
+ }
18
+
19
+ function decode(segment: string): Record<string, unknown> {
20
+ return JSON.parse(Buffer.from(segment, "base64url").toString("utf8")) as Record<string, unknown>;
21
+ }
22
+
23
+ const NOW_MS = 1_700_000_000_000;
24
+
25
+ describe("parseServiceAccountJson", () => {
26
+ test("parses the fields and defaults the token uri", () => {
27
+ const sa = parseServiceAccountJson(
28
+ JSON.stringify({ client_email: "a@b.com", private_key: "k" }),
29
+ );
30
+ expect(sa).not.toBeNull();
31
+ expect(sa?.clientEmail).toBe("a@b.com");
32
+ expect(sa?.privateKey).toBe("k");
33
+ expect(sa?.tokenUri).toBe("https://oauth2.googleapis.com/token");
34
+ });
35
+
36
+ test("honours an explicit token_uri", () => {
37
+ const sa = parseServiceAccountJson(
38
+ JSON.stringify({ client_email: "a@b.com", private_key: "k", token_uri: "https://custom/t" }),
39
+ );
40
+ expect(sa?.tokenUri).toBe("https://custom/t");
41
+ });
42
+
43
+ test("returns null on malformed JSON, non-object, or missing fields", () => {
44
+ expect(parseServiceAccountJson("{not json")).toBeNull();
45
+ expect(parseServiceAccountJson("42")).toBeNull();
46
+ expect(parseServiceAccountJson("null")).toBeNull();
47
+ expect(parseServiceAccountJson(JSON.stringify({ private_key: "k" }))).toBeNull();
48
+ expect(parseServiceAccountJson(JSON.stringify({ client_email: "a@b.com" }))).toBeNull();
49
+ });
50
+ });
51
+
52
+ describe("signServiceAccountAssertion", () => {
53
+ const { privateKey, publicKey } = generateRsa();
54
+ const sa = {
55
+ clientEmail: "sa@example.iam.gserviceaccount.com",
56
+ privateKey,
57
+ tokenUri: "https://oauth2.googleapis.com/token",
58
+ };
59
+
60
+ test("RS256 header, iss/scope/aud claims, and a 1-hour exp", () => {
61
+ const [h, p] = signServiceAccountAssertion(sa, NOW_MS).split(".");
62
+ expect(decode(h as string)).toEqual({ alg: "RS256", typ: "JWT" });
63
+ const payload = decode(p as string);
64
+ const nowSec = Math.floor(NOW_MS / 1000);
65
+ expect(payload["iss"]).toBe(sa.clientEmail);
66
+ expect(payload["scope"]).toBe("https://www.googleapis.com/auth/cloud-platform");
67
+ expect(payload["aud"]).toBe(sa.tokenUri);
68
+ expect(payload["iat"]).toBe(nowSec);
69
+ expect(payload["exp"]).toBe(nowSec + 3600);
70
+ });
71
+
72
+ test("honours a custom scope", () => {
73
+ const payload = decode(
74
+ signServiceAccountAssertion(
75
+ sa,
76
+ NOW_MS,
77
+ "https://www.googleapis.com/auth/devstorage.read_only",
78
+ ).split(".")[1] as string,
79
+ );
80
+ expect(payload["scope"]).toBe("https://www.googleapis.com/auth/devstorage.read_only");
81
+ });
82
+
83
+ test("signature verifies under RS256", () => {
84
+ const [h, p, sig] = signServiceAccountAssertion(sa, NOW_MS).split(".");
85
+ const ok = crypto.verify(
86
+ "sha256",
87
+ Buffer.from(`${h}.${p}`, "utf8"),
88
+ crypto.createPublicKey(publicKey),
89
+ Buffer.from(sig as string, "base64url"), // NOSONAR S4325: sig is string|undefined from the JWT split under noUncheckedIndexedAccess
90
+ );
91
+ expect(ok).toBe(true);
92
+ });
93
+ });
94
+
95
+ describe("mintGoogleAccessToken", () => {
96
+ const { privateKey } = generateRsa();
97
+ const sa = {
98
+ clientEmail: "sa@example.iam.gserviceaccount.com",
99
+ privateKey,
100
+ tokenUri: "https://oauth2.googleapis.com/token",
101
+ };
102
+
103
+ test("exchanges the assertion for an access token", async () => {
104
+ let posted: { url: string; body: string } | null = null;
105
+ const fetchFn: FetchLike = async (input, init) => {
106
+ posted = { url: String(input), body: String(init?.body) };
107
+ return new Response(JSON.stringify({ access_token: "ya29.test", expires_in: 3599 }), {
108
+ status: 200,
109
+ });
110
+ };
111
+ const token = await mintGoogleAccessToken(sa, fetchFn, NOW_MS);
112
+ expect(token).toBe("ya29.test");
113
+ const sent = posted as unknown as { url: string; body: string };
114
+ expect(sent.url).toBe(sa.tokenUri);
115
+ expect(sent.body).toContain("grant_type=urn");
116
+ expect(sent.body).toContain("assertion=");
117
+ });
118
+
119
+ test("returns null on non-ok, non-JSON, or missing access_token", async () => {
120
+ const bad: FetchLike = async () => new Response("denied", { status: 400 });
121
+ expect(await mintGoogleAccessToken(sa, bad, NOW_MS)).toBeNull();
122
+ const notJson: FetchLike = async () => new Response("<html>", { status: 200 });
123
+ expect(await mintGoogleAccessToken(sa, notJson, NOW_MS)).toBeNull();
124
+ const noToken: FetchLike = async () =>
125
+ new Response(JSON.stringify({ token_type: "Bearer" }), { status: 200 });
126
+ expect(await mintGoogleAccessToken(sa, noToken, NOW_MS)).toBeNull();
127
+ });
128
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Google service-account OAuth2 access tokens via the JWT-bearer grant.
3
+ *
4
+ * Google Cloud REST APIs (App Distribution, BigQuery, …) accept a short-lived
5
+ * OAuth2 access token. This mints one from a service-account key (the JSON the
6
+ * developer downloads) by signing an RS256 JWT assertion and exchanging it at
7
+ * the token endpoint — no `googleapis` dependency. Shared so the gateway sync
8
+ * and a connector's MCP server sign identically without duplicating the flow.
9
+ *
10
+ * See: https://developers.google.com/identity/protocols/oauth2/service-account
11
+ */
12
+
13
+ import { signJwt } from "./jwt";
14
+
15
+ const SCOPE_CLOUD_PLATFORM = "https://www.googleapis.com/auth/cloud-platform";
16
+ const DEFAULT_TOKEN_URI = "https://oauth2.googleapis.com/token";
17
+ /** Google caps SA assertion lifetime at 1 hour. */
18
+ const ASSERTION_TTL_SECONDS = 3600;
19
+ const JWT_BEARER_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer";
20
+
21
+ export interface GoogleServiceAccount {
22
+ readonly clientEmail: string;
23
+ readonly privateKey: string;
24
+ readonly tokenUri: string;
25
+ }
26
+
27
+ function asString(value: unknown): string | undefined {
28
+ return typeof value === "string" && value !== "" ? value : undefined;
29
+ }
30
+
31
+ /**
32
+ * Parse a service-account key JSON string into the fields the JWT-bearer flow
33
+ * needs. Returns null on malformed JSON or a missing `client_email` /
34
+ * `private_key`; `token_uri` defaults to Google's endpoint when absent.
35
+ */
36
+ export function parseServiceAccountJson(json: string): GoogleServiceAccount | null {
37
+ let parsed: unknown;
38
+ try {
39
+ parsed = JSON.parse(json) as unknown;
40
+ } catch {
41
+ return null;
42
+ }
43
+ if (typeof parsed !== "object" || parsed === null) {
44
+ return null;
45
+ }
46
+ const obj = parsed as Record<string, unknown>;
47
+ const clientEmail = asString(obj["client_email"]);
48
+ const privateKey = asString(obj["private_key"]);
49
+ if (clientEmail === undefined || privateKey === undefined) {
50
+ return null;
51
+ }
52
+ return {
53
+ clientEmail,
54
+ privateKey,
55
+ tokenUri: asString(obj["token_uri"]) ?? DEFAULT_TOKEN_URI,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Sign an RS256 JWT assertion for the JWT-bearer grant. `nowMs` is injectable
61
+ * so tests can assert deterministic `iat`/`exp`; `scope` defaults to
62
+ * `cloud-platform`.
63
+ */
64
+ export function signServiceAccountAssertion(
65
+ sa: GoogleServiceAccount,
66
+ nowMs: number = Date.now(),
67
+ scope: string = SCOPE_CLOUD_PLATFORM,
68
+ ): string {
69
+ const nowSec = Math.floor(nowMs / 1000);
70
+ return signJwt({
71
+ header: { alg: "RS256", typ: "JWT" },
72
+ payload: {
73
+ iss: sa.clientEmail,
74
+ scope,
75
+ aud: sa.tokenUri,
76
+ iat: nowSec,
77
+ exp: nowSec + ASSERTION_TTL_SECONDS,
78
+ },
79
+ privateKeyPem: sa.privateKey,
80
+ });
81
+ }
82
+
83
+ export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
84
+
85
+ /**
86
+ * Exchange a service-account assertion for an OAuth2 access token. `fetchFn` and
87
+ * `nowMs` are injectable for tests; the live path uses the global `fetch`.
88
+ * Returns null when the token endpoint declines the assertion.
89
+ */
90
+ export async function mintGoogleAccessToken(
91
+ sa: GoogleServiceAccount,
92
+ fetchFn: FetchLike = globalThis.fetch as FetchLike,
93
+ nowMs: number = Date.now(),
94
+ scope: string = SCOPE_CLOUD_PLATFORM,
95
+ ): Promise<string | null> {
96
+ const assertion = signServiceAccountAssertion(sa, nowMs, scope);
97
+ const body = new URLSearchParams({ grant_type: JWT_BEARER_GRANT, assertion }).toString();
98
+ const res = await fetchFn(sa.tokenUri, {
99
+ method: "POST",
100
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
101
+ body,
102
+ });
103
+ if (!res.ok) {
104
+ return null;
105
+ }
106
+ let parsed: unknown;
107
+ try {
108
+ parsed = (await res.json()) as unknown;
109
+ } catch {
110
+ return null;
111
+ }
112
+ if (typeof parsed !== "object" || parsed === null) {
113
+ return null;
114
+ }
115
+ return asString((parsed as Record<string, unknown>)["access_token"]) ?? null;
116
+ }
@@ -0,0 +1,118 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ decodeBase64,
5
+ encodeBase64,
6
+ errorToHardDisableReason,
7
+ generateEd25519Keypair,
8
+ PublisherKeyMismatch,
9
+ SignatureInvalid,
10
+ SignatureInvalidFormat,
11
+ signManifest,
12
+ verifyManifestSignature,
13
+ } from "./verify-signature.ts";
14
+
15
+ type Manifest = {
16
+ publisher?: { id: string; key: string };
17
+ signature?: string;
18
+ [k: string]: unknown;
19
+ };
20
+
21
+ async function signedManifest(): Promise<{
22
+ manifest: Manifest;
23
+ pubkey: Uint8Array;
24
+ privkey: Uint8Array;
25
+ }> {
26
+ const { privkey, pubkey } = generateEd25519Keypair();
27
+ const manifest: Manifest = {
28
+ id: "com.example.demo",
29
+ version: "1.0.0",
30
+ publisher: { id: "demo", key: encodeBase64(pubkey) },
31
+ };
32
+ manifest.signature = await signManifest(manifest, privkey);
33
+ return { manifest, pubkey, privkey };
34
+ }
35
+
36
+ describe("base64 round-trip", () => {
37
+ test("encode then decode is identity", () => {
38
+ const bytes = new Uint8Array([0, 1, 2, 250, 255]);
39
+ expect(Array.from(decodeBase64(encodeBase64(bytes)))).toEqual(Array.from(bytes));
40
+ });
41
+ });
42
+
43
+ describe("verifyManifestSignature", () => {
44
+ test("accepts a correctly signed manifest", async () => {
45
+ const { manifest, pubkey } = await signedManifest();
46
+ await expect(verifyManifestSignature(manifest, pubkey)).resolves.toBeUndefined();
47
+ });
48
+ test("throws SignatureInvalid when a field is tampered after signing", async () => {
49
+ const { manifest, pubkey } = await signedManifest();
50
+ manifest["version"] = "9.9.9";
51
+ await expect(verifyManifestSignature(manifest, pubkey)).rejects.toBeInstanceOf(
52
+ SignatureInvalid,
53
+ );
54
+ });
55
+ test("throws PublisherKeyMismatch when resolved key differs from declared", async () => {
56
+ const { manifest } = await signedManifest();
57
+ const other = generateEd25519Keypair().pubkey;
58
+ await expect(verifyManifestSignature(manifest, other)).rejects.toBeInstanceOf(
59
+ PublisherKeyMismatch,
60
+ );
61
+ });
62
+ test("throws SignatureInvalidFormat for a wrong-length resolved pubkey", async () => {
63
+ const { manifest } = await signedManifest();
64
+ await expect(verifyManifestSignature(manifest, new Uint8Array(31))).rejects.toBeInstanceOf(
65
+ SignatureInvalidFormat,
66
+ );
67
+ });
68
+ test("throws SignatureInvalidFormat for a wrong-length declared pubkey", async () => {
69
+ const { manifest, pubkey } = await signedManifest();
70
+ manifest.publisher = { id: "demo", key: encodeBase64(new Uint8Array(31)) };
71
+ await expect(verifyManifestSignature(manifest, pubkey)).rejects.toBeInstanceOf(
72
+ SignatureInvalidFormat,
73
+ );
74
+ });
75
+ test("throws SignatureInvalidFormat for a wrong-length signature", async () => {
76
+ const { manifest, pubkey } = await signedManifest();
77
+ manifest.signature = encodeBase64(new Uint8Array(63));
78
+ await expect(verifyManifestSignature(manifest, pubkey)).rejects.toBeInstanceOf(
79
+ SignatureInvalidFormat,
80
+ );
81
+ });
82
+ test("throws when manifest is unsigned (no publisher / no signature)", async () => {
83
+ const { pubkey } = await signedManifest();
84
+ await expect(verifyManifestSignature({ id: "x" }, pubkey)).rejects.toThrow(/unsigned manifest/);
85
+ });
86
+ test("throws when publisher is present but signature is missing", async () => {
87
+ const pubkey = generateEd25519Keypair().pubkey;
88
+ await expect(
89
+ verifyManifestSignature(
90
+ { id: "x", publisher: { id: "p", key: encodeBase64(pubkey) } },
91
+ pubkey,
92
+ ),
93
+ ).rejects.toThrow(/unsigned manifest/);
94
+ });
95
+ test("throws when signature is present but publisher is missing", async () => {
96
+ const pubkey = generateEd25519Keypair().pubkey;
97
+ await expect(verifyManifestSignature({ id: "x", signature: "AA==" }, pubkey)).rejects.toThrow(
98
+ /unsigned manifest/,
99
+ );
100
+ });
101
+ });
102
+
103
+ describe("signManifest", () => {
104
+ test("throws SignatureInvalidFormat for a non-32-byte private key", async () => {
105
+ await expect(signManifest({ id: "x" }, new Uint8Array(16))).rejects.toBeInstanceOf(
106
+ SignatureInvalidFormat,
107
+ );
108
+ });
109
+ });
110
+
111
+ describe("errorToHardDisableReason", () => {
112
+ test("maps each error class to its reason", () => {
113
+ expect(errorToHardDisableReason(new PublisherKeyMismatch())).toBe("publisher_key_mismatch");
114
+ expect(errorToHardDisableReason(new SignatureInvalidFormat())).toBe("signature_malformed");
115
+ expect(errorToHardDisableReason(new SignatureInvalid())).toBe("signature_failed");
116
+ expect(errorToHardDisableReason(new Error("unknown"))).toBe("signature_failed");
117
+ });
118
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Ed25519 sign + verify primitives for extension manifest signatures.
3
+ * Connector authors use this to sign manifests; the gateway uses it to verify
4
+ * at install + every startup (I16 wiring sites).
5
+ */
6
+
7
+ import { canonicalizeManifest } from "./canonical-json";
8
+
9
+ export class PublisherKeyMismatch extends Error {
10
+ override readonly name = "PublisherKeyMismatch";
11
+ }
12
+ export class SignatureInvalidFormat extends Error {
13
+ override readonly name = "SignatureInvalidFormat";
14
+ }
15
+ export class SignatureInvalid extends Error {
16
+ override readonly name = "SignatureInvalid";
17
+ }
18
+
19
+ export type SignatureDisableReason =
20
+ | "publisher_key_missing"
21
+ | "publisher_key_mismatch"
22
+ | "signature_failed"
23
+ | "signature_malformed";
24
+
25
+ export function encodeBase64(bytes: Uint8Array): string {
26
+ return Buffer.from(bytes).toString("base64");
27
+ }
28
+
29
+ export function decodeBase64(s: string): Uint8Array {
30
+ return new Uint8Array(Buffer.from(s, "base64"));
31
+ }
32
+
33
+ function constantTimeBytesEqual(a: Uint8Array, b: Uint8Array): boolean {
34
+ if (a.length !== b.length) return false;
35
+ let diff = 0;
36
+ for (let i = 0; i < a.length; i++) {
37
+ diff |= (a[i] ?? 0) ^ (b[i] ?? 0);
38
+ }
39
+ return diff === 0;
40
+ }
41
+
42
+ type SignedManifestShape = {
43
+ publisher?: { id: string; key: string };
44
+ signature?: string;
45
+ [k: string]: unknown;
46
+ };
47
+
48
+ /**
49
+ * Verify `manifest.signature` against the canonical bytes of the manifest
50
+ * (with `signature` stripped), the declared `manifest.publisher.key`, and
51
+ * the externally-resolved `resolvedPubkey`. Throws on any mismatch.
52
+ *
53
+ * Caller must check `manifest.publisher !== undefined` first — this function
54
+ * does not gate the unsigned case.
55
+ */
56
+ export async function verifyManifestSignature(
57
+ manifest: SignedManifestShape,
58
+ resolvedPubkey: Uint8Array,
59
+ ): Promise<void> {
60
+ if (manifest.publisher === undefined || manifest.signature === undefined) {
61
+ throw new Error(
62
+ "verifyManifestSignature called on unsigned manifest — caller must check first",
63
+ );
64
+ }
65
+ if (resolvedPubkey.length !== 32) throw new SignatureInvalidFormat();
66
+ const declaredPubkey = decodeBase64(manifest.publisher.key);
67
+ if (declaredPubkey.length !== 32) throw new SignatureInvalidFormat();
68
+ if (!constantTimeBytesEqual(declaredPubkey, resolvedPubkey)) {
69
+ throw new PublisherKeyMismatch();
70
+ }
71
+ const sig = decodeBase64(manifest.signature);
72
+ if (sig.length !== 64) throw new SignatureInvalidFormat();
73
+ const canonical = canonicalizeManifest(manifest);
74
+ const cryptoKey = await crypto.subtle.importKey(
75
+ "raw",
76
+ new Uint8Array(resolvedPubkey),
77
+ { name: "Ed25519" },
78
+ false,
79
+ ["verify"],
80
+ );
81
+ const ok = await crypto.subtle.verify(
82
+ "Ed25519",
83
+ cryptoKey,
84
+ new Uint8Array(sig),
85
+ new Uint8Array(canonical),
86
+ );
87
+ if (!ok) throw new SignatureInvalid();
88
+ }
89
+
90
+ /**
91
+ * Deterministically sign a manifest's canonical bytes with `privkey` (32-byte
92
+ * Ed25519 seed). Returns the 64-byte signature as base64. Any existing
93
+ * `signature` field on the manifest is ignored (stripped by
94
+ * `canonicalizeManifest`).
95
+ */
96
+ export async function signManifest(
97
+ manifest: SignedManifestShape,
98
+ privkey: Uint8Array,
99
+ ): Promise<string> {
100
+ if (privkey.length !== 32) throw new SignatureInvalidFormat();
101
+ const d = Buffer.from(privkey).toString("base64url");
102
+ const cryptoKey = await crypto.subtle.importKey(
103
+ "jwk",
104
+ { kty: "OKP", crv: "Ed25519", d },
105
+ { name: "Ed25519" },
106
+ false,
107
+ ["sign"],
108
+ );
109
+ const canonical = canonicalizeManifest(manifest);
110
+ const sig = await crypto.subtle.sign("Ed25519", cryptoKey, new Uint8Array(canonical));
111
+ return encodeBase64(new Uint8Array(sig));
112
+ }
113
+
114
+ /**
115
+ * Generate a fresh Ed25519 keypair via WebCrypto and export both halves as
116
+ * raw 32-byte arrays. Used by `nimbus extension keygen` and by every test
117
+ * fixture (no committed crypto material — see spec §6.3).
118
+ */
119
+ export function generateEd25519Keypair(): { privkey: Uint8Array; pubkey: Uint8Array } {
120
+ const nodeCrypto: typeof import("node:crypto") = require("node:crypto");
121
+ const { privateKey, publicKey } = nodeCrypto.generateKeyPairSync("ed25519");
122
+ const privJwk = privateKey.export({ format: "jwk" }) as { d: string };
123
+ const pubJwk = publicKey.export({ format: "jwk" }) as { x: string };
124
+ const privkey = new Uint8Array(Buffer.from(privJwk.d, "base64url"));
125
+ const pubkey = new Uint8Array(Buffer.from(pubJwk.x, "base64url"));
126
+ return { privkey, pubkey };
127
+ }
128
+
129
+ /**
130
+ * Map a verification error class to the `SignatureDisableReason` string the
131
+ * `SignatureDisabledRegistry` (hard-disable.ts) records.
132
+ */
133
+ export function errorToHardDisableReason(err: unknown): SignatureDisableReason {
134
+ if (err instanceof PublisherKeyMismatch) return "publisher_key_mismatch";
135
+ if (err instanceof SignatureInvalidFormat) return "signature_malformed";
136
+ if (err instanceof SignatureInvalid) return "signature_failed";
137
+ return "signature_failed";
138
+ }