@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 +18 -0
- package/README.md +57 -2
- package/index.d.ts +28 -0
- package/index.js +126 -0
- package/package.json +42 -3
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
|
-
#
|
|
1
|
+
# @paramira/did-e2e
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
}
|