baseh 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +117 -27
- data/lib/baseh/baseh.rb +302 -25
- data/lib/baseh/basen.rb +7 -0
- data/lib/baseh/checksum.rb +13 -6
- data/lib/baseh/feistel.rb +27 -14
- data/lib/baseh/profile.rb +143 -12
- data/lib/baseh/profiles.rb +97 -30
- data/lib/baseh/version.rb +1 -1
- data/lib/baseh.rb +89 -11
- metadata +6 -4
- data/lib/baseh/zero.rb +0 -71
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f624ebca106c2a3733ad2d655ff7f56a0b32b5ce55882e8932d107bc7561997d
|
|
4
|
+
data.tar.gz: 6bd8749f57881c40ef0b83b70d35b159fd69a2bb2fb493c0a009bb0b829be7b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b5b2361b1d7d6fc42927f9b9a9ec63d1905a514fb98d8bee73bd365b51a603b8f9a33b668c9219b39a5dc07236a2fd1a0040a3cecbc9061e2e71f001f57130c5
|
|
7
|
+
data.tar.gz: 760c74a6d57f511b4b1d24f0b87ae4e29e364dde6f15d44a7075fc00748943efc022950203051dc00c1113fa2cdcd34a0857974a5a349a28be6006af673089fe
|
data/README.md
CHANGED
|
@@ -1,44 +1,105 @@
|
|
|
1
1
|
# baseh
|
|
2
2
|
|
|
3
3
|
Ruby port of the baseH (Human Reference Code) codec. Encodes integer IDs as
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
short, checksummed, human-friendly reference codes with a feistel-v1
|
|
5
|
+
permutation on every tier and profanity safety. The normative spec is
|
|
6
|
+
`spec/IMPLEMENTATION_CODEC.md` in the monorepo root.
|
|
7
7
|
|
|
8
8
|
## Install
|
|
9
9
|
|
|
10
|
+
```sh
|
|
11
|
+
gem install baseh
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
or in a Gemfile:
|
|
15
|
+
|
|
10
16
|
```ruby
|
|
11
17
|
# Gemfile
|
|
12
18
|
gem "baseh", path: "ruby"
|
|
13
19
|
```
|
|
14
20
|
|
|
15
|
-
or
|
|
21
|
+
or from a local build:
|
|
16
22
|
|
|
17
23
|
```sh
|
|
18
24
|
gem build baseh.gemspec
|
|
19
|
-
gem install ./baseh-
|
|
25
|
+
gem install ./baseh-2.0.0.gem
|
|
20
26
|
```
|
|
21
27
|
|
|
22
28
|
Zero runtime dependencies. Only `openssl` and `json` from the standard
|
|
23
29
|
library are used.
|
|
24
30
|
|
|
25
|
-
##
|
|
31
|
+
## Expandable mode (recommended default)
|
|
32
|
+
|
|
33
|
+
Profiles carry a `mode:` field: `"expandable"` or `"fixed"`. Expandable is
|
|
34
|
+
the recommended default for new users. The frozen tier
|
|
35
|
+
`Baseh.baseh_expandable_v1` ships as the starting point, with a keyed
|
|
36
|
+
private-mapping variant `Baseh.baseh_expandable_p_v1` that relates to it
|
|
37
|
+
exactly as the other `-p` tiers relate to their plain tiers.
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
require "baseh"
|
|
41
|
+
|
|
42
|
+
codec = Baseh::Baseh.new(Baseh.baseh_expandable_v1)
|
|
43
|
+
|
|
44
|
+
code = codec.encode(id: 123_456) # 4 characters at this namespace size
|
|
45
|
+
codec.decode(code).id # => 123456
|
|
46
|
+
```
|
|
26
47
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
48
|
+
How expandable differs from fixed:
|
|
49
|
+
|
|
50
|
+
- **Codes start short and grow.** Minimum length is 4 characters
|
|
51
|
+
(`min_length`, default 4). When the id sequence climbs past a length's
|
|
52
|
+
capacity, codes simply become one character longer — transparently, no
|
|
53
|
+
migration, no re-issue. Old shorter codes keep decoding forever.
|
|
54
|
+
- **Medium safety plus the zero ban.** The default expandable body alphabet
|
|
55
|
+
is the 27 symbols left after the medium visual and spoken safety strips
|
|
56
|
+
and the `0`/`O` zero ban, so an issued code never emits a visual or spoken
|
|
57
|
+
confusable. A custom alphabet containing `0`/`O` has those symbols silently
|
|
58
|
+
removed during profile preparation (the derived alphabet is always
|
|
59
|
+
displayed in tooling). This applies on top of whatever visual/spoken
|
|
60
|
+
safety levels, profanity modes, or blocklists are configured; every
|
|
61
|
+
existing profile option composes with expandable unchanged.
|
|
62
|
+
- **Checksum alphabet gains `0`.** The checksum alphabet is the body
|
|
63
|
+
alphabet plus `0` (28 symbols for the default). The existing input alias
|
|
64
|
+
`O -> 0` remains, so a typed or misread `O` in a checksum position
|
|
65
|
+
resolves to `0`. There is no left-padding in expandable mode; a `0` or
|
|
66
|
+
`O` in a body position of presented input is simply an invalid character.
|
|
67
|
+
- **Permutation stays on.** The Feistel permutation is applied per
|
|
68
|
+
generation (per code length), with the length mixed into the key
|
|
69
|
+
derivation alongside the profile id. Codes within each length look random
|
|
70
|
+
even though issuance is a sequential counter. Presentation only, not
|
|
71
|
+
encryption.
|
|
72
|
+
- **Separators appear later.** Grouping only kicks in once codes reach
|
|
73
|
+
`separator_min_length` (the shipped tier uses 6, so no hyphen until codes
|
|
74
|
+
are 6+ characters). Below the threshold there is no separator.
|
|
75
|
+
- **Decode is length-driven.** The code's length selects the generation;
|
|
76
|
+
the checksum validates exactly as in fixed mode, domain-separated by
|
|
77
|
+
profile id.
|
|
78
|
+
|
|
79
|
+
The security posture is unchanged: a code is a reference alias, never an
|
|
80
|
+
authorization token. Expandable codes in the smallest generations are a
|
|
81
|
+
small namespace, so rate-limit public lookups and enforce authorization
|
|
82
|
+
after decode.
|
|
83
|
+
|
|
84
|
+
## Fixed mode (frozen tiers)
|
|
85
|
+
|
|
86
|
+
Four frozen tiers ship with the gem, all `mode: "fixed"`, built from the
|
|
87
|
+
full alphanumeric set with cumulative visual and spoken strips. All four encode 6 body symbols,
|
|
88
|
+
are case-insensitive, hyphen-delimit at the midpoint, run the default
|
|
89
|
+
profanity blocklist and permute with the published frozen key.
|
|
30
90
|
|
|
31
91
|
| Tier | Helper | Body symbols | Checksum | Format | Capacity |
|
|
32
92
|
| ---- | ------ | ------------ | -------- | ------ | -------- |
|
|
33
93
|
| Minimum | `Baseh.baseh_minimum_v1` | 36 | none | `XXX-XXX` | 2,176,782,336 |
|
|
34
|
-
| Light | `Baseh.baseh_light_v1` | 31 |
|
|
35
|
-
| Medium | `Baseh.baseh_medium_v1` | 28 |
|
|
36
|
-
| Heavy | `Baseh.baseh_heavy_v1` | 26 |
|
|
94
|
+
| Light | `Baseh.baseh_light_v1` | 31 | 2 | `XXXX-XXXX` | 887,503,681 |
|
|
95
|
+
| Medium | `Baseh.baseh_medium_v1` | 28 | 2 | `XXXX-XXXX` | 481,890,304 |
|
|
96
|
+
| Heavy | `Baseh.baseh_heavy_v1` | 26 | 2 | `XXXX-XXXX` | 308,915,776 |
|
|
37
97
|
|
|
38
|
-
Medium is the default
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
98
|
+
Medium is the default of the fixed tiers. The frozen key is public by
|
|
99
|
+
design: it hides sequence, not records. It is not a secret and anyone can read it at
|
|
100
|
+
`Baseh::FROZEN_KEY_BYTES`; it must never change for a live namespace. Each
|
|
101
|
+
tier keeps the typed O/I/L aliases where possible and adds spoken-confusion
|
|
102
|
+
aliases for the stripped symbols.
|
|
42
103
|
|
|
43
104
|
Every helper returns a freshly built mutable profile hash on each call, so
|
|
44
105
|
callers can load a default and modify it before constructing a codec.
|
|
@@ -48,30 +109,43 @@ callers can load a default and modify it before constructing a codec.
|
|
|
48
109
|
```ruby
|
|
49
110
|
require "baseh"
|
|
50
111
|
|
|
51
|
-
codec = Baseh::Baseh.new(Baseh.
|
|
112
|
+
codec = Baseh::Baseh.new(Baseh.baseh_expandable_v1)
|
|
52
113
|
|
|
53
|
-
code = codec.encode(id: 123_456) # =>
|
|
114
|
+
code = codec.encode(id: 123_456) # => short code, grows as ids climb
|
|
54
115
|
|
|
55
116
|
result = codec.decode(code)
|
|
56
117
|
result.id # => 123456
|
|
57
118
|
result.canonical_code # => canonical form
|
|
58
119
|
result.corrected # => true when input needed correction
|
|
59
120
|
|
|
60
|
-
codec.
|
|
61
|
-
|
|
62
|
-
check = codec.validate("0000000")
|
|
121
|
+
check = codec.validate("00000000")
|
|
63
122
|
check.valid # => false
|
|
64
123
|
check.reason # => "INVALID_CHECKSUM"
|
|
65
124
|
|
|
125
|
+
# Live as-you-type feedback for a code entry field (never raises)
|
|
126
|
+
look = codec.inspect("C8XP8")
|
|
127
|
+
look.state # => "typing" (or "empty", "bad-char",
|
|
128
|
+
# "too-long", "invalid", "valid")
|
|
129
|
+
look.typed # => "C8XP-8"
|
|
130
|
+
look.progress # => 0.625
|
|
131
|
+
|
|
66
132
|
# Spoken-confusion correction
|
|
67
|
-
result = codec.decode("
|
|
133
|
+
result = codec.decode("TB14QDFU", try_correction: true, confusion_profile: :light)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The same API applies to the fixed tiers (`codec.capacity` reports the fixed
|
|
137
|
+
namespace size there; expandable grows instead):
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
codec = Baseh::Baseh.new(Baseh.baseh_medium_v1)
|
|
141
|
+
codec.capacity # => 481890304
|
|
68
142
|
```
|
|
69
143
|
|
|
70
|
-
## Permutation
|
|
144
|
+
## Permutation
|
|
71
145
|
|
|
72
|
-
The
|
|
73
|
-
`
|
|
74
|
-
it for a live profile:
|
|
146
|
+
The plain tiers permute with `Baseh::FROZEN_KEY_BYTES`, the published frozen
|
|
147
|
+
key. The `-p` variants take caller-supplied key material instead; keep that
|
|
148
|
+
key in a secret manager and never change it for a live profile:
|
|
75
149
|
|
|
76
150
|
```ruby
|
|
77
151
|
profile = Baseh.baseh_medium_p_v1(
|
|
@@ -82,7 +156,7 @@ codec = Baseh::Baseh.new(profile)
|
|
|
82
156
|
```
|
|
83
157
|
|
|
84
158
|
`rounds:` is also accepted (default 8). The `-p` profile is identical to
|
|
85
|
-
its plain tier apart from the
|
|
159
|
+
its plain tier apart from the key material; its profile id gains a `-p`
|
|
86
160
|
segment, for example `baseh-medium-p-v1`.
|
|
87
161
|
|
|
88
162
|
## Profanity safety (spec 18)
|
|
@@ -121,3 +195,19 @@ The vector tests load `../vectors/vectors.json` and
|
|
|
121
195
|
`../vectors/feistel-vectors.json` from the monorepo root and assert every
|
|
122
196
|
entry. Running the suite from a different directory layout requires those
|
|
123
197
|
files at that relative path.
|
|
198
|
+
|
|
199
|
+
The full 100,000-body single-substitution checksum sweep of the test-suite
|
|
200
|
+
spec (section 6) runs under `BASEH_SOAK=1` alongside the soak suite:
|
|
201
|
+
|
|
202
|
+
```sh
|
|
203
|
+
BASEH_SOAK=1 rake test # full soak + 100k checksum sweep per tier
|
|
204
|
+
BASEH_SOAK=1 BASEH_SOAK_BODIES=1_000 rake test # smoke the sweep
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Linting
|
|
208
|
+
|
|
209
|
+
No RuboCop config ships in this gem: RuboCop is not part of the bundle, and
|
|
210
|
+
adding a lint dependency was judged not worth it for a zero-dependency,
|
|
211
|
+
~500-line library whose conformance is enforced by the shared cross-language
|
|
212
|
+
vectors. The codebase follows the standard Ruby style (two-space indent,
|
|
213
|
+
frozen string literals) by convention instead.
|
data/lib/baseh/baseh.rb
CHANGED
|
@@ -31,6 +31,13 @@ module Baseh
|
|
|
31
31
|
# Result of validate, which never raises on user input.
|
|
32
32
|
ValidateResult = Struct.new(:valid, :canonical_code, :reason, keyword_init: true)
|
|
33
33
|
|
|
34
|
+
# Result of inspect (spec 12.5). #state is one of "empty", "typing",
|
|
35
|
+
# "bad-char", "too-long", "invalid", "valid". Payload fields are present
|
|
36
|
+
# exactly when the state carries them: #typed and #progress for "typing",
|
|
37
|
+
# #reason for "invalid", #id and #canonical_code for "valid".
|
|
38
|
+
InspectResult = Struct.new(:state, :typed, :progress, :reason, :id, :canonical_code,
|
|
39
|
+
keyword_init: true)
|
|
40
|
+
|
|
34
41
|
attr_reader :profile
|
|
35
42
|
|
|
36
43
|
# @param profile [Hash] profile definition per spec 2.1 (symbol keys)
|
|
@@ -41,19 +48,85 @@ module Baseh
|
|
|
41
48
|
end
|
|
42
49
|
|
|
43
50
|
# Spec section 4. Capacity is an arbitrary-precision Integer.
|
|
51
|
+
# Spec 12.3: fixed mode only; expandable profiles have no single
|
|
52
|
+
# capacity (use the per-generation formulas of spec 19.1).
|
|
44
53
|
def capacity
|
|
54
|
+
if @profile.mode != "fixed"
|
|
55
|
+
raise BasehError.new(
|
|
56
|
+
"INVALID_PROFILE",
|
|
57
|
+
"capacity is only defined for fixed-mode profiles",
|
|
58
|
+
safe_for_customer: false
|
|
59
|
+
)
|
|
60
|
+
end
|
|
45
61
|
@profile.capacity
|
|
46
62
|
end
|
|
47
63
|
|
|
48
|
-
# Spec
|
|
64
|
+
# Spec 19.1/22.3. First id of generation length: the sum of each
|
|
65
|
+
# generation's capacity A^(k - effectiveK(k)) for k from minLength
|
|
66
|
+
# through length-1. The effective checksum length is per-generation
|
|
67
|
+
# (spec 22), so the sum is not a single geometric series when the short
|
|
68
|
+
# checksum is on.
|
|
69
|
+
def generation_base(length)
|
|
70
|
+
base = 0
|
|
71
|
+
@profile.min_length.upto(length - 1) do |l|
|
|
72
|
+
base += generation_capacity(l)
|
|
73
|
+
end
|
|
74
|
+
base
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Spec 19.1/22.3. Ids held by generation length: A^(length - effectiveK).
|
|
78
|
+
def generation_capacity(length)
|
|
79
|
+
@profile.body_alphabet.length**(length - @profile.effective_checksum_length(length))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Smallest generation whose range holds id, per spec 19.6. The loop is
|
|
83
|
+
# capped at the 32-symbol code limit (33 - minLength iterations) so an
|
|
84
|
+
# adversarial id fails fast with OUT_OF_RANGE instead of running big-integer
|
|
85
|
+
# multiplication on exponentially growing values.
|
|
86
|
+
def generation_for_id(id)
|
|
87
|
+
l = @profile.min_length
|
|
88
|
+
base = 0
|
|
89
|
+
cap = generation_capacity(l)
|
|
90
|
+
while id >= base + cap
|
|
91
|
+
if l >= 32
|
|
92
|
+
raise BasehError.new(
|
|
93
|
+
"OUT_OF_RANGE",
|
|
94
|
+
"ID requires a code longer than 32 symbols"
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
base += cap
|
|
98
|
+
l += 1
|
|
99
|
+
cap = generation_capacity(l)
|
|
100
|
+
end
|
|
101
|
+
l
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Spec 19.5. Balanced grouping: the split is a pure function of the total
|
|
105
|
+
# length — g = max(2, ceil(L / 5)) groups differing in size by at most
|
|
106
|
+
# one, larger groups to the left. There is no configurable pattern in
|
|
107
|
+
# expandable mode (grouping must be empty, section 2.2).
|
|
108
|
+
def self.expandable_grouping(length)
|
|
109
|
+
g = [2, (length + 4) / 5].max
|
|
110
|
+
base = length / g
|
|
111
|
+
return [length] if base < 1
|
|
112
|
+
|
|
113
|
+
rem = length % g
|
|
114
|
+
[base + 1] * rem + [base] * (g - rem)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Spec section 8 (fixed mode) / 19.6 (expandable mode), with the spec
|
|
118
|
+
# 18.2 blocklist scan over the raw code.
|
|
49
119
|
#
|
|
50
|
-
# @param id [Integer] 0 <= id < capacity
|
|
51
|
-
#
|
|
120
|
+
# @param id [Integer] 0 <= id < capacity (fixed); any non-negative id
|
|
121
|
+
# whose code fits in 32 symbols (expandable)
|
|
122
|
+
# @return [String] canonical code (grouped only when a separator applies)
|
|
52
123
|
# @raise [BasehError] OUT_OF_RANGE, PERMUTATION_FAILURE, BLOCKED_CODE
|
|
53
124
|
def encode(id:)
|
|
54
125
|
unless id.is_a?(Integer)
|
|
55
126
|
raise TypeError, "id must be an Integer"
|
|
56
127
|
end
|
|
128
|
+
return encode_expandable(id) if @profile.mode == "expandable"
|
|
129
|
+
|
|
57
130
|
if id.negative? || id >= @profile.capacity
|
|
58
131
|
raise BasehError.new("OUT_OF_RANGE", "ID #{id} is outside the profile capacity")
|
|
59
132
|
end
|
|
@@ -93,8 +166,22 @@ module Baseh
|
|
|
93
166
|
end
|
|
94
167
|
|
|
95
168
|
raw = normalize(input, accept_spaces)
|
|
96
|
-
|
|
97
|
-
|
|
169
|
+
# Spec 22: the generation is selected by the presented total length,
|
|
170
|
+
# so the effective checksum length is a deterministic function of it.
|
|
171
|
+
effective_k =
|
|
172
|
+
if @profile.mode == "expandable"
|
|
173
|
+
@profile.effective_checksum_length(raw.length)
|
|
174
|
+
else
|
|
175
|
+
@profile.checksum_length
|
|
176
|
+
end
|
|
177
|
+
body_length =
|
|
178
|
+
if @profile.mode == "expandable"
|
|
179
|
+
raw.length - effective_k
|
|
180
|
+
else
|
|
181
|
+
@profile.body_length
|
|
182
|
+
end
|
|
183
|
+
body = raw.slice(0, body_length)
|
|
184
|
+
supplied_checksum = raw.slice(body_length..) || ""
|
|
98
185
|
|
|
99
186
|
# normalize validates every symbol against the union of the body and
|
|
100
187
|
# checksum alphabets (spec 3.1 step 6). A checksum-only symbol in a
|
|
@@ -111,7 +198,7 @@ module Baseh
|
|
|
111
198
|
)
|
|
112
199
|
end
|
|
113
200
|
|
|
114
|
-
if Checksum.calculate_checksum(@profile, body, @body_index) != supplied_checksum
|
|
201
|
+
if Checksum.calculate_checksum(@profile, body, @body_index, effective_k) != supplied_checksum
|
|
115
202
|
unless try_correction && max_corrections != 0
|
|
116
203
|
raise BasehError.new(
|
|
117
204
|
"INVALID_CHECKSUM",
|
|
@@ -132,7 +219,7 @@ module Baseh
|
|
|
132
219
|
end
|
|
133
220
|
valid = {}
|
|
134
221
|
generate_candidates(body, filtered, max_corrections).each do |candidate|
|
|
135
|
-
if Checksum.calculate_checksum(@profile, candidate, @body_index) == supplied_checksum
|
|
222
|
+
if Checksum.calculate_checksum(@profile, candidate, @body_index, effective_k) == supplied_checksum
|
|
136
223
|
valid[candidate] = true
|
|
137
224
|
end
|
|
138
225
|
end
|
|
@@ -155,7 +242,22 @@ module Baseh
|
|
|
155
242
|
|
|
156
243
|
value = BaseN.decode_base_n(body, @profile.body_alphabet, @body_index)
|
|
157
244
|
perm = @profile.permutation
|
|
158
|
-
if
|
|
245
|
+
if @profile.mode == "expandable"
|
|
246
|
+
# Spec 19.7: the offset is de-permuted within the generation's own
|
|
247
|
+
# domain (length mixed into the key derivation), then the generation
|
|
248
|
+
# base is added back.
|
|
249
|
+
l = raw.length
|
|
250
|
+
if perm[:enabled]
|
|
251
|
+
value = Feistel.inverse_permute(
|
|
252
|
+
value, generation_capacity(l),
|
|
253
|
+
profile_id: @profile.profile_id,
|
|
254
|
+
key_bytes: perm[:key_bytes],
|
|
255
|
+
rounds: perm[:rounds],
|
|
256
|
+
length: l
|
|
257
|
+
)
|
|
258
|
+
end
|
|
259
|
+
value = generation_base(l) + value
|
|
260
|
+
elsif perm[:enabled]
|
|
159
261
|
value = Feistel.inverse_permute(
|
|
160
262
|
value, @profile.capacity,
|
|
161
263
|
profile_id: @profile.profile_id,
|
|
@@ -181,14 +283,80 @@ module Baseh
|
|
|
181
283
|
ValidateResult.new(valid: false, reason: e.code)
|
|
182
284
|
end
|
|
183
285
|
|
|
184
|
-
# Spec
|
|
185
|
-
# the
|
|
286
|
+
# Spec 12.5. Live as-you-type inspection. Gates on the typed length before
|
|
287
|
+
# validating, so the spec 3.4 re-pad can never paint an incomplete
|
|
288
|
+
# fixed-mode code "valid" (or "invalid"): a short fixed input is "typing",
|
|
289
|
+
# never checked. Never raises on user input and never reports "valid" for
|
|
290
|
+
# an incomplete code.
|
|
291
|
+
def inspect(input)
|
|
292
|
+
p = @profile
|
|
293
|
+
# Step 1: remove every occurrence of the separator string, then drop
|
|
294
|
+
# ASCII whitespace anywhere (a paste can carry either inside the code).
|
|
295
|
+
s = p.separator.empty? ? input.dup : input.gsub(p.separator, "")
|
|
296
|
+
s = s.delete("\t\n\v\f\r ")
|
|
297
|
+
typed = s.length
|
|
298
|
+
# Step 2.
|
|
299
|
+
return InspectResult.new(state: "empty") if typed.zero?
|
|
300
|
+
|
|
301
|
+
# Step 3. Fixed: complete exactly at bodyLength + checksumLength.
|
|
302
|
+
# Expandable: complete at every length from minLength through 32 (the
|
|
303
|
+
# length selects the generation), 32 is the over-length bound.
|
|
304
|
+
fixed = p.mode == "fixed"
|
|
305
|
+
expected = fixed ? p.body_length + p.checksum_length : 32
|
|
306
|
+
return InspectResult.new(state: "too-long") if typed > expected
|
|
307
|
+
|
|
308
|
+
# Step 4: spec 3.1 steps 4-6 without the length checks — case
|
|
309
|
+
# normalization, aliases, then union membership. A symbol outside both
|
|
310
|
+
# alphabets is "bad-char"; a symbol valid only in the other region (say
|
|
311
|
+
# a checksum-only symbol typed into the body) passes here and fails
|
|
312
|
+
# later under validate as INVALID_CHARACTER.
|
|
313
|
+
s = s.upcase unless p.case_sensitive
|
|
314
|
+
raw = s.each_char.map do |ch|
|
|
315
|
+
inspect_union?(ch) ? ch : p.aliases.fetch(ch, ch)
|
|
316
|
+
end.join
|
|
317
|
+
return InspectResult.new(state: "bad-char") if raw.each_char.any? { |ch| !inspect_union?(ch) }
|
|
318
|
+
|
|
319
|
+
# Step 5.
|
|
320
|
+
complete = fixed ? typed == expected : typed >= p.min_length
|
|
321
|
+
unless complete
|
|
322
|
+
return InspectResult.new(
|
|
323
|
+
state: "typing",
|
|
324
|
+
typed: format_partial(raw),
|
|
325
|
+
progress: typed.to_f / (fixed ? expected : p.min_length)
|
|
326
|
+
)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Step 6: judge the normalized string (no separator, no whitespace,
|
|
330
|
+
# case- and alias-normalized).
|
|
331
|
+
result = validate(raw)
|
|
332
|
+
return InspectResult.new(state: "invalid", reason: result.reason) unless result.valid
|
|
333
|
+
|
|
334
|
+
decoded = decode(raw)
|
|
335
|
+
InspectResult.new(state: "valid", id: decoded.id, canonical_code: decoded.canonical_code)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Spec 3.1 normalization, steps 1-9, with the spec 3.4 re-pad in fixed
|
|
339
|
+
# mode only. Returns the raw unformatted string.
|
|
186
340
|
def normalize(input, accept_spaces)
|
|
187
341
|
s = input.gsub(ASCII_WS, "")
|
|
188
|
-
|
|
342
|
+
had_separator = !@profile.separator.empty? && s.include?(@profile.separator)
|
|
343
|
+
# Literal-substring removal (gsub with a string pattern), matching the
|
|
344
|
+
# JS split-join reference: String#delete would treat the separator as a
|
|
345
|
+
# character class and corrupt multi-character separators like "..".
|
|
346
|
+
s = s.gsub(@profile.separator, "") unless @profile.separator.empty?
|
|
189
347
|
s = s.delete(" ") if accept_spaces
|
|
190
348
|
s = s.upcase unless @profile.case_sensitive
|
|
191
|
-
|
|
349
|
+
# Spec 3.2: an alias never maps two distinct canonical symbols into one
|
|
350
|
+
# value, so a symbol that is already canonical stays as-is and only
|
|
351
|
+
# non-canonical symbols are aliased. (In fixed tiers alias sources are
|
|
352
|
+
# never canonical, so this changes nothing there.)
|
|
353
|
+
s = s.each_char.map do |ch|
|
|
354
|
+
if @body_index.key?(ch) || @profile.checksum_alphabet.include?(ch)
|
|
355
|
+
ch
|
|
356
|
+
else
|
|
357
|
+
@profile.aliases.fetch(ch, ch)
|
|
358
|
+
end
|
|
359
|
+
end.join
|
|
192
360
|
|
|
193
361
|
s.each_char do |ch|
|
|
194
362
|
next if @body_index.key?(ch) || @profile.checksum_alphabet.include?(ch)
|
|
@@ -199,6 +367,32 @@ module Baseh
|
|
|
199
367
|
)
|
|
200
368
|
end
|
|
201
369
|
|
|
370
|
+
if @profile.mode == "expandable"
|
|
371
|
+
# Spec 19.2/19.7: no left-padding and no stripped-zero leniency. Input
|
|
372
|
+
# shorter than minLength or longer than 32 fails INVALID_LENGTH, and a
|
|
373
|
+
# separator below separatorMinLength is rejected (spec 19.5: the
|
|
374
|
+
# decoder expects no separators there).
|
|
375
|
+
if s.length < @profile.min_length
|
|
376
|
+
raise BasehError.new(
|
|
377
|
+
"INVALID_LENGTH",
|
|
378
|
+
"Expected at least #{@profile.min_length} symbols, got #{s.length}"
|
|
379
|
+
)
|
|
380
|
+
end
|
|
381
|
+
if s.length > 32
|
|
382
|
+
raise BasehError.new(
|
|
383
|
+
"INVALID_LENGTH",
|
|
384
|
+
"Expected at most 32 symbols, got #{s.length}"
|
|
385
|
+
)
|
|
386
|
+
end
|
|
387
|
+
if had_separator && s.length < @profile.separator_min_length
|
|
388
|
+
raise BasehError.new(
|
|
389
|
+
"INVALID_CHARACTER",
|
|
390
|
+
"Separators do not appear below #{@profile.separator_min_length} symbols"
|
|
391
|
+
)
|
|
392
|
+
end
|
|
393
|
+
return s
|
|
394
|
+
end
|
|
395
|
+
|
|
202
396
|
expected = @profile.body_length + @profile.checksum_length
|
|
203
397
|
# Spec 3.4: a code that lost leading zero body symbols is re-padded
|
|
204
398
|
# with the body zero symbol. The checksum symbols always remain, so
|
|
@@ -242,20 +436,32 @@ module Baseh
|
|
|
242
436
|
private
|
|
243
437
|
|
|
244
438
|
# Spec 18.2: case-insensitive substring scan over the raw unformatted
|
|
245
|
-
# code. BLOCKED_CODE is an issuance
|
|
439
|
+
# code, plus the spec 21.2 run scan. BLOCKED_CODE is an issuance
|
|
440
|
+
# decision, not an end-user condition.
|
|
246
441
|
def check_blocklist!(raw)
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
next unless upper.include?(word)
|
|
442
|
+
unless @profile.blocklist.empty?
|
|
443
|
+
upper = raw.upcase
|
|
444
|
+
@profile.blocklist.each do |word|
|
|
445
|
+
next unless upper.include?(word)
|
|
252
446
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
447
|
+
raise BasehError.new(
|
|
448
|
+
"BLOCKED_CODE",
|
|
449
|
+
"The generated reference contains a blocked substring",
|
|
450
|
+
safe_for_customer: false
|
|
451
|
+
)
|
|
452
|
+
end
|
|
258
453
|
end
|
|
454
|
+
# Spec 21.2: a run of the same symbol at or above maxRepetition blocks
|
|
455
|
+
# the code. Runs are measured on the raw string, so a separator never
|
|
456
|
+
# breaks a run.
|
|
457
|
+
max = @profile.max_repetition
|
|
458
|
+
return unless max.positive? && /(.)\1{#{max - 1},}/.match?(raw)
|
|
459
|
+
|
|
460
|
+
raise BasehError.new(
|
|
461
|
+
"BLOCKED_CODE",
|
|
462
|
+
"The generated reference repeats a symbol beyond the profile limit",
|
|
463
|
+
safe_for_customer: false
|
|
464
|
+
)
|
|
259
465
|
end
|
|
260
466
|
|
|
261
467
|
def confusion_map(name)
|
|
@@ -269,12 +475,83 @@ module Baseh
|
|
|
269
475
|
end
|
|
270
476
|
end
|
|
271
477
|
|
|
478
|
+
# Spec 19.6. The id selects its generation by magnitude; the offset
|
|
479
|
+
# within the generation is permuted in that generation's own domain.
|
|
480
|
+
def encode_expandable(id)
|
|
481
|
+
if id.negative?
|
|
482
|
+
raise BasehError.new("OUT_OF_RANGE", "ID #{id} is negative")
|
|
483
|
+
end
|
|
484
|
+
l = generation_for_id(id)
|
|
485
|
+
value = id - generation_base(l)
|
|
486
|
+
domain = generation_capacity(l)
|
|
487
|
+
perm = @profile.permutation
|
|
488
|
+
effective_k = @profile.effective_checksum_length(l)
|
|
489
|
+
if perm[:enabled]
|
|
490
|
+
value = Feistel.permute(
|
|
491
|
+
value, domain,
|
|
492
|
+
profile_id: @profile.profile_id,
|
|
493
|
+
key_bytes: perm[:key_bytes],
|
|
494
|
+
rounds: perm[:rounds],
|
|
495
|
+
length: l
|
|
496
|
+
)
|
|
497
|
+
end
|
|
498
|
+
body = BaseN.encode_base_n(value, @profile.body_alphabet, l - effective_k)
|
|
499
|
+
checksum = Checksum.calculate_checksum(@profile, body, @body_index, effective_k)
|
|
500
|
+
raw = body + checksum
|
|
501
|
+
check_blocklist!(raw)
|
|
502
|
+
format_raw(raw)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Spec 11/19.5. In expandable mode the separator applies only at or above
|
|
506
|
+
# separatorMinLength, with the balanced grouping derived from the total
|
|
507
|
+
# length.
|
|
272
508
|
def format_raw(raw)
|
|
273
509
|
return raw if @profile.separator.empty?
|
|
274
510
|
|
|
511
|
+
grouping =
|
|
512
|
+
if @profile.mode == "expandable"
|
|
513
|
+
return raw if raw.length < @profile.separator_min_length
|
|
514
|
+
|
|
515
|
+
self.class.expandable_grouping(raw.length)
|
|
516
|
+
else
|
|
517
|
+
@profile.grouping
|
|
518
|
+
end
|
|
275
519
|
parts = []
|
|
276
520
|
offset = 0
|
|
277
|
-
|
|
521
|
+
grouping.each do |size|
|
|
522
|
+
parts << raw.slice(offset, size)
|
|
523
|
+
offset += size
|
|
524
|
+
end
|
|
525
|
+
parts.join(@profile.separator)
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Spec 12.5 step 4: membership in the union of the body and checksum
|
|
529
|
+
# alphabets.
|
|
530
|
+
def inspect_union?(ch)
|
|
531
|
+
@body_index.key?(ch) || @profile.checksum_alphabet.include?(ch)
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# Spec 12.5: separators inserted into a partially typed code, as far as
|
|
535
|
+
# the groups go. Fixed mode walks the configured grouping, emitting no
|
|
536
|
+
# separator for a group the symbols do not reach; expandable mode uses
|
|
537
|
+
# the balanced grouping rule of spec 19.5 for the typed length, bare
|
|
538
|
+
# below separatorMinLength.
|
|
539
|
+
def format_partial(raw)
|
|
540
|
+
return raw if @profile.separator.empty?
|
|
541
|
+
|
|
542
|
+
grouping =
|
|
543
|
+
if @profile.mode == "expandable"
|
|
544
|
+
return raw if raw.length < @profile.separator_min_length
|
|
545
|
+
|
|
546
|
+
self.class.expandable_grouping(raw.length)
|
|
547
|
+
else
|
|
548
|
+
@profile.grouping
|
|
549
|
+
end
|
|
550
|
+
parts = []
|
|
551
|
+
offset = 0
|
|
552
|
+
grouping.each do |size|
|
|
553
|
+
break if offset >= raw.length
|
|
554
|
+
|
|
278
555
|
parts << raw.slice(offset, size)
|
|
279
556
|
offset += size
|
|
280
557
|
end
|
|
@@ -284,7 +561,7 @@ module Baseh
|
|
|
284
561
|
def canonical_raw(canonical_code)
|
|
285
562
|
return canonical_code if @profile.separator.empty?
|
|
286
563
|
|
|
287
|
-
canonical_code.
|
|
564
|
+
canonical_code.gsub(@profile.separator, "")
|
|
288
565
|
end
|
|
289
566
|
end
|
|
290
567
|
end
|
data/lib/baseh/basen.rb
CHANGED
|
@@ -8,6 +8,13 @@ module Baseh
|
|
|
8
8
|
# Spec 5.1. All arithmetic stays in Integer (arbitrary precision).
|
|
9
9
|
def encode_base_n(value, alphabet, length)
|
|
10
10
|
base = alphabet.length
|
|
11
|
+
capacity = base**length
|
|
12
|
+
if value.negative? || value >= capacity
|
|
13
|
+
raise BasehError.new(
|
|
14
|
+
"OUT_OF_RANGE",
|
|
15
|
+
"Value #{value} does not fit in #{length} base-#{base} symbols"
|
|
16
|
+
)
|
|
17
|
+
end
|
|
11
18
|
out = Array.new(length)
|
|
12
19
|
v = value
|
|
13
20
|
(length - 1).downto(0) do |pos|
|