familia 2.0.0.pre8 → 2.0.0.pre12
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/.github/workflows/ci.yml +13 -0
- data/.github/workflows/docs.yml +1 -1
- data/.gitignore +9 -9
- data/.rubocop.yml +19 -0
- data/.yardopts +22 -1
- data/CHANGELOG.md +247 -0
- data/CLAUDE.md +12 -59
- data/Gemfile.lock +1 -1
- data/README.md +62 -2
- data/changelog.d/README.md +77 -0
- data/docs/archive/.gitignore +2 -0
- data/docs/archive/FAMILIA_RELATIONSHIPS.md +210 -0
- data/docs/archive/FAMILIA_TECHNICAL.md +823 -0
- data/docs/archive/FAMILIA_UPDATE.md +226 -0
- data/docs/archive/README.md +63 -0
- data/docs/guides/.gitignore +2 -0
- data/docs/{wiki → guides}/Home.md +1 -1
- data/docs/{wiki → guides}/Implementation-Guide.md +1 -1
- data/docs/{wiki → guides}/Relationships-Guide.md +103 -50
- data/docs/guides/relationships-methods.md +266 -0
- data/docs/migrating/.gitignore +2 -0
- data/docs/migrating/v2.0.0-pre.md +84 -0
- data/docs/migrating/v2.0.0-pre11.md +255 -0
- data/docs/migrating/v2.0.0-pre12.md +306 -0
- data/docs/migrating/v2.0.0-pre5.md +110 -0
- data/docs/migrating/v2.0.0-pre6.md +154 -0
- data/docs/migrating/v2.0.0-pre7.md +222 -0
- data/docs/overview.md +6 -7
- data/{examples/redis_command_validation_example.rb → docs/reference/auditing_database_commands.rb} +29 -32
- data/examples/{bit_encoding_integration.rb → permissions.rb} +30 -27
- data/examples/relationships.rb +205 -0
- data/examples/safe_dump.rb +281 -0
- data/familia.gemspec +4 -4
- data/lib/familia/base.rb +52 -0
- data/lib/familia/connection.rb +4 -21
- data/lib/familia/{encryption_request_cache.rb → encryption/request_cache.rb} +1 -1
- data/lib/familia/errors.rb +2 -0
- data/lib/familia/features/autoloader.rb +57 -0
- data/lib/familia/features/external_identifier.rb +310 -0
- data/lib/familia/features/object_identifier.rb +307 -0
- data/lib/familia/features/relationships/indexing.rb +160 -175
- data/lib/familia/features/relationships/membership.rb +16 -21
- data/lib/familia/features/relationships/tracking.rb +61 -21
- data/lib/familia/features/relationships.rb +15 -8
- data/lib/familia/features/safe_dump.rb +66 -72
- data/lib/familia/features.rb +93 -5
- data/lib/familia/horreum/subclass/definition.rb +49 -3
- data/lib/familia/horreum.rb +15 -24
- data/lib/familia/secure_identifier.rb +51 -75
- data/lib/familia/verifiable_identifier.rb +162 -0
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +1 -0
- data/setup.cfg +5 -0
- data/try/core/secure_identifier_try.rb +47 -18
- data/try/core/verifiable_identifier_try.rb +171 -0
- data/try/features/{external_identifiers/external_identifiers_try.rb → external_identifier/external_identifier_try.rb} +25 -28
- data/try/features/feature_improvements_try.rb +126 -0
- data/try/features/{object_identifiers/object_identifiers_integration_try.rb → object_identifier/object_identifier_integration_try.rb} +28 -30
- data/try/features/{object_identifiers/object_identifiers_try.rb → object_identifier/object_identifier_try.rb} +13 -13
- data/try/features/real_feature_integration_try.rb +7 -6
- data/try/features/relationships/relationships_api_changes_try.rb +339 -0
- data/try/features/relationships/relationships_try.rb +6 -5
- data/try/features/safe_dump/safe_dump_try.rb +8 -9
- data/try/helpers/test_helpers.rb +17 -17
- metadata +62 -41
- data/examples/relationships_basic.rb +0 -273
- data/lib/familia/features/external_identifiers/external_identifier_field_type.rb +0 -120
- data/lib/familia/features/external_identifiers.rb +0 -111
- data/lib/familia/features/object_identifiers/object_identifier_field_type.rb +0 -91
- data/lib/familia/features/object_identifiers.rb +0 -194
- /data/docs/{wiki → guides}/API-Reference.md +0 -0
- /data/docs/{wiki → guides}/Connection-Pooling-Guide.md +0 -0
- /data/docs/{wiki → guides}/Encrypted-Fields-Overview.md +0 -0
- /data/docs/{wiki → guides}/Expiration-Feature-Guide.md +0 -0
- /data/docs/{wiki → guides}/Feature-System-Guide.md +0 -0
- /data/docs/{wiki → guides}/Features-System-Developer-Guide.md +0 -0
- /data/docs/{wiki → guides}/Field-System-Guide.md +0 -0
- /data/docs/{wiki → guides}/Quantization-Feature-Guide.md +0 -0
- /data/docs/{wiki → guides}/Security-Model.md +0 -0
- /data/docs/{wiki → guides}/Transient-Fields-Guide.md +0 -0
@@ -2,13 +2,14 @@
|
|
2
2
|
|
3
3
|
require 'securerandom'
|
4
4
|
|
5
|
+
# Provides a suite of tools for generating and manipulating cryptographically
|
6
|
+
# secure identifiers in various formats and lengths.
|
5
7
|
module Familia
|
6
8
|
module SecureIdentifier
|
7
|
-
|
8
9
|
# Generates a 256-bit cryptographically secure hexadecimal identifier.
|
9
10
|
#
|
10
11
|
# @return [String] A 64-character hex string representing 256 bits of entropy.
|
11
|
-
# @security Provides ~10^77 possible values, far exceeding
|
12
|
+
# @security Provides ~10^77 possible values, far exceeding UUIDv4's 128 bits.
|
12
13
|
def generate_hex_id
|
13
14
|
SecureRandom.hex(32)
|
14
15
|
end
|
@@ -26,13 +27,6 @@ module Familia
|
|
26
27
|
#
|
27
28
|
# @param base [Integer] The base for encoding the output string (2-36, default: 36).
|
28
29
|
# @return [String] A secure identifier.
|
29
|
-
#
|
30
|
-
# @example Generate a 256-bit ID in base-36 (default)
|
31
|
-
# generate_id # => "25nkfebno45yy36z47ffxef2a7vpg4qk06ylgxzwgpnz4q3os4"
|
32
|
-
#
|
33
|
-
# @example Generate a 256-bit ID in base-16 (hexadecimal)
|
34
|
-
# generate_id(16) # => "568bdb582bc5042bf435d3f126cf71593981067463709c880c91df1ad9777a34"
|
35
|
-
#
|
36
30
|
def generate_id(base = 36)
|
37
31
|
target_length = SecureIdentifier.min_length_for_bits(256, base)
|
38
32
|
generate_hex_id.to_i(16).to_s(base).rjust(target_length, '0')
|
@@ -43,87 +37,69 @@ module Familia
|
|
43
37
|
#
|
44
38
|
# @param base [Integer] The base for encoding the output string (2-36, default: 36).
|
45
39
|
# @return [String] A secure short identifier.
|
46
|
-
#
|
47
|
-
# @example Generate a 64-bit short ID in base-36 (default)
|
48
|
-
# generate_trace_id # => "lh7uap704unf"
|
49
|
-
#
|
50
|
-
# @example Generate a 64-bit short ID in base-16 (hexadecimal)
|
51
|
-
# generate_trace_id(16) # => "94cf9f8cfb0eb692"
|
52
|
-
#
|
53
40
|
def generate_trace_id(base = 36)
|
54
41
|
target_length = SecureIdentifier.min_length_for_bits(64, base)
|
55
42
|
generate_hex_trace_id.to_i(16).to_s(base).rjust(target_length, '0')
|
56
43
|
end
|
57
44
|
|
58
|
-
#
|
59
|
-
# These short, deterministic IDs are useful for secure logging. By inputting the
|
60
|
-
# full hexadecimal string, you can generate a consistent short ID that allows
|
61
|
-
# tracking an entity through logs without exposing the entity's full identifier..
|
45
|
+
# Creates a deterministic 64-bit trace identifier from a longer hex ID.
|
62
46
|
#
|
63
|
-
#
|
64
|
-
#
|
65
|
-
#
|
47
|
+
# This is a convenience method for `truncate_hex(hex_id, bits: 64)`.
|
48
|
+
# Useful for creating short, consistent IDs for logging and tracing.
|
49
|
+
#
|
50
|
+
# @param (see #truncate_hex)
|
51
|
+
# @return (see #truncate_hex)
|
66
52
|
def shorten_to_trace_id(hex_id, base: 36)
|
67
|
-
|
68
|
-
truncated = hex_id.to_i(16) >> (256 - 64) # Always 64 bits
|
69
|
-
truncated.to_s(base).rjust(target_length, '0')
|
53
|
+
truncate_hex(hex_id, bits: 64, base: base)
|
70
54
|
end
|
71
55
|
|
72
|
-
#
|
73
|
-
#
|
74
|
-
#
|
75
|
-
#
|
76
|
-
#
|
77
|
-
# @param hex_id [String]
|
78
|
-
# @param
|
79
|
-
# @
|
80
|
-
#
|
81
|
-
# @
|
82
|
-
#
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
# @security Truncation preserves the cryptographic properties of the most significant bits.
|
87
|
-
def shorten_to_external_id(hex_id, base: 36)
|
88
|
-
target_length = SecureIdentifier.min_length_for_bits(128, base)
|
89
|
-
truncated = hex_id.to_i(16) >> (256 - 128) # Always 128 bits
|
90
|
-
truncated.to_s(base).rjust(target_length, '0')
|
91
|
-
end
|
56
|
+
# Deterministically truncates a hexadecimal ID to a specified bit length.
|
57
|
+
#
|
58
|
+
# This function preserves the most significant bits of the input `hex_id` to
|
59
|
+
# create a shorter, yet still random-looking, identifier.
|
60
|
+
#
|
61
|
+
# @param hex_id [String] The input hexadecimal string.
|
62
|
+
# @param bits [Integer] The desired output bit length (e.g., 128, 64). Defaults to 128.
|
63
|
+
# @param base [Integer] The numeric base for the output string (2-36). Defaults to 36.
|
64
|
+
# @return [String] A new, shorter identifier in the specified base.
|
65
|
+
# @raise [ArgumentError] if `hex_id` is not a valid hex string, or if `input_bits`
|
66
|
+
# is less than the desired output `bits`.
|
67
|
+
def truncate_hex(hex_id, bits: 128, base: 36)
|
68
|
+
target_length = SecureIdentifier.min_length_for_bits(bits, base)
|
69
|
+
input_bits = hex_id.length * 4
|
92
70
|
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
# to represent a certain amount of entropy. This ensures consistent ID lengths.
|
97
|
-
#
|
98
|
-
# Formula: ceil(bits * log(2) / log(base))
|
99
|
-
#
|
100
|
-
# @example Common usage with SecureRandom
|
101
|
-
# SecureRandom.hex(32) # 32 bytes = 256 bits = 64 hex chars
|
102
|
-
# SecureRandom.hex(16) # 16 bytes = 128 bits = 32 hex chars
|
103
|
-
#
|
104
|
-
# @example Using the method
|
105
|
-
# min_length_for_bits(256, 16) # => 64 (hex)
|
106
|
-
# min_length_for_bits(256, 36) # => 50 (base36)
|
107
|
-
# min_length_for_bits(128, 10) # => 39 (decimal)
|
71
|
+
unless hex_id.match?(/\A[0-9a-fA-F]+\z/)
|
72
|
+
raise ArgumentError, "Invalid hexadecimal string: #{hex_id}"
|
73
|
+
end
|
108
74
|
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
256 => 64, # SHA-256 equivalent entropy
|
113
|
-
128 => 32, # UUID equivalent entropy
|
114
|
-
64 => 16, # Compact ID
|
115
|
-
}.freeze
|
75
|
+
if input_bits < bits
|
76
|
+
raise ArgumentError, "Input bits (#{input_bits}) cannot be less than desired output bits (#{bits})."
|
77
|
+
end
|
116
78
|
|
117
|
-
|
79
|
+
# Truncate by right-shifting to keep the most significant bits
|
80
|
+
truncated_int = hex_id.to_i(16) >> (input_bits - bits)
|
81
|
+
truncated_int.to_s(base).rjust(target_length, '0')
|
82
|
+
end
|
83
|
+
|
84
|
+
# Calculates the minimum string length required to represent a given number of
|
85
|
+
# bits in a specific numeric base.
|
86
|
+
#
|
87
|
+
# @private
|
118
88
|
#
|
119
|
-
# @param bits [Integer]
|
120
|
-
# @param base [Integer]
|
121
|
-
# @return [Integer]
|
89
|
+
# @param bits [Integer] The number of bits of entropy.
|
90
|
+
# @param base [Integer] The numeric base (2-36).
|
91
|
+
# @return [Integer] The minimum string length required.
|
122
92
|
def self.min_length_for_bits(bits, base)
|
123
|
-
|
93
|
+
# Fast lookup for hex (base 16) - our most common case
|
94
|
+
hex_lengths = {
|
95
|
+
256 => 64, # SHA-256 equivalent entropy
|
96
|
+
128 => 32, # UUID equivalent entropy
|
97
|
+
64 => 16, # Compact ID
|
98
|
+
}.freeze
|
99
|
+
return hex_lengths[bits] if base == 16 && hex_lengths.key?(bits)
|
124
100
|
|
125
|
-
@
|
126
|
-
@
|
101
|
+
@min_length_for_bits_cache ||= {}
|
102
|
+
@min_length_for_bits_cache[[bits, base]] ||= (bits * Math.log(2) / Math.log(base)).ceil
|
127
103
|
end
|
128
104
|
end
|
129
105
|
end
|
@@ -0,0 +1,162 @@
|
|
1
|
+
# lib/familia/verifiable_identifier.rb
|
2
|
+
|
3
|
+
require 'openssl'
|
4
|
+
require_relative 'secure_identifier'
|
5
|
+
|
6
|
+
module Familia
|
7
|
+
# Creates and verifies identifiers that contain an embedded HMAC signature,
|
8
|
+
# allowing for stateless verification of an identifier's authenticity.
|
9
|
+
module VerifiableIdentifier
|
10
|
+
# By extending SecureIdentifier, we gain access to its instance methods
|
11
|
+
# (like generate_hex_id) as class methods on this module.
|
12
|
+
extend Familia::SecureIdentifier
|
13
|
+
|
14
|
+
# The secret key for HMAC generation, loaded from an environment variable.
|
15
|
+
#
|
16
|
+
# This key is the root of trust for verifying identifier authenticity. It must be
|
17
|
+
# a long, random, and cryptographically strong string.
|
18
|
+
#
|
19
|
+
# @!attribute [r] SECRET_KEY
|
20
|
+
# @return [String] The secret key.
|
21
|
+
#
|
22
|
+
# @note Security Considerations:
|
23
|
+
# - **Secrecy:** This key MUST be kept secret and secure, just like a database
|
24
|
+
# password or API key. Do not commit it to version control.
|
25
|
+
# - **Consistency:** All running instances of your application must use the
|
26
|
+
# exact same key, otherwise verification will fail across different servers.
|
27
|
+
# - **Rotation:** If this key is ever compromised, it must be rotated. Be
|
28
|
+
# aware that rotating the key will invalidate all previously generated
|
29
|
+
# verifiable identifiers.
|
30
|
+
#
|
31
|
+
# @example Generating and Setting the Key
|
32
|
+
# 1. Generate a new secure key in your terminal:
|
33
|
+
# $ openssl rand -hex 32
|
34
|
+
# > cafef00dcafef00dcafef00dcafef00dcafef00dcafef00d
|
35
|
+
#
|
36
|
+
# 2. Set it as an environment variable in your production environment:
|
37
|
+
# export VERIFIABLE_ID_HMAC_SECRET="cafef00dcafef00dcafef00dcafef00dcafef00dcafef00d"
|
38
|
+
#
|
39
|
+
SECRET_KEY = ENV.fetch('VERIFIABLE_ID_HMAC_SECRET', 'cafef00dcafef00dcafef00dcafef00dcafef00dcafef00d')
|
40
|
+
|
41
|
+
# The length of the random part of the ID in hex characters (256 bits).
|
42
|
+
RANDOM_HEX_LENGTH = 64
|
43
|
+
# The length of the HMAC tag in hex characters (64 bits).
|
44
|
+
# 64 bits is strong enough to prevent forgery (1 in 18 quintillion chance).
|
45
|
+
TAG_HEX_LENGTH = 16
|
46
|
+
|
47
|
+
# Generates a verifiable, base-36 encoded identifier.
|
48
|
+
#
|
49
|
+
# The final identifier contains a 256-bit random component and a 64-bit
|
50
|
+
# authentication tag.
|
51
|
+
#
|
52
|
+
# @param base [Integer] The base for encoding the output string.
|
53
|
+
# @return [String] A verifiable, signed identifier.
|
54
|
+
def self.generate_verifiable_id(base_or_scope = nil, scope: nil, base: 36)
|
55
|
+
# Handle backward compatibility with positional base argument
|
56
|
+
if base_or_scope.is_a?(Integer)
|
57
|
+
base = base_or_scope
|
58
|
+
# scope remains as passed in keyword argument
|
59
|
+
elsif base_or_scope.is_a?(String) || base_or_scope.nil?
|
60
|
+
scope = base_or_scope if scope.nil?
|
61
|
+
# base remains as passed in keyword argument or default
|
62
|
+
end
|
63
|
+
|
64
|
+
# Re-use generate_hex_id from the SecureIdentifier module.
|
65
|
+
random_hex = generate_hex_id
|
66
|
+
tag_hex = generate_tag(random_hex, scope: scope)
|
67
|
+
|
68
|
+
combined_hex = random_hex + tag_hex
|
69
|
+
|
70
|
+
# Re-use the min_length_for_bits helper from the SecureIdentifier module.
|
71
|
+
total_bits = (RANDOM_HEX_LENGTH + TAG_HEX_LENGTH) * 4
|
72
|
+
target_length = Familia::SecureIdentifier.min_length_for_bits(total_bits, base)
|
73
|
+
|
74
|
+
combined_hex.to_i(16).to_s(base).rjust(target_length, '0')
|
75
|
+
end
|
76
|
+
|
77
|
+
# Verifies the authenticity of a given identifier using a timing-safe comparison.
|
78
|
+
#
|
79
|
+
# @param verifiable_id [String] The identifier string to check.
|
80
|
+
# @param base [Integer] The base of the input string.
|
81
|
+
# @return [Boolean] True if the identifier is authentic, false otherwise.
|
82
|
+
def self.verified_identifier?(verifiable_id, base_or_scope = nil, scope: nil, base: 36)
|
83
|
+
# Handle backward compatibility with positional base argument
|
84
|
+
if base_or_scope.is_a?(Integer)
|
85
|
+
base = base_or_scope
|
86
|
+
# scope remains as passed in keyword argument
|
87
|
+
elsif base_or_scope.is_a?(String) || base_or_scope.nil?
|
88
|
+
scope = base_or_scope if scope.nil?
|
89
|
+
# base remains as passed in keyword argument or default
|
90
|
+
end
|
91
|
+
|
92
|
+
return false unless plausible_identifier?(verifiable_id, base)
|
93
|
+
|
94
|
+
expected_hex_length = (RANDOM_HEX_LENGTH + TAG_HEX_LENGTH)
|
95
|
+
combined_hex = verifiable_id.to_i(base).to_s(16).rjust(expected_hex_length, '0')
|
96
|
+
|
97
|
+
random_part = combined_hex[0...RANDOM_HEX_LENGTH]
|
98
|
+
tag_part = combined_hex[RANDOM_HEX_LENGTH..]
|
99
|
+
|
100
|
+
expected_tag = generate_tag(random_part, scope: scope)
|
101
|
+
OpenSSL.secure_compare(expected_tag, tag_part)
|
102
|
+
end
|
103
|
+
|
104
|
+
# Checks if an identifier is plausible (correct format and length) without
|
105
|
+
# performing cryptographic verification.
|
106
|
+
#
|
107
|
+
# This can be used as a fast pre-flight check to reject obviously
|
108
|
+
# malformed identifiers.
|
109
|
+
#
|
110
|
+
# @param identifier_str [String] The identifier string to check.
|
111
|
+
# @param base [Integer] The base of the input string.
|
112
|
+
# @return [Boolean] True if the identifier has a valid format, false otherwise.
|
113
|
+
def self.plausible_identifier?(identifier_str, base = 36)
|
114
|
+
return false unless identifier_str.is_a?(::String)
|
115
|
+
|
116
|
+
# 1. Check length
|
117
|
+
total_bits = (RANDOM_HEX_LENGTH + TAG_HEX_LENGTH) * 4
|
118
|
+
expected_length = Familia::SecureIdentifier.min_length_for_bits(total_bits, base)
|
119
|
+
return false unless identifier_str.length == expected_length
|
120
|
+
|
121
|
+
# 2. Check character set
|
122
|
+
# The most efficient way to check for invalid characters is to attempt
|
123
|
+
# conversion and rescue the error.
|
124
|
+
Integer(identifier_str, base)
|
125
|
+
true
|
126
|
+
rescue ArgumentError
|
127
|
+
false
|
128
|
+
end
|
129
|
+
|
130
|
+
class << self
|
131
|
+
private
|
132
|
+
|
133
|
+
# Generates the HMAC tag for a given message.
|
134
|
+
# @private
|
135
|
+
def generate_tag(message, scope: nil)
|
136
|
+
# Include scope in HMAC calculation for domain separation if provided.
|
137
|
+
# The scope parameter enables creating cryptographically isolated identifier
|
138
|
+
# namespaces (e.g., per-domain, per-tenant, per-application) while maintaining
|
139
|
+
# all security properties of the base system.
|
140
|
+
#
|
141
|
+
# Security considerations for scope values:
|
142
|
+
# - Any string content is cryptographically safe (HMAC handles arbitrary input)
|
143
|
+
# - No length restrictions (short scopes like "a" or long scopes work equally well)
|
144
|
+
# - UTF-8 encoding is handled consistently
|
145
|
+
# - Empty string "" vs nil produce different identifiers (intentional for security)
|
146
|
+
# - Different scope values guarantee different identifier spaces
|
147
|
+
#
|
148
|
+
# Examples of scope usage:
|
149
|
+
# - Customer isolation: scope: "tenant:#{tenant_id}"
|
150
|
+
# - Environment separation: scope: "production" vs scope: "staging"
|
151
|
+
# - Domain scoping: scope: "example.com"
|
152
|
+
# - Application scoping: scope: "#{app_name}:#{version}"
|
153
|
+
hmac_input = scope ? "#{message}:scope:#{scope}" : message
|
154
|
+
|
155
|
+
digest = OpenSSL::Digest.new('sha256')
|
156
|
+
hmac = OpenSSL::HMAC.hexdigest(digest, SECRET_KEY, hmac_input)
|
157
|
+
# Truncate to the desired length for the tag.
|
158
|
+
hmac[0...TAG_HEX_LENGTH]
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
data/lib/familia/version.rb
CHANGED
data/lib/familia.rb
CHANGED
data/setup.cfg
ADDED
@@ -56,22 +56,6 @@ hex_trace_id = Familia.generate_hex_trace_id
|
|
56
56
|
[hex_trace_id.class, hex_trace_id.length == 16, hex_trace_id.match?(/^[a-f0-9]+$/)]
|
57
57
|
#=> [String, true, true]
|
58
58
|
|
59
|
-
## Familia.shorten_to_external_id
|
60
|
-
Familia.respond_to?(:shorten_to_external_id)
|
61
|
-
#=> true
|
62
|
-
|
63
|
-
## Can shorten hex ID to external ID (128 bits)
|
64
|
-
hex_id = Familia.generate_hex_id
|
65
|
-
external_id = Familia.shorten_to_external_id(hex_id)
|
66
|
-
[external_id.class, external_id.length < hex_id.length]
|
67
|
-
#=> [String, true]
|
68
|
-
|
69
|
-
## Can shorten hex ID to external ID with custom base (hex)
|
70
|
-
hex_id = Familia.generate_hex_id
|
71
|
-
hex_external_id = Familia.shorten_to_external_id(hex_id, base: 16)
|
72
|
-
[hex_external_id.class, hex_external_id.length == 32]
|
73
|
-
#=> [String, true]
|
74
|
-
|
75
59
|
## Familia.shorten_to_trace_id
|
76
60
|
Familia.respond_to?(:shorten_to_trace_id)
|
77
61
|
#=> true
|
@@ -88,10 +72,55 @@ hex_trace_id = Familia.shorten_to_trace_id(hex_id, base: 16)
|
|
88
72
|
[hex_trace_id.class, hex_trace_id.length == 16]
|
89
73
|
#=> [String, true]
|
90
74
|
|
75
|
+
## Familia.truncate_hex
|
76
|
+
Familia.respond_to?(:truncate_hex)
|
77
|
+
#=> true
|
78
|
+
|
79
|
+
## Can truncate hex ID to 128 bits by default
|
80
|
+
hex_id = Familia.generate_hex_id
|
81
|
+
truncated_id = Familia.truncate_hex(hex_id)
|
82
|
+
[truncated_id.class, truncated_id.length < hex_id.length]
|
83
|
+
#=> [String, true]
|
84
|
+
|
85
|
+
## Can truncate hex ID to a custom bit length (64 bits)
|
86
|
+
hex_id = Familia.generate_hex_id
|
87
|
+
truncated_64 = Familia.truncate_hex(hex_id, bits: 64)
|
88
|
+
[truncated_64.class, truncated_64.length < hex_id.length]
|
89
|
+
#=> [String, true]
|
90
|
+
|
91
|
+
## Can truncate with a custom base (hex)
|
92
|
+
hex_id = Familia.generate_hex_id
|
93
|
+
hex_truncated = Familia.truncate_hex(hex_id, bits: 128, base: 16)
|
94
|
+
[hex_truncated.class, hex_truncated.length == 32]
|
95
|
+
#=> [String, true]
|
96
|
+
|
97
|
+
## Truncated IDs are deterministic
|
98
|
+
hex_id = Familia.generate_hex_id
|
99
|
+
id1 = Familia.truncate_hex(hex_id)
|
100
|
+
id2 = Familia.truncate_hex(hex_id)
|
101
|
+
id1 == id2
|
102
|
+
#=> true
|
103
|
+
|
104
|
+
## Raises error for invalid hex
|
105
|
+
begin
|
106
|
+
Familia.truncate_hex("not-a-hex-string")
|
107
|
+
rescue ArgumentError => e
|
108
|
+
e.message
|
109
|
+
end
|
110
|
+
#=> "Invalid hexadecimal string: not-a-hex-string"
|
111
|
+
|
112
|
+
## Raises error if input bits are less than output bits
|
113
|
+
begin
|
114
|
+
Familia.truncate_hex("abc", bits: 64)
|
115
|
+
rescue ArgumentError => e
|
116
|
+
e.message
|
117
|
+
end
|
118
|
+
#=> "Input bits (12) cannot be less than desired output bits (64)."
|
119
|
+
|
91
120
|
## Shortened IDs are deterministic
|
92
121
|
hex_id = Familia.generate_hex_id
|
93
|
-
id1 = Familia.
|
94
|
-
id2 = Familia.
|
122
|
+
id1 = Familia.shorten_to_trace_id(hex_id)
|
123
|
+
id2 = Familia.shorten_to_trace_id(hex_id)
|
95
124
|
id1 == id2
|
96
125
|
#=> true
|
97
126
|
|
@@ -0,0 +1,171 @@
|
|
1
|
+
# try/core/verifiable_identifier_try.rb
|
2
|
+
|
3
|
+
require_relative '../helpers/test_helpers'
|
4
|
+
require 'familia/verifiable_identifier'
|
5
|
+
|
6
|
+
## Module is available
|
7
|
+
defined?(Familia::VerifiableIdentifier)
|
8
|
+
#=> "constant"
|
9
|
+
|
10
|
+
## Uses the development secret key by default when ENV is not set
|
11
|
+
Familia::VerifiableIdentifier::SECRET_KEY
|
12
|
+
#=> "cafef00dcafef00dcafef00dcafef00dcafef00dcafef00d"
|
13
|
+
|
14
|
+
# --- Verifiable ID Generation and Verification ---
|
15
|
+
|
16
|
+
## Generates a non-empty string ID
|
17
|
+
id = Familia::VerifiableIdentifier.generate_verifiable_id
|
18
|
+
id.is_a?(String) && !id.empty?
|
19
|
+
#=> true
|
20
|
+
|
21
|
+
## Generated ID is URL-safe (base36)
|
22
|
+
id = Familia::VerifiableIdentifier.generate_verifiable_id
|
23
|
+
id.match?(/[a-z0-9]+/ix)
|
24
|
+
#=> true
|
25
|
+
|
26
|
+
## Generates unique identifiers on subsequent calls
|
27
|
+
Familia::VerifiableIdentifier.generate_verifiable_id
|
28
|
+
#=/> Familia::VerifiableIdentifier.generate_verifiable_id
|
29
|
+
|
30
|
+
## A genuinely generated ID successfully verifies
|
31
|
+
id = Familia::VerifiableIdentifier.generate_verifiable_id
|
32
|
+
Familia::VerifiableIdentifier.verified_identifier?(id)
|
33
|
+
#=> true
|
34
|
+
|
35
|
+
## Fails verification for a completely random garbage string
|
36
|
+
Familia::VerifiableIdentifier.verified_identifier?('this-is-not-a-valid-id-at-all')
|
37
|
+
#=> false
|
38
|
+
|
39
|
+
## Fails verification for a string with invalid characters for the base
|
40
|
+
# A plus sign is not a valid base-36 character.
|
41
|
+
Familia::VerifiableIdentifier.verified_identifier?('this+is+invalid', 36)
|
42
|
+
#=> false
|
43
|
+
|
44
|
+
## Fails verification if the random part of the ID is tampered with
|
45
|
+
id = Familia::VerifiableIdentifier.generate_verifiable_id
|
46
|
+
tampered_id = id.dup
|
47
|
+
tampered_id[0] = (tampered_id[0] == 'a' ? 'b' : 'a') # Flip the first character
|
48
|
+
Familia::VerifiableIdentifier.verified_identifier?(tampered_id)
|
49
|
+
#=> false
|
50
|
+
|
51
|
+
## Fails verification if the tag part of the ID is tampered with
|
52
|
+
id = Familia::VerifiableIdentifier.generate_verifiable_id
|
53
|
+
tampered_id = id.dup
|
54
|
+
idx = tampered_id.length - 1
|
55
|
+
tampered_id[idx] = (tampered_id[idx] == 'a' ? 'b' : 'a') # Flip the last character
|
56
|
+
Familia::VerifiableIdentifier.verified_identifier?(tampered_id)
|
57
|
+
#=> false
|
58
|
+
|
59
|
+
## Works correctly with a different base (hexadecimal)
|
60
|
+
id_hex = Familia::VerifiableIdentifier.generate_verifiable_id(16)
|
61
|
+
Familia::VerifiableIdentifier.verified_identifier?(id_hex, 16)
|
62
|
+
#=> true
|
63
|
+
|
64
|
+
## Base 16 ID has the correct hex length (64 random + 16 tag = 80 chars)
|
65
|
+
id_hex = Familia::VerifiableIdentifier.generate_verifiable_id(16)
|
66
|
+
id_hex.length
|
67
|
+
#=> 80
|
68
|
+
|
69
|
+
# --- Plausibility Checks ---
|
70
|
+
|
71
|
+
## A genuinely generated ID is plausible
|
72
|
+
id = Familia::VerifiableIdentifier.generate_verifiable_id
|
73
|
+
Familia::VerifiableIdentifier.plausible_identifier?(id)
|
74
|
+
#=> true
|
75
|
+
|
76
|
+
## A well-formed but fake ID is still plausible
|
77
|
+
# A string of the correct length (62 for base 36) and charset is plausible
|
78
|
+
total_bits = (Familia::VerifiableIdentifier::RANDOM_HEX_LENGTH + Familia::VerifiableIdentifier::TAG_HEX_LENGTH) * 4
|
79
|
+
fake_id = 'a' * Familia::SecureIdentifier.min_length_for_bits(total_bits, 36)
|
80
|
+
Familia::VerifiableIdentifier.plausible_identifier?(fake_id)
|
81
|
+
#=> true
|
82
|
+
|
83
|
+
## Fails plausibility check if too short
|
84
|
+
short_id = 'a' * 60
|
85
|
+
Familia::VerifiableIdentifier.plausible_identifier?(short_id)
|
86
|
+
#=> false
|
87
|
+
|
88
|
+
## Fails plausibility check if too long
|
89
|
+
long_id = 'a' * 66
|
90
|
+
Familia::VerifiableIdentifier.plausible_identifier?(long_id)
|
91
|
+
#=> false
|
92
|
+
|
93
|
+
## Fails plausibility check for invalid characters
|
94
|
+
invalid_char_id = 'a' * 61 + '+'
|
95
|
+
Familia::VerifiableIdentifier.plausible_identifier?(invalid_char_id)
|
96
|
+
#=> false
|
97
|
+
|
98
|
+
## Fails plausibility check for nil input
|
99
|
+
Familia::VerifiableIdentifier.plausible_identifier?(nil)
|
100
|
+
#=> false
|
101
|
+
|
102
|
+
# --- Scoped Identifier Generation and Verification ---
|
103
|
+
|
104
|
+
## Scoped identifier generation produces different results than unscoped
|
105
|
+
scoped_id = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "example.com")
|
106
|
+
unscoped_id = Familia::VerifiableIdentifier.generate_verifiable_id
|
107
|
+
scoped_id != unscoped_id
|
108
|
+
#=> true
|
109
|
+
|
110
|
+
## Scoped identifiers verify successfully with correct scope
|
111
|
+
scoped_id = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "example.com")
|
112
|
+
Familia::VerifiableIdentifier.verified_identifier?(scoped_id, scope: "example.com")
|
113
|
+
#=> true
|
114
|
+
|
115
|
+
## Scoped identifiers fail verification with wrong scope
|
116
|
+
scoped_id = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "example.com")
|
117
|
+
Familia::VerifiableIdentifier.verified_identifier?(scoped_id, scope: "different.com")
|
118
|
+
#=> false
|
119
|
+
|
120
|
+
## Scoped identifiers fail verification without scope parameter
|
121
|
+
scoped_id = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "example.com")
|
122
|
+
Familia::VerifiableIdentifier.verified_identifier?(scoped_id)
|
123
|
+
#=> false
|
124
|
+
|
125
|
+
## Unscoped identifiers fail verification with scope parameter
|
126
|
+
unscoped_id = Familia::VerifiableIdentifier.generate_verifiable_id
|
127
|
+
Familia::VerifiableIdentifier.verified_identifier?(unscoped_id, scope: "example.com")
|
128
|
+
#=> false
|
129
|
+
|
130
|
+
## Empty string scope produces different identifier than nil scope
|
131
|
+
id_nil = Familia::VerifiableIdentifier.generate_verifiable_id(scope: nil)
|
132
|
+
id_empty = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "")
|
133
|
+
id_nil != id_empty
|
134
|
+
#=> true
|
135
|
+
|
136
|
+
## Empty string scope verifies correctly
|
137
|
+
id_empty = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "")
|
138
|
+
Familia::VerifiableIdentifier.verified_identifier?(id_empty, scope: "")
|
139
|
+
#=> true
|
140
|
+
|
141
|
+
## Short scope values work correctly
|
142
|
+
id_short = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "a")
|
143
|
+
Familia::VerifiableIdentifier.verified_identifier?(id_short, scope: "a")
|
144
|
+
#=> true
|
145
|
+
|
146
|
+
## Long scope values work correctly
|
147
|
+
long_scope = "x" * 1000
|
148
|
+
id_long = Familia::VerifiableIdentifier.generate_verifiable_id(scope: long_scope)
|
149
|
+
Familia::VerifiableIdentifier.verified_identifier?(id_long, scope: long_scope)
|
150
|
+
#=> true
|
151
|
+
|
152
|
+
## Unicode scope values work correctly
|
153
|
+
unicode_scope = "测试🔒🔑"
|
154
|
+
id_unicode = Familia::VerifiableIdentifier.generate_verifiable_id(scope: unicode_scope)
|
155
|
+
Familia::VerifiableIdentifier.verified_identifier?(id_unicode, scope: unicode_scope)
|
156
|
+
#=> true
|
157
|
+
|
158
|
+
## Scoped identifiers work with different bases
|
159
|
+
id_hex = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "test", base: 16)
|
160
|
+
Familia::VerifiableIdentifier.verified_identifier?(id_hex, scope: "test", base: 16)
|
161
|
+
#=> true
|
162
|
+
|
163
|
+
## Backward compatibility: existing method signatures still work
|
164
|
+
id = Familia::VerifiableIdentifier.generate_verifiable_id(36)
|
165
|
+
Familia::VerifiableIdentifier.verified_identifier?(id, 36)
|
166
|
+
#=> true
|
167
|
+
|
168
|
+
## Mixed parameter styles work correctly
|
169
|
+
id = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "test", base: 16)
|
170
|
+
Familia::VerifiableIdentifier.verified_identifier?(id, scope: "test", base: 16)
|
171
|
+
#=> true
|