Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/drivers/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export const fetchAdminLsCommand = async <T>(

export const submitPublicKey = async <T>(
authn: Authn,
args: { publicKey: string; requestId: string },
args: { publicKey: string; requestId: string; signature?: string },
debug?: boolean
) =>
authFetch<T>(authn, {
Expand All @@ -126,6 +126,7 @@ export const submitPublicKey = async <T>(
body: JSON.stringify({
requestId: args.requestId,
publicKey: args.publicKey,
...(args.signature ? { signature: args.signature } : {}),
}),
debug,
});
Expand Down
146 changes: 146 additions & 0 deletions src/plugins/self-hosted/kms.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
**/
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<string> => {
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<string> => {
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<string> => {
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;
};
38 changes: 36 additions & 2 deletions src/plugins/self-hosted/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://www.gnu.org/licenses/>.
**/
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;
Expand Down Expand Up @@ -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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a little more convenient from a backend perspective to have this in the self-hosted integration config doc instead.

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<string, GcloudIamWriteItem> };
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) => {
Expand Down
1 change: 1 addition & 0 deletions src/plugins/ssh/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type SshItemConfig = {

export type SshConfig = {
"iam-write": Record<string, SshItemConfig>;
kmsKeyResourceName?: string;
};

export type CommonSshPermissionSpec = {
Expand Down
Loading