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.
- checksums.yaml +4 -4
- data/.rubocop.yml +4 -0
- data/.rubocop_todo.yml +10 -3
- data/CHANGELOG.md +25 -0
- data/CLAUDE.md +34 -11
- data/Gemfile +5 -5
- data/Gemfile.lock +119 -117
- data/README.md +33 -1
- data/lib/rodauth/features/account_id_obfuscation.rb +317 -0
- data/lib/rodauth/features/hmac_secret_guard.rb +55 -47
- data/lib/rodauth/features/jwt_secret_guard.rb +55 -45
- data/lib/rodauth/features/table_guard.rb +195 -53
- data/lib/rodauth/secret_guard.rb +137 -0
- data/lib/rodauth/sequel_generator.rb +17 -5
- data/lib/rodauth/table_inspector.rb +1 -1
- data/lib/rodauth/tools/account_id_cipher.rb +150 -0
- data/lib/rodauth/tools/migration.rb +3 -4
- data/lib/rodauth/tools/version.rb +1 -1
- data/lib/rodauth/tools.rb +2 -0
- data/package-lock.json +943 -76
- data/package.json +1 -1
- metadata +4 -1
|
@@ -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
|
|
162
|
-
# for querying. Sequel doesn't have
|
|
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
|
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
|