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))))
|