rodauth-tools 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ # lib/rodauth/tools/account_id_cipher.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'openssl'
6
+
7
+ module Rodauth
8
+ module Tools
9
+ # Keyed format-preserving obfuscator for integer primary keys.
10
+ #
11
+ # Turns a numeric account id (e.g. +2+) into a fixed-width, URL-safe,
12
+ # non-sequential token (e.g. +"E946V4SD7Z7RV"+) and back again, WITHOUT
13
+ # changing the database schema. The mapping is a keyed pseudo-random
14
+ # permutation (a bijection) over the 64-bit domain, so:
15
+ #
16
+ # - every id maps to exactly one token and vice-versa (no collisions),
17
+ # - the token reveals nothing about the id or about neighbouring ids
18
+ # to anyone who does not hold the secret,
19
+ # - +decode(encode(id)) == id+ for all ids in range, and +decode+ rejects
20
+ # any well-formed-but-non-canonical token (see {#decode}).
21
+ #
22
+ # It is a 4-round Feistel network keyed with HMAC-SHA256. This is genuine
23
+ # keyed encryption over a small domain (format-preserving encryption),
24
+ # not a reversible "encoder" like Hashids/Sqids: without the secret the
25
+ # permutation is hard to invert and, for a bounded number of observed
26
+ # tokens, hard to distinguish from random. As with any small-block Feistel
27
+ # construction, that only holds up to the birthday bound of the 32-bit
28
+ # half-block (roughly 2^16 tokens observed under one secret) — it is not
29
+ # an unconditional guarantee at unbounded query volume.
30
+ #
31
+ # The output alphabet is Crockford Base32, which deliberately excludes the
32
+ # +_+ character so encoded ids never collide with Rodauth's +token_separator+.
33
+ #
34
+ # This class is a pure +Integer <-> 13-char+ bijection: it knows nothing
35
+ # about versions, prefixes or legacy formats. The +account_id_obfuscation+
36
+ # feature adds a one-character non-digit version prefix on top (which is what
37
+ # makes obfuscated tokens deterministically distinguishable from legacy
38
+ # decimal ids and drives key rotation), keeping this primitive reusable and
39
+ # trivially testable in isolation.
40
+ #
41
+ # @example
42
+ # cipher = Rodauth::Tools::AccountIdCipher.new(ENV.fetch('ACCOUNT_ID_SECRET'))
43
+ # token = cipher.encode(2) # => "E946V4SD7Z7RV" (13 chars)
44
+ # cipher.decode(token) # => 2
45
+ # cipher.decode('not-a-token') # => nil
46
+ class AccountIdCipher
47
+ MASK32 = 0xFFFF_FFFF
48
+ MASK64 = 0xFFFF_FFFF_FFFF_FFFF
49
+ ROUNDS = 4
50
+
51
+ # Crockford Base32 (no I, L, O, U; no underscore). 13 chars encodes 65 bits,
52
+ # covering the full 64-bit domain with a fixed, padding-free width.
53
+ ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
54
+ WIDTH = 13
55
+
56
+ # Minimum secret length. HMAC-SHA256's block size is 64 bytes; 32 bytes
57
+ # (256 bits) is the smallest key we accept as cryptographically adequate.
58
+ MIN_SECRET_BYTES = 32
59
+
60
+ # @param secret [String] a high-entropy key of at least 32 bytes. Keep this
61
+ # independent of +hmac_secret+ so rotating the HMAC secret does not
62
+ # invalidate in-flight obfuscated ids.
63
+ # @raise [ArgumentError] if the secret is shorter than 32 bytes.
64
+ def initialize(secret)
65
+ secret = secret.to_s
66
+ raise ArgumentError, "secret must be at least #{MIN_SECRET_BYTES} bytes" if secret.bytesize < MIN_SECRET_BYTES
67
+
68
+ @secret = secret
69
+ end
70
+
71
+ # Encode an integer id into its fixed-width obfuscated token.
72
+ #
73
+ # @param id [Integer, String] the numeric account id, parsed with the
74
+ # strict Kernel#Integer — unlike #to_i, a non-integer String (or other
75
+ # value Integer() rejects) raises ArgumentError rather than silently
76
+ # truncating.
77
+ # @return [String] a 13-character Crockford Base32 token
78
+ def encode(id)
79
+ base32(feistel(Integer(id) & MASK64, :encrypt))
80
+ end
81
+
82
+ # Decode an obfuscated token back into its integer id.
83
+ #
84
+ # Returns +nil+ for anything that is not a well-formed 13-char token (wrong
85
+ # length, illegal character, non-string), so callers can pass foreign input
86
+ # through without raising. Also returns +nil+ for a well-formed-but-
87
+ # non-canonical token: 13 Crockford chars encode 65 bits for a 64-bit
88
+ # block, so each id has exactly one canonical 13-char token but a second,
89
+ # non-canonical encoding that differs only in the (discarded) 65th bit of
90
+ # its first character. Re-encoding the decrypted id and comparing against
91
+ # the input rejects that non-canonical form, keeping {#decode} a strict
92
+ # inverse of {#encode} rather than a 2-to-1 mapping.
93
+ #
94
+ # @param token [String] a token previously produced by {#encode}
95
+ # @return [Integer, nil] the original id, or nil if +token+ is not a
96
+ # valid, canonical token
97
+ def decode(token)
98
+ number = unbase32(token)
99
+ return nil unless number
100
+
101
+ id = feistel(number, :decrypt)
102
+ encode(id) == token ? id : nil
103
+ end
104
+
105
+ private
106
+
107
+ # 4-round Feistel network. Encryption runs the round schedule forward;
108
+ # decryption runs it in reverse, which inverts the permutation exactly.
109
+ def feistel(block, direction)
110
+ left = (block >> 32) & MASK32
111
+ right = block & MASK32
112
+
113
+ schedule = (0...ROUNDS).to_a
114
+ schedule = schedule.reverse if direction == :decrypt
115
+
116
+ schedule.each do |round_index|
117
+ left, right = right, (left ^ round_function(round_index, right))
118
+ end
119
+
120
+ ((right << 32) | left) & MASK64
121
+ end
122
+
123
+ # Keyed round function: HMAC-SHA256 over the round index and half-block,
124
+ # folded to 32 bits. Domain-separating on the round index keeps the
125
+ # per-round subkeys independent.
126
+ def round_function(round_index, half)
127
+ digest = OpenSSL::HMAC.digest('SHA256', @secret, [round_index, half].pack('C N'))
128
+ digest.unpack1('N')
129
+ end
130
+
131
+ # Little-endian Crockford Base32, fixed 13 chars.
132
+ def base32(number)
133
+ Array.new(WIDTH) { |i| ALPHABET[(number >> (5 * i)) & 31] }.reverse.join
134
+ end
135
+
136
+ # Inverse of {#base32}. Returns nil (never raises) on malformed input so
137
+ # {#decode} can treat legacy/foreign values as "not one of ours".
138
+ def unbase32(token)
139
+ return nil unless token.is_a?(String) && token.length == WIDTH
140
+
141
+ token.each_char.reduce(0) do |acc, char|
142
+ value = ALPHABET.index(char)
143
+ return nil unless value
144
+
145
+ (acc << 5) | value
146
+ end & MASK64
147
+ end
148
+ end
149
+ end
150
+ end
@@ -158,8 +158,9 @@ module Rodauth
158
158
  #
159
159
  # PostgreSQL: Use native jsonb type for efficient JSON storage and querying
160
160
  # MySQL: Use native json type (supported since MySQL 5.7.8)
161
- # SQLite: Use String type - SQLite stores JSON as text, but has JSON1 extension
162
- # for querying. Sequel doesn't have a :json type for SQLite.
161
+ # SQLite: Use String type (via the fallback below) - SQLite stores JSON as
162
+ # text, but has JSON1 extension for querying. Sequel doesn't have
163
+ # a :json type for SQLite.
163
164
  # Other: Fall back to String for maximum compatibility
164
165
  def json_type
165
166
  case db.database_type
@@ -167,8 +168,6 @@ module Rodauth
167
168
  ':jsonb'
168
169
  when :mysql
169
170
  ':json'
170
- when :sqlite
171
- String
172
171
  else
173
172
  String
174
173
  end
@@ -4,6 +4,6 @@
4
4
 
5
5
  module Rodauth
6
6
  module Tools
7
- VERSION = '0.3.1'
7
+ VERSION = '0.4.0'
8
8
  end
9
9
  end
data/lib/rodauth/tools.rb CHANGED
@@ -12,6 +12,7 @@ end
12
12
  require_relative 'tools/version'
13
13
  require_relative 'tools/migration'
14
14
  require_relative 'tools/console_helpers'
15
+ require_relative 'tools/account_id_cipher'
15
16
 
16
17
  # Load rodauth-tools utilities (only if Rodauth is available)
17
18
  if defined?(Rodauth)
@@ -19,6 +20,7 @@ if defined?(Rodauth)
19
20
  require_relative 'sequel_generator'
20
21
  require_relative 'features/table_guard'
21
22
  require_relative 'features/external_identity'
23
+ require_relative 'features/account_id_obfuscation'
22
24
  end
23
25
 
24
26
  module Rodauth