@meshline/sdk 0.0.0-stage → 0.1.0-alpha.1
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 +21 -0
- package/README.md +43 -2
- package/dist/channels/repository.d.ts +73 -0
- package/dist/channels/repository.d.ts.map +1 -0
- package/dist/channels/repository.js +367 -0
- package/dist/channels/repository.js.map +1 -0
- package/dist/client/account-operations.d.ts +41 -0
- package/dist/client/account-operations.d.ts.map +1 -0
- package/dist/client/account-operations.js +304 -0
- package/dist/client/account-operations.js.map +1 -0
- package/dist/client/conversations.d.ts +36 -0
- package/dist/client/conversations.d.ts.map +1 -0
- package/dist/client/conversations.js +144 -0
- package/dist/client/conversations.js.map +1 -0
- package/dist/client.d.ts +53 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +162 -0
- package/dist/client.js.map +1 -0
- package/dist/components/account.d.ts +38 -0
- package/dist/components/account.d.ts.map +1 -0
- package/dist/components/account.js +243 -0
- package/dist/components/account.js.map +1 -0
- package/dist/components/channel.d.ts +78 -0
- package/dist/components/channel.d.ts.map +1 -0
- package/dist/components/channel.js +475 -0
- package/dist/components/channel.js.map +1 -0
- package/dist/components/component.d.ts +49 -0
- package/dist/components/component.d.ts.map +1 -0
- package/dist/components/component.js +194 -0
- package/dist/components/component.js.map +1 -0
- package/dist/components/device.d.ts +60 -0
- package/dist/components/device.d.ts.map +1 -0
- package/dist/components/device.js +648 -0
- package/dist/components/device.js.map +1 -0
- package/dist/components/group.d.ts +141 -0
- package/dist/components/group.d.ts.map +1 -0
- package/dist/components/group.js +933 -0
- package/dist/components/group.js.map +1 -0
- package/dist/components/message.d.ts +63 -0
- package/dist/components/message.d.ts.map +1 -0
- package/dist/components/message.js +439 -0
- package/dist/components/message.js.map +1 -0
- package/dist/components/profile.d.ts +37 -0
- package/dist/components/profile.d.ts.map +1 -0
- package/dist/components/profile.js +232 -0
- package/dist/components/profile.js.map +1 -0
- package/dist/crypto/groups.d.ts +56 -0
- package/dist/crypto/groups.d.ts.map +1 -0
- package/dist/crypto/groups.js +163 -0
- package/dist/crypto/groups.js.map +1 -0
- package/dist/crypto/messages.d.ts +52 -0
- package/dist/crypto/messages.d.ts.map +1 -0
- package/dist/crypto/messages.js +180 -0
- package/dist/crypto/messages.js.map +1 -0
- package/dist/crypto/primitives.d.ts +30 -0
- package/dist/crypto/primitives.d.ts.map +1 -0
- package/dist/crypto/primitives.js +102 -0
- package/dist/crypto/primitives.js.map +1 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +14 -0
- package/dist/errors.js.map +1 -0
- package/dist/groups/account-sync.d.ts +28 -0
- package/dist/groups/account-sync.d.ts.map +1 -0
- package/dist/groups/account-sync.js +235 -0
- package/dist/groups/account-sync.js.map +1 -0
- package/dist/groups/messages.d.ts +39 -0
- package/dist/groups/messages.d.ts.map +1 -0
- package/dist/groups/messages.js +141 -0
- package/dist/groups/messages.js.map +1 -0
- package/dist/groups/operations.d.ts +57 -0
- package/dist/groups/operations.d.ts.map +1 -0
- package/dist/groups/operations.js +225 -0
- package/dist/groups/operations.js.map +1 -0
- package/dist/groups/repository.d.ts +56 -0
- package/dist/groups/repository.d.ts.map +1 -0
- package/dist/groups/repository.js +254 -0
- package/dist/groups/repository.js.map +1 -0
- package/dist/groups/secrets.d.ts +67 -0
- package/dist/groups/secrets.d.ts.map +1 -0
- package/dist/groups/secrets.js +452 -0
- package/dist/groups/secrets.js.map +1 -0
- package/dist/groups/state.d.ts +39 -0
- package/dist/groups/state.d.ts.map +1 -0
- package/dist/groups/state.js +201 -0
- package/dist/groups/state.js.map +1 -0
- package/dist/identity/identifiers.d.ts +9 -0
- package/dist/identity/identifiers.d.ts.map +1 -0
- package/dist/identity/identifiers.js +37 -0
- package/dist/identity/identifiers.js.map +1 -0
- package/dist/identity/neo.d.ts +11 -0
- package/dist/identity/neo.d.ts.map +1 -0
- package/dist/identity/neo.js +77 -0
- package/dist/identity/neo.js.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +46 -0
- package/dist/index.js.map +1 -0
- package/dist/interactions.d.ts +31 -0
- package/dist/interactions.d.ts.map +1 -0
- package/dist/interactions.js +2 -0
- package/dist/interactions.js.map +1 -0
- package/dist/messages/account-sender.d.ts +9 -0
- package/dist/messages/account-sender.d.ts.map +1 -0
- package/dist/messages/account-sender.js +46 -0
- package/dist/messages/account-sender.js.map +1 -0
- package/dist/messages/contacts.d.ts +103 -0
- package/dist/messages/contacts.d.ts.map +1 -0
- package/dist/messages/contacts.js +525 -0
- package/dist/messages/contacts.js.map +1 -0
- package/dist/messages/outbox.d.ts +66 -0
- package/dist/messages/outbox.d.ts.map +1 -0
- package/dist/messages/outbox.js +167 -0
- package/dist/messages/outbox.js.map +1 -0
- package/dist/messages/receiver.d.ts +35 -0
- package/dist/messages/receiver.d.ts.map +1 -0
- package/dist/messages/receiver.js +113 -0
- package/dist/messages/receiver.js.map +1 -0
- package/dist/messages/repository.d.ts +89 -0
- package/dist/messages/repository.d.ts.map +1 -0
- package/dist/messages/repository.js +295 -0
- package/dist/messages/repository.js.map +1 -0
- package/dist/models/authentication.d.ts +37 -0
- package/dist/models/authentication.d.ts.map +1 -0
- package/dist/models/authentication.js +53 -0
- package/dist/models/authentication.js.map +1 -0
- package/dist/models/channels.d.ts +141 -0
- package/dist/models/channels.d.ts.map +1 -0
- package/dist/models/channels.js +258 -0
- package/dist/models/channels.js.map +1 -0
- package/dist/models/contacts.d.ts +68 -0
- package/dist/models/contacts.d.ts.map +1 -0
- package/dist/models/contacts.js +186 -0
- package/dist/models/contacts.js.map +1 -0
- package/dist/models/content.d.ts +37 -0
- package/dist/models/content.d.ts.map +1 -0
- package/dist/models/content.js +137 -0
- package/dist/models/content.js.map +1 -0
- package/dist/models/device-state.d.ts +8 -0
- package/dist/models/device-state.d.ts.map +1 -0
- package/dist/models/device-state.js +22 -0
- package/dist/models/device-state.js.map +1 -0
- package/dist/models/group-admission.d.ts +111 -0
- package/dist/models/group-admission.d.ts.map +1 -0
- package/dist/models/group-admission.js +153 -0
- package/dist/models/group-admission.js.map +1 -0
- package/dist/models/group-keys.d.ts +30 -0
- package/dist/models/group-keys.d.ts.map +1 -0
- package/dist/models/group-keys.js +52 -0
- package/dist/models/group-keys.js.map +1 -0
- package/dist/models/group-management.d.ts +190 -0
- package/dist/models/group-management.d.ts.map +1 -0
- package/dist/models/group-management.js +263 -0
- package/dist/models/group-management.js.map +1 -0
- package/dist/models/groups.d.ts +81 -0
- package/dist/models/groups.d.ts.map +1 -0
- package/dist/models/groups.js +127 -0
- package/dist/models/groups.js.map +1 -0
- package/dist/models/identity.d.ts +47 -0
- package/dist/models/identity.d.ts.map +1 -0
- package/dist/models/identity.js +136 -0
- package/dist/models/identity.js.map +1 -0
- package/dist/models/messages.d.ts +104 -0
- package/dist/models/messages.d.ts.map +1 -0
- package/dist/models/messages.js +168 -0
- package/dist/models/messages.js.map +1 -0
- package/dist/models/profile.d.ts +24 -0
- package/dist/models/profile.d.ts.map +1 -0
- package/dist/models/profile.js +47 -0
- package/dist/models/profile.js.map +1 -0
- package/dist/models/relay.d.ts +39 -0
- package/dist/models/relay.d.ts.map +1 -0
- package/dist/models/relay.js +160 -0
- package/dist/models/relay.js.map +1 -0
- package/dist/protocol/codec.d.ts +36 -0
- package/dist/protocol/codec.d.ts.map +1 -0
- package/dist/protocol/codec.js +144 -0
- package/dist/protocol/codec.js.map +1 -0
- package/dist/protocol/context.d.ts +14 -0
- package/dist/protocol/context.d.ts.map +1 -0
- package/dist/protocol/context.js +43 -0
- package/dist/protocol/context.js.map +1 -0
- package/dist/protocol/encoding.d.ts +15 -0
- package/dist/protocol/encoding.d.ts.map +1 -0
- package/dist/protocol/encoding.js +133 -0
- package/dist/protocol/encoding.js.map +1 -0
- package/dist/protocol/json.d.ts +16 -0
- package/dist/protocol/json.d.ts.map +1 -0
- package/dist/protocol/json.js +172 -0
- package/dist/protocol/json.js.map +1 -0
- package/dist/protocol/validation.d.ts +4 -0
- package/dist/protocol/validation.d.ts.map +1 -0
- package/dist/protocol/validation.js +18 -0
- package/dist/protocol/validation.js.map +1 -0
- package/dist/runtime/abort.d.ts +6 -0
- package/dist/runtime/abort.d.ts.map +1 -0
- package/dist/runtime/abort.js +32 -0
- package/dist/runtime/abort.js.map +1 -0
- package/dist/runtime/async-gate.d.ts +6 -0
- package/dist/runtime/async-gate.d.ts.map +1 -0
- package/dist/runtime/async-gate.js +15 -0
- package/dist/runtime/async-gate.js.map +1 -0
- package/dist/runtime/async-pulse.d.ts +7 -0
- package/dist/runtime/async-pulse.d.ts.map +1 -0
- package/dist/runtime/async-pulse.js +23 -0
- package/dist/runtime/async-pulse.js.map +1 -0
- package/dist/runtime/clock.d.ts +16 -0
- package/dist/runtime/clock.d.ts.map +1 -0
- package/dist/runtime/clock.js +62 -0
- package/dist/runtime/clock.js.map +1 -0
- package/dist/runtime/events.d.ts +11 -0
- package/dist/runtime/events.d.ts.map +1 -0
- package/dist/runtime/events.js +45 -0
- package/dist/runtime/events.js.map +1 -0
- package/dist/runtime/url.d.ts +3 -0
- package/dist/runtime/url.d.ts.map +1 -0
- package/dist/runtime/url.js +23 -0
- package/dist/runtime/url.js.map +1 -0
- package/dist/storage/signed-request.d.ts +14 -0
- package/dist/storage/signed-request.d.ts.map +1 -0
- package/dist/storage/signed-request.js +26 -0
- package/dist/storage/signed-request.js.map +1 -0
- package/dist/storage/store.d.ts +58 -0
- package/dist/storage/store.d.ts.map +1 -0
- package/dist/storage/store.js +52 -0
- package/dist/storage/store.js.map +1 -0
- package/dist/storage/transaction.d.ts +7 -0
- package/dist/storage/transaction.d.ts.map +1 -0
- package/dist/storage/transaction.js +22 -0
- package/dist/storage/transaction.js.map +1 -0
- package/dist/transport/client.d.ts +61 -0
- package/dist/transport/client.d.ts.map +1 -0
- package/dist/transport/client.js +392 -0
- package/dist/transport/client.js.map +1 -0
- package/dist/transport/discovery.d.ts +19 -0
- package/dist/transport/discovery.d.ts.map +1 -0
- package/dist/transport/discovery.js +75 -0
- package/dist/transport/discovery.js.map +1 -0
- package/dist/transport/endpoint.d.ts +6 -0
- package/dist/transport/endpoint.d.ts.map +1 -0
- package/dist/transport/endpoint.js +35 -0
- package/dist/transport/endpoint.js.map +1 -0
- package/dist/transport/http.d.ts +52 -0
- package/dist/transport/http.d.ts.map +1 -0
- package/dist/transport/http.js +128 -0
- package/dist/transport/http.js.map +1 -0
- package/dist/transport/pool.d.ts +31 -0
- package/dist/transport/pool.d.ts.map +1 -0
- package/dist/transport/pool.js +137 -0
- package/dist/transport/pool.js.map +1 -0
- package/dist/transport/relay-error.d.ts +23 -0
- package/dist/transport/relay-error.d.ts.map +1 -0
- package/dist/transport/relay-error.js +47 -0
- package/dist/transport/relay-error.js.map +1 -0
- package/dist/transport/rpc.d.ts +31 -0
- package/dist/transport/rpc.d.ts.map +1 -0
- package/dist/transport/rpc.js +56 -0
- package/dist/transport/rpc.js.map +1 -0
- package/dist/transport/session.d.ts +49 -0
- package/dist/transport/session.d.ts.map +1 -0
- package/dist/transport/session.js +169 -0
- package/dist/transport/session.js.map +1 -0
- package/dist/transport/socket.d.ts +55 -0
- package/dist/transport/socket.d.ts.map +1 -0
- package/dist/transport/socket.js +266 -0
- package/dist/transport/socket.js.map +1 -0
- package/dist/transport/subscription.d.ts +36 -0
- package/dist/transport/subscription.d.ts.map +1 -0
- package/dist/transport/subscription.js +169 -0
- package/dist/transport/subscription.js.map +1 -0
- package/package.json +35 -4
- package/src/channels/repository.ts +244 -0
- package/src/client/account-operations.ts +183 -0
- package/src/client/conversations.ts +95 -0
- package/src/client.ts +108 -0
- package/src/components/account.ts +206 -0
- package/src/components/channel.ts +253 -0
- package/src/components/component.ts +151 -0
- package/src/components/device.ts +455 -0
- package/src/components/group.ts +546 -0
- package/src/components/message.ts +253 -0
- package/src/components/profile.ts +179 -0
- package/src/crypto/groups.ts +107 -0
- package/src/crypto/messages.ts +129 -0
- package/src/crypto/primitives.ts +103 -0
- package/src/errors.ts +13 -0
- package/src/groups/account-sync.ts +144 -0
- package/src/groups/messages.ts +89 -0
- package/src/groups/operations.ts +145 -0
- package/src/groups/repository.ts +161 -0
- package/src/groups/secrets.ts +256 -0
- package/src/groups/state.ts +138 -0
- package/src/identity/identifiers.ts +41 -0
- package/src/identity/neo.ts +72 -0
- package/src/index.ts +52 -0
- package/src/interactions.ts +35 -0
- package/src/messages/account-sender.ts +33 -0
- package/src/messages/contacts.ts +379 -0
- package/src/messages/outbox.ts +152 -0
- package/src/messages/receiver.ts +86 -0
- package/src/messages/repository.ts +230 -0
- package/src/models/authentication.ts +63 -0
- package/src/models/channels.ts +155 -0
- package/src/models/contacts.ts +141 -0
- package/src/models/content.ts +105 -0
- package/src/models/device-state.ts +18 -0
- package/src/models/group-admission.ts +110 -0
- package/src/models/group-keys.ts +34 -0
- package/src/models/group-management.ts +182 -0
- package/src/models/groups.ts +100 -0
- package/src/models/identity.ts +164 -0
- package/src/models/messages.ts +139 -0
- package/src/models/profile.ts +46 -0
- package/src/models/relay.ts +151 -0
- package/src/protocol/codec.ts +144 -0
- package/src/protocol/context.ts +39 -0
- package/src/protocol/encoding.ts +106 -0
- package/src/protocol/json.ts +140 -0
- package/src/protocol/validation.ts +13 -0
- package/src/runtime/abort.ts +27 -0
- package/src/runtime/async-gate.ts +16 -0
- package/src/runtime/async-pulse.ts +20 -0
- package/src/runtime/clock.ts +63 -0
- package/src/runtime/events.ts +31 -0
- package/src/runtime/url.ts +17 -0
- package/src/storage/signed-request.ts +26 -0
- package/src/storage/store.ts +90 -0
- package/src/storage/transaction.ts +17 -0
- package/src/transport/client.ts +328 -0
- package/src/transport/discovery.ts +64 -0
- package/src/transport/endpoint.ts +32 -0
- package/src/transport/http.ts +129 -0
- package/src/transport/pool.ts +115 -0
- package/src/transport/relay-error.ts +46 -0
- package/src/transport/rpc.ts +56 -0
- package/src/transport/session.ts +154 -0
- package/src/transport/socket.ts +248 -0
- package/src/transport/subscription.ts +117 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { ProtocolError } from '../errors.js';
|
|
2
|
+
import type { DeviceSigner } from '../interactions.js';
|
|
3
|
+
import { validateIdentifier } from '../identity/identifiers.js';
|
|
4
|
+
import { validateAccountId } from '../identity/neo.js';
|
|
5
|
+
import { contactAuthorizationCodec, type ContactAuthorization } from '../models/contacts.js';
|
|
6
|
+
import { certificateId, deviceCertificateCodec, validateCertificate, type DeviceCertificate } from '../models/identity.js';
|
|
7
|
+
import { messageEnvelopeCodec, messageEnvelopeInput, messageKeyBoxCodec, messagePayloadCodec, validateMessageKeyBox, validateMessageSendRequest, verifyMessageEnvelope,
|
|
8
|
+
type MessageEnvelope, type MessageKeyBox, type MessageSendRequest } from '../models/messages.js';
|
|
9
|
+
import { signingInput, type NetworkContext } from '../protocol/context.js';
|
|
10
|
+
import { concatBytes, encodeBase64Url, encodeUtf8 } from '../protocol/encoding.js';
|
|
11
|
+
import { canonicalBytes, parseJson, requireSafeInteger, type JsonObject } from '../protocol/json.js';
|
|
12
|
+
import { throwIfAborted } from '../runtime/clock.js';
|
|
13
|
+
import { agreeKey, decryptAes, deriveKey, encryptAes, encryptionPublicKey, requireLength, sha256, systemRandom, type RandomSource } from './primitives.js';
|
|
14
|
+
|
|
15
|
+
const keyBoxSalt = sha256(encodeUtf8('Meshline/keybox-salt/v1'));
|
|
16
|
+
type MessageHeader = Pick<MessageEnvelope, 'messageId' | 'createdAt' | 'from' | 'fromDeviceId' | 'to'>;
|
|
17
|
+
function validateHeader(value: MessageHeader): void {
|
|
18
|
+
validateIdentifier('message', value.messageId); validateIdentifier('device', value.fromDeviceId); validateAccountId(value.from); validateAccountId(value.to); requireSafeInteger(value.createdAt, 0);
|
|
19
|
+
}
|
|
20
|
+
/** Fixed protocol projection; envelope extensions are signed but are not part of payload AAD. */
|
|
21
|
+
export function messageAad(value: MessageHeader, context: NetworkContext): Uint8Array {
|
|
22
|
+
validateHeader(value);
|
|
23
|
+
return signingInput({ $type: 'meshline.message.aad', message_id: value.messageId, created_at: value.createdAt, from: value.from, from_device_id: value.fromDeviceId, to: value.to }, context);
|
|
24
|
+
}
|
|
25
|
+
/** Fixed projection shared by HKDF info and AES-GCM key wrapping AAD. */
|
|
26
|
+
export function messageKeyBoxAad(value: MessageHeader & { readonly payload: { readonly alg: string } }, box: Pick<MessageKeyBox, 'alg' | 'deviceId' | 'enc'>, account: string, context: NetworkContext): Uint8Array {
|
|
27
|
+
validateHeader(value); validateAccountId(account); validateIdentifier('device', box.deviceId); requireLength(box.enc, 32, 'Ephemeral public key');
|
|
28
|
+
if (account !== value.from && account !== value.to) throw new ProtocolError('invalid_identity', 'Key box account must be a message participant.');
|
|
29
|
+
if (box.alg !== 'X25519-HKDF-SHA256-AES256GCM' || value.payload.alg !== 'AES-256-GCM') throw new ProtocolError('unsupported_algorithm', 'Unsupported message key wrapping algorithms.');
|
|
30
|
+
return signingInput({ $type: 'meshline.message.key_box.aad', alg: box.alg, payload_alg: value.payload.alg, message_id: value.messageId,
|
|
31
|
+
created_at: value.createdAt, from: value.from, from_device_id: value.fromDeviceId, account, device_id: box.deviceId, enc: encodeBase64Url(box.enc) }, context);
|
|
32
|
+
}
|
|
33
|
+
export interface EncryptMessageOptions {
|
|
34
|
+
readonly context: NetworkContext; readonly signer: DeviceSigner; readonly messageId: string; readonly createdAt: number;
|
|
35
|
+
readonly recipient: string; readonly payload: JsonObject; readonly recipientDevices: readonly DeviceCertificate[];
|
|
36
|
+
readonly senderDevices?: readonly DeviceCertificate[]; readonly authorization?: ContactAuthorization; readonly random?: RandomSource; readonly signal?: AbortSignal;
|
|
37
|
+
}
|
|
38
|
+
/** Builds immutable delivery material. Device lists must come from current authoritative states, checked by the calling manager. */
|
|
39
|
+
export async function encryptMessage(options: EncryptMessageOptions): Promise<MessageSendRequest> {
|
|
40
|
+
const { context, signer, messageId, createdAt, recipient, signal } = options; throwIfAborted(signal);
|
|
41
|
+
const random = options.random ?? systemRandom;
|
|
42
|
+
const certificate = deviceCertificateCodec.decode(deviceCertificateCodec.encode(signer.certificate)); validateCertificate(certificate, context);
|
|
43
|
+
const senderId = certificateId(certificate, context);
|
|
44
|
+
const recipients = copyDevices(options.recipientDevices, recipient, createdAt, context);
|
|
45
|
+
const senders = recipient === certificate.account ? undefined : copyDevices(options.senderDevices ?? [certificate], certificate.account, createdAt, context);
|
|
46
|
+
const authorization = options.authorization === undefined ? undefined : contactAuthorizationCodec.decode(contactAuthorizationCodec.encode(options.authorization));
|
|
47
|
+
if (createdAt < certificate.notBefore || createdAt >= certificate.expiresAt) throw new ProtocolError('unauthorized_device', 'Message signer certificate is outside its validity window.');
|
|
48
|
+
if (recipient === certificate.account && (authorization !== undefined || options.senderDevices !== undefined)) throw new ProtocolError('invalid_self_delivery', 'Self messages must omit sender devices and authorization.');
|
|
49
|
+
const header = { messageId, createdAt, from: certificate.account, fromDeviceId: senderId, to: recipient };
|
|
50
|
+
const aad = messageAad(header, context);
|
|
51
|
+
const plaintext = canonicalBytes(messagePayloadCodec.decode(options.payload));
|
|
52
|
+
let contentKey: Uint8Array | undefined;
|
|
53
|
+
try {
|
|
54
|
+
contentKey = random.bytes(32); requireLength(contentKey, 32, 'Content key');
|
|
55
|
+
const nonce = random.bytes(12); requireLength(nonce, 12, 'Payload nonce');
|
|
56
|
+
let envelope: MessageEnvelope = { ...header, payload: { alg: 'AES-256-GCM', nonce, ciphertext: encryptAes(contentKey, nonce, plaintext, aad) }, deviceSignature: new Uint8Array(64) };
|
|
57
|
+
envelope = { ...envelope, deviceSignature: (await signer.sign(messageEnvelopeInput(envelope, context), signal)).slice() };
|
|
58
|
+
throwIfAborted(signal);
|
|
59
|
+
if (certificateId(signer.certificate, context) !== senderId) throw new ProtocolError('invalid_identity', 'Device signer changed identity during message signing.');
|
|
60
|
+
verifyMessageEnvelope(envelope, certificate, context);
|
|
61
|
+
const recipientBoxes = sealKeys(envelope, recipient, recipients, contentKey, context, random);
|
|
62
|
+
const senderBoxes = senders && sealKeys(envelope, certificate.account, senders, contentKey, context, random);
|
|
63
|
+
const result: MessageSendRequest = { envelope, recipientBoxes, ...(senderBoxes ? { senderBoxes } : {}), ...(authorization ? { authorization } : {}) };
|
|
64
|
+
validateMessageSendRequest(result, createdAt); throwIfAborted(signal); return result;
|
|
65
|
+
} finally { contentKey?.fill(0); plaintext.fill(0); }
|
|
66
|
+
}
|
|
67
|
+
function copyDevices(values: readonly DeviceCertificate[], account: string, now: number, context: NetworkContext): DeviceCertificate[] {
|
|
68
|
+
if (values.length < 1 || values.length > 8) throw new ProtocolError('invalid_size', 'Message encryption requires between one and eight devices per account.');
|
|
69
|
+
const ids = new Set<string>();
|
|
70
|
+
return values.map(value => {
|
|
71
|
+
const copy = deviceCertificateCodec.decode(deviceCertificateCodec.encode(value)); validateCertificate(copy, context);
|
|
72
|
+
if (copy.account !== account || copy.notBefore > now || now >= copy.expiresAt) throw new ProtocolError('unauthorized_device', 'Encryption certificate belongs to another account or is outside its validity window.');
|
|
73
|
+
const id = certificateId(copy, context); if (ids.has(id)) throw new ProtocolError('duplicate_device', 'Duplicate encryption device.'); ids.add(id); return copy;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function sealKeys(envelope: MessageEnvelope, account: string, certificates: readonly DeviceCertificate[], contentKey: Uint8Array, context: NetworkContext, random: RandomSource): MessageKeyBox[] {
|
|
77
|
+
return certificates.map(certificate => {
|
|
78
|
+
let ephemeral: Uint8Array | undefined; let shared: Uint8Array | undefined; let wrappingKey: Uint8Array | undefined;
|
|
79
|
+
try {
|
|
80
|
+
ephemeral = random.bytes(32); requireLength(ephemeral, 32, 'Ephemeral private key');
|
|
81
|
+
const box = { deviceId: certificateId(certificate, context), alg: 'X25519-HKDF-SHA256-AES256GCM', enc: encryptionPublicKey(ephemeral) };
|
|
82
|
+
shared = agreeKey(ephemeral, certificate.encryptionPublicKey);
|
|
83
|
+
const aad = messageKeyBoxAad(envelope, box, account, context); wrappingKey = deriveKey(shared, keyBoxSalt, aad);
|
|
84
|
+
const nonce = random.bytes(12); requireLength(nonce, 12, 'Key box nonce');
|
|
85
|
+
return { ...box, sealedKey: concatBytes(nonce, encryptAes(wrappingKey, nonce, contentKey, aad)) };
|
|
86
|
+
} finally { ephemeral?.fill(0); shared?.fill(0); wrappingKey?.fill(0); }
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/** Implementations return a fresh 32-byte shared secret; this function owns and erases that buffer. */
|
|
90
|
+
export interface MessageDecryptor { readonly certificate: DeviceCertificate; deriveSharedSecret(peerPublicKey: Uint8Array, signal?: AbortSignal): Promise<Uint8Array> }
|
|
91
|
+
/** Local key access failures are retryable reception failures, never grounds for discarding a remote message. */
|
|
92
|
+
export class MessageKeyAccessError extends Error { override readonly name = 'MessageKeyAccessError'; }
|
|
93
|
+
export interface DecryptMessageOptions {
|
|
94
|
+
readonly context: NetworkContext; readonly receiver: MessageDecryptor; readonly envelope: MessageEnvelope; readonly keyBox: MessageKeyBox;
|
|
95
|
+
readonly sender: DeviceCertificate; readonly signal?: AbortSignal;
|
|
96
|
+
}
|
|
97
|
+
/** Verifies signature before key agreement and returns no plaintext until both GCM authentications and complete JSON validation pass. */
|
|
98
|
+
export async function decryptMessage(options: DecryptMessageOptions): Promise<JsonObject> {
|
|
99
|
+
const { context, receiver, signal } = options; throwIfAborted(signal);
|
|
100
|
+
const envelope = messageEnvelopeCodec.decode(messageEnvelopeCodec.encode(options.envelope));
|
|
101
|
+
const box = messageKeyBoxCodec.decode(messageKeyBoxCodec.encode(options.keyBox));
|
|
102
|
+
const sender = deviceCertificateCodec.decode(deviceCertificateCodec.encode(options.sender));
|
|
103
|
+
verifyMessageEnvelope(envelope, sender, context); validateMessageKeyBox(box);
|
|
104
|
+
// Public low-order-point validation distinguishes malformed remote input from failures opening local protected keys.
|
|
105
|
+
const probe = agreeKey(new Uint8Array(32).fill(1), box.enc); probe.fill(0);
|
|
106
|
+
let local: DeviceCertificate;
|
|
107
|
+
try { local = deviceCertificateCodec.decode(deviceCertificateCodec.encode(receiver.certificate)); validateCertificate(local, context); }
|
|
108
|
+
catch (cause) { throw new MessageKeyAccessError('Local device certificate is unavailable or invalid.', { cause }); }
|
|
109
|
+
const localId = certificateId(local, context);
|
|
110
|
+
if (local.account !== envelope.from && local.account !== envelope.to || box.deviceId !== localId) throw new ProtocolError('invalid_identity', 'Message or key box belongs to another account or device.');
|
|
111
|
+
let shared: Uint8Array | undefined; let wrappingKey: Uint8Array | undefined; let contentKey: Uint8Array | undefined; let plaintext: Uint8Array | undefined;
|
|
112
|
+
try {
|
|
113
|
+
try {
|
|
114
|
+
shared = await receiver.deriveSharedSecret(box.enc.slice(), signal); throwIfAborted(signal);
|
|
115
|
+
if (certificateId(receiver.certificate, context) !== localId) throw new Error('Decrypting device changed identity during key agreement.');
|
|
116
|
+
requireLength(shared, 32, 'X25519 shared secret');
|
|
117
|
+
if (shared.every(byte => byte === 0)) throw new MessageKeyAccessError('Device key agreement produced an all-zero shared secret.');
|
|
118
|
+
} catch (cause) {
|
|
119
|
+
throwIfAborted(signal);
|
|
120
|
+
if (cause instanceof MessageKeyAccessError) throw cause;
|
|
121
|
+
throw new MessageKeyAccessError('Local device key agreement failed.', { cause });
|
|
122
|
+
}
|
|
123
|
+
const aad = messageKeyBoxAad(envelope, box, local.account, context); wrappingKey = deriveKey(shared, keyBoxSalt, aad);
|
|
124
|
+
contentKey = decryptAes(wrappingKey, box.sealedKey.subarray(0, 12), box.sealedKey.subarray(12), aad);
|
|
125
|
+
requireLength(contentKey, 32, 'Content key');
|
|
126
|
+
plaintext = decryptAes(contentKey, envelope.payload.nonce, envelope.payload.ciphertext, messageAad(envelope, context));
|
|
127
|
+
return messagePayloadCodec.decode(parseJson(plaintext));
|
|
128
|
+
} finally { shared?.fill(0); wrappingKey?.fill(0); contentKey?.fill(0); plaintext?.fill(0); }
|
|
129
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { ed25519, x25519 } from '@noble/curves/ed25519.js';
|
|
2
|
+
import { p256 } from '@noble/curves/nist.js';
|
|
3
|
+
import { sha256 } from '@noble/hashes/sha2.js';
|
|
4
|
+
import { ripemd160 } from '@noble/hashes/legacy.js';
|
|
5
|
+
import { hkdf } from '@noble/hashes/hkdf.js';
|
|
6
|
+
import { gcm } from '@noble/ciphers/aes.js';
|
|
7
|
+
import { ProtocolError } from '../errors.js';
|
|
8
|
+
|
|
9
|
+
export { sha256 };
|
|
10
|
+
|
|
11
|
+
/** Platform cryptographic random source; implementations must never use Math.random. */
|
|
12
|
+
export interface RandomSource { bytes(length: number): Uint8Array }
|
|
13
|
+
|
|
14
|
+
/** Browser and Node.js random source. Expo supplies an explicit native source. */
|
|
15
|
+
export const systemRandom: RandomSource = {
|
|
16
|
+
bytes(length) {
|
|
17
|
+
if (!Number.isSafeInteger(length) || length < 0) throw new RangeError('Invalid random byte count.');
|
|
18
|
+
if (!globalThis.crypto?.getRandomValues)
|
|
19
|
+
throw new ProtocolError('random_unavailable', 'A cryptographic random source is required.');
|
|
20
|
+
const result = new Uint8Array(length);
|
|
21
|
+
for (let offset = 0; offset < length; offset += 65_536)
|
|
22
|
+
globalThis.crypto.getRandomValues(result.subarray(offset, offset + 65_536));
|
|
23
|
+
return result;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function requireLength(value: Uint8Array, length: number, name: string): void {
|
|
28
|
+
if (!(value instanceof Uint8Array) || value.length !== length)
|
|
29
|
+
throw new ProtocolError('invalid_length', `${name} must contain ${length} bytes.`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Ed25519 public key derived from a 32-byte seed. */
|
|
33
|
+
export function devicePublicKey(seed: Uint8Array): Uint8Array {
|
|
34
|
+
requireLength(seed, 32, 'Ed25519 seed');
|
|
35
|
+
return ed25519.getPublicKey(seed);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Signs the exact supplied bytes with Ed25519. */
|
|
39
|
+
export function signDevice(input: Uint8Array, seed: Uint8Array): Uint8Array {
|
|
40
|
+
requireLength(seed, 32, 'Ed25519 seed');
|
|
41
|
+
return ed25519.sign(input, seed);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Uses strict RFC 8032 verification, not permissive ZIP215 acceptance. */
|
|
45
|
+
export function verifyDevice(input: Uint8Array, signature: Uint8Array, publicKey: Uint8Array): boolean {
|
|
46
|
+
if (signature.length !== 64 || publicKey.length !== 32) return false;
|
|
47
|
+
return ed25519.verify(signature, input, publicKey, { zip215: false });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Compressed P-256 public key, in the format used by Neo N3 accounts. */
|
|
51
|
+
export function accountPublicKey(privateKey: Uint8Array): Uint8Array {
|
|
52
|
+
requireLength(privateKey, 32, 'P-256 private key');
|
|
53
|
+
return p256.getPublicKey(privateKey, true);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Verifies SHA-256 / P-256 compact signatures; both valid high-S and low-S forms are accepted. */
|
|
57
|
+
export function verifyAccount(input: Uint8Array, signature: Uint8Array, publicKey: Uint8Array): boolean {
|
|
58
|
+
if (signature.length !== 64 || publicKey.length !== 33 || (publicKey[0] !== 2 && publicKey[0] !== 3)) return false;
|
|
59
|
+
return p256.verify(signature, input, publicKey, { prehash: true, lowS: false, format: 'compact' });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Low-level signing helper; application signers retain ownership of account keys. */
|
|
63
|
+
export function signAccount(input: Uint8Array, privateKey: Uint8Array, random: RandomSource = systemRandom): Uint8Array {
|
|
64
|
+
requireLength(privateKey, 32, 'P-256 private key');
|
|
65
|
+
const entropy = random.bytes(32);
|
|
66
|
+
requireLength(entropy, 32, 'Signing entropy');
|
|
67
|
+
try { return p256.sign(input, privateKey, { prehash: true, lowS: false, format: 'compact', extraEntropy: entropy }); }
|
|
68
|
+
finally { entropy.fill(0); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function encryptionPublicKey(privateKey: Uint8Array): Uint8Array {
|
|
72
|
+
requireLength(privateKey, 32, 'X25519 private key');
|
|
73
|
+
return x25519.getPublicKey(privateKey);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function agreeKey(privateKey: Uint8Array, publicKey: Uint8Array): Uint8Array {
|
|
77
|
+
requireLength(privateKey, 32, 'X25519 private key');
|
|
78
|
+
requireLength(publicKey, 32, 'X25519 public key');
|
|
79
|
+
try { return x25519.getSharedSecret(privateKey, publicKey); }
|
|
80
|
+
catch (cause) { throw new ProtocolError('invalid_key', 'X25519 agreement failed, including an all-zero shared secret.', { cause }); }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function hash160(value: Uint8Array): Uint8Array { return ripemd160(sha256(value)); }
|
|
84
|
+
|
|
85
|
+
export function deriveKey(secret: Uint8Array, salt: Uint8Array, info: Uint8Array, length = 32): Uint8Array {
|
|
86
|
+
return hkdf(sha256, secret, salt, info, length);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** AES-256-GCM ciphertext followed by a 16-byte tag. The caller guarantees nonce uniqueness. */
|
|
90
|
+
export function encryptAes(key: Uint8Array, nonce: Uint8Array, plaintext: Uint8Array, aad: Uint8Array): Uint8Array {
|
|
91
|
+
requireLength(key, 32, 'AES-256 key');
|
|
92
|
+
requireLength(nonce, 12, 'GCM nonce');
|
|
93
|
+
return gcm(key, nonce, aad).encrypt(plaintext);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Authenticates before exposing plaintext; malformed ciphertext is a protocol failure. */
|
|
97
|
+
export function decryptAes(key: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array, aad: Uint8Array): Uint8Array {
|
|
98
|
+
requireLength(key, 32, 'AES-256 key');
|
|
99
|
+
requireLength(nonce, 12, 'GCM nonce');
|
|
100
|
+
if (ciphertext.length < 16) throw new ProtocolError('invalid_ciphertext', 'Missing GCM tag.');
|
|
101
|
+
try { return gcm(key, nonce, aad).decrypt(ciphertext); }
|
|
102
|
+
catch (cause) { throw new ProtocolError('invalid_ciphertext', 'GCM authentication failed.', { cause }); }
|
|
103
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** A local encoding, identity, signature, or authorization violation. */
|
|
2
|
+
export class ProtocolError extends Error {
|
|
3
|
+
override readonly name = 'ProtocolError';
|
|
4
|
+
|
|
5
|
+
constructor(readonly code: string, message: string, options?: ErrorOptions) {
|
|
6
|
+
super(message, options);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A persisted operation cannot safely overwrite the current revision. */
|
|
11
|
+
export class StateConflictError extends Error {
|
|
12
|
+
override readonly name = 'StateConflictError';
|
|
13
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { DeviceManager } from '../components/device.js';
|
|
2
|
+
import type { MessageManager } from '../components/message.js';
|
|
3
|
+
import { ProtocolError, StateConflictError } from '../errors.js';
|
|
4
|
+
import { sendGroupAccountPayload } from '../messages/account-sender.js';
|
|
5
|
+
import { cachedDeviceStateKey } from '../messages/contacts.js';
|
|
6
|
+
import type { AccountMessage, MessageEffects } from '../messages/repository.js';
|
|
7
|
+
import { accountGroupHistorySecretSyncCodec, accountGroupPrivateStateRequestCodec, accountGroupPrivateStateSyncCodec,
|
|
8
|
+
validateAccountGroupHistorySecretSync, validateAccountGroupPrivateStateRequest, validateAccountGroupPrivateStateSync, type GroupHistorySecret, type GroupRef } from '../models/groups.js';
|
|
9
|
+
import { accountDeviceStateCodec, authorizedDevice, certificateId } from '../models/identity.js';
|
|
10
|
+
import type { NetworkContext } from '../protocol/context.js';
|
|
11
|
+
import { canonicalJson, requireObject, requireSafeInteger, type JsonObject } from '../protocol/json.js';
|
|
12
|
+
import { throwIfAborted, type RuntimeClock } from '../runtime/clock.js';
|
|
13
|
+
import type { MeshlineStore, RecordKey, RecordQuery, StoreMutation, StoreSnapshot } from '../storage/store.js';
|
|
14
|
+
import { updateStore } from '../storage/transaction.js';
|
|
15
|
+
import { decodeGroupProjection, groupEpochKey, groupKey, GroupRepository } from './repository.js';
|
|
16
|
+
import { groupApplicationSecretKey, groupMemberSecretKey, type GroupSecrets, type GroupSecretEffect } from './secrets.js';
|
|
17
|
+
|
|
18
|
+
export interface GroupAccountSyncOptions {
|
|
19
|
+
readonly store: MeshlineStore; readonly context: NetworkContext; readonly accountId: string; readonly clock: RuntimeClock;
|
|
20
|
+
readonly device: DeviceManager; readonly messages: MessageManager; readonly secrets: GroupSecrets;
|
|
21
|
+
rejected(messageId: string, error: ProtocolError): void;
|
|
22
|
+
}
|
|
23
|
+
const cursorKey: RecordKey = { collection: 'group_account_meta', key: 'local_sequence' };
|
|
24
|
+
const types = new Set(['meshline.account.group.state.request', 'meshline.account.group.state.sync', 'meshline.account.group.history_secret.sync']);
|
|
25
|
+
const fingerprint = (value: JsonObject | undefined): string => canonicalJson(value ?? null);
|
|
26
|
+
class AccountGroupRejection extends ProtocolError {}
|
|
27
|
+
function input<T>(action: () => T): T {
|
|
28
|
+
try { return action(); } catch (error) { if (!(error instanceof ProtocolError)) throw error; throw new AccountGroupRejection(error.code, error.message, { cause: error }); }
|
|
29
|
+
}
|
|
30
|
+
function secretEffect(effect: GroupSecretEffect): GroupSecretEffect {
|
|
31
|
+
return { queries: effect.queries, plan: snapshot => {
|
|
32
|
+
try { return effect.plan(snapshot); }
|
|
33
|
+
catch (error) { if (!(error instanceof ProtocolError) || error.code !== 'invalid_binding') throw error; throw new AccountGroupRejection(error.code, error.message, { cause: error }); }
|
|
34
|
+
} };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One consumer cursor commits with all key effects; local protection/storage failures remain retryable. */
|
|
38
|
+
export class GroupAccountSync {
|
|
39
|
+
constructor(readonly options: GroupAccountSyncOptions) {}
|
|
40
|
+
request(signal?: AbortSignal): Promise<void> { return sendGroupAccountPayload(this.options.messages, accountGroupPrivateStateRequestCodec.encode({}), undefined, signal); }
|
|
41
|
+
async consume(signal: AbortSignal): Promise<void> {
|
|
42
|
+
const { store, messages } = this.options;
|
|
43
|
+
for (;;) {
|
|
44
|
+
const after = (await store.read([cursorKey], signal)).sets[0]![0]?.value.sequence ?? 0; requireSafeInteger(after, 0);
|
|
45
|
+
const page = await messages.readTimeline(after, 64, signal); if (!page.length) return;
|
|
46
|
+
for (const message of page) {
|
|
47
|
+
throwIfAborted(signal); let effect: GroupSecretEffect | undefined; let rejection: AccountGroupRejection | undefined;
|
|
48
|
+
try { effect = await this.#prepare(message, signal); }
|
|
49
|
+
catch (error) { throwIfAborted(signal); if (!(error instanceof AccountGroupRejection)) throw error; rejection = error; }
|
|
50
|
+
try { await this.#commit(message, effect, rejection, signal); }
|
|
51
|
+
catch (error) {
|
|
52
|
+
throwIfAborted(signal); if (rejection || !(error instanceof AccountGroupRejection)) throw error;
|
|
53
|
+
rejection = error; await this.#commit(message, undefined, rejection, signal);
|
|
54
|
+
}
|
|
55
|
+
if (rejection) this.options.rejected(message.messageId, rejection);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async #commit(message: AccountMessage, effect: GroupSecretEffect | undefined, rejection: ProtocolError | undefined, signal: AbortSignal): Promise<void> {
|
|
60
|
+
requireSafeInteger(message.localSequence, 1);
|
|
61
|
+
await updateStore(this.options.store, [cursorKey, ...effect?.queries ?? []], snapshot => {
|
|
62
|
+
const previous = snapshot.sets[0]![0]?.value.sequence ?? 0; requireSafeInteger(previous, 0);
|
|
63
|
+
if (previous >= message.localSequence) return { mutations: [], result: undefined };
|
|
64
|
+
const mutations: StoreMutation[] = [...effect?.plan({ ...snapshot, sets: snapshot.sets.slice(1) }) ?? [], { kind: 'put', ...cursorKey, value: { sequence: message.localSequence } }];
|
|
65
|
+
if (rejection) mutations.push({ kind: 'put', collection: 'group_account_rejections', key: String(message.localSequence).padStart(16, '0'), value: { messageId: message.messageId, code: rejection.code, message: rejection.message } });
|
|
66
|
+
return { mutations, result: undefined };
|
|
67
|
+
}, signal);
|
|
68
|
+
}
|
|
69
|
+
#authorize(senderDeviceId: string, effect?: GroupSecretEffect): GroupSecretEffect {
|
|
70
|
+
return { queries: [cachedDeviceStateKey(this.options.accountId), ...effect?.queries ?? []], plan: snapshot => {
|
|
71
|
+
const row = snapshot.sets[0]![0]?.value; if (!row) throw new StateConflictError('Current device authorization disappeared during group account synchronization.');
|
|
72
|
+
const state = accountDeviceStateCodec.decode(row); if (state.account !== this.options.accountId) throw new ProtocolError('invalid_storage', 'Cached device authorization belongs to another account.');
|
|
73
|
+
input(() => authorizedDevice(state, senderDeviceId, this.options.context, this.options.clock.nowSeconds()));
|
|
74
|
+
return effect?.plan({ ...snapshot, sets: snapshot.sets.slice(1) }) ?? [];
|
|
75
|
+
} };
|
|
76
|
+
}
|
|
77
|
+
async #prepare(message: AccountMessage, signal: AbortSignal): Promise<GroupSecretEffect | undefined> {
|
|
78
|
+
if (!types.has(String(message.payload['$type']))) return undefined;
|
|
79
|
+
const { accountId, device, context, secrets } = this.options;
|
|
80
|
+
if (message.sender !== accountId || message.recipient !== accountId) throw new AccountGroupRejection('invalid_binding', 'Private group synchronization must stay within the current account.');
|
|
81
|
+
const own = await device.getDeviceState(undefined, signal); if (!own) throw new ProtocolError('device_state_required', 'Current account authorization is unavailable.');
|
|
82
|
+
input(() => authorizedDevice(own, message.senderDeviceId, context, this.options.clock.nowSeconds()));
|
|
83
|
+
let effect: GroupSecretEffect | undefined;
|
|
84
|
+
if (message.payload['$type'] === 'meshline.account.group.state.request') {
|
|
85
|
+
const request = input(() => { const value = accountGroupPrivateStateRequestCodec.decode(message.payload); validateAccountGroupPrivateStateRequest(value); return value; });
|
|
86
|
+
if (message.senderDeviceId !== certificateId(device.certificate, context)) await this.#reply(message.senderDeviceId, request.groupId, signal);
|
|
87
|
+
} else if (message.payload['$type'] === 'meshline.account.group.state.sync') {
|
|
88
|
+
const value = input(() => accountGroupPrivateStateSyncCodec.decode(message.payload));
|
|
89
|
+
try { input(() => validateAccountGroupPrivateStateSync(value)); effect = secretEffect(await secrets.prepareMemberStates(value.states, signal)); }
|
|
90
|
+
finally { for (const state of value.states) state.memberEncryptionPrivateKey.fill(0); }
|
|
91
|
+
} else {
|
|
92
|
+
const value = input(() => accountGroupHistorySecretSyncCodec.decode(message.payload));
|
|
93
|
+
try {
|
|
94
|
+
input(() => validateAccountGroupHistorySecretSync(value));
|
|
95
|
+
try { effect = await secrets.prepareHistorySecrets(value.secrets, signal); }
|
|
96
|
+
catch (error) { if (!(error instanceof ProtocolError) || error.code !== 'conflicting_epoch_secret') throw error; throw new AccountGroupRejection(error.code, error.message, { cause: error }); }
|
|
97
|
+
} finally { for (const secret of value.secrets) secret.applicationSecret.fill(0); }
|
|
98
|
+
}
|
|
99
|
+
return this.#authorize(message.senderDeviceId, effect);
|
|
100
|
+
}
|
|
101
|
+
#member(group: GroupRef, row: JsonObject | undefined) {
|
|
102
|
+
if (!row) return undefined; if (row.relayId !== group.relayId) throw new ProtocolError('invalid_binding', 'Synchronized group belongs to another hosting relay.');
|
|
103
|
+
if (!row.projection) return undefined; const projection = decodeGroupProjection(requireObject(row.projection), group);
|
|
104
|
+
if (row.localDepartureAfter !== undefined) { requireSafeInteger(row.localDepartureAfter, 0); if (projection.sequence <= row.localDepartureAfter) return undefined; }
|
|
105
|
+
return projection.members.some(value => value.account === this.options.accountId) ? projection : undefined;
|
|
106
|
+
}
|
|
107
|
+
#guard(queries: readonly RecordQuery[], before: StoreSnapshot, mutations: readonly StoreMutation[] = []): MessageEffects {
|
|
108
|
+
return { queries, plan: snapshot => {
|
|
109
|
+
if (snapshot.sets.some((rows, index) => fingerprint(rows[0]?.value) !== fingerprint(before.sets[index]![0]?.value))) throw new StateConflictError('Group key authority changed before account synchronization was enqueued.');
|
|
110
|
+
return mutations;
|
|
111
|
+
} };
|
|
112
|
+
}
|
|
113
|
+
async shareCurrent(group: GroupRef, signal: AbortSignal): Promise<void> { await this.#sendPrivate(group, undefined, signal); }
|
|
114
|
+
async #sendPrivate(group: GroupRef, devices: readonly string[] | undefined, signal: AbortSignal): Promise<void> {
|
|
115
|
+
const { store, secrets, messages } = this.options; const row = (await store.read([groupKey(group)], signal)).sets[0]![0]?.value;
|
|
116
|
+
const projection = this.#member(group, row); if (!projection) return;
|
|
117
|
+
const member = projection.members.find(value => value.account === this.options.accountId)!;
|
|
118
|
+
const key = groupMemberSecretKey(group.groupId, member.memberEncryptionPublicKey); const queries = [groupKey(group), groupEpochKey(group, projection.epoch), key]; const snapshot = await store.read(queries, signal);
|
|
119
|
+
if (fingerprint(snapshot.sets[0]![0]?.value) !== fingerprint(row)) throw new StateConflictError('Group authority changed before key sharing.');
|
|
120
|
+
const stored = snapshot.sets[2]![0]?.value; if (!stored || devices === undefined && stored.shared === true || snapshot.sets[1]![0]?.value.materialVerified !== true) return;
|
|
121
|
+
const privateKey = await secrets.readMemberKey(group.groupId, member.memberEncryptionPublicKey, signal); if (!privateKey) return;
|
|
122
|
+
try {
|
|
123
|
+
await sendGroupAccountPayload(messages, accountGroupPrivateStateSyncCodec.encode({ states: [{ ...group, memberEncryptionPrivateKey: privateKey }] }), devices, signal,
|
|
124
|
+
this.#guard(queries, snapshot, devices === undefined ? [{ kind: 'put', ...key, value: { ...stored, shared: true } }] : []));
|
|
125
|
+
} finally { privateKey.fill(0); }
|
|
126
|
+
}
|
|
127
|
+
async #reply(sender: string, requested: string | undefined, signal: AbortSignal): Promise<void> {
|
|
128
|
+
const { store, context, accountId, secrets, messages } = this.options; const repository = new GroupRepository(store, context, accountId);
|
|
129
|
+
const rows = (await store.read([{ collection: 'groups', ...(requested ? { key: requested } : {}) }], signal)).sets[0]!;
|
|
130
|
+
for (const row of rows) {
|
|
131
|
+
const group = { groupId: row.key, relayId: String(row.value.relayId) }; if (!this.#member(group, row.value)) continue;
|
|
132
|
+
await this.#sendPrivate(group, [sender], signal); const epochs = (await repository.epochs(group, signal)).filter(value => value.commitment);
|
|
133
|
+
for (let start = 0; start < epochs.length; start += 64) {
|
|
134
|
+
const batch = epochs.slice(start, start + 64); const queries = [groupKey(group), ...batch.flatMap(value => [groupEpochKey(group, value.epoch), groupApplicationSecretKey(group.groupId, value.epoch)])];
|
|
135
|
+
const snapshot = await store.read(queries, signal); if (!this.#member(group, snapshot.sets[0]![0]?.value)) break;
|
|
136
|
+
const history: GroupHistorySecret[] = [];
|
|
137
|
+
try {
|
|
138
|
+
for (const value of batch) { const secret = await secrets.readApplicationSecret(group, value.epoch, signal); if (secret) history.push({ groupId: group.groupId, epoch: value.epoch, applicationSecret: secret }); }
|
|
139
|
+
if (history.length) await sendGroupAccountPayload(messages, accountGroupHistorySecretSyncCodec.encode({ secrets: history }), [sender], signal, this.#guard(queries, snapshot));
|
|
140
|
+
} finally { for (const value of history) value.applicationSecret.fill(0); }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { decryptGroupMessage, GroupKeyAccessError } from '../crypto/groups.js';
|
|
2
|
+
import { requireLength } from '../crypto/primitives.js';
|
|
3
|
+
import { ProtocolError, StateConflictError } from '../errors.js';
|
|
4
|
+
import { groupEventCodec } from '../models/group-management.js';
|
|
5
|
+
import { groupMessageCodec, groupMessageEnvelopeCodec, groupMemberNicknameUpdateCodec, validateGroupPayload, validateGroupRef, type GroupMessage, type GroupRef } from '../models/groups.js';
|
|
6
|
+
import { deviceCertificateCodec } from '../models/identity.js';
|
|
7
|
+
import type { NetworkContext } from '../protocol/context.js';
|
|
8
|
+
import { canonicalJson, requireObject, requireSafeInteger, type JsonObject } from '../protocol/json.js';
|
|
9
|
+
import { throwIfAborted } from '../runtime/clock.js';
|
|
10
|
+
import type { MeshlineStore, StoreMutation } from '../storage/store.js';
|
|
11
|
+
import { updateStore } from '../storage/transaction.js';
|
|
12
|
+
import { decodeGroupProjection, encodeGroupProjection, groupEpochKey, groupEventKey, groupKey, groupPendingMessageKey } from './repository.js';
|
|
13
|
+
import type { GroupProjection } from './state.js';
|
|
14
|
+
|
|
15
|
+
export interface GroupMessageInfo extends GroupMessage {
|
|
16
|
+
readonly group: GroupRef; readonly sequence: number; readonly messageId: string; readonly sender: string; readonly senderDeviceId: string; readonly createdAt: number; readonly acceptedAt: number;
|
|
17
|
+
}
|
|
18
|
+
export interface GroupMessageProcessingResult { readonly state: 'waitingForKey' | 'processed' | 'alreadyProcessed'; readonly message?: GroupMessageInfo; readonly nicknameChanged?: boolean; readonly groupRecord?: JsonObject; readonly error?: ProtocolError }
|
|
19
|
+
export interface GroupApplicationSecrets {
|
|
20
|
+
/** Returns a fresh owned buffer only after the epoch's authority is verified. Caller erases it. */
|
|
21
|
+
readApplicationSecret(group: GroupRef, epoch: number, signal?: AbortSignal): Promise<Uint8Array | undefined>;
|
|
22
|
+
}
|
|
23
|
+
/** Sequence is scoped to this sender's and this recipient's current membership; unknown payload attributes never select the target member. */
|
|
24
|
+
export function applyGroupNickname(projection: GroupProjection, accountId: string, sender: string, sequence: number, nickname: string | null): GroupProjection {
|
|
25
|
+
requireSafeInteger(sequence, 1);
|
|
26
|
+
const receiver = projection.members.find(member => member.account === accountId); const member = projection.members.find(member => member.account === sender);
|
|
27
|
+
if (!receiver || !member || sequence < receiver.joinedAtSequence || sequence < member.joinedAtSequence || sequence <= (member.nicknameSequence ?? -1)) return projection;
|
|
28
|
+
const next = { ...member, nicknameSequence: sequence }; if (nickname === null) delete next.nickname; else next.nickname = nickname;
|
|
29
|
+
return { ...projection, members: projection.members.map(value => value.account === sender ? next : value) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Decryption is separate from verified timeline ingestion, so a missing old key cannot prevent recovery of later epochs. */
|
|
33
|
+
export class GroupMessageProcessor {
|
|
34
|
+
constructor(readonly store: MeshlineStore, readonly context: NetworkContext, readonly accountId: string, readonly secrets: GroupApplicationSecrets) {}
|
|
35
|
+
async processPending(group: GroupRef, committed: (sequence: number, result: GroupMessageProcessingResult) => void, signal?: AbortSignal): Promise<void> {
|
|
36
|
+
validateGroupRef(group); const reader = await this.store.openQuery({ collection: 'group_pending_messages', prefix: `${group.groupId}|` }, signal);
|
|
37
|
+
try {
|
|
38
|
+
while (true) {
|
|
39
|
+
const rows = await reader.readNext(128, signal); if (!rows.length) break;
|
|
40
|
+
for (const row of rows) { requireSafeInteger(row.value.sequence, 1); const result = await this.process(group, row.value.sequence, signal); if (result.state === 'processed') committed(row.value.sequence, result); }
|
|
41
|
+
}
|
|
42
|
+
} finally { await reader.dispose(); }
|
|
43
|
+
}
|
|
44
|
+
async process(group: GroupRef, sequence: number, signal?: AbortSignal): Promise<GroupMessageProcessingResult> {
|
|
45
|
+
validateGroupRef(group); requireSafeInteger(sequence, 1); throwIfAborted(signal);
|
|
46
|
+
const queries = [groupKey(group), groupEventKey(group, sequence), groupPendingMessageKey(group, sequence)];
|
|
47
|
+
const initial = await this.store.read(queries, signal); const groupRow = initial.sets[0]![0]?.value; const stored = initial.sets[1]![0]?.value;
|
|
48
|
+
if (groupRow?.relayId !== group.relayId || !stored) throw new ProtocolError('invalid_storage', 'Verified group message or its hosting association is missing.');
|
|
49
|
+
const projection = decodeGroupProjection(requireObject(groupRow.projection!), group);
|
|
50
|
+
if (stored.decryptedPayload !== undefined || stored.rejection !== undefined || !initial.sets[2]!.length) return { state: 'alreadyProcessed' };
|
|
51
|
+
const event = groupEventCodec.decode(stored.event!); const message = requireObject(stored.message!); const sender = deviceCertificateCodec.decode(stored.certificate!);
|
|
52
|
+
if (event.sequence !== sequence || projection.sequence < sequence || message.sender !== sender.account || message.senderDeviceId !== event.signerDeviceId) throw new ProtocolError('invalid_storage', 'Stored group message evidence has inconsistent identities or sequence.');
|
|
53
|
+
const envelope = groupMessageEnvelopeCodec.decode(event.payload);
|
|
54
|
+
if (message.messageId !== envelope.messageId || message.createdAt !== envelope.createdAt || envelope.groupId !== group.groupId || envelope.epoch !== event.epoch) throw new ProtocolError('invalid_storage', 'Stored group envelope differs from its verified metadata.');
|
|
55
|
+
const epoch = (await this.store.read([groupEpochKey(group, event.epoch)], signal)).sets[0]![0];
|
|
56
|
+
if (!epoch?.value.commitment) throw new ProtocolError('invalid_storage', 'Verified group message has no established epoch.');
|
|
57
|
+
// Key access is outside the permanent-content rejection boundary, even when a local provider throws ProtocolError.
|
|
58
|
+
const secret = await this.secrets.readApplicationSecret(group, event.epoch, signal); if (!secret) return { state: 'waitingForKey' };
|
|
59
|
+
let payload: JsonObject | undefined; let error: ProtocolError | undefined;
|
|
60
|
+
try {
|
|
61
|
+
throwIfAborted(signal); try { requireLength(secret, 32, 'Stored application secret'); } catch (cause) { throw new GroupKeyAccessError('Local group application secret has an invalid length.', { cause }); }
|
|
62
|
+
try {
|
|
63
|
+
payload = decryptGroupMessage({ context: this.context, envelope, sender, signerDeviceId: event.signerDeviceId!, applicationSecret: secret }); validateGroupPayload(payload);
|
|
64
|
+
if (payload.$type === 'meshline.group.message.content' && (groupMessageCodec.decode(payload).replyToSeq ?? 0) >= sequence) throw new ProtocolError('invalid_reply', 'Group reply sequence must precede its containing message.');
|
|
65
|
+
} catch (cause) { if (!(cause instanceof ProtocolError)) throw cause; error = cause; }
|
|
66
|
+
} finally { secret.fill(0); }
|
|
67
|
+
throwIfAborted(signal);
|
|
68
|
+
return updateStore(this.store, queries, snapshot => {
|
|
69
|
+
const current = snapshot.sets[1]![0]?.value; const currentGroup = snapshot.sets[0]![0]?.value;
|
|
70
|
+
if (current?.decryptedPayload !== undefined || current?.rejection !== undefined) return { mutations: [], result: { state: 'alreadyProcessed' } as GroupMessageProcessingResult };
|
|
71
|
+
if (canonicalJson(current ?? null) !== canonicalJson(stored) || currentGroup?.relayId !== group.relayId) throw new StateConflictError('Group message evidence changed during decryption.');
|
|
72
|
+
const mutations: StoreMutation[] = [{ kind: 'delete', ...groupPendingMessageKey(group, sequence) }];
|
|
73
|
+
const result: GroupMessageProcessingResult = error ? { state: 'processed', error } : { state: 'processed' };
|
|
74
|
+
if (error) mutations.push({ kind: 'put', ...groupEventKey(group, sequence), value: { ...current, rejection: { code: error.code, message: error.message } } });
|
|
75
|
+
else {
|
|
76
|
+
const content = payload!; let next = decodeGroupProjection(requireObject(currentGroup.projection!), group); const before = next;
|
|
77
|
+
if (content.$type === 'meshline.group.member.nickname.update') next = applyGroupNickname(next, this.accountId, sender.account, sequence, groupMemberNicknameUpdateCodec.decode(content).nickname);
|
|
78
|
+
const groupRecord = next === before ? undefined : { ...currentGroup, projection: encodeGroupProjection(next) };
|
|
79
|
+
if (groupRecord) mutations.push({ kind: 'put', ...groupKey(group), value: groupRecord });
|
|
80
|
+
const isMessage = content.$type === 'meshline.group.message.content';
|
|
81
|
+
mutations.push({ kind: 'put', ...groupEventKey(group, sequence), value: { ...current, decryptedPayload: content, isMessage } });
|
|
82
|
+
return { mutations, result: { state: 'processed', ...(groupRecord ? { nicknameChanged: true, groupRecord } : {}), ...(isMessage ? { message: {
|
|
83
|
+
...groupMessageCodec.decode(content), group: { ...group }, sequence, sender: sender.account, senderDeviceId: event.signerDeviceId!, messageId: envelope.messageId, createdAt: envelope.createdAt, acceptedAt: event.acceptedAt,
|
|
84
|
+
} } : {}) } as GroupMessageProcessingResult };
|
|
85
|
+
}
|
|
86
|
+
return { mutations, result };
|
|
87
|
+
}, signal);
|
|
88
|
+
}
|
|
89
|
+
}
|