baseh 1.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d62aeddba45748e9bf4c1d40935e09d8f18bbe032f7d97320e4f78b7598d33ed
4
+ data.tar.gz: 29a24ad4f909e9832ae668c1d0d1765d94884c4533b7fa1a98bfbc72d86e2347
5
+ SHA512:
6
+ metadata.gz: e8b586ae0ac27e61037a413032bfcff338140ffb0a9fd0f99bebe3377657244d326e8a03e53c185c58713408d0968df71a8bfaf0ed908a80faea6c29b805de98
7
+ data.tar.gz: a0a5578f2f70d553415396b47e28183edab7eab72e780fdd76834863a79ef8e0b3a99b7a7fb1fa55b8310ad38c7fcde2c82cc6a62cf87eb3b57d9f401a0ce97e
data/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # baseh
2
+
3
+ Ruby port of the baseH (Human Reference Code) codec. Encodes integer IDs as
4
+ fixed-length, checksummed, human-friendly reference codes with an opt-in
5
+ reversible feistel-v1 permutation and profanity safety. The normative spec
6
+ is `spec/IMPLEMENTATION_CODEC.md` in the monorepo root.
7
+
8
+ ## Install
9
+
10
+ ```ruby
11
+ # Gemfile
12
+ gem "baseh", path: "ruby"
13
+ ```
14
+
15
+ or
16
+
17
+ ```sh
18
+ gem build baseh.gemspec
19
+ gem install ./baseh-1.0.0.gem
20
+ ```
21
+
22
+ Zero runtime dependencies. Only `openssl` and `json` from the standard
23
+ library are used.
24
+
25
+ ## Frozen tiers
26
+
27
+ Four frozen tiers ship with the gem, built from the full alphanumeric set
28
+ with cumulative visual and spoken strips. All four encode 6 body symbols,
29
+ are case-insensitive and run the default profanity blocklist.
30
+
31
+ | Tier | Helper | Body symbols | Checksum | Format | Capacity |
32
+ | ---- | ------ | ------------ | -------- | ------ | -------- |
33
+ | Minimum | `Baseh.baseh_minimum_v1` | 36 | none | `XXX-XXX` | 2,176,782,336 |
34
+ | Light | `Baseh.baseh_light_v1` | 31 | 1 | plain | 887,503,681 |
35
+ | Medium | `Baseh.baseh_medium_v1` | 28 | 1 | plain | 481,890,304 |
36
+ | Heavy | `Baseh.baseh_heavy_v1` | 26 | 1 | plain | 308,915,776 |
37
+
38
+ Medium is the default. Minimum keeps the full alphabet and uses a hyphen
39
+ delimiter; the rest have no separator. Each tier keeps the typed O/I/L
40
+ aliases where possible and adds spoken-confusion aliases for the stripped
41
+ symbols.
42
+
43
+ Every helper returns a freshly built mutable profile hash on each call, so
44
+ callers can load a default and modify it before constructing a codec.
45
+
46
+ ## Usage
47
+
48
+ ```ruby
49
+ require "baseh"
50
+
51
+ codec = Baseh::Baseh.new(Baseh.baseh_medium_v1)
52
+
53
+ code = codec.encode(id: 123_456) # => raw fixed-width code
54
+
55
+ result = codec.decode(code)
56
+ result.id # => 123456
57
+ result.canonical_code # => canonical form
58
+ result.corrected # => true when input needed correction
59
+
60
+ codec.capacity # => 481890304
61
+
62
+ check = codec.validate("0000000")
63
+ check.valid # => false
64
+ check.reason # => "INVALID_CHECKSUM"
65
+
66
+ # Spoken-confusion correction
67
+ result = codec.decode("TB14QDF", try_correction: true, confusion_profile: :light)
68
+ ```
69
+
70
+ ## Permutation (opt-in)
71
+
72
+ The `-p` variants opt a tier into the reversible feistel-v1 permutation.
73
+ `key_bytes:` is required; keep the key in a secret manager and never change
74
+ it for a live profile:
75
+
76
+ ```ruby
77
+ profile = Baseh.baseh_medium_p_v1(
78
+ key_bytes: File.binread("path/to/key.bin"),
79
+ key_id: "prod-01" # optional, defaults to "default"
80
+ )
81
+ codec = Baseh::Baseh.new(profile)
82
+ ```
83
+
84
+ `rounds:` is also accepted (default 8). The `-p` profile is identical to
85
+ its plain tier apart from the permutation; its profile id gains a `-p`
86
+ segment, for example `baseh-medium-p-v1`.
87
+
88
+ ## Profanity safety (spec 18)
89
+
90
+ Profiles accept an optional `profanity:` object:
91
+
92
+ ```ruby
93
+ # mode "no-vowels": vowels are stripped from both alphabets at construction
94
+ # and can never appear in issued codes.
95
+ profanity: { mode: "no-vowels" }
96
+
97
+ # mode "blocklist": encode raises BLOCKED_CODE when the raw code contains an
98
+ # entry. words replaces the default list, extra_words appends to it.
99
+ profanity: { mode: "blocklist", words: ["ZZZZ"], extra_words: ["QQQQ"] }
100
+ ```
101
+
102
+ The frozen tiers run the default blocklist out of the box.
103
+
104
+ All failures raise `Baseh::BasehError` with a `#code` from the spec:
105
+ `INVALID_PROFILE`, `OUT_OF_RANGE`, `PERMUTATION_FAILURE`, `INVALID_LENGTH`,
106
+ `INVALID_CHARACTER`, `INVALID_CHECKSUM`, `AMBIGUOUS_INPUT`,
107
+ `TOO_MANY_CANDIDATES` and `BLOCKED_CODE`. `validate` never raises on user
108
+ input.
109
+
110
+ Ruby `Integer` is arbitrary precision, so every capacity and ID operation is
111
+ exact at any size.
112
+
113
+ ## Tests
114
+
115
+ ```sh
116
+ rake # or: ruby -Ilib -Itest -e 'Dir["test/test_*.rb"].each { |f| require "./#{f}" }'
117
+ SLOW=1 rake # includes the 10k sequential round trip and bijection checks
118
+ ```
119
+
120
+ The vector tests load `../vectors/vectors.json` and
121
+ `../vectors/feistel-vectors.json` from the monorepo root and assert every
122
+ entry. Running the suite from a different directory layout requires those
123
+ files at that relative path.
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Baseh
4
+ # Codec engine, spec sections 8 through 12 and 18. Instances wrap one
5
+ # validated profile and are stateless and safe to share across threads.
6
+ class Baseh
7
+ # Built-in spoken-confusion candidate maps, spec section 3.3.
8
+ # Pairs apply to body symbols only, never to checksum characters.
9
+ CONFUSION_MAPS = {
10
+ light: {
11
+ "B" => %w[D], "D" => %w[B], "P" => %w[T], "T" => %w[P]
12
+ }.freeze,
13
+ medium: {
14
+ "B" => %w[D], "D" => %w[B], "P" => %w[T], "T" => %w[P],
15
+ "M" => %w[N], "N" => %w[M], "V" => %w[W], "W" => %w[V]
16
+ }.freeze,
17
+ heavy: {
18
+ "B" => %w[D], "D" => %w[B], "P" => %w[T], "T" => %w[P],
19
+ "M" => %w[N], "N" => %w[M], "V" => %w[W], "W" => %w[V],
20
+ "F" => %w[S], "S" => %w[F], "C" => %w[G], "G" => %w[C]
21
+ }.freeze
22
+ }.freeze
23
+
24
+ MAX_CANDIDATES = 64
25
+
26
+ ASCII_WS = /\A[\t\n\v\f\r ]+|[\t\n\v\f\r ]+\z/.freeze
27
+
28
+ # Result of a successful decode.
29
+ DecodeResult = Struct.new(:id, :canonical_code, :corrected, keyword_init: true)
30
+
31
+ # Result of validate, which never raises on user input.
32
+ ValidateResult = Struct.new(:valid, :canonical_code, :reason, keyword_init: true)
33
+
34
+ attr_reader :profile
35
+
36
+ # @param profile [Hash] profile definition per spec 2.1 (symbol keys)
37
+ # @raise [BasehError] INVALID_PROFILE when the profile violates spec 2.2
38
+ def initialize(profile)
39
+ @profile = Profile.prepare(profile)
40
+ @body_index = BaseN.alphabet_index(@profile.body_alphabet)
41
+ end
42
+
43
+ # Spec section 4. Capacity is an arbitrary-precision Integer.
44
+ def capacity
45
+ @profile.capacity
46
+ end
47
+
48
+ # Spec section 8, with the spec 18.2 blocklist scan over the raw code.
49
+ #
50
+ # @param id [Integer] 0 <= id < capacity
51
+ # @return [String] canonical code (grouped only when a separator is set)
52
+ # @raise [BasehError] OUT_OF_RANGE, PERMUTATION_FAILURE, BLOCKED_CODE
53
+ def encode(id:)
54
+ unless id.is_a?(Integer)
55
+ raise TypeError, "id must be an Integer"
56
+ end
57
+ if id.negative? || id >= @profile.capacity
58
+ raise BasehError.new("OUT_OF_RANGE", "ID #{id} is outside the profile capacity")
59
+ end
60
+
61
+ value = id
62
+ perm = @profile.permutation
63
+ if perm[:enabled]
64
+ value = Feistel.permute(
65
+ value, @profile.capacity,
66
+ profile_id: @profile.profile_id,
67
+ key_bytes: perm[:key_bytes],
68
+ rounds: perm[:rounds]
69
+ )
70
+ end
71
+
72
+ body = BaseN.encode_base_n(value, @profile.body_alphabet, @profile.body_length)
73
+ checksum = Checksum.calculate_checksum(@profile, body, @body_index)
74
+ raw = body + checksum
75
+ check_blocklist!(raw)
76
+ format_raw(raw)
77
+ end
78
+
79
+ # Spec section 9.
80
+ #
81
+ # @param input [String]
82
+ # @param accept_spaces [Boolean] strip ASCII spaces before validation
83
+ # @param try_correction [Boolean] attempt single-symbol spoken correction
84
+ # @param confusion_profile [:none, :light, :medium, :heavy]
85
+ # @param max_corrections [0, 1]
86
+ # @return [DecodeResult]
87
+ # @raise [BasehError] INVALID_LENGTH, INVALID_CHARACTER, INVALID_CHECKSUM,
88
+ # AMBIGUOUS_INPUT, TOO_MANY_CANDIDATES, PERMUTATION_FAILURE, BLOCKED_CODE
89
+ def decode(input, accept_spaces: false, try_correction: false,
90
+ confusion_profile: :none, max_corrections: 1)
91
+ unless input.is_a?(String)
92
+ raise BasehError.new("INVALID_CHARACTER", "Input must be a string")
93
+ end
94
+
95
+ raw = normalize(input, accept_spaces)
96
+ body = raw.slice(0, @profile.body_length)
97
+ supplied_checksum = raw.slice(@profile.body_length..) || ""
98
+
99
+ # normalize validates every symbol against the union of the body and
100
+ # checksum alphabets (spec 3.1 step 6). A checksum-only symbol in a
101
+ # body slot is INVALID_CHARACTER before any checksum work. A body-only
102
+ # symbol in the checksum slot survives to the checksum comparison and
103
+ # fails as INVALID_CHECKSUM; the frozen error vectors require that
104
+ # exact outcome.
105
+ body.each_char do |ch|
106
+ next if @body_index.key?(ch)
107
+
108
+ raise BasehError.new(
109
+ "INVALID_CHARACTER",
110
+ "Symbol #{ch.inspect} cannot appear in the body"
111
+ )
112
+ end
113
+
114
+ if Checksum.calculate_checksum(@profile, body, @body_index) != supplied_checksum
115
+ unless try_correction && max_corrections != 0
116
+ raise BasehError.new(
117
+ "INVALID_CHECKSUM",
118
+ "The reference code did not pass validation"
119
+ )
120
+ end
121
+ # Spec 10: replacements that are not body alphabet symbols are
122
+ # dropped before candidate generation. A suggested symbol the alphabet
123
+ # cannot contain (say a spoken drop on a stripped-alphabet profile)
124
+ # could never validate; generating it anyway would throw
125
+ # INVALID_CHARACTER from the checksum step instead of reporting an
126
+ # honest INVALID_CHECKSUM.
127
+ map = confusion_map(confusion_profile)
128
+ filtered = {}
129
+ map.each do |source, replacements|
130
+ kept = replacements.select { |r| @body_index.key?(r) }
131
+ filtered[source] = kept unless kept.empty?
132
+ end
133
+ valid = {}
134
+ generate_candidates(body, filtered, max_corrections).each do |candidate|
135
+ if Checksum.calculate_checksum(@profile, candidate, @body_index) == supplied_checksum
136
+ valid[candidate] = true
137
+ end
138
+ end
139
+ case valid.size
140
+ when 0
141
+ raise BasehError.new(
142
+ "INVALID_CHECKSUM",
143
+ "The reference code did not pass validation"
144
+ )
145
+ when 1
146
+ body = valid.keys.first
147
+ else
148
+ raise BasehError.new(
149
+ "AMBIGUOUS_INPUT",
150
+ "The reference code matches more than one record",
151
+ safe_for_customer: false
152
+ )
153
+ end
154
+ end
155
+
156
+ value = BaseN.decode_base_n(body, @profile.body_alphabet, @body_index)
157
+ perm = @profile.permutation
158
+ if perm[:enabled]
159
+ value = Feistel.inverse_permute(
160
+ value, @profile.capacity,
161
+ profile_id: @profile.profile_id,
162
+ key_bytes: perm[:key_bytes],
163
+ rounds: perm[:rounds]
164
+ )
165
+ end
166
+
167
+ # encode re-scans the blocklist, so decode raises BLOCKED_CODE when
168
+ # reconstructing a canonical form that could never have been issued
169
+ # (spec 18.2).
170
+ canonical_code = encode(id: value)
171
+ corrected = raw != canonical_raw(canonical_code)
172
+ DecodeResult.new(id: value, canonical_code: canonical_code, corrected: corrected)
173
+ end
174
+
175
+ # Spec section 12.4. Never raises on user input; returns a ValidateResult
176
+ # with the failing error code in #reason instead.
177
+ def validate(input, **options)
178
+ result = decode(input, **options)
179
+ ValidateResult.new(valid: true, canonical_code: result.canonical_code)
180
+ rescue BasehError => e
181
+ ValidateResult.new(valid: false, reason: e.code)
182
+ end
183
+
184
+ # Spec 3.1 normalization, steps 1-9, with the spec 3.4 re-pad. Returns
185
+ # the raw unformatted string.
186
+ def normalize(input, accept_spaces)
187
+ s = input.gsub(ASCII_WS, "")
188
+ s = s.delete(@profile.separator) unless @profile.separator.empty?
189
+ s = s.delete(" ") if accept_spaces
190
+ s = s.upcase unless @profile.case_sensitive
191
+ s = s.each_char.map { |ch| @profile.aliases.fetch(ch, ch) }.join
192
+
193
+ s.each_char do |ch|
194
+ next if @body_index.key?(ch) || @profile.checksum_alphabet.include?(ch)
195
+
196
+ raise BasehError.new(
197
+ "INVALID_CHARACTER",
198
+ "Symbol #{ch.inspect} is not accepted"
199
+ )
200
+ end
201
+
202
+ expected = @profile.body_length + @profile.checksum_length
203
+ # Spec 3.4: a code that lost leading zero body symbols is re-padded
204
+ # with the body zero symbol. The checksum symbols always remain, so
205
+ # the split point is unambiguous. A fully stripped no-checksum code
206
+ # would be empty and stays a length error.
207
+ if s.length < expected && s.length >= [@profile.checksum_length, 1].max
208
+ s = @profile.body_alphabet[0] * (expected - s.length) + s
209
+ end
210
+ if s.length != expected
211
+ raise BasehError.new(
212
+ "INVALID_LENGTH",
213
+ "Expected #{expected} symbols, got #{s.length}"
214
+ )
215
+ end
216
+ s
217
+ end
218
+
219
+ # Spec section 10. Substitution-only generation, capped and deduplicated.
220
+ def generate_candidates(body, confusion_map, max_edits = 1)
221
+ return [] if max_edits.zero?
222
+
223
+ results = {}
224
+ chars = body.chars
225
+ chars.each_index do |pos|
226
+ Array(confusion_map[chars[pos]]).each do |replacement|
227
+ candidate = chars.dup
228
+ candidate[pos] = replacement
229
+ results[candidate.join] = true
230
+ next unless results.size > MAX_CANDIDATES
231
+
232
+ raise BasehError.new(
233
+ "TOO_MANY_CANDIDATES",
234
+ "Candidate generation exceeded 64 entries",
235
+ safe_for_customer: false
236
+ )
237
+ end
238
+ end
239
+ results.keys
240
+ end
241
+
242
+ private
243
+
244
+ # Spec 18.2: case-insensitive substring scan over the raw unformatted
245
+ # code. BLOCKED_CODE is an issuance decision, not an end-user condition.
246
+ def check_blocklist!(raw)
247
+ return if @profile.blocklist.empty?
248
+
249
+ upper = raw.upcase
250
+ @profile.blocklist.each do |word|
251
+ next unless upper.include?(word)
252
+
253
+ raise BasehError.new(
254
+ "BLOCKED_CODE",
255
+ "The generated reference contains a blocked substring",
256
+ safe_for_customer: false
257
+ )
258
+ end
259
+ end
260
+
261
+ def confusion_map(name)
262
+ case name
263
+ when :none, "none" then {}.freeze
264
+ when :light, "light" then CONFUSION_MAPS[:light]
265
+ when :medium, "medium" then CONFUSION_MAPS[:medium]
266
+ when :heavy, "heavy" then CONFUSION_MAPS[:heavy]
267
+ else
268
+ raise ArgumentError, "unknown confusion profile #{name.inspect}"
269
+ end
270
+ end
271
+
272
+ def format_raw(raw)
273
+ return raw if @profile.separator.empty?
274
+
275
+ parts = []
276
+ offset = 0
277
+ @profile.grouping.each do |size|
278
+ parts << raw.slice(offset, size)
279
+ offset += size
280
+ end
281
+ parts.join(@profile.separator)
282
+ end
283
+
284
+ def canonical_raw(canonical_code)
285
+ return canonical_code if @profile.separator.empty?
286
+
287
+ canonical_code.delete(@profile.separator)
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Baseh
4
+ # Fixed-length base-N encoding, spec section 5. Most significant digit first.
5
+ module BaseN
6
+ module_function
7
+
8
+ # Spec 5.1. All arithmetic stays in Integer (arbitrary precision).
9
+ def encode_base_n(value, alphabet, length)
10
+ base = alphabet.length
11
+ out = Array.new(length)
12
+ v = value
13
+ (length - 1).downto(0) do |pos|
14
+ digit = v % base
15
+ out[pos] = alphabet[digit]
16
+ v /= base
17
+ end
18
+ out.join
19
+ end
20
+
21
+ # Spec 5.2. Raises INVALID_CHARACTER for symbols outside the alphabet.
22
+ def decode_base_n(text, alphabet, index = nil)
23
+ index ||= alphabet_index(alphabet)
24
+ base = alphabet.length
25
+ value = 0
26
+ text.each_char do |ch|
27
+ digit = index[ch]
28
+ if digit.nil?
29
+ raise BasehError.new(
30
+ "INVALID_CHARACTER",
31
+ "Symbol #{ch.inspect} is not in the alphabet"
32
+ )
33
+ end
34
+ value = value * base + digit
35
+ end
36
+ value
37
+ end
38
+
39
+ def alphabet_index(alphabet)
40
+ alphabet.each_char.each_with_index.to_h
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Baseh
4
+ # Version 1 checksum, spec section 6.2. Rolling polynomial over symbol
5
+ # values, then modulus conversion into the checksum alphabet.
6
+ module Checksum
7
+ INITIAL_STATE = 17
8
+ MULTIPLIER = 37
9
+
10
+ module_function
11
+
12
+ # Returns the checksum value in [0, modulus).
13
+ def checksum_value(prepared, body, body_index = nil)
14
+ body_index ||= BaseN.alphabet_index(prepared.body_alphabet)
15
+ modulus = prepared.checksum_modulus
16
+
17
+ state = INITIAL_STATE
18
+ prepared.profile_id.each_byte do |byte|
19
+ state = (state * MULTIPLIER + byte + 1) % modulus
20
+ end
21
+ state = (state * MULTIPLIER) % modulus
22
+ body.each_char.each_with_index do |ch, pos|
23
+ symbol_value = body_index[ch]
24
+ unless symbol_value
25
+ raise BasehError.new(
26
+ "INVALID_CHARACTER",
27
+ "Body symbol #{ch.inspect} is not in the body alphabet"
28
+ )
29
+ end
30
+ state = (state * MULTIPLIER + symbol_value + pos + 1) % modulus
31
+ end
32
+ state
33
+ end
34
+
35
+ # Expected checksum string for a normalized body.
36
+ def calculate_checksum(prepared, body, body_index = nil)
37
+ return "" if prepared.checksum_length.zero?
38
+
39
+ body_index ||= BaseN.alphabet_index(prepared.body_alphabet)
40
+ value = checksum_value(prepared, body, body_index)
41
+ BaseN.encode_base_n(value, prepared.checksum_alphabet, prepared.checksum_length)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Baseh
4
+ # Error raised by every baseH failure path. #code is one of the spec error
5
+ # codes (spec sections 12, 13 and 18).
6
+ class BasehError < StandardError
7
+ CODES = %w[
8
+ INVALID_PROFILE
9
+ OUT_OF_RANGE
10
+ PERMUTATION_FAILURE
11
+ INVALID_LENGTH
12
+ INVALID_CHARACTER
13
+ INVALID_CHECKSUM
14
+ AMBIGUOUS_INPUT
15
+ TOO_MANY_CANDIDATES
16
+ BLOCKED_CODE
17
+ ].freeze
18
+
19
+ # @return [String] one of CODES
20
+ attr_reader :code
21
+
22
+ # @return [Boolean] true when the message may be shown to an end user
23
+ attr_reader :safe_for_customer
24
+
25
+ def initialize(code, message, safe_for_customer: true)
26
+ raise ArgumentError, "unknown baseH error code #{code}" unless CODES.include?(code)
27
+
28
+ super(message)
29
+ @code = code
30
+ @safe_for_customer = safe_for_customer
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Baseh
6
+ # Balanced Feistel network with cycle walking, spec section 7.3.
7
+ # HMAC-SHA-256 comes from OpenSSL; HMAC and SHA-256 are never implemented
8
+ # by hand (section 7.5).
9
+ module Feistel
10
+ TAG = "BASEH-FEISTEL-V1".b
11
+ MAX_WALKS = 1000
12
+
13
+ module_function
14
+
15
+ # ceil(log2(capacity)); capacity >= 2 so bits >= 1.
16
+ def bit_length(capacity)
17
+ (capacity - 1).bit_length
18
+ end
19
+
20
+ # Low n bits of the HMAC-SHA-256 digest: first ceil(n / 8) bytes read as
21
+ # a big-endian integer and masked with 2^n - 1.
22
+ def low_bits(digest, n)
23
+ byte_count = (n + 7) / 8
24
+ v = digest.byteslice(0, byte_count).unpack("C*").inject(0) { |acc, b| (acc << 8) | b }
25
+ v & ((1 << n) - 1)
26
+ end
27
+
28
+ def to_be(value, byte_count)
29
+ return "".b if byte_count.zero?
30
+
31
+ bytes = Array.new(byte_count)
32
+ v = value
33
+ (byte_count - 1).downto(0) do |i|
34
+ bytes[i] = v & 0xff
35
+ v >>= 8
36
+ end
37
+ bytes.pack("C*")
38
+ end
39
+
40
+ # Normative round message, spec 7.3 step 4.
41
+ def round_message(profile_id, round, right, wr)
42
+ "".b
43
+ .concat(TAG)
44
+ .concat(0.chr(Encoding::BINARY))
45
+ .concat(profile_id)
46
+ .concat(0.chr(Encoding::BINARY))
47
+ .concat(round.chr(Encoding::BINARY))
48
+ .concat(to_be(right, (wr + 7) / 8))
49
+ end
50
+
51
+ def hmac(key_bytes, message)
52
+ OpenSSL::HMAC.digest(OpenSSL::Digest.new("sha256"), key_bytes, message)
53
+ end
54
+
55
+ def run_rounds(left, right, profile_id, key_bytes, rounds, w0, w1)
56
+ rounds.times do |i|
57
+ even = i.even?
58
+ wr = even ? w1 : w0
59
+ wl = even ? w0 : w1
60
+ f = low_bits(hmac(key_bytes, round_message(profile_id, i, right, wr)), wl)
61
+ new_left = right
62
+ new_right = left ^ f
63
+ left = new_left
64
+ right = new_right
65
+ end
66
+ [left, right]
67
+ end
68
+
69
+ def run_inverse(left, right, profile_id, key_bytes, rounds, w0, w1)
70
+ (rounds - 1).downto(0) do |i|
71
+ even = i.even?
72
+ wr = even ? w1 : w0
73
+ wl = even ? w0 : w1
74
+ f = low_bits(hmac(key_bytes, round_message(profile_id, i, left, wr)), wl)
75
+ prev_right = left
76
+ prev_left = right ^ f
77
+ left = prev_left
78
+ right = prev_right
79
+ end
80
+ [left, right]
81
+ end
82
+
83
+ # Forward permutation with cycle walking.
84
+ def permute(value, capacity, profile_id:, key_bytes:, rounds:)
85
+ walk(value, capacity, profile_id, key_bytes, rounds) do |left, right, w0, w1|
86
+ run_rounds(left, right, profile_id, key_bytes, rounds, w0, w1)
87
+ end
88
+ end
89
+
90
+ # Inverse permutation with cycle walking.
91
+ def inverse_permute(value, capacity, profile_id:, key_bytes:, rounds:)
92
+ walk(value, capacity, profile_id, key_bytes, rounds) do |left, right, w0, w1|
93
+ run_inverse(left, right, profile_id, key_bytes, rounds, w0, w1)
94
+ end
95
+ end
96
+
97
+ def walk(value, capacity, profile_id, key_bytes, rounds)
98
+ bits = bit_length(capacity)
99
+ w1 = bits / 2
100
+ w0 = bits - w1
101
+ v = value
102
+ MAX_WALKS.times do
103
+ left = v >> w1
104
+ right = v & ((1 << w1) - 1)
105
+ out_left, out_right = yield(left, right, w0, w1)
106
+ combined = (out_left << w1) | out_right
107
+ return combined if combined < capacity
108
+
109
+ v = combined
110
+ end
111
+ raise BasehError.new(
112
+ "PERMUTATION_FAILURE",
113
+ "Feistel cycle walking exceeded 1000 iterations",
114
+ safe_for_customer: false
115
+ )
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Baseh
4
+ # Profanity safety, spec section 18. Profiles gain an optional
5
+ # profanity: { mode:, words:, extra_words: } object. It never changes
6
+ # decode behavior for issued codes and never changes capacity accounting.
7
+ module Profanity
8
+ # Spec 18.2 default list. Deliberately small; applications extend it.
9
+ DEFAULT_BLOCKLIST = %w[
10
+ CRAP TWAT SHAG DAMN FCK FUC SHT CNT TWT DCK AZZ BCH
11
+ ].freeze
12
+
13
+ MODES = %w[none no-vowels blocklist].freeze
14
+ WORD = /\A[A-Za-z]{2,32}\z/.freeze
15
+ VOWELS = "AEIOU"
16
+
17
+ module_function
18
+
19
+ # Spec 18.1: vowels removed for no-vowels mode, applied after case
20
+ # normalization.
21
+ def strip_vowels(alphabet_norm)
22
+ alphabet_norm.delete(VOWELS)
23
+ end
24
+
25
+ # Spec 18.2: replacement semantics, then augmentation, uppercased and
26
+ # deduplicated. Raises BasehError INVALID_PROFILE for malformed entries.
27
+ def effective_blocklist(profanity)
28
+ base = profanity[:words] || DEFAULT_BLOCKLIST
29
+ list = Array(base) + Array(profanity[:extra_words] || [])
30
+ out = []
31
+ list.each do |word|
32
+ unless word.is_a?(String) && WORD.match?(word)
33
+ raise BasehError.new(
34
+ "INVALID_PROFILE",
35
+ "Invalid baseH profile: blocklist entries must be 2 through 32 ASCII letters",
36
+ safe_for_customer: false
37
+ )
38
+ end
39
+ upper = word.upcase
40
+ out << upper unless out.include?(upper)
41
+ end
42
+ out
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Baseh
4
+ # Profile validation and derived values, spec section 2.2.
5
+ # Validation runs once at construction, never per encode/decode.
6
+ #
7
+ # Profiles are plain hashes with symbol keys:
8
+ # profile_id:, body_alphabet:, body_length:, checksum_alphabet:,
9
+ # checksum_length:, case_sensitive:, separator:, grouping:, aliases:,
10
+ # permutation: { enabled: true, algorithm: "feistel-v1", key_id:,
11
+ # key_bytes:, rounds: } or { enabled: false },
12
+ # profanity: { mode: "none" | "no-vowels" | "blocklist",
13
+ # words: [...], extra_words: [...] } (optional, spec 18)
14
+ module Profile
15
+ ASCII_ONLY = /\A[\x20-\x7e]*\z/.freeze
16
+
17
+ # Immutable, fully validated profile with pre-computed derived values.
18
+ class Prepared
19
+ attr_reader :profile_id, :body_alphabet, :body_length,
20
+ :checksum_alphabet, :checksum_length, :case_sensitive,
21
+ :separator, :grouping, :aliases, :permutation,
22
+ :capacity, :checksum_modulus, :profanity_mode, :blocklist
23
+
24
+ def initialize(profile)
25
+ validate_type!(profile)
26
+ @profile_id = validate_profile_id!(profile[:profile_id])
27
+ @case_sensitive = profile[:case_sensitive] == true
28
+
29
+ @body_alphabet = validate_body_alphabet!(profile[:body_alphabet])
30
+ @body_length = validate_integer!(profile[:body_length], 1, 32, "bodyLength")
31
+ @checksum_length = validate_integer!(profile[:checksum_length], 0, 8, "checksumLength")
32
+ @checksum_alphabet = validate_checksum_alphabet!(
33
+ profile[:checksum_alphabet], @checksum_length
34
+ )
35
+
36
+ # Spec 18: validation happens before the vowel strip so malformed
37
+ # alphabets are reported as such, then no-vowels strips and
38
+ # re-validates the result.
39
+ @profanity_mode = validate_profanity_mode!(profile[:profanity])
40
+ if @profanity_mode == "no-vowels"
41
+ @body_alphabet = Profanity.strip_vowels(@body_alphabet)
42
+ @checksum_alphabet = Profanity.strip_vowels(@checksum_alphabet)
43
+ validate_stripped!(@body_alphabet, "body")
44
+ validate_stripped!(@checksum_alphabet, "checksum") if @checksum_length.positive?
45
+ @body_alphabet.freeze
46
+ @checksum_alphabet.freeze
47
+ end
48
+ @blocklist =
49
+ if @profanity_mode == "blocklist"
50
+ Profanity.effective_blocklist(profile[:profanity]).freeze
51
+ else
52
+ [].freeze
53
+ end
54
+
55
+ @separator = validate_separator!(
56
+ profile[:separator].to_s, @body_alphabet, @checksum_alphabet
57
+ )
58
+ @aliases = validate_aliases!(
59
+ profile[:aliases] || {}, @body_alphabet, @checksum_alphabet, @case_sensitive
60
+ )
61
+ @grouping = validate_grouping!(
62
+ profile[:grouping], @separator, @body_length, @checksum_length
63
+ )
64
+ @permutation = validate_permutation!(profile[:permutation] || { enabled: false })
65
+
66
+ @capacity = @body_alphabet.length**@body_length
67
+ @checksum_modulus = [@checksum_alphabet.length, 1].max**@checksum_length
68
+ freeze
69
+ end
70
+
71
+ private
72
+
73
+ def self.fail_profile!(reason)
74
+ raise BasehError.new("INVALID_PROFILE", "Invalid baseH profile: #{reason}", safe_for_customer: false)
75
+ end
76
+
77
+ def ascii_char?(ch)
78
+ ch.is_a?(String) && ch.length == 1 && ASCII_ONLY.match?(ch)
79
+ end
80
+
81
+ def validate_type!(profile)
82
+ self.class.fail_profile!("profile is required") unless profile.is_a?(Hash)
83
+ end
84
+
85
+ def validate_profile_id!(value)
86
+ unless value.is_a?(String) && !value.empty?
87
+ self.class.fail_profile!("profileId must be non-empty")
88
+ end
89
+ unless ASCII_ONLY.match?(value)
90
+ self.class.fail_profile!("profileId must be ASCII")
91
+ end
92
+ value
93
+ end
94
+
95
+ def validate_alphabet_symbols!(alphabet, label)
96
+ alphabet.each_char do |ch|
97
+ next if ascii_char?(ch)
98
+
99
+ self.class.fail_profile!("#{label} symbol is not single ASCII: #{ch.inspect}")
100
+ end
101
+ end
102
+
103
+ def norm_string(str)
104
+ @case_sensitive ? str : str.upcase
105
+ end
106
+
107
+ def validate_body_alphabet!(alphabet)
108
+ unless alphabet.is_a?(String) && alphabet.length >= 2
109
+ self.class.fail_profile!("bodyAlphabet needs at least two symbols")
110
+ end
111
+ validate_alphabet_symbols!(alphabet, "body alphabet")
112
+ normed = norm_string(alphabet)
113
+ unless normed.each_char.uniq.length == normed.length
114
+ self.class.fail_profile!("body alphabet symbols must be unique after case normalization")
115
+ end
116
+ normed.freeze
117
+ end
118
+
119
+ def validate_integer!(value, min, max, label)
120
+ unless value.is_a?(Integer) && value >= min && value <= max
121
+ self.class.fail_profile!("#{label} must be an integer from #{min} through #{max}")
122
+ end
123
+ value
124
+ end
125
+
126
+ def validate_checksum_alphabet!(alphabet, checksum_length)
127
+ alphabet = alphabet.to_s
128
+ if checksum_length.positive?
129
+ unless alphabet.is_a?(String) && alphabet.length >= 2
130
+ self.class.fail_profile!(
131
+ "checksumAlphabet needs at least two symbols when checksumLength is positive"
132
+ )
133
+ end
134
+ validate_alphabet_symbols!(alphabet, "checksum alphabet")
135
+ end
136
+ normed = norm_string(alphabet)
137
+ unless normed.each_char.uniq.length == normed.length
138
+ self.class.fail_profile!(
139
+ "checksum alphabet symbols must be unique after case normalization"
140
+ )
141
+ end
142
+ normed.freeze
143
+ end
144
+
145
+ def validate_profanity_mode!(profanity)
146
+ mode = "none"
147
+ if profanity.is_a?(Hash)
148
+ mode = profanity[:mode].to_s
149
+ elsif !profanity.nil?
150
+ self.class.fail_profile!("profanity must be a mapping")
151
+ end
152
+ unless Profanity::MODES.include?(mode)
153
+ self.class.fail_profile!("profanity mode must be none, no-vowels or blocklist")
154
+ end
155
+ mode
156
+ end
157
+
158
+ def validate_stripped!(alphabet, label)
159
+ return if alphabet.length >= 2
160
+
161
+ self.class.fail_profile!(
162
+ "no-vowels mode leaves the #{label} alphabet with fewer than two symbols"
163
+ )
164
+ end
165
+
166
+ def validate_separator!(separator, body_norm, checksum_norm)
167
+ separator.each_char do |ch|
168
+ next unless body_norm.include?(ch) || checksum_norm.include?(ch)
169
+
170
+ self.class.fail_profile!("separator must not occur in either alphabet")
171
+ end
172
+ separator.freeze
173
+ end
174
+
175
+ def validate_aliases!(aliases, body_norm, checksum_norm, case_sensitive)
176
+ unless aliases.is_a?(Hash)
177
+ self.class.fail_profile!("aliases must be a mapping")
178
+ end
179
+ canonical = (body_norm + checksum_norm).each_char.to_a.to_set
180
+ result = {}
181
+ aliases.each do |src, tgt|
182
+ src = src.to_s
183
+ tgt = tgt.to_s
184
+ unless ascii_char?(src)
185
+ self.class.fail_profile!("alias source is not single ASCII: #{src.inspect}")
186
+ end
187
+ unless ascii_char?(tgt)
188
+ self.class.fail_profile!("alias target is not single ASCII: #{tgt.inspect}")
189
+ end
190
+ s_norm = case_sensitive ? src : src.upcase
191
+ t_norm = case_sensitive ? tgt : tgt.upcase
192
+ if canonical.include?(s_norm)
193
+ self.class.fail_profile!("alias source #{src.inspect} is already a canonical symbol")
194
+ end
195
+ unless canonical.include?(t_norm)
196
+ self.class.fail_profile!("alias target #{tgt.inspect} is not a canonical symbol")
197
+ end
198
+ if result.key?(s_norm)
199
+ self.class.fail_profile!("duplicate alias source #{s_norm.inspect} after case normalization")
200
+ end
201
+ # Alias chains (and therefore cycles) are forbidden: a target may
202
+ # never itself be an alias source.
203
+ chain = result.key?(t_norm) ||
204
+ aliases.keys.any? { |k| (case_sensitive ? k.to_s : k.to_s.upcase) == t_norm }
205
+ if chain
206
+ self.class.fail_profile!("alias chain forbidden: target #{t_norm} is also an alias source")
207
+ end
208
+ result[s_norm] = t_norm
209
+ end
210
+ result.freeze
211
+ end
212
+
213
+ def validate_grouping!(grouping, separator, body_length, checksum_length)
214
+ unless grouping.is_a?(Array)
215
+ self.class.fail_profile!("group sizes must be positive integers")
216
+ end
217
+ if separator.empty?
218
+ unless grouping.empty?
219
+ self.class.fail_profile!("grouping must be empty when separator is empty")
220
+ end
221
+ else
222
+ unless grouping.all? { |g| g.is_a?(Integer) && g >= 1 }
223
+ self.class.fail_profile!("group sizes must be positive integers")
224
+ end
225
+ unless grouping.sum == body_length + checksum_length
226
+ self.class.fail_profile!("group sizes must sum to bodyLength + checksumLength")
227
+ end
228
+ end
229
+ grouping.dup.freeze
230
+ end
231
+
232
+ def validate_permutation!(permutation)
233
+ unless permutation.is_a?(Hash)
234
+ self.class.fail_profile!("permutation must be a mapping")
235
+ end
236
+ return { enabled: false }.freeze unless permutation[:enabled] == true
237
+
238
+ if permutation[:algorithm] != "feistel-v1"
239
+ self.class.fail_profile!("unknown permutation algorithm")
240
+ end
241
+ key_id = permutation[:key_id]
242
+ unless key_id.is_a?(String) && !key_id.empty?
243
+ self.class.fail_profile!("permutation requires a keyId")
244
+ end
245
+ key_bytes = permutation[:key_bytes]
246
+ unless key_bytes.is_a?(String) && !key_bytes.empty?
247
+ self.class.fail_profile!("permutation requires key material")
248
+ end
249
+ rounds = permutation[:rounds]
250
+ unless rounds.is_a?(Integer) && rounds >= 4 && rounds <= 16 && rounds.even?
251
+ self.class.fail_profile!("Feistel rounds must be an even integer from 4 through 16")
252
+ end
253
+ {
254
+ enabled: true,
255
+ algorithm: "feistel-v1",
256
+ key_id: key_id.dup.freeze,
257
+ key_bytes: key_bytes.dup.force_encoding(Encoding::BINARY).freeze,
258
+ rounds: rounds
259
+ }.freeze
260
+ end
261
+ end
262
+
263
+ # Validates a profile hash and returns a Prepared instance.
264
+ # Raises BasehError with code INVALID_PROFILE on any violation.
265
+ def self.prepare(profile)
266
+ Prepared.new(profile)
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Baseh
4
+ # Frozen tier profiles. Each is built from the full alphanumeric set with
5
+ # cumulative visual and spoken strips; the spoken strips interact with the
6
+ # visual ones exactly as the web tools derive them, so the tool capacities
7
+ # match.
8
+ #
9
+ # Minimum 36 symbols, no checksum 2,176,782,336 ids
10
+ # Light 31 symbols, 1 checksum 887,503,681 ids
11
+ # Medium 28 symbols, 1 checksum 481,890,304 ids (default)
12
+ # Heavy 26 symbols, 1 checksum 308,915,776 ids
13
+ #
14
+ # All four keep the typed O/I/L aliases where possible and run the default
15
+ # profanity blocklist. Minimum also uses a hyphen delimiter; the rest have
16
+ # none. The _p variants are identical but with feistel-v1 permutation and
17
+ # require caller-supplied key material.
18
+ module Profiles
19
+ OIL_ALIASES = { "O" => "0", "I" => "1", "L" => "1" }.freeze
20
+
21
+ # Tier shapes shared by the plain and (-p) keyed helpers. The values are
22
+ # thawed on every build, so each helper returns a fresh mutable profile.
23
+ TIERS = {
24
+ minimum: {
25
+ profile_id: "baseh-minimum",
26
+ body_alphabet: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ",
27
+ checksum_alphabet: "",
28
+ checksum_length: 0,
29
+ separator: "-",
30
+ grouping: [3, 3],
31
+ aliases: {}
32
+ },
33
+ light: {
34
+ profile_id: "baseh-light",
35
+ body_alphabet: "0123456789ABCEFGHJKMNPQRSUVWXYZ",
36
+ checksum_alphabet: "234679ACEFGHJKMNPQRUVWXY",
37
+ checksum_length: 1,
38
+ separator: "",
39
+ grouping: [],
40
+ aliases: { **OIL_ALIASES, "D" => "B", "T" => "P" }
41
+ },
42
+ medium: {
43
+ profile_id: "baseh-medium",
44
+ body_alphabet: "0123456789ACDEFGHJKMPQRUVXYZ",
45
+ checksum_alphabet: "234679ACDEFGHJKMPQRUVXY",
46
+ checksum_length: 1,
47
+ separator: "",
48
+ grouping: [],
49
+ # B and S are dropped for looking like 8 and 5; since they can never
50
+ # be issued, a typed B is always an 8 and a typed S always a 5.
51
+ aliases: { **OIL_ALIASES, "B" => "8", "S" => "5", "T" => "P",
52
+ "N" => "M", "W" => "V" }
53
+ },
54
+ heavy: {
55
+ profile_id: "baseh-heavy",
56
+ body_alphabet: "0123456789ABCEFHJKMPQRVXYZ",
57
+ checksum_alphabet: "234679ACEFHJKMPQRUVXY",
58
+ checksum_length: 1,
59
+ separator: "",
60
+ grouping: [],
61
+ aliases: { **OIL_ALIASES, "D" => "B", "T" => "P", "N" => "M",
62
+ "W" => "V", "S" => "F", "G" => "C" }
63
+ }
64
+ }.freeze
65
+
66
+ module_function
67
+
68
+ # Alphanumeric, no safety strips, no checksum, hyphen-delimited XXX-XXX.
69
+ def baseh_minimum_v1
70
+ tier(:minimum, { enabled: false }, false)
71
+ end
72
+
73
+ # baseh-minimum with feistel-v1 permutation. key_bytes is required.
74
+ def baseh_minimum_p_v1(key_bytes:, key_id: "default", rounds: 8)
75
+ tier(:minimum, keyed_permutation(key_bytes, key_id, rounds), true)
76
+ end
77
+
78
+ # Visual light plus spoken light, one checksum symbol.
79
+ def baseh_light_v1
80
+ tier(:light, { enabled: false }, false)
81
+ end
82
+
83
+ # baseh-light with feistel-v1 permutation. key_bytes is required.
84
+ def baseh_light_p_v1(key_bytes:, key_id: "default", rounds: 8)
85
+ tier(:light, keyed_permutation(key_bytes, key_id, rounds), true)
86
+ end
87
+
88
+ # Visual medium plus spoken medium, one checksum symbol. The default.
89
+ def baseh_medium_v1
90
+ tier(:medium, { enabled: false }, false)
91
+ end
92
+
93
+ # baseh-medium with feistel-v1 permutation. key_bytes is required.
94
+ def baseh_medium_p_v1(key_bytes:, key_id: "default", rounds: 8)
95
+ tier(:medium, keyed_permutation(key_bytes, key_id, rounds), true)
96
+ end
97
+
98
+ # Conservative alphabet plus spoken heavy, one checksum symbol.
99
+ def baseh_heavy_v1
100
+ tier(:heavy, { enabled: false }, false)
101
+ end
102
+
103
+ # baseh-heavy with feistel-v1 permutation. key_bytes is required.
104
+ def baseh_heavy_p_v1(key_bytes:, key_id: "default", rounds: 8)
105
+ tier(:heavy, keyed_permutation(key_bytes, key_id, rounds), true)
106
+ end
107
+
108
+ # feistel-v1 permutation block for the keyed (p) helpers.
109
+ def keyed_permutation(key_bytes, key_id, rounds)
110
+ {
111
+ enabled: true,
112
+ algorithm: "feistel-v1",
113
+ key_id: key_id,
114
+ key_bytes: key_bytes,
115
+ rounds: rounds
116
+ }
117
+ end
118
+
119
+ # Builds a fresh mutable profile for a tier. Every call returns new
120
+ # strings, arrays and hashes so callers can load a default and modify it.
121
+ def tier(tier_name, permutation, p_suffix)
122
+ shape = TIERS.fetch(tier_name)
123
+ {
124
+ profile_id: shape[:profile_id] + (p_suffix ? "-p" : "") + "-v1",
125
+ body_alphabet: shape[:body_alphabet].dup,
126
+ body_length: 6,
127
+ checksum_alphabet: shape[:checksum_alphabet].dup,
128
+ checksum_length: shape[:checksum_length],
129
+ case_sensitive: false,
130
+ separator: shape[:separator].dup,
131
+ grouping: shape[:grouping].dup,
132
+ aliases: shape[:aliases].dup,
133
+ permutation: permutation,
134
+ profanity: { mode: "blocklist" }
135
+ }
136
+ end
137
+ private_class_method :keyed_permutation, :tier
138
+ end
139
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Baseh
4
+ VERSION = "1.1.0"
5
+ end
data/lib/baseh/zero.rb ADDED
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Baseh
4
+ # Zero-config pair over the frozen baseh-medium-v1 profile. No profile
5
+ # object, no key: just the two functions an application needs when it
6
+ # does not want to think about configuration.
7
+ #
8
+ # Baseh.to_code(481890303) -> "ZZZZZZV"
9
+ # Baseh.from_code("ZZZZZZV") -> 481890303
10
+ #
11
+ # to_code accepts an Integer or a decimal string of digits. from_code
12
+ # strips every whitespace character (edges and internal), accepts
13
+ # lowercase and the typed aliases (O, I, L) and returns the id as an
14
+ # Integer. Any invalid input raises BasehError, including the rare
15
+ # BLOCKED_CODE identifiers that spell a blocklisted word; no correction
16
+ # attempts are ever made.
17
+ module Zero
18
+ DECIMAL = /\A[0-9]+\z/.freeze
19
+ WHITESPACE = /\s+/.freeze
20
+
21
+ ZERO = Baseh.new(Profiles.baseh_medium_v1)
22
+
23
+ module_function
24
+
25
+ # Encode an identifier with the zero-config Medium profile.
26
+ #
27
+ # @param id [Integer, String] Integer or decimal string of digits
28
+ # @return [String] canonical code
29
+ # @raise [ArgumentError] when id is neither an Integer nor a decimal string
30
+ # @raise [BasehError] OUT_OF_RANGE, BLOCKED_CODE
31
+ def to_code(id)
32
+ value =
33
+ case id
34
+ when Integer then id
35
+ when String
36
+ if DECIMAL.match?(id)
37
+ id.to_i
38
+ else
39
+ raise ArgumentError,
40
+ "to_code expects a non-negative Integer or a decimal string"
41
+ end
42
+ else
43
+ raise ArgumentError,
44
+ "to_code expects a non-negative Integer or a decimal string"
45
+ end
46
+ ZERO.encode(id: value)
47
+ end
48
+
49
+ # Decode a code from the zero-config Medium profile back to its id.
50
+ #
51
+ # @param code [String]
52
+ # @return [Integer]
53
+ # @raise [BasehError] INVALID_LENGTH, INVALID_CHARACTER, INVALID_CHECKSUM
54
+ def from_code(code)
55
+ input = code.is_a?(String) ? code.gsub(WHITESPACE, "") : code
56
+ ZERO.decode(input).id
57
+ end
58
+ end
59
+
60
+ class << self
61
+ # See Baseh::Zero.to_code.
62
+ def to_code(id)
63
+ Zero.to_code(id)
64
+ end
65
+
66
+ # See Baseh::Zero.from_code.
67
+ def from_code(code)
68
+ Zero.from_code(code)
69
+ end
70
+ end
71
+ end
data/lib/baseh.rb ADDED
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require_relative "baseh/version"
5
+ require_relative "baseh/errors"
6
+ require_relative "baseh/profanity"
7
+ require_relative "baseh/profile"
8
+ require_relative "baseh/basen"
9
+ require_relative "baseh/checksum"
10
+ require_relative "baseh/feistel"
11
+ require_relative "baseh/profiles"
12
+ require_relative "baseh/baseh"
13
+ require_relative "baseh/zero"
14
+
15
+ # baseH (Human Reference Code) codec. See spec/IMPLEMENTATION_CODEC.md in the
16
+ # repository root for the normative specification.
17
+ module Baseh
18
+ class << self
19
+ # Frozen tier baseh-minimum-v1: alphanumeric with no strips, no checksum,
20
+ # hyphen-delimited XXX-XXX. Permutation off.
21
+ def baseh_minimum_v1
22
+ Profiles.baseh_minimum_v1
23
+ end
24
+
25
+ # baseh-minimum with feistel-v1 permutation. key_bytes is required.
26
+ def baseh_minimum_p_v1(key_bytes:, key_id: "default", rounds: 8)
27
+ Profiles.baseh_minimum_p_v1(key_bytes: key_bytes, key_id: key_id, rounds: rounds)
28
+ end
29
+
30
+ # Frozen tier baseh-light-v1: visual light plus spoken light, one
31
+ # checksum symbol. Permutation off.
32
+ def baseh_light_v1
33
+ Profiles.baseh_light_v1
34
+ end
35
+
36
+ # baseh-light with feistel-v1 permutation. key_bytes is required.
37
+ def baseh_light_p_v1(key_bytes:, key_id: "default", rounds: 8)
38
+ Profiles.baseh_light_p_v1(key_bytes: key_bytes, key_id: key_id, rounds: rounds)
39
+ end
40
+
41
+ # Frozen tier baseh-medium-v1: visual medium plus spoken medium, one
42
+ # checksum symbol. The default. Permutation off.
43
+ def baseh_medium_v1
44
+ Profiles.baseh_medium_v1
45
+ end
46
+
47
+ # baseh-medium with feistel-v1 permutation. key_bytes is required.
48
+ def baseh_medium_p_v1(key_bytes:, key_id: "default", rounds: 8)
49
+ Profiles.baseh_medium_p_v1(key_bytes: key_bytes, key_id: key_id, rounds: rounds)
50
+ end
51
+
52
+ # Frozen tier baseh-heavy-v1: conservative alphabet plus spoken heavy,
53
+ # one checksum symbol. Permutation off.
54
+ def baseh_heavy_v1
55
+ Profiles.baseh_heavy_v1
56
+ end
57
+
58
+ # baseh-heavy with feistel-v1 permutation. key_bytes is required.
59
+ def baseh_heavy_p_v1(key_bytes:, key_id: "default", rounds: 8)
60
+ Profiles.baseh_heavy_p_v1(key_bytes: key_bytes, key_id: key_id, rounds: rounds)
61
+ end
62
+ end
63
+ end
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: baseh
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.1.0
5
+ platform: ruby
6
+ authors:
7
+ - cloudyventures
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '13.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '13.0'
27
+ description: 'Encodes and decodes human reference codes per the baseH codec specification:
28
+ fixed-length base-N bodies, rolling polynomial checksums, optional feistel-v1 permutation,
29
+ spoken-confusion correction and profanity safety.'
30
+ email:
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - README.md
36
+ - lib/baseh.rb
37
+ - lib/baseh/baseh.rb
38
+ - lib/baseh/basen.rb
39
+ - lib/baseh/checksum.rb
40
+ - lib/baseh/errors.rb
41
+ - lib/baseh/feistel.rb
42
+ - lib/baseh/profanity.rb
43
+ - lib/baseh/profile.rb
44
+ - lib/baseh/profiles.rb
45
+ - lib/baseh/version.rb
46
+ - lib/baseh/zero.rb
47
+ homepage:
48
+ licenses:
49
+ - AGPL-3.0
50
+ metadata:
51
+ rubygems_mfa_required: 'true'
52
+ post_install_message:
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '3.0'
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 3.5.22
68
+ signing_key:
69
+ specification_version: 4
70
+ summary: baseH (Human Reference Code) codec, Ruby port of the frozen spec
71
+ test_files: []