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..5adccfac --- /dev/null +++ b/src/plugins/self-hosted/kms.ts @@ -0,0 +1,146 @@ +/** 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 { 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"; +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 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, + 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. + * + * 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. + * `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; + + const wifScopedToken = await fetchOidcTokenForWif(identity, wifPool); + const accessToken = await exchangeOidcForGcpToken(wifScopedToken, wifPool); + + 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 re-authenticate with '${identity.org.slug}' and try again.`; + } + 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..0a9d672a 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,39 @@ 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) { + 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); }, 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 = {