diff --git a/src/chains/stellar/README.md b/src/chains/stellar/README.md new file mode 100644 index 0000000..7cd582e --- /dev/null +++ b/src/chains/stellar/README.md @@ -0,0 +1,61 @@ +# `@wraith-protocol/sdk/chains/stellar` + +Stellar stealth address crypto primitives, announcement scanning, and transaction +helpers for the Wraith stealth address protocol. + +## Log redaction + +Scanner debug logs can leak meta-addresses, tx hashes, and other Stellar +identifiers. When a user shares a bug report or a test failure, that log text +leaks scan context along with it. `redact()` strips those identifiers out +before you share anything. + +```typescript +import { redact } from '@wraith-protocol/sdk/chains/stellar'; + +redact(`scanning st:xlm:${spendingHex}${viewingHex} against tx ${txHash}`); +// => "scanning [redact:meta:3f1a9c02de] against tx [redact:hash:8b6e21a4f0]" +``` + +`redact()` recognizes: + +- Stealth meta-addresses (`st:xlm:...`) +- Stellar strkeys: accounts (`G...`), secret seeds (`S...`), Soroban contract + IDs (`C...`), pre-auth tx hashes (`T...`), hashx signers (`X...`), and + liquidity pool IDs (`L...`) +- Muxed accounts (`M...`) +- 32-byte hex blobs — transaction hashes, ephemeral public keys, wasm hashes + +Each identifier is replaced with a pseudonym derived by hashing the identifier +itself, so the same value always redacts to the same alias — you can still +tell that two log lines reference the same address without either line +revealing what that address is. There's no lookup table and no shared state, +so this holds across calls and across processes. + +`redact()` is a plain function with no side effects: it is never called +automatically by SDK code, so production logs are unaffected unless you call +it yourself. + +### Redacting Vitest output + +`RedactingReporter` wires `redact()` into a project's own test run so failure +output — error messages, stack traces, diffs, and captured `console.log` +output — is redacted by default: + +```typescript +// vitest.config.ts +import { defineConfig } from 'vitest/config'; +import { RedactingReporter } from '@wraith-protocol/sdk/chains/stellar'; + +export default defineConfig({ + test: { + reporters: [new RedactingReporter(), 'default'], + }, +}); +``` + +List `RedactingReporter` **before** your output reporter (`'default'`, +`'verbose'`, etc.) — reporters run in array order, and redaction has to run +before the identifiers are printed. `RedactingReporter` does not import +`vitest` itself; it only relies on the shape of the objects Vitest passes to +reporter hooks, so it has no runtime dependency on the `vitest` package. diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index d294b4b..1cee224 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -90,3 +90,5 @@ export type { export { encodeMemo, decodeMemo, extractMemoFromTransaction } from './memo'; export type { MemoType, MemoValue, TypedMemo } from './memo'; export { MemoValidationError, TEXT_MEMO_MAX_BYTES, HASH_MEMO_BYTES, ID_MEMO_MAX } from './memo'; + +export { redact, RedactingReporter } from './log/redact'; diff --git a/src/chains/stellar/log/redact.ts b/src/chains/stellar/log/redact.ts new file mode 100644 index 0000000..55edf33 --- /dev/null +++ b/src/chains/stellar/log/redact.ts @@ -0,0 +1,181 @@ +import { sha256 } from '@noble/hashes/sha256'; +import { META_ADDRESS_PREFIX } from '../constants'; +import { bytesToHex } from '../utils'; + +// --------------------------------------------------------------------------- +// Patterns +// --------------------------------------------------------------------------- + +/** Base32 alphabet used by Stellar strkey-encoded identifiers. */ +const STRKEY_ALPHABET = 'A-Z2-7'; + +/** Maps a Stellar strkey's leading character to a human-readable identifier kind. */ +const STRKEY_KIND_BY_PREFIX: Record = { + G: 'account', + S: 'secret', + C: 'contract', + T: 'preauth', + X: 'hashx', + L: 'pool', +}; + +/** Matches Wraith Stellar stealth meta-addresses, e.g. `st:xlm:<128 hex chars>`. */ +const META_ADDRESS_RE = new RegExp(`${escapeRegExp(META_ADDRESS_PREFIX)}[0-9a-fA-F]{128}`, 'g'); + +/** Matches 69-char muxed Stellar accounts (`M...`). */ +const MUXED_ACCOUNT_RE = new RegExp(`\\bM[${STRKEY_ALPHABET}]{68}\\b`, 'g'); + +/** + * Matches 56-char Stellar strkeys: accounts (`G`), secret seeds (`S`), + * Soroban contract IDs (`C`), pre-auth tx hashes (`T`), hashx signers (`X`), + * and liquidity pool IDs (`L`). + */ +const STRKEY_RE = new RegExp( + `\\b[${Object.keys(STRKEY_KIND_BY_PREFIX).join('')}][${STRKEY_ALPHABET}]{55}\\b`, + 'g', +); + +/** Matches 32-byte hex blobs: transaction hashes, ephemeral public keys, wasm hashes. */ +const HEX_BLOB_RE = /\b(?:0x)?[0-9a-fA-F]{64}\b/g; + +// --------------------------------------------------------------------------- +// Pseudonymization +// --------------------------------------------------------------------------- + +const ALIAS_HEX_LENGTH = 10; +const textEncoder = new TextEncoder(); + +/** + * Derives a stable, non-reversible alias for an identifier. + * + * Hashing (rather than a lookup table) means the same identifier always + * redacts to the same alias — across calls, and across processes — without + * needing any shared state. + */ +function pseudonymize(kind: string, value: string): string { + const digest = sha256(textEncoder.encode(`wraith:redact:v1:${kind}:${value}`)); + return `[redact:${kind}:${bytesToHex(digest).slice(0, ALIAS_HEX_LENGTH)}]`; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Redacts common Stellar identifiers from a block of text, replacing each + * with a stable pseudonym. + * + * Handles stealth meta-addresses (`st:xlm:...`), Stellar strkeys (accounts, + * secrets, Soroban contract IDs, pre-auth tx hashes, hashx signers, and + * liquidity pool IDs), muxed accounts, and 32-byte hex blobs such as + * transaction hashes and ephemeral public keys. + * + * The same identifier always redacts to the same alias, so redacted logs + * still let you correlate repeated occurrences of the same address or hash + * without revealing what it is. + * + * @param text - Arbitrary log text that may contain Stellar identifiers. + * @returns The text with every recognized identifier replaced by an alias. + * + * @example + * ```ts + * import { redact } from "@wraith-protocol/sdk/chains/stellar"; + * + * redact("scanning st:xlm:" + "ab".repeat(64)); + * // => "scanning [redact:meta:]" + * ``` + */ +export function redact(text: string): string { + let redacted = text; + redacted = redacted.replace(META_ADDRESS_RE, (match) => pseudonymize('meta', match)); + redacted = redacted.replace(MUXED_ACCOUNT_RE, (match) => pseudonymize('muxed', match)); + redacted = redacted.replace(STRKEY_RE, (match) => + pseudonymize(STRKEY_KIND_BY_PREFIX[match[0]] ?? 'id', match), + ); + redacted = redacted.replace(HEX_BLOB_RE, (match) => pseudonymize('hash', match)); + return redacted; +} + +// --------------------------------------------------------------------------- +// Vitest reporter +// --------------------------------------------------------------------------- + +/** Minimal shape of a Vitest `UserConsoleLog` — only the field we redact. */ +interface RedactableConsoleLog { + content: string; +} + +/** Minimal shape of a Vitest `TestError` / `SerializedError` — only the fields we redact. */ +interface RedactableError { + message?: string; + stack?: string; + diff?: string; + actual?: string; + expected?: string; + cause?: RedactableError; + [key: string]: unknown; +} + +/** Minimal shape of a Vitest `TestCase` / `TestSuite` — only what we need to reach its errors. */ +interface RedactableTaskResult { + result?: () => { errors?: readonly RedactableError[] } | undefined; +} + +function redactErrorInPlace(error: RedactableError | undefined): void { + if (!error) return; + if (typeof error.message === 'string') error.message = redact(error.message); + if (typeof error.stack === 'string') error.stack = redact(error.stack); + if (typeof error.diff === 'string') error.diff = redact(error.diff); + if (typeof error.actual === 'string') error.actual = redact(error.actual); + if (typeof error.expected === 'string') error.expected = redact(error.expected); + redactErrorInPlace(error.cause); +} + +/** + * Vitest reporter that redacts Stellar identifiers from failure output. + * + * Register it alongside your normal reporter, listed *before* it, so the + * redaction runs before anything is printed: + * + * ```ts + * // vitest.config.ts + * import { RedactingReporter } from "@wraith-protocol/sdk/chains/stellar"; + * + * export default defineConfig({ + * test: { reporters: [new RedactingReporter(), "default"] }, + * }); + * ``` + * + * This has no dependency on the `vitest` package — it only relies on the + * shape of the objects Vitest passes to reporter hooks — so it is safe to + * import outside of a Vitest process. + */ +export class RedactingReporter { + onTestCaseResult(testCase: RedactableTaskResult): void { + for (const error of testCase.result?.()?.errors ?? []) { + redactErrorInPlace(error); + } + } + + onTestSuiteResult(testSuite: RedactableTaskResult): void { + for (const error of testSuite.result?.()?.errors ?? []) { + redactErrorInPlace(error); + } + } + + onTestRunEnd(_testModules: unknown, unhandledErrors?: readonly RedactableError[]): void { + for (const error of unhandledErrors ?? []) { + redactErrorInPlace(error); + } + } + + onUserConsoleLog(log: RedactableConsoleLog): void { + if (typeof log?.content === 'string') { + log.content = redact(log.content); + } + } +} diff --git a/test/chains/stellar/log/redact.test.ts b/test/chains/stellar/log/redact.test.ts new file mode 100644 index 0000000..c1bb117 --- /dev/null +++ b/test/chains/stellar/log/redact.test.ts @@ -0,0 +1,140 @@ +import { describe, test, expect } from 'vitest'; +import { redact, RedactingReporter } from '../../../../src/chains/stellar/log/redact'; + +const account = 'G' + 'A'.repeat(55); +const otherAccount = 'G' + 'B'.repeat(55); +const secret = 'S' + 'A'.repeat(55); +const contract = 'C' + 'A'.repeat(55); +const preauth = 'T' + 'A'.repeat(55); +const hashx = 'X' + 'A'.repeat(55); +const pool = 'L' + 'A'.repeat(55); +const muxed = 'M' + 'A'.repeat(68); +const txHash = 'ab'.repeat(32); +const metaAddress = 'st:xlm:' + 'cd'.repeat(64); + +describe('redact', () => { + test('redacts an account strkey', () => { + const out = redact(`scanning ${account} for payments`); + expect(out).not.toContain(account); + expect(out).toMatch(/\[redact:account:[0-9a-f]{10}\]/); + }); + + test('redacts every identifier kind in a canned log', () => { + const log = [ + `account=${account}`, + `secret=${secret}`, + `contract=${contract}`, + `preauth=${preauth}`, + `hashx=${hashx}`, + `pool=${pool}`, + `muxed=${muxed}`, + `tx=${txHash}`, + `meta=${metaAddress}`, + ].join('\n'); + + const out = redact(log); + + for (const identifier of [ + account, + secret, + contract, + preauth, + hashx, + pool, + muxed, + txHash, + metaAddress, + ]) { + expect(out).not.toContain(identifier); + } + + expect(out).toMatch(/\[redact:account:[0-9a-f]{10}\]/); + expect(out).toMatch(/\[redact:secret:[0-9a-f]{10}\]/); + expect(out).toMatch(/\[redact:contract:[0-9a-f]{10}\]/); + expect(out).toMatch(/\[redact:preauth:[0-9a-f]{10}\]/); + expect(out).toMatch(/\[redact:hashx:[0-9a-f]{10}\]/); + expect(out).toMatch(/\[redact:pool:[0-9a-f]{10}\]/); + expect(out).toMatch(/\[redact:muxed:[0-9a-f]{10}\]/); + expect(out).toMatch(/\[redact:hash:[0-9a-f]{10}\]/); + expect(out).toMatch(/\[redact:meta:[0-9a-f]{10}\]/); + }); + + test('pseudonyms are stable across calls', () => { + const first = redact(account); + const second = redact(account); + expect(first).toBe(second); + }); + + test('different identifiers get different pseudonyms', () => { + expect(redact(account)).not.toBe(redact(otherAccount)); + }); + + test('is idempotent — redacting already-redacted text is a no-op', () => { + const once = redact(`caller ${account}`); + const twice = redact(once); + expect(twice).toBe(once); + }); + + test('leaves non-identifier text untouched', () => { + const text = 'no secrets here, just a regular log line'; + expect(redact(text)).toBe(text); + }); + + test('does not redact a bare 32-char hex string (too short to be a hash)', () => { + const shortHex = 'ab'.repeat(16); + expect(redact(shortHex)).toBe(shortHex); + }); +}); + +describe('RedactingReporter', () => { + test('redacts error message, stack, and cause on test case failure', () => { + const reporter = new RedactingReporter(); + const error = { + message: `expected ${account}`, + stack: `Error: mismatch\n at scan (${txHash})`, + cause: { message: `nested ${contract}` }, + }; + const testCase = { result: () => ({ errors: [error] }) }; + + reporter.onTestCaseResult(testCase); + + expect(error.message).not.toContain(account); + expect(error.stack).not.toContain(txHash); + expect(error.cause.message).not.toContain(contract); + }); + + test('redacts suite-level errors', () => { + const reporter = new RedactingReporter(); + const error = { message: `beforeAll failed for ${account}` }; + const testSuite = { result: () => ({ errors: [error] }) }; + + reporter.onTestSuiteResult(testSuite); + + expect(error.message).not.toContain(account); + }); + + test('redacts unhandled errors passed to onTestRunEnd', () => { + const reporter = new RedactingReporter(); + const error = { message: `unhandled rejection: ${account}` }; + + reporter.onTestRunEnd([], [error]); + + expect(error.message).not.toContain(account); + }); + + test('redacts console log content in place', () => { + const reporter = new RedactingReporter(); + const log = { content: `debug: matched ${account}` }; + + reporter.onUserConsoleLog(log); + + expect(log.content).not.toContain(account); + expect(log.content).toMatch(/\[redact:account:[0-9a-f]{10}\]/); + }); + + test('tolerates a passing test case with no errors', () => { + const reporter = new RedactingReporter(); + expect(() => reporter.onTestCaseResult({ result: () => ({ errors: [] }) })).not.toThrow(); + expect(() => reporter.onTestCaseResult({ result: () => undefined })).not.toThrow(); + }); +});