/** * 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.buffer } /** 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.buffer } 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 { 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 { 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}`, } }