← 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 }
}