airsign-sdk-core
Version:
AirSign Protocol Core SDK - secure nearby crypto & data exchange
242 lines • 9.15 kB
JavaScript
/**
* AirSign Protocol Message Handling
*
* Provides message envelope validation, presence packet parsing,
* and protocol-level message processing with security checks.
*/
import { v4 as uuidv4 } from 'uuid';
import { MessageType, AirSignError, ErrorCode } from './types.js';
import { hashMessageEnvelope, verifySenderSignature } from './crypto.js';
/**
* In-memory store for replay protection
*/
const seenMessageIds = new Set();
/**
* Default validation configuration
*/
const DEFAULT_VALIDATION = {
maxMessageAge: 5 * 60 * 1000, // 5 minutes
enforceExpiry: true,
replayProtection: true
};
/**
* Parse and validate a presence packet from JSON string
*
* @param packetJson - JSON string containing presence packet
* @returns Validated presence packet
* @throws {AirSignError} If packet is invalid
*
* @example
* ```typescript
* const packet = parsePresence('{"proto":"airsign-v1","ephemeral_pub":"...", ...}');
* console.log(packet.name); // Device name
* ```
*/
export function parsePresence(packetJson) {
try {
const packet = JSON.parse(packetJson);
// Validate required fields
if (packet.proto !== 'airsign-v1') {
throw new AirSignError('Invalid protocol version', ErrorCode.INVALID_PRESENCE, { proto: packet.proto });
}
if (!packet.ephemeral_pub || typeof packet.ephemeral_pub !== 'string') {
throw new AirSignError('Missing or invalid ephemeral_pub', ErrorCode.MISSING_FIELD);
}
if (!packet.name || typeof packet.name !== 'string' || packet.name.trim().length === 0) {
throw new AirSignError('Missing or invalid device name', ErrorCode.MISSING_FIELD);
}
if (!Array.isArray(packet.capabilities)) {
throw new AirSignError('Missing or invalid capabilities array', ErrorCode.MISSING_FIELD);
}
if (!packet.timestamp || typeof packet.timestamp !== 'number' || packet.timestamp <= 0) {
throw new AirSignError('Missing or invalid timestamp', ErrorCode.MISSING_FIELD);
}
// Validate timestamp is not too old (within 1 hour)
const now = Date.now();
const maxAge = 60 * 60 * 1000; // 1 hour
if (now - packet.timestamp > maxAge) {
throw new AirSignError('Presence packet is too old', ErrorCode.EXPIRED_MESSAGE, { timestamp: packet.timestamp, age: now - packet.timestamp });
}
// Validate base64 public key format
if (!isValidBase64(packet.ephemeral_pub)) {
throw new AirSignError('Invalid base64 format for ephemeral_pub', ErrorCode.INVALID_PRESENCE);
}
return packet;
}
catch (error) {
if (error instanceof AirSignError) {
throw error;
}
throw new AirSignError('Failed to parse presence packet', ErrorCode.INVALID_PRESENCE, { error });
}
}
/**
* Create a new message envelope with validation
*
* @param type - Message type
* @param payload - Message payload as string
* @param meta - Message metadata
* @param senderSig - Optional sender signature (hex)
* @param senderPub - Optional sender public key (hex)
* @returns Validated message envelope
* @throws {AirSignError} If parameters are invalid
*
* @example
* ```typescript
* const envelope = createMessageEnvelope(
* MessageType.PAYMENT_URI,
* 'ethereum:0x123...?value=1000000000000000000',
* { expiry: Date.now() + 300000 }
* );
* ```
*/
export function createMessageEnvelope(type, payload, meta, senderSig, senderPub) {
// Validate required fields
if (!Object.values(MessageType).includes(type)) {
throw new AirSignError('Invalid message type', ErrorCode.INVALID_MESSAGE, { type });
}
if (!payload || typeof payload !== 'string') {
throw new AirSignError('Missing or invalid payload', ErrorCode.MISSING_FIELD);
}
if (!meta || typeof meta !== 'object') {
throw new AirSignError('Missing or invalid meta object', ErrorCode.MISSING_FIELD);
}
if (!meta.expiry || typeof meta.expiry !== 'number' || meta.expiry <= Date.now()) {
throw new AirSignError('Missing or invalid expiry timestamp', ErrorCode.MISSING_FIELD);
}
// Validate optional signature fields
if (senderSig && !senderPub) {
throw new AirSignError('sender_pub required when sender_sig is provided', ErrorCode.MISSING_FIELD);
}
if (senderSig && !isValidHex(senderSig)) {
throw new AirSignError('Invalid hex format for sender_sig', ErrorCode.INVALID_MESSAGE);
}
if (senderPub && !isValidHex(senderPub)) {
throw new AirSignError('Invalid hex format for sender_pub', ErrorCode.INVALID_MESSAGE);
}
const envelope = {
type,
id: uuidv4(),
payload,
meta
};
// Add optional signature fields only if provided
if (senderSig && senderPub) {
envelope.sender_sig = senderSig;
envelope.sender_pub = senderPub;
}
return envelope;
}
/**
* Validate a received message envelope
*
* @param envelope - Message envelope to validate
* @param config - Validation configuration
* @returns Promise resolving to true if valid
* @throws {AirSignError} If message is invalid
*
* @example
* ```typescript
* await validateMessageEnvelope(receivedMessage);
* console.log('Message is valid and safe to process');
* ```
*/
export async function validateMessageEnvelope(envelope, config = DEFAULT_VALIDATION) {
// Validate structure
if (!envelope || typeof envelope !== 'object') {
throw new AirSignError('Invalid message envelope', ErrorCode.INVALID_MESSAGE);
}
// Check required fields
if (!envelope.id || typeof envelope.id !== 'string') {
throw new AirSignError('Missing or invalid message id', ErrorCode.MISSING_FIELD);
}
if (!Object.values(MessageType).includes(envelope.type)) {
throw new AirSignError('Invalid message type', ErrorCode.INVALID_MESSAGE, { type: envelope.type });
}
if (!envelope.payload || typeof envelope.payload !== 'string') {
throw new AirSignError('Missing or invalid payload', ErrorCode.MISSING_FIELD);
}
if (!envelope.meta || typeof envelope.meta !== 'object') {
throw new AirSignError('Missing or invalid meta object', ErrorCode.MISSING_FIELD);
}
// Validate expiry
if (config.enforceExpiry && envelope.meta.expiry) {
if (Date.now() > envelope.meta.expiry) {
throw new AirSignError('Message has expired', ErrorCode.EXPIRED_MESSAGE, { expiry: envelope.meta.expiry, now: Date.now() });
}
}
// Check for replay attacks
if (config.replayProtection) {
if (seenMessageIds.has(envelope.id)) {
throw new AirSignError('Replay attack detected - message ID already seen', ErrorCode.REPLAY_DETECTED, { id: envelope.id });
}
// Add to seen messages (with basic cleanup)
seenMessageIds.add(envelope.id);
// Clean up old messages periodically (basic implementation)
if (seenMessageIds.size > 10000) {
const toDelete = Array.from(seenMessageIds).slice(0, 1000);
toDelete.forEach(id => seenMessageIds.delete(id));
}
}
// Validate signature if present
if (envelope.sender_sig && envelope.sender_pub) {
const messageHash = await hashMessageEnvelope(envelope);
// Try secp256k1 first, then ed25519
let signatureValid = false;
try {
signatureValid = await verifySenderSignature(messageHash, envelope.sender_sig, envelope.sender_pub, 'secp256k1');
}
catch {
try {
signatureValid = await verifySenderSignature(messageHash, envelope.sender_sig, envelope.sender_pub, 'ed25519');
}
catch {
// Both schemes failed
}
}
if (!signatureValid) {
throw new AirSignError('Invalid sender signature', ErrorCode.INVALID_SIGNATURE, { id: envelope.id });
}
}
return true;
}
/**
* Clean up replay protection storage (call periodically)
*/
export function cleanupReplayProtection() {
// Simple cleanup - in production this would be more sophisticated
if (seenMessageIds.size > 1000) {
seenMessageIds.clear();
}
}
/**
* Get current replay protection statistics
*
* @returns Object with replay protection stats
*/
export function getReplayProtectionStats() {
return {
seenMessages: seenMessageIds.size
};
}
// Helper functions
function isValidBase64(str) {
try {
// Handle both standard base64 and URL-safe base64
let normalized = str;
// Convert URL-safe base64 to standard base64
normalized = normalized.replace(/-/g, '+').replace(/_/g, '/');
// Add padding if needed
while (normalized.length % 4) {
normalized += '=';
}
return Buffer.from(normalized, 'base64').toString('base64') === normalized;
}
catch {
return false;
}
}
function isValidHex(str) {
return /^[0-9a-fA-F]+$/.test(str) && str.length % 2 === 0;
}
//# sourceMappingURL=protocol.js.map