@sudowealth/schwab-api
Version:
TypeScript client for Charles Schwab API with OAuth support, market data, trading functionality, and complete type safety
335 lines (334 loc) • 10.3 kB
JavaScript
import { createLogger } from './secure-logger';
const logger = createLogger('CryptoUtils');
/**
* Default options for signing operations
*/
const DEFAULT_SIGNING_OPTIONS = {
algorithm: 'HMAC',
encoding: 'hex',
hash: 'SHA-256',
};
/**
* Create an HMAC key from a secret string
*
* @param secret The secret key string
* @param options Validation options
* @returns A CryptoKey for HMAC operations
* @throws Error if the secret doesn't meet security requirements
*
* @example
* ```typescript
* const key = await createHmacKey('your-secret-key-at-least-32-chars');
* ```
*/
export async function createHmacKey(secret, options = {}) {
const opts = {
minLength: 32,
minEntropy: 8,
checkWeakPatterns: true,
...options,
};
// Validate secret
if (!secret) {
throw new Error('Secret key is required');
}
if (secret.length < opts.minLength) {
throw new Error(`Secret key must be at least ${opts.minLength} characters long for security`);
}
// Check entropy
const uniqueChars = new Set(secret).size;
if (uniqueChars < opts.minEntropy) {
throw new Error(`Secret key must have at least ${opts.minEntropy} unique characters for sufficient entropy`);
}
// Check for weak patterns
if (opts.checkWeakPatterns) {
const weakPatterns = [
/^(.)\1*$/, // All same character
/^(01|10)+$/, // Binary pattern
/^(abc|123)+$/i, // Sequential pattern
/^(password|secret|key)/i, // Common weak prefixes
];
for (const pattern of weakPatterns) {
if (pattern.test(secret)) {
throw new Error('Secret key appears to use a weak pattern. Use a cryptographically secure random string.');
}
}
}
// Import the key
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
return crypto.subtle.importKey('raw', keyData, {
name: 'HMAC',
hash: 'SHA-256',
}, false, // not extractable
['sign', 'verify']);
}
/**
* Sign data using HMAC or RSA
*
* @param key The CryptoKey to use for signing
* @param data The data to sign
* @param options Signing options
* @returns The signature as a hex or base64 string
*
* @example
* ```typescript
* const signature = await signData(key, 'data-to-sign');
* ```
*/
export async function signData(key, data, options = {}) {
const opts = { ...DEFAULT_SIGNING_OPTIONS, ...options };
try {
const encoder = new TextEncoder();
const dataBuffer = encoder.encode(data);
let signatureBuffer;
if (opts.algorithm === 'HMAC') {
signatureBuffer = await crypto.subtle.sign('HMAC', key, dataBuffer);
}
else if (opts.algorithm === 'RSA') {
signatureBuffer = await crypto.subtle.sign({
name: 'RSA-PSS',
saltLength: 32,
}, key, dataBuffer);
}
else {
throw new Error(`Unsupported algorithm: ${opts.algorithm}`);
}
// Encode the signature
if (opts.encoding === 'hex') {
return toHex(signatureBuffer);
}
else if (opts.encoding === 'base64') {
return bufferToBase64(signatureBuffer);
}
else {
throw new Error(`Unsupported encoding: ${opts.encoding}`);
}
}
catch (error) {
logger.error('Error signing data', error);
throw new Error('Failed to sign data');
}
}
/**
* Verify a signature using HMAC or RSA
*
* @param key The CryptoKey to use for verification
* @param signature The signature to verify
* @param data The original data that was signed
* @param options Signing options (must match those used for signing)
* @returns True if the signature is valid, false otherwise
*
* @example
* ```typescript
* const isValid = await verifySignature(key, signature, 'data-to-sign');
* ```
*/
export async function verifySignature(key, signature, data, options = {}) {
const opts = { ...DEFAULT_SIGNING_OPTIONS, ...options };
try {
const encoder = new TextEncoder();
const dataBuffer = encoder.encode(data);
// Decode the signature
let signatureBuffer;
if (opts.encoding === 'hex') {
signatureBuffer = fromHex(signature);
}
else if (opts.encoding === 'base64') {
signatureBuffer = base64ToBuffer(signature);
}
else {
throw new Error(`Unsupported encoding: ${opts.encoding}`);
}
if (opts.algorithm === 'HMAC') {
return await crypto.subtle.verify('HMAC', key, signatureBuffer, dataBuffer);
}
else if (opts.algorithm === 'RSA') {
return await crypto.subtle.verify({
name: 'RSA-PSS',
saltLength: 32,
}, key, signatureBuffer, dataBuffer);
}
else {
throw new Error(`Unsupported algorithm: ${opts.algorithm}`);
}
}
catch (error) {
logger.debug('Error verifying signature', error);
return false;
}
}
/**
* Convert an ArrayBuffer to a hexadecimal string
*
* @param buffer The buffer to convert
* @returns Hex string representation
*/
export function toHex(buffer) {
if (typeof Buffer !== 'undefined') {
// Node.js environment
return Buffer.from(buffer).toString('hex');
}
else {
// Browser environment
const bytes = new Uint8Array(buffer);
return Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
}
/**
* Convert a hexadecimal string to an ArrayBuffer
*
* @param hex The hex string to convert
* @returns ArrayBuffer representation
* @throws Error if the hex string is invalid
*/
export function fromHex(hex) {
// Remove any spaces or non-hex characters
const cleanHex = hex.replace(/[^0-9a-fA-F]/g, '');
if (cleanHex.length % 2 !== 0) {
throw new Error('Invalid hex string: odd number of characters');
}
if (typeof Buffer !== 'undefined') {
// Node.js environment
try {
return Buffer.from(cleanHex, 'hex').buffer;
}
catch {
throw new Error('Invalid hex string format');
}
}
else {
// Browser environment
const bytes = new Uint8Array(cleanHex.length / 2);
for (let i = 0; i < cleanHex.length; i += 2) {
const byte = parseInt(cleanHex.substr(i, 2), 16);
if (isNaN(byte)) {
throw new Error(`Invalid hex string at position ${i}`);
}
bytes[i / 2] = byte;
}
return bytes.buffer;
}
}
/**
* Convert an ArrayBuffer to base64
*
* @param buffer The buffer to convert
* @returns Base64 string
*/
export function bufferToBase64(buffer) {
if (typeof Buffer !== 'undefined') {
// Node.js environment
return Buffer.from(buffer).toString('base64');
}
else {
// Browser environment
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
}
/**
* Convert a base64 string to ArrayBuffer
*
* @param base64 The base64 string to convert
* @returns ArrayBuffer
*/
export function base64ToBuffer(base64) {
if (typeof Buffer !== 'undefined') {
// Node.js environment
return Buffer.from(base64, 'base64').buffer;
}
else {
// Browser environment
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
}
/**
* Constant-time string comparison to prevent timing attacks
*
* @param a First string
* @param b Second string
* @returns True if strings are equal
*/
export function constantTimeCompare(a, b) {
if (a.length !== b.length) {
return false;
}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
/**
* Generate a cryptographically secure random string
*
* @param length The length of the string to generate
* @param charset The character set to use
* @returns Random string
*/
export function generateSecureRandomString(length, charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789') {
const values = new Uint32Array(length);
crypto.getRandomValues(values);
let result = '';
for (let i = 0; i < length; i++) {
result += charset[values[i] % charset.length];
}
return result;
}
/**
* Hash data using SHA-256 or other algorithms
*
* @param data The data to hash
* @param algorithm The hash algorithm to use
* @returns Hex-encoded hash
*/
export async function hashData(data, algorithm = 'SHA-256') {
const encoder = new TextEncoder();
const dataBuffer = encoder.encode(data);
const hashBuffer = await crypto.subtle.digest(algorithm, dataBuffer);
return toHex(hashBuffer);
}
/**
* Create a time-based signature with expiration
*
* @param key The signing key
* @param data The data to sign
* @param expiresIn Expiration time in seconds
* @returns Signature with timestamp
*/
export async function createTimedSignature(key, data, expiresIn = 300) {
const expires = Math.floor(Date.now() / 1000) + expiresIn;
const dataWithTimestamp = `${data}.${expires}`;
const signature = await signData(key, dataWithTimestamp);
return { signature, expires };
}
/**
* Verify a time-based signature
*
* @param key The signing key
* @param signature The signature to verify
* @param data The original data
* @param expires The expiration timestamp
* @returns True if valid and not expired
*/
export async function verifyTimedSignature(key, signature, data, expires) {
// Check if expired
if (Math.floor(Date.now() / 1000) > expires) {
logger.debug('Timed signature has expired');
return false;
}
const dataWithTimestamp = `${data}.${expires}`;
return verifySignature(key, signature, dataWithTimestamp);
}