// AtprotoClient — secp256k1 / ES256K identity for the browser (the atproto-native "pgv3 method").
//
// The browser analog of pear-git-v3's anproto-client.js, but for atproto's own key world: secp256k1
// signing keys and ES256K signed assertions (NOT ed25519/anproto, NOT keet). A browser holds a
// per-origin SUB-KEY; the DID's #atproto key DELEGATES it (a signed assertion minted once by the
// keyholder, the hpp); on login the sub-key signs the server's fresh challenge and favela verifies
// KEYLESSLY against the DID's PLC-published key (atproto-identity/lib/web-auth.js). favela holds no key.
//
// Byte-compatible with the SERVER's atproto-identity/lib/auth.js: signAssertion here produces the SAME
// `<b64url(head)>.<b64url(body)>.<b64url(sig)>` ES256K JWT the server's verifyAssertion consumes. We use
// the standalone @noble/secp256k1 (vendored, pure-JS, works on plain HTTP) — its compact low-S sigs and
// compressed pubkeys interoperate exactly with the server's @noble/curves secp256k1 (same author,
// same RFC6979/low-S). Proven by test/browser-auth-client.test.mjs.
//
// Universal (browser / Node / Bare): base64 falls back to Buffer when btoa is absent, so the same file
// unit-tests under Node. generateSubKey() is browser-only (needs crypto.getRandomValues).
import * as secp from './vendor/noble-secp256k1.js'
import { sha256 } from './vendor/sha256-pure.js'
// ── HMAC-SHA256 from the pure-JS sha256 — wired into noble so its RFC6979 deterministic signer runs
// SYNC with no crypto.subtle dependency (works on plain HTTP LAN). Mirrors how pgv3's anproto-client
// wires ed.etc.sha512Sync from a pure sha512. ──
const HMAC_BLOCK = 64 // sha256 block size
function hmacSha256 (key, msg) {
let k = key
if (k.length > HMAC_BLOCK) k = sha256(k)
const block = new Uint8Array(HMAC_BLOCK)
block.set(k)
const ipad = new Uint8Array(HMAC_BLOCK)
const opad = new Uint8Array(HMAC_BLOCK)
for (let i = 0; i < HMAC_BLOCK; i++) { ipad[i] = block[i] ^ 0x36; opad[i] = block[i] ^ 0x5c }
const inner = sha256(concatBytes(ipad, msg))
return sha256(concatBytes(opad, inner))
}
secp.etc.hmacSha256Sync = (key, ...msgs) => hmacSha256(key, concatBytes(...msgs))
// ── encoding helpers (universal) ──
function concatBytes (...arrs) {
let n = 0
for (const a of arrs) n += a.length
const out = new Uint8Array(n)
let o = 0
for (const a of arrs) { out.set(a, o); o += a.length }
return out
}
function utf8 (str) { return new TextEncoder().encode(str) }
function bytesToHex (bytes) { return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('') }
function hexToBytes (hex) {
const clean = String(hex).toLowerCase().replace(/[^0-9a-f]/g, '')
const out = new Uint8Array(clean.length >> 1)
for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.substr(i * 2, 2), 16)
return out
}
// base64url over bytes, matching the server's b64url (base64 → +/ to -_, strip padding).
function b64url (bytes) {
let b64
if (typeof btoa !== 'undefined') {
let bin = ''
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
b64 = btoa(bin)
} else {
b64 = Buffer.from(bytes).toString('base64')
}
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
// Generate a fresh browser sub-key (secp256k1). Browser-only: randomPrivateKey → crypto.getRandomValues.
// Returns { privHex, subKey } where subKey is the 33-byte COMPRESSED pubkey hex (what delegate() names).
export function generateSubKey () {
const priv = secp.utils.randomPrivateKey()
const pub = secp.getPublicKey(priv, true) // compressed
return { privHex: bytesToHex(priv), subKey: bytesToHex(pub) }
}
// The compressed-pubkey hex for a private key (used to re-derive subKey from a stored priv).
export function subKeyOf (privHex) { return bytesToHex(secp.getPublicKey(hexToBytes(privHex), true)) }
// Sign an ES256K assertion — byte-for-byte the server's atproto-identity signAssertion shape:
// head = b64url(utf8(JSON({alg:'ES256K',typ:'JWT'}))), body = b64url(utf8(JSON(payload))),
// sig = low-S compact secp256k1 over sha256(utf8(head.body)); token = head.body.sig.
export function signAssertion (payload, privHex) {
const head = b64url(utf8(JSON.stringify({ alg: 'ES256K', typ: 'JWT' })))
const body = b64url(utf8(JSON.stringify(payload)))
const signingInput = `${head}.${body}`
const digest = sha256(utf8(signingInput))
const sig = secp.sign(digest, hexToBytes(privHex)).toCompactRawBytes()
return `${signingInput}.${b64url(sig)}`
}
export { bytesToHex, hexToBytes }
export default { generateSubKey, subKeyOf, signAssertion }