diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a2aa79..41101c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,7 +102,7 @@ examples/ Runnable usage examples - Run `npm run lint` before pushing — this type-checks the full source tree. - Match the naming and file conventions already present in `src/`. - Keep functions focused and add JSDoc comments for any exported API. -- Do not introduce new runtime dependencies without discussion in the issue first. +- Do not introduce new dependencies without following the [Dependency Review Standards](./docs/dependency-review.md) and discussing in the issue first. --- diff --git a/README.md b/README.md index 0efd565..18907d2 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ npm install @axionvera/pocketpay-sdk - [Release Checklist](./docs/release-checklist.md) - Pre-release verification steps for maintainers - [Architecture Decision Records](./docs/adr/) - Records of significant SDK design decisions and their rationale - [Support Policy](./docs/support-policy.md) - Supported runtimes, versions, network status, and maintenance expectations +- [Dependency Review Standards](./docs/dependency-review.md) - Guidelines for evaluating, adding, and updating SDK dependencies - [Changelog](./CHANGELOG.md) - Track changes across SDK versions ## Package Root Imports diff --git a/docs/api-reference.md b/docs/api-reference.md index 373d00e..3acbe59 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -118,3 +118,22 @@ interface ValidationError { ``` Consumers should branch on `err.code`; codes are part of the public contract, messages are not. See `docs/getting-started.md` for a form-integration example. + +## Wallet Import API + +### `importWallet(secretKey)` +Imports an existing wallet from a Stellar secret key (S...). +- **Throws**: `PocketPayError` (`INVALID_SECRET_KEY`) with typed validation reason (`not_a_string`, `missing`, `invalid_prefix`, `invalid_length`, `invalid_format`). + +### `safeImportWallet(secretKey)` +Safely attempts to import a wallet without throwing. +- **Returns**: `PocketPayResult` (`{ ok: true, value }` or `{ ok: false, error }`). + +### `enhancedImportWallet(secretKey)` +Imports a wallet and returns an enhanced result with warnings and recovery hints. +- **Returns**: `EnhancedPocketPayResult`. + +### `safeEnhancedImportWallet(secretKey)` +Non-throwing wrapper for `enhancedImportWallet`. +- **Returns**: `EnhancedPocketPayResult`. + diff --git a/docs/dependency-review.md b/docs/dependency-review.md index a22d109..ddb3026 100644 --- a/docs/dependency-review.md +++ b/docs/dependency-review.md @@ -67,4 +67,4 @@ Before merging any dependency change, confirm that: - No new high or critical security advisories are introduced. - The dependency license is compatible with MIT. -See also [Security Best Practices](./security.md) and the [Contributing Guide](../CONTRIBUTING.md). \ No newline at end of file +See also [Security Best Practices](./security.md) and the [Contributing Guide](../CONTRIBUTING.md). diff --git a/docs/error-handling.md b/docs/error-handling.md index 7794a98..a22bcfd 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -254,8 +254,9 @@ The enhanced pattern is currently applied to two pilot operations: |:---|:---|:---|:---| | `sendXLM` | `enhancedSendXLM` | `HIGH_FEE_RATIO` (fee > 10% of amount) | `fund_account`, `check_input`, `retry` | | `getBalance` | `enhancedGetBalance` | `ZERO_NATIVE_BALANCE`, `MANY_ASSETS` (> 20) | `fund_account`, `check_network`, `check_input` | +| `importWallet` | `enhancedImportWallet` | None | `check_input` | -Each pilot also has a non-throwing variant: `safeEnhancedSendXLM` and `safeEnhancedGetBalance`. +Each pilot also has a non-throwing variant: `safeEnhancedSendXLM`, `safeEnhancedGetBalance`, and `safeEnhancedImportWallet`. ### Building Your Own Enhanced Results diff --git a/docs/wallet-import-safety.md b/docs/wallet-import-safety.md index 0b0f5ab..e575c9d 100644 --- a/docs/wallet-import-safety.md +++ b/docs/wallet-import-safety.md @@ -88,6 +88,43 @@ Do not send a secret key to customer support or ask a user to paste one into a support ticket. Diagnose wallet-import problems with public keys, sanitized error codes, and non-sensitive device or transaction metadata. +## Validation & Safe Error Handling + +When importing secret keys, the SDK performs strict local validation prior to any key derivation or SDK operations: + +- **Type Check**: Non-string values throw `PocketPayError` with validation reason `not_a_string`. +- **Presence Check**: Empty or whitespace-only strings throw `PocketPayError` with validation reason `missing`. +- **Prefix Check**: Keys must start with `'S'`. Non-matching values throw `PocketPayError` with validation reason `invalid_prefix`. +- **Length Check**: Keys must be exactly 56 characters. Incorrect lengths throw `PocketPayError` with validation reason `invalid_length`. +- **Format Check**: Keys with invalid strkey payloads or checksums throw `PocketPayError` with validation reason `invalid_format`. + +### Redaction Guarantee +Secret key inputs are **never** attached to `error.validation.value` or raw error messages. Sanitized error messages give clear guidance without echoing key material. + +### Safe & Enhanced Import Helpers + +In addition to `importWallet(secretKey)`, the SDK provides non-throwing and enriched wrappers: + +```typescript +import { safeImportWallet, enhancedImportWallet } from '@axionvera/pocketpay-sdk'; + +// 1. Non-throwing result wrapper +const result = safeImportWallet(userInputKey); +if (result.ok) { + console.log('Wallet imported:', result.value.publicKey); +} else { + console.error('Import failed [code]:', result.error.code); +} + +// 2. Enhanced wrapper with recovery hints +const enhanced = enhancedImportWallet(userInputKey); +if (!enhanced.ok) { + enhanced.recoveryHints?.forEach(hint => { + console.log('Recovery action:', hint.action, hint.message); + }); +} +``` + ## Integration Checklist - Secret keys never appear in logs, errors, analytics, or crash reports. diff --git a/src/index.ts b/src/index.ts index e6233de..2033242 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,6 +77,9 @@ export type { ResultWarning, RecoveryHint } from './errors'; export { createWallet, importWallet, + safeImportWallet, + enhancedImportWallet, + safeEnhancedImportWallet, getPublicKey, getBalance, getBalanceOrUnfunded, diff --git a/src/utils/index.ts b/src/utils/index.ts index f21b902..911b0eb 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -38,21 +38,74 @@ export function validatePublicKey(publicKey: string): boolean { } } -export function validateSecretKey(secretKey: string): boolean { +export function validateSecretKey(secretKey: unknown): boolean { + if (typeof secretKey !== 'string') { + throw new PocketPayError( + 'Invalid Stellar secret key: secret key must be a string', + 'INVALID_SECRET_KEY', + { + validation: { + field: 'secretKey', + reason: 'not_a_string', + }, + }, + ); + } + + if (!secretKey || secretKey.trim().length === 0) { + throw new PocketPayError( + 'Invalid Stellar secret key: secret key cannot be empty', + 'INVALID_SECRET_KEY', + { + validation: { + field: 'secretKey', + reason: 'missing', + }, + }, + ); + } + + const trimmed = secretKey.trim(); + + if (!trimmed.startsWith('S')) { + throw new PocketPayError( + "Invalid Stellar secret key: secret key must start with 'S'", + 'INVALID_SECRET_KEY', + { + validation: { + field: 'secretKey', + reason: 'invalid_prefix', + }, + }, + ); + } + + if (trimmed.length !== 56) { + throw new PocketPayError( + `Invalid Stellar secret key: secret key must be 56 characters long (got ${trimmed.length})`, + 'INVALID_SECRET_KEY', + { + validation: { + field: 'secretKey', + reason: 'invalid_length', + }, + }, + ); + } + try { - StellarSDK.Keypair.fromSecret(secretKey); + StellarSDK.Keypair.fromSecret(trimmed); return true; } catch { throw new PocketPayError( - 'Invalid Stellar secret key', + 'Invalid Stellar secret key: failed strkey checksum or payload verification', 'INVALID_SECRET_KEY', { validation: { field: 'secretKey', - reason: 'invalid_format' - // Do NOT include value (secret!) - } - } + reason: 'invalid_format', + }, + }, ); } } diff --git a/src/wallet/index.ts b/src/wallet/index.ts index 41116a7..4a646b5 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -56,14 +56,97 @@ export function createWallet(): WalletKeypair { */ export function importWallet(secretKey: string): WalletKeypair { validateSecretKey(secretKey); - const kp = StellarSDK.Keypair.fromSecret(secretKey); - return { publicKey: kp.publicKey(), secretKey: kp.secret() }; + const trimmed = secretKey.trim(); + try { + const kp = StellarSDK.Keypair.fromSecret(trimmed); + return { publicKey: kp.publicKey(), secretKey: kp.secret() }; + } catch (error) { + if (error instanceof PocketPayError) { + throw error; + } + throw new PocketPayError( + 'Invalid Stellar secret key', + 'INVALID_SECRET_KEY', + { + validation: { + field: 'secretKey', + reason: 'invalid_format', + }, + cause: error instanceof Error ? error : undefined, + }, + ); + } } /** Derives the public key from a secret key. */ export function getPublicKey(secretKey: string): string { validateSecretKey(secretKey); - return StellarSDK.Keypair.fromSecret(secretKey).publicKey(); + const trimmed = secretKey.trim(); + return StellarSDK.Keypair.fromSecret(trimmed).publicKey(); +} + +/** + * Safely imports a wallet from a secret key without throwing. + * + * @param secretKey - Stellar secret key (S...) to import + * @returns A {@link PocketPayResult} with either the {@link WalletKeypair} or a {@link PocketPayError} + */ +export function safeImportWallet(secretKey: string): PocketPayResult { + try { + const wallet = importWallet(secretKey); + return toSuccessResult(wallet); + } catch (error) { + const pocketErr = + error instanceof PocketPayError + ? error + : wrapError(error, 'Failed to import wallet', 'INVALID_SECRET_KEY'); + return toFailureResult(pocketErr); + } +} + +/** + * Imports an existing wallet with an enriched result containing warnings and recovery hints. + * + * @param secretKey - Stellar secret key (S...) to import + * @returns An {@link EnhancedPocketPayResult} with the imported wallet or failure details & recovery hints + */ +export function enhancedImportWallet( + secretKey: string, +): EnhancedPocketPayResult { + const warnings: ResultWarning[] = []; + const recoveryHints: RecoveryHint[] = []; + + try { + const wallet = importWallet(secretKey); + return toEnhancedSuccessResult(wallet, warnings, recoveryHints); + } catch (error) { + const pocketErr = + error instanceof PocketPayError + ? error + : wrapError(error, 'Failed to import wallet', 'INVALID_SECRET_KEY'); + + if (pocketErr.code === 'INVALID_SECRET_KEY') { + recoveryHints.push({ + action: 'check_input', + message: 'Ensure the secret key is a valid 56-character Stellar secret key starting with S.', + retryable: false, + }); + } + + return toEnhancedFailureResult(pocketErr, warnings, recoveryHints); + } +} + +/** + * Non-throwing wrapper for {@link enhancedImportWallet}. + * + * @param secretKey - Stellar secret key (S...) to import + * @returns An enriched result that never throws + */ +export function safeEnhancedImportWallet( + secretKey: string, +): EnhancedPocketPayResult { + return enhancedImportWallet(secretKey); } /** diff --git a/tests/wallet.test.ts b/tests/wallet.test.ts index c5b73e7..98d014c 100644 --- a/tests/wallet.test.ts +++ b/tests/wallet.test.ts @@ -1,5 +1,14 @@ import { describe, it, expect } from 'vitest'; -import { createWallet, importWallet, getPublicKey, PocketPayError } from '../src'; +import { + createWallet, + importWallet, + safeImportWallet, + enhancedImportWallet, + safeEnhancedImportWallet, + getPublicKey, + validateSecretKey, + PocketPayError, +} from '../src'; import { fundedAccount } from './fixtures'; describe('Wallet Module', () => { @@ -20,6 +29,87 @@ describe('Wallet Module', () => { }); }); + describe('validateSecretKey', () => { + it('should return true for a valid secret key', () => { + const { secretKey } = createWallet(); + expect(validateSecretKey(secretKey)).toBe(true); + }); + + it('should throw typed PocketPayError with reason "not_a_string" for non-string inputs', () => { + const invalidInputs = [null, undefined, 12345, {}, ['S123']]; + for (const input of invalidInputs) { + try { + validateSecretKey(input as any); + expect.fail('Should have thrown PocketPayError'); + } catch (err) { + expect(err).toBeInstanceOf(PocketPayError); + const pErr = err as PocketPayError; + expect(pErr.code).toBe('INVALID_SECRET_KEY'); + expect(pErr.validation?.field).toBe('secretKey'); + expect(pErr.validation?.reason).toBe('not_a_string'); + expect((pErr.validation as any)?.value).toBeUndefined(); + } + } + }); + + it('should throw typed PocketPayError with reason "missing" for empty or whitespace strings', () => { + for (const input of ['', ' ', '\t\n']) { + try { + validateSecretKey(input); + expect.fail('Should have thrown PocketPayError'); + } catch (err) { + expect(err).toBeInstanceOf(PocketPayError); + const pErr = err as PocketPayError; + expect(pErr.code).toBe('INVALID_SECRET_KEY'); + expect(pErr.validation?.reason).toBe('missing'); + expect((pErr.validation as any)?.value).toBeUndefined(); + } + } + }); + + it('should throw typed PocketPayError with reason "invalid_prefix" for key not starting with S', () => { + const wrongPrefix = 'G' + 'A'.repeat(55); + try { + validateSecretKey(wrongPrefix); + expect.fail('Should have thrown PocketPayError'); + } catch (err) { + expect(err).toBeInstanceOf(PocketPayError); + const pErr = err as PocketPayError; + expect(pErr.code).toBe('INVALID_SECRET_KEY'); + expect(pErr.validation?.reason).toBe('invalid_prefix'); + expect((pErr.validation as any)?.value).toBeUndefined(); + } + }); + + it('should throw typed PocketPayError with reason "invalid_length" for key with incorrect length', () => { + const shortKey = 'S1234567890'; + try { + validateSecretKey(shortKey); + expect.fail('Should have thrown PocketPayError'); + } catch (err) { + expect(err).toBeInstanceOf(PocketPayError); + const pErr = err as PocketPayError; + expect(pErr.code).toBe('INVALID_SECRET_KEY'); + expect(pErr.validation?.reason).toBe('invalid_length'); + expect((pErr.validation as any)?.value).toBeUndefined(); + } + }); + + it('should throw typed PocketPayError with reason "invalid_format" for 56-char string starting with S but bad strkey checksum', () => { + const badChecksum = 'S' + '0'.repeat(55); + try { + validateSecretKey(badChecksum); + expect.fail('Should have thrown PocketPayError'); + } catch (err) { + expect(err).toBeInstanceOf(PocketPayError); + const pErr = err as PocketPayError; + expect(pErr.code).toBe('INVALID_SECRET_KEY'); + expect(pErr.validation?.reason).toBe('invalid_format'); + expect((pErr.validation as any)?.value).toBeUndefined(); + } + }); + }); + describe('importWallet', () => { it('should import a wallet from a valid secret key', () => { const original = createWallet(); @@ -28,9 +118,25 @@ describe('Wallet Module', () => { expect(imported.secretKey).toEqual(original.secretKey); }); - it('should throw PocketPayError for invalid secret key', () => { - expect(() => importWallet('INVALID_KEY')).toThrow(PocketPayError); - expect(() => importWallet('INVALID_KEY')).toThrow('Invalid Stellar secret key'); + it('should trim surrounding whitespace on import', () => { + const original = createWallet(); + const imported = importWallet(` ${original.secretKey}\n`); + expect(imported.publicKey).toEqual(original.publicKey); + }); + + it('should throw PocketPayError for invalid secret key without leaking secret data', () => { + const secretAttempt = 'S' + 'X'.repeat(55); + try { + importWallet(secretAttempt); + expect.fail('Should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(PocketPayError); + const pErr = err as PocketPayError; + expect(pErr.code).toBe('INVALID_SECRET_KEY'); + expect(pErr.message).not.toContain(secretAttempt); + expect(JSON.stringify(pErr)).not.toContain(secretAttempt); + expect((pErr.validation as any)?.value).toBeUndefined(); + } }); it('should throw for empty string', () => { @@ -38,6 +144,55 @@ describe('Wallet Module', () => { }); }); + describe('safeImportWallet', () => { + it('should return success result for valid secret key', () => { + const original = createWallet(); + const result = safeImportWallet(original.secretKey); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.publicKey).toBe(original.publicKey); + } + }); + + it('should return failure result with INVALID_SECRET_KEY error for invalid secret key without throwing', () => { + const result = safeImportWallet('invalid-key'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBeInstanceOf(PocketPayError); + expect(result.error.code).toBe('INVALID_SECRET_KEY'); + } + }); + }); + + describe('enhancedImportWallet & safeEnhancedImportWallet', () => { + it('should return enhanced success result for valid secret key', () => { + const original = createWallet(); + const result = enhancedImportWallet(original.secretKey); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.publicKey).toBe(original.publicKey); + } + }); + + it('should return enhanced failure result with recovery hints for invalid secret key', () => { + const result = enhancedImportWallet('invalid-key'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('INVALID_SECRET_KEY'); + expect(result.recoveryHints).toBeDefined(); + expect(result.recoveryHints?.some((h) => h.action === 'check_input')).toBe(true); + } + }); + + it('safeEnhancedImportWallet should never throw and return enhanced result', () => { + const result = safeEnhancedImportWallet(''); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('INVALID_SECRET_KEY'); + } + }); + }); + describe('getPublicKey', () => { it('should derive the correct public key from a secret key', () => { const wallet = createWallet(); @@ -58,3 +213,4 @@ describe('Wallet Module', () => { }); }); }); +