@seekrit/cli 0.14.0 → 0.15.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.
Files changed (2) hide show
  1. package/dist/index.js +243 -7
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -12,7 +12,8 @@ z.enum([
12
12
  "mysql",
13
13
  "ssh",
14
14
  "redis",
15
- "aws"
15
+ "aws",
16
+ "gcp"
16
17
  ]);
17
18
  const executorModeSchema = z.enum(["in_do", "remote"]);
18
19
  /**
@@ -83,6 +84,22 @@ const awsRegionSchema = z.string().regex(/^[a-z]{2}(?:-[a-z]+)+-\d$/, "must be a
83
84
  const awsExternalIdSchema = z.string().regex(/^[\w+=,.@:/-]{2,1224}$/, "must be a valid STS external id");
84
85
  z.string().regex(/^[\w+=,.@-]{2,64}$/, "must be 2–64 chars of [A-Za-z0-9_+=,.@-]");
85
86
  /**
87
+ * A GCP service-account email the broker is allowed to impersonate (or that
88
+ * appears in a delegation chain). Structurally validated and bounded: it is
89
+ * interpolated into the IAM Credentials API URL path, so the charset excludes
90
+ * anything that could break out of a path segment. Covers user-managed
91
+ * (`name@<project>.iam.gserviceaccount.com`) and Google-managed
92
+ * (`<project-number>-compute@developer.gserviceaccount.com`) forms.
93
+ */
94
+ const gcpServiceAccountEmailSchema = z.string().max(256).regex(/^[a-z0-9-]+@[a-z0-9.-]+\.gserviceaccount\.com$/, "must be a service-account email (…@….gserviceaccount.com)");
95
+ /**
96
+ * An OAuth 2.0 scope granted to the minted access token, e.g.
97
+ * `https://www.googleapis.com/auth/cloud-platform`. Bounded and whitespace-free
98
+ * (scopes are space-delimited); passed to the IAM Credentials API in a JSON body
99
+ * array, not a URL, so this is sanity/DoS hardening rather than an injection gate.
100
+ */
101
+ const gcpOauthScopeSchema = z.string().min(1).max(256).regex(/^\S+$/, "must be a single OAuth scope with no whitespace");
102
+ /**
86
103
  * The consumer's ephemeral P-256 public key (JWK-serialized) that a tier-2
87
104
  * credential is wrapped to before it is returned. Validated structurally here;
88
105
  * the executor imports it defensively before wrapping. Bounded so a giant blob
@@ -184,12 +201,22 @@ const awsTargetConfigSchema = z.object({
184
201
  sessionPolicy: z.string().min(1).max(4e3).optional(),
185
202
  maxTtlSeconds: z.number().int().min(900).max(AWS_MAX_TTL_SECONDS).optional()
186
203
  });
204
+ const GCP_MAX_TTL_SECONDS = 3600 * 12;
205
+ const gcpTargetConfigSchema = z.object({
206
+ provider: z.literal("gcp"),
207
+ executor: z.literal("in_do"),
208
+ serviceAccount: gcpServiceAccountEmailSchema,
209
+ scopes: z.array(gcpOauthScopeSchema).min(1).max(32).optional(),
210
+ delegates: z.array(gcpServiceAccountEmailSchema).max(8).optional(),
211
+ maxTtlSeconds: z.number().int().min(60).max(GCP_MAX_TTL_SECONDS).optional()
212
+ });
187
213
  const leaseTargetConfigSchema = z.discriminatedUnion("provider", [
188
214
  postgresTargetConfigSchema,
189
215
  mysqlTargetConfigSchema,
190
216
  redisTargetConfigSchema,
191
217
  sshTargetConfigSchema,
192
- awsTargetConfigSchema
218
+ awsTargetConfigSchema,
219
+ gcpTargetConfigSchema
193
220
  ]);
194
221
  z.object({
195
222
  name: z.string().trim().min(1).max(128),
@@ -266,12 +293,29 @@ const mintAwsLeaseSchema = z.object({
266
293
  recipientPublicKey: p256PublicKeyJwkSchema,
267
294
  ttlSeconds: z.number().int().min(900).max(AWS_MAX_TTL_SECONDS)
268
295
  });
296
+ /**
297
+ * Client → API: mint a GCP lease. Like AWS (tier 2): the client generates an
298
+ * ephemeral P-256 keypair locally and sends only the public key; the IAM
299
+ * Credentials API mints the access token and the broker returns it wrapped to
300
+ * that key. The private key never leaves the requesting machine, so the plaintext
301
+ * token is only decryptable there.
302
+ *
303
+ * TTL bounds are GCP's `generateAccessToken` limits (1 min – 12 h); tokens over
304
+ * 1 h require the credential-lifetime-extension org policy.
305
+ */
306
+ const mintGcpLeaseSchema = z.object({
307
+ provider: z.literal("gcp"),
308
+ targetId: z.string().min(1),
309
+ recipientPublicKey: p256PublicKeyJwkSchema,
310
+ ttlSeconds: z.number().int().min(60).max(GCP_MAX_TTL_SECONDS)
311
+ });
269
312
  z.discriminatedUnion("provider", [
270
313
  mintPostgresLeaseSchema,
271
314
  mintMysqlLeaseSchema,
272
315
  mintRedisLeaseSchema,
273
316
  mintSshLeaseSchema,
274
- mintAwsLeaseSchema
317
+ mintAwsLeaseSchema,
318
+ mintGcpLeaseSchema
275
319
  ]);
276
320
  //#endregion
277
321
  //#region ../../packages/core/src/providers/aws.ts
@@ -302,6 +346,29 @@ function awsTrustPolicyInstructions(config) {
302
346
  ].join("\n");
303
347
  }
304
348
  //#endregion
349
+ //#region ../../packages/core/src/providers/gcp.ts
350
+ /**
351
+ * The one-time IAM setup an admin performs so seekrit can impersonate the target
352
+ * service account. Analogue of `awsTrustPolicyInstructions` — printed for the
353
+ * admin to apply, never executed by seekrit. Grants the *source* service account
354
+ * (the one whose key was registered as the admin secret) the token-creator role
355
+ * on the target service account.
356
+ */
357
+ function gcpSetupInstructions(config) {
358
+ return [
359
+ "# Grant the service account whose key you registered as the admin secret",
360
+ "# permission to mint tokens for the target service account. Replace",
361
+ "# <SOURCE_SA_EMAIL> with the client_email from that key JSON.",
362
+ `gcloud iam service-accounts add-iam-policy-binding ${config.serviceAccount} \\`,
363
+ " --member=\"serviceAccount:<SOURCE_SA_EMAIL>\" \\",
364
+ " --role=\"roles/iam.serviceAccountTokenCreator\"",
365
+ "",
366
+ "# The source service account's project must have the IAM Service Account",
367
+ "# Credentials API enabled (seekrit only ever calls generateAccessToken):",
368
+ "gcloud services enable iamcredentials.googleapis.com"
369
+ ].join("\n");
370
+ }
371
+ //#endregion
305
372
  //#region ../../packages/core/src/providers/postgres.ts
306
373
  /**
307
374
  * The one-time setup SQL an admin runs to create the shared group role that a
@@ -690,6 +757,33 @@ async function unwrapAwsCredential(wrapped, privateKeyJwk) {
690
757
  };
691
758
  }
692
759
  //#endregion
760
+ //#region ../../packages/crypto/src/gcp.ts
761
+ /** Generate the ephemeral P-256 keypair a client uses to receive one GCP lease. */
762
+ async function generateGcpRecipientKeyPair() {
763
+ return generateKeyPair();
764
+ }
765
+ /**
766
+ * Unwrap the `wd1.` blob returned by a GCP lease mint using the ephemeral
767
+ * private key generated for that lease, yielding the plaintext access token.
768
+ */
769
+ async function unwrapGcpCredential(wrapped, privateKeyJwk) {
770
+ const bytes = await unwrapDek(wrapped, await importPrivateKey(privateKeyJwk));
771
+ let parsed;
772
+ try {
773
+ parsed = JSON.parse(utf8Decode(bytes));
774
+ } catch {
775
+ throw new SeekritCryptoError("DECRYPT_FAILED", "unwrapped GCP credential was not valid JSON");
776
+ }
777
+ const c = parsed;
778
+ if (typeof c.accessToken !== "string" || typeof c.expiration !== "string" || typeof c.serviceAccount !== "string" || !Array.isArray(c.scopes) || !c.scopes.every((s) => typeof s === "string")) throw new SeekritCryptoError("DECRYPT_FAILED", "unwrapped GCP credential is missing fields");
779
+ return {
780
+ accessToken: c.accessToken,
781
+ expiration: c.expiration,
782
+ serviceAccount: c.serviceAccount,
783
+ scopes: c.scopes
784
+ };
785
+ }
786
+ //#endregion
693
787
  //#region ../../packages/crypto/src/mysql.ts
694
788
  /**
695
789
  * Client-side construction of a MySQL/MariaDB `mysql_native_password`
@@ -1134,7 +1228,7 @@ function isServiceToken(value) {
1134
1228
  }
1135
1229
  //#endregion
1136
1230
  //#region package.json
1137
- var version = "0.14.0";
1231
+ var version = "0.15.0";
1138
1232
  //#endregion
1139
1233
  //#region ../../packages/api-client/src/index.ts
1140
1234
  var SeekritApiError = class extends Error {
@@ -1583,7 +1677,7 @@ async function resolveGroup(ctx, opts) {
1583
1677
  * `sts:AssumeRole` on the target role.
1584
1678
  */
1585
1679
  /** Parse a duration like `30m`, `1h`, or a bare seconds count. */
1586
- function parseTtlSeconds$4(input) {
1680
+ function parseTtlSeconds$5(input) {
1587
1681
  const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
1588
1682
  if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 12h)`);
1589
1683
  return Number(m[1]) * ({
@@ -1623,7 +1717,7 @@ function registerAwsCommands(program) {
1623
1717
  region: options.region,
1624
1718
  ...options.externalId ? { externalId: options.externalId } : {},
1625
1719
  ...sessionPolicy ? { sessionPolicy } : {},
1626
- ...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$4(options.maxTtl) } : {}
1720
+ ...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$5(options.maxTtl) } : {}
1627
1721
  };
1628
1722
  const baseCredential = resolveBaseCredential(options);
1629
1723
  const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
@@ -1671,7 +1765,7 @@ function registerAwsCommands(program) {
1671
1765
  if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
1672
1766
  const cfg = t.config;
1673
1767
  if (cfg.provider !== "aws") fail(`"${t.name}" is not an aws target (see \`seekrit pg\`)`);
1674
- const ttlSeconds = parseTtlSeconds$4(options.ttl);
1768
+ const ttlSeconds = parseTtlSeconds$5(options.ttl);
1675
1769
  if (ttlSeconds < 900) fail(`--ttl must be at least ${900 / 60}m (STS minimum)`);
1676
1770
  const recipient = await generateAwsRecipientKeyPair();
1677
1771
  const { aws: leased } = await ctx.client.mintLease(org.id, {
@@ -1761,6 +1855,147 @@ function formatSecrets(values, format) {
1761
1855
  }
1762
1856
  }
1763
1857
  //#endregion
1858
+ //#region src/gcp.ts
1859
+ /**
1860
+ * `seekrit gcp` — temporary GCP credentials via IAM Credentials
1861
+ * `generateAccessToken` (Vault-style dynamic secrets, the tier-2 sibling of
1862
+ * `seekrit aws`).
1863
+ *
1864
+ * Zero-knowledge for the leased credential: minting generates an ephemeral P-256
1865
+ * keypair on THIS machine and sends only the public key; GCP mints the token and
1866
+ * the broker returns it wrapped to that key, so the control plane only ever
1867
+ * relays ciphertext and only this machine can unwrap it. Registering a target
1868
+ * wraps the service-account key JSON to the broker's public key locally, so the
1869
+ * control plane never sees it either — the source service account needs only
1870
+ * `roles/iam.serviceAccountTokenCreator` on the target.
1871
+ */
1872
+ /** Parse a duration like `30m`, `1h`, or a bare seconds count. */
1873
+ function parseTtlSeconds$4(input) {
1874
+ const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
1875
+ if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 12h)`);
1876
+ return Number(m[1]) * ({
1877
+ s: 1,
1878
+ m: 60,
1879
+ h: 3600,
1880
+ d: 86400
1881
+ }[m[2] || "s"] ?? 1);
1882
+ }
1883
+ /** Collect a repeatable flag (e.g. --scope) into a list. */
1884
+ function collectList$1(value, acc = []) {
1885
+ acc.push(value);
1886
+ return acc;
1887
+ }
1888
+ /**
1889
+ * The service-account key JSON the broker impersonates with. From --key-file or
1890
+ * GOOGLE_APPLICATION_CREDENTIALS. Never leaves this machine unwrapped — it is
1891
+ * wrapped to the broker key before upload.
1892
+ */
1893
+ function resolveServiceAccountKey(opts) {
1894
+ const path = opts.keyFile ?? process.env.GOOGLE_APPLICATION_CREDENTIALS;
1895
+ if (!path) fail("provide the source service-account key JSON via --key-file or GOOGLE_APPLICATION_CREDENTIALS (it needs roles/iam.serviceAccountTokenCreator on the target)");
1896
+ const raw = readFileSync(path, "utf8").trim();
1897
+ try {
1898
+ const parsed = JSON.parse(raw);
1899
+ if (typeof parsed.client_email !== "string" || typeof parsed.private_key !== "string") fail(`${path} is not a service-account key JSON (missing client_email/private_key)`);
1900
+ } catch {
1901
+ fail(`${path} is not valid JSON`);
1902
+ }
1903
+ return raw;
1904
+ }
1905
+ function registerGcpCommands(program) {
1906
+ const gcp = program.command("gcp").description("temporary GCP credentials (IAM generateAccessToken, zero-knowledge)");
1907
+ const target = gcp.command("target").description("manage GCP service-account targets");
1908
+ target.command("add").description("register an impersonable service account to issue temporary tokens from").requiredOption("--name <name>", "display name, e.g. prod-deploy").requiredOption("--service-account <email>", "the service account to impersonate, name@project.iam.gserviceaccount.com").option("--org <slug>").option("--scope <scope>", "OAuth scope to grant (repeatable; default cloud-platform)", collectList$1).option("--delegate <email>", "delegation-chain service account (repeatable)", collectList$1).option("--max-ttl <duration>", "clamp requested token lifetime, e.g. 1h").option("--key-file <path>", "source SA key JSON (else GOOGLE_APPLICATION_CREDENTIALS)").action(async (options) => {
1909
+ const ctx = buildContext();
1910
+ const org = await resolveOrg(ctx, options.org);
1911
+ const config = {
1912
+ provider: "gcp",
1913
+ executor: "in_do",
1914
+ serviceAccount: options.serviceAccount,
1915
+ ...options.scope?.length ? { scopes: options.scope } : {},
1916
+ ...options.delegate?.length ? { delegates: options.delegate } : {},
1917
+ ...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$4(options.maxTtl) } : {}
1918
+ };
1919
+ const keyJson = resolveServiceAccountKey(options);
1920
+ const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
1921
+ const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(keyJson), publicKeyJwk);
1922
+ const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
1923
+ name: options.name,
1924
+ config,
1925
+ wrappedAdminSecret
1926
+ });
1927
+ console.error(`registered GCP target ${created.name} (${created.id})`);
1928
+ console.error("\nGrant the source SA the token-creator role, then `seekrit gcp lease`:\n");
1929
+ console.log(gcpSetupInstructions(config));
1930
+ });
1931
+ target.command("list").description("list GCP service-account targets").option("--org <slug>").action(async (options) => {
1932
+ const ctx = buildContext();
1933
+ const org = await resolveOrg(ctx, options.org);
1934
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
1935
+ for (const t of targets) {
1936
+ const cfg = t.config;
1937
+ if (cfg.provider !== "gcp") continue;
1938
+ console.log(`${t.id}\t${t.name}\t${cfg.serviceAccount}`);
1939
+ }
1940
+ });
1941
+ target.command("setup <targetId>").description("reprint the IAM setup for a GCP target").option("--org <slug>").action(async (targetId, options) => {
1942
+ const ctx = buildContext();
1943
+ const org = await resolveOrg(ctx, options.org);
1944
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
1945
+ const t = targets.find((x) => x.id === targetId || x.name === targetId);
1946
+ if (!t) fail(`no target "${targetId}" in ${org.slug}`);
1947
+ const cfg = t.config;
1948
+ if (cfg.provider !== "gcp") fail("not a gcp target (see `seekrit aws`/`seekrit ssh`)");
1949
+ console.log(gcpSetupInstructions(cfg));
1950
+ });
1951
+ target.command("rm <targetId>").description("delete a GCP service-account target").option("--org <slug>").action(async (targetId, options) => {
1952
+ const ctx = buildContext();
1953
+ const org = await resolveOrg(ctx, options.org);
1954
+ await ctx.client.deleteLeaseTarget(org.id, targetId);
1955
+ console.error(`deleted ${targetId}`);
1956
+ });
1957
+ gcp.command("lease <target>").description("mint a short-lived GCP access token; prints ready-to-source export lines").option("--org <slug>").option("--ttl <duration>", "token lifetime, e.g. 15m, 1h", "1h").option("--json", "print the full credential as JSON").action(async (targetRef, options) => {
1958
+ const ctx = buildContext();
1959
+ const org = await resolveOrg(ctx, options.org);
1960
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
1961
+ const t = targets.find((x) => x.id === targetRef || x.name === targetRef);
1962
+ if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
1963
+ if (t.config.provider !== "gcp") fail(`"${t.name}" is not a gcp target (see \`seekrit aws\`)`);
1964
+ const ttlSeconds = parseTtlSeconds$4(options.ttl);
1965
+ if (ttlSeconds < 60) fail(`--ttl must be at least 60s`);
1966
+ if (ttlSeconds > 43200) fail(`--ttl must be at most ${GCP_MAX_TTL_SECONDS / 3600}h`);
1967
+ const recipient = await generateGcpRecipientKeyPair();
1968
+ const { gcp: leased } = await ctx.client.mintLease(org.id, {
1969
+ provider: "gcp",
1970
+ targetId: t.id,
1971
+ recipientPublicKey: recipient.publicKeyJwk,
1972
+ ttlSeconds
1973
+ });
1974
+ const cred = await unwrapGcpCredential(leased.wrappedCredential, recipient.privateKeyJwk);
1975
+ console.error(`leased ${cred.serviceAccount} — expires ${cred.expiration}`);
1976
+ if (options.json) console.log(JSON.stringify(cred, null, 2));
1977
+ else {
1978
+ console.log(`export CLOUDSDK_AUTH_ACCESS_TOKEN=${cred.accessToken}`);
1979
+ console.log(`export GOOGLE_OAUTH_ACCESS_TOKEN=${cred.accessToken}`);
1980
+ }
1981
+ });
1982
+ gcp.command("leases").description("list GCP leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
1983
+ const ctx = buildContext();
1984
+ const org = await resolveOrg(ctx, options.org);
1985
+ const { leases } = await ctx.client.listLeases(org.id);
1986
+ for (const l of leases) {
1987
+ if (l.provider !== "gcp") continue;
1988
+ console.log(`${l.id}\t${l.status}\texpires ${l.expiresAt}`);
1989
+ }
1990
+ });
1991
+ gcp.command("revoke <leaseId>").description("mark a lease revoked in the ledger (tokens stay valid until they expire)").option("--org <slug>").action(async (leaseId, options) => {
1992
+ const ctx = buildContext();
1993
+ const org = await resolveOrg(ctx, options.org);
1994
+ await ctx.client.revokeLease(org.id, leaseId);
1995
+ console.error(`revoked ${leaseId} (issued tokens remain valid until they expire)`);
1996
+ });
1997
+ }
1998
+ //#endregion
1764
1999
  //#region src/provisioner.ts
1765
2000
  /**
1766
2001
  * `seekrit provisioner` — helpers for the self-hosted **remote executor**
@@ -2851,6 +3086,7 @@ registerRedisCommands(program);
2851
3086
  registerProvisionerCommands(program);
2852
3087
  registerSshCommands(program);
2853
3088
  registerAwsCommands(program);
3089
+ registerGcpCommands(program);
2854
3090
  program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
2855
3091
  const { runMcpServer } = await import("./mcp-ARBuneH3.js");
2856
3092
  await runMcpServer();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seekrit/cli",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -23,9 +23,9 @@
23
23
  "devDependencies": {
24
24
  "@types/node": "^26.1.0",
25
25
  "tsdown": "^0.22.3",
26
- "@seekrit/api-client": "0.0.1",
27
26
  "@seekrit/core": "0.0.1",
28
- "@seekrit/crypto": "0.0.1"
27
+ "@seekrit/crypto": "0.0.1",
28
+ "@seekrit/api-client": "0.0.1"
29
29
  },
30
30
  "scripts": {
31
31
  "build": "tsdown",