Module: Clickwrap::Digest

Defined in:
lib/clickwrap/digest.rb

Overview

Digest helpers for document bytes, presentation manifests, receipts, and the optional event chain.

What a digest here does and does not mean is part of the public contract. A digest detects that the bytes it covers changed. It does not identify who produced them, when they were produced, or that a party with full control of the database and application could not have rewritten both the bytes and the digest. Stronger claims need the optional independent anchor or timestamp adapters, and even then Clickwrap reports exactly the assurance those adapters supply.

SHA-2 is a hash standard (NIST FIPS 180-4, https://csrc.nist.gov/pubs/fips/180-4/upd1/final). It is not a signature, not an identity, and not a time source.

Constant Summary collapse

SUPPORTED_ALGORITHMS =

Algorithm name => the OpenSSL digest that computes it. Every value stored in evidence carries its algorithm name so a future release can add an algorithm without making old events unverifiable.

{
  "sha256" => "SHA256",
  "sha384" => "SHA384",
  "sha512" => "SHA512"
}.freeze
DEFAULT_ALGORITHM =
"sha256"
HEX_LENGTH_BY_ALGORITHM =
{
  "sha256" => 64,
  "sha384" => 96,
  "sha512" => 128
}.freeze
PREFIXED_PATTERN =

Digests are written as ":" everywhere they appear, so an auditor never has to guess which function produced a bare hex string.

/\A(?<algorithm>[a-z0-9]+):(?<value>[0-9a-f]+)\z/

Class Method Summary collapse

Class Method Details

.algorithm_of(prefixed) ⇒ Object



82
83
84
# File 'lib/clickwrap/digest.rb', line 82

def algorithm_of(prefixed)
  PREFIXED_PATTERN.match(prefixed.to_s)&.[](:algorithm)
end

.digest(bytes, algorithm: DEFAULT_ALGORITHM) ⇒ Object

Returns "sha256:" for the given bytes.



45
46
47
# File 'lib/clickwrap/digest.rb', line 45

def digest(bytes, algorithm: DEFAULT_ALGORITHM)
  "#{algorithm}:#{hex(bytes, algorithm:)}"
end

.digest_canonical(value, algorithm: DEFAULT_ALGORITHM) ⇒ Object

Canonicalizes value per RFC 8785 and digests the resulting bytes. This is how manifests, compiled policy revisions, and receipts are digested: the digest covers meaning, not formatting.



57
58
59
# File 'lib/clickwrap/digest.rb', line 57

def digest_canonical(value, algorithm: DEFAULT_ALGORITHM)
  digest(CanonicalJson.generate(value), algorithm:)
end

.hex(bytes, algorithm: DEFAULT_ALGORITHM) ⇒ Object

Returns the bare lowercase hex digest.



50
51
52
# File 'lib/clickwrap/digest.rb', line 50

def hex(bytes, algorithm: DEFAULT_ALGORITHM)
  OpenSSL::Digest.hexdigest(openssl_name(algorithm), bytes.to_s.b)
end

.keyed_digest(bytes, key:, algorithm: DEFAULT_ALGORITHM) ⇒ Object

A keyed digest, used where an unkeyed one would be guessable.

An IPv4 address is 32 bits (RFC 791), so an unsalted hash of one can be tested by enumerating every address in minutes. Clickwrap therefore binds request evidence to its event with a keyed construction and says plainly that the result is a linkable pseudonymous value, not an anonymous one.

Raises:

  • (ArgumentError)


68
69
70
71
72
73
# File 'lib/clickwrap/digest.rb', line 68

def keyed_digest(bytes, key:, algorithm: DEFAULT_ALGORITHM)
  raise ArgumentError, "A keyed digest needs a key" if key.nil? || key.to_s.empty?

  mac = OpenSSL::HMAC.hexdigest(openssl_name(algorithm), key.to_s, bytes.to_s.b)
  "hmac-#{algorithm}:#{mac}"
end

.matches?(bytes, expected) ⇒ Boolean

Verifies that bytes still hash to expected, which must be a prefixed digest so the algorithm travels with the value.

Returns:

  • (Boolean)


104
105
106
107
108
109
110
111
112
# File 'lib/clickwrap/digest.rb', line 104

def matches?(bytes, expected)
  match = PREFIXED_PATTERN.match(expected.to_s)
  return false unless match

  algorithm = match[:algorithm]
  return false unless supported?(algorithm)

  secure_compare?(hex(bytes, algorithm:), match[:value])
end

.secure_compare?(left, right) ⇒ Boolean

Compares two digest strings without leaking timing information.

Returns:

  • (Boolean)


76
77
78
79
80
# File 'lib/clickwrap/digest.rb', line 76

def secure_compare?(left, right)
  return false if left.nil? || right.nil?

  OpenSSL.secure_compare(left.to_s, right.to_s)
end

.supported?(algorithm) ⇒ Boolean

Returns:

  • (Boolean)


86
87
88
# File 'lib/clickwrap/digest.rb', line 86

def supported?(algorithm)
  SUPPORTED_ALGORITHMS.key?(algorithm.to_s)
end

.well_formed?(prefixed) ⇒ Boolean

Whether a stored digest has a supported algorithm prefix and the exact hexadecimal length that algorithm emits. This validates provenance identifiers whose source bytes are intentionally unavailable here; it does not claim the digest matches those unavailable bytes.

Returns:

  • (Boolean)


94
95
96
97
98
99
100
# File 'lib/clickwrap/digest.rb', line 94

def well_formed?(prefixed)
  match = PREFIXED_PATTERN.match(prefixed.to_s)
  return false unless match

  expected_length = HEX_LENGTH_BY_ALGORITHM[match[:algorithm]]
  !expected_length.nil? && match[:value].length == expected_length
end