@textrp/briij-js-sdk 42.0.0 → 43.0.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.
@@ -39,6 +39,8 @@ export interface ILoginFlowsResponse {
39
39
  }
40
40
 
41
41
  export const XRPL_WALLET_LOGIN_TYPE = "io.briij.login.xrpl";
42
+ export const WALLET_E2EE_RECOVERY_ACCOUNT_DATA_TYPE = "org.textrp.wallet.e2ee_recovery.v1";
43
+ export const WALLET_IDENTITY_ACCOUNT_DATA_TYPE = "org.textrp.wallet.identity";
42
44
 
43
45
  export type LoginFlow = ISSOFlow | IPasswordFlow | IXrplWalletLoginFlow | ILoginFlow;
44
46
 
@@ -206,6 +208,7 @@ export interface LoginRequest {
206
208
  export type ILoginParams = LoginRequest;
207
209
 
208
210
  export interface XrplWalletChallengePayload {
211
+ session?: string;
209
212
  nonce: string;
210
213
  timestamp: number | string;
211
214
  message: string;
@@ -213,6 +216,63 @@ export interface XrplWalletChallengePayload {
213
216
  algorithm?: "secp256k1" | "ed25519" | string;
214
217
  }
215
218
 
219
+ export interface XrplAuthChallengeRequest extends Omit<LoginRequest, "type"> {
220
+ type: typeof XRPL_WALLET_LOGIN_TYPE;
221
+ address: string;
222
+ network: string;
223
+ preferred_localpart?: string;
224
+ username?: string;
225
+ display_name?: string;
226
+ }
227
+
228
+ export interface XrplAuthChallengeResponse {
229
+ session: string;
230
+ challenge: string;
231
+ }
232
+
233
+ export interface XrplAuthCompleteRequest extends Omit<LoginRequest, "type"> {
234
+ type: typeof XRPL_WALLET_LOGIN_TYPE;
235
+ session: string;
236
+ address: string;
237
+ signature: string;
238
+ public_key?: string;
239
+ network?: string;
240
+ wallet_e2ee_recovery?: WalletE2eeRecoveryEnvelope;
241
+ }
242
+
243
+ export interface WalletRecoveryWrap {
244
+ alg: string;
245
+ kdf: string;
246
+ salt: string;
247
+ nonce: string;
248
+ ciphertext: string;
249
+ aad?: string;
250
+ params?: Record<string, unknown>;
251
+ }
252
+
253
+ export interface WalletRecoveryPasswordWrap extends WalletRecoveryWrap {
254
+ kdf: string;
255
+ params?: Record<string, unknown>;
256
+ }
257
+
258
+ export interface WalletE2eeRecoveryEnvelope {
259
+ envelope_version: number;
260
+ chain_id: string;
261
+ account_id: string;
262
+ created_at_ms: number;
263
+ key_id: string;
264
+ wallet_wrap: WalletRecoveryWrap;
265
+ password_wrap: WalletRecoveryPasswordWrap;
266
+ }
267
+
268
+ export interface WalletIdentityAccountData {
269
+ chain_id: string;
270
+ account_id: string;
271
+ public_key: string | null;
272
+ network?: string | null;
273
+ key_type?: string | null;
274
+ }
275
+
216
276
  export interface XrplWalletLoginRequest extends Omit<LoginRequest, "type"> {
217
277
  type: typeof XRPL_WALLET_LOGIN_TYPE;
218
278
  wallet_address: string;
@@ -455,6 +455,38 @@ export interface AccountDataEvents extends SecretStorageAccountDataEvents {
455
455
  [POLICIES_ACCOUNT_EVENT_TYPE.altName]: { [key: string]: any };
456
456
 
457
457
  [EventType.InvitePermissionConfig]: { default_action?: string };
458
+ "org.textrp.wallet.e2ee_recovery.v1": {
459
+ envelope_version: number;
460
+ chain_id: string;
461
+ account_id: string;
462
+ created_at_ms: number;
463
+ key_id: string;
464
+ wallet_wrap: {
465
+ alg: string;
466
+ kdf: string;
467
+ salt: string;
468
+ nonce: string;
469
+ ciphertext: string;
470
+ aad?: string;
471
+ params?: Record<string, unknown>;
472
+ };
473
+ password_wrap: {
474
+ alg: string;
475
+ kdf: string;
476
+ salt: string;
477
+ nonce: string;
478
+ ciphertext: string;
479
+ aad?: string;
480
+ params?: Record<string, unknown>;
481
+ };
482
+ };
483
+ "org.textrp.wallet.identity": {
484
+ chain_id: string;
485
+ account_id: string;
486
+ public_key: string | null;
487
+ network?: string | null;
488
+ key_type?: string | null;
489
+ };
458
490
  }
459
491
 
460
492
  /**
package/src/briij.ts CHANGED
@@ -52,6 +52,7 @@ export * from "./interactive-auth.ts";
52
52
  export * from "./xrpl/identity.ts";
53
53
  export * from "./xrpl/trust.ts";
54
54
  export * from "./xrpl/verification.ts";
55
+ export * from "./wallet-recovery.ts";
55
56
  export * from "./version-support.ts";
56
57
  export * from "./service-types.ts";
57
58
  export * from "./store/memory.ts";
package/src/client.ts CHANGED
@@ -198,7 +198,14 @@ import {
198
198
  type LoginResponse,
199
199
  type LoginTokenPostResponse,
200
200
  type SSOAction,
201
+ WALLET_E2EE_RECOVERY_ACCOUNT_DATA_TYPE,
202
+ WALLET_IDENTITY_ACCOUNT_DATA_TYPE,
201
203
  XRPL_WALLET_LOGIN_TYPE,
204
+ type WalletE2eeRecoveryEnvelope,
205
+ type WalletIdentityAccountData,
206
+ type XrplAuthChallengeRequest,
207
+ type XrplAuthChallengeResponse,
208
+ type XrplAuthCompleteRequest,
202
209
  type XrplWalletChallengePayload,
203
210
  } from "./@types/auth.ts";
204
211
  import { TypedEventEmitter } from "./models/typed-event-emitter.ts";
@@ -6724,31 +6731,117 @@ export class BriijClient extends TypedEventEmitter<EmittedEvents, ClientEventHan
6724
6731
  }
6725
6732
 
6726
6733
  /**
6727
- * @param user - Matrix user ID localpart or fully qualified MXID.
6728
- * @param walletAddress - XRPL/Xahau classic wallet address.
6729
- * @param signature - Hex signature for the challenge message.
6730
- * @param challenge - Signed challenge payload.
6731
- * @param network - Ledger network identifier. Defaults to `xrpl`.
6734
+ * Request an XRPL login challenge from the homeserver.
6735
+ *
6736
+ * The briij homeserver responds with HTTP 401 as the successful challenge
6737
+ * response, so this helper normalizes that into a resolved promise.
6738
+ */
6739
+ public async getXrplAuthChallenge(
6740
+ request: Omit<XrplAuthChallengeRequest, "type">,
6741
+ ): Promise<XrplAuthChallengeResponse> {
6742
+ try {
6743
+ const response = (await this.loginRequest({
6744
+ ...request,
6745
+ type: XRPL_WALLET_LOGIN_TYPE,
6746
+ })) as unknown as XrplAuthChallengeResponse;
6747
+ if (typeof response.session === "string" && typeof response.challenge === "string") {
6748
+ return response;
6749
+ }
6750
+ } catch (error) {
6751
+ if (error instanceof BriijError && error.httpStatus === 401) {
6752
+ const session = error.data?.session;
6753
+ const challenge = error.data?.challenge;
6754
+ if (typeof session === "string" && typeof challenge === "string") {
6755
+ return { session, challenge };
6756
+ }
6757
+ }
6758
+ throw error;
6759
+ }
6760
+
6761
+ throw new Error("XRPL challenge response was not returned by homeserver");
6762
+ }
6763
+
6764
+ /**
6765
+ * Complete XRPL login using the previously issued challenge session.
6766
+ *
6732
6767
  * @returns Promise which resolves to a LoginResponse object.
6733
6768
  * @returns Rejects: with an error response.
6734
6769
  */
6735
- public loginWithXrplWallet(
6770
+ public completeXrplAuth(request: Omit<XrplAuthCompleteRequest, "type">): Promise<LoginResponse> {
6771
+ return this.loginRequest({
6772
+ ...request,
6773
+ type: XRPL_WALLET_LOGIN_TYPE,
6774
+ });
6775
+ }
6776
+
6777
+ /**
6778
+ * Store a chain-agnostic wallet recovery envelope in account data.
6779
+ *
6780
+ * This uses a stable account-data type and does not affect core E2EE state.
6781
+ *
6782
+ * @param envelope - Wallet recovery envelope payload.
6783
+ */
6784
+ public setWalletRecoveryEnvelope(envelope: WalletE2eeRecoveryEnvelope): Promise<EmptyObject> {
6785
+ return this.setAccountDataRaw(WALLET_E2EE_RECOVERY_ACCOUNT_DATA_TYPE, envelope);
6786
+ }
6787
+
6788
+ /**
6789
+ * Fetch wallet recovery envelope directly from the homeserver.
6790
+ *
6791
+ * @returns The stored envelope, or null if not found.
6792
+ */
6793
+ public getWalletRecoveryEnvelopeFromServer(): Promise<WalletE2eeRecoveryEnvelope | null> {
6794
+ return this.getAccountDataFromServer(WALLET_E2EE_RECOVERY_ACCOUNT_DATA_TYPE);
6795
+ }
6796
+
6797
+ /**
6798
+ * Fetch canonical wallet identity metadata from the homeserver.
6799
+ *
6800
+ * @returns Wallet identity metadata, or null if not found.
6801
+ */
6802
+ public getWalletIdentityFromServer(): Promise<WalletIdentityAccountData | null> {
6803
+ return this.getAccountDataFromServer(WALLET_IDENTITY_ACCOUNT_DATA_TYPE);
6804
+ }
6805
+
6806
+ /**
6807
+ * @deprecated Use `getXrplAuthChallenge` + `completeXrplAuth` for explicit
6808
+ * two-step XRPL login flow. This wrapper now expects `challenge` to include
6809
+ * the `session` and optional `public_key`.
6810
+ */
6811
+ public async loginWithXrplWallet(
6736
6812
  user: string,
6737
6813
  walletAddress: string,
6738
6814
  signature: string,
6739
6815
  challenge: string | XrplWalletChallengePayload,
6740
6816
  network = "xrpl",
6741
6817
  ): Promise<LoginResponse> {
6742
- return this.login(XRPL_WALLET_LOGIN_TYPE, {
6743
- user: user,
6818
+ let parsedChallenge: Partial<XrplWalletChallengePayload> | null = null;
6819
+ if (typeof challenge === "string") {
6820
+ try {
6821
+ parsedChallenge = JSON.parse(challenge) as Partial<XrplWalletChallengePayload>;
6822
+ } catch {
6823
+ throw new Error("XRPL challenge must be JSON with a session field");
6824
+ }
6825
+ } else {
6826
+ parsedChallenge = challenge;
6827
+ }
6828
+
6829
+ const session = parsedChallenge?.session;
6830
+ if (!session) {
6831
+ throw new Error("XRPL challenge payload is missing session; call getXrplAuthChallenge first");
6832
+ }
6833
+
6834
+ return this.completeXrplAuth({
6835
+ user,
6744
6836
  identifier: {
6745
6837
  type: "m.id.user",
6746
- user: user,
6838
+ user,
6747
6839
  },
6748
- wallet_address: walletAddress,
6749
- signature: signature,
6750
- challenge: typeof challenge === "string" ? challenge : JSON.stringify(challenge),
6751
- network: network,
6840
+ session,
6841
+ address: walletAddress,
6842
+ signature,
6843
+ public_key: parsedChallenge?.public_key,
6844
+ network,
6752
6845
  });
6753
6846
  }
6754
6847
 
@@ -0,0 +1,252 @@
1
+ /*
2
+ Copyright 2026 TextRP
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { type WalletE2eeRecoveryEnvelope, type WalletRecoveryWrap } from "./@types/auth.ts";
18
+
19
+ const ENVELOPE_VERSION = 1;
20
+ const KEY_BYTES = 32;
21
+ const NONCE_BYTES = 12;
22
+ const SALT_BYTES = 16;
23
+ const PASSWORD_KDF_ITERATIONS = 210_000;
24
+
25
+ export interface CreateDualWrapEnvelopeParams {
26
+ chainId: string;
27
+ accountId: string;
28
+ backupPassword: string;
29
+ walletWrapKey: Uint8Array;
30
+ createdAtMs?: number;
31
+ keyId?: string;
32
+ recoveryKey?: Uint8Array;
33
+ }
34
+
35
+ export interface UnwrapWithWalletParams {
36
+ envelope: WalletE2eeRecoveryEnvelope;
37
+ walletWrapKey: Uint8Array;
38
+ }
39
+
40
+ export interface UnwrapWithPasswordParams {
41
+ envelope: WalletE2eeRecoveryEnvelope;
42
+ backupPassword: string;
43
+ }
44
+
45
+ function assert(condition: boolean, message: string): asserts condition {
46
+ if (!condition) throw new Error(message);
47
+ }
48
+
49
+ function toBase64(bytes: Uint8Array): string {
50
+ if (typeof Buffer !== "undefined") return Buffer.from(bytes).toString("base64");
51
+ let binary = "";
52
+ bytes.forEach((b) => (binary += String.fromCodePoint(b)));
53
+ return btoa(binary);
54
+ }
55
+
56
+ function fromBase64(value: string): Uint8Array {
57
+ if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(value, "base64"));
58
+ const binary = atob(value);
59
+ const out = new Uint8Array(binary.length);
60
+ for (let i = 0; i < binary.length; i++) out[i] = binary.codePointAt(i)!;
61
+ return out;
62
+ }
63
+
64
+ function toUtf8(value: string): Uint8Array {
65
+ return new TextEncoder().encode(value);
66
+ }
67
+
68
+ function toArrayBuffer(value: Uint8Array): ArrayBuffer {
69
+ return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer;
70
+ }
71
+
72
+ function randomBytes(len: number): Uint8Array {
73
+ const out = new Uint8Array(len);
74
+ globalThis.crypto.getRandomValues(out);
75
+ return out;
76
+ }
77
+
78
+ async function importAesGcmKey(key: Uint8Array): Promise<CryptoKey> {
79
+ return await globalThis.crypto.subtle.importKey(
80
+ "raw",
81
+ toArrayBuffer(key),
82
+ { name: "AES-GCM" },
83
+ false,
84
+ ["encrypt", "decrypt"],
85
+ );
86
+ }
87
+
88
+ async function encryptWrap(
89
+ plaintext: Uint8Array,
90
+ {
91
+ key,
92
+ aad,
93
+ kdf,
94
+ params,
95
+ }: {
96
+ key: Uint8Array;
97
+ aad: Uint8Array;
98
+ kdf: string;
99
+ params?: Record<string, unknown>;
100
+ },
101
+ ): Promise<WalletRecoveryWrap> {
102
+ assert(key.length >= KEY_BYTES, "wallet recovery key must be at least 32 bytes");
103
+ const salt = randomBytes(SALT_BYTES);
104
+ const nonce = randomBytes(NONCE_BYTES);
105
+ const cryptoKey = await importAesGcmKey(key.slice(0, KEY_BYTES));
106
+ const ciphertext = await globalThis.crypto.subtle.encrypt(
107
+ {
108
+ name: "AES-GCM",
109
+ iv: toArrayBuffer(nonce),
110
+ additionalData: toArrayBuffer(aad),
111
+ tagLength: 128,
112
+ },
113
+ cryptoKey,
114
+ toArrayBuffer(plaintext),
115
+ );
116
+ return {
117
+ alg: "aes-256-gcm",
118
+ kdf,
119
+ salt: toBase64(salt),
120
+ nonce: toBase64(nonce),
121
+ ciphertext: toBase64(new Uint8Array(ciphertext)),
122
+ aad: toBase64(aad),
123
+ params,
124
+ };
125
+ }
126
+
127
+ async function decryptWrap(wrap: WalletRecoveryWrap, key: Uint8Array): Promise<Uint8Array> {
128
+ const nonce = fromBase64(wrap.nonce);
129
+ const aad = wrap.aad ? fromBase64(wrap.aad) : new Uint8Array();
130
+ const ciphertext = fromBase64(wrap.ciphertext);
131
+ const cryptoKey = await importAesGcmKey(key.slice(0, KEY_BYTES));
132
+ const plaintext = await globalThis.crypto.subtle.decrypt(
133
+ {
134
+ name: "AES-GCM",
135
+ iv: toArrayBuffer(nonce),
136
+ additionalData: toArrayBuffer(aad),
137
+ tagLength: 128,
138
+ },
139
+ cryptoKey,
140
+ toArrayBuffer(ciphertext),
141
+ );
142
+ return new Uint8Array(plaintext);
143
+ }
144
+
145
+ async function derivePasswordWrapKey(password: string, salt: Uint8Array): Promise<Uint8Array> {
146
+ const keyMaterial = await globalThis.crypto.subtle.importKey("raw", toArrayBuffer(toUtf8(password)), "PBKDF2", false, [
147
+ "deriveBits",
148
+ ]);
149
+ const bits = await globalThis.crypto.subtle.deriveBits(
150
+ {
151
+ name: "PBKDF2",
152
+ hash: "SHA-256",
153
+ salt: toArrayBuffer(salt),
154
+ iterations: PASSWORD_KDF_ITERATIONS,
155
+ },
156
+ keyMaterial,
157
+ KEY_BYTES * 8,
158
+ );
159
+ return new Uint8Array(bits);
160
+ }
161
+
162
+ export async function deriveWalletWrapKeyFromSecret(
163
+ walletSecret: string,
164
+ chainId: string,
165
+ accountId: string,
166
+ homeserver: string,
167
+ ): Promise<Uint8Array> {
168
+ const context = `briij-wallet-auth-wrap-v1:${homeserver}:${chainId}:${accountId}`;
169
+ const digest = await globalThis.crypto.subtle.digest(
170
+ "SHA-256",
171
+ toArrayBuffer(toUtf8(`${context}:${walletSecret}`)),
172
+ );
173
+ return new Uint8Array(digest);
174
+ }
175
+
176
+ export function validateRecoveryEnvelopeShape(
177
+ value: unknown,
178
+ ): asserts value is WalletE2eeRecoveryEnvelope {
179
+ assert(!!value && typeof value === "object", "recovery envelope must be an object");
180
+ const envelope = value as WalletE2eeRecoveryEnvelope;
181
+ assert(typeof envelope.envelope_version === "number", "envelope_version must be a number");
182
+ assert(typeof envelope.chain_id === "string" && envelope.chain_id.length > 0, "chain_id is required");
183
+ assert(typeof envelope.account_id === "string" && envelope.account_id.length > 0, "account_id is required");
184
+ assert(typeof envelope.created_at_ms === "number", "created_at_ms must be a number");
185
+ assert(typeof envelope.key_id === "string" && envelope.key_id.length > 0, "key_id is required");
186
+ assert(!!envelope.wallet_wrap && typeof envelope.wallet_wrap === "object", "wallet_wrap is required");
187
+ assert(!!envelope.password_wrap && typeof envelope.password_wrap === "object", "password_wrap is required");
188
+ for (const wrap of [envelope.wallet_wrap, envelope.password_wrap]) {
189
+ assert(typeof wrap.alg === "string" && wrap.alg.length > 0, "wrap.alg is required");
190
+ assert(typeof wrap.kdf === "string" && wrap.kdf.length > 0, "wrap.kdf is required");
191
+ assert(typeof wrap.salt === "string" && wrap.salt.length > 0, "wrap.salt is required");
192
+ assert(typeof wrap.nonce === "string" && wrap.nonce.length > 0, "wrap.nonce is required");
193
+ assert(typeof wrap.ciphertext === "string" && wrap.ciphertext.length > 0, "wrap.ciphertext is required");
194
+ }
195
+ }
196
+
197
+ export async function createDualWrapEnvelope(params: CreateDualWrapEnvelopeParams): Promise<WalletE2eeRecoveryEnvelope> {
198
+ assert(params.chainId.length > 0, "chainId is required");
199
+ assert(params.accountId.length > 0, "accountId is required");
200
+ assert(params.backupPassword.length > 0, "backupPassword is required");
201
+ assert(params.walletWrapKey.length >= KEY_BYTES, "walletWrapKey must be at least 32 bytes");
202
+
203
+ const createdAtMs = params.createdAtMs ?? Date.now();
204
+ const keyId = params.keyId ?? `k-${toBase64(randomBytes(8)).replace(/=+$/g, "")}`;
205
+ const recoveryKey = params.recoveryKey ?? randomBytes(KEY_BYTES);
206
+ assert(recoveryKey.length === KEY_BYTES, "recoveryKey must be 32 bytes");
207
+
208
+ const aad = toUtf8(`briij-recovery-envelope-v1:${params.chainId}:${params.accountId}:${keyId}:${createdAtMs}`);
209
+ const walletWrap = await encryptWrap(recoveryKey, {
210
+ key: params.walletWrapKey,
211
+ aad,
212
+ kdf: "sha256-context-v1",
213
+ });
214
+
215
+ const passwordSalt = randomBytes(SALT_BYTES);
216
+ const passwordWrapKey = await derivePasswordWrapKey(params.backupPassword, passwordSalt);
217
+ const passwordWrap = await encryptWrap(recoveryKey, {
218
+ key: passwordWrapKey,
219
+ aad,
220
+ kdf: "pbkdf2-sha256-v1",
221
+ params: {
222
+ iterations: PASSWORD_KDF_ITERATIONS,
223
+ hash: "SHA-256",
224
+ },
225
+ });
226
+ passwordWrap.salt = toBase64(passwordSalt);
227
+
228
+ return {
229
+ envelope_version: ENVELOPE_VERSION,
230
+ chain_id: params.chainId,
231
+ account_id: params.accountId,
232
+ created_at_ms: createdAtMs,
233
+ key_id: keyId,
234
+ wallet_wrap: walletWrap,
235
+ password_wrap: passwordWrap,
236
+ };
237
+ }
238
+
239
+ export async function unwrapWithWallet({ envelope, walletWrapKey }: UnwrapWithWalletParams): Promise<Uint8Array> {
240
+ validateRecoveryEnvelopeShape(envelope);
241
+ return await decryptWrap(envelope.wallet_wrap, walletWrapKey);
242
+ }
243
+
244
+ export async function unwrapWithPassword({
245
+ envelope,
246
+ backupPassword,
247
+ }: UnwrapWithPasswordParams): Promise<Uint8Array> {
248
+ validateRecoveryEnvelopeShape(envelope);
249
+ const passwordSalt = fromBase64(envelope.password_wrap.salt);
250
+ const passwordWrapKey = await derivePasswordWrapKey(backupPassword, passwordSalt);
251
+ return await decryptWrap(envelope.password_wrap, passwordWrapKey);
252
+ }