added on local at 2026-07-01 21:47:13
| 1 | package MODS::RePricer::SignedDownload; | |
| 2 | #====================================================================== | |
| 3 | # RePricer - signed-URL helper for receipt-email download links. | |
| 4 | # | |
| 5 | # Buyers who check out as a guest (no buyer_account on the storefront) | |
| 6 | # can't authenticate to /download.cgi via cookie. Receipt emails embed | |
| 7 | # a signed token that proves "this URL was minted by us, for this | |
| 8 | # (order_id, file_id), within the 30-day download window". The token | |
| 9 | # is short and url-safe. | |
| 10 | # | |
| 11 | # Token format: | |
| 12 | # <order_id>.<file_id>.<paid_at_epoch>.<hmac> | |
| 13 | # where hmac = first 32 hex chars of HMAC-SHA256(join('|', the three | |
| 14 | # preceding parts), secret). | |
| 15 | # | |
| 16 | # verify() must: | |
| 17 | # - parse the four dot-separated parts | |
| 18 | # - recompute the HMAC over the first three parts | |
| 19 | # - constant-time compare against the supplied hmac | |
| 20 | # - check that paid_at_epoch is within the 30-day window vs now | |
| 21 | # It returns (ok=>1, order_id=>N, file_id=>M, paid_at=>EPOCH) on success | |
| 22 | # or (ok=>0, error=>'reason') otherwise. | |
| 23 | # | |
| 24 | # This module never opens a DB connection -- it just proves the URL was | |
| 25 | # minted by us. download.cgi still authorizes against the order row. | |
| 26 | #====================================================================== | |
| 27 | ||
| 28 | use strict; | |
| 29 | use warnings; | |
| 30 | use Digest::SHA qw(hmac_sha256_hex); | |
| 31 | use MODS::RePricer::Config; | |
| 32 | ||
| 33 | # Window must match download.cgi's $DOWNLOAD_WINDOW_DAYS. Kept here as | |
| 34 | # a constant so mint() refuses to create tokens that would be rejected | |
| 35 | # by verify() before they reach the buyer's inbox. | |
| 36 | use constant DOWNLOAD_WINDOW_SECONDS => 30 * 86400; | |
| 37 | ||
| 38 | sub _secret { | |
| 39 | my $cfg = MODS::RePricer::Config->new; | |
| 40 | return $cfg->settings('stripe_webhook_secret') | |
| 41 | || $cfg->settings('stripe_secret') | |
| 42 | || 'repricer-static-fallback'; | |
| 43 | } | |
| 44 | ||
| 45 | # Build the canonical message we sign over. Order matters -- changing | |
| 46 | # this format invalidates every token in flight. | |
| 47 | sub _canonical { | |
| 48 | my ($order_id, $file_id, $paid_epoch) = @_; | |
| 49 | return "$order_id|$file_id|$paid_epoch"; | |
| 50 | } | |
| 51 | ||
| 52 | # mint($order_id, $file_id, $paid_at_unix) -> string token | |
| 53 | # $paid_at_unix is the epoch seconds value of orders.paid_at. Pass as | |
| 54 | # integer; helper does not parse SQL timestamps. | |
| 55 | sub mint { | |
| 56 | my ($class, $order_id, $file_id, $paid_at_unix) = @_; | |
| 57 | $order_id = 0 + ($order_id || 0); | |
| 58 | $file_id = 0 + ($file_id || 0); | |
| 59 | $paid_at_unix = 0 + ($paid_at_unix || 0); | |
| 60 | return '' unless $order_id && $file_id && $paid_at_unix; | |
| 61 | ||
| 62 | my $canon = _canonical($order_id, $file_id, $paid_at_unix); | |
| 63 | my $hmac = substr(hmac_sha256_hex($canon, _secret()), 0, 32); | |
| 64 | return "$order_id.$file_id.$paid_at_unix.$hmac"; | |
| 65 | } | |
| 66 | ||
| 67 | # verify($token) -> hash { ok, order_id, file_id, paid_at, error } | |
| 68 | sub verify { | |
| 69 | my ($class, $token) = @_; | |
| 70 | return { ok => 0, error => 'missing token' } unless defined $token && length $token; | |
| 71 | ||
| 72 | # Format guard: exactly four dot-separated parts, each non-empty | |
| 73 | # and a positive integer for the first three, 32 hex chars for the | |
| 74 | # signature. | |
| 75 | my @parts = split /\./, $token, 5; | |
| 76 | return { ok => 0, error => 'malformed token' } unless @parts == 4; | |
| 77 | my ($o, $f, $p, $sig) = @parts; | |
| 78 | return { ok => 0, error => 'malformed token' } | |
| 79 | unless $o =~ /^\d+$/ && $f =~ /^\d+$/ && $p =~ /^\d+$/ && $sig =~ /^[a-f0-9]{32}$/i; | |
| 80 | ||
| 81 | my $canon = _canonical($o + 0, $f + 0, $p + 0); | |
| 82 | my $expected = substr(hmac_sha256_hex($canon, _secret()), 0, 32); | |
| 83 | ||
| 84 | # Constant-time compare. Same-length strings xor'd byte by byte. | |
| 85 | return { ok => 0, error => 'bad signature' } if length($sig) != length($expected); | |
| 86 | my $diff = 0; | |
| 87 | for my $i (0 .. length($expected) - 1) { | |
| 88 | $diff |= ord(substr($expected, $i, 1)) ^ ord(substr($sig, $i, 1)); | |
| 89 | } | |
| 90 | return { ok => 0, error => 'bad signature' } if $diff; | |
| 91 | ||
| 92 | # Window check: paid_at_epoch must be within the last DOWNLOAD_WINDOW | |
| 93 | # seconds. We check here too -- belt-and-suspenders against a stale | |
| 94 | # token surviving past the download.cgi window check. | |
| 95 | if (time() - ($p + 0) > DOWNLOAD_WINDOW_SECONDS) { | |
| 96 | return { ok => 0, error => 'token expired' }; | |
| 97 | } | |
| 98 | return { | |
| 99 | ok => 1, | |
| 100 | order_id => $o + 0, | |
| 101 | file_id => $f + 0, | |
| 102 | paid_at => $p + 0, | |
| 103 | }; | |
| 104 | } | |
| 105 | ||
| 106 | 1; |