@agora-sdk/secure-chat-core 0.1.0

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.
Files changed (56) hide show
  1. package/LICENSE +202 -0
  2. package/dist/cjs/context/secure-chat-context.d.ts +60 -0
  3. package/dist/cjs/context/secure-chat-context.js +70 -0
  4. package/dist/cjs/context/secure-chat-context.js.map +1 -0
  5. package/dist/cjs/contract/index.d.ts +150 -0
  6. package/dist/cjs/contract/index.js +16 -0
  7. package/dist/cjs/contract/index.js.map +1 -0
  8. package/dist/cjs/hooks/useSecureConversations.d.ts +35 -0
  9. package/dist/cjs/hooks/useSecureConversations.js +109 -0
  10. package/dist/cjs/hooks/useSecureConversations.js.map +1 -0
  11. package/dist/cjs/hooks/useSecureDevice.d.ts +47 -0
  12. package/dist/cjs/hooks/useSecureDevice.js +120 -0
  13. package/dist/cjs/hooks/useSecureDevice.js.map +1 -0
  14. package/dist/cjs/hooks/useSecureMessages.d.ts +52 -0
  15. package/dist/cjs/hooks/useSecureMessages.js +113 -0
  16. package/dist/cjs/hooks/useSecureMessages.js.map +1 -0
  17. package/dist/cjs/index.d.ts +15 -0
  18. package/dist/cjs/index.js +31 -0
  19. package/dist/cjs/index.js.map +1 -0
  20. package/dist/cjs/transport/rest.d.ts +154 -0
  21. package/dist/cjs/transport/rest.js +208 -0
  22. package/dist/cjs/transport/rest.js.map +1 -0
  23. package/dist/cjs/transport/socket.d.ts +79 -0
  24. package/dist/cjs/transport/socket.js +70 -0
  25. package/dist/cjs/transport/socket.js.map +1 -0
  26. package/dist/cjs/util/base64.d.ts +8 -0
  27. package/dist/cjs/util/base64.js +69 -0
  28. package/dist/cjs/util/base64.js.map +1 -0
  29. package/dist/esm/context/secure-chat-context.d.ts +60 -0
  30. package/dist/esm/context/secure-chat-context.js +66 -0
  31. package/dist/esm/context/secure-chat-context.js.map +1 -0
  32. package/dist/esm/contract/index.d.ts +150 -0
  33. package/dist/esm/contract/index.js +15 -0
  34. package/dist/esm/contract/index.js.map +1 -0
  35. package/dist/esm/hooks/useSecureConversations.d.ts +35 -0
  36. package/dist/esm/hooks/useSecureConversations.js +106 -0
  37. package/dist/esm/hooks/useSecureConversations.js.map +1 -0
  38. package/dist/esm/hooks/useSecureDevice.d.ts +47 -0
  39. package/dist/esm/hooks/useSecureDevice.js +117 -0
  40. package/dist/esm/hooks/useSecureDevice.js.map +1 -0
  41. package/dist/esm/hooks/useSecureMessages.d.ts +52 -0
  42. package/dist/esm/hooks/useSecureMessages.js +110 -0
  43. package/dist/esm/hooks/useSecureMessages.js.map +1 -0
  44. package/dist/esm/index.d.ts +15 -0
  45. package/dist/esm/index.js +17 -0
  46. package/dist/esm/index.js.map +1 -0
  47. package/dist/esm/transport/rest.d.ts +154 -0
  48. package/dist/esm/transport/rest.js +201 -0
  49. package/dist/esm/transport/rest.js.map +1 -0
  50. package/dist/esm/transport/socket.d.ts +79 -0
  51. package/dist/esm/transport/socket.js +66 -0
  52. package/dist/esm/transport/socket.js.map +1 -0
  53. package/dist/esm/util/base64.d.ts +8 -0
  54. package/dist/esm/util/base64.js +63 -0
  55. package/dist/esm/util/base64.js.map +1 -0
  56. package/package.json +52 -0
@@ -0,0 +1,150 @@
1
+ export type SecureConversationType = "dm" | "group" | "channel";
2
+ export type SecureMemberRole = "admin" | "member";
3
+ export type SecureHandshakeKind = "welcome" | "commit" | "proposal";
4
+ /** A Welcome targeted at the ONE device whose claimed KeyPackage was consumed. */
5
+ export interface WelcomeEnvelope {
6
+ targetDeviceId: string;
7
+ payload: string;
8
+ epoch: string;
9
+ }
10
+ /** A Commit/Proposal broadcast to the whole group (no target device). */
11
+ export interface HandshakeBlob {
12
+ payload: string;
13
+ epoch: string;
14
+ }
15
+ export interface RegisterDeviceBody {
16
+ deviceId: string;
17
+ displayName?: string | null;
18
+ signaturePublicKey: string;
19
+ credential: string;
20
+ ciphersuite: number;
21
+ }
22
+ export interface PublishKeyPackagesBody {
23
+ keyPackages: {
24
+ keyPackageRef: string;
25
+ keyPackage: string;
26
+ ciphersuite: number;
27
+ expiresAt?: string | null;
28
+ }[];
29
+ }
30
+ export interface CreateSecureConversationBody {
31
+ type: SecureConversationType;
32
+ mlsGroupId: string;
33
+ spaceId?: string | null;
34
+ name?: string | null;
35
+ memberUserIds?: string[];
36
+ welcomes?: WelcomeEnvelope[];
37
+ }
38
+ export interface AddSecureMemberBody {
39
+ userId: string;
40
+ commit: HandshakeBlob;
41
+ welcomes: WelcomeEnvelope[];
42
+ }
43
+ export interface RemoveSecureMemberBody {
44
+ commit: HandshakeBlob;
45
+ }
46
+ export interface SendSecureMessageBody {
47
+ ciphertext: string;
48
+ epoch: string;
49
+ senderDeviceId: string;
50
+ contentType?: string | null;
51
+ }
52
+ export interface UploadKeyBackupBody {
53
+ deviceId?: string | null;
54
+ blob: string;
55
+ nonce: string;
56
+ kdf: "argon2id" | "pbkdf2";
57
+ kdfParams: Record<string, unknown>;
58
+ cipher: "xchacha20poly1305" | "aes-256-gcm";
59
+ version: number;
60
+ }
61
+ export interface SecureDeviceModel {
62
+ id: string;
63
+ projectId: string;
64
+ userId: string;
65
+ deviceId: string;
66
+ displayName: string | null;
67
+ signaturePublicKey: string;
68
+ credential: string;
69
+ ciphersuite: number;
70
+ revokedAt: string | null;
71
+ lastSeenAt: string | null;
72
+ createdAt: string;
73
+ updatedAt: string;
74
+ }
75
+ export interface SecureKeyPackageClaim {
76
+ deviceId: string;
77
+ keyPackageRef: string;
78
+ keyPackage: string;
79
+ ciphersuite: number;
80
+ }
81
+ export interface SecureConversationMemberModel {
82
+ id: string;
83
+ projectId: string;
84
+ conversationId: string;
85
+ userId: string;
86
+ role: SecureMemberRole;
87
+ isActive: boolean;
88
+ joinedAtEpoch: string | null;
89
+ lastReadAt: string | null;
90
+ leftAt: string | null;
91
+ createdAt: string;
92
+ updatedAt: string;
93
+ }
94
+ export interface SecureConversationModel {
95
+ id: string;
96
+ projectId: string;
97
+ type: SecureConversationType;
98
+ mlsGroupId: string;
99
+ spaceId: string | null;
100
+ currentEpoch: string;
101
+ name: string | null;
102
+ createdById: string | null;
103
+ lastMessageAt: string | null;
104
+ memberCount?: number;
105
+ unreadCount?: number;
106
+ currentMember?: SecureConversationMemberModel;
107
+ createdAt: string;
108
+ updatedAt: string;
109
+ }
110
+ export interface SecureMessageModel {
111
+ id: string;
112
+ projectId: string;
113
+ conversationId: string;
114
+ senderUserId: string | null;
115
+ senderDeviceId: string | null;
116
+ epoch: string;
117
+ ciphertext: string;
118
+ contentType: string;
119
+ createdAt: string;
120
+ }
121
+ export interface SecureHandshakeModel {
122
+ id: string;
123
+ seq: string;
124
+ kind: SecureHandshakeKind;
125
+ conversationId: string;
126
+ epoch: string;
127
+ payload: string;
128
+ senderDeviceId: string | null;
129
+ targetDeviceId: string | null;
130
+ }
131
+ export interface SecureKeyBackupModel {
132
+ id: string;
133
+ projectId: string;
134
+ userId: string;
135
+ deviceId: string | null;
136
+ blob: string;
137
+ nonce: string;
138
+ kdf: string;
139
+ kdfParams: Record<string, unknown>;
140
+ cipher: string;
141
+ version: number;
142
+ createdAt: string;
143
+ updatedAt: string;
144
+ }
145
+ /** Standard error envelope: `{ error, code, field? }` with `secure-chat/*` codes. */
146
+ export interface SecureChatErrorBody {
147
+ error: string;
148
+ code: string;
149
+ field?: string;
150
+ }
@@ -0,0 +1,15 @@
1
+ // Secure-chat wire contract — stand-in for @agora/contract (the Apache-2.0 server contract).
2
+ //
3
+ // Mirrors the response models + request bodies of agora-server's
4
+ // `packages/contract/src/secure-chat.ts`. That package is the source of truth (zod + TS); the
5
+ // client only needs the TypeScript shapes, so this copy is types-only.
6
+ //
7
+ // ⚠️ Keep this byte-faithful to the server contract. The arrow is SDK → contract: once
8
+ // `@agora/contract` is published, DELETE this file and `import type { ... } from "@agora/contract"`.
9
+ // Do NOT publish a separate `@agora-sdk/secure-chat-contract` (that would invert the dependency —
10
+ // see STATUS.md). Do not let this copy drift in the meantime.
11
+ //
12
+ // Wire conventions: every binary value is **base64** (the server stores/relays opaque blobs and
13
+ // never parses them); MLS epochs are **decimal strings** (u64 exceeds JS safe-int range).
14
+ export {};
15
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/contract/index.ts"],"names":[],"mappings":"AAAA,6FAA6F;AAC7F,EAAE;AACF,iEAAiE;AACjE,8FAA8F;AAC9F,uEAAuE;AACvE,EAAE;AACF,uFAAuF;AACvF,qGAAqG;AACrG,kGAAkG;AAClG,8DAA8D;AAC9D,EAAE;AACF,gGAAgG;AAChG,0FAA0F"}
@@ -0,0 +1,35 @@
1
+ import { SecureConversationModel } from "../contract";
2
+ /** The state and actions returned by {@link useSecureConversations}. */
3
+ export interface UseSecureConversationsValues {
4
+ /** The loaded conversations, newest activity first. */
5
+ conversations: SecureConversationModel[];
6
+ /** True while a page load or refresh is in flight. */
7
+ loading: boolean;
8
+ /** Whether more pages remain to {@link UseSecureConversationsValues.loadMore}. */
9
+ hasMore: boolean;
10
+ /** The last error thrown by loading or conversation creation, or `null`. */
11
+ error: unknown;
12
+ /** Append the next page of older conversations. No-op when already loading or exhausted. */
13
+ loadMore: () => Promise<void>;
14
+ /** Reload from the top, replacing the current list. */
15
+ refresh: () => Promise<void>;
16
+ /** Start (or surface) a 1:1 conversation with another user across all their devices. */
17
+ createDirectConversation: (peerUserId: string) => Promise<SecureConversationModel>;
18
+ }
19
+ /**
20
+ * List the caller's secure conversations and start new direct messages.
21
+ *
22
+ * Refreshes on mount and on `secure:member:joined` / `secure:member:left` signals.
23
+ * {@link UseSecureConversationsValues.createDirectConversation} claims one KeyPackage per peer
24
+ * device, builds the MLS group locally, and registers it on the blind DS with targeted Welcomes —
25
+ * the group secrets never leave the client.
26
+ *
27
+ * @returns {@link UseSecureConversationsValues} — the conversation list, paging state, and actions.
28
+ *
29
+ * @example
30
+ * ```tsx
31
+ * const { conversations, createDirectConversation } = useSecureConversations();
32
+ * await createDirectConversation(peerUserId);
33
+ * ```
34
+ */
35
+ export declare function useSecureConversations(): UseSecureConversationsValues;
@@ -0,0 +1,106 @@
1
+ // useSecureConversations — list the caller's secure conversations and start new DMs.
2
+ //
3
+ // Starting a DM (server spec §14.3): claim each peer device's KeyPackage → createGroup locally →
4
+ // POST /conversations with the Welcomes targeted to each peer device. The MLS group secrets stay on
5
+ // the client; the server only stores ciphertext metadata.
6
+ import { useCallback, useEffect, useState } from "react";
7
+ import { toBase64, fromBase64 } from "../util/base64";
8
+ import { useSecureChat } from "../context/secure-chat-context";
9
+ /**
10
+ * List the caller's secure conversations and start new direct messages.
11
+ *
12
+ * Refreshes on mount and on `secure:member:joined` / `secure:member:left` signals.
13
+ * {@link UseSecureConversationsValues.createDirectConversation} claims one KeyPackage per peer
14
+ * device, builds the MLS group locally, and registers it on the blind DS with targeted Welcomes —
15
+ * the group secrets never leave the client.
16
+ *
17
+ * @returns {@link UseSecureConversationsValues} — the conversation list, paging state, and actions.
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * const { conversations, createDirectConversation } = useSecureConversations();
22
+ * await createDirectConversation(peerUserId);
23
+ * ```
24
+ */
25
+ export function useSecureConversations() {
26
+ const { rest, crypto, socket } = useSecureChat();
27
+ const [conversations, setConversations] = useState([]);
28
+ const [cursor, setCursor] = useState(undefined);
29
+ const [hasMore, setHasMore] = useState(true);
30
+ const [loading, setLoading] = useState(false);
31
+ const [error, setError] = useState(null);
32
+ const load = useCallback(async (reset) => {
33
+ setLoading(true);
34
+ setError(null);
35
+ try {
36
+ const page = await rest.listConversations({
37
+ cursor: reset ? undefined : cursor,
38
+ limit: 30,
39
+ });
40
+ const last = page.conversations[page.conversations.length - 1];
41
+ setCursor(last ? last.lastMessageAt ?? last.createdAt : cursor);
42
+ setHasMore(page.hasMore);
43
+ setConversations((prev) => (reset ? page.conversations : [...prev, ...page.conversations]));
44
+ }
45
+ catch (err) {
46
+ setError(err);
47
+ }
48
+ finally {
49
+ setLoading(false);
50
+ }
51
+ }, [rest, cursor]);
52
+ const refresh = useCallback(async () => {
53
+ setCursor(undefined);
54
+ await load(true);
55
+ }, [load]);
56
+ const loadMore = useCallback(async () => {
57
+ if (!hasMore || loading)
58
+ return;
59
+ await load(false);
60
+ }, [hasMore, loading, load]);
61
+ const createDirectConversation = useCallback(async (peerUserId) => {
62
+ // 1. Discover the peer's active devices and claim one KeyPackage per device.
63
+ const peerDevices = await rest.listDevices(peerUserId);
64
+ if (peerDevices.length === 0) {
65
+ throw new Error("Peer has no registered secure-chat devices.");
66
+ }
67
+ const initialMembers = await Promise.all(peerDevices.map(async (d) => {
68
+ const claim = await rest.claimKeyPackage(d.id);
69
+ return { deviceId: d.id, keyPackage: fromBase64(claim.keyPackage) };
70
+ }));
71
+ // 2. Create the MLS group locally; the crypto layer holds the secrets.
72
+ const { group, welcomes } = await crypto.createGroup({ initialMembers });
73
+ // 3. Register the conversation on the blind DS, relaying the targeted Welcomes.
74
+ const conversation = await rest.createConversation({
75
+ type: "dm",
76
+ mlsGroupId: toBase64(group.mlsGroupId),
77
+ memberUserIds: [peerUserId],
78
+ welcomes: welcomes.map((w) => ({
79
+ targetDeviceId: w.targetDeviceId,
80
+ payload: toBase64(w.payload),
81
+ epoch: group.epoch.toString(),
82
+ })),
83
+ });
84
+ setConversations((prev) => [conversation, ...prev.filter((c) => c.id !== conversation.id)]);
85
+ return conversation;
86
+ }, [rest, crypto]);
87
+ useEffect(() => {
88
+ refresh();
89
+ // eslint-disable-next-line react-hooks/exhaustive-deps
90
+ }, []);
91
+ // Light realtime refresh on membership changes (metadata-only signals).
92
+ useEffect(() => {
93
+ const offJoined = socket.on("secure:member:joined", () => {
94
+ refresh().catch(setError);
95
+ });
96
+ const offLeft = socket.on("secure:member:left", () => {
97
+ refresh().catch(setError);
98
+ });
99
+ return () => {
100
+ offJoined();
101
+ offLeft();
102
+ };
103
+ }, [socket, refresh]);
104
+ return { conversations, loading, hasMore, error, loadMore, refresh, createDirectConversation };
105
+ }
106
+ //# sourceMappingURL=useSecureConversations.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useSecureConversations.js","sourceRoot":"","sources":["../../../src/hooks/useSecureConversations.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,47 @@
1
+ import { SecureDeviceModel } from "../contract";
2
+ /** Options for {@link useSecureDevice}. */
3
+ export interface UseSecureDeviceOptions {
4
+ /** Stable, persisted client device id. Generated if omitted (persist it in the platform layer). */
5
+ deviceId?: string;
6
+ /** MLS ciphersuite to register under. Defaults to the crypto implementation's preferred suite. */
7
+ ciphersuite?: number;
8
+ /** How many KeyPackages to publish on registration and replenish toward. Default 20. */
9
+ keyPackageTarget?: number;
10
+ /** Auto-replenish when `secure:key-packages-low` fires. Default true. */
11
+ autoReplenish?: boolean;
12
+ }
13
+ /** The state and actions returned by {@link useSecureDevice}. */
14
+ export interface UseSecureDeviceValues {
15
+ /** The registered device row (its `.id` is the uuid used as targetDeviceId everywhere). */
16
+ device: SecureDeviceModel | null;
17
+ /** True while {@link UseSecureDeviceValues.register} is in flight. */
18
+ registering: boolean;
19
+ /** The last error thrown by registration or replenishment, or `null`. */
20
+ error: unknown;
21
+ /** Last known count of unconsumed KeyPackages, or `null` until refreshed. */
22
+ keyPackagesAvailable: number | null;
23
+ /** Generate identity + register (idempotent server-side on (userId, deviceId)). */
24
+ register: () => Promise<SecureDeviceModel>;
25
+ /** Generate + publish `count` fresh KeyPackages (default = keyPackageTarget). */
26
+ publishKeyPackages: (count?: number) => Promise<number>;
27
+ /** Re-query the server for the available KeyPackage count and update `keyPackagesAvailable`. */
28
+ refreshKeyPackageCount: () => Promise<number>;
29
+ }
30
+ /**
31
+ * Register this client as an MLS device (one device = one leaf) and keep its KeyPackages stocked.
32
+ *
33
+ * On {@link UseSecureDeviceValues.register} it generates a device identity, POSTs it to `/devices`,
34
+ * and (when `autoReplenish` is on) republishes KeyPackages whenever the server emits
35
+ * `secure:key-packages-low` for this device. The device's private key material must be persisted by
36
+ * the platform layer — this hook only handles registration and relay.
37
+ *
38
+ * @param options - {@link UseSecureDeviceOptions} — device id, ciphersuite, and replenishment tuning.
39
+ * @returns {@link UseSecureDeviceValues} — the device row, status flags, and register/publish actions.
40
+ *
41
+ * @example
42
+ * ```tsx
43
+ * const { device, register } = useSecureDevice({ keyPackageTarget: 20 });
44
+ * useEffect(() => { register(); }, []);
45
+ * ```
46
+ */
47
+ export declare function useSecureDevice(options?: UseSecureDeviceOptions): UseSecureDeviceValues;
@@ -0,0 +1,117 @@
1
+ // useSecureDevice — register this client as an MLS device (leaf) and keep its KeyPackages topped up.
2
+ //
3
+ // Flow (server spec §14): generateDeviceIdentity → POST /devices → publish a batch of KeyPackages;
4
+ // replenish on the `secure:key-packages-low` realtime signal or via the count endpoint.
5
+ //
6
+ // NOTE: the device's PRIVATE state (`privateState` from generateDeviceIdentity, and MLS group
7
+ // state) must be persisted by the platform layer (IndexedDB on web, keystore on native — Phase 2/3).
8
+ // Core only performs registration + relay; it does not persist secrets.
9
+ import { useCallback, useEffect, useRef, useState } from "react";
10
+ import { toBase64 } from "../util/base64";
11
+ import { useSecureChat } from "../context/secure-chat-context";
12
+ /**
13
+ * Mint a device id when the caller doesn't supply one — `crypto.randomUUID()` when available, else a
14
+ * non-cryptographic timestamp+random fallback. The platform layer should supply a persisted id.
15
+ *
16
+ * @returns A fresh device id string.
17
+ */
18
+ function newDeviceId() {
19
+ const g = globalThis.crypto;
20
+ if (g?.randomUUID)
21
+ return g.randomUUID();
22
+ // Platform layer should supply a persisted, stable device id; this is a non-crypto fallback.
23
+ return `dev-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`;
24
+ }
25
+ /**
26
+ * Register this client as an MLS device (one device = one leaf) and keep its KeyPackages stocked.
27
+ *
28
+ * On {@link UseSecureDeviceValues.register} it generates a device identity, POSTs it to `/devices`,
29
+ * and (when `autoReplenish` is on) republishes KeyPackages whenever the server emits
30
+ * `secure:key-packages-low` for this device. The device's private key material must be persisted by
31
+ * the platform layer — this hook only handles registration and relay.
32
+ *
33
+ * @param options - {@link UseSecureDeviceOptions} — device id, ciphersuite, and replenishment tuning.
34
+ * @returns {@link UseSecureDeviceValues} — the device row, status flags, and register/publish actions.
35
+ *
36
+ * @example
37
+ * ```tsx
38
+ * const { device, register } = useSecureDevice({ keyPackageTarget: 20 });
39
+ * useEffect(() => { register(); }, []);
40
+ * ```
41
+ */
42
+ export function useSecureDevice(options = {}) {
43
+ const { crypto, rest, socket } = useSecureChat();
44
+ const { ciphersuite, keyPackageTarget = 20, autoReplenish = true } = options;
45
+ const [device, setDevice] = useState(null);
46
+ const [registering, setRegistering] = useState(false);
47
+ const [error, setError] = useState(null);
48
+ const [keyPackagesAvailable, setKeyPackagesAvailable] = useState(null);
49
+ const deviceIdRef = useRef(options.deviceId ?? newDeviceId());
50
+ const publishKeyPackages = useCallback(async (count = keyPackageTarget) => {
51
+ if (!device)
52
+ throw new Error("Register the device before publishing KeyPackages.");
53
+ const bundles = await crypto.generateKeyPackages(count);
54
+ const published = await rest.publishKeyPackages(device.id, {
55
+ keyPackages: bundles.map((b) => ({
56
+ keyPackageRef: b.keyPackageRef,
57
+ keyPackage: toBase64(b.keyPackage),
58
+ ciphersuite: b.ciphersuite,
59
+ expiresAt: b.expiresAt,
60
+ })),
61
+ });
62
+ return published;
63
+ }, [crypto, rest, device, keyPackageTarget]);
64
+ const refreshKeyPackageCount = useCallback(async () => {
65
+ if (!device)
66
+ throw new Error("Register the device before checking KeyPackage count.");
67
+ const available = await rest.keyPackageCount(device.id);
68
+ setKeyPackagesAvailable(available);
69
+ return available;
70
+ }, [rest, device]);
71
+ const register = useCallback(async () => {
72
+ setRegistering(true);
73
+ setError(null);
74
+ try {
75
+ const { identity } = await crypto.generateDeviceIdentity({
76
+ deviceId: deviceIdRef.current,
77
+ ciphersuite,
78
+ });
79
+ const registered = await rest.registerDevice({
80
+ deviceId: identity.deviceId,
81
+ signaturePublicKey: toBase64(identity.signaturePublicKey),
82
+ credential: toBase64(identity.credential),
83
+ ciphersuite: identity.ciphersuite,
84
+ });
85
+ setDevice(registered);
86
+ return registered;
87
+ }
88
+ catch (err) {
89
+ setError(err);
90
+ throw err;
91
+ }
92
+ finally {
93
+ setRegistering(false);
94
+ }
95
+ }, [crypto, rest, ciphersuite]);
96
+ // Auto-replenish on the server's low-water signal for this device.
97
+ useEffect(() => {
98
+ if (!autoReplenish || !device)
99
+ return;
100
+ const off = socket.on("secure:key-packages-low", (signal) => {
101
+ if (signal.deviceId !== device.id)
102
+ return;
103
+ publishKeyPackages().catch(setError);
104
+ });
105
+ return off;
106
+ }, [autoReplenish, device, socket, publishKeyPackages]);
107
+ return {
108
+ device,
109
+ registering,
110
+ error,
111
+ keyPackagesAvailable,
112
+ register,
113
+ publishKeyPackages,
114
+ refreshKeyPackageCount,
115
+ };
116
+ }
117
+ //# sourceMappingURL=useSecureDevice.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useSecureDevice.js","sourceRoot":"","sources":["../../../src/hooks/useSecureDevice.tsx"],"names":[],"mappings":"AAAA,qGAAqG;AACrG,EAAE;AACF,mGAAmG;AACnG,wFAAwF;AACxF,EAAE;AACF,8FAA8F;AAC9F,qGAAqG;AACrG,wEAAwE;AAExE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAEjE,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,gCAAgC,CAAC;AAE/D;;;;;GAKG;AACH,SAAS,WAAW;IAClB,MAAM,CAAC,GAAI,UAAyD,CAAC,MAAM,CAAC;IAC5E,IAAI,CAAC,EAAE,UAAU;QAAE,OAAO,CAAC,CAAC,UAAU,EAAE,CAAC;IACzC,6FAA6F;IAC7F,OAAO,OAAO,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,GAAG,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,CAAC;AAC1F,CAAC;AAgCD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,eAAe,CAAC,UAAkC,EAAE;IAClE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,aAAa,EAAE,CAAC;IACjD,MAAM,EAAE,WAAW,EAAE,gBAAgB,GAAG,EAAE,EAAE,aAAa,GAAG,IAAI,EAAE,GAAG,OAAO,CAAC;IAE7E,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,QAAQ,CAA2B,IAAI,CAAC,CAAC;IACrE,MAAM,CAAC,WAAW,EAAE,cAAc,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IACtD,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAU,IAAI,CAAC,CAAC;IAClD,MAAM,CAAC,oBAAoB,EAAE,uBAAuB,CAAC,GAAG,QAAQ,CAAgB,IAAI,CAAC,CAAC;IAEtF,MAAM,WAAW,GAAG,MAAM,CAAS,OAAO,CAAC,QAAQ,IAAI,WAAW,EAAE,CAAC,CAAC;IAEtE,MAAM,kBAAkB,GAAG,WAAW,CACpC,KAAK,EAAE,QAAgB,gBAAgB,EAAmB,EAAE;QAC1D,IAAI,CAAC,MAAM;YAAE,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAC;QACnF,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,mBAAmB,CAAC,KAAK,CAAC,CAAC;QACxD,MAAM,SAAS,GAAG,MAAM,IAAI,CAAC,kBAAkB,CAAC,MAAM,CAAC,EAAE,EAAE;YACzD,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;gBAC/B,aAAa,EAAE,CAAC,CAAC,aAAa;gBAC9B,UAAU,EAAE,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC;gBAClC,WAAW,EAAE,CAAC,CAAC,WAAW;gBAC1B,SAAS,EAAE,CAAC,CAAC,SAAS;aACvB,CAAC,CAAC;SACJ,CAAC,CAAC;QACH,OAAO,SAAS,CAAC;IACnB,CAAC,EACD,CAAC,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,gBAAgB,CAAC,CACzC,CAAC;IAEF,MAAM,sBAAsB,GAAG,WAAW,CAAC,KAAK,IAAqB,EAAE;QACrE,IAAI,CAAC,MAAM;YAAE,MAAM,IAAI,KAAK,CAAC,uDAAuD,CAAC,CAAC;QACtF,MAAM,SAAS,GAAG,MAAM,IAAI,CAAC,eAAe,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QACxD,uBAAuB,CAAC,SAAS,CAAC,CAAC;QACnC,OAAO,SAAS,CAAC;IACnB,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;IAEnB,MAAM,QAAQ,GAAG,WAAW,CAAC,KAAK,IAAgC,EAAE;QAClE,cAAc,CAAC,IAAI,CAAC,CAAC;QACrB,QAAQ,CAAC,IAAI,CAAC,CAAC;QACf,IAAI,CAAC;YACH,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,MAAM,CAAC,sBAAsB,CAAC;gBACvD,QAAQ,EAAE,WAAW,CAAC,OAAO;gBAC7B,WAAW;aACZ,CAAC,CAAC;YACH,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,cAAc,CAAC;gBAC3C,QAAQ,EAAE,QAAQ,CAAC,QAAQ;gBAC3B,kBAAkB,EAAE,QAAQ,CAAC,QAAQ,CAAC,kBAAkB,CAAC;gBACzD,UAAU,EAAE,QAAQ,CAAC,QAAQ,CAAC,UAAU,CAAC;gBACzC,WAAW,EAAE,QAAQ,CAAC,WAAW;aAClC,CAAC,CAAC;YACH,SAAS,CAAC,UAAU,CAAC,CAAC;YACtB,OAAO,UAAU,CAAC;QACpB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,QAAQ,CAAC,GAAG,CAAC,CAAC;YACd,MAAM,GAAG,CAAC;QACZ,CAAC;gBAAS,CAAC;YACT,cAAc,CAAC,KAAK,CAAC,CAAC;QACxB,CAAC;IACH,CAAC,EAAE,CAAC,MAAM,EAAE,IAAI,EAAE,WAAW,CAAC,CAAC,CAAC;IAEhC,mEAAmE;IACnE,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,CAAC,aAAa,IAAI,CAAC,MAAM;YAAE,OAAO;QACtC,MAAM,GAAG,GAAG,MAAM,CAAC,EAAE,CAAC,yBAAyB,EAAE,CAAC,MAAM,EAAE,EAAE;YAC1D,IAAI,MAAM,CAAC,QAAQ,KAAK,MAAM,CAAC,EAAE;gBAAE,OAAO;YAC1C,kBAAkB,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;QACvC,CAAC,CAAC,CAAC;QACH,OAAO,GAAG,CAAC;IACb,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,EAAE,MAAM,EAAE,kBAAkB,CAAC,CAAC,CAAC;IAExD,OAAO;QACL,MAAM;QACN,WAAW;QACX,KAAK;QACL,oBAAoB;QACpB,QAAQ;QACR,kBAAkB;QAClB,sBAAsB;KACvB,CAAC;AACJ,CAAC"}
@@ -0,0 +1,52 @@
1
+ import { SecureMessageModel } from "../contract";
2
+ import { GroupHandle } from "@agora-sdk/secure-chat-crypto";
3
+ /** A stored message paired with its decrypted text (when a group handle is available). */
4
+ export interface DecryptedSecureMessage {
5
+ /** The raw message row from the server (still holds the base64 ciphertext). */
6
+ model: SecureMessageModel;
7
+ /** Decrypted text, or null when no group handle is available or decryption is pending/failed. */
8
+ plaintext: string | null;
9
+ }
10
+ /** Options for {@link useSecureMessages}. */
11
+ export interface UseSecureMessagesOptions {
12
+ /** The MLS group handle for this conversation (from the platform persistence layer). */
13
+ group?: GroupHandle;
14
+ /** The caller's device row id — required to send (the server verifies it belongs to the caller). */
15
+ senderDeviceId?: string;
16
+ }
17
+ /** The state and actions returned by {@link useSecureMessages}. */
18
+ export interface UseSecureMessagesValues {
19
+ /** Loaded messages, newest first, each with decrypted text when possible. */
20
+ messages: DecryptedSecureMessage[];
21
+ /** True while a page load or refresh is in flight. */
22
+ loading: boolean;
23
+ /** Whether older messages remain to {@link UseSecureMessagesValues.loadMore}. */
24
+ hasMore: boolean;
25
+ /** The last error thrown by loading or sending, or `null`. */
26
+ error: unknown;
27
+ /** Append the next page of older messages. No-op when already loading or exhausted. */
28
+ loadMore: () => Promise<void>;
29
+ /** Reload from the newest message, replacing the current list. */
30
+ refresh: () => Promise<void>;
31
+ /** Encrypt + send a text message. Requires `group` + `senderDeviceId`. */
32
+ sendMessage: (text: string) => Promise<void>;
33
+ }
34
+ /**
35
+ * Load, decrypt, send, and live-receive messages in one secure conversation.
36
+ *
37
+ * Decryption runs through the injected `SecureChatCrypto` against the conversation's MLS
38
+ * `GroupHandle`. Without a `group` in `options`, ciphertext is still listed and received (as
39
+ * `plaintext: null`) and sending is disabled — letting the transport work ahead of the crypto
40
+ * wiring. Joins the conversation's socket room to receive `secure:message` events live.
41
+ *
42
+ * @param conversationId - The conversation to read and send within.
43
+ * @param options - {@link UseSecureMessagesOptions} — the MLS `group` handle and `senderDeviceId`.
44
+ * @returns {@link UseSecureMessagesValues} — the message list, paging state, and `sendMessage`.
45
+ *
46
+ * @example
47
+ * ```tsx
48
+ * const { messages, sendMessage } = useSecureMessages(conversationId, { group, senderDeviceId });
49
+ * await sendMessage("hello 💜");
50
+ * ```
51
+ */
52
+ export declare function useSecureMessages(conversationId: string, options?: UseSecureMessagesOptions): UseSecureMessagesValues;
@@ -0,0 +1,110 @@
1
+ // useSecureMessages — load, decrypt, send, and live-receive messages in a secure conversation.
2
+ //
3
+ // Encryption/decryption runs through the injected `SecureChatCrypto` against the conversation's MLS
4
+ // `GroupHandle`. Resolving conversationId → GroupHandle is owned by the platform persistence layer
5
+ // (Phase 2: IndexedDB group state via processWelcome/importGroupState), so the caller passes the
6
+ // handle in. Without it, ciphertext is still listed/received but left undecrypted (`plaintext: null`)
7
+ // and sending is disabled — keeping the transport usable ahead of the crypto wiring.
8
+ import { useCallback, useEffect, useState } from "react";
9
+ import { toBase64, fromBase64, utf8ToBytes, bytesToUtf8 } from "../util/base64";
10
+ import { useSecureChat } from "../context/secure-chat-context";
11
+ /**
12
+ * Load, decrypt, send, and live-receive messages in one secure conversation.
13
+ *
14
+ * Decryption runs through the injected `SecureChatCrypto` against the conversation's MLS
15
+ * `GroupHandle`. Without a `group` in `options`, ciphertext is still listed and received (as
16
+ * `plaintext: null`) and sending is disabled — letting the transport work ahead of the crypto
17
+ * wiring. Joins the conversation's socket room to receive `secure:message` events live.
18
+ *
19
+ * @param conversationId - The conversation to read and send within.
20
+ * @param options - {@link UseSecureMessagesOptions} — the MLS `group` handle and `senderDeviceId`.
21
+ * @returns {@link UseSecureMessagesValues} — the message list, paging state, and `sendMessage`.
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * const { messages, sendMessage } = useSecureMessages(conversationId, { group, senderDeviceId });
26
+ * await sendMessage("hello 💜");
27
+ * ```
28
+ */
29
+ export function useSecureMessages(conversationId, options = {}) {
30
+ const { rest, crypto, socket } = useSecureChat();
31
+ const { group, senderDeviceId } = options;
32
+ const [messages, setMessages] = useState([]);
33
+ const [before, setBefore] = useState(undefined);
34
+ const [hasMore, setHasMore] = useState(true);
35
+ const [loading, setLoading] = useState(false);
36
+ const [error, setError] = useState(null);
37
+ const decrypt = useCallback(async (model) => {
38
+ if (!group)
39
+ return { model, plaintext: null };
40
+ try {
41
+ const { plaintext } = await crypto.decryptMessage(group, fromBase64(model.ciphertext));
42
+ return { model, plaintext: bytesToUtf8(plaintext) };
43
+ }
44
+ catch {
45
+ // Buffer/skip: epoch not yet reached, or undecryptable. Surface ciphertext without text.
46
+ return { model, plaintext: null };
47
+ }
48
+ }, [crypto, group]);
49
+ const load = useCallback(async (reset) => {
50
+ setLoading(true);
51
+ setError(null);
52
+ try {
53
+ const page = await rest.listMessages(conversationId, {
54
+ before: reset ? undefined : before,
55
+ limit: 40,
56
+ });
57
+ const decrypted = await Promise.all(page.messages.map(decrypt));
58
+ const oldest = page.messages[page.messages.length - 1];
59
+ setBefore(oldest ? oldest.createdAt : before);
60
+ setHasMore(page.hasMore);
61
+ // Server returns created_at DESC; keep newest-first in state.
62
+ setMessages((prev) => (reset ? decrypted : [...prev, ...decrypted]));
63
+ }
64
+ catch (err) {
65
+ setError(err);
66
+ }
67
+ finally {
68
+ setLoading(false);
69
+ }
70
+ }, [rest, conversationId, before, decrypt]);
71
+ const refresh = useCallback(async () => {
72
+ setBefore(undefined);
73
+ await load(true);
74
+ }, [load]);
75
+ const loadMore = useCallback(async () => {
76
+ if (!hasMore || loading)
77
+ return;
78
+ await load(false);
79
+ }, [hasMore, loading, load]);
80
+ const sendMessage = useCallback(async (text) => {
81
+ if (!group)
82
+ throw new Error("Cannot send: no MLS group handle for this conversation.");
83
+ if (!senderDeviceId)
84
+ throw new Error("Cannot send: senderDeviceId is required.");
85
+ const { ciphertext, epoch } = await crypto.encryptMessage(group, utf8ToBytes(text));
86
+ const sent = await rest.sendMessage(conversationId, {
87
+ ciphertext: toBase64(ciphertext),
88
+ epoch: epoch.toString(),
89
+ senderDeviceId,
90
+ });
91
+ // Optimistic: we know our own plaintext without a round-trip through decrypt.
92
+ setMessages((prev) => [{ model: sent, plaintext: text }, ...prev]);
93
+ }, [crypto, rest, conversationId, group, senderDeviceId]);
94
+ useEffect(() => {
95
+ refresh();
96
+ // eslint-disable-next-line react-hooks/exhaustive-deps
97
+ }, [conversationId]);
98
+ // Live receive: join the conversation room and decrypt inbound ciphertext.
99
+ useEffect(() => {
100
+ socket.joinConversation(conversationId);
101
+ const off = socket.on("secure:message", (model) => {
102
+ if (model.conversationId !== conversationId)
103
+ return;
104
+ decrypt(model).then((m) => setMessages((prev) => prev.some((p) => p.model.id === m.model.id) ? prev : [m, ...prev]));
105
+ });
106
+ return off;
107
+ }, [socket, conversationId, decrypt]);
108
+ return { messages, loading, hasMore, error, loadMore, refresh, sendMessage };
109
+ }
110
+ //# sourceMappingURL=useSecureMessages.js.map