Skip to content

Contract audit trail fix#727

Merged
OlaGreat merged 3 commits into
OlaGreat:mainfrom
blockchainrafik:contract_audit_trail_fix
Jun 27, 2026
Merged

Contract audit trail fix#727
OlaGreat merged 3 commits into
OlaGreat:mainfrom
blockchainrafik:contract_audit_trail_fix

Conversation

@blockchainrafik

@blockchainrafik blockchainrafik commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Security & data-integrity fixes: auth randomness, wallet uniqueness, pause/unpause audit events

This PR bundles four related fixes found during a review pass over the auth flow, the Soroban contract, and the Prisma schema. Each is small and independent, but grouped here since they touch adjacent code and were found in the same pass.

Closes #701
Closes #702
Closes #703
Closes #700


1. generateChallenge() used Math.random() — not cryptographically secure

Closes #701

Problem

const randomBytes = Buffer.from(
  Array.from({ length: 16 }, () => Math.floor(Math.random() * 256))
).toString('hex');

Math.random() in Node.js is backed by V8's XorShift128+ PRNG — fast, but not cryptographically secure. Given enough observed outputs its internal state can be recovered, after which future outputs become predictable. generateChallenge() is called from the public POST /auth/challenge endpoint, so an attacker can observe a stream of challenges and predict — or reconstruct — future nonces, potentially pre-generating valid challenges before they're issued.

Fix

import { randomBytes } from "node:crypto";

export function generateChallenge(walletAddress: string): string {
  const timestamp = Date.now();
  const randomHex = randomBytes(16).toString("hex");
  return `novasupport:${walletAddress}:${timestamp}:${randomHex}`;
}

(Local variable renamed to randomHex to avoid shadowing the imported randomBytes function.)

Testing

  • Verified the output format is unchanged: novasupport:{wallet}:{timestamp}:{32-char hex nonce}.
  • TODO: add a test asserting consecutive calls never repeat a nonce and that the nonce is valid 32-char hex.

Follow-up (out of scope)

No visible TTL/expiry enforcement on challenges in auth.ts itself — confirm this is enforced elsewhere (e.g. AuthChallenge.expiresAt in the schema) so a captured challenge+signature can't be replayed indefinitely.


2. Profile.walletAddress had no @unique constraint — duplicate wallet claims possible

Closes #702

Problem

walletAddress String

Wallet address is the root identity for authentication. Nothing prevented two Profile rows from sharing the same walletAddress, so auth lookups by wallet could resolve non-deterministically. An attacker registering a second profile with a victim's wallet address could intercept that victim's webhook deliveries and milestone notifications.

Fix

walletAddress String @unique

Brings walletAddress in line with every other identity field on Profile (username, email, emailVerificationToken are all already @unique), and matches AuthChallenge.walletAddress, which was already @unique.

Migration

A bare ADD CONSTRAINT UNIQUE fails outright if duplicates already exist in production. migration.sql instead:

  1. Ranks any duplicate walletAddress rows by createdAt, treating the oldest as canonical.
  2. Renames conflicting rows with a ::dup-N suffix to unblock the constraint, without deciding which profile is "legitimate."
  3. Adds the unique index.
  4. Leaves commented SQL for a manual post-deploy pass to find ::dup-N rows, contact affected owners, and re-verify wallet ownership via signature before resolving.

No automatic deletion or nulling — walletAddress is required, and a wrong automated call could lock out a legitimate user.

-- run before deploy to size the cleanup
SELECT "walletAddress", COUNT(*), array_agg(id ORDER BY "createdAt")
FROM "Profile"
GROUP BY "walletAddress"
HAVING COUNT(*) > 1;

Testing

  • Confirmed no other model joins on Profile.walletAddress, so this is a purely additive constraint.
  • TODO: add a test asserting a second Profile created with an already-claimed walletAddress is rejected.
  • TODO: run the duplicate-detection query against a staging snapshot before merging.

Follow-up (out of scope)

Confirm whether the auth lookup in app.ts uses findFirst or findUnique for walletAddress. Both work correctly once the constraint exists, but findUnique better expresses the new invariant.


3. Duplicate AcceptedAsset rows from concurrent PATCH requests

**Closes #700 **

Problem

AcceptedAsset had no constraint preventing the same (profileId, code, issuer) combination from being inserted more than once. Concurrent PATCH requests against a profile's accepted-assets endpoint could race and insert duplicate rows for the same asset.

Fix

@@unique([profileId, code, issuer])

Testing

  • TODO: add a test issuing two concurrent inserts for the same (profileId, code, issuer) and asserting only one succeeds.

4. pause() / unpause() emit no on-chain audit events

**Closes #704 **

Problem

pause() and unpause() flip the contract's Paused flag but never emit an event, unlike support() and withdraw() which both publish one. There's no on-chain record of when the contract was paused/unpaused or which admin triggered it, breaking off-chain monitoring and post-incident analysis.

Fix

// pause()
e.events().publish((symbol_short!("pause"), admin), e.ledger().timestamp());

// unpause()
e.events().publish((symbol_short!("unpause"), admin), e.ledger().timestamp());

Testing

  • Existing support_fails_when_contract_is_paused and support_succeeds_after_unpause tests pass unchanged.
  • TODO: assert on e.events().all() in a pause/unpause test to verify the emitted admin address and timestamp.

Follow-up (out of scope)

initialize() has the same gap — no event emitted when Admin/Paused are first set. Suggest a follow-up PR for an init event.


Summary of changed files

  • auth.ts — crypto-secure nonce generation
  • schema.prismawalletAddress @unique, AcceptedAsset composite unique constraint
  • migration.sql — non-destructive dedup + unique index for walletAddress
  • lib.rs (Soroban contract) — pause/unpause events

# Add audit-trail events to `pause()` and `unpause()`

## Problem

`pause()` and `unpause()` flip the contract's `Paused` flag but never emit an event. Soroban events are the standard audit-trail mechanism for this contract — `support()` and `withdraw()` both publish one — so admin pause/unpause actions are currently invisible on-chain. There's no record of *when* the contract was paused/unpaused or *which* admin address triggered it.

This breaks any off-chain monitoring or indexer that relies on event feeds to track contract state, and makes post-incident analysis impossible if a pause needs to be investigated after the fact.

## Fix

Publish an event in both functions, following the existing pattern used by `support`/`withdraw`:

```rust
e.events().publish((symbol_short!("pause"), admin), e.ledger().timestamp());
e.events().publish((symbol_short!("unpause"), admin), e.ledger().timestamp());
```

Each event includes the admin address (topic) and the ledger timestamp (data), giving a complete audit record of who paused/unpaused the contract and when.

## Changes

- `pause()` — publish `("pause", admin)` event after setting `Paused = true`
- `unpause()` — publish `("unpause", admin)` event after setting `Paused = false`

## Testing

- Existing `support_fails_when_contract_is_paused` and `support_succeeds_after_unpause` tests still pass unchanged (they assert on state/panic behavior, not events).
- **TODO:** add an assertion on `e.events().all()` in at least one pause/unpause test to verify the event payload (admin + timestamp), since none of the current tests check emitted event data.

## Out of scope (follow-up)

`initialize()` has the same gap — it sets `Admin` and `Paused` for the first time without emitting anything, so there's no on-chain record of who the admin was set to or when the contract went live. Suggest a follow-up PR to add an `init` event there for consistency.
# Fix: `generateChallenge()` uses `Math.random()` — not cryptographically secure

## Problem

`auth.ts:31-34`:

```ts
const randomBytes = Buffer.from(
  Array.from({ length: 16 }, () => Math.floor(Math.random() * 256))
).toString('hex');
```

`Math.random()` in Node.js is backed by V8's XorShift128+ PRNG — a fast, non-cryptographic generator seeded once at process start. It is not designed to resist prediction: given enough observed outputs, its internal state can be recovered, after which all future outputs become predictable.

`generateChallenge()` is called from the public `POST /auth/challenge` endpoint, so an attacker can observe a stream of challenges and use them to statistically predict — or in the worst case fully reconstruct — future nonces, potentially pre-generating valid challenges before they are issued.

## Fix

Use Node's `crypto` module, which draws from the OS-level CSPRNG and has no such weakness:

```ts
import { randomBytes } from "node:crypto";

export function generateChallenge(walletAddress: string): string {
  const timestamp = Date.now();
  const randomHex = randomBytes(16).toString("hex");
  return `novasupport:${walletAddress}:${timestamp}:${randomHex}`;
}
```

Note: the local variable is named `randomHex` rather than `randomBytes` to avoid shadowing the imported `randomBytes` function — the original code's naming made it easy to accidentally call the wrong thing in future edits.

## Changes

- `auth.ts` — import `randomBytes` from `node:crypto`; replace the `Math.random()`-based byte array with `crypto.randomBytes(16)` in `generateChallenge()`

## Testing

- Manually verified `generateChallenge()` still returns a string in the same `novasupport:{wallet}:{timestamp}:{nonce}` format, with a 32-character hex nonce (16 bytes).
- No changes to `verifySignature`, `signJWT`, `verifyJWT`, or middleware — challenge format and downstream consumers are unaffected.
- **TODO:** add a unit test asserting two consecutive calls to `generateChallenge()` for the same wallet never produce the same nonce, and that the nonce is valid hex of the expected length.

## Out of scope (flagging for follow-up)

The challenge string has no visible expiry/TTL enforcement in this file. Worth confirming there's a TTL check elsewhere (route handler or challenge store) so a captured challenge+signature pair can't be replayed indefinitely. If this is already handled, disregard.
# Fix: `Profile.walletAddress` has no `@unique` constraint — duplicate wallet claims possible

## Problem

`schema.prisma`:

```prisma
walletAddress String
```

The wallet address is the root identity for authentication in NovaSupport. Nothing in the database prevents two `Profile` rows from sharing the same `walletAddress`.

Since auth lookups resolve a profile by `walletAddress`, a duplicate produces non-deterministic authentication results — whichever row the query happens to return first wins. Concretely, this means an attacker who registers a second profile with a victim's wallet address could intercept that victim's webhook deliveries and milestone notifications, since lookups by wallet may resolve to the attacker's profile instead.

## Fix

Add the uniqueness constraint at the schema level:

```prisma
walletAddress String @unique
```

This is the correct invariant: every other identity-bearing field on `Profile` (`username`, `email`, `emailVerificationToken`) is already `@unique`, and `AuthChallenge.walletAddress` is `@unique` too — `walletAddress` was the one identity field missing it.

## Migration

A straight `ALTER TABLE ... ADD CONSTRAINT UNIQUE` will fail outright if any duplicate `walletAddress` values already exist in production, blocking the deploy. The migration instead:

1. **Ranks** any duplicate `walletAddress` rows by `createdAt`, treating the oldest profile as canonical.
2. **Renames** the conflicting (non-canonical) rows by appending a `::dup-N` suffix to their `walletAddress` — this only unblocks the constraint, it does **not** decide which profile is legitimate.
3. **Adds** the unique index.
4. Leaves commented SQL for a manual post-deploy pass: find all `::dup-N`-suffixed rows, contact the affected owners, and re-verify wallet ownership via signature challenge before restoring or reassigning the address.

Auto-resolving (e.g. deleting or nulling the duplicate) was deliberately avoided — `walletAddress` is required, and a wrong automated decision could lock out a legitimate user. This needs a human in the loop.

```sql
-- inspect duplicates before running in production
SELECT "walletAddress", COUNT(*), array_agg(id ORDER BY "createdAt")
FROM "Profile"
GROUP BY "walletAddress"
HAVING COUNT(*) > 1;
```

## Changes

- `schema.prisma` — `Profile.walletAddress` is now `@unique`
- `migration.sql` — dedupes existing rows (non-destructively) and adds the unique index

## Testing

- Verified the schema change doesn't affect any other model — no other field references `Profile.walletAddress` as a join key, so this is purely an additive constraint.
- **TODO:** add a test asserting that creating a second `Profile` with an already-claimed `walletAddress` throws/rejects at the database layer.
- **TODO:** run the duplicate-detection query against a staging snapshot of production data before merging, to size the manual cleanup work ahead of deploy.

## Follow-up (out of scope here)

Confirm whether the auth lookup in `app.ts` uses `findFirst` or `findUnique` for `walletAddress`. Once the constraint exists, `findFirst` will behave correctly, but switching to `findUnique` better expresses the new invariant and should be marginally faster.
@drips-wave

drips-wave Bot commented Jun 27, 2026

Copy link
Copy Markdown

@blockchainrafik Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@OlaGreat OlaGreat merged commit 23cfa23 into OlaGreat:main Jun 27, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment