Module: Familia::VerifiableIdentifier

Extended by:
SecureIdentifier
Defined in:
lib/familia/verifiable_identifier.rb

Overview

Creates and verifies identifiers that contain an embedded HMAC signature, allowing for stateless verification of an identifier's authenticity.

Constant Summary collapse

RANDOM_HEX_LENGTH =

The length of the random part of the ID in hex characters (256 bits).

64
TAG_HEX_LENGTH =

The length of the HMAC tag in hex characters (64 bits). 64 bits is strong enough to prevent forgery (1 in 18 quintillion chance).

16

Class Method Summary collapse

Methods included from SecureIdentifier

generate_id, generate_lite_id, generate_trace_id, shorten_to_trace_id, truncate_hex

Class Method Details

.generate_verifiable_id(base_or_scope = nil, scope: nil, base: 36) ⇒ String

Generates a verifiable, base-36 encoded identifier.

The final identifier contains a 256-bit random component and a 64-bit authentication tag.

Parameters:

  • base (Integer) (defaults to: 36)

    The base for encoding the output string.

Returns:

  • (String)

    A verifiable, signed identifier.



117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/familia/verifiable_identifier.rb', line 117

def self.generate_verifiable_id(base_or_scope = nil, scope: nil, base: 36)
  # Handle backward compatibility with positional base argument
  if base_or_scope.is_a?(Integer)
    base = base_or_scope
    # scope remains as passed in keyword argument
  elsif base_or_scope.is_a?(String) || base_or_scope.nil?
    scope = base_or_scope if scope.nil?
    # base remains as passed in keyword argument or default
  end

  # Re-use generate_id from the SecureIdentifier module.
  random_hex = generate_id(16)
  tag_hex = generate_tag(random_hex, scope: scope)

  combined_hex = random_hex + tag_hex

  # Re-use the min_length_for_bits helper from the SecureIdentifier module.
  total_bits = (RANDOM_HEX_LENGTH + TAG_HEX_LENGTH) * 4
  target_length = Familia::SecureIdentifier.min_length_for_bits(total_bits, base)

  combined_hex.to_i(16).to_s(base).rjust(target_length, '0')
end

.plausible_identifier?(identifier_str, base = 36) ⇒ Boolean

Checks if an identifier is plausible (correct format and length) without performing cryptographic verification.

This can be used as a fast pre-flight check to reject obviously malformed identifiers.

Parameters:

  • identifier_str (String)

    The identifier string to check.

  • base (Integer) (defaults to: 36)

    The base of the input string.

Returns:

  • (Boolean)

    True if the identifier has a valid format, false otherwise.



176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/familia/verifiable_identifier.rb', line 176

def self.plausible_identifier?(identifier_str, base = 36)
  return false unless identifier_str.is_a?(::String)

  # 1. Check length
  total_bits = (RANDOM_HEX_LENGTH + TAG_HEX_LENGTH) * 4
  expected_length = Familia::SecureIdentifier.min_length_for_bits(total_bits, base)
  return false unless identifier_str.length == expected_length

  # 2. Check character set
  # The most efficient way to check for invalid characters is to attempt
  # conversion and rescue the error.
  Integer(identifier_str, base)
  true
rescue ArgumentError
  false
end

.reset_secret_key!nil

Clears the memoized secret so the next secret_key call re-reads the environment. Intended for test suites that swap the configured secret (or exercise the missing-secret path) within a single process. Production code never needs this: the secret is fixed for a deployment, and rotating it requires a restart (and invalidates every previously generated identifier).

Returns:

  • (nil)


100
101
102
# File 'lib/familia/verifiable_identifier.rb', line 100

def self.reset_secret_key!
  @secret_key = nil
end

.secret_keyString

Note:

Security Considerations:

  • Secrecy: This key MUST be kept secret and secure, just like a database password or API key. Do not commit it to version control.
  • Consistency: All running instances of your application must use the exact same key, otherwise verification will fail across different servers. If both variables are set, they must hold the same value: identifiers are only verifiable with the secret that generated them.
  • Rotation: If this key is ever compromised, it must be rotated. Be aware that rotating the key will invalidate all previously generated verifiable identifiers.
  • No committed fallback: There is intentionally NO default. A hardcoded secret in source would be public knowledge, letting anyone forge valid identifiers (issue #310, S1). When neither variable holds a nonblank value, this raises -- but lazily, the first time an identifier is actually minted or a plausible one is verified, so merely requiring this file (e.g. for introspection) never blows up. Malformed identifiers are rejected by verified_identifier? before the secret is read. The failure is not memoized: fixing the environment and calling again succeeds.

The secret key for HMAC generation, loaded from an environment variable.

This key is the root of trust for verifying identifier authenticity. It must be a long, random, and cryptographically strong string.

Two environment variables are consulted, in this order:

  1. +VERIFIABLE_ID_HMAC_SECRET+ -- the legacy name. When nonblank it wins.
  2. +IDENTIFIER_SECRET+ -- the preferred name going forward. Read only when the legacy variable is unset or blank.

A blank (empty or whitespace-only) +VERIFIABLE_ID_HMAC_SECRET+ does not raise on its own; it falls through to +IDENTIFIER_SECRET+. Container setups that inject +VERIFIABLE_ID_HMAC_SECRET=$VERIFIABLE_ID_HMAC_SECRET:-+ (an empty string whenever the outer variable is unset) therefore keep working when only +IDENTIFIER_SECRET+ is configured. +VERIFIABLE_ID_HMAC_SECRET+ remains fully supported for compatibility and is not deprecated.

Examples:

Generating the key

$ openssl rand -hex 32
> <64 hex characters>

Configuring with the preferred variable

export IDENTIFIER_SECRET="<the generated value>"

Configuring with the legacy variable (still supported)

export VERIFIABLE_ID_HMAC_SECRET="<the generated value>"

Returns:

  • (String)

    the configured secret, exactly as found in the environment

Raises:

  • (KeyError)

    if neither VERIFIABLE_ID_HMAC_SECRET nor IDENTIFIER_SECRET holds a nonblank value



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/familia/verifiable_identifier.rb', line 67

def self.secret_key
  # Treat a present-but-blank value the same as absent. ENV.fetch only fires
  # its default block when the key is missing, so a blank "" (or a
  # whitespace-only "   ", e.g. from a YAML parser or templating tool) would
  # otherwise sail through and HMAC every identifier under an effectively
  # empty key -- silently reintroducing the forgeable-key weakness #310 S1
  # closed. nonblank_env fails closed on nil, "", and all-whitespace alike,
  # which is also what lets a blank legacy variable fall through to
  # IDENTIFIER_SECRET instead of raising (issue #423).
  @secret_key ||= begin
    value = nonblank_env('VERIFIABLE_ID_HMAC_SECRET') || nonblank_env('IDENTIFIER_SECRET')
    if value.nil?
      raise KeyError, <<~MSG.strip
        Neither VERIFIABLE_ID_HMAC_SECRET nor IDENTIFIER_SECRET is set (or both are blank).
        Familia::VerifiableIdentifier reads VERIFIABLE_ID_HMAC_SECRET first and falls back
        to IDENTIFIER_SECRET; it refuses to fall back to a committed default or empty
        secret -- a known or empty key would let anyone forge valid identifiers. Generate
        one with `openssl rand -hex 32` and export it as IDENTIFIER_SECRET before
        generating or verifying identifiers.
      MSG
    end

    value
  end
end

.verified_identifier?(verifiable_id, base_or_scope = nil, scope: nil, base: 36) ⇒ Boolean

Verifies the authenticity of a given identifier using a timing-safe comparison.

Parameters:

  • verifiable_id (String)

    The identifier string to check.

  • base (Integer) (defaults to: 36)

    The base of the input string.

Returns:

  • (Boolean)

    True if the identifier is authentic, false otherwise.



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/familia/verifiable_identifier.rb', line 145

def self.verified_identifier?(verifiable_id, base_or_scope = nil, scope: nil, base: 36)
  # Handle backward compatibility with positional base argument
  if base_or_scope.is_a?(Integer)
    base = base_or_scope
    # scope remains as passed in keyword argument
  elsif base_or_scope.is_a?(String) || base_or_scope.nil?
    scope = base_or_scope if scope.nil?
    # base remains as passed in keyword argument or default
  end

  return false unless plausible_identifier?(verifiable_id, base)

  expected_hex_length = (RANDOM_HEX_LENGTH + TAG_HEX_LENGTH)
  combined_hex = verifiable_id.to_i(base).to_s(16).rjust(expected_hex_length, '0')

  random_part = combined_hex[0...RANDOM_HEX_LENGTH]
  tag_part = combined_hex[RANDOM_HEX_LENGTH..]

  expected_tag = generate_tag(random_part, scope: scope)
  OpenSSL.secure_compare(expected_tag, tag_part)
end