@idos-network/kwil-infra 3.0.0 → 3.0.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/actions/index.d.mts +1 -1
  3. package/dist/facesign/index.d.mts +2 -2
  4. package/dist/facesign/index.mjs +1 -1
  5. package/dist/facesign-signer-CTPPUFxT.mjs +2 -0
  6. package/dist/facesign-signer-CTPPUFxT.mjs.map +1 -0
  7. package/dist/{facesign-signer-5I8juA7p.d.mts → facesign-signer-Dqh899om.d.mts} +3 -2
  8. package/dist/facesign-signer-Dqh899om.d.mts.map +1 -0
  9. package/dist/{index-Bqz6trCk.d.mts → index-Bk8YCFlx.d.mts} +2 -2
  10. package/dist/{index-Bqz6trCk.d.mts.map → index-Bk8YCFlx.d.mts.map} +1 -1
  11. package/dist/index.d.mts +1 -1
  12. package/dist/index.mjs +1 -1
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/nep413-D_D-6Yf6.mjs +2 -0
  15. package/dist/nep413-D_D-6Yf6.mjs.map +1 -0
  16. package/dist/signature-verification/index.d.mts +1 -1
  17. package/dist/signature-verification/index.mjs +1 -1
  18. package/dist/signature-verification/index.mjs.map +1 -1
  19. package/dist/xrp/utils.d.mts +41 -41
  20. package/dist/xrp/utils.d.mts.map +1 -1
  21. package/package.json +3 -3
  22. package/src/facesign/facesign-signer.test.ts +58 -1
  23. package/src/facesign/facesign-signer.ts +8 -0
  24. package/src/facesign/index.ts +1 -1
  25. package/src/near/create-near-wallet-kwil-signer.test.ts +218 -0
  26. package/src/near/create-near-wallet-kwil-signer.ts +121 -28
  27. package/src/near/nep413.ts +47 -37
  28. package/src/near/signature-verification.test.ts +35 -0
  29. package/src/near/signature-verification.ts +15 -36
  30. package/dist/facesign-signer-5I8juA7p.d.mts.map +0 -1
  31. package/dist/facesign-signer-z5T312uQ.mjs +0 -2
  32. package/dist/facesign-signer-z5T312uQ.mjs.map +0 -1
@@ -0,0 +1,218 @@
1
+ import type { Wallet as NearWallet } from "@near-wallet-selector/core";
2
+
3
+ import {
4
+ base64Encode,
5
+ borshSerialize,
6
+ bs58Encode,
7
+ bytesConcat,
8
+ sha256Hash,
9
+ } from "@idos-network/utils/codecs";
10
+ import { MemoryStore } from "@idos-network/utils/store";
11
+ import nacl from "tweetnacl";
12
+ import { afterEach, describe, expect, it, vi } from "vitest";
13
+
14
+ import type { KwilActionClient } from "../create-kwil-client";
15
+
16
+ import { createNearWalletKwilSigner } from "./create-near-wallet-kwil-signer";
17
+
18
+ const fullAccess = vi.hoisted(() => ({
19
+ keys: [] as string[],
20
+ limitedKeys: [] as string[],
21
+ queries: 0,
22
+ lastRequest: undefined as { request_type?: string; account_id?: string } | undefined,
23
+ }));
24
+
25
+ vi.mock("near-api-js", () => ({
26
+ connect: async () => ({
27
+ connection: {
28
+ provider: {
29
+ query: async (request: { request_type?: string; account_id?: string }) => {
30
+ fullAccess.queries += 1;
31
+ fullAccess.lastRequest = request;
32
+ return {
33
+ keys: [
34
+ ...fullAccess.keys.map((public_key) => ({
35
+ public_key,
36
+ access_key: { permission: "FullAccess" },
37
+ })),
38
+ ...fullAccess.limitedKeys.map((public_key) => ({
39
+ public_key,
40
+ access_key: {
41
+ permission: {
42
+ FunctionCall: {
43
+ allowance: "0",
44
+ receiver_id: "example.near",
45
+ method_names: [],
46
+ },
47
+ },
48
+ },
49
+ })),
50
+ ],
51
+ };
52
+ },
53
+ },
54
+ },
55
+ }),
56
+ }));
57
+
58
+ const ACCOUNT = "victim.testnet";
59
+ const RECIPIENT = "idos.network";
60
+ const CALLBACK = "https://app.example/callback";
61
+ const MESSAGE = "idOS authentication";
62
+ const NEP413_TAG = 2147484061;
63
+
64
+ const nep413PayloadSchema = {
65
+ struct: {
66
+ message: "string",
67
+ nonce: { array: { type: "u8", len: 32 } },
68
+ recipient: "string",
69
+ callbackUrl: { option: "string" },
70
+ },
71
+ } as const;
72
+
73
+ const keyPair = nacl.sign.keyPair.fromSeed(new Uint8Array(32).fill(4));
74
+ const fragmentKey = `ed25519:${bs58Encode(keyPair.publicKey)}`;
75
+ const realKey = `ed25519:${bs58Encode(new Uint8Array(32).fill(1))}`;
76
+
77
+ const kwilClient = {
78
+ client: { auth: { logoutKGW: async () => {} } },
79
+ } as unknown as KwilActionClient;
80
+
81
+ function signNep413(nonce: Uint8Array, message = MESSAGE): string {
82
+ const hash = sha256Hash(
83
+ bytesConcat(
84
+ borshSerialize("u32", NEP413_TAG),
85
+ borshSerialize(nep413PayloadSchema, {
86
+ message,
87
+ nonce,
88
+ recipient: RECIPIENT,
89
+ callbackUrl: CALLBACK,
90
+ }),
91
+ ),
92
+ );
93
+ return base64Encode(nacl.sign.detached(hash, keyPair.secretKey));
94
+ }
95
+
96
+ function signedRequest(): { nonce: Uint8Array; signature: string } {
97
+ for (let i = 0; i < 64; i++) {
98
+ const nonce = new Uint8Array(32).fill(i);
99
+ const signature = signNep413(nonce);
100
+ if (signature.includes("+")) return { nonce, signature };
101
+ }
102
+ throw new Error("expected a base64 signature containing '+'");
103
+ }
104
+
105
+ async function signerPublicKey(fragment: {
106
+ accountId: string;
107
+ signature: string;
108
+ publicKey: string;
109
+ nonce: Uint8Array;
110
+ message?: string;
111
+ }): Promise<string> {
112
+ // Literal "+", not percent-encoded, so URLSearchParams would turn it into a space.
113
+ window.location.hash = `accountId=${fragment.accountId}&signature=${fragment.signature}&publicKey=${fragment.publicKey}`;
114
+ const store = new MemoryStore();
115
+ await store.set("signer-address", ACCOUNT);
116
+ await store.set("signer-public-key", realKey);
117
+ await store.set("sign-last-message", fragment.message ?? MESSAGE);
118
+ await store.set("sign-last-nonce", Array.from(fragment.nonce));
119
+ await store.set("sign-last-url", CALLBACK);
120
+ const wallet = { id: "my-near-wallet", signMessage: vi.fn() } as unknown as NearWallet;
121
+
122
+ const { publicKey } = await createNearWalletKwilSigner(wallet, ACCOUNT, store, kwilClient);
123
+ return publicKey;
124
+ }
125
+
126
+ describe("createNearWalletKwilSigner my-near-wallet redirect", () => {
127
+ afterEach(() => {
128
+ window.location.hash = "";
129
+ fullAccess.keys = [];
130
+ fullAccess.limitedKeys = [];
131
+ fullAccess.queries = 0;
132
+ fullAccess.lastRequest = undefined;
133
+ });
134
+
135
+ it("accepts a fragment whose signature matches the saved request and a full-access key", async () => {
136
+ const { nonce, signature } = signedRequest();
137
+ fullAccess.keys = [realKey, fragmentKey];
138
+
139
+ expect(
140
+ await signerPublicKey({ accountId: ACCOUNT, signature, publicKey: fragmentKey, nonce }),
141
+ ).toBe(fragmentKey);
142
+ expect(window.location.hash).toBe("");
143
+ expect(fullAccess.queries).toBe(1);
144
+ expect(fullAccess.lastRequest).toMatchObject({
145
+ request_type: "view_access_key_list",
146
+ account_id: ACCOUNT,
147
+ });
148
+ });
149
+
150
+ it("ignores a signature that does not match the saved request", async () => {
151
+ const { nonce, signature } = signedRequest();
152
+ fullAccess.keys = [realKey, fragmentKey];
153
+
154
+ expect(
155
+ await signerPublicKey({
156
+ accountId: ACCOUNT,
157
+ signature,
158
+ publicKey: fragmentKey,
159
+ nonce,
160
+ message: "different message",
161
+ }),
162
+ ).toBe(realKey);
163
+ expect(fullAccess.queries).toBe(0);
164
+ });
165
+
166
+ it("ignores a fragment for a different account", async () => {
167
+ const { nonce, signature } = signedRequest();
168
+ fullAccess.keys = [realKey, fragmentKey];
169
+
170
+ expect(
171
+ await signerPublicKey({
172
+ accountId: "attacker.testnet",
173
+ signature,
174
+ publicKey: fragmentKey,
175
+ nonce,
176
+ }),
177
+ ).toBe(realKey);
178
+ expect(fullAccess.queries).toBe(0);
179
+ });
180
+
181
+ it("ignores a public key that is not a full-access key of the account", async () => {
182
+ const { nonce, signature } = signedRequest();
183
+ fullAccess.keys = [realKey];
184
+ fullAccess.limitedKeys = [fragmentKey];
185
+
186
+ expect(
187
+ await signerPublicKey({ accountId: ACCOUNT, signature, publicKey: fragmentKey, nonce }),
188
+ ).toBe(realKey);
189
+ expect(fullAccess.queries).toBe(1);
190
+ expect(fullAccess.lastRequest).toMatchObject({
191
+ request_type: "view_access_key_list",
192
+ account_id: ACCOUNT,
193
+ });
194
+ });
195
+
196
+ it("does not replay an untrusted fragment signature", async () => {
197
+ const { nonce } = signedRequest();
198
+ window.location.hash = `accountId=${ACCOUNT}&signature=sig&publicKey=${fragmentKey}`;
199
+ const store = new MemoryStore();
200
+ await store.set("signer-address", ACCOUNT);
201
+ await store.set("signer-public-key", realKey);
202
+ await store.set("sign-last-message", MESSAGE);
203
+ await store.set("sign-last-nonce", Array.from(nonce));
204
+ await store.set("sign-last-url", CALLBACK);
205
+ const original = vi.fn();
206
+ const wallet = { id: "my-near-wallet", signMessage: original } as unknown as NearWallet;
207
+
208
+ await createNearWalletKwilSigner(wallet, ACCOUNT, store, kwilClient);
209
+ void wallet.signMessage?.({
210
+ message: MESSAGE,
211
+ recipient: RECIPIENT,
212
+ nonce: Buffer.alloc(32),
213
+ });
214
+
215
+ await vi.waitFor(() => expect(original).toHaveBeenCalledOnce());
216
+ expect(original.mock.calls[0]?.[0].message).toBe(MESSAGE);
217
+ });
218
+ });
@@ -11,16 +11,17 @@ import { KwilSigner } from "@idos-network/kwil-js";
11
11
  import {
12
12
  base64Decode,
13
13
  binaryWriteUint16BE,
14
- borshSerialize,
15
14
  bs58Decode,
16
15
  bytesConcat,
17
16
  hexEncode,
18
17
  utf8Decode,
19
18
  } from "@idos-network/utils/codecs";
19
+ import nacl from "tweetnacl";
20
20
 
21
21
  import type { KwilActionClient } from "../create-kwil-client";
22
22
 
23
23
  import { getNearConnectionConfig } from "./get-config";
24
+ import { nep413SignHash, serializeKwilNep413 } from "./nep413";
24
25
 
25
26
  const NEAR_WALLET_TYPES: string[] = [
26
27
  "browser",
@@ -88,25 +89,12 @@ function createNearWalletSigner(
88
89
  nonce: nonceSuggestion,
89
90
  }))!;
90
91
 
91
- const nep413BorschSchema = {
92
- struct: {
93
- tag: "u32",
94
- message: "string",
95
- nonce: { array: { type: "u8", len: 32 } },
96
- recipient: "string",
97
- callbackUrl: { option: "string" },
98
- },
99
- };
100
-
101
- const nep413BorshParams = {
102
- tag: 2147484061,
92
+ const nep413BorshPayload = serializeKwilNep413({
103
93
  message: messageString,
104
94
  nonce: Array.from(nonce),
105
95
  recipient,
106
96
  callbackUrl,
107
- };
108
-
109
- const nep413BorshPayload = borshSerialize(nep413BorschSchema, nep413BorshParams);
97
+ });
110
98
  const result = bytesConcat(
111
99
  binaryWriteUint16BE(nep413BorshPayload.length),
112
100
  nep413BorshPayload,
@@ -149,6 +137,112 @@ export async function getNearFullAccessPublicKeys(
149
137
  }
150
138
  }
151
139
 
140
+ type NearRedirect = {
141
+ accountId?: string;
142
+ publicKey?: string;
143
+ signature?: string;
144
+ error?: string;
145
+ };
146
+
147
+ function parseNearRedirect(hash: string): NearRedirect {
148
+ const params = new URLSearchParams(hash.startsWith("#") ? hash.slice(1) : hash);
149
+ const signature = params.get("signature");
150
+ return {
151
+ accountId: params.get("accountId") ?? undefined,
152
+ publicKey: params.get("publicKey") ?? undefined,
153
+ // URLSearchParams decodes a literal "+" as a space. NEP-413 signatures are base64.
154
+ signature: signature?.replaceAll(" ", "+"),
155
+ error: params.get("error") ?? undefined,
156
+ };
157
+ }
158
+
159
+ function takeNearRedirect(): NearRedirect {
160
+ const redirect = parseNearRedirect(window.location.hash);
161
+ if (!redirect.accountId && !redirect.publicKey && !redirect.signature && !redirect.error) {
162
+ return redirect;
163
+ }
164
+
165
+ const params = new URLSearchParams(window.location.hash.slice(1));
166
+ for (const key of ["accountId", "publicKey", "signature", "error", "state"]) params.delete(key);
167
+ const rest = params.toString();
168
+ window.history.replaceState(
169
+ null,
170
+ "",
171
+ `${window.location.pathname}${window.location.search}${rest ? `#${rest}` : ""}`,
172
+ );
173
+ return redirect;
174
+ }
175
+
176
+ function verifyNep413Signature(
177
+ message: string,
178
+ nonce: Uint8Array,
179
+ recipient: string,
180
+ callbackUrl: string,
181
+ signature: string,
182
+ publicKey: string,
183
+ ): boolean {
184
+ const pieces = publicKey.split(":");
185
+ if (pieces.length !== 2 || pieces[0] !== "ed25519" || !pieces[1] || nonce.length !== 32) {
186
+ return false;
187
+ }
188
+
189
+ try {
190
+ const publicKeyBytes = bs58Decode(pieces[1]);
191
+ const signatureBytes = base64Decode(signature);
192
+ if (publicKeyBytes.length !== 32 || signatureBytes.length !== 64) return false;
193
+
194
+ return nacl.sign.detached.verify(
195
+ nep413SignHash({ message, nonce, recipient, callbackUrl }),
196
+ signatureBytes,
197
+ publicKeyBytes,
198
+ );
199
+ } catch {
200
+ return false;
201
+ }
202
+ }
203
+
204
+ // The hash is attacker-controlled. A signature is only the wallet's if it verifies
205
+ // against the request we stored before the redirect, for a full-access key of this account.
206
+ async function trustedNearRedirect(
207
+ redirect: NearRedirect,
208
+ currentAddress: string,
209
+ recipient: string,
210
+ store: Store,
211
+ ): Promise<{ signature: string; publicKey: string } | undefined> {
212
+ if (
213
+ redirect.error ||
214
+ !redirect.signature ||
215
+ !redirect.publicKey ||
216
+ redirect.accountId !== currentAddress
217
+ ) {
218
+ return;
219
+ }
220
+
221
+ const message = await store.get<string>("sign-last-message");
222
+ const savedNonce = await store.get<number[] | Uint8Array>("sign-last-nonce");
223
+ const callbackUrl = await store.get<string>("sign-last-url");
224
+ if (!message || !savedNonce || !callbackUrl) return;
225
+
226
+ const nonce = Uint8Array.from(savedNonce);
227
+ if (
228
+ !verifyNep413Signature(
229
+ message,
230
+ nonce,
231
+ recipient,
232
+ callbackUrl,
233
+ redirect.signature,
234
+ redirect.publicKey,
235
+ )
236
+ ) {
237
+ return;
238
+ }
239
+
240
+ const keys = await getNearFullAccessPublicKeys(currentAddress);
241
+ if (!keys?.includes(redirect.publicKey)) return;
242
+
243
+ return { signature: redirect.signature, publicKey: redirect.publicKey };
244
+ }
245
+
152
246
  export async function signNearMessage(
153
247
  wallet: NearWallet,
154
248
  message: string,
@@ -169,34 +263,33 @@ export async function createNearWalletKwilSigner(
169
263
  if (!wallet.signMessage) throw new Error("Only wallets with signMessage are supported.");
170
264
 
171
265
  if (wallet.id === "my-near-wallet") {
172
- const { accountId, signature, publicKey, error } = Object.fromEntries(
173
- new URLSearchParams(window.location.hash.slice(1)).entries(),
174
- );
266
+ const redirect = takeNearRedirect();
267
+ const trusted = await trustedNearRedirect(redirect, currentAddress, recipient, store);
175
268
 
176
- if (signature) {
177
- await store.set("signer-address", accountId);
178
- await store.set("signer-public-key", publicKey);
269
+ if (trusted) {
270
+ await store.set("signer-address", currentAddress);
271
+ await store.set("signer-public-key", trusted.publicKey);
179
272
  }
180
273
 
181
274
  const signMessageOriginal = wallet.signMessage.bind(wallet);
182
275
 
183
276
  wallet.signMessage = async ({
184
277
  message,
185
- recipient,
278
+ recipient: signRecipient,
186
279
  }: SignMessageParams): Promise<SignedMessage & { nonce?: Uint8Array }> => {
187
- if (error) return Promise.reject();
280
+ if (redirect.error) return Promise.reject();
188
281
 
189
282
  const lastMessage = await store.get<string>("sign-last-message");
190
283
  const lastNonce = await store.get<Uint8Array>("sign-last-nonce");
191
284
 
192
- if (signature && message === lastMessage && lastNonce) {
285
+ if (trusted && message === lastMessage && lastNonce) {
193
286
  const nonce = Buffer.from(lastNonce);
194
287
  const callbackUrl = await store.get<string>("sign-last-url");
195
288
 
196
289
  return Promise.resolve({
197
290
  accountId: currentAddress,
198
- publicKey,
199
- signature,
291
+ publicKey: trusted.publicKey,
292
+ signature: trusted.signature,
200
293
  nonce,
201
294
  message,
202
295
  callbackUrl,
@@ -210,7 +303,7 @@ export async function createNearWalletKwilSigner(
210
303
  await store.set("sign-last-nonce", Array.from(nonce));
211
304
  await store.set("sign-last-url", callbackUrl);
212
305
 
213
- signMessageOriginal({ message, nonce, recipient, callbackUrl });
306
+ signMessageOriginal({ message, nonce, recipient: signRecipient, callbackUrl });
214
307
 
215
308
  return new Promise(() => ({}) as SignedMessage);
216
309
  };
@@ -2,56 +2,66 @@ import type { KeyPair } from "near-api-js";
2
2
 
3
3
  import {
4
4
  binaryWriteUint16BE,
5
+ borshDeserialize,
5
6
  borshSerialize,
6
7
  bytesConcat,
7
8
  sha256Hash,
8
9
  utf8Decode,
9
10
  } from "@idos-network/utils/codecs";
10
11
 
12
+ // 2**31 + 413. Prepended so a NEP-413 payload cannot be a valid NEAR transaction.
13
+ const NEP413_TAG = 2147484061;
14
+
15
+ const nep413PayloadSchema = {
16
+ struct: {
17
+ message: "string",
18
+ nonce: { array: { type: "u8", len: 32 } },
19
+ recipient: "string",
20
+ callbackUrl: { option: "string" },
21
+ },
22
+ } as const;
23
+
24
+ // Borsh has no struct header, so this is the same bytes as `u32 tag` followed by `nep413PayloadSchema`.
25
+ const kwilNep413Schema = {
26
+ struct: {
27
+ tag: "u32",
28
+ ...nep413PayloadSchema.struct,
29
+ },
30
+ } as const;
31
+
32
+ type Nep413Fields = {
33
+ message: string;
34
+ nonce: Uint8Array | number[];
35
+ recipient: string;
36
+ callbackUrl?: string;
37
+ };
38
+
39
+ export type Nep413Envelope = Nep413Fields & { tag: number };
40
+
41
+ export function nep413SignHash(payload: Nep413Fields): Uint8Array {
42
+ return sha256Hash(
43
+ bytesConcat(borshSerialize("u32", NEP413_TAG), borshSerialize(nep413PayloadSchema, payload)),
44
+ );
45
+ }
46
+
47
+ export function serializeKwilNep413(payload: Nep413Fields): Uint8Array {
48
+ return borshSerialize(kwilNep413Schema, { tag: NEP413_TAG, ...payload });
49
+ }
50
+
51
+ export function deserializeKwilNep413(bytes: Uint8Array): Nep413Envelope {
52
+ return borshDeserialize(kwilNep413Schema, bytes) as Nep413Envelope;
53
+ }
54
+
11
55
  export function kwilNep413Signer(
12
56
  recipient: string,
13
57
  ): (keyPair: KeyPair) => (messageBytes: Uint8Array) => Promise<Uint8Array> {
14
58
  return (keyPair: KeyPair) =>
15
59
  async (messageBytes: Uint8Array): Promise<Uint8Array> => {
16
60
  const message = utf8Decode(messageBytes);
17
- const nonceLength = 32;
18
- const nonce = crypto.getRandomValues(new Uint8Array(nonceLength));
19
-
20
- const nep413BorschSchema = {
21
- struct: {
22
- message: "string",
23
- nonce: { array: { type: "u8", len: nonceLength } },
24
- recipient: "string",
25
- callbackUrl: { option: "string" },
26
- },
27
- } as const;
28
-
29
- const tag = 2147484061; // 2**31 + 413
30
-
31
- const { signature } = keyPair.sign(
32
- sha256Hash(
33
- bytesConcat(
34
- borshSerialize("u32", tag),
35
- borshSerialize(nep413BorschSchema, { message, nonce, recipient }),
36
- ),
37
- ),
38
- );
61
+ const nonce = crypto.getRandomValues(new Uint8Array(32));
62
+ const { signature } = keyPair.sign(nep413SignHash({ message, nonce, recipient }));
39
63
 
40
- const kwilNep413BorschSchema = {
41
- struct: {
42
- tag: "u32",
43
- ...nep413BorschSchema.struct,
44
- },
45
- };
46
-
47
- const kwilNep413BorshParams = {
48
- tag,
49
- message,
50
- nonce,
51
- recipient,
52
- };
53
-
54
- const kwilNep413BorshPayload = borshSerialize(kwilNep413BorschSchema, kwilNep413BorshParams);
64
+ const kwilNep413BorshPayload = serializeKwilNep413({ message, nonce, recipient });
55
65
 
56
66
  return bytesConcat(
57
67
  binaryWriteUint16BE(kwilNep413BorshPayload.length),
@@ -0,0 +1,35 @@
1
+ import { hexEncode, utf8Encode } from "@idos-network/utils/codecs";
2
+ import { KeyPair } from "near-api-js";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { kwilNep413Signer } from "./nep413";
6
+ import { verifyNearSignature } from "./signature-verification";
7
+
8
+ async function sign(message: string, recipient: string) {
9
+ const keyPair = KeyPair.fromRandom("ed25519");
10
+ const signed = await kwilNep413Signer(recipient)(keyPair)(utf8Encode(message));
11
+ return {
12
+ signature: hexEncode(signed),
13
+ publicKey: keyPair.getPublicKey().toString(),
14
+ };
15
+ }
16
+
17
+ describe("verifyNearSignature", () => {
18
+ it("accepts a signature addressed to idos.network", async () => {
19
+ const { signature, publicKey } = await sign("hello", "idos.network");
20
+ expect(await verifyNearSignature("hello", signature, publicKey)).toBe(true);
21
+ });
22
+
23
+ it.each(["evil.example", "idos.network.evil", "idos-issuer"])(
24
+ "rejects a valid signature addressed to %s",
25
+ async (recipient) => {
26
+ const { signature, publicKey } = await sign("hello", recipient);
27
+ expect(await verifyNearSignature("hello", signature, publicKey)).toBe(false);
28
+ },
29
+ );
30
+
31
+ it("rejects an idos.network signature of a different message", async () => {
32
+ const { signature, publicKey } = await sign("hello", "idos.network");
33
+ expect(await verifyNearSignature("goodbye", signature, publicKey)).toBe(false);
34
+ });
35
+ });
@@ -1,19 +1,10 @@
1
- import {
2
- borshDeserialize,
3
- borshSerialize,
4
- bs58Decode,
5
- hexDecode,
6
- sha256Hash,
7
- } from "@idos-network/utils/codecs";
1
+ import { bs58Decode, hexDecode } from "@idos-network/utils/codecs";
8
2
  import nacl from "tweetnacl";
9
3
 
10
- interface NEP413Payload {
11
- tag: number;
12
- message: string;
13
- nonce: number[];
14
- recipient: string;
15
- callbackUrl?: string;
16
- }
4
+ import { deserializeKwilNep413, nep413SignHash } from "./nep413";
5
+
6
+ // Must match the recipient the idOS node accepts for NEP-413 wallet signatures.
7
+ const NEP413_RECIPIENT = "idos.network";
17
8
 
18
9
  export const verifyNearSignature = async (
19
10
  message: string,
@@ -48,33 +39,21 @@ export const verifyNearSignature = async (
48
39
  return false;
49
40
  }
50
41
 
51
- // Deserialize the payload to extract nonce, recipient, callbackUrl
52
- const payloadSchema = {
53
- struct: {
54
- tag: "u32",
55
- message: "string",
56
- nonce: { array: { type: "u8", len: 32 } },
57
- recipient: "string",
58
- callbackUrl: { option: "string" },
59
- },
60
- };
42
+ const deserializedPayload = deserializeKwilNep413(payloadBytes);
61
43
 
62
- const deserializedPayload = borshDeserialize(payloadSchema, payloadBytes) as NEP413Payload;
44
+ // NEP-413 binds the signature to a relying party; reject ones addressed elsewhere.
45
+ if (deserializedPayload.recipient !== NEP413_RECIPIENT) {
46
+ console.warn("Invalid NEP-413 recipient");
47
+ return false;
48
+ }
63
49
 
64
- // Reconstruct the payload with tag and message (matching Go implementation)
65
- const reconstructedPayload: NEP413Payload = {
66
- tag: 2147484061, // 2**31 + 413
67
- message: message, // Use the provided message
50
+ // Hash the caller-supplied message with the nonce and recipient from the envelope.
51
+ const hash = nep413SignHash({
52
+ message,
68
53
  nonce: deserializedPayload.nonce,
69
54
  recipient: deserializedPayload.recipient,
70
55
  callbackUrl: deserializedPayload.callbackUrl,
71
- };
72
-
73
- // Serialize the entire payload (matching Go's borsch.Serialize(*payload))
74
- const fullPayloadBytes = borshSerialize(payloadSchema, reconstructedPayload);
75
- // Hash the payload (matching Go's sha256.Sum256(payloadBytes))
76
- const hash = sha256Hash(fullPayloadBytes);
77
- // Raw ed25519 verification using nacl (matching Go's ed25519.Verify)
56
+ });
78
57
  const isValid = nacl.sign.detached.verify(hash, actualSignature, publicKeyBytes);
79
58
 
80
59
  return isValid;
@@ -1 +0,0 @@
1
- {"version":3,"file":"facesign-signer-5I8juA7p.d.mts","names":[],"sources":["../src/facesign/facesign-signer.ts"],"mappings":";UAMiB;EACf;EACA;;UAGe;EACf,UAAU;EACV;;cAOW;;EAqBX;EACA;EACA;EACA;EAEA,YAAY,SAAS;EA2DrB,WAAiB;IAAU;;EAM3B,QAAc;EAWd,cAAoB;MA0BhB;EAIJ,YAAkB,SAAS,sBAAsB,QAAQ;EAiFzD,SAAe;EAqCf;EAqBA"}
@@ -1,2 +0,0 @@
1
- const e=6e5;var t=class{#e=null;#t=null;#n=null;#r=null;#i=null;#a=null;#o=null;#s=null;#c=null;#l=null;#u=null;#d=!1;#f=0;#p=null;#m;#h;#g;signatureType=`ed25519`;walletType=`FaceSign`;publicAddress=``;publicKey=``;constructor(e){this.#m=e.metadata,this.#h=e.enclaveUrl,this.#g=new URL(e.enclaveUrl).origin}#_(){this.#d||=(this.#u=e=>{if(e.origin===this.#g){if(e.data?.type===`facesign_ready`&&(this.#p=!!e.data.hasKey,this.#n?.()),e.data?.type===`sign_proposal_response`){let t=e.data.data;if(!t)return;t.signature?this.#r?.(new Uint8Array(t.signature)):this.#i?.(Error(`Sign request rejected by user`))}if(e.data?.type===`session_proposal_response`){let t=e.data.data;if(!t)return;t.approved&&t.address?this.#a?.({address:t.address}):this.#o?.(Error(`Session request rejected by user`))}if(e.data?.type===`address_response`){let t=e.data.data;this.#s?.(t?.address??null)}e.data?.type===`reset_complete`&&(e.data.data?.ok?this.#c?.():this.#l?.(Error(`FaceSign enclave failed to delete its key`)))}},window.addEventListener(`message`,this.#u),!0)}async preload(){return this.#_(),await this.#x(),{hasKey:this.#p??!1}}async init(){this.#_();let e=await this.#v();return this.publicKey=e.address,this.publicAddress=e.address,e.address}async getAddress(){return this.#_(),await this.#x(),this.#p?new Promise(t=>{let n=setTimeout(()=>t(null),e);this.#s=e=>{clearTimeout(n),this.#s=null,e&&(this.publicKey=e,this.publicAddress=e),t(e)},this.#e?.contentWindow?.postMessage({type:`address_request`,data:{id:++this.#f}},this.#h)}):null}get signer(){return this}async signMessage(t){await this.#x(),this.#S();let n=typeof t==`string`?new TextEncoder().encode(t):t;try{return await new Promise((t,r)=>{let i=setTimeout(()=>{r(Error(`FaceSign sign request timed out`))},e);this.#r=e=>{clearTimeout(i),t(e)},this.#i=e=>{clearTimeout(i),r(e)},setTimeout(()=>{this.#e?.contentWindow?.postMessage({type:`sign_proposal`,data:{id:++this.#f,data:n,metadata:this.#m}},this.#h)},300)})}finally{this.#r=null,this.#i=null,this.#C()}}async#v(){await this.#x(),this.#S();try{return await new Promise((t,n)=>{let r=setTimeout(()=>{n(Error(`FaceSign session proposal timed out`))},e);this.#a=e=>{clearTimeout(r),t(e)},this.#o=e=>{clearTimeout(r),n(e)},setTimeout(()=>{this.#e?.contentWindow?.postMessage({type:`session_proposal`,data:{id:++this.#f,metadata:this.#m}},this.#h)},300)})}finally{this.#a=null,this.#o=null,this.#C()}}async reset(){this.#_();let e=Date.now()+15e3;try{await this.#x(e-Date.now()),await new Promise((t,n)=>{let r=setTimeout(()=>{n(Error(`FaceSign reset timed out`))},Math.max(e-Date.now(),0));this.#c=()=>{clearTimeout(r),this.#c=null,this.#l=null,t()},this.#l=e=>{clearTimeout(r),this.#c=null,this.#l=null,n(e)},this.#e?.contentWindow?.postMessage({type:`reset`,data:{id:++this.#f}},this.#g)})}finally{this.destroy()}}hide(){this.#y(),this.#C()}#y(){let e=Error(`FaceSign operation cancelled`);this.#i&&=(this.#i(e),this.#r=null,null),this.#o&&=(this.#o(e),this.#a=null,null)}destroy(){this.#y(),this.#u&&=(window.removeEventListener(`message`,this.#u),null),this.#w()}#b(){let e=document.createElement(`dialog`);return Object.assign(e.style,{position:`fixed`,inset:`0`,width:`100%`,height:`100%`,maxWidth:`100%`,maxHeight:`100%`,margin:`0`,padding:`0`,border:`none`,zIndex:`99999`,background:`#0a0a0a`}),document.body.appendChild(e),e}#x(t=e){return this.#e?.contentWindow?Promise.resolve():new Promise((e,n)=>{let r=setTimeout(()=>{n(Error(`FaceSign enclave failed to load`))},t);this.#t||=this.#b(),this.#e=document.createElement(`iframe`),this.#e.src=this.#h,this.#e.title=`FaceSign Enclave`,this.#e.allow=`camera; fullscreen`,this.#e.style.cssText=`width:100%;height:100%;border:none;`,this.#t.appendChild(this.#e),this.#n=()=>{console.log(`resolveOpenEnclave`),clearTimeout(r),e()}})}#S(){let e=this.#t;e&&!e.open&&e.showModal()}#C(){let e=this.#t;e?.open&&e.close()}#w(){this.#e&&=(this.#e.remove(),null),this.#t&&=(this.#t.remove(),null)}};export{t};
2
- //# sourceMappingURL=facesign-signer-z5T312uQ.mjs.map