Skip to content

Generate invoice memos from a secure random source and stop matching arbitrary numeric memos #498

Description

@Cedarich
  • Complexity: Hard
  • Labels: Backend, security, payments, reconciliation, Hard
  • Overview: The invoice memo is the key that binds an incoming Stellar payment to an invoice, and it is generated from a timestamp plus sixteen bits of Math.random(). generateMemoId() returns String(Date.now() * 65536 + Math.floor(Math.random() * 65536)). Anyone who knows roughly when an invoice was created has only 65,536 candidate memos to search, and the reconciliation watcher accepts any purely numeric memo as an invoice identifier. Combined with the missing amount validation in Validate payment amount and asset in the Horizon reconciliation path before marking invoices paid #439, guessing a memo is enough to mark someone else's invoice paid.
  • Details:
    • The entropy is 16 bits per millisecond. An attacker who knows the creation time to within a second has roughly 65 million candidates; to within a millisecond, 65,536. Invoice creation time is not secret — the public payer page exposes createdAt through findPublicInvoice, so for any invoice whose link has been shared, the timestamp half of the memo is known exactly and only the 16-bit random half remains.
    • Math.random() is not cryptographically secure and is not intended for values that gate access to anything. crypto.randomBytes is already imported and used in AuthService for nonces, so the secure primitive is available and in use elsewhere in the codebase.
    • resolveMemoId() in the Horizon watcher returns any memo matching /^\d+$/ verbatim as an invoice ID, before the configured MEMO_PREFIX is considered. Since generated memos are purely numeric, the prefixed form is effectively unused for matching, and any numeric memo is a candidate lookup key.
    • That numeric-passthrough is a collision surface beyond deliberate attack. Numeric memo IDs are the standard convention for exchange deposits across the Stellar ecosystem, so an unrelated payment arriving at the merchant account carrying a numeric memo is looked up as an invoice ID and settles a matching invoice if one exists.
    • findByMemo returns any non-cancelled invoice with that memo regardless of merchant, and markAsPaid then settles it without checking amount, asset, or payer — so a successful guess costs the attacker one dust payment and the merchant a falsely settled invoice, a webhook, a notification, and an activity-feed entry.
    • The memo is also enumerable in bulk: because the timestamp component is monotonic, an attacker who has seen two invoices from a merchant can bound the range for everything issued between them.
    • The comment on the function reasons only about staying inside Number.MAX_SAFE_INTEGER. It treats the memo as an identifier that needs to be unique, not as a value that needs to be unguessable — which is the actual requirement given how the watcher uses it.
  • Scope:
    • Generate memos from a cryptographically secure source with enough entropy that guessing is not viable, while staying within the Stellar memo constraints for the memo type in use.
    • Decide deliberately between a numeric memo ID and a text memo with the configured prefix, and make the watcher's matching rule agree with what the generator produces instead of accepting both shapes.
    • Stop treating an arbitrary numeric memo as an invoice identifier, so unrelated ecosystem traffic carrying numeric memos cannot resolve to an invoice.
    • Keep memo lookup constrained to the merchant the payment was actually sent to, so a match is scoped rather than global.
    • Preserve compatibility for invoices already issued with existing memos, and document the transition.
    • Confirm the change composes with the amount, asset, and payer validation in Validate payment amount and asset in the Horizon reconciliation path before marking invoices paid #439 so that a memo match is a necessary but not sufficient condition for settlement.
    • Add tests covering memo entropy, the rejection of unrecognised memo shapes, and the merchant scoping of lookups.
  • Technical scope:
    • backend/src/invoices/invoices.service.ts
    • backend/src/stellar/horizon-watcher.service.ts
    • backend/src/stellar/stellar.service.ts
    • backend/prisma/schema.prisma
    • backend/src/config/stellar.config.ts
  • Acceptance criteria:
    • Memos are generated from a cryptographically secure source and are not derivable from the invoice creation time.
    • Knowing an invoice's public creation timestamp does not meaningfully reduce the search space for its memo.
    • The watcher accepts only the memo shape the generator produces, and an arbitrary numeric memo no longer resolves to an invoice.
    • Memo lookup is scoped to the receiving merchant.
    • Invoices issued before the change continue to reconcile correctly, and the transition is documented.
    • A memo match alone does not settle an invoice; amount and asset validation still applies.
    • Tests cover memo generation, unrecognised memo shapes, merchant scoping, and the legacy-memo path.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

BackendGrantFox OSSIssue tracked in GrantFox OSSHardHigh-complexity taskMaybe RewardedIssue may be eligible for a GrantFox rewardThird CampaignCampaign: Third CampaignpaymentsPayment flow and settlementreconciliationsecuritySecurity-related work

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions