@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,253 @@
|
|
|
1
|
+
import { encryptMessage, decryptMessage } from '../crypto/messages.js';
|
|
2
|
+
import { systemRandom, type RandomSource } from '../crypto/primitives.js';
|
|
3
|
+
import { ProtocolError } from '../errors.js';
|
|
4
|
+
import { certificateId, accountDeviceStateCodec, accountRouteCodec, authorizedDevice, validateDeviceState } from '../models/identity.js';
|
|
5
|
+
import { createIdentifier, validateIdentifier } from '../identity/identifiers.js';
|
|
6
|
+
import { validateAccountId } from '../identity/neo.js';
|
|
7
|
+
import type { SecretProtector } from '../interactions.js';
|
|
8
|
+
import { accountContactSyncCodec, contactAuthorizationCodec, contactConsentCodec, contactGrantCodec, verifyContactGrant, verifyContactInvite,
|
|
9
|
+
type ContactAuthorization, type ContactGrant, type ContactInvite } from '../models/contacts.js';
|
|
10
|
+
import { directMessageCodec, messageDeliveryStatusCodec, messageSendRequestCodec, messageTimelineChangedCodec, validateDirectMessage, validateMessageTimelineChanged, type DirectMessage } from '../models/messages.js';
|
|
11
|
+
import { MessageContacts, ContactMessageRejection, cachedDeviceStateKey, contactKey, contactRequestKey, readContact, readContactRequest,
|
|
12
|
+
type ContactChange, type ContactRequestChange, type ContactChanges, type ContactInfo, type ContactRequestDirection, type ContactRequestInfo, type PreparedContactSend } from '../messages/contacts.js';
|
|
13
|
+
import { MessageOutbox, decodeOutbox, outboxStatus, type MessageSendState, type MessageSendStatus, type OutboxChange } from '../messages/outbox.js';
|
|
14
|
+
import { MessageRepository, snapshotReader, type AccountMessage, type MessageInfo, type MessageKey } from '../messages/repository.js';
|
|
15
|
+
import { MessageReceiver, type ReceptionResult } from '../messages/receiver.js';
|
|
16
|
+
import { registerAccountSender } from '../messages/account-sender.js';
|
|
17
|
+
import { requireObject, type JsonObject } from '../protocol/json.js';
|
|
18
|
+
import { AsyncGate } from '../runtime/async-gate.js';
|
|
19
|
+
import { AsyncPulse } from '../runtime/async-pulse.js';
|
|
20
|
+
import { throwIfAborted } from '../runtime/clock.js';
|
|
21
|
+
import { EventHub, type EventListener } from '../runtime/events.js';
|
|
22
|
+
import type { MeshlineStore, QueryReader, RecordKey } from '../storage/store.js';
|
|
23
|
+
import { readSignedRequest, requestKey } from '../storage/signed-request.js';
|
|
24
|
+
import type { RelayClientPool } from '../transport/pool.js';
|
|
25
|
+
import type { RelayClient } from '../transport/client.js';
|
|
26
|
+
import type { AccountManager } from './account.js';
|
|
27
|
+
import type { DeviceManager } from './device.js';
|
|
28
|
+
import { ClientComponent, type ClientOptions } from './component.js';
|
|
29
|
+
|
|
30
|
+
export interface MessageManagerOptions extends ClientOptions {
|
|
31
|
+
readonly store: MeshlineStore; readonly relayClients: RelayClientPool; readonly accountManager: AccountManager; readonly deviceManager: DeviceManager;
|
|
32
|
+
readonly secretProtector?: SecretProtector; readonly random?: RandomSource;
|
|
33
|
+
}
|
|
34
|
+
export interface MessageEvents {
|
|
35
|
+
readonly timelineChanged: undefined; readonly messageReceived: readonly MessageInfo[]; readonly sendStatusChanged: MessageSendStatus;
|
|
36
|
+
readonly contactChanged: ContactChange;
|
|
37
|
+
readonly contactRequestChanged: ContactRequestChange;
|
|
38
|
+
}
|
|
39
|
+
export interface OutboxQuery { readonly recipient?: string; readonly states?: readonly MessageSendState[] }
|
|
40
|
+
export interface ContactRequestQuery { readonly accountId?: string; readonly direction?: ContactRequestDirection }
|
|
41
|
+
const sendStates = new Set<MessageSendState>(['queued', 'submitting', 'submissionUnknown', 'relayAccepted', 'targetAccepted', 'failed', 'canceled']);
|
|
42
|
+
const routeKey = (account: string): RecordKey => ({ collection: 'account_routes', key: account });
|
|
43
|
+
|
|
44
|
+
/** Account messaging and contact relationships share durable authorization, delivery and synchronization transactions. */
|
|
45
|
+
export class MessageManager extends ClientComponent {
|
|
46
|
+
readonly #store: MeshlineStore; readonly #pool: RelayClientPool; readonly #account: AccountManager; readonly #device: DeviceManager; readonly #random: RandomSource;
|
|
47
|
+
readonly #events = new EventHub<MessageEvents>(); readonly #writes = new AsyncGate();
|
|
48
|
+
readonly #repository: MessageRepository; readonly #contacts: MessageContacts; readonly #outbox: MessageOutbox; readonly #receiver: MessageReceiver;
|
|
49
|
+
readonly #deliveryGrants = new Map<string, ContactGrant>();
|
|
50
|
+
readonly #observed = new Map<string, { client: RelayClient; detach: (() => void)[] }>();
|
|
51
|
+
readonly #pendingRoutes = new Set<string>(); #routeRecording = Promise.resolve(); #removeRouteListener: (() => void) | undefined;
|
|
52
|
+
#sendPulse: AsyncPulse | undefined; #syncPulse: AsyncPulse | undefined; #maintenancePulse: AsyncPulse | undefined; #runtimeJobs: Promise<void>[] = [];
|
|
53
|
+
#pendingDeviceRevision: number | undefined; #deviceListeners: (() => void)[] = [];
|
|
54
|
+
constructor(options: MessageManagerOptions) {
|
|
55
|
+
super(options);
|
|
56
|
+
for (const dependency of [options.relayClients, options.accountManager, options.deviceManager]) if (dependency.accountId !== this.accountId || dependency.context.toString() !== this.context.toString())
|
|
57
|
+
throw new ProtocolError('invalid_context', 'Message dependencies belong to another account or network.');
|
|
58
|
+
this.#store = options.store; this.#pool = options.relayClients; this.#account = options.accountManager; this.#device = options.deviceManager; this.#random = options.random ?? systemRandom;
|
|
59
|
+
this.#repository = new MessageRepository({ store: this.#store, context: this.context, accountId: this.accountId, deviceId: () => certificateId(this.#device.certificate, this.context), clock: this.clock,
|
|
60
|
+
...(options.secretProtector ? { secretProtector: options.secretProtector } : {}) });
|
|
61
|
+
this.#contacts = new MessageContacts({ store: this.#store, context: this.context, accountId: this.accountId, device: this.#device, clock: this.clock,
|
|
62
|
+
getState: (target, signal) => this.#getState(target, signal), prepareSend: (recipient, payload, authorization, devices, signal) => this.#prepareSend(recipient, payload, authorization, devices, signal),
|
|
63
|
+
changed: changes => this.#contactChanges(changes) });
|
|
64
|
+
this.#outbox = new MessageOutbox(this.#store, this.clock, {
|
|
65
|
+
getHome: async signal => { const route = await this.#account.getRoute(undefined, signal); if (!route) throw new ProtocolError('route_required', 'The account has no current route.'); return route.relayId; },
|
|
66
|
+
currentHome: () => this.#account.route?.relayId,
|
|
67
|
+
prepare: async (relayId, signal) => { const relay = await this.#pool.get(relayId, { mode: 'device', signer: this.#device }, signal); return {
|
|
68
|
+
send: async (request, token) => messageDeliveryStatusCodec.decode((await relay.requestHttp('POST', 'message.send', messageSendRequestCodec.encode(request), { ...(token ? { signal: token } : {}) }))!),
|
|
69
|
+
status: async (messageId, token) => messageDeliveryStatusCodec.decode((await relay.requestHttp('GET', 'message.delivery.status', { message_id: messageId }, { ...(token ? { signal: token } : {}) }))!),
|
|
70
|
+
}; },
|
|
71
|
+
}, this.#contacts.deliveryEffects(this.#deliveryGrants));
|
|
72
|
+
this.#receiver = new MessageReceiver(this.#repository, this.context, this.#device, { prepare: (entry, payload, signal) => this.#contacts.prepareReception(entry, payload, signal),
|
|
73
|
+
isPermanentRejection: (error): error is ContactMessageRejection => error instanceof ContactMessageRejection });
|
|
74
|
+
registerAccountSender(this, async (payload, devices, signal, effects) => {
|
|
75
|
+
await this.runOperation(scope => this.#writes.run(async () => {
|
|
76
|
+
const prepared = await this.#prepareSend(this.accountId, payload, undefined, devices, scope);
|
|
77
|
+
await this.#repository.enqueue(prepared.outbox, undefined, prepared.guards, effects, scope);
|
|
78
|
+
}, scope), signal);
|
|
79
|
+
this.#sendPulse?.pulse();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
on<K extends keyof MessageEvents>(event: K, listener: EventListener<MessageEvents[K]>): () => void { return this.#events.on(event, listener); }
|
|
83
|
+
#notify<K extends keyof MessageEvents>(event: K, value: MessageEvents[K]): void {
|
|
84
|
+
this.#events.notify(event, value, error => this.notifyBackgroundError({ operation: 'observer', resource: event, error }));
|
|
85
|
+
}
|
|
86
|
+
#contactChanges(changes: ContactChanges): void {
|
|
87
|
+
for (const change of changes.contacts) this.#notify('contactChanged', change);
|
|
88
|
+
for (const change of changes.requests) this.#notify('contactRequestChanged', change);
|
|
89
|
+
this.#sendPulse?.pulse(); this.#maintenancePulse?.pulse();
|
|
90
|
+
}
|
|
91
|
+
protected override async onInitialize(signal: AbortSignal): Promise<void> {
|
|
92
|
+
this.#account.ensureInitialized(); this.#device.ensureInitialized(); await this.#store.initialize({ context: this.context.toString(), accountId: this.accountId }, signal);
|
|
93
|
+
await this.#outbox.recover(signal);
|
|
94
|
+
this.#removeRouteListener?.(); this.#removeRouteListener = this.#account.on('accountChanged', value => { if (value.route) this.#observeRoute(value.route.relayId); });
|
|
95
|
+
if (this.#account.route) this.#observeRoute(this.#account.route.relayId); await this.#routeRecording;
|
|
96
|
+
}
|
|
97
|
+
#observeRoute(relayId: string): void {
|
|
98
|
+
this.#pendingRoutes.add(relayId);
|
|
99
|
+
this.#routeRecording = this.#routeRecording.then(async () => {
|
|
100
|
+
for (const relay of [...this.#pendingRoutes]) { try { await this.#repository.observeRelay(relay); this.#pendingRoutes.delete(relay); }
|
|
101
|
+
catch (error) { this.notifyBackgroundError({ operation: 'record_route', resource: relay, error }); } }
|
|
102
|
+
this.#sendPulse?.pulse(); this.#syncPulse?.pulse();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async #getState(target: string | ContactAuthorization, signal?: AbortSignal) {
|
|
106
|
+
const state = await this.#device.getDeviceState(target, signal); if (!state) throw new ProtocolError('device_state_required', 'The requested account has no device state.'); return state;
|
|
107
|
+
}
|
|
108
|
+
async #prepareSend(recipient: string, payload: JsonObject, explicit: ContactAuthorization | undefined, deviceIds: readonly string[] | undefined, signal?: AbortSignal): Promise<PreparedContactSend> {
|
|
109
|
+
validateAccountId(recipient); const self = recipient === this.accountId;
|
|
110
|
+
const contactQueries = self ? [] : [contactKey(recipient), contactRequestKey(recipient, 'incoming')];
|
|
111
|
+
const before = await this.#store.read(contactQueries, signal);
|
|
112
|
+
const contact = self ? undefined : readContact(before.sets[0]![0]?.value); const incoming = self ? undefined : readContactRequest(before.sets[1]![0]?.value);
|
|
113
|
+
const authorization = explicit ?? (contact?.record.status === 'active' ? contact.record.grantFromContact : undefined) ?? incoming?.consent.grant;
|
|
114
|
+
if (payload['$type'] === 'meshline.message.direct' && !self && (!authorization || !('grantor' in authorization))) throw new ProtocolError('contact_required', 'A direct message requires a contact grant from its recipient.');
|
|
115
|
+
await this.#getState(this.accountId, signal); if (!self) await this.#getState(authorization ?? recipient, signal);
|
|
116
|
+
const queries = [routeKey(this.accountId), cachedDeviceStateKey(this.accountId), ...(self ? [] : [cachedDeviceStateKey(recipient)]), requestKey('device.state.publish')]; const snapshot = await this.#store.read(queries, signal);
|
|
117
|
+
const route = accountRouteCodec.decode(snapshot.sets[0]![0]?.value!); const own = accountDeviceStateCodec.decode(snapshot.sets[1]![0]?.value!);
|
|
118
|
+
const remote = self ? own : accountDeviceStateCodec.decode(snapshot.sets[2]![0]?.value!); const now = this.clock.nowSeconds();
|
|
119
|
+
validateDeviceState(own, this.context); validateDeviceState(remote, this.context);
|
|
120
|
+
const pendingState = readSignedRequest(snapshot.sets.at(-1)![0]?.value);
|
|
121
|
+
if (pendingState?.pending) {
|
|
122
|
+
const proposed = accountDeviceStateCodec.decode(pendingState.document); const ids = new Set(proposed.certificates.map(value => certificateId(value, this.context)));
|
|
123
|
+
if (own.certificates.some(value => !ids.has(certificateId(value, this.context)))) throw new ProtocolError('device_removal_pending', 'Resolve the pending device removal before preparing new messages or grants.');
|
|
124
|
+
}
|
|
125
|
+
if (route.account !== this.accountId || own.account !== this.accountId || remote.account !== recipient || route.expiresAt <= now) throw new ProtocolError('invalid_authorization', 'Message routing or device state is outside its account scope or validity.');
|
|
126
|
+
authorizedDevice(own, certificateId(this.#device.certificate, this.context), this.context, now);
|
|
127
|
+
if (authorization) { if ('grantor' in authorization) verifyContactGrant(authorization, remote, this.context, now); else verifyContactInvite(authorization, remote, this.context, now); }
|
|
128
|
+
let recipients = remote.certificates.filter(value => value.notBefore <= now && now < value.expiresAt);
|
|
129
|
+
if (deviceIds) { const selected = new Set(deviceIds); recipients = recipients.filter(value => selected.has(certificateId(value, this.context)));
|
|
130
|
+
if (recipients.length !== selected.size) throw new ProtocolError('unauthorized_device', 'A selected receiving device is no longer authorized.'); }
|
|
131
|
+
const request = await encryptMessage({ context: this.context, signer: this.#device, messageId: createIdentifier('message', this.#random), createdAt: now, recipient, payload,
|
|
132
|
+
recipientDevices: recipients, ...(self ? {} : { senderDevices: own.certificates.filter(value => value.notBefore <= now && now < value.expiresAt) }),
|
|
133
|
+
...(authorization ? { authorization } : {}), random: this.#random, ...(signal ? { signal } : {}) });
|
|
134
|
+
return { outbox: { request, relayId: route.relayId, state: 'queued', nextAttemptAt: now, isDirect: payload['$type'] === 'meshline.message.direct' },
|
|
135
|
+
guards: [...queries.map((query, index) => ({ query, value: snapshot.sets[index]![0]?.value })), ...contactQueries.map((query, index) => ({ query, value: before.sets[index]![0]?.value }))] };
|
|
136
|
+
}
|
|
137
|
+
async sendMessage(recipient: string, draft: DirectMessage, signal?: AbortSignal): Promise<MessageSendStatus> {
|
|
138
|
+
const payload = directMessageCodec.encode(draft); validateDirectMessage(directMessageCodec.decode(payload));
|
|
139
|
+
const status = await this.runOperation(scope => this.#writes.run(async () => { const prepared = await this.#prepareSend(recipient, payload, undefined, undefined, scope);
|
|
140
|
+
const message = await this.#repository.prepare(prepared.outbox.request.envelope, payload, scope); await this.#repository.enqueue(prepared.outbox, message, prepared.guards, undefined, scope); return outboxStatus(prepared.outbox); }, scope), signal);
|
|
141
|
+
this.#sendPulse?.pulse(); this.#notify('timelineChanged', undefined); this.#notify('sendStatusChanged', status); return status;
|
|
142
|
+
}
|
|
143
|
+
getMessage(key: MessageKey, signal?: AbortSignal): Promise<MessageInfo | undefined> { const copy = { ...key }; return this.runOperation(scope => this.#repository.get(copy, scope), signal); }
|
|
144
|
+
getMessageHistory(peer?: string, signal?: AbortSignal): Promise<QueryReader<MessageInfo>> { return this.runOperation(scope => this.#repository.history(peer, scope), signal); }
|
|
145
|
+
readTimeline(after: number, count: number, signal?: AbortSignal): Promise<readonly AccountMessage[]> { return this.runOperation(scope => this.#repository.readTimeline(after, count, scope), signal); }
|
|
146
|
+
getOutbox(query: OutboxQuery = {}, signal?: AbortSignal): Promise<QueryReader<MessageSendStatus>> {
|
|
147
|
+
const recipient = query.recipient; const states = query.states && [...query.states]; if (recipient !== undefined) validateAccountId(recipient);
|
|
148
|
+
if (states?.some(value => !sendStates.has(value))) throw new TypeError('Unknown message send state.');
|
|
149
|
+
return this.runOperation(async scope => { const rows = await this.#store.read([{ collection: 'message_outbox' }], scope);
|
|
150
|
+
return snapshotReader(rows.sets[0]!.map(decodeOutbox).filter(value => value.isDirect && (recipient === undefined || value.request.envelope.to === recipient) && (states === undefined || states.includes(value.state)))
|
|
151
|
+
.sort((a, b) => a.request.envelope.createdAt - b.request.envelope.createdAt || ordinal(a.request.envelope.messageId, b.request.envelope.messageId)).map(outboxStatus)); }, signal);
|
|
152
|
+
}
|
|
153
|
+
getSendStatus(messageId: string, signal?: AbortSignal): Promise<MessageSendStatus | undefined> { return this.runOperation(async scope => { const record = await this.#outbox.get(messageId, scope); return record && outboxStatus(record); }, signal); }
|
|
154
|
+
async cancelMessage(messageId: string, signal?: AbortSignal): Promise<boolean> { const change = await this.runOperation(scope => this.#outbox.cancel(messageId, scope), signal); if (change) await this.#sendChanges([change]); return Boolean(change); }
|
|
155
|
+
createInvite(expiresAt: number, signal?: AbortSignal): Promise<ContactInvite> { return this.runOperation(scope => this.#contacts.invite(expiresAt, scope), signal); }
|
|
156
|
+
addContact(target: string | ContactInvite, note?: string, signal?: AbortSignal): Promise<ContactRequestInfo> {
|
|
157
|
+
const copy = typeof target === 'string' ? target : contactAuthorizationCodec.decode(contactAuthorizationCodec.encode(target)) as ContactInvite;
|
|
158
|
+
return this.runOperation(scope => this.#writes.run(() => this.#contacts.add(copy, note, scope), scope), signal);
|
|
159
|
+
}
|
|
160
|
+
acceptContactRequest(account: string, signal?: AbortSignal): Promise<ContactInfo> { return this.runOperation(scope => this.#writes.run(() => this.#contacts.accept(account, scope), scope), signal); }
|
|
161
|
+
dismissContactRequest(account: string, signal?: AbortSignal): Promise<void> { return this.runOperation(scope => this.#contacts.dismiss(account, scope), signal); }
|
|
162
|
+
getContacts(search?: string, signal?: AbortSignal): Promise<QueryReader<ContactInfo>> { return this.runOperation(scope => this.#contacts.list(search, scope), signal); }
|
|
163
|
+
getContact(account: string, signal?: AbortSignal): Promise<ContactInfo | undefined> { return this.runOperation(scope => this.#contacts.get(account, scope), signal); }
|
|
164
|
+
getContactRequests(query: ContactRequestQuery = {}, signal?: AbortSignal): Promise<QueryReader<ContactRequestInfo>> { const { accountId, direction } = query; return this.runOperation(scope => this.#contacts.requests(accountId, direction, scope), signal); }
|
|
165
|
+
setContactAlias(account: string, alias: string | null, signal?: AbortSignal): Promise<ContactInfo> { return this.runOperation(scope => this.#writes.run(() => this.#contacts.edit(account, { alias }, scope), scope), signal); }
|
|
166
|
+
removeContact(account: string, signal?: AbortSignal): Promise<void> { return this.runOperation(scope => this.#writes.run(() => this.#contacts.edit(account, { deleted: true }, scope), scope), signal); }
|
|
167
|
+
protected override async onStart(signal: AbortSignal): Promise<void> {
|
|
168
|
+
const own = await this.#getState(this.accountId, signal); authorizedDevice(own, certificateId(this.#device.certificate, this.context), this.context, this.clock.nowSeconds());
|
|
169
|
+
const initial = await this.#prepareSend(this.accountId, accountContactSyncCodec.encode({ records: [], requestSnapshot: true }), undefined, undefined, signal);
|
|
170
|
+
await this.#repository.enqueue(initial.outbox, undefined, initial.guards, undefined, signal);
|
|
171
|
+
this.#sendPulse = new AsyncPulse(); this.#syncPulse = new AsyncPulse(); this.#maintenancePulse = new AsyncPulse(); this.#pendingDeviceRevision = own.revision;
|
|
172
|
+
this.#deviceListeners = [this.#device.on('deviceChanged', () => { this.#maintenancePulse?.pulse(); }), this.#device.on('deviceStateChanged', value => {
|
|
173
|
+
if (value.account === this.accountId) { this.#pendingDeviceRevision = Math.max(value.revision, this.#pendingDeviceRevision ?? -1); this.#maintenancePulse?.pulse(); }
|
|
174
|
+
})];
|
|
175
|
+
this.#runtimeJobs = [this.#sendLoop(this.runtimeSignal), this.#syncLoop(this.runtimeSignal), this.#poll(this.runtimeSignal), this.#maintainContacts(this.runtimeSignal), this.#pollContacts(this.runtimeSignal)];
|
|
176
|
+
for (const job of this.#runtimeJobs) job.catch(() => undefined); this.#sendPulse.pulse(); this.#syncPulse.pulse(); this.#maintenancePulse.pulse();
|
|
177
|
+
}
|
|
178
|
+
async #poll(signal: AbortSignal): Promise<void> { try { for (;;) { await this.clock.delay(15_000, signal); this.#sendPulse?.pulse(); this.#syncPulse?.pulse(); } } catch (error) { if (!signal.aborted) throw error; } }
|
|
179
|
+
async #pollContacts(signal: AbortSignal): Promise<void> { try { for (;;) { await this.clock.delay(30_000, signal); this.#maintenancePulse?.pulse(); } } catch (error) { if (!signal.aborted) throw error; } }
|
|
180
|
+
async #maintainContacts(signal: AbortSignal): Promise<void> {
|
|
181
|
+
try { for (;;) {
|
|
182
|
+
await this.#maintenancePulse!.wait(signal); const revision = this.#pendingDeviceRevision; let failed = false;
|
|
183
|
+
try {
|
|
184
|
+
const rows = await this.#store.read([{ collection: 'contacts' }], signal);
|
|
185
|
+
for (const row of rows.sets[0]!) {
|
|
186
|
+
if (readContact(row.value)?.record.status !== 'active') continue;
|
|
187
|
+
try { await this.#contacts.maintain(row.key, revision, signal); }
|
|
188
|
+
catch (error) { throwIfAborted(signal); failed = true; this.notifyBackgroundError({ operation: 'maintain_contact', resource: row.key, error }); }
|
|
189
|
+
}
|
|
190
|
+
if (!failed && this.#pendingDeviceRevision === revision) this.#pendingDeviceRevision = undefined;
|
|
191
|
+
} catch (error) { throwIfAborted(signal); this.notifyBackgroundError({ operation: 'maintain_contacts', error }); }
|
|
192
|
+
} } catch (error) { if (!signal.aborted) throw error; }
|
|
193
|
+
}
|
|
194
|
+
async #sendLoop(signal: AbortSignal): Promise<void> {
|
|
195
|
+
try { for (;;) {
|
|
196
|
+
await this.#sendPulse!.wait(signal);
|
|
197
|
+
try { for (const messageId of await this.#outbox.due(signal)) {
|
|
198
|
+
try {
|
|
199
|
+
const record = await this.#outbox.get(messageId, signal); if (!record) continue;
|
|
200
|
+
if (!record.isDirect && record.request.envelope.to !== this.accountId) {
|
|
201
|
+
const box = record.request.senderBoxes?.find(value => value.deviceId === certificateId(this.#device.certificate, this.context));
|
|
202
|
+
if (!box) throw new ProtocolError('missing_key_box', 'Outgoing protocol message has no local sender key box.');
|
|
203
|
+
const payload = await decryptMessage({ context: this.context, receiver: this.#device, sender: this.#device.certificate, envelope: record.request.envelope, keyBox: box, signal });
|
|
204
|
+
const grant = payload['$type'] === 'meshline.contact.consent' ? contactConsentCodec.decode(payload).grant : payload['$type'] === 'meshline.contact.grant' ? contactGrantCodec.decode(payload) : undefined;
|
|
205
|
+
if (grant) this.#deliveryGrants.set(messageId, grant);
|
|
206
|
+
}
|
|
207
|
+
const result = await this.#outbox.process(messageId, signal); await this.#sendChanges(result.changes); if (result.error && !signal.aborted) this.notifyBackgroundError({ operation: 'send_message', resource: messageId, error: result.error });
|
|
208
|
+
} catch (error) { throwIfAborted(signal); this.notifyBackgroundError({ operation: 'send_message', resource: messageId, error }); }
|
|
209
|
+
finally { this.#deliveryGrants.delete(messageId); }
|
|
210
|
+
} } catch (error) { throwIfAborted(signal); this.notifyBackgroundError({ operation: 'send_message', error }); }
|
|
211
|
+
} } catch (error) { if (!signal.aborted) throw error; }
|
|
212
|
+
}
|
|
213
|
+
async #sendChanges(changes: readonly OutboxChange[]): Promise<void> {
|
|
214
|
+
for (const { current } of changes) {
|
|
215
|
+
if (current.isDirect) this.#notify('sendStatusChanged', outboxStatus(current));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async #syncLoop(signal: AbortSignal): Promise<void> {
|
|
219
|
+
try { for (;;) {
|
|
220
|
+
await this.#syncPulse!.wait(signal);
|
|
221
|
+
try {
|
|
222
|
+
try { await this.#account.getRoute(undefined, signal); } catch (error) { throwIfAborted(signal); this.notifyBackgroundError({ operation: 'resolve_route', error }); }
|
|
223
|
+
await this.#routeRecording;
|
|
224
|
+
const rows = await this.#store.read([{ collection: 'message_timelines' }], signal);
|
|
225
|
+
for (const row of rows.sets[0]!) {
|
|
226
|
+
try { const client = await this.#pool.get(row.key, { mode: 'device', signer: this.#device }, signal); await this.#observeClient(client, signal);
|
|
227
|
+
await this.#receiver.synchronize(row.key, { read: async (after, scope) => (await client.requestHttp('GET', 'message.timeline.sync', { after }, { ...(scope ? { signal: scope } : {}) }))! }, result => this.#received(row.key, result), signal);
|
|
228
|
+
} catch (error) { throwIfAborted(signal); this.notifyBackgroundError({ operation: 'synchronize_messages', resource: row.key, error }); }
|
|
229
|
+
}
|
|
230
|
+
} catch (error) { throwIfAborted(signal); this.notifyBackgroundError({ operation: 'synchronize_messages', error }); }
|
|
231
|
+
} } catch (error) { if (!signal.aborted) throw error; }
|
|
232
|
+
}
|
|
233
|
+
async #observeClient(client: RelayClient, signal: AbortSignal): Promise<void> {
|
|
234
|
+
const previous = this.#observed.get(client.relayId); if (previous?.client === client) return;
|
|
235
|
+
const descriptor = await client.getDescriptor(signal); const detach = [client.on('notificationReceived', request => {
|
|
236
|
+
if (request.method !== 'message.timeline.changed') return; validateMessageTimelineChanged(messageTimelineChangedCodec.decode(requireObject(request.params!))); this.#syncPulse?.pulse();
|
|
237
|
+
}), client.on('socketConnected', () => { this.#syncPulse?.pulse(); }), client.on('errorOccurred', error => this.notifyBackgroundError({ operation: 'connect', resource: client.relayId, error }))];
|
|
238
|
+
for (const remove of previous?.detach ?? []) remove(); this.#observed.set(client.relayId, { client, detach });
|
|
239
|
+
if (descriptor.endpoints.some(value => value.startsWith('wss://'))) client.startNotifications();
|
|
240
|
+
}
|
|
241
|
+
#received(relayId: string, result: ReceptionResult): void {
|
|
242
|
+
if (result.inserted) this.#notify('timelineChanged', undefined); if (result.messages.length) this.#notify('messageReceived', result.messages);
|
|
243
|
+
for (const rejection of result.rejected) this.notifyBackgroundError({ operation: 'reject_message', resource: `${relayId}/${rejection.sequence}`, error: rejection.error });
|
|
244
|
+
this.#sendPulse?.pulse();
|
|
245
|
+
}
|
|
246
|
+
protected override async onStop(): Promise<void> {
|
|
247
|
+
try { const results = await Promise.allSettled(this.#runtimeJobs); const failures = results.filter((value): value is PromiseRejectedResult => value.status === 'rejected'); if (failures.length) throw new AggregateError(failures.map(value => value.reason), 'Message runtime failed.'); }
|
|
248
|
+
finally { for (const observed of this.#observed.values()) for (const remove of observed.detach) remove(); this.#observed.clear();
|
|
249
|
+
for (const remove of this.#deviceListeners) remove(); this.#deviceListeners = []; this.#runtimeJobs = []; this.#sendPulse = this.#syncPulse = this.#maintenancePulse = undefined; this.#pendingDeviceRevision = undefined; }
|
|
250
|
+
}
|
|
251
|
+
protected override async onDispose(): Promise<void> { this.#removeRouteListener?.(); this.#removeRouteListener = undefined; await this.#routeRecording; if (this.#pendingRoutes.size) throw new Error('Some observed message timelines could not be persisted.'); this.#events.clear(); }
|
|
252
|
+
}
|
|
253
|
+
function ordinal(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; }
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { ProtocolError, StateConflictError } from '../errors.js';
|
|
2
|
+
import { validateAccountId } from '../identity/neo.js';
|
|
3
|
+
import type { DeviceSigner } from '../interactions.js';
|
|
4
|
+
import { contentReferenceCodec, type ContentReference } from '../models/content.js';
|
|
5
|
+
import { deviceCertificateCodec } from '../models/identity.js';
|
|
6
|
+
import { accountProfileCodec, profileInput, profileResolveResultCodec, validateProfile, validateProfileResult, type AccountProfile, type ProfileResolveResult } from '../models/profile.js';
|
|
7
|
+
import { canonicalJson, type JsonObject } from '../protocol/json.js';
|
|
8
|
+
import { AsyncGate } from '../runtime/async-gate.js';
|
|
9
|
+
import { EventHub, type EventListener } from '../runtime/events.js';
|
|
10
|
+
import { finishSignedRequest, putSignedRequest, readSignedRequest, requestKey, type SignedRequest } from '../storage/signed-request.js';
|
|
11
|
+
import type { MeshlineStore, RecordKey, StoreMutation } from '../storage/store.js';
|
|
12
|
+
import { updateStore } from '../storage/transaction.js';
|
|
13
|
+
import type { RelayClientPool } from '../transport/pool.js';
|
|
14
|
+
import { RelayError } from '../transport/relay-error.js';
|
|
15
|
+
import type { AccountManager } from './account.js';
|
|
16
|
+
import { ClientComponent, type ClientOptions } from './component.js';
|
|
17
|
+
|
|
18
|
+
/** Omitted fields are retained; null removes an optional field, including an existing empty string. */
|
|
19
|
+
export interface ProfileUpdate { readonly nickname?: string | null; readonly bio?: string | null; readonly avatar?: ContentReference | null; readonly publicDiscovery?: boolean }
|
|
20
|
+
export interface ProfileEvents { readonly profileChanged: AccountProfile | undefined }
|
|
21
|
+
export interface ProfileManagerOptions extends ClientOptions {
|
|
22
|
+
readonly store: MeshlineStore; readonly relayClients: RelayClientPool; readonly accountManager: AccountManager; readonly deviceSigner: DeviceSigner;
|
|
23
|
+
}
|
|
24
|
+
const method = 'profile.publish';
|
|
25
|
+
const profileKey = (account: string): RecordKey => ({ collection: 'profiles', key: account });
|
|
26
|
+
const clone = (value: AccountProfile): AccountProfile => accountProfileCodec.decode(accountProfileCodec.encode(value));
|
|
27
|
+
const fingerprint = (value: JsonObject | undefined): string => canonicalJson(value ?? null);
|
|
28
|
+
function copyUpdate(value: ProfileUpdate): ProfileUpdate {
|
|
29
|
+
for (const key of Object.keys(value)) if (!['nickname', 'bio', 'avatar', 'publicDiscovery'].includes(key)) throw new TypeError(`Unknown profile update field ${key}.`);
|
|
30
|
+
if (value.publicDiscovery !== undefined && typeof value.publicDiscovery !== 'boolean') throw new TypeError('publicDiscovery must be boolean and cannot be deleted.');
|
|
31
|
+
for (const field of [value.nickname, value.bio]) if (field !== undefined && field !== null && typeof field !== 'string') throw new TypeError('Profile text must be a string or null.');
|
|
32
|
+
return { ...value, ...(value.avatar == null ? {} : { avatar: contentReferenceCodec.decode(contentReferenceCodec.encode(value.avatar)) }) };
|
|
33
|
+
}
|
|
34
|
+
function apply(update: ProfileUpdate, basis: AccountProfile): AccountProfile {
|
|
35
|
+
const document = accountProfileCodec.encode(basis);
|
|
36
|
+
for (const field of ['nickname', 'bio', 'avatar'] as const) {
|
|
37
|
+
const value = update[field];
|
|
38
|
+
if (value === null) delete document[field];
|
|
39
|
+
else if (value !== undefined) document[field] = typeof value === 'string' ? value : contentReferenceCodec.encode(value);
|
|
40
|
+
}
|
|
41
|
+
if (update.publicDiscovery !== undefined) document.public_discovery = update.publicDiscovery;
|
|
42
|
+
return accountProfileCodec.decode(document);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class ProfileManager extends ClientComponent {
|
|
46
|
+
readonly #store: MeshlineStore; readonly #pool: RelayClientPool; readonly #account: AccountManager; readonly #signer: DeviceSigner;
|
|
47
|
+
readonly #gate = new AsyncGate(); readonly #events = new EventHub<ProfileEvents>();
|
|
48
|
+
#profile: AccountProfile | undefined;
|
|
49
|
+
constructor(options: ProfileManagerOptions) {
|
|
50
|
+
super(options);
|
|
51
|
+
for (const dependency of [options.relayClients, options.accountManager])
|
|
52
|
+
if (dependency.accountId !== this.accountId || dependency.context.toString() !== this.context.toString())
|
|
53
|
+
throw new ProtocolError('invalid_context', 'Profile dependencies belong to another account or network.');
|
|
54
|
+
this.#store = options.store; this.#pool = options.relayClients; this.#account = options.accountManager; this.#signer = options.deviceSigner;
|
|
55
|
+
}
|
|
56
|
+
get profile(): AccountProfile | undefined { return this.#profile && clone(this.#profile); }
|
|
57
|
+
on<K extends keyof ProfileEvents>(event: K, listener: EventListener<ProfileEvents[K]>): () => void { return this.#events.on(event, listener); }
|
|
58
|
+
protected override async onInitialize(signal: AbortSignal): Promise<void> {
|
|
59
|
+
this.#account.ensureInitialized();
|
|
60
|
+
await this.#store.initialize({ context: this.context.toString(), accountId: this.accountId }, signal);
|
|
61
|
+
const saved = (await this.#store.read([profileKey(this.accountId)], signal)).sets[0]![0]?.value;
|
|
62
|
+
if (saved) { const result = profileResolveResultCodec.decode(saved); validateProfileResult(result, this.context, this.accountId); this.#profile = result.profile; }
|
|
63
|
+
}
|
|
64
|
+
#accept(profile: AccountProfile | undefined): boolean {
|
|
65
|
+
const changed = (profile && accountProfileCodec.stringify(profile)) !== (this.#profile && accountProfileCodec.stringify(this.#profile));
|
|
66
|
+
this.#profile = profile && clone(profile); return changed;
|
|
67
|
+
}
|
|
68
|
+
#changed(): void { this.#events.notify('profileChanged', this.profile, error => this.notifyBackgroundError({ operation: 'observer', resource: 'profileChanged', error })); }
|
|
69
|
+
async getProfile(accountId = this.accountId, signal?: AbortSignal): Promise<AccountProfile | undefined> {
|
|
70
|
+
validateAccountId(accountId); let changed = false;
|
|
71
|
+
const profile = await this.runOperation(scope => this.#gate.run(async () => {
|
|
72
|
+
const result = await this.#resolve(accountId, await this.#home(scope), scope);
|
|
73
|
+
if (result) await this.#save(result, scope);
|
|
74
|
+
if (accountId === this.accountId) changed = this.#accept(result?.profile);
|
|
75
|
+
return result?.profile;
|
|
76
|
+
}, scope), signal);
|
|
77
|
+
if (changed) this.#changed(); return profile && clone(profile);
|
|
78
|
+
}
|
|
79
|
+
async updateProfile(update: ProfileUpdate, signal?: AbortSignal): Promise<AccountProfile> {
|
|
80
|
+
const copied = copyUpdate(update); let changed = false;
|
|
81
|
+
const result = await this.runOperation(scope => this.#gate.run(async () => {
|
|
82
|
+
const result = await this.#publish(copied, undefined, scope); changed = this.#accept(result); return result;
|
|
83
|
+
}, scope), signal);
|
|
84
|
+
if (changed) this.#changed(); return clone(result);
|
|
85
|
+
}
|
|
86
|
+
/** Republish the newest known local/pending/supplied snapshot, including during home-relay migration. */
|
|
87
|
+
async publishProfile(profile?: AccountProfile, signal?: AbortSignal): Promise<AccountProfile | undefined> {
|
|
88
|
+
const supplied = profile && clone(profile);
|
|
89
|
+
if (supplied) { validateProfile(supplied); if (supplied.account !== this.accountId) throw new ProtocolError('invalid_identity', 'Profile belongs to another account.'); }
|
|
90
|
+
let changed = false;
|
|
91
|
+
const result = await this.runOperation(scope => this.#gate.run(async () => {
|
|
92
|
+
const snapshot = await this.#store.read([profileKey(this.accountId), requestKey(method)], scope);
|
|
93
|
+
const local = snapshot.sets[0]![0]?.value;
|
|
94
|
+
const signed = readSignedRequest(snapshot.sets[1]![0]?.value);
|
|
95
|
+
let latest = local && profileResolveResultCodec.decode(local).profile;
|
|
96
|
+
if (signed?.pending && (!latest || signed.revision >= latest.updatedAt)) latest = accountProfileCodec.decode(signed.document);
|
|
97
|
+
if (supplied && (!latest || supplied.updatedAt > latest.updatedAt)) latest = supplied;
|
|
98
|
+
if (!latest) return undefined;
|
|
99
|
+
const published = await this.#publish({}, latest, scope); changed = this.#accept(published); return published;
|
|
100
|
+
}, scope), signal);
|
|
101
|
+
if (changed) this.#changed(); return result && clone(result);
|
|
102
|
+
}
|
|
103
|
+
async #home(signal: AbortSignal): Promise<string> {
|
|
104
|
+
const route = await this.#account.getRoute(undefined, signal);
|
|
105
|
+
if (!route) throw new ProtocolError('route_required', 'Establish or recover the account route before publishing profiles.');
|
|
106
|
+
return route.relayId;
|
|
107
|
+
}
|
|
108
|
+
async #resolve(account: string, relayId: string, signal: AbortSignal): Promise<ProfileResolveResult | undefined> {
|
|
109
|
+
const client = await this.#pool.get(relayId, { mode: 'device', signer: this.#signer }, signal);
|
|
110
|
+
let response;
|
|
111
|
+
try { response = await client.requestHttp('GET', 'profile.resolve', { account }, { signal }); }
|
|
112
|
+
catch (error) { if (error instanceof RelayError && error.code === 'not_found') return undefined; throw error; }
|
|
113
|
+
if (response === undefined) throw new ProtocolError('invalid_response', 'Profile resolution returned no document.');
|
|
114
|
+
const result = profileResolveResultCodec.decode(response); validateProfileResult(result, this.context, account); return result;
|
|
115
|
+
}
|
|
116
|
+
async #publish(update: ProfileUpdate, restored: AccountProfile | undefined, signal: AbortSignal): Promise<AccountProfile> {
|
|
117
|
+
const certificate = deviceCertificateCodec.decode(deviceCertificateCodec.encode(this.#signer.certificate));
|
|
118
|
+
if (certificate.account !== this.accountId) throw new ProtocolError('invalid_identity', 'Device signer belongs to another account.');
|
|
119
|
+
const relayId = await this.#home(signal);
|
|
120
|
+
const queries = [profileKey(this.accountId), requestKey(method)];
|
|
121
|
+
const snapshot = await this.#store.read(queries, signal);
|
|
122
|
+
const local = snapshot.sets[0]![0]?.value; const saved = snapshot.sets[1]![0]?.value;
|
|
123
|
+
let signed = readSignedRequest(saved); let request: AccountProfile;
|
|
124
|
+
if (signed?.pending && (!restored || signed.relayId === relayId)) {
|
|
125
|
+
request = accountProfileCodec.decode(signed.document);
|
|
126
|
+
if (signed.relayId !== relayId || accountProfileCodec.stringify(apply(update, request)) !== accountProfileCodec.stringify(request))
|
|
127
|
+
throw new StateConflictError('A profile publication has an unknown result. Retry the original update or resolve it first.');
|
|
128
|
+
} else {
|
|
129
|
+
const current = await this.#resolve(this.accountId, relayId, signal);
|
|
130
|
+
const cached = local && profileResolveResultCodec.decode(local).profile;
|
|
131
|
+
let basis = current?.profile;
|
|
132
|
+
if (basis && cached && basis.updatedAt < cached.updatedAt) throw new ProtocolError('stale_profile', 'Relay returned an older profile.');
|
|
133
|
+
for (const candidate of [cached, restored, signed?.pending ? accountProfileCodec.decode(signed.document) : undefined])
|
|
134
|
+
if (candidate && (!basis || candidate.updatedAt > basis.updatedAt)) basis = candidate;
|
|
135
|
+
basis ??= { account: this.accountId, publicDiscovery: false, updatedAt: 0, deviceSignature: new Uint8Array(64) };
|
|
136
|
+
request = { ...apply(update, basis), updatedAt: Math.max(this.clock.nowSeconds(), basis.updatedAt), deviceSignature: new Uint8Array(64) };
|
|
137
|
+
validateProfile(request);
|
|
138
|
+
request = { ...request, deviceSignature: (await this.#signer.sign(profileInput(request, this.context), signal)).slice() };
|
|
139
|
+
validateProfileResult({ profile: request, signerCertificate: certificate }, this.context, this.accountId);
|
|
140
|
+
const prepared: SignedRequest = { relayId, revision: request.updatedAt, document: accountProfileCodec.encode(request), pending: true };
|
|
141
|
+
await updateStore(this.#store, queries, state => {
|
|
142
|
+
if (fingerprint(state.sets[0]![0]?.value) !== fingerprint(local) || fingerprint(state.sets[1]![0]?.value) !== fingerprint(saved))
|
|
143
|
+
throw new StateConflictError('Profile changed while signing. Resolve current state before retrying.');
|
|
144
|
+
return { mutations: [putSignedRequest(method, prepared)], result: undefined };
|
|
145
|
+
}, signal);
|
|
146
|
+
signed = prepared;
|
|
147
|
+
}
|
|
148
|
+
// A renewed certificate with the same key is valid; a replaced signing identity cannot replay the old request.
|
|
149
|
+
validateProfileResult({ profile: request, signerCertificate: certificate }, this.context, this.accountId);
|
|
150
|
+
const currentCertificate = this.#signer.certificate;
|
|
151
|
+
if (currentCertificate.account !== certificate.account || canonicalJson(deviceCertificateCodec.encode(currentCertificate)) !== canonicalJson(deviceCertificateCodec.encode(certificate)))
|
|
152
|
+
throw new StateConflictError('Device signer changed during profile publication.');
|
|
153
|
+
const client = await this.#pool.get(relayId, { mode: 'device', signer: this.#signer }, signal);
|
|
154
|
+
try { await client.requestHttp('PUT', method, signed!.document, { signal }); }
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (error instanceof RelayError && error.isDefinitiveRejection) {
|
|
157
|
+
try { await finishSignedRequest(this.#store, method, signed!); }
|
|
158
|
+
catch (storageError) { throw new AggregateError([error, storageError], 'Profile rejection could not be persisted.'); }
|
|
159
|
+
}
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
await this.#save({ profile: request, signerCertificate: certificate }, signal); return request;
|
|
163
|
+
}
|
|
164
|
+
async #save(result: ProfileResolveResult, signal: AbortSignal): Promise<void> {
|
|
165
|
+
const profile = result.profile;
|
|
166
|
+
await updateStore(this.#store, [profileKey(profile.account), requestKey(method)], snapshot => {
|
|
167
|
+
const previous = snapshot.sets[0]![0]?.value;
|
|
168
|
+
if (previous && profile.updatedAt < profileResolveResultCodec.decode(previous).profile.updatedAt)
|
|
169
|
+
throw new ProtocolError('stale_profile', 'Returned profile is older than the known profile.');
|
|
170
|
+
const mutations: StoreMutation[] = [{ ...profileKey(profile.account), kind: 'put', value: profileResolveResultCodec.encode(result) }];
|
|
171
|
+
const signed = readSignedRequest(snapshot.sets[1]![0]?.value);
|
|
172
|
+
if (profile.account === this.accountId && signed?.pending && (profile.updatedAt > signed.revision
|
|
173
|
+
|| profile.updatedAt === signed.revision && accountProfileCodec.stringify(profile) === canonicalJson(signed.document)))
|
|
174
|
+
mutations.push(putSignedRequest(method, { ...signed, pending: false }));
|
|
175
|
+
return { mutations, result: undefined };
|
|
176
|
+
}, signal);
|
|
177
|
+
}
|
|
178
|
+
protected override async onDispose(): Promise<void> { this.#events.clear(); }
|
|
179
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { ProtocolError } from '../errors.js';
|
|
2
|
+
import { validateIdentifier } from '../identity/identifiers.js';
|
|
3
|
+
import { validateAccountId } from '../identity/neo.js';
|
|
4
|
+
import type { DeviceSigner } from '../interactions.js';
|
|
5
|
+
import { contentHashBytes } from '../models/content.js';
|
|
6
|
+
import { certificateId, deviceCertificateCodec, validateCertificate, type DeviceCertificate } from '../models/identity.js';
|
|
7
|
+
import { groupMessageEnvelopeCodec, groupMessageEnvelopeInput, groupPayloadCodec, validateGroupPayload, validateGroupSecretBox, verifyGroupMessageEnvelope,
|
|
8
|
+
type GroupMessageEnvelope, type GroupSecretBox } from '../models/groups.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 boxSalt = sha256(encodeUtf8('Meshline/keybox-salt/v1'));
|
|
16
|
+
const epochSalt = sha256(encodeUtf8('Meshline/group-epoch-salt/v1'));
|
|
17
|
+
const messageSalt = sha256(encodeUtf8('Meshline/group-message-salt/v1'));
|
|
18
|
+
/** Local protection/key-access failures remain retryable and never reject remote group messages. */
|
|
19
|
+
export class GroupKeyAccessError extends Error { override readonly name = 'GroupKeyAccessError'; }
|
|
20
|
+
/** Hash the complete signed management object, including its device signature; timeline metadata is external. */
|
|
21
|
+
export function groupManagementHash(value: JsonObject, context: NetworkContext): string {
|
|
22
|
+
if (typeof value.$type !== 'string') throw new ProtocolError('invalid_type', 'Management hashes require a typed protocol object.');
|
|
23
|
+
return `sha256:${encodeBase64Url(sha256(signingInput(value, context)))}`;
|
|
24
|
+
}
|
|
25
|
+
export function groupClientSecretCommitment(groupId: string, secret: Uint8Array, context: NetworkContext): string {
|
|
26
|
+
validateIdentifier('group', groupId); requireLength(secret, 32, 'Client group secret');
|
|
27
|
+
return groupManagementHash({ $type: 'meshline.group.client_secret.commitment', group_id: groupId, secret: encodeBase64Url(secret) }, context);
|
|
28
|
+
}
|
|
29
|
+
export interface GroupClientSecretBoxHeader { readonly groupId: string; readonly account: string; readonly memberEncryptionPublicKey: Uint8Array; readonly clientSecretCommitment: string }
|
|
30
|
+
export function groupClientSecretBoxAad(value: GroupClientSecretBoxHeader, context: NetworkContext): Uint8Array {
|
|
31
|
+
validateIdentifier('group', value.groupId); validateAccountId(value.account); requireLength(value.memberEncryptionPublicKey, 32, 'Member encryption public key'); contentHashBytes(value.clientSecretCommitment);
|
|
32
|
+
return signingInput({ $type: 'meshline.group.client_secret_box.aad', group_id: value.groupId, account: value.account,
|
|
33
|
+
member_encryption_public_key: encodeBase64Url(value.memberEncryptionPublicKey), client_secret_commitment: value.clientSecretCommitment }, context);
|
|
34
|
+
}
|
|
35
|
+
export interface GroupRelaySecretBoxHeader { readonly groupId: string; readonly account: string; readonly deviceId: string; readonly epoch: number }
|
|
36
|
+
export function groupRelaySecretBoxAad(value: GroupRelaySecretBoxHeader, context: NetworkContext): Uint8Array {
|
|
37
|
+
validateIdentifier('group', value.groupId); validateAccountId(value.account); validateIdentifier('device', value.deviceId); requireSafeInteger(value.epoch, 0);
|
|
38
|
+
return signingInput({ $type: 'meshline.group.relay_secret_box.aad', group_id: value.groupId, account: value.account, device_id: value.deviceId, epoch: value.epoch }, context);
|
|
39
|
+
}
|
|
40
|
+
/** Low-level secret wrapping with a protocol AAD projection; caller-owned secret and public key are never erased. */
|
|
41
|
+
export function sealGroupSecret(secret: Uint8Array, publicKey: Uint8Array, aad: Uint8Array, random: RandomSource = systemRandom): GroupSecretBox {
|
|
42
|
+
requireLength(secret, 32, 'Group secret'); requireLength(publicKey, 32, 'Secret recipient public key');
|
|
43
|
+
let ephemeral: Uint8Array | undefined; let shared: Uint8Array | undefined; let key: Uint8Array | undefined;
|
|
44
|
+
try {
|
|
45
|
+
ephemeral = random.bytes(32); requireLength(ephemeral, 32, 'Ephemeral private key'); const enc = encryptionPublicKey(ephemeral);
|
|
46
|
+
shared = agreeKey(ephemeral, publicKey); key = deriveKey(shared, boxSalt, aad);
|
|
47
|
+
const nonce = random.bytes(12); requireLength(nonce, 12, 'Secret box nonce');
|
|
48
|
+
return { alg: 'X25519-HKDF-SHA256-AES256GCM', enc, sealedSecret: concatBytes(nonce, encryptAes(key, nonce, secret, aad)) };
|
|
49
|
+
} finally { ephemeral?.fill(0); shared?.fill(0); key?.fill(0); }
|
|
50
|
+
}
|
|
51
|
+
/** Caller owns both the supplied shared secret and the returned plaintext; erase them after use. */
|
|
52
|
+
export function openGroupSecret(box: GroupSecretBox, shared: Uint8Array, aad: Uint8Array): Uint8Array {
|
|
53
|
+
validateGroupSecretBox(box); requireLength(shared, 32, 'Secret box shared secret');
|
|
54
|
+
if (shared.every(byte => byte === 0)) throw new ProtocolError('invalid_key', 'An all-zero shared secret is forbidden.');
|
|
55
|
+
const key = deriveKey(shared, boxSalt, aad);
|
|
56
|
+
try { return decryptAes(key, box.sealedSecret.subarray(0, 12), box.sealedSecret.subarray(12), aad); }
|
|
57
|
+
finally { key.fill(0); }
|
|
58
|
+
}
|
|
59
|
+
export function sealGroupClientSecret(header: GroupClientSecretBoxHeader, secret: Uint8Array, context: NetworkContext, random: RandomSource = systemRandom): GroupSecretBox {
|
|
60
|
+
const aad = groupClientSecretBoxAad(header, context);
|
|
61
|
+
if (groupClientSecretCommitment(header.groupId, secret, context) !== header.clientSecretCommitment) throw new ProtocolError('invalid_commitment', 'Client group secret does not match its commitment.');
|
|
62
|
+
return sealGroupSecret(secret, header.memberEncryptionPublicKey, aad, random);
|
|
63
|
+
}
|
|
64
|
+
/** Validates the client half before combining both independently supplied 32-byte secrets. */
|
|
65
|
+
export function deriveGroupApplicationSecret(groupId: string, epoch: number, clientSecretCommitment: string, clientSecret: Uint8Array, relaySecret: Uint8Array, context: NetworkContext): Uint8Array {
|
|
66
|
+
validateIdentifier('group', groupId); requireSafeInteger(epoch, 0); contentHashBytes(clientSecretCommitment);
|
|
67
|
+
requireLength(clientSecret, 32, 'Client group secret'); requireLength(relaySecret, 32, 'Relay epoch secret');
|
|
68
|
+
if (groupClientSecretCommitment(groupId, clientSecret, context) !== clientSecretCommitment) throw new ProtocolError('invalid_commitment', 'Client group secret does not match its commitment.');
|
|
69
|
+
const material = concatBytes(clientSecret, relaySecret);
|
|
70
|
+
try { return deriveKey(material, epochSalt, signingInput({ $type: 'meshline.group.epoch_secret', group_id: groupId, epoch, client_secret_commitment: clientSecretCommitment }, context)); }
|
|
71
|
+
finally { material.fill(0); }
|
|
72
|
+
}
|
|
73
|
+
export type GroupMessageHeader = Pick<GroupMessageEnvelope, 'groupId' | 'epoch' | 'messageId' | 'createdAt'>;
|
|
74
|
+
export function groupMessageAad(value: GroupMessageHeader, account: string, deviceId: string, context: NetworkContext): Uint8Array {
|
|
75
|
+
validateIdentifier('group', value.groupId); validateIdentifier('message', value.messageId); requireSafeInteger(value.epoch, 0); requireSafeInteger(value.createdAt, 0); validateAccountId(account); validateIdentifier('device', deviceId);
|
|
76
|
+
return signingInput({ $type: 'meshline.group.message.aad', group_id: value.groupId, epoch: value.epoch, message_id: value.messageId, created_at: value.createdAt, from: account, from_device_id: deviceId }, context);
|
|
77
|
+
}
|
|
78
|
+
export function deriveGroupMessageKey(applicationSecret: Uint8Array, aad: Uint8Array): Uint8Array { requireLength(applicationSecret, 32, 'Epoch application secret'); return deriveKey(applicationSecret, messageSalt, aad); }
|
|
79
|
+
export interface EncryptGroupMessageOptions extends GroupMessageHeader {
|
|
80
|
+
readonly context: NetworkContext; readonly signer: DeviceSigner; readonly applicationSecret: Uint8Array; readonly payload: JsonObject; readonly random?: RandomSource; readonly signal?: AbortSignal;
|
|
81
|
+
}
|
|
82
|
+
export async function encryptGroupMessage(options: EncryptGroupMessageOptions): Promise<GroupMessageEnvelope> {
|
|
83
|
+
const { context, signer, signal } = options; throwIfAborted(signal);
|
|
84
|
+
const certificate = deviceCertificateCodec.decode(deviceCertificateCodec.encode(signer.certificate)); validateCertificate(certificate, context);
|
|
85
|
+
const signerId = certificateId(certificate, context); const header = { groupId: options.groupId, epoch: options.epoch, messageId: options.messageId, createdAt: options.createdAt };
|
|
86
|
+
if (header.createdAt < certificate.notBefore || header.createdAt >= certificate.expiresAt) throw new ProtocolError('unauthorized_device', 'Group signer certificate is outside its validity window.');
|
|
87
|
+
const payload = groupPayloadCodec.decode(options.payload); validateGroupPayload(payload);
|
|
88
|
+
const aad = groupMessageAad(header, certificate.account, signerId, context); const key = deriveGroupMessageKey(options.applicationSecret, aad); let plaintext: Uint8Array | undefined;
|
|
89
|
+
try {
|
|
90
|
+
plaintext = canonicalBytes(payload); const nonce = (options.random ?? systemRandom).bytes(12); requireLength(nonce, 12, 'Group message nonce');
|
|
91
|
+
let envelope: GroupMessageEnvelope = { ...header, payload: { alg: 'AES-256-GCM', nonce, ciphertext: encryptAes(key, nonce, plaintext, aad) }, deviceSignature: new Uint8Array(64) };
|
|
92
|
+
envelope = { ...envelope, deviceSignature: (await signer.sign(groupMessageEnvelopeInput(envelope, context), signal)).slice() }; throwIfAborted(signal);
|
|
93
|
+
if (certificateId(signer.certificate, context) !== signerId) throw new ProtocolError('invalid_identity', 'Group signer changed identity during signing.');
|
|
94
|
+
verifyGroupMessageEnvelope(envelope, certificate, signerId, context); return envelope;
|
|
95
|
+
} finally { key.fill(0); plaintext?.fill(0); }
|
|
96
|
+
}
|
|
97
|
+
export interface DecryptGroupMessageOptions {
|
|
98
|
+
readonly context: NetworkContext; readonly envelope: GroupMessageEnvelope; readonly sender: DeviceCertificate; readonly signerDeviceId: string; readonly applicationSecret: Uint8Array;
|
|
99
|
+
}
|
|
100
|
+
/** Authenticates the signature, sender account and device before returning typed content. Membership validation remains mandatory in the receiver. */
|
|
101
|
+
export function decryptGroupMessage(options: DecryptGroupMessageOptions): JsonObject {
|
|
102
|
+
const { context, signerDeviceId } = options; const envelope = groupMessageEnvelopeCodec.decode(groupMessageEnvelopeCodec.encode(options.envelope));
|
|
103
|
+
const sender = deviceCertificateCodec.decode(deviceCertificateCodec.encode(options.sender)); verifyGroupMessageEnvelope(envelope, sender, signerDeviceId, context);
|
|
104
|
+
const key = deriveGroupMessageKey(options.applicationSecret, groupMessageAad(envelope, sender.account, signerDeviceId, context)); let plaintext: Uint8Array | undefined;
|
|
105
|
+
try { plaintext = decryptAes(key, envelope.payload.nonce, envelope.payload.ciphertext, groupMessageAad(envelope, sender.account, signerDeviceId, context)); return groupPayloadCodec.decode(parseJson(plaintext)); }
|
|
106
|
+
finally { key.fill(0); plaintext?.fill(0); }
|
|
107
|
+
}
|