| A third target, and the seam that was already waiting for it f54ca45 nandi yesterday | 1 | // DPoP for the browser OAuth client: ES256 keys, proofs, PKCE. |
| 2 | // |
| 3 | // JavaScript rather than ClojureDart, deliberately. What this does is |
| 4 | // WebCrypto — generateKey, sign, digest, exportKey — and every one of those |
| 5 | // speaks in Promises, ArrayBuffers, JWK objects and JS algorithm records. |
| 6 | // Reaching them from cljd means dart:js_util for each value in both |
| 7 | // directions, and ArrayBuffer-to-bytes is the kind of conversion that fails |
| 8 | // at run time rather than at the compiler. Here it is the language's home |
| 9 | // ground, and what crosses the boundary is a string. |
| 10 | // |
| 11 | // So the contract is narrow on purpose: every function below takes strings |
| 12 | // and returns a string or a Promise of one. `frq.dpop.web` is the other half. |
| 13 | (function () { |
| 14 | 'use strict'; |
| 15 | |
| 16 | const enc = new TextEncoder(); |
| 17 | |
| 18 | const b64u = (buf) => |
| 19 | btoa(String.fromCharCode(...new Uint8Array(buf))) |
| 20 | .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); |
| 21 | |
| 22 | const ALG = { name: 'ECDSA', namedCurve: 'P-256' }; |
| 23 | const SIGN = { name: 'ECDSA', hash: 'SHA-256' }; |
| 24 | |
| 25 | // The key pair this client proves it holds. One per sign-in, and it must |
| 26 | // outlive a full-page redirect — the authorization leg leaves for the PDS |
| 27 | // and comes back as a fresh load — so it is kept as JWK in localStorage |
| 28 | // rather than as a non-extractable CryptoKey in IndexedDB. |
| 29 | // |
| 30 | // That is a deliberate trade and worth naming: an extractable key sits |
| 31 | // beside the access token it is bound to, in the same store, and anything |
| 32 | // that can read one can read the other. They share a lifetime and a blast |
| 33 | // radius, so the key being extractable costs nothing the token does not |
| 34 | // already cost — and IndexedDB interop through cljd would cost a great deal. |
| 35 | const KEY = 'frq:dpop:jwk'; |
| 36 | |
| 37 | let cached = null; |
| 38 | |
| 39 | async function keys() { |
| 40 | if (cached) return cached; |
| 41 | let jwk = null; |
| 42 | try { jwk = JSON.parse(localStorage.getItem(KEY)); } catch (e) { jwk = null; } |
| 43 | if (!jwk) { |
| 44 | const kp = await crypto.subtle.generateKey(ALG, true, ['sign', 'verify']); |
| 45 | jwk = await crypto.subtle.exportKey('jwk', kp.privateKey); |
| 46 | try { localStorage.setItem(KEY, JSON.stringify(jwk)); } catch (e) { /* private mode */ } |
| 47 | } |
| 48 | const priv = await crypto.subtle.importKey('jwk', jwk, ALG, true, ['sign']); |
| 49 | // The public half of the same key, which is what a proof carries in its |
| 50 | // header. Derived from the private JWK by dropping the private fields |
| 51 | // rather than exported separately, so the two cannot drift apart. |
| 52 | const pub = { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }; |
| 53 | cached = { priv, pub }; |
| 54 | return cached; |
| 55 | } |
| 56 | |
| 57 | async function jws(header, payload, priv) { |
| 58 | const h = b64u(enc.encode(JSON.stringify(header))); |
| 59 | const p = b64u(enc.encode(JSON.stringify(payload))); |
| 60 | // WebCrypto signs ECDSA as raw R||S, which is exactly what JOSE wants — |
| 61 | // no DER unwrapping, unlike most non-browser crypto libraries. |
| 62 | const sig = await crypto.subtle.sign(SIGN, priv, enc.encode(h + '.' + p)); |
| 63 | return h + '.' + p + '.' + b64u(sig); |
| 64 | } |
| 65 | |
| 66 | // One DPoP proof. `nonce` and `token` may be empty strings — cljd has no |
| 67 | // convenient undefined, and an empty string is the honest "not this time". |
| 68 | // |
| 69 | // `ath` is the access token's SHA-256, and it is what lets a proof be |
| 70 | // minted for a request this client will never make: freeq's SASL calls the |
| 71 | // PDS's getSession on our behalf, with our token and our proof, and the PDS |
| 72 | // checks that the proof names that token and that URL. |
| 73 | async function proof(htm, htu, nonce, token) { |
| 74 | const { priv, pub } = await keys(); |
| 75 | const payload = { |
| 76 | jti: crypto.randomUUID(), |
| 77 | htm: htm, |
| 78 | htu: htu, |
| 79 | iat: Math.floor(Date.now() / 1000), |
| 80 | }; |
| 81 | if (nonce) payload.nonce = nonce; |
| 82 | if (token) { |
| 83 | payload.ath = b64u(await crypto.subtle.digest('SHA-256', enc.encode(token))); |
| 84 | } |
| 85 | return jws({ typ: 'dpop+jwt', alg: 'ES256', jwk: pub }, payload, priv); |
| 86 | } |
| 87 | |
| 88 | // PKCE. The verifier is kept by the caller (it has to survive the redirect |
| 89 | // and `frq.io` already knows how to keep things); this only makes the pair. |
| 90 | function verifier() { |
| 91 | return b64u(crypto.getRandomValues(new Uint8Array(32))); |
| 92 | } |
| 93 | |
| 94 | async function challenge(verifier) { |
| 95 | return b64u(await crypto.subtle.digest('SHA-256', enc.encode(verifier))); |
| 96 | } |
| 97 | |
| 98 | function random(n) { |
| 99 | return b64u(crypto.getRandomValues(new Uint8Array(n))); |
| 100 | } |
| 101 | |
| 102 | // Forget the key. Called when a session is dropped: a DPoP key outliving |
| 103 | // the token it was bound to is a key with nothing to prove. |
| 104 | function forget() { |
| 105 | cached = null; |
| 106 | try { localStorage.removeItem(KEY); } catch (e) { /* nothing to do */ } |
| 107 | } |
| 108 | |
| 109 | // Callbacks rather than Promises, and node-style `cb(err, value)`. |
| 110 | // |
| 111 | // ClojureDart can only reach JavaScript through `dart:js` here: cljd's |
| 112 | // analyzer resolves that library and neither `dart:js_util` nor |
| 113 | // `dart:js_interop` ("Can't find Dart lib"), so there is no |
| 114 | // `promiseToFuture` to turn a thenable into a Future. What `dart:js` does |
| 115 | // give is automatic wrapping of a Dart closure passed as an argument — so |
| 116 | // the Promise is unwrapped on this side and the answer handed back through |
| 117 | // a function call, which crosses the boundary cleanly. |
| 118 | const cbify = (fn) => (...args) => { |
| 119 | const cb = args.pop(); |
| 120 | Promise.resolve(fn(...args)).then( |
| 121 | (v) => cb('', v), |
| 122 | (e) => cb(String(e && e.message ? e.message : e), ''), |
| 123 | ); |
| 124 | }; |
| 125 | |
| 126 | window.frqDpop = { |
| 127 | proof: cbify(proof), |
| 128 | challenge: cbify(challenge), |
| 129 | // Synchronous already: no crypto to await, just random bytes. |
| 130 | verifier: verifier, |
| 131 | random: random, |
| 132 | forget: forget, |
| 133 | }; |
| 134 | })(); |