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 +7 -0
- data/README.md +123 -0
- data/lib/baseh/baseh.rb +290 -0
- data/lib/baseh/basen.rb +43 -0
- data/lib/baseh/checksum.rb +44 -0
- data/lib/baseh/errors.rb +33 -0
- data/lib/baseh/feistel.rb +118 -0
- data/lib/baseh/profanity.rb +45 -0
- data/lib/baseh/profile.rb +269 -0
- data/lib/baseh/profiles.rb +139 -0
- data/lib/baseh/version.rb +5 -0
- data/lib/baseh/zero.rb +71 -0
- data/lib/baseh.rb +63 -0
- metadata +71 -0
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.
|
data/lib/baseh/baseh.rb
ADDED
|
@@ -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
|
data/lib/baseh/basen.rb
ADDED
|
@@ -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
|
data/lib/baseh/errors.rb
ADDED
|
@@ -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
|
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: []
|