Skip to content

sec: hash approval/revocation tokens at rest (store sha256, compare hashes) #77

Closed

Description

@cristim

Independent security review of LeanerCloud/cloud-commitments-cli#889 confirmed a pre-existing codebase-wide pattern (not a regression): approval/revocation tokens are stored PLAINTEXT in approval_token (internal/purchase/approvals.go:315; internal/config/store_postgres.go:944; also internal/email/notifications.go:121, internal/api/handler_plans.go:393). The SHA-256-before-ConstantTimeCompare on validate is only length-normalization, not hashed-at-rest. Harden: store sha256(token), compare hashes, migrate/expire existing rows. Impact: many token paths; effort ~M. Out of scope for LeanerCloud/cloud-commitments-cli#889. Related LeanerCloud/cloud-commitments-cli#1356.

Activity

  1. IAmJSD commented on Oct 20, 2026

    @IAmJSD
    Collaborator

    Claiming: working on this

  2. self-assigned this
    on Oct 20, 2026
  3. IAmJSD commented on Oct 20, 2026

    @IAmJSD
    Collaborator

    Closing as already fixed on main by #416 (merge commit 0c09e11, "sec(api): hash approval and revocation tokens at rest", merged 2026-09-28). That PR closed duplicate #103, so this one wasn't linked.

    On current main, internal/config/approval_token.go provides two helpers:

    • HashApprovalToken: sha256 hex.
    • ApprovalTokenMatches: hashes the supplied token, then does a constant-time compare of digests. It fails closed on an empty value.

    Every write stores only approval_token_hash:

    • execution create and retry;
    • the revocation token minted on approve;
    • RotatePendingApprovalToken;
    • RI-exchange records.

    Every validate path compares hashes:

    • approve, cancel, revoke and the async SQS path;
    • TransitionExecutionStatusTokenPinned;
    • GetRIExchangeRecordByToken;
    • RI-exchange approve and reject.

    Migration 000100 hashed existing raw tokens into the hash column and blanked the raw values. No production code writes the raw approval_token column anymore.

    What's left is #422's contract step: drop the raw columns. @cristim

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

Metadata

Metadata

Assignees

Labels

priority/p3Polish / idea / may never shipseverity/lowMinor harmtriagedItem has been triagedtype/choreMaintenance / non-user-visible

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions