sos-sdk-core-ts 0.2.0 → 0.2.2
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/CHANGELOG.md +17 -0
- package/README.md +25 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/secure-message-crypto.d.ts +49 -0
- package/dist/secure-message-crypto.js +306 -0
- package/docs/SECURE_MESSAGES.md +59 -0
- package/package.json +15 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.2.2 - 2026-09-25
|
|
6
|
+
|
|
7
|
+
- Add runtime-neutral ML-KEM-768 multi-recipient encryption for written and
|
|
8
|
+
binary-audio messages with one SHA3-256-derived AES-256-GCM content key and
|
|
9
|
+
one independently protected CEK entry per recipient.
|
|
10
|
+
- Preserve the historical deterministic 32-byte message-seed derivation
|
|
11
|
+
contract and add recipient updates that rewrap the CEK without changing the
|
|
12
|
+
stored ciphertext, IV or authentication tag.
|
|
13
|
+
- Fail closed for duplicate or unknown recipients, malformed envelopes,
|
|
14
|
+
invalid random-source output and authenticated-ciphertext tampering.
|
|
15
|
+
|
|
16
|
+
## 0.2.1
|
|
17
|
+
|
|
18
|
+
- Link the shared runtime layer to the blocking local-first release order: complete local proof, merge validated branches to main, publish bottom-up, pin exact registry artifacts, then stage.
|
|
19
|
+
|
|
3
20
|
## 0.2.0
|
|
4
21
|
|
|
5
22
|
- Redefine `sos-sdk-core-ts` as the runtime-neutral orchestration layer consumed
|
package/README.md
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
# Shared SOS SDK Core
|
|
2
|
+
## Blocking local-first SDK release
|
|
3
|
+
|
|
4
|
+
The canonical contract is [`SDK_LAYERING.md`](https://github.com/Fundacion-UNID/sos-data-utils-ts/blob/main/docs/SDK_LAYERING.md). Its
|
|
5
|
+
indivisible order is **local packages -> full local test matrix -> merge every
|
|
6
|
+
validated branch to `main` -> publish bottom-up -> install exact registry
|
|
7
|
+
versions and commit lockfiles on `main` -> staging**. Never publish to discover
|
|
8
|
+
deterministic failures and never commit a local package reference. Staging
|
|
9
|
+
begins only after exact registry packages are installed and verified.
|
|
10
|
+
|
|
2
11
|
|
|
3
12
|
Runtime-neutral shared SDK for SOSChain, VetChain and UHC. Deterministic
|
|
4
13
|
cross-product record types and builders come from `sos-data-utils-ts`; this SDK
|
|
@@ -48,6 +57,22 @@ to the same types and implementation; they are not a second contract.
|
|
|
48
57
|
`buildSoschainPlaceCardAssociationClaims` records a UHC/VetChain card link to
|
|
49
58
|
a canonical SOSChain Place or vehicle without merging either identity.
|
|
50
59
|
|
|
60
|
+
## Multi-recipient secure messages
|
|
61
|
+
|
|
62
|
+
`sos-sdk-core-ts/secure-message-crypto` protects written and binary-audio
|
|
63
|
+
messages with the same runtime-neutral byte API. It derives one AES-256 content
|
|
64
|
+
key from one random 32-byte message seed, encrypts the content once, and wraps
|
|
65
|
+
that CEK independently for each ML-KEM-768 recipient. The ML-KEM shared secret
|
|
66
|
+
is recipient-specific; the resulting content key and ciphertext are common to
|
|
67
|
+
all authorized recipients.
|
|
68
|
+
|
|
69
|
+
`updateRecipients(...)` rewraps the existing CEK for added device/channel keys
|
|
70
|
+
and can remove obsolete or temporary service entries without changing the
|
|
71
|
+
stored ciphertext, IV or authentication tag. The caller remains responsible
|
|
72
|
+
for proving authorization to change recipients. Runtime packages inject only
|
|
73
|
+
`SecureRandomSource`; Node, Expo and browser crypto adapters must not duplicate
|
|
74
|
+
ML-KEM, HKDF or AES logic. See [docs/SECURE_MESSAGES.md](docs/SECURE_MESSAGES.md).
|
|
75
|
+
|
|
51
76
|
```bash
|
|
52
77
|
npm install
|
|
53
78
|
npm run check
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type SecureMessageEnvelope } from 'sos-data-utils-ts/secure-message-envelope';
|
|
2
|
+
export interface SecureRandomSource {
|
|
3
|
+
getRandomBytes(length: number): Promise<Uint8Array>;
|
|
4
|
+
}
|
|
5
|
+
export interface SecureMessageRecipientPublicKey {
|
|
6
|
+
readonly keyId: string;
|
|
7
|
+
readonly publicKey: Uint8Array;
|
|
8
|
+
}
|
|
9
|
+
export interface SecureMessageRecipientKeyPair extends SecureMessageRecipientPublicKey {
|
|
10
|
+
readonly secretKey: Uint8Array;
|
|
11
|
+
}
|
|
12
|
+
export interface OpenedSecureMessage {
|
|
13
|
+
readonly contentType: string;
|
|
14
|
+
readonly plaintext: Uint8Array;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Preserve the historical secure-message contract: one random 32-byte message
|
|
18
|
+
* seed deterministically derives the AES-256 content-encryption key. The CEK is
|
|
19
|
+
* then wrapped independently for every current ML-KEM recipient.
|
|
20
|
+
*/
|
|
21
|
+
export declare const deriveSecureMessageContentEncryptionKey: (messageSeed: Uint8Array) => Uint8Array;
|
|
22
|
+
/** Runtime-neutral ML-KEM multi-recipient encryption for text or binary audio messages. */
|
|
23
|
+
export declare class MultiRecipientSecureMessageCrypto {
|
|
24
|
+
private readonly randomSource;
|
|
25
|
+
constructor(randomSource: SecureRandomSource);
|
|
26
|
+
generateRecipientKeyPair(keyId: string): Promise<SecureMessageRecipientKeyPair>;
|
|
27
|
+
seal(input: Readonly<{
|
|
28
|
+
plaintext: Uint8Array;
|
|
29
|
+
contentType: string;
|
|
30
|
+
recipients: readonly SecureMessageRecipientPublicKey[];
|
|
31
|
+
}>): Promise<SecureMessageEnvelope>;
|
|
32
|
+
open(input: Readonly<{
|
|
33
|
+
envelope: SecureMessageEnvelope;
|
|
34
|
+
recipient: SecureMessageRecipientKeyPair;
|
|
35
|
+
}>): Promise<OpenedSecureMessage>;
|
|
36
|
+
/**
|
|
37
|
+
* Rewrap the existing CEK without opening or re-encrypting message content.
|
|
38
|
+
* Business authorization for adding or removing a DCR remains with the caller.
|
|
39
|
+
*/
|
|
40
|
+
updateRecipients(input: Readonly<{
|
|
41
|
+
envelope: SecureMessageEnvelope;
|
|
42
|
+
authorizingRecipient: SecureMessageRecipientKeyPair;
|
|
43
|
+
addRecipients?: readonly SecureMessageRecipientPublicKey[];
|
|
44
|
+
removeRecipientKeyIds?: readonly string[];
|
|
45
|
+
}>): Promise<SecureMessageEnvelope>;
|
|
46
|
+
private assertUniqueRecipientKeys;
|
|
47
|
+
private wrapContentEncryptionKey;
|
|
48
|
+
private unwrapContentEncryptionKey;
|
|
49
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { gcm } from '@noble/ciphers/aes.js';
|
|
2
|
+
import { hkdf } from '@noble/hashes/hkdf.js';
|
|
3
|
+
import { sha256 } from '@noble/hashes/sha2.js';
|
|
4
|
+
import { sha3_256 } from '@noble/hashes/sha3.js';
|
|
5
|
+
import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
|
|
6
|
+
import { SecureMessageContentEncryptionAlgorithm, SecureMessageEnvelopeProfile, SecureMessageKeyManagementAlgorithm, assertSecureMessageEnvelope, } from 'sos-data-utils-ts/secure-message-envelope';
|
|
7
|
+
const textEncoder = new TextEncoder();
|
|
8
|
+
const textDecoder = new TextDecoder(undefined, { fatal: true });
|
|
9
|
+
const AES_KEY_BYTES = 32;
|
|
10
|
+
const AES_GCM_NONCE_BYTES = 12;
|
|
11
|
+
const AES_GCM_TAG_BYTES = 16;
|
|
12
|
+
const ML_KEM_KEYGEN_SEED_BYTES = 64;
|
|
13
|
+
const ML_KEM_ENCAPSULATION_SEED_BYTES = 32;
|
|
14
|
+
const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
15
|
+
const WRAP_VERSION = 'mlkem-cek-wrap-v1';
|
|
16
|
+
const assertRandomBytes = (bytes, expectedLength) => {
|
|
17
|
+
if (!(bytes instanceof Uint8Array) || bytes.length !== expectedLength) {
|
|
18
|
+
throw new Error('secure_random_source_invalid');
|
|
19
|
+
}
|
|
20
|
+
return bytes;
|
|
21
|
+
};
|
|
22
|
+
const assertKeyId = (keyId) => {
|
|
23
|
+
const normalized = String(keyId).trim();
|
|
24
|
+
if (!normalized)
|
|
25
|
+
throw new Error('secure_message_recipient_key_id_required');
|
|
26
|
+
return normalized;
|
|
27
|
+
};
|
|
28
|
+
const encodeBase64Url = (bytes) => {
|
|
29
|
+
let output = '';
|
|
30
|
+
for (let offset = 0; offset < bytes.length; offset += 3) {
|
|
31
|
+
const first = bytes[offset];
|
|
32
|
+
const second = bytes[offset + 1];
|
|
33
|
+
const third = bytes[offset + 2];
|
|
34
|
+
const combined = (first << 16) | ((second ?? 0) << 8) | (third ?? 0);
|
|
35
|
+
output += BASE64URL_ALPHABET[(combined >>> 18) & 63];
|
|
36
|
+
output += BASE64URL_ALPHABET[(combined >>> 12) & 63];
|
|
37
|
+
if (second !== undefined)
|
|
38
|
+
output += BASE64URL_ALPHABET[(combined >>> 6) & 63];
|
|
39
|
+
if (third !== undefined)
|
|
40
|
+
output += BASE64URL_ALPHABET[combined & 63];
|
|
41
|
+
}
|
|
42
|
+
return output;
|
|
43
|
+
};
|
|
44
|
+
const decodeBase64Url = (value) => {
|
|
45
|
+
if (!/^[A-Za-z0-9_-]+$/u.test(value))
|
|
46
|
+
throw new Error('secure_message_base64url_invalid');
|
|
47
|
+
const output = new Uint8Array(Math.floor((value.length * 6) / 8));
|
|
48
|
+
let accumulator = 0;
|
|
49
|
+
let bits = 0;
|
|
50
|
+
let outputOffset = 0;
|
|
51
|
+
for (const character of value) {
|
|
52
|
+
const alphabetIndex = BASE64URL_ALPHABET.indexOf(character);
|
|
53
|
+
if (alphabetIndex < 0)
|
|
54
|
+
throw new Error('secure_message_base64url_invalid');
|
|
55
|
+
accumulator = (accumulator << 6) | alphabetIndex;
|
|
56
|
+
bits += 6;
|
|
57
|
+
if (bits >= 8) {
|
|
58
|
+
bits -= 8;
|
|
59
|
+
output[outputOffset] = (accumulator >>> bits) & 0xff;
|
|
60
|
+
outputOffset += 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return output;
|
|
64
|
+
};
|
|
65
|
+
const encodeJson = (value) => encodeBase64Url(textEncoder.encode(JSON.stringify(value)));
|
|
66
|
+
const decodeJson = (value) => {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(textDecoder.decode(decodeBase64Url(value)));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw new Error('secure_message_json_invalid');
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const splitCiphertextAndTag = (sealed) => {
|
|
75
|
+
if (sealed.length < AES_GCM_TAG_BYTES)
|
|
76
|
+
throw new Error('secure_message_ciphertext_invalid');
|
|
77
|
+
return {
|
|
78
|
+
ciphertext: sealed.slice(0, -AES_GCM_TAG_BYTES),
|
|
79
|
+
tag: sealed.slice(-AES_GCM_TAG_BYTES),
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
const joinCiphertextAndTag = (ciphertext, tag) => {
|
|
83
|
+
if (tag.length !== AES_GCM_TAG_BYTES)
|
|
84
|
+
throw new Error('secure_message_tag_invalid');
|
|
85
|
+
const sealed = new Uint8Array(ciphertext.length + tag.length);
|
|
86
|
+
sealed.set(ciphertext);
|
|
87
|
+
sealed.set(tag, ciphertext.length);
|
|
88
|
+
return sealed;
|
|
89
|
+
};
|
|
90
|
+
const protectedHeader = (contentType) => {
|
|
91
|
+
const normalizedContentType = String(contentType).trim();
|
|
92
|
+
if (!normalizedContentType || normalizedContentType.length > 255 || /[\u0000-\u001f\u007f]/u.test(normalizedContentType)) {
|
|
93
|
+
throw new Error('secure_message_content_type_invalid');
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
typ: 'application/jose+json',
|
|
97
|
+
cty: normalizedContentType,
|
|
98
|
+
enc: SecureMessageContentEncryptionAlgorithm.A256GCM,
|
|
99
|
+
profile: SecureMessageEnvelopeProfile.MultirecipientMlKemV1,
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
const parseProtectedHeader = (encoded) => {
|
|
103
|
+
const header = decodeJson(encoded);
|
|
104
|
+
if (header.typ !== 'application/jose+json'
|
|
105
|
+
|| typeof header.cty !== 'string'
|
|
106
|
+
|| header.enc !== SecureMessageContentEncryptionAlgorithm.A256GCM
|
|
107
|
+
|| header.profile !== SecureMessageEnvelopeProfile.MultirecipientMlKemV1) {
|
|
108
|
+
throw new Error('secure_message_protected_header_invalid');
|
|
109
|
+
}
|
|
110
|
+
return protectedHeader(header.cty);
|
|
111
|
+
};
|
|
112
|
+
const deriveRecipientWrappingKey = (sharedSecret, encodedProtectedHeader, recipientKeyId) => hkdf(sha256, sharedSecret, textEncoder.encode('multi-recipient-ml-kem-v1'), textEncoder.encode(`${encodedProtectedHeader}.${recipientKeyId}.cek`), AES_KEY_BYTES);
|
|
113
|
+
/**
|
|
114
|
+
* Preserve the historical secure-message contract: one random 32-byte message
|
|
115
|
+
* seed deterministically derives the AES-256 content-encryption key. The CEK is
|
|
116
|
+
* then wrapped independently for every current ML-KEM recipient.
|
|
117
|
+
*/
|
|
118
|
+
export const deriveSecureMessageContentEncryptionKey = (messageSeed) => {
|
|
119
|
+
if (!(messageSeed instanceof Uint8Array) || messageSeed.length !== AES_KEY_BYTES) {
|
|
120
|
+
throw new Error('secure_message_seed_invalid');
|
|
121
|
+
}
|
|
122
|
+
return sha3_256(messageSeed);
|
|
123
|
+
};
|
|
124
|
+
/** Runtime-neutral ML-KEM multi-recipient encryption for text or binary audio messages. */
|
|
125
|
+
export class MultiRecipientSecureMessageCrypto {
|
|
126
|
+
constructor(randomSource) {
|
|
127
|
+
this.randomSource = randomSource;
|
|
128
|
+
}
|
|
129
|
+
async generateRecipientKeyPair(keyId) {
|
|
130
|
+
const seed = assertRandomBytes(await this.randomSource.getRandomBytes(ML_KEM_KEYGEN_SEED_BYTES), ML_KEM_KEYGEN_SEED_BYTES);
|
|
131
|
+
try {
|
|
132
|
+
const { publicKey, secretKey } = ml_kem768.keygen(seed);
|
|
133
|
+
return { keyId: assertKeyId(keyId), publicKey, secretKey };
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
seed.fill(0);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async seal(input) {
|
|
140
|
+
if (!(input.plaintext instanceof Uint8Array) || input.plaintext.length === 0) {
|
|
141
|
+
throw new Error('secure_message_plaintext_required');
|
|
142
|
+
}
|
|
143
|
+
this.assertUniqueRecipientKeys(input.recipients);
|
|
144
|
+
const messageSeed = assertRandomBytes(await this.randomSource.getRandomBytes(AES_KEY_BYTES), AES_KEY_BYTES);
|
|
145
|
+
const cek = deriveSecureMessageContentEncryptionKey(messageSeed);
|
|
146
|
+
const iv = assertRandomBytes(await this.randomSource.getRandomBytes(AES_GCM_NONCE_BYTES), AES_GCM_NONCE_BYTES);
|
|
147
|
+
const encodedProtectedHeader = encodeJson(protectedHeader(input.contentType));
|
|
148
|
+
try {
|
|
149
|
+
const sealed = gcm(cek, iv, textEncoder.encode(encodedProtectedHeader)).encrypt(input.plaintext);
|
|
150
|
+
const { ciphertext, tag } = splitCiphertextAndTag(sealed);
|
|
151
|
+
const recipients = [];
|
|
152
|
+
for (const recipient of input.recipients) {
|
|
153
|
+
recipients.push(await this.wrapContentEncryptionKey(cek, encodedProtectedHeader, recipient));
|
|
154
|
+
}
|
|
155
|
+
const envelope = {
|
|
156
|
+
profile: SecureMessageEnvelopeProfile.MultirecipientMlKemV1,
|
|
157
|
+
protected: encodedProtectedHeader,
|
|
158
|
+
recipients,
|
|
159
|
+
iv: encodeBase64Url(iv),
|
|
160
|
+
ciphertext: encodeBase64Url(ciphertext),
|
|
161
|
+
tag: encodeBase64Url(tag),
|
|
162
|
+
};
|
|
163
|
+
assertSecureMessageEnvelope(envelope);
|
|
164
|
+
return envelope;
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
messageSeed.fill(0);
|
|
168
|
+
cek.fill(0);
|
|
169
|
+
iv.fill(0);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async open(input) {
|
|
173
|
+
assertSecureMessageEnvelope(input.envelope);
|
|
174
|
+
const header = parseProtectedHeader(input.envelope.protected);
|
|
175
|
+
const cek = await this.unwrapContentEncryptionKey(input.envelope, input.recipient);
|
|
176
|
+
try {
|
|
177
|
+
const plaintext = gcm(cek, decodeBase64Url(input.envelope.iv), textEncoder.encode(input.envelope.protected)).decrypt(joinCiphertextAndTag(decodeBase64Url(input.envelope.ciphertext), decodeBase64Url(input.envelope.tag)));
|
|
178
|
+
return { contentType: header.cty, plaintext };
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
cek.fill(0);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Rewrap the existing CEK without opening or re-encrypting message content.
|
|
186
|
+
* Business authorization for adding or removing a DCR remains with the caller.
|
|
187
|
+
*/
|
|
188
|
+
async updateRecipients(input) {
|
|
189
|
+
assertSecureMessageEnvelope(input.envelope);
|
|
190
|
+
parseProtectedHeader(input.envelope.protected);
|
|
191
|
+
const addRecipients = input.addRecipients ?? [];
|
|
192
|
+
this.assertUniqueRecipientKeys(addRecipients, false);
|
|
193
|
+
const removedKeyIds = new Set((input.removeRecipientKeyIds ?? []).map(assertKeyId));
|
|
194
|
+
const existingKeyIds = new Set(input.envelope.recipients.map(({ header }) => header.kid));
|
|
195
|
+
for (const removedKeyId of removedKeyIds) {
|
|
196
|
+
if (!existingKeyIds.has(removedKeyId)) {
|
|
197
|
+
throw new Error('secure_message_recipient_remove_not_found');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const retainedRecipients = input.envelope.recipients.filter(({ header }) => !removedKeyIds.has(header.kid));
|
|
201
|
+
const retainedKeyIds = new Set(retainedRecipients.map(({ header }) => header.kid));
|
|
202
|
+
for (const recipient of addRecipients) {
|
|
203
|
+
const keyId = assertKeyId(recipient.keyId);
|
|
204
|
+
if (retainedKeyIds.has(keyId))
|
|
205
|
+
throw new Error('secure_message_recipient_duplicate');
|
|
206
|
+
retainedKeyIds.add(keyId);
|
|
207
|
+
}
|
|
208
|
+
if (retainedRecipients.length + addRecipients.length === 0) {
|
|
209
|
+
throw new Error('secure_message_recipients_required');
|
|
210
|
+
}
|
|
211
|
+
const cek = await this.unwrapContentEncryptionKey(input.envelope, input.authorizingRecipient);
|
|
212
|
+
try {
|
|
213
|
+
const addedRecipients = [];
|
|
214
|
+
for (const recipient of addRecipients) {
|
|
215
|
+
addedRecipients.push(await this.wrapContentEncryptionKey(cek, input.envelope.protected, recipient));
|
|
216
|
+
}
|
|
217
|
+
const updatedEnvelope = {
|
|
218
|
+
...input.envelope,
|
|
219
|
+
recipients: [...retainedRecipients, ...addedRecipients],
|
|
220
|
+
};
|
|
221
|
+
assertSecureMessageEnvelope(updatedEnvelope);
|
|
222
|
+
return updatedEnvelope;
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
cek.fill(0);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
assertUniqueRecipientKeys(recipients, requireAtLeastOne = true) {
|
|
229
|
+
if (!Array.isArray(recipients) || (requireAtLeastOne && recipients.length === 0)) {
|
|
230
|
+
throw new Error('secure_message_recipients_required');
|
|
231
|
+
}
|
|
232
|
+
const keyIds = new Set();
|
|
233
|
+
for (const recipient of recipients) {
|
|
234
|
+
const keyId = assertKeyId(recipient.keyId);
|
|
235
|
+
if (!(recipient.publicKey instanceof Uint8Array))
|
|
236
|
+
throw new Error('secure_message_recipient_public_key_invalid');
|
|
237
|
+
if (keyIds.has(keyId))
|
|
238
|
+
throw new Error('secure_message_recipient_duplicate');
|
|
239
|
+
keyIds.add(keyId);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async wrapContentEncryptionKey(cek, encodedProtectedHeader, recipient) {
|
|
243
|
+
const recipientKeyId = assertKeyId(recipient.keyId);
|
|
244
|
+
const encapsulationSeed = assertRandomBytes(await this.randomSource.getRandomBytes(ML_KEM_ENCAPSULATION_SEED_BYTES), ML_KEM_ENCAPSULATION_SEED_BYTES);
|
|
245
|
+
let sharedSecret;
|
|
246
|
+
let wrappingKey;
|
|
247
|
+
const wrappingIv = assertRandomBytes(await this.randomSource.getRandomBytes(AES_GCM_NONCE_BYTES), AES_GCM_NONCE_BYTES);
|
|
248
|
+
try {
|
|
249
|
+
const encapsulated = ml_kem768.encapsulate(recipient.publicKey, encapsulationSeed);
|
|
250
|
+
sharedSecret = encapsulated.sharedSecret;
|
|
251
|
+
wrappingKey = deriveRecipientWrappingKey(sharedSecret, encodedProtectedHeader, recipientKeyId);
|
|
252
|
+
const wrapAad = textEncoder.encode(`${encodedProtectedHeader}.${recipientKeyId}.cek`);
|
|
253
|
+
const sealedCek = gcm(wrappingKey, wrappingIv, wrapAad).encrypt(cek);
|
|
254
|
+
const { ciphertext, tag } = splitCiphertextAndTag(sealedCek);
|
|
255
|
+
const wrappedCek = {
|
|
256
|
+
v: WRAP_VERSION,
|
|
257
|
+
kem: 'ML-KEM-768',
|
|
258
|
+
kdf: 'HKDF-SHA-256',
|
|
259
|
+
wrap: 'A256GCM',
|
|
260
|
+
kemCiphertext: encodeBase64Url(encapsulated.cipherText),
|
|
261
|
+
iv: encodeBase64Url(wrappingIv),
|
|
262
|
+
ciphertext: encodeBase64Url(ciphertext),
|
|
263
|
+
tag: encodeBase64Url(tag),
|
|
264
|
+
};
|
|
265
|
+
return {
|
|
266
|
+
header: {
|
|
267
|
+
alg: SecureMessageKeyManagementAlgorithm.MlKem768HkdfSha256A256GcmKw,
|
|
268
|
+
kid: recipientKeyId,
|
|
269
|
+
},
|
|
270
|
+
encrypted_key: encodeJson(wrappedCek),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
encapsulationSeed.fill(0);
|
|
275
|
+
wrappingIv.fill(0);
|
|
276
|
+
sharedSecret?.fill(0);
|
|
277
|
+
wrappingKey?.fill(0);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async unwrapContentEncryptionKey(envelope, recipientKeyPair) {
|
|
281
|
+
const recipientKeyId = assertKeyId(recipientKeyPair.keyId);
|
|
282
|
+
const recipient = envelope.recipients.find(({ header }) => header.kid === recipientKeyId);
|
|
283
|
+
if (!recipient)
|
|
284
|
+
throw new Error('secure_message_recipient_not_found');
|
|
285
|
+
const wrappedCek = decodeJson(recipient.encrypted_key);
|
|
286
|
+
if (wrappedCek.v !== WRAP_VERSION
|
|
287
|
+
|| wrappedCek.kem !== 'ML-KEM-768'
|
|
288
|
+
|| wrappedCek.kdf !== 'HKDF-SHA-256'
|
|
289
|
+
|| wrappedCek.wrap !== 'A256GCM'
|
|
290
|
+
|| typeof wrappedCek.kemCiphertext !== 'string'
|
|
291
|
+
|| typeof wrappedCek.iv !== 'string'
|
|
292
|
+
|| typeof wrappedCek.ciphertext !== 'string'
|
|
293
|
+
|| typeof wrappedCek.tag !== 'string') {
|
|
294
|
+
throw new Error('secure_message_recipient_wrap_invalid');
|
|
295
|
+
}
|
|
296
|
+
const sharedSecret = ml_kem768.decapsulate(decodeBase64Url(wrappedCek.kemCiphertext), recipientKeyPair.secretKey);
|
|
297
|
+
const wrappingKey = deriveRecipientWrappingKey(sharedSecret, envelope.protected, recipientKeyId);
|
|
298
|
+
try {
|
|
299
|
+
return gcm(wrappingKey, decodeBase64Url(wrappedCek.iv), textEncoder.encode(`${envelope.protected}.${recipientKeyId}.cek`)).decrypt(joinCiphertextAndTag(decodeBase64Url(wrappedCek.ciphertext), decodeBase64Url(wrappedCek.tag)));
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
sharedSecret.fill(0);
|
|
303
|
+
wrappingKey.fill(0);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Multi-recipient secure messages
|
|
2
|
+
|
|
3
|
+
## Public API
|
|
4
|
+
|
|
5
|
+
Create `MultiRecipientSecureMessageCrypto` with a runtime-provided
|
|
6
|
+
`SecureRandomSource`. The runtime supplies cryptographically secure random
|
|
7
|
+
bytes only. The shared SDK owns ML-KEM-768, HKDF-SHA-256, SHA3-256 content-key
|
|
8
|
+
derivation and AES-256-GCM.
|
|
9
|
+
|
|
10
|
+
The byte-oriented methods are:
|
|
11
|
+
|
|
12
|
+
- `generateRecipientKeyPair(keyId)` creates an ML-KEM recipient key pair.
|
|
13
|
+
- `seal({ plaintext, contentType, recipients })` encrypts content once and
|
|
14
|
+
protects the CEK independently for every recipient public key.
|
|
15
|
+
- `open({ envelope, recipient })` authenticates and returns the original bytes
|
|
16
|
+
plus protected content type.
|
|
17
|
+
- `updateRecipients(...)` adds and removes recipient wraps without changing
|
|
18
|
+
the content ciphertext.
|
|
19
|
+
|
|
20
|
+
Text callers encode/decode UTF-8 at their UI boundary. Audio callers pass the
|
|
21
|
+
recorded binary bytes directly. The cryptographic layer never transcribes,
|
|
22
|
+
converts or interprets either payload.
|
|
23
|
+
|
|
24
|
+
## Key model
|
|
25
|
+
|
|
26
|
+
One random 32-byte message seed deterministically derives one AES-256 CEK using
|
|
27
|
+
SHA3-256. Content is encrypted once with AES-256-GCM. For each recipient:
|
|
28
|
+
|
|
29
|
+
1. ML-KEM-768 independently produces a recipient-specific shared secret and
|
|
30
|
+
KEM ciphertext.
|
|
31
|
+
2. HKDF-SHA-256 binds a wrapping key to the protected header and recipient
|
|
32
|
+
`kid`.
|
|
33
|
+
3. AES-256-GCM protects the common CEK with that recipient wrapping key.
|
|
34
|
+
|
|
35
|
+
The common CEK is not the ML-KEM shared secret. This distinction permits every
|
|
36
|
+
authorized recipient to open the same content ciphertext while preserving an
|
|
37
|
+
independent post-quantum encapsulation for each recipient key.
|
|
38
|
+
|
|
39
|
+
## Recipient changes
|
|
40
|
+
|
|
41
|
+
`updateRecipients(...)` proves possession of one current recipient secret key,
|
|
42
|
+
unwraps the CEK in memory, adds independently wrapped CEKs for new public keys,
|
|
43
|
+
and removes selected old entries. It does not decrypt or re-encrypt message
|
|
44
|
+
content. Temporary buffers holding seeds, shared secrets, CEKs and wrapping
|
|
45
|
+
keys are cleared after use where the JavaScript runtime permits it.
|
|
46
|
+
|
|
47
|
+
This method is a cryptographic mechanism, not an authorization decision. A BFF
|
|
48
|
+
or product service must first enforce the current subject, device/channel,
|
|
49
|
+
Consent and recovery policy. It must reject an empty final recipient list.
|
|
50
|
+
|
|
51
|
+
## Storage and channels
|
|
52
|
+
|
|
53
|
+
Storage persists the opaque envelope from `sos-data-utils-ts`. It need not
|
|
54
|
+
retain a service recipient once the current user devices have received their
|
|
55
|
+
wraps. Web, telephone, WhatsApp or future channel DCRs use distinct `kid` and
|
|
56
|
+
ML-KEM keys, but the message remains one ciphertext.
|
|
57
|
+
|
|
58
|
+
Audio duration, file size, retention, notification email and voicemail access
|
|
59
|
+
are product-level policies. They do not change this envelope or core API.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sos-sdk-core-ts",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Runtime-neutral shared orchestration for SOS, Vet and UHC products",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Connecting Solution & Applications Ltd",
|
|
@@ -27,9 +27,18 @@
|
|
|
27
27
|
"./place-card-association": {
|
|
28
28
|
"types": "./dist/place-card-association.d.ts",
|
|
29
29
|
"default": "./dist/place-card-association.js"
|
|
30
|
+
},
|
|
31
|
+
"./secure-message-crypto": {
|
|
32
|
+
"types": "./dist/secure-message-crypto.d.ts",
|
|
33
|
+
"default": "./dist/secure-message-crypto.js"
|
|
30
34
|
}
|
|
31
35
|
},
|
|
32
|
-
"files": [
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"README.md",
|
|
39
|
+
"CHANGELOG.md",
|
|
40
|
+
"docs"
|
|
41
|
+
],
|
|
33
42
|
"scripts": {
|
|
34
43
|
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
35
44
|
"build": "npm run clean && tsc -p tsconfig.json",
|
|
@@ -39,7 +48,10 @@
|
|
|
39
48
|
"prepack": "npm run check"
|
|
40
49
|
},
|
|
41
50
|
"dependencies": {
|
|
42
|
-
"
|
|
51
|
+
"@noble/ciphers": "2.4.0",
|
|
52
|
+
"@noble/hashes": "2.4.0",
|
|
53
|
+
"@noble/post-quantum": "0.7.1",
|
|
54
|
+
"sos-data-utils-ts": "0.3.9"
|
|
43
55
|
},
|
|
44
56
|
"devDependencies": {
|
|
45
57
|
"@types/node": "^22.5.0",
|