wallet-kit / src / ardegazu / wallet / paylink.cljs
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
;; The `#pay=` deep link: a signed payment request as a URL.
;;
;;   https://banca.ardegazu.ro/#pay=<b64url(JSON.stringify(wpr))>
;;
;; Fragment-only, like every capability link in this suite: the request never
;; reaches a server, not banca's and not anyone's, because everything after the
;; `#` stays in the browser. A payee generates one and hands it over by whatever
;; means they already have — a chat message, a QR code, a piece of paper.
;;
;; Nothing here is a capability to SPEND. A request is a claim about what
;; someone wants and who they are, and it is worth exactly its signature: the
;; parse verifies before it returns, and returns null otherwise. Deciding to pay
;; is a separate act, and it produces a separate signed artifact.
;;
;; Its own module (dist/paylink.js) so a page that only shows "someone is asking
;; you for 12.00" does not pull LedgerStore and a localStorage-backed fold in
;; with it — the same reason social-kit's `./join` chip is not in its index.
(ns ardegazu.wallet.paylink
  (:require [ardegazu.id.crypto :as id-crypto]
            [ardegazu.wallet.consts :as consts]
            [ardegazu.wallet.shapes :as shapes])
  (:require-macros [ardegazu.wallet.macros :refer [oget]]))

(def ^:private td (js/TextDecoder.))

;; Canonical base64url and nothing else — the same discipline the signature and
;; digest shapes keep in consts.cljs, applied to the one place a request arrives
;; as a STRING that something might key on. Standard base64 (`+` `/` `=`) and
;; percent-encoding both decode to the same request under a lenient decoder,
;; which would make "the same link" a question with three answers for any cache,
;; dedup or "have I already shown this?" check a wallet builds on top. Nothing
;; honest emits them: base64url has no character a URL fragment escapes, so a
;; percent sequence in a `pay=` value is a re-encoder's fingerprint, not a user's
;; link. A length ≡ 1 (mod 4) cannot be the encoding of any byte string.
(def ^:private B64URL-RE #"^[A-Za-z0-9_-]+$")

(defn- b64url? [s]
  (and (string? s)
       (.test B64URL-RE s)
       (not (identical? 1 (mod (.-length s) 4)))))

(defn pay-request-url
  "A verified-on-arrival link for one payment request.

  BUILD-SIDE, so it REJECTS rather than returning null: `payRequestUrl(null)`
  used to hand back a perfectly well-formed link to the four bytes `null`, and a
  caller who got a string back had no way to learn otherwise until a stranger
  opened it. The request is re-shaped and the REBUILT one is what gets encoded,
  so a link always carries the documented key order."
  [wpr]
  ;; the link builder's own wall clock: a link is minted in the present
  (let [r (shapes/pay-request-shape wpr js/undefined)]
    (when (nil? r)
      (throw (js/Error. "wallet-kit: payRequestUrl needs a structurally valid wpr")))
    (str consts/PAY-BASE-URL "#" consts/PAY-FRAGMENT-KEY "="
         (id-crypto/to-b64url (id-crypto/utf8 (js/JSON.stringify r))))))

(defn- fragment-of
  "Everything after the first `#`, or the input itself when there is none — so a
  caller may pass `location.hash`, a bare fragment, or a whole URL."
  [s]
  (if-not (string? s)
    ""
    (let [i (.indexOf s "#")]
      (if (< i 0) s (.slice s (inc i))))))

(defn- pay-param
  "The `pay=` value out of a fragment that may carry other parameters. base64url
  has no `&` in its alphabet, so splitting on `&` can never cut a payload in
  half."
  [frag]
  (let [prefix (str consts/PAY-FRAGMENT-KEY "=")
        parts (.split frag "&")
        found (js-obj "v" nil)]
    (dotimes [i (.-length ^js parts)]
      (let [p (aget parts i)]
        (when (and (nil? (oget found "v")) (identical? 0 (.indexOf p prefix)))
          (unchecked-set found "v" (.slice p (.-length prefix))))))
    (oget found "v")))

(defn parse-pay-fragment
  "Decode + fully verify a `#pay=` fragment. null on ANY failure — no such
  parameter, a non-canonical or malformed encoding, bad JSON, or a request whose
  signature does not hold — and never throws."
  [fragment]
  (-> (js/Promise.resolve nil)
      (.then (fn [_]
               (let [v (pay-param (fragment-of fragment))]
                 (if-not (b64url? v)
                   nil
                   ;; a link is read by a person, now: the reader's wall clock is
                   ;; the right admission clock, and there is no fold here to
                   ;; make deterministic
                   (shapes/verify-pay-request
                    (js/JSON.parse (.decode td (id-crypto/from-b64url v))) js/undefined)))))
      (.catch (fn [_] nil))))

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/wallet-kit.git