Skip to content
← Public packages

@kentcdodds/audible

Personal Audible library app — browser re-auth, library, wishlist, NAS archive stub.

src/sign.ts

158 lines · 4.2 KB · TypeScript
/**
 * Audible ADP sign-request auth (same family as mobile apps / audible-cli).
 * Signs: METHOD\nPATH\nDATE\nBODY\nADP_TOKEN with RSA-SHA256.
 */

const encoder = new TextEncoder()

function pemToDer(pem: string): ArrayBuffer {
	const cleaned = pem
		.replace(/-----BEGIN [^-]+-----/g, '')
		.replace(/-----END [^-]+-----/g, '')
		.replace(/\s+/g, '')
	const binary = atob(cleaned)
	const bytes = new Uint8Array(binary.length)
	for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
	return bytes
}

/** Wrap PKCS#1 RSAPrivateKey DER in PKCS#8 PrivateKeyInfo for WebCrypto. */
function pkcs1ToPkcs8(pkcs1: ArrayBuffer): ArrayBuffer {
	const inner = new Uint8Array(pkcs1)
	const algId = new Uint8Array([
		0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01,
		0x01, 0x05, 0x00,
	])
	const version = new Uint8Array([0x02, 0x01, 0x00])
	const octetHeader = encodeLength(0x04, inner.length)
	const contentLen =
		version.length + algId.length + octetHeader.length + inner.length
	const seqHeader = encodeLength(0x30, contentLen)
	const out = new Uint8Array(
		seqHeader.length +
			version.length +
			algId.length +
			octetHeader.length +
			inner.length,
	)
	let offset = 0
	out.set(seqHeader, offset)
	offset += seqHeader.length
	out.set(version, offset)
	offset += version.length
	out.set(algId, offset)
	offset += algId.length
	out.set(octetHeader, offset)
	offset += octetHeader.length
	out.set(inner, offset)
	return out
}

function encodeLength(tag: number, length: number): Uint8Array {
	if (length < 0x80) return new Uint8Array([tag, length])
	if (length < 0x100) return new Uint8Array([tag, 0x81, length])
	if (length < 0x10000)
		return new Uint8Array([tag, 0x82, (length >> 8) & 0xff, length & 0xff])
	if (length < 0x1000000)
		return new Uint8Array([
			tag,
			0x83,
			(length >> 16) & 0xff,
			(length >> 8) & 0xff,
			length & 0xff,
		])
	return new Uint8Array([
		tag,
		0x84,
		(length >> 24) & 0xff,
		(length >> 16) & 0xff,
		(length >> 8) & 0xff,
		length & 0xff,
	])
}

function ensurePem(key: string): string {
	const trimmed = key.trim()
	if (/BEGIN (?:RSA )?PRIVATE KEY/.test(trimmed)) return trimmed
	return `-----BEGIN RSA PRIVATE KEY-----\n${trimmed}\n-----END RSA PRIVATE KEY-----`
}

function bytesToBase64(bytes: Uint8Array): string {
	let binary = ''
	for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!)
	return btoa(binary)
}

async function importPrivateKey(pemOrRaw: string): Promise<CryptoKey> {
	const pem = ensurePem(pemOrRaw)
	const isPkcs1 = /BEGIN RSA PRIVATE KEY/.test(pem)
	const der = isPkcs1 ? pkcs1ToPkcs8(pemToDer(pem)) : pemToDer(pem)
	try {
		return await crypto.subtle.importKey(
			'pkcs8',
			der,
			{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
			false,
			['sign'],
		)
	} catch (first) {
		try {
			const wrapped = pkcs1ToPkcs8(pemToDer(pem))
			return await crypto.subtle.importKey(
				'pkcs8',
				wrapped,
				{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
				false,
				['sign'],
			)
		} catch {
			throw new Error(
				`Failed to import Audible device_private_key for ADP signing: ${String(
					(first as Error)?.message ?? first,
				)}`,
			)
		}
	}
}

function utcIsoNow(): string {
	const iso = new Date().toISOString()
	return iso.replace(/\.\d{3}Z$/, (m) => `${m.slice(0, -1)}000Z`)
}

export type SignedHeaders = {
	'x-adp-token': string
	'x-adp-alg': string
	'x-adp-signature': string
}

/**
 * Build ADP signed headers for an Audible API request.
 * `path` must include the path and query string (e.g. `/1.0/library?page=1`).
 */
export async function signRequest(input: {
	method: string
	path: string
	body?: string
	adpToken: string
	devicePrivateKey: string
	date?: string
}): Promise<SignedHeaders> {
	const method = input.method.toUpperCase()
	const path = input.path.startsWith('/') ? input.path : `/${input.path}`
	const body = input.body ?? ''
	const date = input.date ?? utcIsoNow()
	const data = `${method}\n${path}\n${date}\n${body}\n${input.adpToken}`
	const key = await importPrivateKey(input.devicePrivateKey)
	const signature = await crypto.subtle.sign(
		'RSASSA-PKCS1-v1_5',
		key,
		encoder.encode(data),
	)
	const b64 = bytesToBase64(new Uint8Array(signature))
	return {
		'x-adp-token': input.adpToken,
		'x-adp-alg': 'SHA256withRSA:1.0',
		'x-adp-signature': `${b64}:${date}`,
	}
}