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