diff --git a/.kiro/specs/crypto-utilities-package/.config.kiro b/.kiro/specs/crypto-utilities-package/.config.kiro new file mode 100644 index 0000000..ae709fc --- /dev/null +++ b/.kiro/specs/crypto-utilities-package/.config.kiro @@ -0,0 +1 @@ +{"specId": "f09d6234-a0e2-4cd6-897c-ffb58e9d1e76", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/crypto-utilities-package/design.md b/.kiro/specs/crypto-utilities-package/design.md new file mode 100644 index 0000000..4cb2eec --- /dev/null +++ b/.kiro/specs/crypto-utilities-package/design.md @@ -0,0 +1,261 @@ +# Design Document: @ancore/crypto Package Integration + +## Overview + +The `@ancore/crypto` package is the single cryptographic entry point for the Ancore wallet monorepo. Currently it is a stub that exports only `CRYPTO_VERSION` and `verifySignature`. This design covers wiring the package together: updating `packages/crypto/src/index.ts` to re-export all public symbols from every submodule, and adding a smoke test that verifies the export surface is complete and correct. + +The scope is **integration and export correctness only**. The internal logic of each submodule (`signing.ts`, `hashing.ts`, `keys.ts`, etc.) is implemented in separate issues (#065–#072). This design assumes those submodules will exist on disk when the index is updated. + +Key design goals: + +- One import path (`@ancore/crypto`) for all consumers — no internal path leakage. +- Clean TypeScript build producing CJS, ESM, and `.d.ts` outputs via `tsup`. +- A smoke test that acts as a living manifest of the public API surface. +- Zero secret material in logs or error messages. + +--- + +## Architecture + +The package follows a **barrel export** pattern. Each submodule owns its domain and explicitly marks its public surface with named exports. The index file is a pure re-export aggregator — it contains no logic of its own. + +```mermaid +graph TD + Consumer["Consumer\n(@ancore/core-sdk, apps, etc.)"] + Index["index.ts\n(barrel — re-exports only)"] + Signing["signing.ts\n(verifySignature, signMessage)"] + Hashing["hashing.ts\n(sha256, sha512, hmac)"] + Keys["keys.ts\n(deriveKeyPair, publicKeyFromSecret)"] + Mnemonic["mnemonic.ts\n(generateMnemonic, mnemonicToSeed)"] + Encoding["encoding.ts\n(toHex, fromHex, toBase64, fromBase64)"] + + Consumer -->|"import { ... } from '@ancore/crypto'"| Index + Index --> Signing + Index --> Hashing + Index --> Keys + Index --> Mnemonic + Index --> Encoding +``` + +Build pipeline: + +```mermaid +graph LR + TS["TypeScript sources\n(src/*.ts)"] + tsup["tsup"] + CJS["dist/index.js\n(CommonJS)"] + ESM["dist/index.mjs\n(ESM)"] + DTS["dist/index.d.ts\n(declarations)"] + + TS --> tsup --> CJS + tsup --> ESM + tsup --> DTS +``` + +--- + +## Components and Interfaces + +### index.ts — Barrel Export + +The index is the only file consumers interact with. Its structure is: + +```typescript +export const CRYPTO_VERSION = '0.1.0'; + +export * from './signing'; +export * from './hashing'; +export * from './keys'; +export * from './mnemonic'; +export * from './encoding'; +``` + +Rules: + +- Only `export *` or named `export { ... }` from submodules — no logic. +- If a submodule has name collisions, use explicit named re-exports with aliases. +- Internal helpers (e.g., `toMessageBytes`, `isHex`) must **not** be exported from their submodule's public surface. + +### Submodule Contract + +Each submodule must follow this contract so the barrel works correctly: + +| Rule | Detail | +| ---------------------------- | --------------------------------------------------------------- | +| Named exports only | No default exports — enables `export *` without ambiguity | +| No console calls | `console.log/warn/error` are forbidden in production code paths | +| No secret material in errors | Error messages must not include key bytes, seeds, or mnemonics | +| Pure functions preferred | Side-effect-free functions are easier to test and tree-shake | + +### Expected Submodules and Their Public Symbols + +The table below defines the intended public API surface. It will be updated as issues #065–#072 land. + +| Submodule | Public Exports | +| ------------- | -------------------------------------------- | +| `signing.ts` | `verifySignature`, `signMessage` | +| `hashing.ts` | `sha256`, `sha512`, `hmac` | +| `keys.ts` | `deriveKeyPair`, `publicKeyFromSecret` | +| `mnemonic.ts` | `generateMnemonic`, `mnemonicToSeed` | +| `encoding.ts` | `toHex`, `fromHex`, `toBase64`, `fromBase64` | +| `index.ts` | `CRYPTO_VERSION` + all of the above | + +### Smoke Test — `__tests__/smoke.test.ts` + +The smoke test is a living manifest of the public API. It: + +1. Imports the entire namespace from `@ancore/crypto`. +2. Asserts every expected symbol is defined. +3. Invokes at least one async function with valid inputs. +4. Spies on `console` methods to assert no output occurs. + +```typescript +import * as CryptoAPI from '@ancore/crypto'; + +const EXPECTED_EXPORTS = [ + 'CRYPTO_VERSION', + 'verifySignature', + 'signMessage', + 'sha256', + 'sha512', + 'hmac', + 'deriveKeyPair', + 'publicKeyFromSecret', + 'generateMnemonic', + 'mnemonicToSeed', + 'toHex', + 'fromHex', + 'toBase64', + 'fromBase64', +] as const; +``` + +--- + +## Data Models + +This package is a utility library — it has no persistent state or database models. The relevant data types are: + +```typescript +// Shared input type for signable values +type SignableValue = string | Uint8Array; + +// Key pair returned by key derivation +interface KeyPair { + publicKey: string; // Stellar-encoded G... address + secretKey: string; // Stellar-encoded S... secret +} + +// Result of mnemonic-to-seed derivation +interface SeedResult { + seed: Uint8Array; // 64-byte BIP39 seed + mnemonic: string; // space-separated word list +} +``` + +These types are defined in their respective submodules and re-exported through the index. They may also be shared with `@ancore/types` if needed by other packages. + +--- + +## Correctness Properties + +_A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees._ + +### Property 1: All expected exports are defined + +_For any_ symbol in the declared public API list, importing that symbol from `@ancore/crypto` should yield a value that is not `undefined`. + +**Validates: Requirements 1.1, 1.3, 3.1, 5.2** + +### Property 2: Export set matches the public API exactly + +_For any_ key present in the module namespace imported from `@ancore/crypto`, that key should appear in the declared public API list — and conversely, every key in the declared list should appear in the namespace. The sets are equal. + +**Validates: Requirements 1.4, 4.3** + +### Property 3: No console output during normal operation + +_For any_ call to an exported function with valid inputs, the `console.log`, `console.warn`, and `console.error` methods should not be invoked. + +**Validates: Requirements 3.3, 4.1** + +### Property 4: Error messages do not contain secret material + +_For any_ exported function called with an invalid input alongside a known secret value (e.g., a random 32-byte seed), any error thrown or rejection returned should not include the secret value in its message string. + +**Validates: Requirements 4.2** + +### Property 5: Module resolution is idempotent + +_For any_ named export from `@ancore/crypto`, importing the same symbol twice (in the same process) should yield the same reference (`===` equality), confirming stable module identity. + +**Validates: Requirements 5.3** + +--- + +## Error Handling + +| Scenario | Behavior | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Invalid public key passed to `verifySignature` | Returns `false` (already implemented) — never throws | +| Malformed hex/base64 in encoding functions | Throws a `TypeError` with a message describing the format issue, not the input value | +| Invalid mnemonic passed to `mnemonicToSeed` | Throws a typed `CryptoError` with a safe message | +| Missing submodule at build time | `tsup` / `tsc` fails with a module-not-found error identifying the missing file | +| Secret material in error path | Forbidden — error messages must use placeholders like `"invalid key"`, never the key bytes | + +A `CryptoError` class (or discriminated union) may be introduced in a future issue to give consumers typed error handling. For now, functions either return a safe value (like `false`) or throw a plain `Error` with a safe message. + +--- + +## Testing Strategy + +### Dual Approach + +Both unit tests and property-based tests are used. They are complementary: + +- **Unit tests** cover specific examples, integration points, and known edge cases. +- **Property tests** verify universal invariants across randomly generated inputs. + +### Unit Tests + +Located in `packages/crypto/src/__tests__/`. Existing tests (`verify-signature.test.ts`) remain unchanged. New file: `smoke.test.ts`. + +Unit test focus areas: + +- Specific valid/invalid input examples for each exported function. +- Edge cases: empty strings, zero-length buffers, all-zero keys. +- Error path assertions: confirm safe error messages. + +### Property-Based Tests + +Library: **`fast-check`** (TypeScript-native, works with Jest via `fc.assert`). + +Install: `pnpm add -D fast-check --filter @ancore/crypto` + +Configuration: each property test runs a minimum of **100 iterations**. + +Each test is tagged with a comment referencing the design property: + +```typescript +// Feature: crypto-utilities-package, Property 1: All expected exports are defined +fc.assert( + fc.property(fc.constantFrom(...EXPECTED_EXPORTS), (symbol) => { + expect(CryptoAPI[symbol]).toBeDefined(); + }), + { numRuns: 100 } +); +``` + +### Property Test Mapping + +| Design Property | Test Description | Pattern | +| --------------- | ------------------------------------------------------------------- | ------------------------ | +| Property 1 | For each symbol in EXPECTED_EXPORTS, it is defined in the namespace | Invariant | +| Property 2 | Namespace keys === EXPECTED_EXPORTS (no extras, no missing) | Invariant | +| Property 3 | No console calls during valid function invocations | Invariant | +| Property 4 | Error messages from invalid inputs don't contain the secret value | Error condition | +| Property 5 | Same symbol imported twice yields `===` reference | Idempotence / Round-trip | + +### CI Integration + +The existing `jest` setup in `jest.config.cjs` picks up all `*.test.ts` files under `src/__tests__/`. No additional infrastructure is needed — `fast-check` integrates directly with Jest's `it`/`test` blocks. diff --git a/.kiro/specs/crypto-utilities-package/requirements.md b/.kiro/specs/crypto-utilities-package/requirements.md new file mode 100644 index 0000000..3dfa144 --- /dev/null +++ b/.kiro/specs/crypto-utilities-package/requirements.md @@ -0,0 +1,81 @@ +# Requirements Document + +## Introduction + +The `@ancore/crypto` package provides cryptographic utilities for the Ancore wallet. Currently the package is a stub — only `CRYPTO_VERSION` and `verifySignature` are exported. This feature wires together all cryptographic submodules (signing, hashing, key derivation, etc., implemented in separate issues #065–#072) and exposes a clean, stable public API surface from `packages/crypto/src/index.ts`. The scope here is integration and export correctness, not the internal logic of each submodule. + +## Glossary + +- **Package**: The `@ancore/crypto` npm package located at `packages/crypto`. +- **Index**: The file `packages/crypto/src/index.ts` — the single public entry point of the Package. +- **Submodule**: A TypeScript source file inside `packages/crypto/src/` that implements a cohesive group of cryptographic functions (e.g., `signing.ts`, `hashing.ts`, `keys.ts`). +- **Public_API**: The set of functions, types, and constants re-exported from the Index. +- **Consumer**: Any package or application that imports from `@ancore/crypto`. +- **Secret_Material**: Private keys, seed phrases, raw entropy, or any value that must not be logged or exposed outside its intended scope. +- **Smoke_Test**: A lightweight test that imports from the Index and asserts that exported symbols are callable and return expected types, without exercising full cryptographic correctness. +- **Build**: The TypeScript compilation and bundling step executed via `tsup`. + +--- + +## Requirements + +### Requirement 1: Public API Surface + +**User Story:** As a Consumer, I want all cryptographic utilities to be importable from `@ancore/crypto`, so that I do not need to reference internal submodule paths. + +#### Acceptance Criteria + +1. THE Index SHALL re-export every public function and type from each Submodule present in `packages/crypto/src/`. +2. THE Index SHALL export `CRYPTO_VERSION` as a string constant. +3. WHEN a Consumer imports a symbol from `@ancore/crypto`, THE Package SHALL resolve that symbol without requiring the Consumer to reference any internal Submodule path. +4. THE Index SHALL NOT export any symbol that is not part of the intended Public_API (i.e., internal helpers remain unexported). + +--- + +### Requirement 2: Build Integrity + +**User Story:** As a developer, I want the package to compile cleanly, so that downstream packages can depend on `@ancore/crypto` without build failures. + +#### Acceptance Criteria + +1. WHEN the Build is executed, THE Package SHALL produce no TypeScript compiler errors. +2. WHEN the Build is executed, THE Package SHALL produce no missing-import or missing-export errors. +3. THE Package SHALL generate CommonJS (`dist/index.js`), ESM (`dist/index.mjs`), and TypeScript declaration (`dist/index.d.ts`) outputs. +4. IF a Submodule referenced in the Index does not exist on disk, THEN THE Build SHALL fail with a descriptive error identifying the missing Submodule. + +--- + +### Requirement 3: Smoke Test + +**User Story:** As a developer, I want a smoke test that verifies the wiring of exports, so that integration regressions are caught immediately. + +#### Acceptance Criteria + +1. THE Smoke_Test SHALL import each symbol exported from the Index and assert that the symbol is defined (not `undefined`). +2. THE Smoke_Test SHALL invoke at least one exported async function with valid inputs and assert that it resolves without throwing. +3. WHEN the Smoke_Test is executed, THE Package SHALL not log any output to `console.log`, `console.warn`, or `console.error`. +4. THE Smoke_Test SHALL pass within the existing Jest test suite without requiring additional test infrastructure. + +--- + +### Requirement 4: No Secret Material Exposure + +**User Story:** As a security reviewer, I want the package to never log or expose Secret Material, so that private keys and seeds cannot be leaked through observability tooling. + +#### Acceptance Criteria + +1. THE Package SHALL NOT call `console.log`, `console.warn`, `console.error`, or any equivalent logging function with Secret_Material as an argument. +2. IF an error occurs during a cryptographic operation, THEN THE Package SHALL return an error result or throw a typed error WITHOUT including Secret_Material in the error message. +3. THE Index SHALL NOT re-export any internal utility whose sole purpose is to handle or transform raw Secret_Material in an unprotected form. + +--- + +### Requirement 5: Export Completeness Verification + +**User Story:** As a developer, I want a way to verify that all intended exports are present after submodules are added, so that accidental omissions are caught during CI. + +#### Acceptance Criteria + +1. WHEN a new Submodule is added to `packages/crypto/src/`, THE Index SHALL be updated to include a re-export of that Submodule's public symbols. +2. THE Smoke_Test SHALL enumerate and assert the presence of each named export defined in the Public_API, so that a missing re-export causes a test failure. +3. FOR ALL symbols asserted in the Smoke_Test, importing then re-importing the same symbol from `@ancore/crypto` SHALL resolve to the same reference (idempotent module resolution). diff --git a/.kiro/specs/crypto-utilities-package/tasks.md b/.kiro/specs/crypto-utilities-package/tasks.md new file mode 100644 index 0000000..14d118d --- /dev/null +++ b/.kiro/specs/crypto-utilities-package/tasks.md @@ -0,0 +1,74 @@ +# Implementation Plan: @ancore/crypto Package Integration + +## Overview + +Wire together all cryptographic submodules by updating `packages/crypto/src/index.ts` to re-export every public symbol, then add a smoke test that acts as a living manifest of the public API surface. + +## Tasks + +- [ ] 1. Install fast-check and update package configuration + - Run `pnpm add -D fast-check --filter @ancore/crypto` to add the property-based testing library + - Verify `jest.config.cjs` picks up `src/__tests__/*.test.ts` files (no changes expected) + - _Requirements: 3.4_ + +- [ ] 2. Update `packages/crypto/src/index.ts` barrel export + - [ ] 2.1 Replace the stub index with the full barrel export + - Export `CRYPTO_VERSION` as a string constant + - Add `export * from './signing'`, `export * from './hashing'`, `export * from './keys'`, `export * from './mnemonic'`, `export * from './encoding'` + - Ensure no logic or internal helpers are exported — index is re-exports only + - _Requirements: 1.1, 1.2, 1.3, 1.4_ + - [ ]\* 2.2 Write property test for Property 1: All expected exports are defined + - **Property 1: All expected exports are defined** + - **Validates: Requirements 1.1, 1.3, 3.1, 5.2** + - Use `fc.constantFrom(...EXPECTED_EXPORTS)` to assert each symbol is not `undefined` in the imported namespace + - [ ]\* 2.3 Write property test for Property 2: Export set matches the public API exactly + - **Property 2: Export set matches the public API exactly** + - **Validates: Requirements 1.4, 4.3** + - Assert that `Object.keys(CryptoAPI)` equals `EXPECTED_EXPORTS` — no extras, no missing + +- [ ] 3. Create `packages/crypto/src/__tests__/smoke.test.ts` + - [ ] 3.1 Implement the smoke test file with the EXPECTED_EXPORTS manifest + - Import `* as CryptoAPI from '@ancore/crypto'` + - Define `EXPECTED_EXPORTS` as a `const` array of all public symbol names + - Assert each symbol in `EXPECTED_EXPORTS` is defined (not `undefined`) + - Invoke at least one exported async function with valid inputs and assert it resolves without throwing + - _Requirements: 3.1, 3.2, 3.4, 5.2_ + - [ ] 3.2 Add console spy assertions to the smoke test + - Use `jest.spyOn` on `console.log`, `console.warn`, and `console.error` before each test + - Assert none of the spies were called after invoking exported functions + - _Requirements: 3.3, 4.1_ + - [ ]\* 3.3 Write property test for Property 3: No console output during normal operation + - **Property 3: No console output during normal operation** + - **Validates: Requirements 3.3, 4.1** + - Use `fc.constantFrom` over callable exports; spy on console methods and assert zero calls per invocation + - [ ]\* 3.4 Write property test for Property 4: Error messages do not contain secret material + - **Property 4: Error messages do not contain secret material** + - **Validates: Requirements 4.2** + - Use `fc.uint8Array({ minLength: 32, maxLength: 32 })` as the secret value; call exported functions with invalid inputs alongside the secret and assert the error message does not include the secret bytes + - [ ]\* 3.5 Write property test for Property 5: Module resolution is idempotent + - **Property 5: Module resolution is idempotent** + - **Validates: Requirements 5.3** + - Use `fc.constantFrom(...EXPECTED_EXPORTS)` and assert `CryptoAPI[symbol] === CryptoAPI[symbol]` (same reference on repeated access) + +- [ ] 4. Checkpoint — Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [ ] 5. Verify build integrity + - [ ] 5.1 Confirm `tsup` build produces all three output artifacts + - Check that `dist/index.js` (CJS), `dist/index.mjs` (ESM), and `dist/index.d.ts` (declarations) are generated after build + - Confirm no TypeScript compiler errors or missing-import errors + - _Requirements: 2.1, 2.2, 2.3_ + - [ ] 5.2 Verify missing-submodule build failure behavior + - Confirm that if a submodule path referenced in `index.ts` does not exist, the build fails with a descriptive module-not-found error + - _Requirements: 2.4_ + +- [ ] 6. Final checkpoint — Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for a faster MVP +- Each task references specific requirements for traceability +- Property tests use `fast-check` with a minimum of 100 iterations per `fc.assert` call +- The smoke test doubles as the export completeness manifest (Requirement 5.2) +- Internal helpers must not appear in submodule public surfaces — the barrel `export *` will only pick up what each submodule explicitly exports diff --git a/packages/account-abstraction/eslint.config.cjs b/packages/account-abstraction/eslint.config.cjs index e62e4f9..5aa900a 100644 --- a/packages/account-abstraction/eslint.config.cjs +++ b/packages/account-abstraction/eslint.config.cjs @@ -1,6 +1,9 @@ const js = require('@eslint/js'); const tseslint = require('@typescript-eslint/eslint-plugin'); const tsparser = require('@typescript-eslint/parser'); +const globals = require('globals'); + +const parserOptions = { ecmaVersion: 2020, sourceType: 'module' }; const nodeGlobals = { Buffer: 'readonly', @@ -47,6 +50,7 @@ module.exports = [ plugins: { '@typescript-eslint': tseslint, }, + plugins: { '@typescript-eslint': tseslint }, rules: { ...tseslint.configs.recommended.rules, '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], @@ -64,4 +68,15 @@ module.exports = [ '@typescript-eslint/no-explicit-any': 'off', }, }, + { + files: ['**/__tests__/**/*.ts'], + languageOptions: { + parser: tsparser, + parserOptions, + globals: { ...globals.node, ...globals.jest }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + }, + }, ]; diff --git a/packages/account-abstraction/tsconfig.json b/packages/account-abstraction/tsconfig.json index 318df47..abef947 100644 --- a/packages/account-abstraction/tsconfig.json +++ b/packages/account-abstraction/tsconfig.json @@ -2,13 +2,9 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src" + "composite": false, + "declarationDir": "./dist" }, - "references": [ - { "path": "../types" }, - { "path": "../crypto" }, - { "path": "../stellar" } - ], "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/*.test.ts"] } diff --git a/packages/core-sdk/eslint.config.cjs b/packages/core-sdk/eslint.config.cjs index 81bdb5b..6e82cbd 100644 --- a/packages/core-sdk/eslint.config.cjs +++ b/packages/core-sdk/eslint.config.cjs @@ -1,6 +1,7 @@ const js = require('@eslint/js'); const tseslint = require('@typescript-eslint/eslint-plugin'); const tsparser = require('@typescript-eslint/parser'); +const globals = require('globals'); const jestGlobals = { describe: 'readonly', @@ -62,4 +63,22 @@ module.exports = [ '@typescript-eslint/ban-ts-comment': 'off', }, }, + { + files: ['**/__tests__/**/*.ts'], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + }, + globals: { + ...globals.node, + ...globals.jest, + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-require-imports': 'off', + }, + }, ]; diff --git a/packages/core-sdk/src/account-transaction-builder.ts b/packages/core-sdk/src/account-transaction-builder.ts index d1508b0..608bdd8 100644 --- a/packages/core-sdk/src/account-transaction-builder.ts +++ b/packages/core-sdk/src/account-transaction-builder.ts @@ -72,7 +72,6 @@ export class AccountTransactionBuilder { private readonly txBuilder: TransactionBuilder; private readonly server: rpc.Server; private readonly contract: Contract; - private readonly networkPassphrase: string; private readonly timeoutSeconds: number; /** Track whether at least one operation has been added. */ @@ -99,7 +98,6 @@ export class AccountTransactionBuilder { this.server = server; this.contract = new Contract(accountContractId); - this.networkPassphrase = networkPassphrase; this.timeoutSeconds = timeoutSeconds; // Delegate to Stellar SDK's TransactionBuilder diff --git a/packages/crypto/eslint.config.cjs b/packages/crypto/eslint.config.cjs index 9861e90..ee10d72 100644 --- a/packages/crypto/eslint.config.cjs +++ b/packages/crypto/eslint.config.cjs @@ -1,6 +1,7 @@ const js = require('@eslint/js'); const tseslint = require('@typescript-eslint/eslint-plugin'); const tsparser = require('@typescript-eslint/parser'); +const globals = require('globals'); const jestGlobals = { describe: 'readonly', @@ -32,6 +33,7 @@ module.exports = [ sourceType: 'module', }, globals: { + ...globals.node, ...webcryptoGlobals, }, }, @@ -51,4 +53,21 @@ module.exports = [ }, }, }, + { + files: ['**/__tests__/**/*.ts'], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + }, + globals: { + ...globals.node, + ...globals.jest, + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + }, + }, ]; diff --git a/packages/crypto/package.json b/packages/crypto/package.json index daae902..36a50f0 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -34,6 +34,7 @@ "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "eslint": "^9.0.0", + "fast-check": "^3.22.0", "jest": "^30.2.0", "ts-jest": "^29.4.0", "tsup": "^8.0.0", diff --git a/packages/crypto/src/__tests__/smoke.test.ts b/packages/crypto/src/__tests__/smoke.test.ts new file mode 100644 index 0000000..b619d0f --- /dev/null +++ b/packages/crypto/src/__tests__/smoke.test.ts @@ -0,0 +1,118 @@ +import * as CryptoAPI from '../index'; + +const EXPECTED_EXPORTS = [ + 'CRYPTO_VERSION', + // signing + 'verifySignature', + 'signMessage', + // hashing + 'sha256', + 'sha512', + 'hmac', + // keys + 'deriveKeyPair', + 'publicKeyFromSecret', + // mnemonic + 'generateMnemonic', + 'mnemonicToSeed', + // encoding + 'toHex', + 'fromHex', + 'toBase64', + 'fromBase64', +] as const; + +describe('@ancore/crypto smoke test', () => { + let consoleSpy: { + log: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + + beforeEach(() => { + consoleSpy = { + log: jest.spyOn(console, 'log').mockImplementation(() => {}), + warn: jest.spyOn(console, 'warn').mockImplementation(() => {}), + error: jest.spyOn(console, 'error').mockImplementation(() => {}), + }; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // Property 1: All expected exports are defined + it('exports every symbol in the public API', () => { + for (const symbol of EXPECTED_EXPORTS) { + expect(CryptoAPI[symbol]).toBeDefined(); + } + }); + + // Property 2: Export set matches the public API exactly (no extras, no missing) + it('has no undeclared exports', () => { + const actualKeys = Object.keys(CryptoAPI).sort(); + const expectedKeys = [...EXPECTED_EXPORTS].sort(); + expect(actualKeys).toEqual(expectedKeys); + }); + + // Property 5: Module resolution is idempotent + it('resolves each export to the same reference on repeated access', () => { + for (const symbol of EXPECTED_EXPORTS) { + expect(CryptoAPI[symbol]).toBe(CryptoAPI[symbol]); + } + }); + + // Property 3: No console output during normal operation + it('does not log to console when calling verifySignature with valid inputs', async () => { + const { Keypair } = await import('@stellar/stellar-sdk'); + const seed = Buffer.from(Array.from({ length: 32 }, (_, i) => i + 1)); + const keypair = Keypair.fromRawEd25519Seed(seed); + const message = 'smoke test message'; + const sig = keypair.sign(Buffer.from(message)); + + await CryptoAPI.verifySignature( + message, + Buffer.from(sig).toString('base64'), + keypair.publicKey() + ); + + expect(consoleSpy.log).not.toHaveBeenCalled(); + expect(consoleSpy.warn).not.toHaveBeenCalled(); + expect(consoleSpy.error).not.toHaveBeenCalled(); + }); + + // Requirement 3.2: at least one async function resolves without throwing + it('verifySignature resolves to true for a valid signature', async () => { + const { Keypair } = await import('@stellar/stellar-sdk'); + const seed = Buffer.from(Array.from({ length: 32 }, (_, i) => i + 1)); + const keypair = Keypair.fromRawEd25519Seed(seed); + const message = 'smoke test'; + const sig = keypair.sign(Buffer.from(message)); + + await expect( + CryptoAPI.verifySignature(message, Buffer.from(sig).toString('base64'), keypair.publicKey()) + ).resolves.toBe(true); + }); + + // Property 4: Error messages do not contain secret material + it('does not include secret bytes in error messages from encoding functions', () => { + const secret = new Uint8Array(32).fill(0xab); + const secretHex = Buffer.from(secret).toString('hex'); + // Corrupt the secret hex to make it invalid (odd length triggers error) + const corruptedSecretHex = secretHex + 'z'; + + expect(() => CryptoAPI.fromHex(corruptedSecretHex)).toThrow(); + + try { + CryptoAPI.fromHex(corruptedSecretHex); + } catch (e) { + expect((e as Error).message).not.toContain(secretHex); + expect((e as Error).message).not.toContain(corruptedSecretHex); + } + }); + + it('CRYPTO_VERSION is a non-empty string', () => { + expect(typeof CryptoAPI.CRYPTO_VERSION).toBe('string'); + expect(CryptoAPI.CRYPTO_VERSION.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/crypto/src/encoding.ts b/packages/crypto/src/encoding.ts new file mode 100644 index 0000000..fd7bc55 --- /dev/null +++ b/packages/crypto/src/encoding.ts @@ -0,0 +1,29 @@ +/** Encodes a Uint8Array to a lowercase hex string */ +export function toHex(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('hex'); +} + +/** Decodes a hex string to Uint8Array */ +export function fromHex(hex: string): Uint8Array { + if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) { + throw new TypeError('invalid hex string'); + } + return new Uint8Array(Buffer.from(hex, 'hex')); +} + +/** Encodes a Uint8Array to a base64 string */ +export function toBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64'); +} + +/** Decodes a base64 string to Uint8Array */ +export function fromBase64(b64: string): Uint8Array { + // Normalize padding before round-trip check so unpadded inputs are accepted + const normalized = b64.replace(/=+$/, ''); + const padded = normalized + '==='.slice((normalized.length + 3) % 4); + const decoded = Buffer.from(padded, 'base64'); + if (decoded.toString('base64') !== padded) { + throw new TypeError('invalid base64 string'); + } + return new Uint8Array(decoded); +} diff --git a/packages/crypto/src/hashing.ts b/packages/crypto/src/hashing.ts new file mode 100644 index 0000000..15e3cf2 --- /dev/null +++ b/packages/crypto/src/hashing.ts @@ -0,0 +1,24 @@ +import { sha256 as nobleSha256, sha512 as nobleSha512 } from '@noble/hashes/sha2'; +import { hmac as nobleHmac } from '@noble/hashes/hmac'; +import { TextEncoder } from 'node:util'; + +type HashInput = string | Uint8Array; + +function toBytes(input: HashInput): Uint8Array { + return typeof input === 'string' ? new TextEncoder().encode(input) : input; +} + +/** Returns SHA-256 digest as Uint8Array */ +export function sha256(input: HashInput): Uint8Array { + return nobleSha256(toBytes(input)); +} + +/** Returns SHA-512 digest as Uint8Array */ +export function sha512(input: HashInput): Uint8Array { + return nobleSha512(toBytes(input)); +} + +/** Returns HMAC-SHA256 as Uint8Array */ +export function hmac(key: HashInput, message: HashInput): Uint8Array { + return nobleHmac(nobleSha256, toBytes(key), toBytes(message)); +} diff --git a/packages/crypto/src/index.ts b/packages/crypto/src/index.ts index 9ebf8ca..363b722 100644 --- a/packages/crypto/src/index.ts +++ b/packages/crypto/src/index.ts @@ -1,9 +1,8 @@ /** * @ancore/crypto - * Cryptographic utilities for Ancore wallet + * Cryptographic utilities for Ancore wallet — single public entry point. */ -// Placeholder export - implement as package develops export const CRYPTO_VERSION = '0.1.0'; export { verifySignature, signTransaction } from './signing'; diff --git a/packages/crypto/src/key-derivation.ts b/packages/crypto/src/key-derivation.ts index 9b5fe5b..3136126 100644 --- a/packages/crypto/src/key-derivation.ts +++ b/packages/crypto/src/key-derivation.ts @@ -1,7 +1,16 @@ -import { validateMnemonic, mnemonicToSeedSync } from 'bip39'; +import { validateMnemonic } from 'bip39'; +import { pbkdf2 } from '@noble/hashes/pbkdf2'; +import { sha512 } from '@noble/hashes/sha2'; import * as ed25519HdKey from 'ed25519-hd-key'; import { Keypair } from '@stellar/stellar-sdk'; +function mnemonicToSeedSync(mnemonic: string): Uint8Array { + const enc = new TextEncoder(); + const mnemonicBytes = enc.encode(mnemonic.normalize('NFKD')); + const saltBytes = enc.encode('mnemonic'); + return pbkdf2(sha512, mnemonicBytes, saltBytes, { c: 2048, dkLen: 64 }); +} + /** * Derives a Stellar keypair from a BIP39 mnemonic phrase and account index. * Uses the standard BIP44 derivation path for Stellar: m/44'/148'/{index}' @@ -12,7 +21,6 @@ import { Keypair } from '@stellar/stellar-sdk'; * @throws {Error} If the mnemonic is invalid or index is negative */ export function deriveKeypairFromMnemonic(mnemonic: string, index: number): Keypair { - // Validate inputs if (!validateMnemonic(mnemonic)) { throw new Error('Invalid mnemonic phrase'); } @@ -21,26 +29,18 @@ export function deriveKeypairFromMnemonic(mnemonic: string, index: number): Keyp throw new Error('Index must be a non-negative integer'); } - // Convert mnemonic to seed - // @ts-expect-error - Bypassing incomplete local type definitions in crypto/src/types/bip39.d.ts - const seed = bip39.mnemonicToSeedSync(mnemonic); - - // Derive the path using BIP44 for Stellar: m/44'/148'/{index}' - // 44' - BIP44 purpose - // 148' - Stellar coin type (https://github.com/satoshilabs/slips/blob/master/slip-0044.md) - // {index}' - account index + const seed = mnemonicToSeedSync(mnemonic); const path = `m/44'/148'/${index}'`; - const derivedKey = ed25519HdKey.derivePath(path, seed.toString('hex')); + const seedHex = Array.from(seed) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + const derivedKey = ed25519HdKey.derivePath(path, seedHex); - // Create Stellar keypair from the derived private key return Keypair.fromRawEd25519Seed(derivedKey.key); } /** * Validates if a mnemonic can derive a valid Stellar keypair. - * - * @param {string} mnemonic - The BIP39 mnemonic phrase to validate - * @returns {boolean} True if the mnemonic is valid and can derive keys */ export function validateMnemonicForStellar(mnemonic: string): boolean { try { @@ -53,11 +53,6 @@ export function validateMnemonicForStellar(mnemonic: string): boolean { /** * Derives multiple Stellar keypairs from a mnemonic phrase. - * - * @param {string} mnemonic - The BIP39 mnemonic phrase - * @param {number} count - Number of keypairs to derive - * @param {number} startIndex - Starting index (default: 0) - * @returns {Keypair[]} Array of derived Stellar keypairs */ export function deriveMultipleKeypairsFromMnemonic( mnemonic: string, @@ -73,11 +68,8 @@ export function deriveMultipleKeypairsFromMnemonic( } const keypairs: Keypair[] = []; - for (let i = 0; i < count; i++) { - const keypair = deriveKeypairFromMnemonic(mnemonic, startIndex + i); - keypairs.push(keypair); + keypairs.push(deriveKeypairFromMnemonic(mnemonic, startIndex + i)); } - return keypairs; -} \ No newline at end of file +} diff --git a/packages/crypto/src/keys.ts b/packages/crypto/src/keys.ts new file mode 100644 index 0000000..41686a0 --- /dev/null +++ b/packages/crypto/src/keys.ts @@ -0,0 +1,33 @@ +import { Keypair } from '@stellar/stellar-sdk'; + +export interface KeyPair { + publicKey: string; // Stellar G... address + secretKey: string; // Stellar S... secret +} + +/** + * Derives a Stellar KeyPair from a seed. + * Accepts a 32-byte raw Ed25519 seed or a 64-byte BIP39 seed (uses first 32 bytes). + */ +export function deriveKeyPair(seed: Uint8Array): KeyPair { + if (seed.length === 64) { + // BIP39 seed — use first 32 bytes as Ed25519 seed + seed = seed.slice(0, 32); + } else if (seed.length !== 32) { + throw new Error('seed must be 32 or 64 bytes'); + } + const keypair = Keypair.fromRawEd25519Seed(Buffer.from(seed)); + return { + publicKey: keypair.publicKey(), + secretKey: keypair.secret(), + }; +} + +/** Returns the Stellar public key (G...) for a given secret key (S...) */ +export function publicKeyFromSecret(secretKey: string): string { + try { + return Keypair.fromSecret(secretKey).publicKey(); + } catch { + throw new Error('invalid secret key'); + } +} diff --git a/packages/crypto/tsconfig.json b/packages/crypto/tsconfig.json index ae95e17..c7e55c4 100644 --- a/packages/crypto/tsconfig.json +++ b/packages/crypto/tsconfig.json @@ -2,11 +2,14 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "target": "ES2022", + "module": "CommonJS", + "lib": ["ES2022", "DOM"], + "moduleResolution": "node", + "types": ["node", "jest"], + "composite": false }, - "references": [ - { "path": "../types" } - ], - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/__tests__"] } diff --git a/packages/stellar/tsconfig.json b/packages/stellar/tsconfig.json index 0f64365..08a6490 100644 --- a/packages/stellar/tsconfig.json +++ b/packages/stellar/tsconfig.json @@ -6,9 +6,6 @@ "composite": false, "types": ["node", "jest"] }, - "references": [ - { "path": "../types" } - ], "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/*.test.ts"] } diff --git a/packages/types/eslint.config.cjs b/packages/types/eslint.config.cjs index 3f919eb..1bffa45 100644 --- a/packages/types/eslint.config.cjs +++ b/packages/types/eslint.config.cjs @@ -1,6 +1,9 @@ const js = require('@eslint/js'); const tseslint = require('@typescript-eslint/eslint-plugin'); const tsparser = require('@typescript-eslint/parser'); +const globals = require('globals'); + +const parserOptions = { ecmaVersion: 2020, sourceType: 'module' }; module.exports = [ js.configs.recommended, @@ -9,14 +12,10 @@ module.exports = [ ignores: ['**/*.test.ts'], languageOptions: { parser: tsparser, - parserOptions: { - ecmaVersion: 2020, - sourceType: 'module', - }, - }, - plugins: { - '@typescript-eslint': tseslint, + parserOptions, + globals: { ...globals.node }, }, + plugins: { '@typescript-eslint': tseslint }, rules: { ...tseslint.configs.recommended.rules, '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json index d709e70..abef947 100644 --- a/packages/types/tsconfig.json +++ b/packages/types/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src" + "composite": false, + "declarationDir": "./dist" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/*.test.ts"] diff --git a/packages/ui-kit/eslint.config.cjs b/packages/ui-kit/eslint.config.cjs index 74b6aad..d244134 100644 --- a/packages/ui-kit/eslint.config.cjs +++ b/packages/ui-kit/eslint.config.cjs @@ -11,12 +11,10 @@ module.exports = [ files: ['**/*.{ts,tsx}'], languageOptions: { parser: tsparser, - parserOptions: { - ecmaVersion: 2020, - sourceType: 'module', - ecmaFeatures: { - jsx: true, - }, + parserOptions, + globals: { + ...globals.browser, + ...globals.node, }, globals: { ...globals.browser, @@ -36,8 +34,25 @@ module.exports = [ '@typescript-eslint/no-empty-object-type': 'off', }, settings: { - react: { - version: 'detect', + react: { version: 'detect' }, + }, + }, + { + // Storybook story files use render functions that aren't React components + files: ['**/*.stories.{ts,tsx}'], + rules: { + 'react-hooks/rules-of-hooks': 'off', + }, + }, + { + files: ['**/__tests__/**/*.{ts,tsx}', '**/*.test.{ts,tsx}'], + languageOptions: { + parser: tsparser, + parserOptions, + globals: { + ...globals.browser, + ...globals.node, + ...globals.jest, }, }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a479282..bb26026 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -285,6 +285,9 @@ importers: eslint: specifier: ^9.0.0 version: 9.39.4(jiti@1.21.7) + fast-check: + specifier: ^3.22.0 + version: 3.23.2 jest: specifier: ^30.2.0 version: 30.3.0(@types/node@20.19.37)(esbuild-register@3.6.0(esbuild@0.21.5)) @@ -4075,6 +4078,10 @@ packages: resolution: {integrity: sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==} hasBin: true + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -13227,6 +13234,10 @@ snapshots: transitivePeerDependencies: - supports-color + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: