Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← Public packages

@noah/bitwarden-secrets

Use Bitwarden Secrets Manager with Kody to securely store and use secrets in packages and ad hoc scripts, without ever exposing them to an AI agent.

secret-provider.ts

343 lines · 10.2 KB · TypeScript
/**
 * Sealed Bitwarden Secrets Manager provider.
 * Invoked only from the Kody fetch boundary with action "canonicalize" | "resolve".
 * Never log doorSecretValue or resolved values.
 */

const UUID =
	/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const FIELDS = new Set(['value', 'key', 'note', 'name'])

type ProviderInput = {
	action: 'canonicalize' | 'resolve'
	ref?: string
	canonicalRef?: string
	doorSecretName?: string
	doorSecretValue?: string
	config?: Record<string, string>
}

function fail(message: string): never {
	throw new Error(message)
}

function parseRef(raw: string | undefined): { id: string; field: string } {
	const ref = String(raw ?? '').trim()
	if (!ref) fail('Bitwarden ref is empty. Use i/<secret-uuid>/value.')

	const stripped = ref.replace(/^bitwarden:\/\//i, '')
	let id = ''
	let field = 'value'

	const item = stripped.match(/^i\/([^/]+)(?:\/([^/]+))?$/i)
	if (item) {
		id = item[1]
		field = (item[2] || 'value').toLowerCase()
	} else if (UUID.test(stripped)) {
		id = stripped
	} else {
		fail(
			`Bitwarden ref "${stripped}" is not canonical. Use i/<secret-uuid>/value (UUID from Secrets Manager).`,
		)
	}

	if (!UUID.test(id)) {
		fail(`Bitwarden item id must be a UUID, got "${id}".`)
	}
	if (field === 'name') field = 'key'
	if (!FIELDS.has(field)) {
		fail(`Unknown Bitwarden field "${field}". Use value, key, or note.`)
	}
	return { id: id.toLowerCase(), field }
}

function canonicalRefOf(input: { id: string; field: string }) {
	return `i/${input.id}/${input.field}`
}

function b64ToBytes(value: string): Uint8Array {
	const bin = atob(value)
	const out = new Uint8Array(bin.length)
	for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
	return out
}

function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
	if (a.length !== b.length) return false
	let diff = 0
	for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]
	return diff === 0
}

async function hmacSha256(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {
	const cryptoKey = await crypto.subtle.importKey(
		'raw',
		key,
		{ name: 'HMAC', hash: 'SHA-256' },
		false,
		['sign'],
	)
	return new Uint8Array(await crypto.subtle.sign('HMAC', cryptoKey, data))
}

async function hkdfExpand64(prk: Uint8Array, info: Uint8Array): Promise<Uint8Array> {
	const n = 2
	const out = new Uint8Array(64)
	let previous = new Uint8Array(0)
	let offset = 0
	for (let i = 1; i <= n; i++) {
		const input = new Uint8Array(previous.length + info.length + 1)
		input.set(previous, 0)
		input.set(info, previous.length)
		input[input.length - 1] = i
		previous = await hmacSha256(prk, input)
		out.set(previous, offset)
		offset += previous.length
	}
	return out
}

/**
 * HKDF-Expand producing exactly 32 bytes (a single HMAC block).
 *
 * @param prk Pseudorandom key used as the HMAC key
 * @param info Context string mixed into the expansion
 * @returns 32 derived bytes
 */
async function hkdfExpand32(prk: Uint8Array, info: string): Promise<Uint8Array> {
	const infoBytes = new TextEncoder().encode(info)
	const input = new Uint8Array(infoBytes.length + 1)
	input.set(infoBytes, 0)
	input[infoBytes.length] = 1
	return hmacSha256(prk, input)
}

/**
 * Stretch a 32-byte Bitwarden key into separate encryption and MAC keys,
 * matching the Bitwarden SDK's stretchKey (HKDF-Expand with "enc" and "mac").
 *
 * @param key32 The 32-byte key to stretch
 * @returns 64 bytes: encryption key followed by MAC key
 */
async function stretchKey(key32: Uint8Array): Promise<Uint8Array> {
	const out = new Uint8Array(64)
	out.set(await hkdfExpand32(key32, 'enc'), 0)
	out.set(await hkdfExpand32(key32, 'mac'), 32)
	return out
}

async function deriveShareableKey(raw16: Uint8Array): Promise<Uint8Array> {
	const prk = await hmacSha256(
		new TextEncoder().encode('bitwarden-accesstoken'),
		raw16,
	)
	return hkdfExpand64(prk, new TextEncoder().encode('sm-access-token'))
}

async function decryptCipherString(
	cipher: string,
	key64: Uint8Array,
): Promise<Uint8Array> {
	const trimmed = String(cipher ?? '').trim()
	const match = trimmed.match(/^2\.([^|]+)\|([^|]+)\|([^|]+)$/)
	if (!match) {
		fail('Bitwarden payload is not a type-2 cipher string.')
	}
	const iv = b64ToBytes(match[1])
	const ct = b64ToBytes(match[2])
	const mac = b64ToBytes(match[3])
	const encKey = key64.slice(0, 32)
	const macKey = key64.slice(32, 64)
	const macData = new Uint8Array(iv.length + ct.length)
	macData.set(iv, 0)
	macData.set(ct, iv.length)
	const expected = await hmacSha256(macKey, macData)
	if (!timingSafeEqual(expected, mac)) {
		fail('Bitwarden MAC check failed. The door-key token may be wrong.')
	}
	const cryptoKey = await crypto.subtle.importKey(
		'raw',
		encKey,
		{ name: 'AES-CBC' },
		false,
		['decrypt'],
	)
	const plain = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ct)
	return new Uint8Array(plain)
}

function parseAccessToken(token: string): {
	clientId: string
	clientSecret: string
	rawKey: Uint8Array
} {
	const trimmed = String(token ?? '').trim()
	const colon = trimmed.indexOf(':')
	if (colon < 0) {
		fail(
			'bitwarden-token is not a Secrets Manager access token (expected 0.<uuid>.<secret>:<key>).',
		)
	}
	const left = trimmed.slice(0, colon)
	const right = trimmed.slice(colon + 1)
	const parts = left.split('.')
	if (parts.length !== 3 || parts[0] !== '0' || !UUID.test(parts[1]) || !parts[2]) {
		fail(
			'bitwarden-token is not a Secrets Manager access token (expected 0.<uuid>.<secret>:<key>).',
		)
	}
	let rawKey: Uint8Array
	try {
		rawKey = b64ToBytes(right)
	} catch {
		fail('bitwarden-token encryption key is not valid base64.')
	}
	if (rawKey.length !== 16) {
		fail('bitwarden-token encryption key must decode to 16 bytes.')
	}
	return { clientId: parts[1], clientSecret: parts[2], rawKey }
}

function parseHosts(config: Record<string, string> | undefined): string[] {
	const raw = config?.hosts ?? config?.allowedHosts ?? ''
	const hosts = String(raw)
		.split(',')
		.map((h) => h.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, ''))
		.filter(Boolean)
	if (hosts.length === 0) {
		fail(
			'Bind config.hosts with the destination hostnames this secret may be sent to (comma-separated), for example api.example.com.',
		)
	}
	return [...new Set(hosts)]
}

function origin(config: Record<string, string> | undefined, key: string, fallback: string) {
	const value = String(config?.[key] ?? '').trim()
	return value.replace(/\/$/, '') || fallback
}

async function loginAndDecryptOrgKey(input: {
	token: string
	identityUrl: string
}): Promise<{ jwt: string; orgKey: Uint8Array }> {
	const parsed = parseAccessToken(input.token)
	const body = new URLSearchParams({
		grant_type: 'client_credentials',
		scope: 'api.secrets',
		client_id: parsed.clientId,
		client_secret: parsed.clientSecret,
	})
	const res = await fetch(`${input.identityUrl}/connect/token`, {
		method: 'POST',
		headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
		body,
	})
	if (!res.ok) {
		fail(
			`Bitwarden identity rejected the machine-account token (${res.status}). Check bitwarden-token and US/EU host config.`,
		)
	}
	const json = (await res.json()) as {
		access_token?: string
		encrypted_payload?: string
	}
	if (!json.access_token || !json.encrypted_payload) {
		fail('Bitwarden identity response missing access_token or encrypted_payload.')
	}
	const shareable = await deriveShareableKey(parsed.rawKey)
	const payloadBytes = await decryptCipherString(json.encrypted_payload, shareable)
	const payloadText = new TextDecoder().decode(payloadBytes)
	/* only JSON.parse may fail here; decrypt errors must surface, not be retried */
	let parsedPayload: { encryptionKey?: string } | undefined
	try {
		parsedPayload = JSON.parse(payloadText) as { encryptionKey?: string }
	} catch {
		parsedPayload = undefined
	}

	let orgKey: Uint8Array
	if (parsedPayload) {
		const encKey = String(parsedPayload.encryptionKey ?? '').trim()
		if (!encKey) fail('Decrypted Bitwarden payload had no encryptionKey.')
		orgKey = encKey.startsWith('2.')
			? await decryptCipherString(encKey, shareable)
			: b64ToBytes(encKey)
	} else if (payloadText.startsWith('2.')) {
		orgKey = await decryptCipherString(payloadText, shareable)
	} else {
		orgKey = payloadBytes
	}
	if (orgKey.length === 32) {
		orgKey = await stretchKey(orgKey)
	}
	if (orgKey.length !== 64) {
		fail('Decrypted Bitwarden organization key had an unexpected length.')
	}
	return { jwt: json.access_token, orgKey }
}

async function resolveSecret(input: {
	jwt: string
	orgKey: Uint8Array
	apiUrl: string
	id: string
	field: string
}): Promise<string> {
	const res = await fetch(`${input.apiUrl}/secrets/${input.id}`, {
		headers: { Authorization: `Bearer ${input.jwt}` },
	})
	if (res.status === 404) {
		fail(
			`Bitwarden secret ${input.id} was not found or the machine account cannot read it. Assign it to the Kody project.`,
		)
	}
	if (!res.ok) {
		fail(`Bitwarden secrets API returned ${res.status}.`)
	}
	const json = (await res.json()) as {
		key?: string
		value?: string
		note?: string
	}
	const encField =
		input.field === 'key' || input.field === 'name'
			? json.key
			: input.field === 'note'
				? json.note
				: json.value
	if (!encField) {
		fail(`Bitwarden secret ${input.id} has no ${input.field} field.`)
	}
	const plain = await decryptCipherString(encField, input.orgKey)
	return new TextDecoder().decode(plain)
}

export default async function secretProvider(input: ProviderInput = { action: 'canonicalize' }) {
	const parsed = parseRef(input.canonicalRef || input.ref)
	const canonicalRef = canonicalRefOf(parsed)
	if (input.action === 'canonicalize') {
		return { canonicalRef }
	}
	if (input.action !== 'resolve') {
		fail(`Unsupported secret provider action "${String(input.action)}".`)
	}
	const door = String(input.doorSecretValue ?? '').trim()
	if (!door) {
		fail('Door-key secret bitwarden-token is empty.')
	}
	const config = input.config ?? {}
	const hosts = parseHosts(config)
	const identityUrl = origin(config, 'identityUrl', 'https://identity.bitwarden.com')
	const apiUrl = origin(config, 'apiUrl', 'https://api.bitwarden.com')
	const session = await loginAndDecryptOrgKey({ token: door, identityUrl })
	const value = await resolveSecret({
		jwt: session.jwt,
		orgKey: session.orgKey,
		apiUrl,
		id: parsed.id,
		field: parsed.field,
	})
	if (!value) fail('Resolved Bitwarden secret was empty.')
	return { value, hosts }
}