baseh 1.1.0 → 2.0.2

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.
@@ -10,9 +10,16 @@ module Baseh
10
10
  module_function
11
11
 
12
12
  # Returns the checksum value in [0, modulus).
13
- def checksum_value(prepared, body, body_index = nil)
13
+ # Spec 22: expandable generations may pass a shorter effective checksum
14
+ # length; the modulus is then S^length instead of the profile default.
15
+ def checksum_value(prepared, body, body_index = nil, length = prepared.checksum_length)
14
16
  body_index ||= BaseN.alphabet_index(prepared.body_alphabet)
15
- modulus = prepared.checksum_modulus
17
+ modulus =
18
+ if length == prepared.checksum_length
19
+ prepared.checksum_modulus
20
+ else
21
+ [prepared.checksum_alphabet.length, 1].max**length
22
+ end
16
23
 
17
24
  state = INITIAL_STATE
18
25
  prepared.profile_id.each_byte do |byte|
@@ -33,12 +40,12 @@ module Baseh
33
40
  end
34
41
 
35
42
  # Expected checksum string for a normalized body.
36
- def calculate_checksum(prepared, body, body_index = nil)
37
- return "" if prepared.checksum_length.zero?
43
+ def calculate_checksum(prepared, body, body_index = nil, length = prepared.checksum_length)
44
+ return "" if length.zero?
38
45
 
39
46
  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)
47
+ value = checksum_value(prepared, body, body_index, length)
48
+ BaseN.encode_base_n(value, prepared.checksum_alphabet, length)
42
49
  end
43
50
  end
44
51
  end
data/lib/baseh/feistel.rb CHANGED
@@ -37,13 +37,17 @@ module Baseh
37
37
  bytes.pack("C*")
38
38
  end
39
39
 
40
- # Normative round message, spec 7.3 step 4.
41
- def round_message(profile_id, round, right, wr)
40
+ # Normative round message, spec 7.3 step 4. In expandable mode the
41
+ # generation's total code length L is mixed in as ASCII decimal plus a
42
+ # 0x00 terminator after the profileId field (spec 7.3/19.4); fixed-mode
43
+ # messages stay byte-for-byte unchanged.
44
+ def round_message(profile_id, round, right, wr, length = nil)
42
45
  "".b
43
46
  .concat(TAG)
44
47
  .concat(0.chr(Encoding::BINARY))
45
48
  .concat(profile_id)
46
49
  .concat(0.chr(Encoding::BINARY))
50
+ .concat(length.nil? ? "".b : "#{length}\x00".b)
47
51
  .concat(round.chr(Encoding::BINARY))
48
52
  .concat(to_be(right, (wr + 7) / 8))
49
53
  end
@@ -52,12 +56,12 @@ module Baseh
52
56
  OpenSSL::HMAC.digest(OpenSSL::Digest.new("sha256"), key_bytes, message)
53
57
  end
54
58
 
55
- def run_rounds(left, right, profile_id, key_bytes, rounds, w0, w1)
59
+ def run_rounds(left, right, profile_id, key_bytes, rounds, w0, w1, length)
56
60
  rounds.times do |i|
57
61
  even = i.even?
58
62
  wr = even ? w1 : w0
59
63
  wl = even ? w0 : w1
60
- f = low_bits(hmac(key_bytes, round_message(profile_id, i, right, wr)), wl)
64
+ f = low_bits(hmac(key_bytes, round_message(profile_id, i, right, wr, length)), wl)
61
65
  new_left = right
62
66
  new_right = left ^ f
63
67
  left = new_left
@@ -66,12 +70,12 @@ module Baseh
66
70
  [left, right]
67
71
  end
68
72
 
69
- def run_inverse(left, right, profile_id, key_bytes, rounds, w0, w1)
73
+ def run_inverse(left, right, profile_id, key_bytes, rounds, w0, w1, length)
70
74
  (rounds - 1).downto(0) do |i|
71
75
  even = i.even?
72
76
  wr = even ? w1 : w0
73
77
  wl = even ? w0 : w1
74
- f = low_bits(hmac(key_bytes, round_message(profile_id, i, left, wr)), wl)
78
+ f = low_bits(hmac(key_bytes, round_message(profile_id, i, left, wr, length)), wl)
75
79
  prev_right = left
76
80
  prev_left = right ^ f
77
81
  left = prev_left
@@ -80,21 +84,23 @@ module Baseh
80
84
  [left, right]
81
85
  end
82
86
 
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
+ # Forward permutation with cycle walking. length is expandable mode only
88
+ # (spec 7.3/19.4): the generation's total code length mixed into the round
89
+ # message; omit it in fixed mode.
90
+ def permute(value, capacity, profile_id:, key_bytes:, rounds:, length: nil)
91
+ walk(value, capacity, profile_id, key_bytes, rounds, length) do |left, right, w0, w1|
92
+ run_rounds(left, right, profile_id, key_bytes, rounds, w0, w1, length)
87
93
  end
88
94
  end
89
95
 
90
96
  # 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)
97
+ def inverse_permute(value, capacity, profile_id:, key_bytes:, rounds:, length: nil)
98
+ walk(value, capacity, profile_id, key_bytes, rounds, length) do |left, right, w0, w1|
99
+ run_inverse(left, right, profile_id, key_bytes, rounds, w0, w1, length)
94
100
  end
95
101
  end
96
102
 
97
- def walk(value, capacity, profile_id, key_bytes, rounds)
103
+ def walk(value, capacity, profile_id, key_bytes, rounds, length)
98
104
  bits = bit_length(capacity)
99
105
  w1 = bits / 2
100
106
  w0 = bits - w1
@@ -114,5 +120,12 @@ module Baseh
114
120
  safe_for_customer: false
115
121
  )
116
122
  end
123
+
124
+ # Only permute and inverse_permute are public; the round machinery is
125
+ # internal to the spec 7.3 permutation.
126
+ class << self
127
+ private :bit_length, :low_bits, :to_be, :round_message, :hmac,
128
+ :run_rounds, :run_inverse, :walk
129
+ end
117
130
  end
118
131
  end
data/lib/baseh/profile.rb CHANGED
@@ -11,27 +11,111 @@ module Baseh
11
11
  # key_bytes:, rounds: } or { enabled: false },
12
12
  # profanity: { mode: "none" | "no-vowels" | "blocklist",
13
13
  # words: [...], extra_words: [...] } (optional, spec 18)
14
+ # max_repetition: 0 (off) or an integer >= 3 (optional, spec 21)
15
+ # short_checksum_length:, short_checksum_until: (expandable only,
16
+ # spec 22; 0 or absent disables)
14
17
  module Profile
15
18
  ASCII_ONLY = /\A[\x20-\x7e]*\z/.freeze
16
19
 
17
20
  # Immutable, fully validated profile with pre-computed derived values.
18
21
  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
22
+ attr_reader :profile_id, :mode, :body_alphabet, :body_length,
23
+ :min_length, :checksum_alphabet, :checksum_length,
24
+ :short_checksum_length, :short_checksum_until,
25
+ :case_sensitive, :separator, :separator_min_length,
26
+ :grouping, :aliases, :permutation,
27
+ :capacity, :checksum_modulus, :profanity_mode, :blocklist,
28
+ :max_repetition
23
29
 
24
30
  def initialize(profile)
25
31
  validate_type!(profile)
26
32
  @profile_id = validate_profile_id!(profile[:profile_id])
27
33
  @case_sensitive = profile[:case_sensitive] == true
28
34
 
35
+ # Spec 2.2/19.9. A persisted or frozen profile declares its mode;
36
+ # profiles built before the mode field existed are fixed, so the
37
+ # frozen vectors keep matching byte for byte.
38
+ @mode = (profile[:mode] || "fixed").to_s
39
+ unless %w[fixed expandable].include?(@mode)
40
+ self.class.fail_profile!("mode must be fixed or expandable")
41
+ end
42
+
29
43
  @body_alphabet = validate_body_alphabet!(profile[:body_alphabet])
30
- @body_length = validate_integer!(profile[:body_length], 1, 32, "bodyLength")
44
+ # Spec 19.2: in expandable mode the zero ban strips 0 and O from the
45
+ # body alphabet silently, before any other validation, exactly like
46
+ # the no-vowels strip of section 18.1.
47
+ if @mode == "expandable"
48
+ @body_alphabet = @body_alphabet.delete("0O").freeze
49
+ end
50
+ @body_length =
51
+ if @mode == "fixed"
52
+ validate_integer!(profile[:body_length], 1, 32, "bodyLength")
53
+ end
31
54
  @checksum_length = validate_integer!(profile[:checksum_length], 0, 8, "checksumLength")
32
- @checksum_alphabet = validate_checksum_alphabet!(
33
- profile[:checksum_alphabet], @checksum_length
34
- )
55
+ @min_length = (profile[:min_length] || 4)
56
+ @separator_min_length = (profile[:separator_min_length] || 0)
57
+ if @mode == "fixed"
58
+ unless @separator_min_length.is_a?(Integer) && @separator_min_length.zero?
59
+ self.class.fail_profile!("separatorMinLength must be 0 in fixed mode")
60
+ end
61
+ else
62
+ unless @min_length.is_a?(Integer) && @min_length >= 1
63
+ self.class.fail_profile!("minLength must be an integer of at least 1")
64
+ end
65
+ unless @min_length > @checksum_length
66
+ self.class.fail_profile!("minLength must be greater than checksumLength")
67
+ end
68
+ unless @separator_min_length.is_a?(Integer) && @separator_min_length >= 0
69
+ self.class.fail_profile!("separatorMinLength must be an integer of at least 0")
70
+ end
71
+ end
72
+ # Spec 22. The short checksum is expandable-only; 0 or absent turns
73
+ # it off.
74
+ @short_checksum_length = profile[:short_checksum_length] || 0
75
+ @short_checksum_until = profile[:short_checksum_until] || 0
76
+ # Spec 22. The window field is the switch: shortChecksumUntil of 0
77
+ # turns the feature off (the codebase convention, like
78
+ # maxRepetition), and the length field without a window is
79
+ # INVALID_PROFILE. With a window set, a shortChecksumLength of 0 is
80
+ # legal: the window's generations carry no checksum symbols at all.
81
+ if @mode == "fixed"
82
+ unless @short_checksum_length == 0 && @short_checksum_until == 0
83
+ self.class.fail_profile!(
84
+ "shortChecksumLength and shortChecksumUntil are expandable-mode only"
85
+ )
86
+ end
87
+ elsif @short_checksum_until != 0
88
+ unless @short_checksum_until.is_a?(Integer) && @short_checksum_until >= @min_length
89
+ self.class.fail_profile!("shortChecksumUntil must be an integer of at least minLength")
90
+ end
91
+ # Beyond 8 the window would swallow nearly every practical code,
92
+ # and long codes genuinely want two checksum symbols.
93
+ if @short_checksum_until > 8
94
+ self.class.fail_profile!("shortChecksumUntil must be at most 8")
95
+ end
96
+ unless @short_checksum_length.is_a?(Integer) && @short_checksum_length >= 0 &&
97
+ @short_checksum_length < @checksum_length
98
+ self.class.fail_profile!(
99
+ "shortChecksumLength must be an integer from 0 through checksumLength - 1"
100
+ )
101
+ end
102
+ unless @min_length > @short_checksum_length
103
+ self.class.fail_profile!("minLength must be greater than shortChecksumLength")
104
+ end
105
+ elsif @short_checksum_length != 0
106
+ self.class.fail_profile!("shortChecksumLength requires shortChecksumUntil")
107
+ end
108
+ # Spec 19.3: in expandable mode the checksum alphabet is derived from
109
+ # the body alphabet after every body strip; the configured
110
+ # checksumAlphabet is not consulted.
111
+ @checksum_alphabet =
112
+ if @mode == "expandable"
113
+ "".freeze
114
+ else
115
+ validate_checksum_alphabet!(
116
+ profile[:checksum_alphabet], @checksum_length
117
+ )
118
+ end
35
119
 
36
120
  # Spec 18: validation happens before the vowel strip so malformed
37
121
  # alphabets are reported as such, then no-vowels strips and
@@ -41,10 +125,20 @@ module Baseh
41
125
  @body_alphabet = Profanity.strip_vowels(@body_alphabet)
42
126
  @checksum_alphabet = Profanity.strip_vowels(@checksum_alphabet)
43
127
  validate_stripped!(@body_alphabet, "body")
44
- validate_stripped!(@checksum_alphabet, "checksum") if @checksum_length.positive?
128
+ if @mode == "fixed" && @checksum_length.positive?
129
+ validate_stripped!(@checksum_alphabet, "checksum")
130
+ end
45
131
  @body_alphabet.freeze
46
132
  @checksum_alphabet.freeze
47
133
  end
134
+ if @mode == "expandable"
135
+ # Spec 19.3: the checksum alphabet is derived, "0" followed by the
136
+ # body alphabet in order, after every body strip (zero ban,
137
+ # no-vowels) so all downstream rules see the final alphabets. The
138
+ # configured checksumAlphabet is not consulted.
139
+ @checksum_alphabet = ("0" + @body_alphabet).freeze
140
+ validate_stripped!(@body_alphabet, "body")
141
+ end
48
142
  @blocklist =
49
143
  if @profanity_mode == "blocklist"
50
144
  Profanity.effective_blocklist(profile[:profanity]).freeze
@@ -52,6 +146,14 @@ module Baseh
52
146
  [].freeze
53
147
  end
54
148
 
149
+ # Spec 21: 0 disables the filter; an active filter needs a floor of
150
+ # 3 — banning pairs (2) would destroy roughly 9% of every generation.
151
+ @max_repetition = profile[:max_repetition] || 0
152
+ unless @max_repetition.is_a?(Integer) && @max_repetition >= 0 &&
153
+ (@max_repetition.zero? || @max_repetition >= 3)
154
+ self.class.fail_profile!("maxRepetition must be 0 (off) or an integer of at least 3")
155
+ end
156
+
55
157
  @separator = validate_separator!(
56
158
  profile[:separator].to_s, @body_alphabet, @checksum_alphabet
57
159
  )
@@ -63,11 +165,28 @@ module Baseh
63
165
  )
64
166
  @permutation = validate_permutation!(profile[:permutation] || { enabled: false })
65
167
 
66
- @capacity = @body_alphabet.length**@body_length
168
+ # Capacity is the fixed-mode A^bodyLength; meaningless in expandable
169
+ # mode, where Baseh#capacity refuses per spec 12.3.
170
+ @capacity = @body_alphabet.length**(@body_length || 0)
67
171
  @checksum_modulus = [@checksum_alphabet.length, 1].max**@checksum_length
68
172
  freeze
69
173
  end
70
174
 
175
+ # Spec 22. The checksum length that applies to a generation of the
176
+ # given total length: short_checksum_length at or below
177
+ # short_checksum_until, checksum_length above it (and always in fixed
178
+ # mode). The feature is on exactly when short_checksum_until is
179
+ # non-zero; a short_checksum_length of 0 then means the window's
180
+ # generations carry no checksum symbols at all.
181
+ def effective_checksum_length(length)
182
+ if @mode == "expandable" && @short_checksum_until.positive? &&
183
+ length <= @short_checksum_until
184
+ @short_checksum_length
185
+ else
186
+ @checksum_length
187
+ end
188
+ end
189
+
71
190
  private
72
191
 
73
192
  def self.fail_profile!(reason)
@@ -189,7 +308,13 @@ module Baseh
189
308
  end
190
309
  s_norm = case_sensitive ? src : src.upcase
191
310
  t_norm = case_sensitive ? tgt : tgt.upcase
192
- if canonical.include?(s_norm)
311
+ # Spec 3.2: an alias must never map two distinct canonical symbols
312
+ # into one value. Fixed mode rejects a canonical alias source
313
+ # outright. In expandable mode the frozen tier (spec 17.1) carries
314
+ # aliases whose sources are canonical body symbols (T, N, W stay in
315
+ # the body alphabet); the canonical symbol wins at normalization,
316
+ # making those entries inert instead of destructive.
317
+ if @mode == "fixed" && canonical.include?(s_norm)
193
318
  self.class.fail_profile!("alias source #{src.inspect} is already a canonical symbol")
194
319
  end
195
320
  unless canonical.include?(t_norm)
@@ -222,7 +347,13 @@ module Baseh
222
347
  unless grouping.all? { |g| g.is_a?(Integer) && g >= 1 }
223
348
  self.class.fail_profile!("group sizes must be positive integers")
224
349
  end
225
- unless grouping.sum == body_length + checksum_length
350
+ if @mode == "expandable"
351
+ # Spec 2.2/19.5: the split is derived from the total length; a
352
+ # configurable pattern no longer exists in expandable mode.
353
+ unless grouping.empty?
354
+ self.class.fail_profile!("grouping must be empty in expandable mode")
355
+ end
356
+ elsif grouping.sum != body_length + checksum_length
226
357
  self.class.fail_profile!("group sizes must sum to bodyLength + checksumLength")
227
358
  end
228
359
  end
@@ -6,18 +6,27 @@ module Baseh
6
6
  # visual ones exactly as the web tools derive them, so the tool capacities
7
7
  # match.
8
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
9
+ # Minimum 36 symbols, no checksum, XXX-XXX 2,176,782,336 ids
10
+ # Light 31 symbols, 2 checksums, XXXX-XXXX 887,503,681 ids
11
+ # Medium 28 symbols, 2 checksums, XXXX-XXXX 481,890,304 ids (default)
12
+ # Heavy 26 symbols, 2 checksums, XXXX-XXXX 308,915,776 ids
13
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.
14
+ # All four keep the typed O/I/L aliases where possible, use a hyphen
15
+ # delimiter at the midpoint and run the default profanity blocklist. Every
16
+ # tier permutes with the frozen published key (FROZEN_KEY_BYTES below): the
17
+ # key is public, so the permutation obscures sequence but is not secrecy.
18
+ # The -p variants are identical but permute with caller-supplied key
19
+ # material instead.
18
20
  module Profiles
19
21
  OIL_ALIASES = { "O" => "0", "I" => "1", "L" => "1" }.freeze
20
22
 
23
+ # The frozen published permutation key. Public by design: it makes issued
24
+ # codes look non-sequential but offers no secrecy, since anyone can read
25
+ # it here. Never swap it on a live namespace; codes only decode with the
26
+ # key they were issued under. Use the -p variants to supply private key
27
+ # material.
28
+ FROZEN_KEY_BYTES = "baseh-frozen-key-v1".b.freeze
29
+
21
30
  # Tier shapes shared by the plain and (-p) keyed helpers. The values are
22
31
  # thawed on every build, so each helper returns a fresh mutable profile.
23
32
  TIERS = {
@@ -34,18 +43,18 @@ module Baseh
34
43
  profile_id: "baseh-light",
35
44
  body_alphabet: "0123456789ABCEFGHJKMNPQRSUVWXYZ",
36
45
  checksum_alphabet: "234679ACEFGHJKMNPQRUVWXY",
37
- checksum_length: 1,
38
- separator: "",
39
- grouping: [],
46
+ checksum_length: 2,
47
+ separator: "-",
48
+ grouping: [4, 4],
40
49
  aliases: { **OIL_ALIASES, "D" => "B", "T" => "P" }
41
50
  },
42
51
  medium: {
43
52
  profile_id: "baseh-medium",
44
53
  body_alphabet: "0123456789ACDEFGHJKMPQRUVXYZ",
45
54
  checksum_alphabet: "234679ACDEFGHJKMPQRUVXY",
46
- checksum_length: 1,
47
- separator: "",
48
- grouping: [],
55
+ checksum_length: 2,
56
+ separator: "-",
57
+ grouping: [4, 4],
49
58
  # B and S are dropped for looking like 8 and 5; since they can never
50
59
  # be issued, a typed B is always an 8 and a typed S always a 5.
51
60
  aliases: { **OIL_ALIASES, "B" => "8", "S" => "5", "T" => "P",
@@ -55,9 +64,9 @@ module Baseh
55
64
  profile_id: "baseh-heavy",
56
65
  body_alphabet: "0123456789ABCEFHJKMPQRVXYZ",
57
66
  checksum_alphabet: "234679ACEFHJKMPQRUVXY",
58
- checksum_length: 1,
59
- separator: "",
60
- grouping: [],
67
+ checksum_length: 2,
68
+ separator: "-",
69
+ grouping: [4, 4],
61
70
  aliases: { **OIL_ALIASES, "D" => "B", "T" => "P", "N" => "M",
62
71
  "W" => "V", "S" => "F", "G" => "C" }
63
72
  }
@@ -65,46 +74,80 @@ module Baseh
65
74
 
66
75
  module_function
67
76
 
77
+ # Permutation every plain tier applies, built from the frozen published
78
+ # key.
79
+ def frozen_permutation
80
+ keyed_permutation(FROZEN_KEY_BYTES, "frozen", 8)
81
+ end
82
+
68
83
  # Alphanumeric, no safety strips, no checksum, hyphen-delimited XXX-XXX.
69
84
  def baseh_minimum_v1
70
- tier(:minimum, { enabled: false }, false)
85
+ tier(:minimum, frozen_permutation, false)
71
86
  end
72
87
 
73
- # baseh-minimum with feistel-v1 permutation. key_bytes is required.
88
+ # baseh-minimum permuted with caller-supplied key material.
74
89
  def baseh_minimum_p_v1(key_bytes:, key_id: "default", rounds: 8)
75
90
  tier(:minimum, keyed_permutation(key_bytes, key_id, rounds), true)
76
91
  end
77
92
 
78
- # Visual light plus spoken light, one checksum symbol.
93
+ # Visual light plus spoken light, two checksum symbols, hyphen-delimited.
79
94
  def baseh_light_v1
80
- tier(:light, { enabled: false }, false)
95
+ tier(:light, frozen_permutation, false)
81
96
  end
82
97
 
83
- # baseh-light with feistel-v1 permutation. key_bytes is required.
98
+ # baseh-light permuted with caller-supplied key material.
84
99
  def baseh_light_p_v1(key_bytes:, key_id: "default", rounds: 8)
85
100
  tier(:light, keyed_permutation(key_bytes, key_id, rounds), true)
86
101
  end
87
102
 
88
- # Visual medium plus spoken medium, one checksum symbol. The default.
103
+ # Visual medium plus spoken medium, two checksum symbols, hyphen-delimited. The default.
89
104
  def baseh_medium_v1
90
- tier(:medium, { enabled: false }, false)
105
+ tier(:medium, frozen_permutation, false)
91
106
  end
92
107
 
93
- # baseh-medium with feistel-v1 permutation. key_bytes is required.
108
+ # baseh-medium permuted with caller-supplied key material.
94
109
  def baseh_medium_p_v1(key_bytes:, key_id: "default", rounds: 8)
95
110
  tier(:medium, keyed_permutation(key_bytes, key_id, rounds), true)
96
111
  end
97
112
 
98
- # Conservative alphabet plus spoken heavy, one checksum symbol.
113
+ # Conservative alphabet plus spoken heavy, two checksum symbols, hyphen-delimited.
99
114
  def baseh_heavy_v1
100
- tier(:heavy, { enabled: false }, false)
115
+ tier(:heavy, frozen_permutation, false)
101
116
  end
102
117
 
103
- # baseh-heavy with feistel-v1 permutation. key_bytes is required.
118
+ # baseh-heavy permuted with caller-supplied key material.
104
119
  def baseh_heavy_p_v1(key_bytes:, key_id: "default", rounds: 8)
105
120
  tier(:heavy, keyed_permutation(key_bytes, key_id, rounds), true)
106
121
  end
107
122
 
123
+ # Spec 17.1. The expandable recommended default carries the same safety
124
+ # posture as baseh-medium-v1: the medium visual strips (O, I, L, B, S) and
125
+ # the medium spoken strips (T, N, W), so an issued code never emits a
126
+ # visual or spoken confusable. The zero ban of section 19.2 then removes 0
127
+ # (and O, already gone), leaving a 27-symbol body. The checksum alphabet
128
+ # derives as "0" plus the body (28 symbols).
129
+ EXPANDABLE_BODY = "123456789ACDEFGHJKMPQRUVXYZ"
130
+
131
+ # The frozen expandable tier; the recommended starting point for new
132
+ # namespaces. Four characters while the namespace is small, gaining one
133
+ # symbol automatically as issuance climbs past each generation's
134
+ # capacity. The body alphabet is the medium safety set (the full
135
+ # alphanumeric set minus the visual look-alikes O, I, L, B, S and the
136
+ # spoken-confusable T, N, W), with the zero ban of spec 19.2 removing 0
137
+ # (27 symbols); the checksum alphabet derives as "0" plus the body (28
138
+ # symbols). The short checksum of spec 22 is on: one checksum symbol
139
+ # through five characters (27^3 = 19,683 ids at length 4, 27^4 = 531,441
140
+ # at length 5), two from six characters up. The hyphen appears from six
141
+ # characters up, split by the balanced grouping rule of spec 19.5.
142
+ def baseh_expandable_v1
143
+ expandable_tier(frozen_permutation, false)
144
+ end
145
+
146
+ # baseh-expandable permuted with caller-supplied key material.
147
+ def baseh_expandable_p_v1(key_bytes:, key_id: "default", rounds: 8)
148
+ expandable_tier(keyed_permutation(key_bytes, key_id, rounds), true)
149
+ end
150
+
108
151
  # feistel-v1 permutation block for the keyed (p) helpers.
109
152
  def keyed_permutation(key_bytes, key_id, rounds)
110
153
  {
@@ -131,9 +174,33 @@ module Baseh
131
174
  grouping: shape[:grouping].dup,
132
175
  aliases: shape[:aliases].dup,
133
176
  permutation: permutation,
134
- profanity: { mode: "blocklist" }
177
+ profanity: { mode: "blocklist" },
178
+ max_repetition: 4
179
+ }
180
+ end
181
+ # Builds a fresh mutable profile for the expandable tier, plain or (-p)
182
+ # keyed. Every call returns new strings, arrays and hashes.
183
+ def expandable_tier(permutation, p_suffix)
184
+ {
185
+ profile_id: "baseh-expandable" + (p_suffix ? "-p" : "") + "-v1",
186
+ mode: "expandable",
187
+ body_alphabet: EXPANDABLE_BODY.dup,
188
+ min_length: 4,
189
+ checksum_alphabet: ("0" + EXPANDABLE_BODY).dup,
190
+ checksum_length: 2,
191
+ short_checksum_length: 1,
192
+ short_checksum_until: 5,
193
+ case_sensitive: false,
194
+ separator: "-",
195
+ separator_min_length: 6,
196
+ grouping: [],
197
+ aliases: { **OIL_ALIASES, "B" => "8", "S" => "5", "T" => "P",
198
+ "N" => "M", "W" => "V" },
199
+ permutation: permutation,
200
+ profanity: { mode: "blocklist" },
201
+ max_repetition: 4
135
202
  }
136
203
  end
137
- private_class_method :keyed_permutation, :tier
204
+ private_class_method :frozen_permutation, :keyed_permutation, :tier, :expandable_tier
138
205
  end
139
206
  end
data/lib/baseh/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Baseh
4
- VERSION = "1.1.0"
4
+ VERSION = "2.0.2"
5
5
  end