@paramira/did-e2e 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 phuman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
6
+ associated documentation files (the "Software"), to deal in the Software without restriction, including
7
+ without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
9
+ following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial
12
+ portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
15
+ LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
16
+ EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
18
+ USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,58 @@
1
- # Temporary Holding Version
1
+ # @paramira/did-e2e
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Pairwise end-to-end encryption between two `did:iwacu:` identities — no
4
+ DIDComm, no Credo, no wallet. Ed25519-to-X25519 key conversion, ECDH,
5
+ HKDF-SHA256, XChaCha20-Poly1305. A relay carrying the result sees only an
6
+ opaque tagged blob; it can route the message without ever reading it.
7
+
8
+ ```js
9
+ import { encryptForDid, decryptFromDid, isEncrypted } from '@paramira/did-e2e'
10
+
11
+ // alice and bob are each { did, privateKeyBytes } — the same two fields
12
+ // every paramira identity object already carries.
13
+ const message = await encryptForDid('hey', bob.did, alice)
14
+
15
+ isEncrypted(message) // -> true — a relay can check this without decrypting
16
+
17
+ const result = await decryptFromDid(message, bob)
18
+ // -> { plaintext: 'hey', fromDid: alice.did }
19
+
20
+ // Addressed to someone else, not yet encrypted, or tampered with —
21
+ // decryptFromDid() never throws and never returns garbage, only null.
22
+ await decryptFromDid(message, carol) // -> null
23
+ ```
24
+
25
+ `myIdentity` needs just `{ did, privateKeyBytes }` (the raw 32-byte
26
+ Ed25519 seed) — the exact shape `@paramira/identity`'s `generateKeypair()`
27
+ already returns, so a real identity object passes straight through with
28
+ no adapting.
29
+
30
+ ## What this protects, and what it doesn't
31
+
32
+ Message *content* is end-to-end encrypted — only the recipient's private
33
+ key can read it, not a relay, not a bridge, not an intermediary carrying
34
+ it toward its destination. `from`/`to` DIDs travel in the clear inside
35
+ the encrypted blob's own JSON, though — a relay or bridge still needs
36
+ that to know where to send it next, and the recipient needs it to know
37
+ which shared secret to derive. That means this package hides *what* was
38
+ said, not *who said it to whom* — full metadata-hiding (traffic-analysis
39
+ resistance) is a separate, larger concern this package deliberately
40
+ doesn't attempt.
41
+
42
+ ## Where this came from
43
+
44
+ Extracted out of `apps/isoko/src/services/secureChannel.ts`, where this
45
+ exact logic already existed, worked, and shipped in a real app — just
46
+ untested (isoko had zero test coverage for it) and not reusable outside
47
+ that one React Native codebase. The `domain` option on both functions
48
+ exists solely so isoko's own re-export can keep deriving the exact keys
49
+ it always has (`{ domain: 'isoko-e2e' }`) — anyone starting fresh should
50
+ leave it at the default and never think about it again.
51
+
52
+ Built for `docs/plans/intercommunity-relays.md`'s Plan B — the inner,
53
+ peer-sealed layer of a two-layer envelope for messages crossing between
54
+ communities on separately self-managed relays. The outer, community-
55
+ sealed layer is a job for `@paramira/aes-gcm` instead (a symmetric key
56
+ derived from the community's own `cellSecret`), not this package — see
57
+ that plan for the full two-layer design and why each layer is a
58
+ different primitive.
package/index.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ export const SECURE_TAG: string
2
+
3
+ export interface DidIdentity {
4
+ did: string
5
+ privateKeyBytes: Uint8Array
6
+ }
7
+
8
+ export interface DidE2eOptions {
9
+ /** Domain-separates the derived key. Leave at the default unless you
10
+ * need to match an already-shipped wire format (see isoko's own
11
+ * re-export, which passes 'isoko-e2e' for exactly this reason). */
12
+ domain?: string
13
+ }
14
+
15
+ export function encryptForDid(
16
+ plaintext: string,
17
+ recipientDid: string,
18
+ myIdentity: DidIdentity,
19
+ options?: DidE2eOptions,
20
+ ): Promise<string>
21
+
22
+ export function decryptFromDid(
23
+ message: string,
24
+ myIdentity: DidIdentity,
25
+ options?: DidE2eOptions,
26
+ ): Promise<{ plaintext: string, fromDid: string } | null>
27
+
28
+ export function isEncrypted(message: string): boolean
package/index.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Pairwise end-to-end encryption between two did:iwacu: identities.
3
+ *
4
+ * Key exchange: Ed25519 -> X25519 conversion + ECDH shared secret
5
+ * Encryption: XChaCha20-Poly1305 (authenticated, nonce-misuse-resistant)
6
+ * KDF: HKDF-SHA256 over the ECDH output
7
+ *
8
+ * A relay carrying the result of encryptForDid() only ever sees an opaque
9
+ * tagged blob — it routes but cannot read content. `from`/`to` DIDs
10
+ * travel in the clear inside that blob (needed so the recipient knows
11
+ * which shared secret to derive, and so a relay/bridge knows where to
12
+ * send it next) — this protects content, not who's-talking-to-whom
13
+ * metadata; full metadata-hiding is a deliberately separate, later
14
+ * concern (see docs/plans/intercommunity-relays.md's own open questions).
15
+ *
16
+ * Extracted out of apps/isoko/src/services/secureChannel.ts, where this
17
+ * logic already existed and worked, just app-specific and untested. The
18
+ * `domain` option exists solely so isoko's own re-export of these
19
+ * functions can keep deriving the exact same keys it always has
20
+ * (`domain: 'isoko-e2e'`) — anyone using this package fresh should leave
21
+ * it at the default.
22
+ */
23
+ import { ed25519, x25519 } from '@noble/curves/ed25519.js'
24
+ import { xchacha20poly1305 } from '@noble/ciphers/chacha.js'
25
+ import { hkdf } from '@noble/hashes/hkdf.js'
26
+ import { sha256 } from '@noble/hashes/sha2.js'
27
+ import { publicKeyFromDid } from '@paramira/identity'
28
+
29
+ /** Prefixes every encrypted payload — lets a caller cheaply tell an
30
+ * encrypted message apart from a plain one (see isEncrypted()) without
31
+ * attempting a decrypt. Kept as the literal isoko already shipped with,
32
+ * not re-versioned, so nothing already-relayed/queued stops parsing. */
33
+ export const SECURE_TAG = '__e2e__:'
34
+
35
+ const DEFAULT_DOMAIN = 'paramira-e2e'
36
+
37
+ function bytesToB64(bytes) {
38
+ let bin = ''
39
+ for (const b of bytes) bin += String.fromCharCode(b)
40
+ return btoa(bin)
41
+ }
42
+
43
+ function b64ToBytes(s) {
44
+ return Uint8Array.from(atob(s), c => c.charCodeAt(0))
45
+ }
46
+
47
+ function toX25519Pub(publicKeyBytes) {
48
+ return ed25519.utils.toMontgomery(publicKeyBytes)
49
+ }
50
+
51
+ function toX25519Priv(privateKeyBytes) {
52
+ return ed25519.utils.toMontgomerySecret(privateKeyBytes)
53
+ }
54
+
55
+ function deriveKey(sharedSecret, info) {
56
+ return hkdf(sha256, sharedSecret, new Uint8Array(0), new TextEncoder().encode(info), 32)
57
+ }
58
+
59
+ function randomBytes(n) {
60
+ const arr = new Uint8Array(n)
61
+ crypto.getRandomValues(arr)
62
+ return arr
63
+ }
64
+
65
+ /**
66
+ * Encrypt `plaintext` so only `recipientDid`'s own private key can read
67
+ * it. `myIdentity` needs just `{ did, privateKeyBytes }` — the same two
68
+ * fields every paramira identity object already carries (see
69
+ * @paramira/identity's own Identity shape), so a caller's real identity
70
+ * object can be passed straight through with no adapting.
71
+ */
72
+ export async function encryptForDid(plaintext, recipientDid, myIdentity, options = {}) {
73
+ const domain = options.domain ?? DEFAULT_DOMAIN
74
+ const myX25519Priv = toX25519Priv(myIdentity.privateKeyBytes)
75
+ const theirX25519Pub = toX25519Pub(publicKeyFromDid(recipientDid))
76
+ const sharedSecret = x25519.getSharedSecret(myX25519Priv, theirX25519Pub)
77
+ const key = deriveKey(sharedSecret, `${domain}:${myIdentity.did}:${recipientDid}`)
78
+
79
+ const nonce = randomBytes(24)
80
+ const cipher = xchacha20poly1305(key, nonce)
81
+ const ciphertext = cipher.encrypt(new TextEncoder().encode(plaintext))
82
+
83
+ const payload = {
84
+ from: myIdentity.did,
85
+ to: recipientDid,
86
+ nonce: bytesToB64(nonce),
87
+ ct: bytesToB64(ciphertext),
88
+ }
89
+ return SECURE_TAG + JSON.stringify(payload)
90
+ }
91
+
92
+ /**
93
+ * Reverse encryptForDid(). Returns `null` — never throws, never returns
94
+ * garbage — for anything that isn't a genuine, correctly-addressed,
95
+ * untampered message for `myIdentity`: a plain (unencrypted) message, a
96
+ * message addressed to someone else, a malformed payload, or a payload
97
+ * whose auth tag doesn't verify (wrong key or tampered ciphertext —
98
+ * XChaCha20-Poly1305's own guarantee).
99
+ */
100
+ export async function decryptFromDid(message, myIdentity, options = {}) {
101
+ if (!message.startsWith(SECURE_TAG)) return null
102
+ const domain = options.domain ?? DEFAULT_DOMAIN
103
+ try {
104
+ const payload = JSON.parse(message.slice(SECURE_TAG.length))
105
+ if (payload.to !== myIdentity.did) return null
106
+
107
+ const myX25519Priv = toX25519Priv(myIdentity.privateKeyBytes)
108
+ const senderX25519Pub = toX25519Pub(publicKeyFromDid(payload.from))
109
+ const sharedSecret = x25519.getSharedSecret(myX25519Priv, senderX25519Pub)
110
+ const key = deriveKey(sharedSecret, `${domain}:${payload.from}:${payload.to}`)
111
+
112
+ const nonce = b64ToBytes(payload.nonce)
113
+ const ct = b64ToBytes(payload.ct)
114
+ const cipher = xchacha20poly1305(key, nonce)
115
+ const plaintext = new TextDecoder().decode(cipher.decrypt(ct))
116
+ return { plaintext, fromDid: payload.from }
117
+ } catch {
118
+ return null
119
+ }
120
+ }
121
+
122
+ /** Cheaply tell an encrypted message apart from a plain one, without
123
+ * attempting a decrypt. */
124
+ export function isEncrypted(message) {
125
+ return typeof message === 'string' && message.startsWith(SECURE_TAG)
126
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@paramira/did-e2e",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Pairwise end-to-end encryption between two did:iwacu: identities — Ed25519-to-X25519 conversion, ECDH, HKDF, XChaCha20-Poly1305. A relay carrying the result sees only an opaque blob.",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "types": "./index.d.ts",
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://codeberg.org/phuman/paramira.git",
12
+ "directory": "packages/did-e2e"
13
+ },
14
+ "keywords": [
15
+ "did",
16
+ "end-to-end-encryption",
17
+ "ed25519",
18
+ "x25519",
19
+ "ecdh",
20
+ "paramira"
21
+ ],
22
+ "files": [
23
+ "index.js",
24
+ "index.d.ts",
25
+ "README.md"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "exports": {
31
+ ".": "./index.js"
32
+ },
33
+ "dependencies": {
34
+ "@noble/ciphers": "^2.2.0",
35
+ "@noble/curves": "^2.2.0",
36
+ "@noble/hashes": "^2.2.0",
37
+ "@paramira/identity": "0.1.1"
38
+ },
39
+ "devDependencies": {
40
+ "vitest": "^4.1.9"
41
+ },
42
+ "scripts": {
43
+ "test": "vitest run"
44
+ }
6
45
  }