@idos-network/kwil-infra 2.3.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.
- package/CHANGELOG.md +18 -0
- package/dist/actions/index.d.mts +1 -1
- package/dist/facesign/index.d.mts +2 -2
- package/dist/facesign/index.mjs +1 -1
- package/dist/facesign-signer-CTPPUFxT.mjs +2 -0
- package/dist/facesign-signer-CTPPUFxT.mjs.map +1 -0
- package/dist/{facesign-signer-5I8juA7p.d.mts → facesign-signer-Dqh899om.d.mts} +3 -2
- package/dist/facesign-signer-Dqh899om.d.mts.map +1 -0
- package/dist/{index-Bqz6trCk.d.mts → index-Bk8YCFlx.d.mts} +2 -2
- package/dist/{index-Bqz6trCk.d.mts.map → index-Bk8YCFlx.d.mts.map} +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/nep413-D_D-6Yf6.mjs +2 -0
- package/dist/nep413-D_D-6Yf6.mjs.map +1 -0
- package/dist/signature-verification/index.d.mts +1 -1
- package/dist/signature-verification/index.mjs +1 -1
- package/dist/signature-verification/index.mjs.map +1 -1
- package/dist/xrp/utils.d.mts +41 -41
- package/dist/xrp/utils.d.mts.map +1 -1
- package/package.json +4 -4
- package/src/facesign/facesign-signer.test.ts +58 -1
- package/src/facesign/facesign-signer.ts +8 -0
- package/src/facesign/index.ts +1 -1
- package/src/near/create-near-wallet-kwil-signer.test.ts +218 -0
- package/src/near/create-near-wallet-kwil-signer.ts +121 -28
- package/src/near/nep413.ts +47 -37
- package/src/near/signature-verification.test.ts +35 -0
- package/src/near/signature-verification.ts +15 -36
- package/dist/facesign-signer-5I8juA7p.d.mts.map +0 -1
- package/dist/facesign-signer-z5T312uQ.mjs +0 -2
- 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
|
|
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
|
|
173
|
-
|
|
174
|
-
);
|
|
266
|
+
const redirect = takeNearRedirect();
|
|
267
|
+
const trusted = await trustedNearRedirect(redirect, currentAddress, recipient, store);
|
|
175
268
|
|
|
176
|
-
if (
|
|
177
|
-
await store.set("signer-address",
|
|
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 (
|
|
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
|
};
|
package/src/near/nep413.ts
CHANGED
|
@@ -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
|
|
18
|
-
const
|
|
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
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
65
|
-
const
|
|
66
|
-
|
|
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
|