@bcts/envelope 1.0.0-alpha.10
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 +48 -0
- package/README.md +23 -0
- package/dist/index.cjs +2646 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +782 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +782 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.iife.js +2644 -0
- package/dist/index.iife.js.map +1 -0
- package/dist/index.mjs +2552 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +84 -0
- package/src/base/assertion.ts +179 -0
- package/src/base/assertions.ts +153 -0
- package/src/base/cbor.ts +122 -0
- package/src/base/digest.ts +204 -0
- package/src/base/elide.ts +390 -0
- package/src/base/envelope-decodable.ts +186 -0
- package/src/base/envelope-encodable.ts +71 -0
- package/src/base/envelope.ts +988 -0
- package/src/base/error.ts +421 -0
- package/src/base/index.ts +56 -0
- package/src/base/leaf.ts +147 -0
- package/src/base/queries.ts +244 -0
- package/src/base/walk.ts +215 -0
- package/src/base/wrap.ts +26 -0
- package/src/extension/attachment.ts +280 -0
- package/src/extension/compress.ts +176 -0
- package/src/extension/encrypt.ts +297 -0
- package/src/extension/expression.ts +404 -0
- package/src/extension/index.ts +72 -0
- package/src/extension/proof.ts +227 -0
- package/src/extension/recipient.ts +440 -0
- package/src/extension/salt.ts +114 -0
- package/src/extension/signature.ts +398 -0
- package/src/extension/types.ts +92 -0
- package/src/format/diagnostic.ts +116 -0
- package/src/format/hex.ts +25 -0
- package/src/format/index.ts +13 -0
- package/src/format/tree.ts +168 -0
- package/src/index.ts +32 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/string.ts +48 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { Envelope } from "../base/envelope";
|
|
2
|
+
import { EnvelopeError } from "../base/error";
|
|
3
|
+
import { SymmetricKey } from "./encrypt";
|
|
4
|
+
import {
|
|
5
|
+
x25519NewPrivateKeyUsing,
|
|
6
|
+
x25519PublicKeyFromPrivateKey,
|
|
7
|
+
x25519SharedKey,
|
|
8
|
+
aeadChaCha20Poly1305EncryptWithAad,
|
|
9
|
+
aeadChaCha20Poly1305DecryptWithAad,
|
|
10
|
+
hkdfHmacSha256,
|
|
11
|
+
X25519_PUBLIC_KEY_SIZE,
|
|
12
|
+
X25519_PRIVATE_KEY_SIZE,
|
|
13
|
+
SYMMETRIC_NONCE_SIZE,
|
|
14
|
+
SYMMETRIC_AUTH_SIZE,
|
|
15
|
+
} from "@bcts/crypto";
|
|
16
|
+
import { SecureRandomNumberGenerator, rngRandomData } from "@bcts/rand";
|
|
17
|
+
|
|
18
|
+
/// Extension for public-key encryption to specific recipients.
|
|
19
|
+
///
|
|
20
|
+
/// This module implements multi-recipient public key encryption using the
|
|
21
|
+
/// Gordian Envelope sealed message pattern. Each recipient gets a sealed
|
|
22
|
+
/// message containing an encrypted content key, allowing multiple recipients
|
|
23
|
+
/// to decrypt the same envelope using their private keys.
|
|
24
|
+
///
|
|
25
|
+
/// ## How it works:
|
|
26
|
+
///
|
|
27
|
+
/// 1. A random symmetric content key is generated
|
|
28
|
+
/// 2. The envelope's subject is encrypted with the content key
|
|
29
|
+
/// 3. The content key is encrypted to each recipient's public key using X25519 key agreement
|
|
30
|
+
/// 4. Each encrypted content key is added as a `hasRecipient` assertion
|
|
31
|
+
///
|
|
32
|
+
/// ## Sealed Box Security:
|
|
33
|
+
///
|
|
34
|
+
/// Sealed boxes use ephemeral X25519 key pairs. The ephemeral private key
|
|
35
|
+
/// is discarded after encryption, ensuring that even the sender cannot
|
|
36
|
+
/// decrypt the message later. Recipients must try each sealed message
|
|
37
|
+
/// until one decrypts successfully.
|
|
38
|
+
///
|
|
39
|
+
/// Uses @bcts/crypto functions for X25519 and ChaCha20-Poly1305.
|
|
40
|
+
///
|
|
41
|
+
/// @example
|
|
42
|
+
/// ```typescript
|
|
43
|
+
/// // Generate recipient keys
|
|
44
|
+
/// const alice = PrivateKeyBase.generate();
|
|
45
|
+
/// const bob = PrivateKeyBase.generate();
|
|
46
|
+
///
|
|
47
|
+
/// // Encrypt to multiple recipients
|
|
48
|
+
/// const envelope = Envelope.new("Secret message")
|
|
49
|
+
/// .encryptSubjectToRecipients([alice.publicKeys(), bob.publicKeys()]);
|
|
50
|
+
///
|
|
51
|
+
/// // Alice decrypts
|
|
52
|
+
/// const aliceDecrypted = envelope.decryptSubjectToRecipient(alice);
|
|
53
|
+
///
|
|
54
|
+
/// // Bob decrypts
|
|
55
|
+
/// const bobDecrypted = envelope.decryptSubjectToRecipient(bob);
|
|
56
|
+
/// ```
|
|
57
|
+
|
|
58
|
+
/// Predicate constant for recipient assertions
|
|
59
|
+
export const HAS_RECIPIENT = "hasRecipient";
|
|
60
|
+
|
|
61
|
+
/// Represents an X25519 public key for encryption
|
|
62
|
+
export class PublicKeyBase {
|
|
63
|
+
readonly #publicKey: Uint8Array;
|
|
64
|
+
|
|
65
|
+
constructor(publicKey: Uint8Array) {
|
|
66
|
+
if (publicKey.length !== X25519_PUBLIC_KEY_SIZE) {
|
|
67
|
+
throw new Error(`Public key must be ${X25519_PUBLIC_KEY_SIZE} bytes`);
|
|
68
|
+
}
|
|
69
|
+
this.#publicKey = publicKey;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// Returns the raw public key bytes
|
|
73
|
+
data(): Uint8Array {
|
|
74
|
+
return this.#publicKey;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Returns the public key as a hex string
|
|
78
|
+
hex(): string {
|
|
79
|
+
return Array.from(this.#publicKey)
|
|
80
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
81
|
+
.join("");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Creates a public key from hex string
|
|
85
|
+
static fromHex(hex: string): PublicKeyBase {
|
|
86
|
+
if (hex.length !== 64) {
|
|
87
|
+
throw new Error("Hex string must be 64 characters (32 bytes)");
|
|
88
|
+
}
|
|
89
|
+
const bytes = new Uint8Array(32);
|
|
90
|
+
for (let i = 0; i < 32; i++) {
|
|
91
|
+
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
92
|
+
}
|
|
93
|
+
return new PublicKeyBase(bytes);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// Represents an X25519 private key for decryption
|
|
98
|
+
export class PrivateKeyBase {
|
|
99
|
+
readonly #privateKey: Uint8Array;
|
|
100
|
+
readonly #publicKey: PublicKeyBase;
|
|
101
|
+
|
|
102
|
+
private constructor(privateKey: Uint8Array, publicKey: Uint8Array) {
|
|
103
|
+
this.#privateKey = privateKey;
|
|
104
|
+
this.#publicKey = new PublicKeyBase(publicKey);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/// Generates a new random X25519 key pair
|
|
108
|
+
static generate(): PrivateKeyBase {
|
|
109
|
+
const rng = new SecureRandomNumberGenerator();
|
|
110
|
+
const privateKey = x25519NewPrivateKeyUsing(rng);
|
|
111
|
+
const publicKey = x25519PublicKeyFromPrivateKey(privateKey);
|
|
112
|
+
return new PrivateKeyBase(privateKey, publicKey);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// Creates a private key from existing bytes
|
|
116
|
+
static fromBytes(privateKey: Uint8Array, publicKey: Uint8Array): PrivateKeyBase {
|
|
117
|
+
if (privateKey.length !== X25519_PRIVATE_KEY_SIZE) {
|
|
118
|
+
throw new Error(`Private key must be ${X25519_PRIVATE_KEY_SIZE} bytes`);
|
|
119
|
+
}
|
|
120
|
+
if (publicKey.length !== X25519_PUBLIC_KEY_SIZE) {
|
|
121
|
+
throw new Error(`Public key must be ${X25519_PUBLIC_KEY_SIZE} bytes`);
|
|
122
|
+
}
|
|
123
|
+
return new PrivateKeyBase(privateKey, publicKey);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Creates a private key from hex strings
|
|
127
|
+
static fromHex(privateHex: string, publicHex: string): PrivateKeyBase {
|
|
128
|
+
const privateBytes = new Uint8Array(32);
|
|
129
|
+
const publicBytes = new Uint8Array(32);
|
|
130
|
+
|
|
131
|
+
for (let i = 0; i < 32; i++) {
|
|
132
|
+
privateBytes[i] = parseInt(privateHex.substr(i * 2, 2), 16);
|
|
133
|
+
publicBytes[i] = parseInt(publicHex.substr(i * 2, 2), 16);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return new PrivateKeyBase(privateBytes, publicBytes);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/// Returns the public key
|
|
140
|
+
publicKeys(): PublicKeyBase {
|
|
141
|
+
return this.#publicKey;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/// Returns the raw private key bytes
|
|
145
|
+
data(): Uint8Array {
|
|
146
|
+
return this.#privateKey;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/// Returns the private key as hex string
|
|
150
|
+
hex(): string {
|
|
151
|
+
return Array.from(this.#privateKey)
|
|
152
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
153
|
+
.join("");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/// Decrypts a sealed message to get the content key
|
|
157
|
+
unseal(sealedMessage: SealedMessage): SymmetricKey {
|
|
158
|
+
try {
|
|
159
|
+
const decrypted = sealedMessage.decrypt(this.#privateKey, this.#publicKey.data());
|
|
160
|
+
return SymmetricKey.from(decrypted);
|
|
161
|
+
} catch (_error) {
|
|
162
|
+
throw EnvelopeError.general("Failed to unseal message: not a recipient");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// Represents a sealed message - encrypted content key for a recipient
|
|
168
|
+
/// Uses X25519 key agreement + ChaCha20-Poly1305 AEAD
|
|
169
|
+
///
|
|
170
|
+
/// Format: ephemeral_public_key (32 bytes) || nonce (12 bytes) || ciphertext || auth_tag (16 bytes)
|
|
171
|
+
export class SealedMessage {
|
|
172
|
+
readonly #data: Uint8Array;
|
|
173
|
+
|
|
174
|
+
constructor(data: Uint8Array) {
|
|
175
|
+
this.#data = data;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/// Creates a sealed message by encrypting a symmetric key to a recipient's public key
|
|
179
|
+
/// Uses X25519 ECDH for key agreement and ChaCha20-Poly1305 for encryption
|
|
180
|
+
static seal(contentKey: SymmetricKey, recipientPublicKey: PublicKeyBase): SealedMessage {
|
|
181
|
+
const rng = new SecureRandomNumberGenerator();
|
|
182
|
+
|
|
183
|
+
// Generate ephemeral key pair
|
|
184
|
+
const ephemeralPrivate = x25519NewPrivateKeyUsing(rng);
|
|
185
|
+
const ephemeralPublic = x25519PublicKeyFromPrivateKey(ephemeralPrivate);
|
|
186
|
+
|
|
187
|
+
// Compute shared secret using X25519 ECDH
|
|
188
|
+
const sharedSecret = x25519SharedKey(ephemeralPrivate, recipientPublicKey.data());
|
|
189
|
+
|
|
190
|
+
// Derive encryption key from shared secret using HKDF
|
|
191
|
+
const salt = new TextEncoder().encode("sealed_message");
|
|
192
|
+
const encryptionKey = hkdfHmacSha256(sharedSecret, salt, 32);
|
|
193
|
+
|
|
194
|
+
// Generate random nonce
|
|
195
|
+
const nonce = rngRandomData(rng, SYMMETRIC_NONCE_SIZE);
|
|
196
|
+
|
|
197
|
+
// Encrypt content key using ChaCha20-Poly1305
|
|
198
|
+
const plaintext = contentKey.data();
|
|
199
|
+
const [ciphertext, authTag] = aeadChaCha20Poly1305EncryptWithAad(
|
|
200
|
+
plaintext,
|
|
201
|
+
encryptionKey,
|
|
202
|
+
nonce,
|
|
203
|
+
new Uint8Array(0), // No AAD for sealed box
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// Format: ephemeral_public_key || nonce || ciphertext || auth_tag
|
|
207
|
+
const totalLength = ephemeralPublic.length + nonce.length + ciphertext.length + authTag.length;
|
|
208
|
+
const sealed = new Uint8Array(totalLength);
|
|
209
|
+
let offset = 0;
|
|
210
|
+
|
|
211
|
+
sealed.set(ephemeralPublic, offset);
|
|
212
|
+
offset += ephemeralPublic.length;
|
|
213
|
+
|
|
214
|
+
sealed.set(nonce, offset);
|
|
215
|
+
offset += nonce.length;
|
|
216
|
+
|
|
217
|
+
sealed.set(ciphertext, offset);
|
|
218
|
+
offset += ciphertext.length;
|
|
219
|
+
|
|
220
|
+
sealed.set(authTag, offset);
|
|
221
|
+
|
|
222
|
+
return new SealedMessage(sealed);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/// Decrypts this sealed message using recipient's private key
|
|
226
|
+
decrypt(recipientPrivate: Uint8Array, _recipientPublic: Uint8Array): Uint8Array {
|
|
227
|
+
// Parse sealed message format: ephemeral_public_key || nonce || ciphertext || auth_tag
|
|
228
|
+
const minLength = X25519_PUBLIC_KEY_SIZE + SYMMETRIC_NONCE_SIZE + SYMMETRIC_AUTH_SIZE;
|
|
229
|
+
if (this.#data.length < minLength) {
|
|
230
|
+
throw new Error("Sealed message too short");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let offset = 0;
|
|
234
|
+
|
|
235
|
+
// Extract ephemeral public key
|
|
236
|
+
const ephemeralPublic = this.#data.slice(offset, offset + X25519_PUBLIC_KEY_SIZE);
|
|
237
|
+
offset += X25519_PUBLIC_KEY_SIZE;
|
|
238
|
+
|
|
239
|
+
// Extract nonce
|
|
240
|
+
const nonce = this.#data.slice(offset, offset + SYMMETRIC_NONCE_SIZE);
|
|
241
|
+
offset += SYMMETRIC_NONCE_SIZE;
|
|
242
|
+
|
|
243
|
+
// Extract ciphertext and auth tag
|
|
244
|
+
const ciphertextAndTag = this.#data.slice(offset);
|
|
245
|
+
const ciphertext = ciphertextAndTag.slice(0, -SYMMETRIC_AUTH_SIZE);
|
|
246
|
+
const authTag = ciphertextAndTag.slice(-SYMMETRIC_AUTH_SIZE);
|
|
247
|
+
|
|
248
|
+
// Compute shared secret using X25519 ECDH
|
|
249
|
+
const sharedSecret = x25519SharedKey(recipientPrivate, ephemeralPublic);
|
|
250
|
+
|
|
251
|
+
// Derive decryption key from shared secret using HKDF
|
|
252
|
+
const salt = new TextEncoder().encode("sealed_message");
|
|
253
|
+
const decryptionKey = hkdfHmacSha256(sharedSecret, salt, 32);
|
|
254
|
+
|
|
255
|
+
// Decrypt using ChaCha20-Poly1305
|
|
256
|
+
const plaintext = aeadChaCha20Poly1305DecryptWithAad(
|
|
257
|
+
ciphertext,
|
|
258
|
+
decryptionKey,
|
|
259
|
+
nonce,
|
|
260
|
+
new Uint8Array(0), // No AAD for sealed box
|
|
261
|
+
authTag,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
return plaintext;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/// Returns the raw sealed message bytes
|
|
268
|
+
data(): Uint8Array {
|
|
269
|
+
return this.#data;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/// Returns the sealed message as hex string
|
|
273
|
+
hex(): string {
|
|
274
|
+
return Array.from(this.#data)
|
|
275
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
276
|
+
.join("");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/// Creates a sealed message from hex string
|
|
280
|
+
static fromHex(hex: string): SealedMessage {
|
|
281
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
282
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
283
|
+
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
284
|
+
}
|
|
285
|
+
return new SealedMessage(bytes);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/// Implementation of encryptSubjectToRecipient()
|
|
290
|
+
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
|
291
|
+
if (Envelope?.prototype) {
|
|
292
|
+
Envelope.prototype.encryptSubjectToRecipient = function (
|
|
293
|
+
this: Envelope,
|
|
294
|
+
recipientPublicKey: PublicKeyBase,
|
|
295
|
+
): Envelope {
|
|
296
|
+
// Generate a random content key
|
|
297
|
+
const contentKey = SymmetricKey.generate();
|
|
298
|
+
|
|
299
|
+
// Encrypt the subject with the content key
|
|
300
|
+
const encrypted = this.encryptSubject(contentKey);
|
|
301
|
+
|
|
302
|
+
// Add the recipient
|
|
303
|
+
return encrypted.addRecipient(recipientPublicKey, contentKey);
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/// Implementation of encryptSubjectToRecipients()
|
|
307
|
+
Envelope.prototype.encryptSubjectToRecipients = function (
|
|
308
|
+
this: Envelope,
|
|
309
|
+
recipients: PublicKeyBase[],
|
|
310
|
+
): Envelope {
|
|
311
|
+
if (recipients.length === 0) {
|
|
312
|
+
throw EnvelopeError.general("Must provide at least one recipient");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Generate a random content key
|
|
316
|
+
const contentKey = SymmetricKey.generate();
|
|
317
|
+
|
|
318
|
+
// Encrypt the subject with the content key
|
|
319
|
+
let result = this.encryptSubject(contentKey);
|
|
320
|
+
|
|
321
|
+
// Add each recipient
|
|
322
|
+
for (const recipient of recipients) {
|
|
323
|
+
result = result.addRecipient(recipient, contentKey);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return result;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
/// Implementation of addRecipient()
|
|
330
|
+
Envelope.prototype.addRecipient = function (
|
|
331
|
+
this: Envelope,
|
|
332
|
+
recipientPublicKey: PublicKeyBase,
|
|
333
|
+
contentKey: SymmetricKey,
|
|
334
|
+
): Envelope {
|
|
335
|
+
// Create a sealed message with the content key
|
|
336
|
+
const sealedMessage = SealedMessage.seal(contentKey, recipientPublicKey);
|
|
337
|
+
|
|
338
|
+
// Store the sealed message as bytes in the assertion
|
|
339
|
+
return this.addAssertion(HAS_RECIPIENT, sealedMessage.data());
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
/// Implementation of decryptSubjectToRecipient()
|
|
343
|
+
Envelope.prototype.decryptSubjectToRecipient = function (
|
|
344
|
+
this: Envelope,
|
|
345
|
+
recipientPrivateKey: PrivateKeyBase,
|
|
346
|
+
): Envelope {
|
|
347
|
+
// Check that the subject is encrypted
|
|
348
|
+
const subjectCase = this.subject().case();
|
|
349
|
+
if (subjectCase.type !== "encrypted") {
|
|
350
|
+
throw EnvelopeError.general("Subject is not encrypted");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Get all recipient assertions
|
|
354
|
+
const recipientAssertions = this.assertions().filter((assertion) => {
|
|
355
|
+
try {
|
|
356
|
+
const predicate = assertion.subject().asPredicate();
|
|
357
|
+
if (predicate === undefined) return false;
|
|
358
|
+
return predicate.asText() === HAS_RECIPIENT;
|
|
359
|
+
} catch {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
if (recipientAssertions.length === 0) {
|
|
365
|
+
throw EnvelopeError.general("No recipients found");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Try each recipient assertion until one unseals successfully
|
|
369
|
+
let contentKey: SymmetricKey | null = null;
|
|
370
|
+
|
|
371
|
+
for (const assertion of recipientAssertions) {
|
|
372
|
+
try {
|
|
373
|
+
const obj = assertion.subject().asObject();
|
|
374
|
+
if (obj === undefined) continue;
|
|
375
|
+
const sealedData = obj.asByteString();
|
|
376
|
+
if (sealedData === undefined) continue;
|
|
377
|
+
const sealedMessage = new SealedMessage(sealedData);
|
|
378
|
+
|
|
379
|
+
// Try to unseal with our private key
|
|
380
|
+
contentKey = recipientPrivateKey.unseal(sealedMessage);
|
|
381
|
+
break; // Success!
|
|
382
|
+
} catch {
|
|
383
|
+
// Not for us, try next one
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (contentKey === null) {
|
|
389
|
+
throw EnvelopeError.general("Not a valid recipient");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Decrypt the subject using the content key
|
|
393
|
+
return this.decryptSubject(contentKey);
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
/// Implementation of decryptToRecipient()
|
|
397
|
+
Envelope.prototype.decryptToRecipient = function (
|
|
398
|
+
this: Envelope,
|
|
399
|
+
recipientPrivateKey: PrivateKeyBase,
|
|
400
|
+
): Envelope {
|
|
401
|
+
const decrypted = this.decryptSubjectToRecipient(recipientPrivateKey);
|
|
402
|
+
return decrypted.unwrap();
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/// Implementation of encryptToRecipients()
|
|
406
|
+
Envelope.prototype.encryptToRecipients = function (
|
|
407
|
+
this: Envelope,
|
|
408
|
+
recipients: PublicKeyBase[],
|
|
409
|
+
): Envelope {
|
|
410
|
+
return this.wrap().encryptSubjectToRecipients(recipients);
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/// Implementation of recipients()
|
|
414
|
+
Envelope.prototype.recipients = function (this: Envelope): SealedMessage[] {
|
|
415
|
+
const recipientAssertions = this.assertions().filter((assertion) => {
|
|
416
|
+
try {
|
|
417
|
+
const predicate = assertion.subject().asPredicate();
|
|
418
|
+
if (predicate === undefined) return false;
|
|
419
|
+
return predicate.asText() === HAS_RECIPIENT;
|
|
420
|
+
} catch {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
return recipientAssertions.map((assertion) => {
|
|
426
|
+
const obj = assertion.subject().asObject();
|
|
427
|
+
if (obj === undefined) {
|
|
428
|
+
throw EnvelopeError.general("Invalid recipient assertion");
|
|
429
|
+
}
|
|
430
|
+
const sealedData = obj.asByteString();
|
|
431
|
+
if (sealedData === undefined) {
|
|
432
|
+
throw EnvelopeError.general("Invalid recipient data");
|
|
433
|
+
}
|
|
434
|
+
return new SealedMessage(sealedData);
|
|
435
|
+
});
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Import side-effect to register prototype extensions
|
|
440
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Envelope } from "../base/envelope";
|
|
2
|
+
import { EnvelopeError } from "../base/error";
|
|
3
|
+
import {
|
|
4
|
+
SecureRandomNumberGenerator,
|
|
5
|
+
rngRandomData,
|
|
6
|
+
rngNextInClosedRangeI32,
|
|
7
|
+
type RandomNumberGenerator,
|
|
8
|
+
} from "@bcts/rand";
|
|
9
|
+
|
|
10
|
+
/// Extension for adding salt to envelopes to prevent correlation.
|
|
11
|
+
///
|
|
12
|
+
/// This module provides functionality for decorrelating envelopes by adding
|
|
13
|
+
/// random salt. Salt is added as an assertion with the predicate 'salt' and
|
|
14
|
+
/// a random value. When an envelope is elided, this salt ensures that the
|
|
15
|
+
/// digest of the elided envelope cannot be correlated with other elided
|
|
16
|
+
/// envelopes containing the same information.
|
|
17
|
+
///
|
|
18
|
+
/// Decorrelation is an important privacy feature that prevents third parties
|
|
19
|
+
/// from determining whether two elided envelopes originally contained the same
|
|
20
|
+
/// information by comparing their digests.
|
|
21
|
+
///
|
|
22
|
+
/// Based on bc-envelope-rust/src/extension/salt.rs and bc-components-rust/src/salt.rs
|
|
23
|
+
///
|
|
24
|
+
/// @example
|
|
25
|
+
/// ```typescript
|
|
26
|
+
/// // Create a simple envelope
|
|
27
|
+
/// const envelope = Envelope.new("Hello");
|
|
28
|
+
///
|
|
29
|
+
/// // Create a decorrelated version by adding salt
|
|
30
|
+
/// const salted = envelope.addSalt();
|
|
31
|
+
///
|
|
32
|
+
/// // The salted envelope has a different digest than the original
|
|
33
|
+
/// console.log(envelope.digest().equals(salted.digest())); // false
|
|
34
|
+
/// ```
|
|
35
|
+
|
|
36
|
+
/// The standard predicate for salt assertions
|
|
37
|
+
export const SALT = "salt";
|
|
38
|
+
|
|
39
|
+
/// Minimum salt size in bytes (64 bits)
|
|
40
|
+
const MIN_SALT_SIZE = 8;
|
|
41
|
+
|
|
42
|
+
/// Creates a new SecureRandomNumberGenerator instance
|
|
43
|
+
function createSecureRng(): RandomNumberGenerator {
|
|
44
|
+
return new SecureRandomNumberGenerator();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Generates random bytes using the rand package
|
|
48
|
+
function generateRandomBytes(length: number, rng?: RandomNumberGenerator): Uint8Array {
|
|
49
|
+
const actualRng = rng ?? createSecureRng();
|
|
50
|
+
return rngRandomData(actualRng, length);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// Calculates salt size proportional to envelope size
|
|
54
|
+
/// This matches the Rust implementation in bc-components-rust/src/salt.rs
|
|
55
|
+
function calculateProportionalSaltSize(envelopeSize: number, rng?: RandomNumberGenerator): number {
|
|
56
|
+
const actualRng = rng ?? createSecureRng();
|
|
57
|
+
const count = envelopeSize;
|
|
58
|
+
const minSize = Math.max(8, Math.ceil(count * 0.05));
|
|
59
|
+
const maxSize = Math.max(minSize + 8, Math.ceil(count * 0.25));
|
|
60
|
+
return rngNextInClosedRangeI32(actualRng, minSize, maxSize);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Implementation of addSalt()
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
|
65
|
+
if (Envelope?.prototype) {
|
|
66
|
+
Envelope.prototype.addSalt = function (this: Envelope): Envelope {
|
|
67
|
+
const rng = createSecureRng();
|
|
68
|
+
const envelopeSize = this.cborBytes().length;
|
|
69
|
+
const saltSize = calculateProportionalSaltSize(envelopeSize, rng);
|
|
70
|
+
const saltBytes = generateRandomBytes(saltSize, rng);
|
|
71
|
+
return this.addAssertion(SALT, saltBytes);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/// Implementation of addSaltWithLength()
|
|
75
|
+
Envelope.prototype.addSaltWithLength = function (this: Envelope, count: number): Envelope {
|
|
76
|
+
if (count < MIN_SALT_SIZE) {
|
|
77
|
+
throw EnvelopeError.general(`Salt must be at least ${MIN_SALT_SIZE} bytes, got ${count}`);
|
|
78
|
+
}
|
|
79
|
+
const saltBytes = generateRandomBytes(count);
|
|
80
|
+
return this.addAssertion(SALT, saltBytes);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/// Implementation of addSaltBytes()
|
|
84
|
+
Envelope.prototype.addSaltBytes = function (this: Envelope, saltBytes: Uint8Array): Envelope {
|
|
85
|
+
if (saltBytes.length < MIN_SALT_SIZE) {
|
|
86
|
+
throw EnvelopeError.general(
|
|
87
|
+
`Salt must be at least ${MIN_SALT_SIZE} bytes, got ${saltBytes.length}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return this.addAssertion(SALT, saltBytes);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/// Implementation of addSaltInRange()
|
|
94
|
+
Envelope.prototype.addSaltInRange = function (
|
|
95
|
+
this: Envelope,
|
|
96
|
+
min: number,
|
|
97
|
+
max: number,
|
|
98
|
+
): Envelope {
|
|
99
|
+
if (min < MIN_SALT_SIZE) {
|
|
100
|
+
throw EnvelopeError.general(
|
|
101
|
+
`Minimum salt size must be at least ${MIN_SALT_SIZE} bytes, got ${min}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (max < min) {
|
|
105
|
+
throw EnvelopeError.general(
|
|
106
|
+
`Maximum salt size must be at least minimum, got min=${min} max=${max}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const rng = createSecureRng();
|
|
110
|
+
const saltSize = rngNextInClosedRangeI32(rng, min, max);
|
|
111
|
+
const saltBytes = generateRandomBytes(saltSize, rng);
|
|
112
|
+
return this.addAssertion(SALT, saltBytes);
|
|
113
|
+
};
|
|
114
|
+
}
|