Skip to content
This repository was archived by the owner on Nov 20, 2025. It is now read-only.
Closed
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
184 changes: 184 additions & 0 deletions src/auth/agentidentity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import * as crypto from 'crypto';
import * as fs from 'fs';
import {log as makeLog} from 'google-logging-utils';

const log = makeLog('google-auth-library:agentidentity');

const CERT_CONFIG_ENV_VAR = 'GOOGLE_API_CERTIFICATE_CONFIG';
const PREVENT_SHARING_ENV_VAR =
'GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES';

const AGENT_IDENTITY_SPIFFE_PATTERNS = [
/^agents\.global\.org-\d+\.system\.id\.goog$/,
/^agents\.global\.proj-\d+\.system\.id\.goog$/,
];

// Polling configuration
// Total timeout: 30 seconds
// Phase 1: 5 seconds of fast polling (every 0.1s) = 50 cycles
// Phase 2: 25 seconds of slow polling (every 0.5s) = 50 cycles
const FAST_POLL_INTERVAL_MS = 100;
const FAST_POLL_CYCLES = 50;
const SLOW_POLL_INTERVAL_MS = 500;
const SLOW_POLL_CYCLES = 50;

/**
* Interface for the certificate configuration file.
* Matches the structure expected by CertificateSubjectTokenSupplier but strictly for Workload.
*/
interface CertificateConfigFile {
cert_configs: {
workload?: {
cert_path: string;
};
};
}

/**
* Helper function to delay execution.
* @param ms Time to sleep in milliseconds.
*/
async function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

/**
* Retrieves the path to the agent identity certificate by polling the configuration file.
* @returns The path to the certificate file, or null if not configured.
* @throws Error if the configuration or certificate file cannot be found after the timeout.
*/
async function getAgentIdentityCertificatePath(): Promise<string | null> {
const configPath = process.env[CERT_CONFIG_ENV_VAR];
if (!configPath) {
return null;
}

let hasLoggedWarning = false;

for (let i = 0; i < FAST_POLL_CYCLES + SLOW_POLL_CYCLES; i++) {
try {
if (fs.existsSync(configPath)) {
const configContent = await fs.promises.readFile(configPath, 'utf-8');
const config = JSON.parse(configContent) as CertificateConfigFile;
const certPath = config.cert_configs?.workload?.cert_path;

if (certPath && fs.existsSync(certPath)) {
return certPath;
}
}
} catch (error) {
// Ignore errors during polling, will retry.
}

if (!hasLoggedWarning) {
log.warn(
`Certificate config file not found at ${configPath} (from ${CERT_CONFIG_ENV_VAR} environment variable). ` +
'Retrying for up to 30 seconds.',
);
hasLoggedWarning = true;
}

const interval =
i < FAST_POLL_CYCLES ? FAST_POLL_INTERVAL_MS : SLOW_POLL_INTERVAL_MS;
await sleep(interval);
}

throw new Error(
'Certificate config or certificate file not found after multiple retries. ' +
'Token binding protection is failing. You can turn off this protection by setting ' +
`${PREVENT_SHARING_ENV_VAR} to false to fall back to unbound tokens.`,
);
}

/**
* Parses a PEM-encoded certificate.
* @param certBuffer The certificate data.
* @returns The parsed X509Certificate object.
*/
function parseCertificate(certBuffer: Buffer): crypto.X509Certificate {
return new crypto.X509Certificate(certBuffer);
}

/**
* Checks if the certificate is an Agent Identity certificate by inspecting the SPIFFE ID in the SAN.
* @param cert The parsed certificate.
* @returns True if it matches an Agent Identity pattern.
*/
function isAgentIdentityCertificate(cert: crypto.X509Certificate): boolean {
const san = cert.subjectAltName;
if (!san) {
return false;
}

// Node's X509Certificate.subjectAltName returns a string like "URI:spiffe://..., DNS:..."
// We use a regex to find all SPIFFE URIs and check their trust domains.
const uriMatches = san.matchAll(/URI:spiffe:\/\/([^/]+)\/.*?(?:,|$)/g);
for (const match of uriMatches) {
const trustDomain = match[1];
for (const pattern of AGENT_IDENTITY_SPIFFE_PATTERNS) {
if (pattern.test(trustDomain)) {
return true;
}
}
}

return false;
}

/**
* Calculates the unpadded base64url-encoded SHA256 fingerprint of the certificate.
* @param cert The parsed certificate.
* @returns The fingerprint string.
*/
function calculateCertificateFingerprint(cert: crypto.X509Certificate): string {
return crypto.createHash('sha256').update(cert.raw).digest('base64url');
}

/**
* Main entry point to get the bind certificate fingerprint if appropriate.
* Checks opt-out env var, polls for cert, validates it's an Agent Identity cert, and returns the fingerprint.
* @returns The fingerprint if a bound token should be requested, otherwise undefined.
*/
export async function getBindCertificateFingerprint(): Promise<
string | undefined
> {
// 1. Check opt-out
if (process.env[PREVENT_SHARING_ENV_VAR]?.toLowerCase() === 'false') {
return undefined;
}

// 2. Get certificate path (polling if necessary)
const certPath = await getAgentIdentityCertificatePath();
if (!certPath) {
return undefined;
}

// 3. Read and parse certificate
// We use fs.promises.readFile here. The existence was checked in polling,
// but it might have disappeared or be unreadable.
// We let standard IO errors propagate if it fails now after polling succeeded.
const certBuffer = await fs.promises.readFile(certPath);
const cert = parseCertificate(certBuffer);

// 4. Check if it's an Agent Identity certificate
if (!isAgentIdentityCertificate(cert)) {
return undefined;
}

// 5. Calculate and return fingerprint
return calculateCertificateFingerprint(cert);
}
11 changes: 11 additions & 0 deletions src/auth/computeclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
OAuth2Client,
OAuth2ClientOptions,
} from './oauth2client';
import * as agentIdentity from './agentidentity';

export interface ComputeOptions extends OAuth2ClientOptions {
/**
Expand Down Expand Up @@ -75,6 +76,16 @@ export class Compute extends OAuth2Client {
scopes: this.scopes.join(','),
};
}

// Check for Agent Identity certificate and add fingerprint if present
const fingerprint = await agentIdentity.getBindCertificateFingerprint();
if (fingerprint) {
if (!instanceOptions.params) {
instanceOptions.params = {};
}
instanceOptions.params.bindCertificateFingerprint = fingerprint;
}

data = await gcpMetadata.instance(instanceOptions);
} catch (e) {
if (e instanceof GaxiosError) {
Expand Down
20 changes: 20 additions & 0 deletions test/fixtures/external-account-cert/agentic_cert.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDPjCCAiagAwIBAgIUCYeV4dwM29T5yucwWrSWlOC9wwYwDQYJKoZIhvcNAQEL
BQAwIjEgMB4GA1UEAwwXVGVzdCBTUElGRkUgQ2VydGlmaWNhdGUwHhcNMjUxMTA3
MDEyMjQ4WhcNMzUxMTA1MDEyMjQ4WjAiMSAwHgYDVQQDDBdUZXN0IFNQSUZGRSBD
ZXJ0aWZpY2F0ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDr1Bzo
KtzIZB35acQ+mpk6yScf59AnwHjjgNCMbC7kq2DSUfQzTlu9Kd0uUB6O7DmJ73D8
Pge4XLE/Q1B6dI6DzJx7lhPoC1BiQFUGJ4Cu+TbbdlK3RiXNAZYjIj9UKP7DejCY
WRgFB+PYyLczEkByvU9cy7Z9Uuufsn6LnYu7qOG+DcRSE41ThurZxQ14OWvLfjZm
lhZXam4VBBli8Qku8qFIALe78kpy+hp2YCRnK84amATwPpGprRACp9WVka2JDYKD
LY0OoYlyAQel6960aS11N3/2v0cvx03/LM5+Yj+DTvdyb2Mk/NVeRIKo8cM5YwPn
sTLCf1cdxJvseRMCAwEAAaNsMGowSQYDVR0RBEIwQIY+c3BpZmZlOi8vYWdlbnRz
Lmdsb2JhbC5wcm9qLTEyMzQ1LnN5c3RlbS5pZC5nb29nL3Rlc3Qtd29ya2xvYWQw
HQYDVR0OBBYEFPvn+KXBcrYCmAMopkghUczUx/IkMA0GCSqGSIb3DQEBCwUAA4IB
AQCbwd9RMFkr1C9AEgnLMWd1l9ciBbK0t1Sydu3eA0SNm2w6E58ih8O+huo6eGsM
7z0E4i7YuaHnTdah/lPMqd75YRO57GSRbvi2g+yPyw6XdFl9HCHwF4WARdTF4Nkf
1c1WstvBXb24PSSQQdy9un72ZG6f9fSVQrko6hchv8Rg6yyBTFE8APPkeMR/EJtV
cnXg4CgsQIPHxJGQrhNvQhF7VLZePlTass4bqTqTYXwAte2jX/KW3qlW/t/v4AJe
/q+pcXmNIvwRpT8zYA5tJHIDVJ+v9pWZA+nhoD9Qtr7FVHfB4mdNuFv7bMPoXN0+
mCPzP08MnjgbX7zRETVlblrx
-----END CERTIFICATE-----
Loading
Loading