id-kit / src / ardegazu / id / xkey.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
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
;; ported-from: src/xkey.ts @ v1.1.0
;;
;; X25519 encryption identity + sealed-box "wraps", salt-parameterized.
;;
;; Every Ed25519 identity deterministically owns an X25519 keypair:
;;   xSeed = HKDF-SHA256(ikm = identity seed, salt = UTF8(salt), info = "id-x25519|v1")
;; so the same seed yields the same encryption identity on every device.
;; The X25519 pub is certified by an Ed25519 signature (domain-separated,
;; not replayable across salts), verified before anyone seals anything to it.
;;
;; The cert domain tag is the literal "board-x25519|v1" for historical wire
;; compatibility with deployed board grants; separation comes from the salt.
;;
;; Curve arithmetic uses @noble/curves — an EXTERNAL import (optional
;; peerDependency): only this module references it, so apps that never import
;; "ardegazu-id-kit/xkey" never need it. AEAD stays WebCrypto AES-GCM.
;;
;; wrap(to): HPKE-base-mode shaped sealed box —
;;   (ephPriv, ephPub) fresh; ss = X25519(ephPriv, toXPub)
;;   kW = HKDF(ss, salt, info="wrap|v1|"+ctx+"|"+toIdPub+"|"+ephPub)
;;   ct = AES-GCM(kW, iv, payload, AAD "v1|"+ctx+"|wrap|"+toIdPub)
;; `ctx` is a caller-chosen context string so a wrap can never be replayed
;; into a different context. Sender authenticity is the caller's job.
(ns ardegazu.id.xkey
  (:require ["@noble/curves/ed25519" :refer (x25519)]
            [ardegazu.id.crypto :as c]
            [ardegazu.id.identity :as identity]
            [shadow.cljs.modern :refer (js-await)]))

;; Suite-wide salt: the ONE encryption identity all cross-app features share.
(def SUITE-SALT "ardegazu.ro/id/v1")

(defn derive-x-key-pair
  "Derive the deterministic X25519 keypair from the 32-byte identity seed."
  [identity-seed-b64url salt]
  (-> (js/Promise.resolve nil)
      (.then
       (fn [_]
         (let [seed (c/from-b64url identity-seed-b64url)]
           (js-await [ikm (js/crypto.subtle.importKey "raw" seed "HKDF" false #js ["deriveBits"])]
             (js-await [bits (js/crypto.subtle.deriveBits
                              (js-obj "name" "HKDF"
                                      "hash" "SHA-256"
                                      "salt" (c/utf8 salt)
                                      "info" (c/utf8 "id-x25519|v1"))
                              ikm 256)]
               (let [priv (js/Uint8Array. bits)]
                 (js-obj "priv" priv
                         "pubB64" (c/to-b64url (.getPublicKey ^js x25519 priv)))))))))))

(defn derive-suite-x-key-pair
  "The suite-wide encryption keypair (what cross-app envelopes seal to)."
  [identity-seed-b64url]
  (derive-x-key-pair identity-seed-b64url SUITE-SALT))

(defn- cert-bytes [salt id-pub x-pub]
  (c/utf8 (str "board-x25519|v1|" salt "|" id-pub "|" x-pub)))

(defn sign-x-cert
  "Sign our X25519 pub with the Ed25519 identity (the \"cert\")."
  [id salt x-pub]
  (.then (identity/sign-raw-impl id (cert-bytes salt (unchecked-get id "publicKeyB64") x-pub))
         (fn [sig] (c/to-b64url sig))))

(defn verify-x-cert
  "Verify a peer's cert binds xPub to their Ed25519 identity."
  [salt id-pub x-pub x-sig-b64url]
  (identity/verify-raw id-pub x-sig-b64url (cert-bytes salt id-pub x-pub)))

(defn- wrap-key [ss salt ctx to-id-pub eph-pub-b64]
  (js-await [ikm (js/crypto.subtle.importKey "raw" ss "HKDF" false #js ["deriveKey"])]
    (js/crypto.subtle.deriveKey
     (js-obj "name" "HKDF"
             "hash" "SHA-256"
             "salt" (c/utf8 salt)
             "info" (c/utf8 (str "wrap|v1|" ctx "|" to-id-pub "|" eph-pub-b64)))
     ikm
     (js-obj "name" "AES-GCM" "length" 256)
     false
     #js ["encrypt" "decrypt"])))

(defn wrap-to
  "Seal `payload` to an identity's certified X25519 pub, bound to `ctx`."
  [salt ctx to-id-pub to-x-pub-b64 payload]
  (-> (js/Promise.resolve nil)
      (.then
       (fn [_]
         (let [eph-priv (c/random-bytes 32)
               eph-pub (.getPublicKey ^js x25519 eph-priv)
               eph-pub-b64 (c/to-b64url eph-pub)
               ss (.getSharedSecret ^js x25519 eph-priv (c/from-b64url to-x-pub-b64))]
           (js-await [key (wrap-key ss salt ctx to-id-pub eph-pub-b64)]
             (let [iv (c/random-bytes 12)]
               (js-await [ct (js/crypto.subtle.encrypt
                              (js-obj "name" "AES-GCM"
                                      "iv" iv
                                      "additionalData" (c/utf8 (str "v1|" ctx "|wrap|" to-id-pub)))
                              key payload)]
                 (js-obj "to" to-id-pub
                         "toX" to-x-pub-b64
                         "ephPub" eph-pub-b64
                         "iv" (c/to-b64 iv)
                         "ct" (c/to-b64 (js/Uint8Array. ct)))))))))))

(defn unwrap
  "Open a wrap addressed to us; null on any failure (wrong key, tampered)."
  [salt ctx w my-x]
  (-> (js/Promise.resolve nil)
      (.then
       (fn [_]
         (let [ss (.getSharedSecret ^js x25519 (unchecked-get my-x "priv")
                                    (c/from-b64url (unchecked-get w "ephPub")))]
           (js-await [key (wrap-key ss salt ctx (unchecked-get w "to")
                                    (unchecked-get w "ephPub"))]
             (js-await [pt (js/crypto.subtle.decrypt
                            (js-obj "name" "AES-GCM"
                                    "iv" (c/from-b64 (unchecked-get w "iv"))
                                    "additionalData" (c/utf8 (str "v1|" ctx "|wrap|"
                                                                  (unchecked-get w "to"))))
                            key (c/from-b64 (unchecked-get w "ct")))]
               (js/Uint8Array. pt))))))
      (.catch (fn [_] nil))))

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