ping-openmls-sdk-react-native-macos 0.2.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.
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "ping-openmls-sdk-react-native-macos",
3
+ "version": "0.2.0",
4
+ "description": "Real MLS for React Native macOS apps — wraps the ping-openmls-sdk Rust core via UniFFI.",
5
+ "homepage": "https://github.com/AMP-Media-Development/ping-openmls-sdk",
6
+ "license": "Apache-2.0",
7
+ "author": "Ping",
8
+ "main": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "files": [
11
+ "src",
12
+ "ios",
13
+ "Frameworks",
14
+ "ping-openmls-sdk-react-native-macos.podspec",
15
+ "README.md"
16
+ ],
17
+ "keywords": [
18
+ "react-native",
19
+ "react-native-macos",
20
+ "mls",
21
+ "openmls",
22
+ "e2ee"
23
+ ],
24
+ "peerDependencies": {
25
+ "react": ">=18",
26
+ "react-native": ">=0.73",
27
+ "react-native-macos": ">=0.73"
28
+ },
29
+ "codegenConfig": {
30
+ "name": "PingNativeSpec",
31
+ "type": "modules",
32
+ "jsSrcsDir": "src"
33
+ }
34
+ }
@@ -0,0 +1,54 @@
1
+ # CocoaPods descriptor for `ping-openmls-sdk-react-native-macos`.
2
+ #
3
+ # RN's autolinking discovers this file via the `react-native.config.js` of consumer apps
4
+ # (or via Microsoft's `react-native-macos` autolinking script for macOS targets) and
5
+ # generates the `pod 'ping-openmls-sdk-react-native-macos'` line in the host's Podfile.
6
+ #
7
+ # Spec: docs/RN_NATIVE_BINDINGS.md (stage 2).
8
+
9
+ require 'json'
10
+
11
+ package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
12
+
13
+ Pod::Spec.new do |s|
14
+ # CocoaPods Pod names cannot contain `@` or `/` (those are reserved — `/` means subspec).
15
+ # The npm package is `ping-openmls-sdk-react-native-macos` but the Pod name has to be
16
+ # the hyphenated, unscoped form. RN's autolinker discovers this podspec via
17
+ # `react-native.config.js` (sibling file) and translates between the two names.
18
+ s.name = 'ping-openmls-sdk-react-native-macos'
19
+ s.version = package['version']
20
+ s.summary = package['description']
21
+ s.homepage = package['homepage']
22
+ # SPDX identifier only — pointing at `:file => '../../LICENSE'` would resolve from the
23
+ # package's install location under `node_modules/`, where the relative path doesn't
24
+ # reach the repo root. CocoaPods accepts a bare SPDX string for the license type.
25
+ s.license = package['license']
26
+ s.authors = { 'Ping' => 'https://github.com/AMP-Media-Development' }
27
+
28
+ # macOS-only Pod. The directory is named `ios/` to match the conventional CocoaPods
29
+ # layout, but the platform target is macOS — react-native-macos uses the iOS Pod
30
+ # tooling because it shares the AppKit-on-Cocoa codepath, even though the deployment
31
+ # target is `:osx`.
32
+ s.platforms = { :osx => '10.15' }
33
+
34
+ s.source = { :git => 'https://github.com/AMP-Media-Development/ping-openmls-sdk.git',
35
+ :tag => "ping-openmls-sdk-rn-macos-v#{s.version}" }
36
+
37
+ s.source_files = 'ios/**/*.{h,m,mm,swift}'
38
+ s.public_header_files = 'ios/pingFFI.h'
39
+ s.preserve_paths = 'ios/module.modulemap', 'Frameworks/libping_ffi.a'
40
+
41
+ # Universal arm64+x86_64 static library produced by `scripts/build-macos.sh`. Static
42
+ # linking means the linker bakes the Rust code into the host app's binary at build
43
+ # time — no dylib to embed, no rpath setup, no runtime loading. This matches what the
44
+ # iOS pipeline does (see `scripts/build-ios.sh` → xcframework wraps a `.a`).
45
+ s.vendored_libraries = 'Frameworks/libping_ffi.a'
46
+
47
+ s.dependency 'React-Core'
48
+
49
+ s.pod_target_xcconfig = {
50
+ 'SWIFT_INCLUDE_PATHS' => '$(PODS_TARGET_SRCROOT)/ios',
51
+ 'CLANG_ENABLE_MODULES' => 'YES',
52
+ 'DEFINES_MODULE' => 'YES'
53
+ }
54
+ end
@@ -0,0 +1,488 @@
1
+ // Public `MessagingClient` API for `ping-openmls-sdk-react-native-macos`. Mirrors the
2
+ // shape of `ping-openmls-sdk`'s web SDK exactly so a desktop app can swap between the
3
+ // two with a single import change.
4
+ //
5
+ // Stage 4b surface: `init`, `userId`, `deviceId`. Stages 4c–4f add the rest.
6
+ //
7
+ // Spec: docs/RN_NATIVE_BINDINGS.md (stage 4b).
8
+
9
+ import { NativeEventEmitter, NativeModules } from "react-native";
10
+ import NativePing from "./NativePing";
11
+ import { connectStorageBridge, type Storage } from "./storage-bridge";
12
+ import { connectTransportBridge, type Transport } from "./transport-bridge";
13
+
14
+ export interface ClientConfig {
15
+ /**
16
+ * Identity export bytes. For a fresh device, generate via
17
+ * `MessagingClient.generateIdentity()`. For a returning device, load from secure
18
+ * storage (the bytes carry the long-term identity material).
19
+ */
20
+ identityExport: Uint8Array;
21
+ /** Human-readable label for this device (shown to users in device lists). */
22
+ deviceLabel: string;
23
+ /** Caller's storage backend. Wired into the native bridge during `init`. */
24
+ storage: Storage;
25
+ /** Caller's transport backend. Wired into the native bridge during `init`. */
26
+ transport: Transport;
27
+ }
28
+
29
+ /**
30
+ * High-level facade over the native MessagingClient. Methods marshal arguments + return
31
+ * values across the JS↔Swift bridge; the actual MLS work happens in Rust.
32
+ */
33
+ export class MessagingClient {
34
+ /** Subscribers for incoming application messages (stage 4e). */
35
+ private readonly messageListeners = new Set<(msg: IncomingMessage) => void>();
36
+ /** Subscribers for conversation epoch changes (stage 4e). */
37
+ private readonly convUpdateListeners = new Set<(id: Uint8Array, epoch: number) => void>();
38
+ /** Native event subscriptions held while the client is alive. */
39
+ private observerSubscriptions: Array<{ remove(): void }> = [];
40
+ /** Transport's live-receive subscription, populated by init() if transport.subscribe exists. */
41
+ private transportSubscription?: { unsubscribe(): void };
42
+
43
+ private constructor(
44
+ private readonly disconnectStorage: () => void,
45
+ private readonly disconnectTransport: () => void,
46
+ ) {}
47
+
48
+ /**
49
+ * Construct a MessagingClient. Wires up the storage + transport bridges, then asks the
50
+ * native module to instantiate the underlying UniFFI handle.
51
+ */
52
+ static async init(cfg: ClientConfig): Promise<MessagingClient> {
53
+ // Connect bridges BEFORE calling native init — Rust's `MessagingClient::init` reads
54
+ // from storage during construction, so the JS handlers must be subscribed first.
55
+ const disconnectStorage = connectStorageBridge(cfg.storage);
56
+ const disconnectTransport = connectTransportBridge(cfg.transport);
57
+
58
+ try {
59
+ const identityB64 = bytesToBase64(cfg.identityExport);
60
+ await NativePing.initClient(identityB64, cfg.deviceLabel, Date.now());
61
+ } catch (e) {
62
+ // Init failed — disconnect the bridges so we don't leak event listeners.
63
+ disconnectStorage();
64
+ disconnectTransport();
65
+ throw e;
66
+ }
67
+
68
+ const client = new MessagingClient(disconnectStorage, disconnectTransport);
69
+
70
+ // Install the observer bridge so native can fire application-message and
71
+ // conversation-updated events. Failure here is non-fatal — the client still works
72
+ // for explicit `processEnvelope` / `syncConversations` calls.
73
+ try {
74
+ await client.installObserver();
75
+ } catch (e) {
76
+ // eslint-disable-next-line no-console
77
+ console.warn("[ping-openmls-sdk-react-native-macos] observer install failed:", e);
78
+ }
79
+
80
+ // Auto-subscribe transport's live receive path. Dispatch on envelope kind:
81
+ // - Welcome → joinConversation (the conv is, by definition, unknown until we join)
82
+ // - Everything else → processEnvelope
83
+ // Both can fail silently — Welcomes are broadcast to all WS clients (only the
84
+ // recipient device succeeds), and non-Welcomes for unknown convs are
85
+ // foreign traffic we drop.
86
+ //
87
+ // Rust's `join_conversation` does NOT fire `on_conversation_updated` for the new
88
+ // conv (the observer is only invoked from `process_envelope` on epoch advances).
89
+ // We emit it manually here so consumers using `client.onConversationUpdated`
90
+ // see the join — matching what the web SDK's worker does.
91
+ if (cfg.transport.subscribe) {
92
+ const sub = cfg.transport.subscribe((envelopeJson) => {
93
+ void (async () => {
94
+ const kind = envelopeJson.kind;
95
+ // eslint-disable-next-line no-console
96
+ console.log("[ws-subscribe] received kind=" + String(kind));
97
+ try {
98
+ if (kind === "Welcome") {
99
+ const c = await client.joinConversation(envelopeJson);
100
+ // eslint-disable-next-line no-console
101
+ console.log("[ws-subscribe] joined conv id=" +
102
+ Array.from(c.id).map((b) => b.toString(16).padStart(2, "0")).join(""));
103
+ client.fireConvUpdated(c.id, 0);
104
+ } else {
105
+ await client.processEnvelope(envelopeJson);
106
+ }
107
+ } catch (e) {
108
+ // Expected for foreign Welcomes (other device's KP) or unknown-conv traffic.
109
+ // Logged for diagnostic value during stage 5 — drop to silent in production.
110
+ const msg = e instanceof Error ? e.message : String(e);
111
+ // eslint-disable-next-line no-console
112
+ console.log("[ws-subscribe] " + String(kind) + " dropped: " + msg.slice(0, 80));
113
+ }
114
+ })();
115
+ });
116
+ client.transportSubscription = sub;
117
+ }
118
+
119
+ return client;
120
+ }
121
+
122
+ /**
123
+ * Manually fire the `onConversationUpdated` listeners. Used by the auto-subscribe
124
+ * path to surface joins (since Rust's `join_conversation` doesn't trigger the
125
+ * observer).
126
+ */
127
+ private fireConvUpdated(id: Uint8Array, epoch: number): void {
128
+ for (const cb of this.convUpdateListeners) cb(id, epoch);
129
+ }
130
+
131
+ private async installObserver(): Promise<void> {
132
+ await NativePing.setObserver();
133
+
134
+ const emitter = new NativeEventEmitter(NativeModules.PingNative);
135
+ this.observerSubscriptions.push(
136
+ emitter.addListener("PingApplicationMessage", (raw: Record<string, unknown>) => {
137
+ const msg = decodeIncomingMessage(raw);
138
+ for (const cb of this.messageListeners) cb(msg);
139
+ }),
140
+ emitter.addListener(
141
+ "PingConversationUpdated",
142
+ (raw: { conversation_id?: number[]; new_epoch?: number }) => {
143
+ const id = bytesFrom(raw.conversation_id);
144
+ const epoch = Number(raw.new_epoch ?? 0);
145
+ for (const cb of this.convUpdateListeners) cb(id, epoch);
146
+ },
147
+ ),
148
+ );
149
+ }
150
+
151
+ /**
152
+ * Generate a fresh identity. Returns the bytes that should be persisted by the caller
153
+ * and passed back as `identityExport` on subsequent launches.
154
+ *
155
+ * Calls into Rust via UniFFI's `generateIdentityExport()` free function — the bytes
156
+ * are a properly-formatted Identity export (Ed25519 keypair + metadata, CBOR-encoded),
157
+ * not raw randomness. Persist them to secure storage; never log.
158
+ */
159
+ static async generateIdentity(): Promise<Uint8Array> {
160
+ const b64 = await NativePing.generateIdentityExport();
161
+ return base64ToBytes(b64);
162
+ }
163
+
164
+ /** This client's user id. Stable across launches for a given identity export. */
165
+ async userId(): Promise<Uint8Array> {
166
+ const hex = await NativePing.getUserId();
167
+ return hexToBytes(hex);
168
+ }
169
+
170
+ /** This client's device id. Stable across launches for a given identity + device label. */
171
+ async deviceId(): Promise<Uint8Array> {
172
+ const hex = await NativePing.getDeviceId();
173
+ return hexToBytes(hex);
174
+ }
175
+
176
+ /**
177
+ * Generate a fresh KeyPackage. Peers retrieve this (via the relay's directory or a
178
+ * join-code endpoint) and use it to add this device to their conversations.
179
+ */
180
+ async freshKeyPackage(): Promise<Uint8Array> {
181
+ const arr = await NativePing.freshKeyPackage();
182
+ return Uint8Array.from(arr);
183
+ }
184
+
185
+ /** Create a new conversation. Returns a `Conversation` handle. */
186
+ async createConversation(opts?: { name?: string | null }): Promise<Conversation> {
187
+ const idArr = await NativePing.createConversation(opts?.name ?? null, Date.now());
188
+ return new Conversation(this, Uint8Array.from(idArr));
189
+ }
190
+
191
+ /** Re-bind to an existing conversation by id (e.g. after a Welcome). */
192
+ getConversation(id: Uint8Array): Conversation {
193
+ return new Conversation(this, id);
194
+ }
195
+
196
+ /**
197
+ * Join a conversation from a Welcome envelope. Used by the auto-subscribe path in
198
+ * `init()` — when the transport's live stream delivers a Welcome and we don't yet
199
+ * track that conversation, we route to this method instead of `processEnvelope`.
200
+ *
201
+ * The relay broadcasts Welcomes to every connected device; only the device whose
202
+ * KeyPackage was used succeeds. Other devices throw an MLS-level error which the
203
+ * caller silently drops (they're "not for us").
204
+ */
205
+ async joinConversation(welcomeJson: Record<string, unknown>): Promise<Conversation> {
206
+ const idArr = await NativePing.joinConversation(welcomeJson, Date.now());
207
+ return new Conversation(this, Uint8Array.from(idArr));
208
+ }
209
+
210
+ /**
211
+ * Process an incoming envelope from the transport. Hosts that subscribe to the
212
+ * transport's live stream forward each frame here; returns the decrypted
213
+ * `IncomingMessage` for application traffic, or `null` for handshake messages
214
+ * (Commit/Welcome/Proposal — those advance MLS state without a user-visible payload).
215
+ */
216
+ async processEnvelope(envelope: Record<string, unknown>): Promise<IncomingMessage | null> {
217
+ const result = await NativePing.processEnvelope(envelope, Date.now());
218
+ return result ? decodeIncomingMessage(result) : null;
219
+ }
220
+
221
+ /**
222
+ * Catch-up sync — pull missed envelopes from the transport's `fetchSince` for every
223
+ * open conversation, apply them, and return the decrypted application messages in
224
+ * apply order.
225
+ */
226
+ async syncConversations(): Promise<IncomingMessage[]> {
227
+ const arr = await NativePing.syncConversations(Date.now());
228
+ return arr.map(decodeIncomingMessage);
229
+ }
230
+
231
+ /** Conversation metadata for every open conversation. */
232
+ async listConversations(): Promise<ConversationMeta[]> {
233
+ const arr = await NativePing.listConversations();
234
+ return arr.map(decodeConversationMeta);
235
+ }
236
+
237
+ /** Device info for every known device. v0.1: just the local device. */
238
+ async listDevices(): Promise<DeviceInfo[]> {
239
+ const arr = await NativePing.listDevices();
240
+ return arr.map(decodeDeviceInfo);
241
+ }
242
+
243
+ /**
244
+ * Subscribe to incoming application messages. Returns an unsubscribe function.
245
+ * Fires for every decrypted application payload — handshake messages
246
+ * (Welcome/Commit/Proposal) instead fire `onConversationUpdated`.
247
+ */
248
+ onMessage(callback: (msg: IncomingMessage) => void): () => void {
249
+ this.messageListeners.add(callback);
250
+ return () => this.messageListeners.delete(callback);
251
+ }
252
+
253
+ /**
254
+ * Subscribe to conversation epoch changes. Fires when a Welcome lands (new conv) or
255
+ * a Commit advances an existing conv to a new epoch.
256
+ */
257
+ onConversationUpdated(callback: (id: Uint8Array, newEpoch: number) => void): () => void {
258
+ this.convUpdateListeners.add(callback);
259
+ return () => this.convUpdateListeners.delete(callback);
260
+ }
261
+
262
+ /**
263
+ * Build a linking ticket for a new device. The new device has just published its
264
+ * KeyPackage (`newDeviceKp`); this builds an HPKE-wrapped Welcome to the user's
265
+ * DeviceGroup plus a catch-up snapshot. The new device consumes via
266
+ * `consumeLinkingTicket`.
267
+ */
268
+ async buildLinkingTicket(
269
+ newDeviceId: Uint8Array,
270
+ newDeviceKp: Uint8Array,
271
+ ): Promise<LinkingTicket> {
272
+ const raw = await NativePing.buildLinkingTicket(
273
+ Array.from(newDeviceId),
274
+ Array.from(newDeviceKp),
275
+ Date.now(),
276
+ );
277
+ return decodeLinkingTicket(raw);
278
+ }
279
+
280
+ /** Consume a linking ticket on the new device. Joins the DeviceGroup. */
281
+ async consumeLinkingTicket(ticket: LinkingTicket): Promise<void> {
282
+ await NativePing.consumeLinkingTicket(encodeLinkingTicket(ticket), Date.now());
283
+ }
284
+
285
+ /** Revoke a device — issues Remove proposals across the user's groups. */
286
+ async revokeDevice(deviceId: Uint8Array): Promise<void> {
287
+ await NativePing.revokeDevice(Array.from(deviceId), Date.now());
288
+ }
289
+
290
+ /** Detach storage + transport + observer listeners. Call when you're done. */
291
+ close(): void {
292
+ this.transportSubscription?.unsubscribe();
293
+ this.transportSubscription = undefined;
294
+ for (const sub of this.observerSubscriptions) sub.remove();
295
+ this.observerSubscriptions = [];
296
+ this.messageListeners.clear();
297
+ this.convUpdateListeners.clear();
298
+ this.disconnectStorage();
299
+ this.disconnectTransport();
300
+ }
301
+ }
302
+
303
+ /** Decrypted incoming message — application-layer payload + metadata. */
304
+ export interface IncomingMessage {
305
+ conversationId: Uint8Array;
306
+ senderDevice: Uint8Array;
307
+ epoch: number;
308
+ hlc: { wallMs: number; logical: number };
309
+ plaintext: Uint8Array;
310
+ contentHash: Uint8Array;
311
+ }
312
+
313
+ /** Conversation metadata returned by `listConversations`. */
314
+ export interface ConversationMeta {
315
+ id: Uint8Array;
316
+ name: string | null;
317
+ epoch: number;
318
+ memberCount: number;
319
+ isDeviceGroup: boolean;
320
+ createdAtMs: number;
321
+ }
322
+
323
+ /** Device metadata returned by `listDevices`. */
324
+ export interface DeviceInfo {
325
+ deviceId: Uint8Array;
326
+ userId: Uint8Array;
327
+ label: string;
328
+ createdAtMs: number;
329
+ lastSeenMs: number;
330
+ revoked: boolean;
331
+ }
332
+
333
+ /** Linking ticket — bundle a new device's join package for an existing device to sign. */
334
+ export interface LinkingTicket {
335
+ v: number;
336
+ userId: Uint8Array;
337
+ userPubkey: Uint8Array;
338
+ newDeviceId: Uint8Array;
339
+ deviceBindingSig: Uint8Array;
340
+ deviceGroupWelcome: Uint8Array;
341
+ catchupSnapshot: Uint8Array;
342
+ }
343
+
344
+ function decodeIncomingMessage(d: Record<string, unknown>): IncomingMessage {
345
+ const hlc = d.hlc as { wall_ms?: number; logical?: number } | undefined;
346
+ return {
347
+ conversationId: bytesFrom(d.conversation_id),
348
+ senderDevice: bytesFrom(d.sender_device),
349
+ epoch: Number(d.epoch ?? 0),
350
+ hlc: { wallMs: hlc?.wall_ms ?? 0, logical: hlc?.logical ?? 0 },
351
+ plaintext: bytesFrom(d.plaintext),
352
+ contentHash: bytesFrom(d.content_hash),
353
+ };
354
+ }
355
+
356
+ function decodeConversationMeta(d: Record<string, unknown>): ConversationMeta {
357
+ return {
358
+ id: bytesFrom(d.id),
359
+ name: (d.name as string | null) ?? null,
360
+ epoch: Number(d.epoch ?? 0),
361
+ memberCount: Number(d.member_count ?? 0),
362
+ isDeviceGroup: Boolean(d.is_device_group),
363
+ createdAtMs: Number(d.created_at_ms ?? 0),
364
+ };
365
+ }
366
+
367
+ function decodeDeviceInfo(d: Record<string, unknown>): DeviceInfo {
368
+ return {
369
+ deviceId: bytesFrom(d.device_id),
370
+ userId: bytesFrom(d.user_id),
371
+ label: String(d.label ?? ""),
372
+ createdAtMs: Number(d.created_at_ms ?? 0),
373
+ lastSeenMs: Number(d.last_seen_ms ?? 0),
374
+ revoked: Boolean(d.revoked),
375
+ };
376
+ }
377
+
378
+ function decodeLinkingTicket(d: Record<string, unknown>): LinkingTicket {
379
+ return {
380
+ v: Number(d.v ?? 1),
381
+ userId: bytesFrom(d.user_id),
382
+ userPubkey: bytesFrom(d.user_pubkey),
383
+ newDeviceId: bytesFrom(d.new_device_id),
384
+ deviceBindingSig: bytesFrom(d.device_binding_sig),
385
+ deviceGroupWelcome: bytesFrom(d.device_group_welcome),
386
+ catchupSnapshot: bytesFrom(d.catchup_snapshot),
387
+ };
388
+ }
389
+
390
+ function encodeLinkingTicket(t: LinkingTicket): Record<string, unknown> {
391
+ return {
392
+ v: t.v,
393
+ user_id: Array.from(t.userId),
394
+ user_pubkey: Array.from(t.userPubkey),
395
+ new_device_id: Array.from(t.newDeviceId),
396
+ device_binding_sig: Array.from(t.deviceBindingSig),
397
+ device_group_welcome: Array.from(t.deviceGroupWelcome),
398
+ catchup_snapshot: Array.from(t.catchupSnapshot),
399
+ };
400
+ }
401
+
402
+ function bytesFrom(v: unknown): Uint8Array {
403
+ if (Array.isArray(v)) return Uint8Array.from(v as number[]);
404
+ return new Uint8Array(0);
405
+ }
406
+
407
+ /**
408
+ * Scoped handle for a single conversation. Mirrors `ping-openmls-sdk`'s `Conversation`
409
+ * class so the desktop demo can swap implementations with a single import change.
410
+ */
411
+ export class Conversation {
412
+ constructor(
413
+ private readonly client: MessagingClient,
414
+ public readonly id: Uint8Array,
415
+ ) {}
416
+
417
+ /** Send an application message. Returns the wire envelope; the SDK has already pushed it through the transport. */
418
+ async send(plaintext: Uint8Array): Promise<Record<string, unknown>> {
419
+ return await NativePing.sendMessage(
420
+ Array.from(this.id),
421
+ Array.from(plaintext),
422
+ Date.now(),
423
+ );
424
+ }
425
+
426
+ /** Add members by their KeyPackage bytes (typically obtained via the relay's directory). */
427
+ async addMembers(keyPackages: Uint8Array[]): Promise<void> {
428
+ await NativePing.addMembers(
429
+ Array.from(this.id),
430
+ keyPackages.map((kp) => Array.from(kp)),
431
+ Date.now(),
432
+ );
433
+ }
434
+
435
+ /** Remove members by leaf index in the conversation's MLS ratchet tree. */
436
+ async removeMembers(leafIndexes: number[]): Promise<void> {
437
+ await NativePing.removeMembers(Array.from(this.id), leafIndexes, Date.now());
438
+ }
439
+ }
440
+
441
+ // ---------- helpers ----------
442
+
443
+ // Base64 alphabet — standard, with `+/` and `=` padding.
444
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
445
+
446
+ function bytesToBase64(b: Uint8Array): string {
447
+ // Hermes doesn't ship `btoa`/`Buffer`; this is a small pure-JS encoder. Safe for any
448
+ // size; we only encode ~32B identity exports here so performance is not a concern.
449
+ let out = "";
450
+ let i = 0;
451
+ for (; i + 3 <= b.length; i += 3) {
452
+ const x = (b[i] << 16) | (b[i + 1] << 8) | b[i + 2];
453
+ out += B64[(x >> 18) & 63] + B64[(x >> 12) & 63] + B64[(x >> 6) & 63] + B64[x & 63];
454
+ }
455
+ const rem = b.length - i;
456
+ if (rem === 1) {
457
+ const x = b[i] << 16;
458
+ out += B64[(x >> 18) & 63] + B64[(x >> 12) & 63] + "==";
459
+ } else if (rem === 2) {
460
+ const x = (b[i] << 16) | (b[i + 1] << 8);
461
+ out += B64[(x >> 18) & 63] + B64[(x >> 12) & 63] + B64[(x >> 6) & 63] + "=";
462
+ }
463
+ return out;
464
+ }
465
+
466
+ function hexToBytes(s: string): Uint8Array {
467
+ if (s.length % 2 !== 0) throw new Error("odd-length hex");
468
+ const out = new Uint8Array(s.length / 2);
469
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
470
+ return out;
471
+ }
472
+
473
+ /** Decode base64 to bytes. Mirror of bytesToBase64; same Hermes-compatible reasoning. */
474
+ function base64ToBytes(s: string): Uint8Array {
475
+ const clean = s.replace(/[^A-Za-z0-9+/]/g, "");
476
+ const padded = clean.length % 4 === 2 ? clean + "==" : clean.length % 4 === 3 ? clean + "=" : clean;
477
+ const out: number[] = [];
478
+ for (let i = 0; i < padded.length; i += 4) {
479
+ const a = B64.indexOf(padded[i]);
480
+ const b = B64.indexOf(padded[i + 1]);
481
+ const c = padded[i + 2] === "=" ? -1 : B64.indexOf(padded[i + 2]);
482
+ const d = padded[i + 3] === "=" ? -1 : B64.indexOf(padded[i + 3]);
483
+ out.push((a << 2) | (b >> 4));
484
+ if (c >= 0) out.push(((b & 15) << 4) | (c >> 2));
485
+ if (d >= 0) out.push(((c & 3) << 6) | d);
486
+ }
487
+ return Uint8Array.from(out);
488
+ }