From 8628b3da1982bf82298ba32d98700a941aa8d92a Mon Sep 17 00:00:00 2001 From: Brendan Berrigan Date: Wed, 18 Mar 2026 12:34:43 -0400 Subject: [PATCH 1/3] Generate and send KMS signature --- src/drivers/api.ts | 3 +- src/plugins/self-hosted/kms.ts | 76 ++++++++++++++++++++++++++++++++++ src/plugins/self-hosted/ssh.ts | 18 +++++++- src/plugins/ssh/types.ts | 1 + 4 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 src/plugins/self-hosted/kms.ts diff --git a/src/drivers/api.ts b/src/drivers/api.ts index 20ebd02b..510f6b5d 100644 --- a/src/drivers/api.ts +++ b/src/drivers/api.ts @@ -117,7 +117,7 @@ export const fetchAdminLsCommand = async ( export const submitPublicKey = async ( authn: Authn, - args: { publicKey: string; requestId: string }, + args: { publicKey: string; requestId: string; signature?: string }, debug?: boolean ) => authFetch(authn, { @@ -126,6 +126,7 @@ export const submitPublicKey = async ( body: JSON.stringify({ requestId: args.requestId, publicKey: args.publicKey, + ...(args.signature ? { signature: args.signature } : {}), }), debug, }); diff --git a/src/plugins/self-hosted/kms.ts b/src/plugins/self-hosted/kms.ts new file mode 100644 index 00000000..2da8d4af --- /dev/null +++ b/src/plugins/self-hosted/kms.ts @@ -0,0 +1,76 @@ +/** Copyright © 2024-present P0 Security + +This file is part of @p0security/cli + +@p0security/cli is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 of the License. + +@p0security/cli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see . +**/ +import { asyncSpawn } from "../../common/subprocess"; +import { print2 } from "../../drivers/stdio"; +import { gcloudCommandArgs } from "../google/util"; + +/** + * Signs the given data string using a Google Cloud KMS asymmetric signing key. + * + * Requires the gcloud CLI to be installed and authenticated via `gcloud auth login`. + * + * @param data - The data to sign (the raw OpenSSH public key string). + * @param kmsKeyResourceName - Full KMS key version resource name, e.g. + * `projects/P/locations/L/keyRings/R/cryptoKeys/K/cryptoKeyVersions/V` + * @returns Base64-encoded signature as returned by the KMS API. + */ +export const signWithKms = async ( + data: string, + kmsKeyResourceName: string, + options?: { debug?: boolean } +): Promise => { + const debug = options?.debug ?? false; + + // Force debug=false to avoid printing the access token + const { command: accessTokenCommand, args: accessTokenArgs } = + gcloudCommandArgs(["auth", "print-access-token"]); + const accessToken = await asyncSpawn( + { debug: false }, + accessTokenCommand, + accessTokenArgs + ); + + if (debug) { + print2( + `Signing public key with KMS key: ${kmsKeyResourceName} (token: ${accessToken.slice(0, 10)}...)` + ); + } + + const encodedData = Buffer.from(data, "utf-8").toString("base64"); + + const url = `https://cloudkms.googleapis.com/v1/${kmsKeyResourceName}:asymmetricSign`; + const response = await fetch(url, { + method: "POST", + body: JSON.stringify({ data: encodedData }), + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + if (debug) { + print2(`KMS HTTP error ${response.status}: ${await response.text()}`); + } + if (response.status === 401) { + throw `Authentication failed. Please login to Google Cloud CLI with 'gcloud auth login'`; + } + throw `KMS signing failed.`; + } + + const result: { signature: string } = await response.json(); + + if (debug) { + print2(`KMS signature obtained (${result.signature.length} chars)`); + } + + return result.signature; +}; diff --git a/src/plugins/self-hosted/ssh.ts b/src/plugins/self-hosted/ssh.ts index 1aaf5aba..0bce90b1 100644 --- a/src/plugins/self-hosted/ssh.ts +++ b/src/plugins/self-hosted/ssh.ts @@ -9,9 +9,11 @@ This file is part of @p0security/cli You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see . **/ import { isSudoCommand } from "../../commands/shared/ssh"; -import { submitPublicKey } from "../../drivers/api"; +import { fetchIntegrationConfig, submitPublicKey } from "../../drivers/api"; +import { SshConfig } from "../../plugins/ssh/types"; import { SshProvider } from "../../types/ssh"; import { getAppName, getOperatingSystem } from "../../util"; +import { signWithKms } from "./kms"; import { SelfHostedSshPermissionSpec, SelfHostedSshRequest } from "./types"; const PROPAGATION_TIMEOUT_LIMIT_MS = 2 * 60 * 1000; @@ -57,7 +59,19 @@ export const selfHostedSshProvider: SshProvider< }, async submitPublicKey(authn, _request, requestId, publicKey, debug) { - await submitPublicKey(authn, { publicKey, requestId }, debug); + const configDoc = await fetchIntegrationConfig<{ config: SshConfig }>( + authn, + "ssh", + debug + ); + const kmsKeyResourceName = configDoc?.config?.kmsKeyResourceName; + + let signature: string | undefined; + if (kmsKeyResourceName) { + signature = await signWithKms(publicKey, kmsKeyResourceName, { debug }); + } + + await submitPublicKey(authn, { publicKey, requestId, signature }, debug); }, proxyCommand: (request, port) => { diff --git a/src/plugins/ssh/types.ts b/src/plugins/ssh/types.ts index 0e957629..2b24a28e 100644 --- a/src/plugins/ssh/types.ts +++ b/src/plugins/ssh/types.ts @@ -15,6 +15,7 @@ type SshItemConfig = { export type SshConfig = { "iam-write": Record; + kmsKeyResourceName?: string; }; export type CommonSshPermissionSpec = { From 2571ab07e8fb268b906b57e8aa2485a29d705473 Mon Sep 17 00:00:00 2001 From: Brendan Berrigan Date: Fri, 27 Mar 2026 10:28:47 -0400 Subject: [PATCH 2/3] One sign on --- src/plugins/self-hosted/kms.ts | 58 +++++++++++++++++++++++++++------- src/plugins/self-hosted/ssh.ts | 22 ++++++++++++- 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/src/plugins/self-hosted/kms.ts b/src/plugins/self-hosted/kms.ts index 2da8d4af..af4dcb76 100644 --- a/src/plugins/self-hosted/kms.ts +++ b/src/plugins/self-hosted/kms.ts @@ -8,34 +8,70 @@ This file is part of @p0security/cli You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see . **/ -import { asyncSpawn } from "../../common/subprocess"; +import { OIDC_HEADERS } from "../../common/auth/oidc"; +import { urlEncode, validateResponse } from "../../common/fetch"; import { print2 } from "../../drivers/stdio"; -import { gcloudCommandArgs } from "../google/util"; +import { Identity } from "../../types/identity"; + +const ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"; +const ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token"; +const TOKEN_EXCHANGE_GRANT_TYPE = + "urn:ietf:params:oauth:grant-type:token-exchange"; + +/** + * Exchanges an OIDC id_token for a federated GCP access token via Google STS. + * Uses Workload Identity Federation direct auth (no service account). + * The WIF pool must be configured to accept tokens from the OIDC provider directly. + */ +const exchangeOidcForGcpToken = async ( + oidcToken: string, + wifPool: string +): Promise => { + const response = await fetch("https://sts.googleapis.com/v1/token", { + method: "POST", + headers: OIDC_HEADERS, + body: urlEncode({ + audience: wifPool, + grant_type: TOKEN_EXCHANGE_GRANT_TYPE, + requested_token_type: ACCESS_TOKEN_TYPE, + subject_token: oidcToken, + subject_token_type: ID_TOKEN_TYPE, + scope: "https://www.googleapis.com/auth/cloud-platform", + }), + }); + + await validateResponse(response); + const data: { access_token: string } = await response.json(); + return data.access_token; +}; /** * Signs the given data string using a Google Cloud KMS asymmetric signing key. * - * Requires the gcloud CLI to be installed and authenticated via `gcloud auth login`. + * Authenticates via Workload Identity Federation: sends the user's OIDC + * id_token directly to Google STS to obtain a federated GCP access token, + * then uses that to call the KMS API — no gcloud CLI or service account required. * * @param data - The data to sign (the raw OpenSSH public key string). * @param kmsKeyResourceName - Full KMS key version resource name, e.g. * `projects/P/locations/L/keyRings/R/cryptoKeys/K/cryptoKeyVersions/V` + * @param identity - The user's P0 identity containing OIDC credentials. + * @param wifPool - The WIF pool resource path, e.g. + * `//iam.googleapis.com/projects/P/locations/global/workloadIdentityPools/POOL` * @returns Base64-encoded signature as returned by the KMS API. */ export const signWithKms = async ( data: string, kmsKeyResourceName: string, + identity: Identity, + wifPool: string, options?: { debug?: boolean } ): Promise => { const debug = options?.debug ?? false; - // Force debug=false to avoid printing the access token - const { command: accessTokenCommand, args: accessTokenArgs } = - gcloudCommandArgs(["auth", "print-access-token"]); - const accessToken = await asyncSpawn( - { debug: false }, - accessTokenCommand, - accessTokenArgs + const accessToken = await exchangeOidcForGcpToken( + identity.credential.id_token, + wifPool ); if (debug) { @@ -61,7 +97,7 @@ export const signWithKms = async ( print2(`KMS HTTP error ${response.status}: ${await response.text()}`); } if (response.status === 401) { - throw `Authentication failed. Please login to Google Cloud CLI with 'gcloud auth login'`; + throw `Authentication failed. Please re-authenticate with '${identity.org.slug}' and try again.`; } throw `KMS signing failed.`; } diff --git a/src/plugins/self-hosted/ssh.ts b/src/plugins/self-hosted/ssh.ts index 0bce90b1..0a9d672a 100644 --- a/src/plugins/self-hosted/ssh.ts +++ b/src/plugins/self-hosted/ssh.ts @@ -68,7 +68,27 @@ export const selfHostedSshProvider: SshProvider< let signature: string | undefined; if (kmsKeyResourceName) { - signature = await signWithKms(publicKey, kmsKeyResourceName, { debug }); + const projectId = kmsKeyResourceName.split("/").at(1); + if (!projectId) { + throw `Invalid KMS key resource name: ${kmsKeyResourceName}`; + } + type GcloudIamWriteItem = { defaultPool?: string; state: string }; + type GcloudConfig = { "iam-write": Record }; + const gcloudConfigDoc = await fetchIntegrationConfig<{ + config: GcloudConfig; + }>(authn, "gcloud", debug); + const wifPool = + gcloudConfigDoc?.config?.["iam-write"]?.[projectId]?.defaultPool; + if (!wifPool) { + throw `No WIF pool configured for GCP project ${projectId}. Ensure 'defaultPool' is set in the gcloud iam-write integration.`; + } + signature = await signWithKms( + publicKey, + kmsKeyResourceName, + authn.identity, + wifPool, + { debug } + ); } await submitPublicKey(authn, { publicKey, requestId, signature }, debug); From 0a5bc4ffe19fc34ae939e390425cf8219fdaf241 Mon Sep 17 00:00:00 2001 From: Brendan Berrigan Date: Fri, 27 Mar 2026 14:13:59 -0400 Subject: [PATCH 3/3] Add back in exchange --- src/plugins/self-hosted/kms.ts | 54 +++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/src/plugins/self-hosted/kms.ts b/src/plugins/self-hosted/kms.ts index af4dcb76..5adccfac 100644 --- a/src/plugins/self-hosted/kms.ts +++ b/src/plugins/self-hosted/kms.ts @@ -11,6 +11,7 @@ You should have received a copy of the GNU General Public License along with @p0 import { OIDC_HEADERS } from "../../common/auth/oidc"; import { urlEncode, validateResponse } from "../../common/fetch"; import { print2 } from "../../drivers/stdio"; +import { getClientId, getProviderDomain } from "../../types/authUtils"; import { Identity } from "../../types/identity"; const ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"; @@ -19,9 +20,43 @@ const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; /** - * Exchanges an OIDC id_token for a federated GCP access token via Google STS. - * Uses Workload Identity Federation direct auth (no service account). - * The WIF pool must be configured to accept tokens from the OIDC provider directly. + * Exchanges the user's existing OIDC id_token for a new token whose audience + * is the WIF pool. Uses RFC 8693 token exchange against the configured OIDC + * provider so that the resulting token has the correct `aud` claim for GCP STS. + */ +const fetchOidcTokenForWif = async ( + identity: Identity, + wifPool: string +): Promise => { + const { org, credential } = identity; + const providerDomain = getProviderDomain(org); + const clientId = getClientId(org); + + if (!providerDomain || !clientId) { + throw "Invalid provider configuration for WIF token exchange"; + } + + const response = await fetch(`https://${providerDomain}/oauth2/v1/token`, { + method: "POST", + headers: OIDC_HEADERS, + body: urlEncode({ + audience: wifPool, + client_id: clientId, + grant_type: TOKEN_EXCHANGE_GRANT_TYPE, + subject_token: credential.id_token, + subject_token_type: ID_TOKEN_TYPE, + requested_token_type: ID_TOKEN_TYPE, + }), + }); + + await validateResponse(response); + const data: { access_token: string } = await response.json(); + return data.access_token; +}; + +/** + * Exchanges a WIF-scoped OIDC token for a federated GCP access token via + * Google STS. Uses Workload Identity Federation direct auth (no service account). */ const exchangeOidcForGcpToken = async ( oidcToken: string, @@ -48,9 +83,10 @@ const exchangeOidcForGcpToken = async ( /** * Signs the given data string using a Google Cloud KMS asymmetric signing key. * - * Authenticates via Workload Identity Federation: sends the user's OIDC - * id_token directly to Google STS to obtain a federated GCP access token, - * then uses that to call the KMS API — no gcloud CLI or service account required. + * Authenticates via Workload Identity Federation: exchanges the user's OIDC + * id_token for a WIF-scoped token via the OIDC provider, then exchanges that + * with Google STS for a federated GCP access token — no gcloud CLI or service + * account required. * * @param data - The data to sign (the raw OpenSSH public key string). * @param kmsKeyResourceName - Full KMS key version resource name, e.g. @@ -69,10 +105,8 @@ export const signWithKms = async ( ): Promise => { const debug = options?.debug ?? false; - const accessToken = await exchangeOidcForGcpToken( - identity.credential.id_token, - wifPool - ); + const wifScopedToken = await fetchOidcTokenForWif(identity, wifPool); + const accessToken = await exchangeOidcForGcpToken(wifScopedToken, wifPool); if (debug) { print2(