import nacl from "tweetnacl"; import { ProofStatus, SignatureProof, type SIWXInput, type SolanaMetadata } from "@notabene/javascript-sdk"; import { base64, base58 } from "@scure/base"; interface ParsedSIWSMessage { domain: string; address: string; statement?: string; uri?: string; version?: string; chainId?: string; nonce?: string; issuedAt?: string; expirationTime?: string; notBefore?: string; requestId?: string; resources?: string[]; } /** * Verifies a Solana signature proof. * * This function can verify two types of Solana signatures: * 1. Standard Solana signatures * * @param proof - The signature proof containing the address, attestation, and signature * @returns Promise that resolves to a SignatureProof with updated status (VERIFIED or FAILED) * * @example * // Standard Solana signature verification * const result = await verifySolanaSignature(proof); * */ export async function verifySolanaSignature( proof: SignatureProof, ): Promise { const [ns, , address] = proof.address.split(/:/); if (ns !== "solana") return { ...proof, status: ProofStatus.FAILED }; try { const publicKey = base58.decode(address); const messageBytes = new TextEncoder().encode(proof.attestation); const signatureBytes = base64.decode(proof.proof); const verified = nacl.sign.detached.verify( messageBytes, signatureBytes, publicKey, ); return { ...proof, status: verified ? ProofStatus.VERIFIED : ProofStatus.FAILED, }; } catch { return { ...proof, status: ProofStatus.FAILED }; } } function isSolanaSignInInput(obj: unknown): obj is SIWXInput { if (!obj || typeof obj !== 'object') return false; const input = obj as Record; // Check for required properties return ( typeof input.domain === 'string' && typeof input.address === 'string' && (input.statement === undefined || typeof input.statement === 'string') && (input.uri === undefined || typeof input.uri === 'string') && (input.version === undefined || typeof input.version === 'string') && (input.chainId === undefined || typeof input.chainId === 'string') && (input.nonce === undefined || typeof input.nonce === 'string') && (input.issuedAt === undefined || typeof input.issuedAt === 'string') && (input.expirationTime === undefined || typeof input.expirationTime === 'string') && (input.notBefore === undefined || typeof input.notBefore === 'string') && (input.requestId === undefined || typeof input.requestId === 'string') && (input.resources === undefined || Array.isArray(input.resources)) ); } function isSolanaSignInMetadata(obj: unknown): obj is SolanaMetadata { if (!obj || typeof obj !== 'object') return false; const metadata = obj as Record; // Check account object if (!metadata.account || typeof metadata.account !== 'object') return false; const account = metadata.account as Record; if (typeof account.address !== 'string') return false; // Handle publicKey - could be Uint8Array or serialized object with numeric keys if (!account.publicKey) return false; if (!(account.publicKey instanceof Uint8Array)) { // Try to convert from serialized format const pkObj = account.publicKey as Record; if (typeof pkObj === 'object') { // Convert object with numeric keys to Uint8Array const keys = Object.keys(pkObj).filter(key => !isNaN(Number(key))).sort((a, b) => Number(a) - Number(b)); if (keys.length === 32) { // Solana public keys are 32 bytes const bytes = keys.map(key => Number(pkObj[key])); if (bytes.every(b => typeof b === 'number' && b >= 0 && b <= 255)) { account.publicKey = new Uint8Array(bytes); } else { return false; } } else { return false; } } else { return false; } } // Handle signedMessage - could be Uint8Array or Buffer-like object if (!metadata.signedMessage) return false; if (!(metadata.signedMessage instanceof Uint8Array)) { const smObj = metadata.signedMessage as Record; if (smObj.type === 'Buffer' && Array.isArray(smObj.data)) { metadata.signedMessage = new Uint8Array(smObj.data as number[]); } else { return false; } } // Handle signature - could be Uint8Array or Buffer-like object if (!metadata.signature) return false; if (!(metadata.signature instanceof Uint8Array)) { const sigObj = metadata.signature as Record; if (sigObj.type === 'Buffer' && Array.isArray(sigObj.data)) { metadata.signature = new Uint8Array(sigObj.data as number[]); } else { return false; } } // Check message field contains valid SolanaSignInInput if (!metadata.message || typeof metadata.message !== 'object') { return false; } const message = metadata.message as unknown; // If address is missing from message, try to extract it from signedMessage if (typeof message === 'object' && message !== null) { const messageObj = message as Record; if (!messageObj.address && metadata.signedMessage instanceof Uint8Array) { try { const signedMessageText = new TextDecoder().decode(metadata.signedMessage); const lines = signedMessageText.split('\n'); if (lines.length >= 2) { const address = lines[1].trim(); if (address && /^[a-zA-Z0-9]{32,44}$/.test(address)) { messageObj.address = address; } } } catch { // Ignore errors in address extraction } } } if (!isSolanaSignInInput(metadata.message)) { return false; } return true; } export async function verifySolanaSIWS( proof: SignatureProof, ): Promise { const [ns] = proof.address.split(/:/); if (ns !== "solana") { return { ...proof, status: ProofStatus.FAILED }; } // Validate that metadata conforms to SolanaSignInMetadata if (!proof.chainSpecificData || !isSolanaSignInMetadata(proof.chainSpecificData)) { return { ...proof, status: ProofStatus.FAILED }; } try { // Now we can safely cast to SolanaMetadata since we validated it const metadata = proof.chainSpecificData as SolanaMetadata; const signedMessageText = new TextDecoder().decode(metadata.signedMessage); const parsedMessage = parseSIWSMessage(signedMessageText); if (!parsedMessage) { return { ...proof, status: ProofStatus.FAILED }; } // Validate the parsed message against the input if (!validateSIWSMessage(parsedMessage, metadata.message as SIWXInput)) { return { ...proof, status: ProofStatus.FAILED }; } // Reconstruct the message to ensure it matches the signed message const reconstructedMessage = createSIWSMessage(parsedMessage); if (reconstructedMessage !== signedMessageText) { return { ...proof, status: ProofStatus.FAILED }; } // Verify the signature against the message const verified = nacl.sign.detached.verify( metadata.signedMessage, metadata.signature, metadata.account.publicKey as Uint8Array ); return { ...proof, status: verified ? ProofStatus.VERIFIED : ProofStatus.FAILED, }; } catch { return { ...proof, status: ProofStatus.FAILED }; } } // Parse SIWS message according to ABNF format // https://github.com/phantom/sign-in-with-solana/blob/e4060d2916469116d5080a712feaf81ea1db4f65/README.md#message-construction function parseSIWSMessage(message: string): ParsedSIWSMessage | null { try { const lines = message.split('\n'); // Parse header (domain and address) const header = parseHeader(lines); if (!header) return null; const result: ParsedSIWSMessage = { ...header }; let lineIndex = 2; // Parse statement if present const statementResult = parseStatement(lines, lineIndex); if (statementResult.statement !== undefined) { result.statement = statementResult.statement; lineIndex = statementResult.nextIndex; } // Parse advanced fields const advancedFields = parseAdvancedFields(lines, lineIndex); Object.assign(result, advancedFields); return result; } catch { return null; } } function parseHeader(lines: string[]): { domain: string; address: string } | null { // First line: domain + " wants you to sign in with your Solana account:" const domainMatch = lines[0]?.match(/^(.+) wants you to sign in with your Solana account:$/); if (!domainMatch) return null; const domain = domainMatch[1]; // Second line: address const address = lines[1]; if (!address || !/^[a-zA-Z0-9]{32,44}$/.test(address)) return null; return { domain, address }; } function parseStatement(lines: string[], startIndex: number): { statement?: string; nextIndex: number } { let lineIndex = startIndex; // Check for statement (after empty line) if (lines[lineIndex] === '' && lines[lineIndex + 1] && !lines[lineIndex + 1].includes(':')) { lineIndex++; // Skip empty line const statement = lines[lineIndex]; lineIndex++; // Skip another empty line after statement if (lines[lineIndex] === '') { lineIndex++; } return { statement, nextIndex: lineIndex }; } return { nextIndex: lineIndex }; } function parseAdvancedFields(lines: string[], startIndex: number): Partial { const result: Partial = {}; // Define field parsers for string fields only const fieldParsers: Array<{ prefix: string; key: keyof Omit; }> = [ { prefix: 'URI: ', key: 'uri' }, { prefix: 'Version: ', key: 'version' }, { prefix: 'Chain ID: ', key: 'chainId' }, { prefix: 'Nonce: ', key: 'nonce' }, { prefix: 'Issued At: ', key: 'issuedAt' }, { prefix: 'Expiration Time: ', key: 'expirationTime' }, { prefix: 'Not Before: ', key: 'notBefore' }, { prefix: 'Request ID: ', key: 'requestId' } ]; let lineIndex = startIndex; while (lineIndex < lines.length) { const line = lines[lineIndex]; if (!line) { lineIndex++; continue; } // Check for resources (special case) if (line.startsWith('Resources:')) { const resources = parseResources(lines, lineIndex + 1); if (resources.length > 0) { result.resources = resources; lineIndex += resources.length + 1; // +1 for the "Resources:" line continue; } } // Check for other fields let fieldFound = false; for (const { prefix, key } of fieldParsers) { if (line.startsWith(prefix)) { const value = line.substring(prefix.length); result[key] = value; fieldFound = true; break; } } if (!fieldFound) { // Unknown field, skip it } lineIndex++; } return result; } function parseResources(lines: string[], startIndex: number): string[] { const resources: string[] = []; let lineIndex = startIndex; while (lineIndex < lines.length && lines[lineIndex]?.startsWith('- ')) { resources.push(lines[lineIndex].substring(2)); lineIndex++; } return resources; } // Validate parsed SIWS message against input function validateSIWSMessage(parsed: ParsedSIWSMessage, input: SIWXInput): boolean { // Required fields validation if (parsed.domain !== input.domain || parsed.address !== input.address) { return false; } // Define validation rules for optional fields const fieldValidations: Array<{ inputKey: keyof SIWXInput; parsedKey: keyof ParsedSIWSMessage; validator?: (inputValue: unknown, parsedValue: unknown) => boolean; }> = [ { inputKey: 'statement', parsedKey: 'statement' }, { inputKey: 'uri', parsedKey: 'uri' }, { inputKey: 'version', parsedKey: 'version' }, { inputKey: 'chainId', parsedKey: 'chainId' }, { inputKey: 'nonce', parsedKey: 'nonce' }, { inputKey: 'issuedAt', parsedKey: 'issuedAt' }, { inputKey: 'expirationTime', parsedKey: 'expirationTime' }, { inputKey: 'notBefore', parsedKey: 'notBefore' }, { inputKey: 'requestId', parsedKey: 'requestId' }, { inputKey: 'resources', parsedKey: 'resources', validator: (inputValue, parsedValue) => { if (!Array.isArray(inputValue) || !Array.isArray(parsedValue)) { return false; } return inputValue.length === parsedValue.length && inputValue.every((item, index) => item === parsedValue[index]); } } ]; // Validate optional fields for (const { inputKey, parsedKey, validator } of fieldValidations) { const inputValue = input[inputKey]; const parsedValue = parsed[parsedKey]; if (inputValue !== undefined) { if (validator) { if (!validator(inputValue, parsedValue)) { return false; } } else if (inputValue !== parsedValue) { return false; } } } // Validate timestamps return validateTimestamps(parsed); } // Separate timestamp validation for better testability and clarity function validateTimestamps(parsed: ParsedSIWSMessage): boolean { const now = Date.now(); // Validate issuedAt (allow 24 hour threshold for testing) if (parsed.issuedAt) { const issuedAt = new Date(parsed.issuedAt); const threshold = 24 * 60 * 60 * 1000; // 24 hours in milliseconds const timeDiff = Math.abs(issuedAt.getTime() - now); if (timeDiff > threshold) { return false; } } // Validate expirationTime if (parsed.expirationTime) { const expirationTime = new Date(parsed.expirationTime); if (expirationTime.getTime() <= now) { return false; // Message has expired } } // Validate notBefore if (parsed.notBefore) { const notBefore = new Date(parsed.notBefore); if (notBefore.getTime() > now) { return false; // Message not yet valid } } return true; } // Create SIWS message string according to ABNF format // https://github.com/phantom/sign-in-with-solana/blob/e4060d2916469116d5080a712feaf81ea1db4f65/README.md#abnf-message-format function createSIWSMessage(input: ParsedSIWSMessage): string { let message = `${input.domain} wants you to sign in with your Solana account:\n`; message += `${input.address}`; if (input.statement) { message += `\n\n${input.statement}`; } const fields = buildFieldLines(input); if (fields.length) { message += `\n\n${fields.join('\n')}`; } return message; } function buildFieldLines(input: ParsedSIWSMessage): string[] { const fields: string[] = []; // Define field mappings const fieldMappings: Array<{ key: keyof ParsedSIWSMessage; prefix: string; formatter?: (value: unknown) => string[]; }> = [ { key: 'uri', prefix: 'URI: ' }, { key: 'version', prefix: 'Version: ' }, { key: 'chainId', prefix: 'Chain ID: ' }, { key: 'nonce', prefix: 'Nonce: ' }, { key: 'issuedAt', prefix: 'Issued At: ' }, { key: 'expirationTime', prefix: 'Expiration Time: ' }, { key: 'notBefore', prefix: 'Not Before: ' }, { key: 'requestId', prefix: 'Request ID: ' }, { key: 'resources', prefix: 'Resources:', formatter: (value) => { if (Array.isArray(value) && value.length > 0) { return ['Resources:', ...value.map(resource => `- ${resource}`)]; } return []; } } ]; for (const { key, prefix, formatter } of fieldMappings) { const value = input[key]; if (value !== undefined) { if (formatter) { const formatted = formatter(value); fields.push(...formatted); } else if (typeof value === 'string') { fields.push(`${prefix}${value}`); } } } return fields; }