diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c719670..46c01a10 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,9 +70,25 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: rm -rf node_modules/.cache/rollup-plugin-typescript2 + - name: Determine version specifier from commit title + id: specifier + env: + COMMIT_MSG: ${{ github.event.head_commit.message }} + run: | + TITLE=$(printf '%s' "$COMMIT_MSG" | head -n1) + echo "Commit title: $TITLE" + if printf '%s' "$TITLE" | grep -qE '^feat(\(.+\))?!?:'; then + SPECIFIER=minor + elif printf '%s' "$TITLE" | grep -qE '^fix(\(.+\))?!?:'; then + SPECIFIER=patch + else + SPECIFIER=patch + fi + echo "Resolved specifier: $SPECIFIER" + echo "specifier=$SPECIFIER" >> "$GITHUB_OUTPUT" - name: Release run: | - npx nx release --specifier=patch --yes + npx nx release --specifier=${{ steps.specifier.outputs.specifier }} --yes env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index e5c49852..da64f306 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ node_modules !.vscode/extensions.json .cursor +/.env +/.env.local + # misc /.sass-cache /connect.lock diff --git a/docs/guide/wallet-providers/para.md b/docs/guide/wallet-providers/para.md index f7a4f66c..f5e65160 100644 --- a/docs/guide/wallet-providers/para.md +++ b/docs/guide/wallet-providers/para.md @@ -8,19 +8,19 @@ Run the following command to install the necessary packages: {% tabs %} {% tab title="npm" %} -
npm install @justweb3/widget @getpara/react-sdk @getpara/wagmi-v2-integration wagmi @tanstack/react-query ethers
+
npm install @justweb3/widget @getpara/react-sdk @getpara/wagmi-v2-integration wagmi @tanstack/react-query
 
{% endtab %} {% tab title="pnpm" %} ```bash -pnpm install @justweb3/widget @getpara/react-sdk @getpara/wagmi-v2-integration wagmi @tanstack/react-query ethers +pnpm install @justweb3/widget @getpara/react-sdk @getpara/wagmi-v2-integration wagmi @tanstack/react-query ``` {% endtab %} {% tab title="yarn" %} ```bash -yarn add @justweb3/widget @getpara/react-sdk @getpara/wagmi-v2-integration wagmi @tanstack/react-query ethers +yarn add @justweb3/widget @getpara/react-sdk @getpara/wagmi-v2-integration wagmi @tanstack/react-query ``` {% endtab %} {% endtabs %} diff --git a/docs/sdk/JustaName Core SDK/README.md b/docs/sdk/JustaName Core SDK/README.md index fc98e0d4..4fb8e676 100644 --- a/docs/sdk/JustaName Core SDK/README.md +++ b/docs/sdk/JustaName Core SDK/README.md @@ -60,7 +60,7 @@ First, import the JustaName SDK and initialize it with your configuration: ```typescript import { JustaName } from '@justaname.id/sdk'; -import { ethers } from 'ethers'; +import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts'; // Initialize the SDK with your configuration const justaname = JustaName.init({ @@ -85,7 +85,7 @@ const justaname = JustaName.init({ }); // Create a signer (for example purposes, we're creating a random wallet) -const signer = ethers.Wallet.createRandom(); +const signer = privateKeyToAccount(generatePrivateKey()); ``` ### Issuing a Subname @@ -98,7 +98,7 @@ async function issueSubname() { chainId: 1 // Ethereum Mainnet }); - const signature = await signer.signMessage(challenge.challenge); + const signature = await signer.signMessage({ message: challenge.challenge }); const response = await justaname.subnames.addSubname( { @@ -125,7 +125,7 @@ async function updateSubname() { chainId: 1 }); - const signature = await signer.signMessage(challenge.challenge); + const signature = await signer.signMessage({ message: challenge.challenge }); const response = await justaname.subnames.updateSubname( { @@ -159,7 +159,7 @@ async function signIn() { address: signer.address }); - const signature = await signer.signMessage(message); + const signature = await signer.signMessage({ message }); const response = await justaname.signIn.signIn({ message: message, diff --git a/docs/sdk/siwens/README.md b/docs/sdk/siwens/README.md index e912f1a9..de9e47b9 100644 --- a/docs/sdk/siwens/README.md +++ b/docs/sdk/siwens/README.md @@ -48,12 +48,12 @@ yarn add @justaname.id/siwens ### Example Usage ```typescript import { SIWENS, InvalidDomainException, InvalidENSException, InvalidStatementException, InvalidTimeException } f, InvalidDomainException, InvalidENSException, InvalidStatementException, InvalidTimeException } from '@justaname.id/siwens';rom '@justaname.id/siwens'; -import { ethers } from 'ethers'; +import { privateKeyToAccount } from 'viem/accounts'; // Define your provider URL (e.g., Infura) const providerUrl = 'https://mainnet.infura.io/v3/YOUR_INFURA_KEY'; -const signer = new ethers.Wallet('YOUR_PRIVATE_KEY_ENS_HOLDER') +const signer = privateKeyToAccount('YOUR_PRIVATE_KEY_ENS_HOLDER') async function signInUser() { const siwens = new SIWENS({ @@ -66,7 +66,7 @@ async function signInUser() { providerUrl }); const message = await siwens.prepareMessage(); - const signature = await signer.signMessage(message); + const signature = await signer.signMessage({ message }); return signature; } diff --git a/package.json b/package.json index d488bb73..1a7840c7 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,6 @@ "react-router-dom": "6.11.2", "react-timer-hook": "3.0.8", "react-tiny-popover": "8.0.4", - "siwe": "2.3.2", "tailwind-merge": "2.5.2", "tailwindcss-animate": "1.0.7", "tslib": "2.3.0", diff --git a/packages/@justaname.id/sdk/README.md b/packages/@justaname.id/sdk/README.md index 136f2e2d..3759edef 100644 --- a/packages/@justaname.id/sdk/README.md +++ b/packages/@justaname.id/sdk/README.md @@ -56,7 +56,7 @@ First, import the JustaName SDK and initialize it with your configuration: ```typescript import { JustaName } from '@justaname.id/sdk'; -import { ethers } from 'ethers'; +import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts'; // Initialize the SDK with your configuration const justaname = JustaName.init({ @@ -80,8 +80,8 @@ const justaname = JustaName.init({ } }); -// Create a signer (for example purposes, we're creating a random wallet) -const signer = ethers.Wallet.createRandom(); +// Create a signer (for example purposes, we're creating a random account) +const signer = privateKeyToAccount(generatePrivateKey()); ``` ### Issuing a Subname @@ -94,7 +94,7 @@ async function issueSubname() { chainId: 1 // Ethereum Mainnet }); - const signature = await signer.signMessage(challenge.challenge); + const signature = await signer.signMessage({ message: challenge.challenge }); const response = await justaname.subnames.addSubname( { @@ -121,7 +121,7 @@ async function updateSubname() { chainId: 1 }); - const signature = await signer.signMessage(challenge.challenge); + const signature = await signer.signMessage({ message: challenge.challenge }); const response = await justaname.subnames.updateSubname( { @@ -155,7 +155,7 @@ async function signIn() { address: signer.address }); - const signature = await signer.signMessage(message); + const signature = await signer.signMessage({ message }); const response = await justaname.signIn.signIn({ message: message, diff --git a/packages/@justaname.id/sdk/package.json b/packages/@justaname.id/sdk/package.json index 721d209b..6a08ede3 100644 --- a/packages/@justaname.id/sdk/package.json +++ b/packages/@justaname.id/sdk/package.json @@ -10,7 +10,6 @@ "jest": "^29.4.1" }, "peerDependencies": { - "siwe": ">=2.0.0", "viem": "^2.48.0" }, "exports": { diff --git a/packages/@justaname.id/sdk/src/lib/features/sign-in/index.ts b/packages/@justaname.id/sdk/src/lib/features/sign-in/index.ts index c7619201..7f7ce548 100644 --- a/packages/@justaname.id/sdk/src/lib/features/sign-in/index.ts +++ b/packages/@justaname.id/sdk/src/lib/features/sign-in/index.ts @@ -8,8 +8,6 @@ import { } from '../../errors'; import { OffchainResolvers } from '../offchain-resolvers'; import { RequestSignInParams, SignInFunctionParams } from '../../types/signin'; -import { createPublicClient, http } from 'viem'; -import { mainnet, sepolia } from 'viem/chains'; import { normalize } from 'viem/ens'; export interface SignInResponse extends SiwensResponse { @@ -110,62 +108,13 @@ export class SignIn { providerUrl: network.providerUrl, }); - const siwensResponse = await siwens.verify( - { - signature: params.signature, - nonce: params.nonce, - domain: params.domain, - }, - { - // Smart-contract (EIP-1271) verification is handled inside - // `verificationFallback` below using viem's `verifySiweMessage`. - // We no longer pass `provider` here because it must be an ethers - // `Provider`, and the SDK is now viem-only. - verificationFallback: async (params, opts, message, EIP1271Promise) => { - // Use the chainId extracted from the SIWE message itself, not the - // SDK-default. Otherwise contract-wallet (EIP-1271) verification - // runs against the wrong chain when the message is cross-chain. - const publicClient = createPublicClient({ - chain: chainId === 1 ? mainnet : sepolia, - transport: http(network.providerUrl), - }); - - const result = await EIP1271Promise; - - if (result.success) { - return result; - } else { - let signature = params.signature; - const lastByte = parseInt(params.signature.slice(-2), 16); - if (lastByte < 27) { - const adjustedV = (27 + (lastByte % 2)) - .toString(16) - .padStart(2, '0'); - signature = signature.slice(0, -2) + adjustedV; - } - - const viemResponse = await publicClient.verifySiweMessage({ - message: message.toMessage(), - signature: signature as `0x${string}`, - address: result.data.address as `0x${string}`, - nonce: params.nonce, - domain: params.domain as string, - time: params.time ? new Date(params.time) : undefined, - scheme: params.scheme as string, - }); - - if (viemResponse) { - return { - data: result.data, - success: true, - }; - } - - return result; - } - }, - } - ); + // SIWENS.verify performs EOA recovery, EIP-1271 and ERC-6492 verification + // internally via viem (`verifySiweMessage`) against the message's chain. + const siwensResponse = await siwens.verify({ + signature: params.signature, + nonce: params.nonce, + domain: params.domain, + }); if (siwensResponse.data.chainId !== chainId) { throw InvalidSignInException.chainIdMismatch( diff --git a/packages/@justaname.id/sdk/src/lib/features/subname-challenge/index.ts b/packages/@justaname.id/sdk/src/lib/features/subname-challenge/index.ts index 5d065d22..e8fea9e5 100644 --- a/packages/@justaname.id/sdk/src/lib/features/subname-challenge/index.ts +++ b/packages/@justaname.id/sdk/src/lib/features/subname-challenge/index.ts @@ -6,7 +6,9 @@ import { } from '../../types'; import { SiweConfig } from '../../types/siwe/siwe-config'; import { ChallengeRequestException } from '../../errors/ChallengeRequest.expection'; -import { SiweMessage } from 'siwe'; +import { generateNonce } from '@justaname.id/siwens'; +import { getAddress } from 'viem'; +import { createSiweMessage } from 'viem/siwe'; /** * Represents the Sign-In with Ethereum (SIWE) functionality, providing methods @@ -104,19 +106,18 @@ export class SubnameChallenge { const { expirationTime, issuedAt } = this.generateIssuedAndExpirationTime(_ttl); - const siweMessage = new SiweMessage({ + const prepared = createSiweMessage({ domain: _domain, uri: _origin, - address: _address, + address: getAddress(_address), statement: statement, chainId: _chainId, version: '1', - issuedAt, - expirationTime, + nonce: generateNonce(), + issuedAt: new Date(issuedAt), + expirationTime: new Date(expirationTime), }); - const prepared = siweMessage.prepareMessage(); - if (this.dev) { // eslint-disable-next-line no-console console.debug( diff --git a/packages/@justaname.id/siwens/README.md b/packages/@justaname.id/siwens/README.md index 727d6e1b..3fc11f3c 100644 --- a/packages/@justaname.id/siwens/README.md +++ b/packages/@justaname.id/siwens/README.md @@ -44,14 +44,13 @@ yarn add @justaname.id/siwens ### Example Usage ```typescript import { SIWENS, InvalidENSException } from '@justaname.id/siwens'; -import { Wallet } from 'ethers'; +import { privateKeyToAccount } from 'viem/accounts'; // Define your provider URL (e.g., Infura) const infuraProjectId = 'YOUR_INFURA_PROJECT_ID'; const providerUrl = 'https://mainnet.infura.io/v3/' + infuraProjectId; -// const signer = Wallet.createRandom(); -const signer = new Wallet('YOUR_PRIVATE_KEY'); +const signer = privateKeyToAccount('YOUR_PRIVATE_KEY'); async function signInUser() { const siwens = new SIWENS({ @@ -67,7 +66,7 @@ async function signInUser() { providerUrl }); const message = await siwens.prepareMessage(); - const signature = await signer.signMessage(message); + const signature = await signer.signMessage({ message }); return {signature, message}; } diff --git a/packages/@justaname.id/siwens/package.json b/packages/@justaname.id/siwens/package.json index 1125c483..9cbd9d91 100644 --- a/packages/@justaname.id/siwens/package.json +++ b/packages/@justaname.id/siwens/package.json @@ -2,10 +2,10 @@ "name": "@justaname.id/siwens", "version": "0.0.145", "dependencies": { + "@stablelib/random": "^1.0.2", "punycode": "^2.3.1" }, "peerDependencies": { - "siwe": ">=2.0.0", "viem": "^2.48.0" }, "exports": { diff --git a/packages/@justaname.id/siwens/src/lib/index.ts b/packages/@justaname.id/siwens/src/lib/index.ts index 10ca5f1f..652b428b 100644 --- a/packages/@justaname.id/siwens/src/lib/index.ts +++ b/packages/@justaname.id/siwens/src/lib/index.ts @@ -1,3 +1,4 @@ export * from './errors'; +export * from './types'; export * from './siwens/siwens'; export * from './utils'; \ No newline at end of file diff --git a/packages/@justaname.id/siwens/src/lib/siwens/siwens.ts b/packages/@justaname.id/siwens/src/lib/siwens/siwens.ts index 735df385..e97ec5d3 100644 --- a/packages/@justaname.id/siwens/src/lib/siwens/siwens.ts +++ b/packages/@justaname.id/siwens/src/lib/siwens/siwens.ts @@ -1,10 +1,3 @@ -import { - generateNonce, - SiweMessage, - SiweResponse, - VerifyOpts, - VerifyParams, -} from 'siwe'; import { InvalidConfigurationException, InvalidENSException, @@ -16,7 +9,16 @@ import { checkTTL, constructSignInStatement, extractDataFromStatement, + generateNonce, } from '../utils'; +import { + SiweError, + SiweErrorType, + SiweMessageFields, + SiweResponse, + VerifyOpts, + VerifyParams, +} from '../types'; import { toASCII, toUnicode } from 'punycode'; import { createPublicClient, @@ -28,6 +30,11 @@ import { import { mainnet, sepolia } from 'viem/chains'; import type { Chain } from 'viem'; import { normalize } from 'viem/ens'; +import { + createSiweMessage, + parseSiweMessage, + verifySiweMessage, +} from 'viem/siwe'; const SUPPORTED_CHAINS: Record = { 1: mainnet, @@ -43,14 +50,18 @@ const buildPublicClient = ( transport: http(providerUrl), }); +const toISOStringOrUndefined = (value?: string | Date): string | undefined => { + if (!value) { + return undefined; + } + return value instanceof Date ? value.toISOString() : value; +}; + export interface SiwensResponse extends SiweResponse { ens: string; } -export interface SiwensParams - extends Partial< - Omit - > { +export interface SiwensParams extends Partial { ens: string; ttl?: number; expirationTime?: string; @@ -62,19 +73,57 @@ export interface SiwensConfig { providerUrl?: string; } -export class SIWENS extends SiweMessage { +/** + * Sign-In with ENS message. Previously this extended `siwe`'s `SiweMessage`; + * it is now a standalone, ethers-free implementation backed by viem's native + * SIWE module (`viem/siwe`). The public surface (fields, `prepareMessage`, + * `verify`, `generateNonce`) is preserved. + */ +export class SIWENS { + readonly scheme?: string; + readonly domain: string; + readonly address: string; + readonly statement?: string; + readonly uri: string; + readonly version: string; + readonly chainId: number; + readonly nonce: string; + readonly issuedAt?: string; + readonly expirationTime?: string; + readonly notBefore?: string; + readonly requestId?: string; + readonly resources?: string[]; readonly provider: PublicClient; readonly providerUrl: string | undefined; + /** The raw EIP-4361 message string (parsed input, or the built message). */ + private readonly message: string; constructor(signInConfig: SiwensConfig) { const { params, providerUrl } = signInConfig; + if (typeof params === 'string') { - super(params); if (!providerUrl) { throw InvalidConfigurationException.providerUrlRequired(); } - this.provider = buildPublicClient(providerUrl, this.chainId); + const parsed = parseSiweMessage(params); + this.scheme = parsed.scheme; + this.domain = parsed.domain as string; + // Normalize to EIP-55 checksum so `data.address` matches the casing that + // `siwe` always returned (it rejected non-checksummed addresses). + this.address = viemGetAddress(parsed.address as string); + this.statement = parsed.statement; + this.uri = parsed.uri as string; + this.version = (parsed.version as string) || '1'; + this.chainId = (parsed.chainId as number) ?? 1; + this.nonce = parsed.nonce as string; + this.issuedAt = toISOStringOrUndefined(parsed.issuedAt); + this.expirationTime = toISOStringOrUndefined(parsed.expirationTime); + this.notBefore = toISOStringOrUndefined(parsed.notBefore); + this.requestId = parsed.requestId; + this.resources = parsed.resources; + this.message = params; this.providerUrl = providerUrl; + this.provider = buildPublicClient(providerUrl, this.chainId); return; } @@ -86,18 +135,11 @@ export class SIWENS extends SiweMessage { throw InvalidConfigurationException.domainRequired(); } - let issuedAt = params.issuedAt; - let expirationTime = params.expirationTime; - - if (params.ttl) { - checkTTL(params.ttl); - const { - issuedAt: issuedAtGenerated, - expirationTime: expirationTimeGenerated, - } = SIWENS.generateIssuedAndExpirationTime(params.ttl); - issuedAt = issuedAt || issuedAtGenerated; - expirationTime = expirationTime || expirationTimeGenerated; - } + checkTTL(params.ttl); + const { + issuedAt: issuedAtGenerated, + expirationTime: expirationTimeGenerated, + } = SIWENS.generateIssuedAndExpirationTime(params.ttl); checkDomainValid(params.ens); @@ -106,46 +148,140 @@ export class SIWENS extends SiweMessage { params?.statement || '' ); - super({ - ...params, - statement, - version: params.version || '1', - issuedAt, - expirationTime, - }); + this.scheme = params.scheme; + this.domain = params.domain; + this.address = viemGetAddress(params.address as string); + this.statement = statement; + this.uri = params.uri as string; + this.version = params.version || '1'; + this.chainId = (params.chainId as number) ?? 1; + this.nonce = params.nonce || generateNonce(); + this.issuedAt = params.issuedAt || issuedAtGenerated; + this.expirationTime = params.expirationTime || expirationTimeGenerated; + this.notBefore = params.notBefore; + this.requestId = params.requestId; + this.resources = params.resources; this.providerUrl = providerUrl; this.provider = buildPublicClient(providerUrl, this.chainId); + this.message = this.buildMessage(); + } + + toMessage(): string { + return this.message; + } + + prepareMessage(): string { + return this.message; } - override async verify( + async verify( params: VerifyParams, opts?: VerifyOpts ): Promise { - let verification: SiweResponse; + const suppress = opts?.suppressExceptions ?? false; + const data = this.toFields(); - try { - const { signature, ...rest } = params; - const _tempParams = { - signature, - ...rest, - }; - const lastByte = parseInt(signature.slice(-2), 16); - if (lastByte < 27) { - const adjustedV = (27 + (lastByte % 2)).toString(16).padStart(2, '0'); - _tempParams['signature'] = signature.slice(0, -2) + adjustedV; + const computeEns = (): string | undefined => { + try { + return this.statement + ? toUnicode(extractDataFromStatement(this.statement).ens) + : undefined; + } catch { + return undefined; } + }; - verification = await super.verify(_tempParams, opts); - } catch (e) { - const statement = e.data.statement; - const { ens } = extractDataFromStatement(statement); - throw { - ...e, - ens: toUnicode(ens), + const fail = (error: SiweError): SiwensResponse => { + const result: SiwensResponse = { + success: false, + data, + error, + ens: computeEns() as string, }; + if (suppress) { + return result; + } + throw result; + }; + + // Normalize legacy `v` values (< 27) to canonical 27/28 before verifying. + let signature = params.signature; + const lastByte = parseInt(signature.slice(-2), 16); + if (lastByte < 27) { + const adjustedV = (27 + (lastByte % 2)).toString(16).padStart(2, '0'); + signature = signature.slice(0, -2) + adjustedV; } - const statement = verification.data.statement; + // Field validation — mirrors `siwe`'s order and error types so the thrown + // shape is unchanged for consumers. + if (params.scheme && params.scheme !== this.scheme) { + return fail( + new SiweError(SiweErrorType.SCHEME_MISMATCH, params.scheme, this.scheme) + ); + } + if (params.domain && params.domain !== this.domain) { + return fail( + new SiweError(SiweErrorType.DOMAIN_MISMATCH, params.domain, this.domain) + ); + } + if (params.nonce && params.nonce !== this.nonce) { + return fail( + new SiweError(SiweErrorType.NONCE_MISMATCH, params.nonce, this.nonce) + ); + } + + const checkTime = new Date(params.time || new Date()); + if (this.expirationTime) { + const expirationDate = new Date(this.expirationTime); + if (checkTime.getTime() >= expirationDate.getTime()) { + return fail( + new SiweError( + SiweErrorType.EXPIRED_MESSAGE, + `${checkTime.toISOString()} < ${expirationDate.toISOString()}`, + `${checkTime.toISOString()} >= ${expirationDate.toISOString()}` + ) + ); + } + } + if (this.notBefore) { + const notBefore = new Date(this.notBefore); + if (checkTime.getTime() < notBefore.getTime()) { + return fail( + new SiweError( + SiweErrorType.NOT_YET_VALID_MESSAGE, + `${checkTime.toISOString()} >= ${notBefore.toISOString()}`, + `${checkTime.toISOString()} < ${notBefore.toISOString()}` + ) + ); + } + } + + // Signature verification — EOA recovery + EIP-1271 + ERC-6492 in a single + // viem call against the configured public client. A genuine signature + // mismatch resolves to `false`; operational errors (RPC/transport failures) + // are intentionally left to propagate rather than be masked as an invalid + // signature, so contract-wallet checks on a flaky RPC surface a real error. + const valid = await verifySiweMessage(this.provider, { + message: this.message, + signature: signature as `0x${string}`, + address: this.address as `0x${string}`, + ...(params.domain ? { domain: params.domain } : {}), + ...(params.nonce ? { nonce: params.nonce } : {}), + ...(params.scheme ? { scheme: params.scheme } : {}), + time: checkTime, + }); + + if (!valid) { + return fail( + new SiweError( + SiweErrorType.INVALID_SIGNATURE, + undefined, + `Resolved address to be ${this.address}` + ) + ); + } + + const statement = this.statement; if (!statement) { throw InvalidStatementException.invalidStatement(); } @@ -154,7 +290,8 @@ export class SIWENS extends SiweMessage { await this.verifyEnsAddress(ens, this.address); return { - ...verification, + success: true, + data, ens, }; } @@ -169,10 +306,48 @@ export class SIWENS extends SiweMessage { }; } - static generateNonce() { + static generateNonce(): string { return generateNonce(); } + private toFields(): SiweMessageFields { + return { + scheme: this.scheme, + domain: this.domain, + address: this.address, + statement: this.statement, + uri: this.uri, + version: this.version, + chainId: this.chainId, + nonce: this.nonce, + issuedAt: this.issuedAt, + expirationTime: this.expirationTime, + notBefore: this.notBefore, + requestId: this.requestId, + resources: this.resources, + }; + } + + private buildMessage(): string { + return createSiweMessage({ + ...(this.scheme ? { scheme: this.scheme } : {}), + domain: this.domain, + address: viemGetAddress(this.address), + ...(this.statement ? { statement: this.statement } : {}), + uri: this.uri, + version: this.version as '1', + chainId: this.chainId, + nonce: this.nonce, + ...(this.issuedAt ? { issuedAt: new Date(this.issuedAt) } : {}), + ...(this.expirationTime + ? { expirationTime: new Date(this.expirationTime) } + : {}), + ...(this.notBefore ? { notBefore: new Date(this.notBefore) } : {}), + ...(this.requestId ? { requestId: this.requestId } : {}), + ...(this.resources ? { resources: this.resources } : {}), + }); + } + private async verifyEnsAddress(ens: string, address: string) { const resolvedAddress = await this.provider.getEnsAddress({ name: normalize(ens), diff --git a/packages/@justaname.id/siwens/src/lib/types/index.ts b/packages/@justaname.id/siwens/src/lib/types/index.ts new file mode 100644 index 00000000..97f5bb99 --- /dev/null +++ b/packages/@justaname.id/siwens/src/lib/types/index.ts @@ -0,0 +1,88 @@ +/** + * Local, ethers-free replacements for the SIWE types that used to be imported + * from the `siwe` package. Keeping the same shapes (and the same `SiweError` + * `type` strings) preserves the public API and the error contract that the + * SDK's sign-in flow and downstream consumers depend on. + */ + +/** EIP-4361 message fields, mirroring the public surface of `siwe`'s SiweMessage. */ +export interface SiweMessageFields { + scheme?: string; + domain: string; + address: string; + statement?: string; + uri: string; + version: string; + chainId: number; + nonce: string; + issuedAt?: string; + expirationTime?: string; + notBefore?: string; + requestId?: string; + resources?: string[]; +} + +/** Result returned (or thrown) by a verification. */ +export interface SiweResponse { + success: boolean; + data: SiweMessageFields; + error?: SiweError; +} + +/** Parameters accepted by `SIWENS.verify`. */ +export interface VerifyParams { + signature: string; + scheme?: string; + domain?: string; + nonce?: string; + time?: string; +} + +/** Options accepted by `SIWENS.verify`. */ +export interface VerifyOpts { + suppressExceptions?: boolean; +} + +/** + * Mirrors `siwe`'s SiweError so thrown/returned error shapes are unchanged. + */ +export class SiweError { + constructor( + public type: SiweErrorType, + public expected?: string, + public received?: string + ) {} +} + +/** + * Possible message error types. Values are copied verbatim from `siwe` so any + * consumer matching on the message string keeps working. + */ +export enum SiweErrorType { + /** `expirationTime` is present and in the past. */ + EXPIRED_MESSAGE = 'Expired message.', + /** `domain` is not a valid authority or is empty. */ + INVALID_DOMAIN = 'Invalid domain.', + /** `scheme` don't match the scheme provided for verification. */ + SCHEME_MISMATCH = 'Scheme does not match provided scheme for verification.', + /** `domain` don't match the domain provided for verification. */ + DOMAIN_MISMATCH = 'Domain does not match provided domain for verification.', + /** `nonce` don't match the nonce provided for verification. */ + NONCE_MISMATCH = 'Nonce does not match provided nonce for verification.', + /** `address` does not conform to EIP-55 or is not a valid address. */ + INVALID_ADDRESS = 'Invalid address.', + /** `uri` does not conform to RFC 3986. */ + INVALID_URI = 'URI does not conform to RFC 3986.', + /** `nonce` is smaller then 8 characters or is not alphanumeric */ + INVALID_NONCE = 'Nonce size smaller then 8 characters or is not alphanumeric.', + /** `notBefore` is present and in the future. */ + NOT_YET_VALID_MESSAGE = 'Message is not valid yet.', + /** Signature doesn't match the address of the message. */ + INVALID_SIGNATURE = 'Signature does not match address of the message.', + /** `expirationTime`, `notBefore` or `issuedAt` not complient to ISO-8601. */ + INVALID_TIME_FORMAT = 'Invalid time format.', + /** `version` is not 1. */ + INVALID_MESSAGE_VERSION = 'Invalid message version.', + /** Thrown when some required field is missing. */ + UNABLE_TO_PARSE = 'Unable to parse the message.', +} diff --git a/packages/@justaname.id/siwens/src/lib/utils/generateNonce/index.ts b/packages/@justaname.id/siwens/src/lib/utils/generateNonce/index.ts new file mode 100644 index 00000000..dcc32914 --- /dev/null +++ b/packages/@justaname.id/siwens/src/lib/utils/generateNonce/index.ts @@ -0,0 +1,19 @@ +import { randomStringForEntropy } from '@stablelib/random'; + +/** + * Generates a cryptographically-secure, EIP-4361-compliant nonce. + * + * This mirrors `siwe`'s `generateNonce` (96 bits of entropy via a CSPRNG) so we + * keep identical nonce strength/format after dropping the `siwe` dependency. + * Intentionally NOT viem's `generateSiweNonce`, which is backed by `Math.random` + * and would be a security regression. + * + * @returns {string} A randomly generated alphanumeric nonce. + */ +export function generateNonce(): string { + const nonce = randomStringForEntropy(96); + if (!nonce || nonce.length < 8) { + throw new Error('Error during nonce creation.'); + } + return nonce; +} diff --git a/packages/@justaname.id/siwens/src/lib/utils/index.ts b/packages/@justaname.id/siwens/src/lib/utils/index.ts index 4f35d9ee..b20631c5 100644 --- a/packages/@justaname.id/siwens/src/lib/utils/index.ts +++ b/packages/@justaname.id/siwens/src/lib/utils/index.ts @@ -1,3 +1,4 @@ export * from './checkTTL' export * from './checkDomainValid' -export * from './signInStatementHelpers' \ No newline at end of file +export * from './signInStatementHelpers' +export * from './generateNonce' \ No newline at end of file diff --git a/packages/@justaname.id/siwens/src/test/siwens.format.spec.ts b/packages/@justaname.id/siwens/src/test/siwens.format.spec.ts new file mode 100644 index 00000000..16e1cb68 --- /dev/null +++ b/packages/@justaname.id/siwens/src/test/siwens.format.spec.ts @@ -0,0 +1,119 @@ +import { SIWENS, SiweErrorType } from '../'; + +/** + * CI-safe tests (no RPC required). These lock the EIP-4361 message format to be + * byte-identical to what `siwe` produced before the viem migration, and verify + * that field-mismatch checks throw the same `SiweError` types. Signature + * verification (which needs a provider) is covered by the integration tests in + * siwens.spec.ts. + */ + +const ADDRESS = '0x59c44836630760F97b74b569B379ca94c37B93ca'; +const DUMMY_SIGNATURE = '0x' + '00'.repeat(65); + +// Golden string captured from `siwe`'s SiweMessage.prepareMessage() for the +// SIWENS object-construction inputs below (statement from `alice.eth`). +const GOLDEN_MESSAGE = `localhost wants you to sign in with your Ethereum account: +0x59c44836630760F97b74b569B379ca94c37B93ca + +I am signing in with my ENS: alice.eth + +URI: http://localhost:3333 +Version: 1 +Chain ID: 1 +Nonce: abcdef1234567890 +Issued At: 2024-01-01T00:00:00.000Z +Expiration Time: 2024-01-01T00:01:00.000Z`; + +const baseParams = { + domain: 'localhost', + address: ADDRESS, + uri: 'http://localhost:3333', + version: '1', + nonce: 'abcdef1234567890', + chainId: 1, + ttl: 60 * 1000, + ens: 'alice.eth', + issuedAt: '2024-01-01T00:00:00.000Z', + expirationTime: '2024-01-01T00:01:00.000Z', +}; + +describe('SIWENS message format (golden)', () => { + it('builds a byte-identical EIP-4361 message', () => { + const siwens = new SIWENS({ params: { ...baseParams } }); + expect(siwens.prepareMessage()).toBe(GOLDEN_MESSAGE); + expect(siwens.toMessage()).toBe(GOLDEN_MESSAGE); + }); + + it('round-trips when re-parsed from the string form', () => { + const message = new SIWENS({ params: { ...baseParams } }).prepareMessage(); + const reparsed = new SIWENS({ + params: message, + providerUrl: 'http://127.0.0.1:1', + }); + expect(reparsed.address).toBe(ADDRESS); + expect(reparsed.chainId).toBe(1); + expect(reparsed.domain).toBe('localhost'); + expect(reparsed.nonce).toBe('abcdef1234567890'); + expect(reparsed.statement).toBe('I am signing in with my ENS: alice.eth'); + }); +}); + +describe('SIWENS.verify field validation (no RPC)', () => { + it('throws DOMAIN_MISMATCH with the preserved error shape', async () => { + const siwens = new SIWENS({ params: { ...baseParams } }); + await expect( + siwens.verify({ signature: DUMMY_SIGNATURE, domain: 'evil.com' }) + ).rejects.toMatchObject({ + success: false, + error: { type: SiweErrorType.DOMAIN_MISMATCH }, + ens: 'alice.eth', + }); + }); + + it('throws NONCE_MISMATCH', async () => { + const siwens = new SIWENS({ params: { ...baseParams } }); + await expect( + siwens.verify({ signature: DUMMY_SIGNATURE, nonce: 'someOtherNonce123' }) + ).rejects.toMatchObject({ + error: { type: SiweErrorType.NONCE_MISMATCH }, + }); + }); + + it('throws EXPIRED_MESSAGE when the message is past expiry', async () => { + const siwens = new SIWENS({ + params: { + ...baseParams, + issuedAt: '2020-01-01T00:00:00.000Z', + expirationTime: '2020-01-01T00:01:00.000Z', + }, + }); + await expect( + siwens.verify({ signature: DUMMY_SIGNATURE }) + ).rejects.toMatchObject({ + error: { type: SiweErrorType.EXPIRED_MESSAGE }, + }); + }); + + it('returns a failure result instead of throwing when suppressExceptions is set', async () => { + const siwens = new SIWENS({ params: { ...baseParams } }); + const result = await siwens.verify( + { signature: DUMMY_SIGNATURE, domain: 'evil.com' }, + { suppressExceptions: true } + ); + expect(result.success).toBe(false); + expect(result.error?.type).toBe(SiweErrorType.DOMAIN_MISMATCH); + expect(result.ens).toBe('alice.eth'); + }); +}); + +describe('generateNonce', () => { + it('produces alphanumeric nonces of sufficient length', () => { + const nonce = SIWENS.generateNonce(); + expect(nonce).toMatch(/^[a-zA-Z0-9]{8,}$/); + }); + + it('produces a different nonce each call', () => { + expect(SIWENS.generateNonce()).not.toBe(SIWENS.generateNonce()); + }); +}); diff --git a/yarn.lock b/yarn.lock index d9a439d4..92773d43 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4487,7 +4487,6 @@ __metadata: jest: "npm:^29.4.1" qs: "npm:6.12.0" peerDependencies: - siwe: ">=2.0.0" viem: ^2.48.0 languageName: unknown linkType: soft @@ -4496,9 +4495,9 @@ __metadata: version: 0.0.0-use.local resolution: "@justaname.id/siwens@workspace:packages/@justaname.id/siwens" dependencies: + "@stablelib/random": "npm:^1.0.2" punycode: "npm:^2.3.1" peerDependencies: - siwe: ">=2.0.0" viem: ^2.48.0 languageName: unknown linkType: soft @@ -5640,7 +5639,7 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:1.7.1, @noble/hashes@npm:^1.1.2, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:^1.3.2, @noble/hashes@npm:^1.4.0, @noble/hashes@npm:^1.5.0, @noble/hashes@npm:~1.7.1": +"@noble/hashes@npm:1.7.1, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:^1.3.2, @noble/hashes@npm:^1.4.0, @noble/hashes@npm:^1.5.0, @noble/hashes@npm:~1.7.1": version: 1.7.1 resolution: "@noble/hashes@npm:1.7.1" checksum: 10c0/2f8ec0338ccc92b576a0f5c16ab9c017a3a494062f1fbb569ae641c5e7eab32072f9081acaa96b5048c0898f972916c818ea63cbedda707886a4b5ffcfbf94e3 @@ -9277,18 +9276,6 @@ __metadata: languageName: node linkType: hard -"@spruceid/siwe-parser@npm:^2.1.2": - version: 2.1.2 - resolution: "@spruceid/siwe-parser@npm:2.1.2" - dependencies: - "@noble/hashes": "npm:^1.1.2" - apg-js: "npm:^4.3.0" - uri-js: "npm:^4.4.1" - valid-url: "npm:^1.0.9" - checksum: 10c0/79005ae8978b9dd0c1ece949dbc2294d6a641db757c14ae0864b6803358cc498bac882d8031e656b4dbf3e838be043ce6517c857b6e2df26a1e8922baeb2c07d - languageName: node - linkType: hard - "@stablelib/binary@npm:^1.0.1": version: 1.0.1 resolution: "@stablelib/binary@npm:1.0.1" @@ -9305,7 +9292,7 @@ __metadata: languageName: node linkType: hard -"@stablelib/random@npm:^1.0.1": +"@stablelib/random@npm:^1.0.2": version: 1.0.2 resolution: "@stablelib/random@npm:1.0.2" dependencies: @@ -14270,13 +14257,6 @@ __metadata: languageName: node linkType: hard -"apg-js@npm:^4.3.0": - version: 4.4.0 - resolution: "apg-js@npm:4.4.0" - checksum: 10c0/b3e60e2ba8b25fe1c9fcc648f43b98f02f0eff3bbd593fd2866302fe57b1b7840ee9be894ebed6214876a6feecd543cc717d7b68351bf2df831db110ae01e6bb - languageName: node - linkType: hard - "app-root-dir@npm:^1.0.2": version: 1.0.2 resolution: "app-root-dir@npm:1.0.2" @@ -25427,7 +25407,6 @@ __metadata: rollup-plugin-tailwindcss: "npm:1.0.0" rollup-plugin-typescript2: "npm:0.36.0" rollup-preserve-directives: "npm:1.1.1" - siwe: "npm:2.3.2" storybook: "npm:8.2.8" tailwind-merge: "npm:2.5.2" tailwindcss: "npm:3.4.3" @@ -33040,20 +33019,6 @@ __metadata: languageName: node linkType: hard -"siwe@npm:2.3.2": - version: 2.3.2 - resolution: "siwe@npm:2.3.2" - dependencies: - "@spruceid/siwe-parser": "npm:^2.1.2" - "@stablelib/random": "npm:^1.0.1" - uri-js: "npm:^4.4.1" - valid-url: "npm:^1.0.9" - peerDependencies: - ethers: ^5.6.8 || ^6.0.8 - checksum: 10c0/05ee09cdabef72a8ec54ffe24e517c386eb49bb6385ffc7ec159e266b3661a98405ca88b8e75278b376754e943a5118790c2add98b5012e1c4ec13bce4e6ee03 - languageName: node - linkType: hard - "slash@npm:3.0.0, slash@npm:^3.0.0": version: 3.0.0 resolution: "slash@npm:3.0.0" @@ -35798,7 +35763,7 @@ __metadata: languageName: node linkType: hard -"uri-js@npm:^4.2.2, uri-js@npm:^4.4.1": +"uri-js@npm:^4.2.2": version: 4.4.1 resolution: "uri-js@npm:4.4.1" dependencies: @@ -35998,13 +35963,6 @@ __metadata: languageName: node linkType: hard -"valid-url@npm:^1.0.9": - version: 1.0.9 - resolution: "valid-url@npm:1.0.9" - checksum: 10c0/3995e65f9942dbcb1621754c0f9790335cec61e9e9310c0a809e9ae0e2ae91bb7fc6a471fba788e979db0418d9806639f681ecebacc869bc8c3de88efa562ee6 - languageName: node - linkType: hard - "validate-npm-package-license@npm:^3.0.1, validate-npm-package-license@npm:^3.0.4": version: 3.0.4 resolution: "validate-npm-package-license@npm:3.0.4"