sos-sdk-core-ts 0.1.6 → 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 CHANGED
@@ -2,13 +2,31 @@
2
2
 
3
3
  ## Unreleased
4
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
+
20
+ ## 0.2.0
21
+
22
+ - Redefine `sos-sdk-core-ts` as the runtime-neutral orchestration layer consumed
23
+ by SOSChain, Vet and UHC, above `sos-data-utils-ts` and below product SDKs.
24
+ - Remove the accidental dependency on and root reexport of `uhc-sdk-core-ts`.
25
+ - Add neutral `authorizeEmergencyAction` and `EmergencyDeliveryTarget` APIs;
26
+ retain the older `Soschain*` names as deprecated compatibility aliases.
5
27
  - Consume emergency evidence, Group/ServiceRequest and card-target contracts
6
28
  from `sos-data-utils-ts@0.3.0` instead of maintaining SDK-local copies.
7
29
  - Preserve the existing SDK root and subpath exports as compatibility aliases.
8
- - Pin `uhc-sdk-core-ts@0.6.1` so the complete tree deduplicates to one
9
- `sos-data-utils-ts@0.3.0` installation.
10
- - Pin the first registry release to `uhc-sdk-core-ts@0.6.0` so SOSChain
11
- consumers can remove the historical vendored package from staging builds.
12
30
 
13
31
  ## 0.1.5
14
32
 
package/README.md CHANGED
@@ -1,12 +1,39 @@
1
- # SOSChain SDK Core
1
+ # Shared SOS SDK Core
2
+ ## Blocking local-first SDK release
2
3
 
3
- Runtime-neutral SOSChain extension of `uhc-sdk-core-ts`. Deterministic record
4
- types and builders come from `sos-data-utils-ts`; this SDK owns authorization
5
- and orchestration.
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.
6
10
 
7
- It consumes the exact published `uhc-sdk-core-ts` contract. Git, workspace,
8
- vendored and `file:` dependencies are forbidden in committed or deployed
9
- dependency state.
11
+
12
+ Runtime-neutral shared SDK for SOSChain, VetChain and UHC. Deterministic
13
+ cross-product record types and builders come from `sos-data-utils-ts`; this SDK
14
+ owns reusable authorization and orchestration without Node, browser, gateway or
15
+ product-specific behavior.
16
+
17
+ The canonical architecture keeps both shared layers visible:
18
+
19
+ ```text
20
+ gdc-* frozen
21
+ ↓
22
+ sos-data-utils-ts
23
+ ↓
24
+ sos-sdk-core-ts
25
+ ├──→ vet-sdk-core-ts
26
+ ├──→ uhc-sdk-core-ts
27
+ └──→ sos-sdk-node-ts
28
+ ```
29
+
30
+ Vet and UHC may also depend directly on `sos-data-utils-ts` when they import or
31
+ expose its types. `sos-sdk-core-ts` never depends on or reexports either product
32
+ SDK. `sos-sdk-node-ts` owns SOSChain-specific Node transport, persistence and
33
+ jobs; those adapters do not belong here.
34
+
35
+ Git, workspace, vendored and `file:` dependencies are forbidden in committed
36
+ or deployed dependency state.
10
37
 
11
38
  The emergency-group contract treats `emergencyGroupId`, `subjectId` and
12
39
  `callerId` as opaque business identifiers. A group assistance request is one
@@ -16,17 +43,36 @@ other resource is projected only at an integration boundary.
16
43
  Groups are named collections. A scanned card is attached to one selected group
17
44
  before that group's identifiers are projected into a single service request.
18
45
 
19
- `authorizeSoschainEmergencyAction` separately checks trusted evidence for every
46
+ `authorizeEmergencyAction` separately checks trusted evidence for every
20
47
  member and for `sos:call` or `sos:request`. Identity verification alone never
21
48
  grants an emergency capability. Regional/local delivery is represented by
22
- `SoschainEmergencyDeliveryTarget`; transport and tenant routing stay outside FHIR.
49
+ `EmergencyDeliveryTarget`; transport and tenant routing stay outside FHIR.
23
50
  The `incident-reporter` relationship represents a short-lived grant issued by
24
51
  a trusted scan resolver to an already verified person at the scene; it does
25
52
  not imply guardianship, control or professional status.
26
53
 
54
+ The older `SoschainEmergency*` names remain deprecated compatibility aliases
55
+ to the same types and implementation; they are not a second contract.
56
+
27
57
  `buildSoschainPlaceCardAssociationClaims` records a UHC/VetChain card link to
28
58
  a canonical SOSChain Place or vehicle without merging either identity.
29
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
+
30
76
  ```bash
31
77
  npm install
32
78
  npm run check
@@ -1,6 +1,7 @@
1
- import type { SoschainEmergencyAuthorizationEvidence, SoschainEmergencyCapability } from 'sos-data-utils-ts/emergency-evidence';
2
- export type { SoschainEmergencyAuthorizationEvidence, SoschainEmergencyCapability, SoschainEvidenceDomain, } from 'sos-data-utils-ts/emergency-evidence';
3
- export type SoschainEmergencyDeliveryTarget = Readonly<{
1
+ import type { EmergencyAuthorizationEvidence, EmergencyCapability, EvidenceDomain } from 'sos-data-utils-ts/emergency-evidence';
2
+ export type { EmergencyAuthorizationEvidence, EmergencyCapability, EvidenceDomain, } from 'sos-data-utils-ts/emergency-evidence';
3
+ /** Runtime-neutral endpoint selected after authorization succeeds. */
4
+ export type EmergencyDeliveryTarget = Readonly<{
4
5
  tenantId: string;
5
6
  jurisdiction: string;
6
7
  locality?: string;
@@ -10,10 +11,20 @@ export type SoschainEmergencyDeliveryTarget = Readonly<{
10
11
  * Requires current trusted evidence for every affected subject and capability.
11
12
  * Identifier syntax and the legal meaning of each issuer remain policy concerns.
12
13
  */
13
- export declare function authorizeSoschainEmergencyAction(input: Readonly<{
14
+ export declare function authorizeEmergencyAction(input: Readonly<{
14
15
  callerId: string;
15
16
  memberSubjectIds: readonly string[];
16
- capability: SoschainEmergencyCapability;
17
- evidence: readonly SoschainEmergencyAuthorizationEvidence[];
17
+ capability: EmergencyCapability;
18
+ evidence: readonly EmergencyAuthorizationEvidence[];
18
19
  now?: string;
19
20
  }>): readonly string[];
21
+ /** @deprecated Use `EmergencyDeliveryTarget`. */
22
+ export type SoschainEmergencyDeliveryTarget = EmergencyDeliveryTarget;
23
+ /** @deprecated Import `EmergencyAuthorizationEvidence` from this module. */
24
+ export type SoschainEmergencyAuthorizationEvidence = EmergencyAuthorizationEvidence;
25
+ /** @deprecated Import `EmergencyCapability` from this module. */
26
+ export type SoschainEmergencyCapability = EmergencyCapability;
27
+ /** @deprecated Import `EvidenceDomain` from this module. */
28
+ export type SoschainEvidenceDomain = EvidenceDomain;
29
+ /** @deprecated Use `authorizeEmergencyAction`. */
30
+ export declare const authorizeSoschainEmergencyAction: typeof authorizeEmergencyAction;
@@ -2,7 +2,7 @@
2
2
  * Requires current trusted evidence for every affected subject and capability.
3
3
  * Identifier syntax and the legal meaning of each issuer remain policy concerns.
4
4
  */
5
- export function authorizeSoschainEmergencyAction(input) {
5
+ export function authorizeEmergencyAction(input) {
6
6
  const at = Date.parse(input.now ?? new Date().toISOString());
7
7
  const evidenceIds = new Set();
8
8
  for (const subjectId of input.memberSubjectIds) {
@@ -17,3 +17,5 @@ export function authorizeSoschainEmergencyAction(input) {
17
17
  }
18
18
  return [...evidenceIds];
19
19
  }
20
+ /** @deprecated Use `authorizeEmergencyAction`. */
21
+ export const authorizeSoschainEmergencyAction = authorizeEmergencyAction;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- /** SOSChain extends UHC without moving product-specific contracts into UHC. */
2
- export * from 'uhc-sdk-core-ts';
3
- export * from 'sos-data-utils-ts/emergency-evidence';
1
+ /** Shared runtime-neutral orchestration remains independent from product SDKs. */
2
+ export { EmergencyCapabilities, EvidenceDomains, EvidenceRelationships, SoschainEmergencyCapabilities, SoschainEvidenceDomains, SoschainEvidenceRelationships, } from 'sos-data-utils-ts/emergency-evidence';
3
+ export type { EvidenceRelationship, SoschainEvidenceRelationship, } from 'sos-data-utils-ts/emergency-evidence';
4
4
  export * from './emergency-group.js';
5
5
  export * from './emergency-authorization.js';
6
6
  export * from './place-card-association.js';
7
+ export * from './secure-message-crypto.js';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- /** SOSChain extends UHC without moving product-specific contracts into UHC. */
2
- export * from 'uhc-sdk-core-ts';
3
- export * from 'sos-data-utils-ts/emergency-evidence';
1
+ /** Shared runtime-neutral orchestration remains independent from product SDKs. */
2
+ export { EmergencyCapabilities, EvidenceDomains, EvidenceRelationships, SoschainEmergencyCapabilities, SoschainEvidenceDomains, SoschainEvidenceRelationships, } from 'sos-data-utils-ts/emergency-evidence';
4
3
  export * from './emergency-group.js';
5
4
  export * from './emergency-authorization.js';
6
5
  export * from './place-card-association.js';
6
+ export * from './secure-message-crypto.js';
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "sos-sdk-core-ts",
3
- "version": "0.1.6",
4
- "description": "Runtime-neutral SOSChain contracts extending uhc-sdk-core-ts",
3
+ "version": "0.2.2",
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",
7
7
  "repository": {
@@ -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": ["dist", "README.md", "CHANGELOG.md"],
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,8 +48,10 @@
39
48
  "prepack": "npm run check"
40
49
  },
41
50
  "dependencies": {
42
- "sos-data-utils-ts": "0.3.0",
43
- "uhc-sdk-core-ts": "0.6.1"
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"
44
55
  },
45
56
  "devDependencies": {
46
57
  "@types/node": "^22.5.0",