Skip to content

Commit b4f2821

Browse files
authored
fix(auth): server-issued SIWN nonces + skew-tolerant freshness + distinct verify error codes (#824)
* fix(auth): server-issued SIWN nonces + skew-tolerant freshness + distinct verify error codes A device clock even slightly ahead of the server failed sign-in deterministically across every wallet: the nonce embedded the client's Date.now() and the server rejected any future timestamp (age < 0). - signWithWallet() now fetches the nonce from /near/nonce (server clock), falling back to a local nonce if the endpoint is unavailable; accountId is optional on the nonce request so fresh-connect flows can get a nonce before the wallet account is known - nonce freshness tolerates up to 60s of future skew on both verify paths (ed25519 via near-kit's nonceValidation: "none" + local check, ML-DSA-65 in the shared check) - verify failures return distinct codes (UNAUTHORIZED_NONCE_EXPIRED, UNAUTHORIZED_NONCE_FROM_FUTURE, UNAUTHORIZED_SIGNATURE_INVALID, UNAUTHORIZED_PUBLIC_KEY_INVALID, UNAUTHORIZED_ACCESS_KEY_NOT_FOUND, UNAUTHORIZED_NOT_FULL_ACCESS_KEY, VERIFY_RPC_ERROR) instead of one blanket "Invalid signature"; RPC failures on the access-key lookup no longer masquerade as bad signatures - client propagates error codes to onError; login page maps NONCE_FROM_FUTURE to a "device clock ahead" message (4 locales) * test(auth): sandbox integration tests for real quantum-key sign-in Full SIWN flow against a local nearcore sandbox (near-kit Sandbox, nearcore 2.13.4): the test provisions real accounts via AddKey transactions carrying an ML-DSA-65 full-access key, points the plugin's rpcUrl at the sandbox node, and drives /near/verify end to end with zero mocked crypto — real borsh serialization, real SHA-256, real ml_dsa65 signatures, real on-chain access-key lookups. Covers: ml-dsa-65 sign-in happy path, nonce replay detection, tampered signature (UNAUTHORIZED_SIGNATURE_INVALID), key-not-on-account (UNAUTHORIZED_ACCESS_KEY_NOT_FOUND), stale nonce (UNAUTHORIZED_NONCE_EXPIRED), and an ed25519 parity path. Skips cleanly when the sandbox binary is unavailable. * fix(auth): true borsh Option encoding in ML-DSA-65 NEP-413 verification The ML-DSA-65 verifier serialized callback_url as a u32 (4 zero bytes when absent), but borsh Option<T> is a single discriminant byte — so every signature from a real quantum-capable wallet (Rust/borsh side, e.g. Intear) failed verification with "Invalid signature" even though key, nonce, and message all matched. The ed25519 path never hit this because near-kit serializes the Option correctly. Proven against a captured failing request from a real wallet: ml_dsa65.verify flips false→true with the 1-byte encoding, same key and signature. Test helpers now sign with the spec encoding, and the sandbox integration test signs without callbackUrl while the request includes it, exercising the callbackUrl retry against the real chain. * test(auth): sandbox E2E for the gasless flow — quantum login, gas keys, relay Full gasless-write chain against a local nearcore sandbox: quantum SIWN login, gas-key bootstrap signed by the ML-DSA parent (KeyType 2 transaction accepted on-chain — proves noble/aws-lc interop for transaction signatures), sponsor funding through /near/gas-key/fund (real transferToGasKey), a gasless write signed by the gas key on its own nonce lane with gas paid from the prepaid balance, and a quantum-signed delegate relayed through /near/relay and executed by the chain. The receiver contract is a hand-built minimal wasm module exporting __fastdata_kv, so the suite needs no external fixtures. Also aligns relayedTransaction test-schema optionality with the production migration: gasUsed and updatedAt are nullable there, but the test DDL inferred NOT NULL, breaking any relay-route create under test. * ci: cache the near-sandbox binary for better-near-auth sandbox tests The sandbox integration tests boot a local nearcore node via near-kit, which downloads the ~30MB near-sandbox binary to ~/.near-kit/sandbox/bin on first use. Cache it keyed on the near-kit version (the binary version source) with a restore fallback so version bumps only pay for the new download, not the whole directory.
1 parent 1b0feda commit b4f2821

13 files changed

Lines changed: 842 additions & 90 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"better-near-auth": minor
3+
---
4+
5+
Fix deterministic sign-in failures caused by client/server clock skew, and make SIWN verification failures diagnosable.
6+
7+
ML-DSA-65 NEP-413 verification now uses correct borsh serialization for `callback_url: Option<String>` (one discriminant byte, Some followed by [u32 len][bytes]). The previous encoding wrote the Option as a u32, so signatures from real quantum-capable wallets (Rust/borsh — e.g. Intear) failed verification with "Invalid signature" even when the key, nonce, and message all matched. Test helpers now sign with the spec encoding, and the sandbox integration test signs without callbackUrl while the request includes it, matching real wallet behavior.
8+
9+
New sandbox integration tests run the full quantum flow against a local nearcore sandbox node (near-kit's `Sandbox`, nearcore 2.13.4, skipped when the binary is unavailable): quantum-signed SIWN login, session gas-key bootstrap signed by the ML-DSA parent key, sponsor funding via `/near/gas-key/fund`, a gasless write signed by the gas key on its own nonce lane, and a quantum-signed delegate relayed through `/near/relay`. Also aligns the `relayedTransaction` test schema with the production migration (`gasUsed`/`updatedAt` nullable).
10+
11+
Sign-in nonces are now issued by the server (`/near/nonce`, server clock) instead of being generated from the user's device clock, so a device clock ahead of the server no longer fails login outright; the client falls back to a local nonce if the endpoint is unavailable. `accountId` on the nonce request is now optional (omit it to get a nonce before the wallet account is known). Nonce freshness validation tolerates up to 60s of future skew (previously any future timestamp was rejected), applied to both the ed25519 (via `nonceValidation: "none"` + local check) and ML-DSA-65 paths. Verify failures now return distinct error codes — `UNAUTHORIZED_NONCE_EXPIRED`, `UNAUTHORIZED_NONCE_FROM_FUTURE`, `UNAUTHORIZED_SIGNATURE_INVALID`, `UNAUTHORIZED_PUBLIC_KEY_INVALID`, `UNAUTHORIZED_ACCESS_KEY_NOT_FOUND`, `UNAUTHORIZED_NOT_FULL_ACCESS_KEY`, `VERIFY_RPC_ERROR` — instead of a single blanket "Invalid signature", and the client propagates those codes to `onError` callbacks.

‎.github/workflows/ci.yml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,19 @@ jobs:
370370
- name: Test everything-dev
371371
run: pnpm --filter everything-dev test
372372

373+
- name: Get near-kit version
374+
id: near-kit
375+
working-directory: packages/better-near-auth
376+
run: echo "version=$(node -p "require('near-kit/package.json').version")" >> "$GITHUB_OUTPUT"
377+
378+
- name: Cache near-sandbox binary
379+
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 (Node 24)
380+
with:
381+
path: ~/.near-kit/sandbox/bin
382+
key: ${{ runner.os }}-near-sandbox-${{ steps.near-kit.outputs.version }}
383+
restore-keys: |
384+
${{ runner.os }}-near-sandbox-
385+
373386
- name: Test better-near-auth
374387
run: pnpm --filter better-near-auth test
375388

‎packages/better-near-auth/README.md‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,14 @@ interface AuthCallbacks {
326326
| Code | Description |
327327
|---|---|
328328
| `UNAUTHORIZED_NONCE_REPLAY` | Nonce already used (replay attack detected) |
329-
| `UNAUTHORIZED` | Generic auth failure (invalid signature, account mismatch, etc.) |
329+
| `UNAUTHORIZED_NONCE_EXPIRED` | Nonce timestamp older than the freshness window |
330+
| `UNAUTHORIZED_NONCE_FROM_FUTURE` | Nonce timestamp more than 60s ahead of the server clock (device clock wrong) |
331+
| `UNAUTHORIZED_SIGNATURE_INVALID` | NEP-413 signature failed verification |
332+
| `UNAUTHORIZED_PUBLIC_KEY_INVALID` | Public key malformed (bad encoding or wrong length) |
333+
| `UNAUTHORIZED_ACCESS_KEY_NOT_FOUND` | Signing key not found on the account on-chain |
334+
| `UNAUTHORIZED_NOT_FULL_ACCESS_KEY` | Signing key is not a full access key |
335+
| `VERIFY_RPC_ERROR` | On-chain access-key lookup failed (RPC unavailable) |
336+
| `UNAUTHORIZED` | Generic auth failure (account mismatch, recipient mismatch, etc.) |
330337

331338
### Server Endpoints
332339

@@ -420,7 +427,8 @@ The plugin detects the network from the account ID:
420427
## Security
421428

422429
### NEP-413 Compliance
423-
- Proper nonce handling prevents replay attacks
430+
- Nonces are issued by the server (`/near/nonce`, server clock) during sign-in, so client/server clock skew cannot fail login; the client falls back to a local nonce if the endpoint is unavailable
431+
- Nonce freshness tolerates up to 60s of future clock skew; older timestamps are rejected with `UNAUTHORIZED_NONCE_EXPIRED`, future timestamps beyond the tolerance with `UNAUTHORIZED_NONCE_FROM_FUTURE`
424432
- Message format and recipient validation
425433
- 15-minute server-side nonce expiration with DB replay detection
426434

@@ -436,7 +444,7 @@ The plugin detects the network from the account ID:
436444
- Configurable validation for limited access keys
437445

438446
### Post-Quantum Keys
439-
- `ml-dsa-65:` (FIPS 204) full-access keys are accepted on `/api/auth/near/verify` and `/api/auth/near/link-account`. Signature verification is delegated to `@noble/post-quantum`'s `ml_dsa65.verify`, while the on-chain access-key check stays the same as ed25519. The NEP-413 SHA-256 + borsh payload format is unchanged from the ed25519 path.
447+
- `ml-dsa-65:` (FIPS 204) full-access keys are accepted on `/api/auth/near/verify` and `/api/auth/near/link-account`. Signature verification is delegated to `@noble/post-quantum`'s `ml_dsa65.verify`, while the on-chain access-key check stays the same as ed25519. The NEP-413 payload uses true borsh serialization (1-byte Option discriminant for `callback_url`), matching Rust wallets.
440448

441449
## Troubleshooting
442450

‎packages/better-near-auth/src/client.ts‎

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -495,13 +495,58 @@ export const siwnClient = (config: SIWNClientConfig) => {
495495
});
496496
};
497497

498-
const signWithWallet = async (): Promise<SignWithWalletResult> => {
498+
const errorWithCode = (
499+
message: string,
500+
response: {
501+
error?: {
502+
code?: string;
503+
status?: number;
504+
error?: { code?: string; status?: number };
505+
body?: { code?: string; status?: number };
506+
} | null;
507+
},
508+
fallback: string,
509+
): Error & { code?: string; status?: number } => {
510+
const err = new Error(message || fallback) as Error & { code?: string; status?: number };
511+
const fe = response.error;
512+
err.code = fe?.code ?? fe?.error?.code ?? fe?.body?.code;
513+
err.status = fe?.status ?? fe?.error?.status ?? fe?.body?.status;
514+
return err;
515+
};
516+
517+
const fetchServerNonce = async (
518+
$fetch: BetterFetch,
519+
net: NearNetwork,
520+
accountId?: string,
521+
): Promise<{ nonceBytes: Uint8Array; nonceHex: string; serverIssued: boolean }> => {
522+
try {
523+
const response = await $fetch<{ nonce: string }>("/near/nonce", {
524+
method: "POST",
525+
body: accountId ? { accountId, networkId: net } : { networkId: net },
526+
});
527+
if (response.error || !response.data?.nonce) {
528+
throw new Error(response.error?.message || "nonce endpoint unavailable");
529+
}
530+
return {
531+
nonceBytes: hex.decode(response.data.nonce),
532+
nonceHex: response.data.nonce,
533+
serverIssued: true,
534+
};
535+
} catch (error) {
536+
console.warn(
537+
"[siwn] Server nonce unavailable, falling back to local clock nonce:",
538+
error instanceof Error ? error.message : error,
539+
);
540+
const nonceBytes = generateNonce();
541+
return { nonceBytes, nonceHex: hex.encode(nonceBytes), serverIssued: false };
542+
}
543+
};
544+
545+
const signWithWallet = async ($fetch: BetterFetch): Promise<SignWithWalletResult> => {
499546
const net = activeNetwork.get();
500547
const conn = await requireConnector(net);
501548
const recipient = getRecipient(net);
502549

503-
const nonceBytes = generateNonce();
504-
const nonceHex = hex.encode(nonceBytes);
505550
const message = `Sign in to ${recipient}`;
506551
const callbackUrl = typeof window === "undefined" ? undefined : window.location.href;
507552

@@ -519,20 +564,26 @@ export const siwnClient = (config: SIWNClientConfig) => {
519564
throw new Error("NEAR network changed while connecting wallet");
520565
}
521566

567+
let nonce: { nonceBytes: Uint8Array; nonceHex: string } | null = null;
522568
if (connectedWallet?.accounts?.length) {
523569
const accountId: string = connectedWallet.accounts[0]!.accountId;
524570
const isTestnetAccount = accountId.endsWith(".testnet");
525571
const isExpectedNetwork = (net === "testnet") === isTestnetAccount;
526572
if (!isExpectedNetwork) {
527573
connectedWallet = null;
574+
} else {
575+
nonce = await fetchServerNonce($fetch, net, accountId);
528576
}
529577
}
578+
if (!nonce) {
579+
nonce = await fetchServerNonce($fetch, net);
580+
}
530581

531582
if (connectedWallet?.accounts?.length) {
532583
const signMessageParams: SignMessageParamsWithCallback = {
533584
message,
534585
recipient,
535-
nonce: nonceBytes,
586+
nonce: nonce.nonceBytes,
536587
network: net,
537588
callbackUrl,
538589
};
@@ -546,7 +597,7 @@ export const siwnClient = (config: SIWNClientConfig) => {
546597
signedMessage,
547598
accountId: signedMessage.accountId,
548599
publicKey: signedMessage.publicKey,
549-
nonceHex,
600+
nonceHex: nonce.nonceHex,
550601
callbackUrl,
551602
};
552603
}
@@ -573,7 +624,7 @@ export const siwnClient = (config: SIWNClientConfig) => {
573624
signMessageParams: {
574625
message,
575626
recipient,
576-
nonce: nonceBytes,
627+
nonce: nonce.nonceBytes,
577628
callbackUrl,
578629
} as SignMessageParamsWithCallback,
579630
});
@@ -593,7 +644,7 @@ export const siwnClient = (config: SIWNClientConfig) => {
593644
signedMessage: result.value.signedMessage,
594645
accountId: result.value.accountId,
595646
publicKey: result.value.publicKey,
596-
nonceHex,
647+
nonceHex: nonce.nonceHex,
597648
callbackUrl,
598649
};
599650
};
@@ -969,7 +1020,8 @@ export const siwnClient = (config: SIWNClientConfig) => {
9691020
const net = activeNetwork.get();
9701021
const recipient = getRecipient(net);
9711022
try {
972-
const { signedMessage, accountId, nonceHex, callbackUrl } = await signWithWallet();
1023+
const { signedMessage, accountId, nonceHex, callbackUrl } =
1024+
await signWithWallet($fetch);
9731025
const message = `Sign in to ${recipient}`;
9741026

9751027
await handleAccountConnection(accountId, signedMessage.publicKey, net);
@@ -992,7 +1044,11 @@ export const siwnClient = (config: SIWNClientConfig) => {
9921044
});
9931045

9941046
if (linkResponse.error) {
995-
throw new Error(linkResponse.error.message || "Failed to link NEAR account");
1047+
throw errorWithCode(
1048+
linkResponse.error.message || "",
1049+
linkResponse,
1050+
"Failed to link NEAR account",
1051+
);
9961052
}
9971053

9981054
if (!linkResponse?.data?.success) {
@@ -1199,7 +1255,8 @@ export const siwnClient = (config: SIWNClientConfig) => {
11991255
signIn: {
12001256
near: async (callbacks?: AuthCallbacks, fetchOptions?: BetterFetchOption) => {
12011257
try {
1202-
const { signedMessage, accountId, nonceHex, callbackUrl } = await signWithWallet();
1258+
const { signedMessage, accountId, nonceHex, callbackUrl } =
1259+
await signWithWallet($fetch);
12031260
const net = activeNetwork.get();
12041261
const recipient = getRecipient(net);
12051262
const message = `Sign in to ${recipient}`;
@@ -1223,7 +1280,11 @@ export const siwnClient = (config: SIWNClientConfig) => {
12231280
);
12241281

12251282
if (verifyResponse.error) {
1226-
throw new Error(verifyResponse.error.message || "Failed to verify signature");
1283+
throw errorWithCode(
1284+
verifyResponse.error.message || "",
1285+
verifyResponse,
1286+
"Failed to verify signature",
1287+
);
12271288
}
12281289

12291290
if (!verifyResponse?.data?.success) {

‎packages/better-near-auth/src/gas-key-routes.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ let nonceCounter = 0;
5252
function makeUniqueNonce(): Uint8Array {
5353
nonceCounter++;
5454
const nonce = new Uint8Array(32);
55-
for (let i = 0; i < 32; i++) nonce[i] = (i + 1) ^ (nonceCounter & 0xff);
55+
new DataView(nonce.buffer).setBigUint64(0, BigInt(Date.now()), false);
56+
for (let i = 8; i < 32; i++) nonce[i] = (i + 1) ^ (nonceCounter & 0xff);
5657
return nonce;
5758
}
5859

0 commit comments

Comments
 (0)