ESC
Type to search...
S
Soli Docs

Cryptography Functions

Hash functions (SHA-256, SHA-512, MD5, HMAC), password hashing with Argon2, X25519 key exchange, Ed25519 signatures, TOTP codes, and Base64 encoding.

Crypto Class

All cryptographic functions are available as static methods on the Crypto class. Standalone function aliases are also provided for convenience.

Hash Functions

Not for password storage. SHA-256, SHA-512, and MD5 are general-purpose hashes — they are fast by design, which is exactly the wrong property for a password store (an attacker brute-forces them just as fast). For passwords, use Crypto.argon2_hash. For verifying tokens / MACs in constant time, use secure_compare.

Crypto.sha256(data)

Compute SHA-256 hash of a string. Also available as sha256(). Use for file checksums, ETags, content addressing — not for password hashing (use argon2_hash).

Parameters

data : String - The data to hash

Returns

String - 64-character hex string (32 bytes)
hash = Crypto.sha256("hello")
# "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
Crypto.sha512(data)

Compute SHA-512 hash of a string. Also available as sha512(). Same caveats as SHA-256 — not for password hashing (use argon2_hash).

Parameters

data : String - The data to hash

Returns

String - 128-character hex string (64 bytes)
hash = Crypto.sha512("hello")
# 128-character hex string
Crypto.md5(data)

Compute MD5 hash of a string. Also available as md5(). Cryptographically broken — collisions can be constructed cheaply. Use only for non-security checksums (e.g. content fingerprinting where adversarial collisions don't matter). Never use for passwords or signatures.

Parameters

data : String - The data to hash

Returns

String - 32-character hex string (16 bytes)
hash = Crypto.md5("hello")
# "5d41402abc4b2a76b9719d911017c592"
Crypto.hmac(message, key)

Compute HMAC-SHA256 message authentication code. Also available as hmac().

Parameters

message : String - The message to authenticate
key : String - The secret key

Returns

String - 64-character hex string (32 bytes)
mac = Crypto.hmac("message", "secret_key")
# Use for API signature verification, webhook validation, etc.
Crypto.secure_compare(a, b)

Constant-time string equality. Also available as secure_compare(). Use whenever comparing two values where one comes from an untrusted source and timing-leak of the comparison would help an attacker — verifying an HMAC, a webhook signature, a CSRF token, a session-derived MAC. A naïve a == b short-circuits at the first differing byte and leaks the prefix length to a timing attacker.

Parameters

a : String
b : String

Returns

Bool - true only when both strings have the same length and bytes. Length is not secret; equal-length inputs run in time proportional only to the length, not the position of any differing byte.
expected = Crypto.hmac(payload, getenv("WEBHOOK_SECRET"))
if secure_compare(expected, request_signature)
  # Trust the request
end

Secure Random

Cryptographically secure random values drawn from the operating system entropy source. All three take a byte count, not a character count, and reject anything outside 1..=1024.

Never use Math.random for secrets. It is a general-purpose PRNG, not a CSPRNG — its output is predictable to anyone who observes enough of it. Session tokens, password-reset links, OAuth state and API keys must all come from the functions below.

Crypto.random_hex(n)

Random bytes as lowercase hex. n is a byte count, so the result is 2n characters — Crypto.random_hex(32) returns a 64-character string, matching openssl rand -hex 32.

Parameters

n : Int — number of random bytes

Returns

String
# Generate a value for SOLI_ENCRYPTION_KEY or JWT_SECRET
Crypto.random_hex(32);   # 64 hex characters
Crypto.random_bytes(n)

Raw random bytes, for composing with another encoding.

Returns

Arrayn integers in 0..=255
Base64.urlsafe_encode(Crypto.random_bytes(32))
Hex.encode(Crypto.random_bytes(16))
Crypto.random_token(n = 32)

Random bytes as unpadded URL-safe Base64. The default 32 bytes gives 256 bits of entropy in 43 URL-safe characters, so the value needs no escaping when it travels in a URL. This is the right primitive for OAuth state, PKCE verifiers, authorization codes and refresh tokens.

Returns

String
state = Crypto.random_token()
session_set("oauth_state", state)

# Store only the digest, compare in constant time later
token = Crypto.random_token()
user.reset_token_digest = Crypto.sha256(token)

Tamper-Evidence (Hash Chains & Merkle Trees)

Two building blocks for verifiable, append-only data — audit logs, provenance trails, and hash-chained ledgers.

Crypto.canonical_json(value)

Serializes a value to canonical JSON — object keys sorted lexicographically, recursively — so the same logical content always produces the same bytes, and therefore the same hash. Ordinary .to_json preserves insertion order, which is not stable enough to hash.

Parameters

value : Any - A JSON-shaped value. Opaque values (functions, instances, …) raise.

Returns

String - Deterministic JSON text.
Crypto.canonical_json({ "b": 1, "a": 2 })   # {"a":2,"b":1}
Crypto.canonical_json({ "a": 2, "b": 1 })   # {"a":2,"b":1}  (same bytes)

record = { "amount": 100, "to": "alice" }
hash = Crypto.sha256(Crypto.canonical_json(record))
Crypto.merkle_root(hashes)

Computes the Merkle root of an array of hex leaf hashes — a single hash proving the whole set. Nodes combine pairwise as sha256(left ‖ right); an odd node pairs with itself. Empty → hash of the empty string; a single leaf is its own root. The root changes if any leaf changes or is reordered.

Parameters

hashes : Array<String> - Hex hash strings (e.g. each record's sha256).

Returns

String - 64-character hex Merkle root.
leaves = records.map(fn(r) Crypto.sha256(Crypto.canonical_json(r)))
root = Crypto.merkle_root(leaves)
# Publish `root`; re-derive it later to detect tampering.
Crypto.ledger_hash(prev_hash, seq, data)

The leaf hash of a hash-chained ledger record — a one-call shorthand for Crypto.sha256(prev_hash + ":" + str(seq) + ":" + Crypto.canonical_json(data)). A single definition, so the write path and the verifier share the exact same formula and can't drift apart.

Parameters

prev_hash : String - the previous record's hash (64 zeros for the genesis record)
seq : Int - the record's monotonic sequence number
data : Hash - the record's user fields

Returns

String - 64-character hex hash.
prev = "0000000000000000000000000000000000000000000000000000000000000000"
hash = Crypto.ledger_hash(prev, 0, { "amount": 100, "to": "alice" })

Tamper-evident ledgers. Chain records so each commits to the one before it — any later edit or deletion breaks the chain and is detectable by recomputing it. See the blog post Tamper-Evident Audit Logs in Soli for a complete, verifiable example.

Password Hashing

Crypto.argon2_hash(password)

Hash a password using Argon2id (recommended algorithm). Also available as argon2_hash() and password_hash().

Parameters

password : String - The plain text password to hash

Returns

String - The Argon2id hash string
hash = Crypto.argon2_hash("secretpassword")
# $argon2id$v=19$m=19456,t=2,p=1$...
Crypto.argon2_verify(password, hash)

Verify a password against an Argon2id hash. Also available as argon2_verify() and password_verify().

Parameters

password : String - The plain text password to verify
hash : String - The stored hash to verify against

Returns

Bool - true if password matches, false otherwise
if Crypto.argon2_verify(user_input, stored_hash)
  println("Password correct!")
else
  println("Invalid password")
end

Symmetric Encryption (AES-256-GCM)

Crypto.encrypt(plaintext, key?)

Authenticated encryption with AES-256-GCM. Returns base64 of nonce ‖ ciphertext+tag. The key defaults to the SOLI_ENCRYPTION_KEY environment variable; any string works (it's hashed to a 32-byte key), but use a long, high-entropy secret.

Crypto.decrypt(ciphertext, key?)

Reverses Crypto.encrypt. Throws if the key is wrong or the data was tampered with (GCM authentication).

let key = Crypto.random_hex(32)
let token = Crypto.encrypt("secret value", key)
Crypto.decrypt(token, key)        # => "secret value"

For model fields, prefer the encrypts DSL, which encrypts on save and decrypts on load automatically. Note: GCM uses a random nonce, so ciphertext is non-deterministic — you can't match encrypted values in a query.

X25519 Key Exchange

Crypto.x25519_keypair()

Generate an X25519 key pair for Diffie-Hellman key exchange. Also available as x25519_keypair().

Returns

Hash - { "private": String, "public": String } (hex-encoded, 64 chars each)
keypair = Crypto.x25519_keypair
println(keypair["public"])   # Hex-encoded public key
println(keypair["private"])  # Hex-encoded private key
Crypto.x25519_shared_secret(private_key, public_key)

Compute a shared secret from your private key and another party's public key. Also available as x25519_shared_secret().

Parameters

private_key : String - Your hex-encoded private key
public_key : String - Their hex-encoded public key

Returns

String - Hex-encoded shared secret
alice = Crypto.x25519_keypair
bob = Crypto.x25519_keypair

# Both compute the same shared secret
alice_secret = Crypto.x25519_shared_secret(alice["private"], bob["public"])
bob_secret = Crypto.x25519_shared_secret(bob["private"], alice["public"])
# alice_secret == bob_secret
Crypto.x25519_public_key(private_key)

Derive the public key from a private key. Also available as x25519_public_key().

Parameters

private_key : String - Hex-encoded private key

Returns

String - Hex-encoded public key
keypair = Crypto.x25519_keypair
derived_public = Crypto.x25519_public_key(keypair["private"])
# derived_public == keypair["public"]

Ed25519 Signatures

Crypto.ed25519_keypair()

Generate an Ed25519 signing key pair for digital signatures. Also available as ed25519_keypair().

Returns

Hash - { "private": String, "public": String } (hex-encoded, 64 chars each)
keypair = Crypto.ed25519_keypair
# Use keypair["private"] to sign messages
# Share keypair["public"] for verification

ID Generation

Four ID generators: UUID v4 / v7 (RFC 4122), ULID, and NanoID. Use uuid_v7() or ulid() for time-sortable DB primary keys, uuid_v4() when you specifically don't want the timestamp leaking into the ID, and nanoid() for compact URL-safe random IDs with custom length / alphabet.

UUID.v4()

Generate a random (version 4) UUID with 122 bits of OS-CSPRNG randomness. Also available as uuid_v4().

Returns

String — 36-character hyphenated UUID
id = uuid_v4()
# "c74d395d-5b75-41c3-b873-ca597e1ccaac"

token = UUID.v4()  # equivalent, via the UUID class
UUID.v7()

Generate a time-ordered (version 7) UUID. The high 48 bits are a Unix millisecond timestamp; the remaining bits are random. UUIDs minted in different milliseconds sort by creation time as strings — ideal for B-tree primary keys. Also available as uuid_v7().

Returns

String — 36-character hyphenated UUID
id = uuid_v7()
# "019e6ef2-ba58-7460-ad04-55e291a8c28b"
# Lexicographic order == creation order.

record_id = UUID.v7()
ULID.generate()

Generate a ULID — a 128-bit identifier encoded as 26 Crockford Base32 chars (high 48 bits are a millisecond timestamp, low 80 bits are random). Sorts by creation time as a string, no dashes, shorter than a UUID. Also available as ulid(), and ULID.new() as an alias.

Returns

String — 26-character Crockford Base32 ULID
id = ulid()
# "01KSQG6MAN1B4S05KRV29NWZPT"

record_id = ULID.generate()
NanoID.generate(size?, alphabet?)

Generate a NanoID — a short, URL-safe, cryptographically random ID. Defaults to 21 characters from the 64-char URL-safe alphabet (A-Z a-z 0-9 _ -), giving ~126 bits of entropy — comparable to a v4 UUID. Also available as nanoid(), and NanoID.new(...) as an alias.

Parameters

size : Int? — Length of the generated ID (1–1024). Default: 21.
alphabet : String? — Character set to draw from (1–255 chars). Default: URL-safe 64-char alphabet.

Returns

Stringsize-character random ID
id = nanoid()
# "XBwf0cjEQmwsx8YSQRCws"

short = nanoid(10)
# "hBbpwcRh4j"

# Human-friendly short codes (Crockford-style, no 0/O/1/I/L)
code = nanoid(8, "23456789ABCDEFGHJKMNPQRSTVWXYZ")
# "K7M3PQ2N"

slug = NanoID.generate(12)

Sizing tip. 21 chars at the default alphabet ≈ 126 bits of entropy. Drop to ~12 chars only when per-table uniqueness is enough; never go below 10 for anything security-sensitive.

TOTP (Time-based One-Time Password)

RFC 6238 compliant TOTP generation and verification. Compatible with Google Authenticator, Authy, and other authenticator apps.

Crypto.totp_generate(secret, time?, period?)

Generate a TOTP code (6-digit time-based one-time password). Uses HMAC-SHA1 per RFC 6238.

Parameters

secret : String - Base32-encoded secret key
time : Int? - Optional Unix timestamp (defaults to current time)
period : Int? - Optional time window in seconds (defaults to 30)

Returns

String - 6-digit TOTP code
# Generate code for current time
code = Crypto.totp_generate("JBSWY3DPEHPK3PXP")

# Generate code for specific time
code = Crypto.totp_generate("JBSWY3DPEHPK3PXP", 1704067200, 30)

# Use RFC 6238 test vector
code = Crypto.totp_generate("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", 59, 30)
# Returns "287082"
Crypto.totp_verify(secret, code, time?, period?)

Verify a TOTP code against a secret. Accepts the current code and the previous/next code (1 step window) to handle clock drift.

Parameters

secret : String - Base32-encoded secret key
code : String - 6-digit TOTP code to verify
time : Int? - Optional Unix timestamp (defaults to current time)
period : Int? - Optional time window in seconds (defaults to 30)

Returns

Bool - true if code is valid, false otherwise
secret = "JBSWY3DPEHPK3PXP"
user_code = request.body["code"]

if Crypto.totp_verify(secret, user_code)
  println("Authentication successful!")
else
  println("Invalid code")
end
Crypto.totp_uri(secret, account_name?, issuer?, period?)

Generate an otpauth:// URI for easy TOTP setup in authenticator apps. This URI can be encoded into a QR code.

Parameters

secret : String - Base32-encoded secret key
account_name : String? - Optional account name (e.g., email)
issuer : String? - Optional service name (e.g., "MyApp")
period : Int? - Optional time window in seconds (defaults to 30)

Returns

String - otpauth:// URI
secret = "JBSWY3DPEHPK3PXP"
uri = Crypto.totp_uri(secret, "user@example.com", "MyApp", 30)

# Returns: otpauth://totp/MyApp:user%40example.com?secret=...&algorithm=SHA1&digits=6&period=30

# Use with QR code library
qr_data = QRCode.encode(uri)
# Display qr_data to user for scanning

XML Digital Signature Primitives

Low-level building blocks for RSA signatures and XML-DSig / SAML / WS-Security: 2048-bit modular exponentiation, PKCS#1 v1.5 padding, and exclusive XML canonicalization. Octet inputs are hex strings (an optional 0x prefix is allowed) or arrays of byte-valued Ints; results are returned as hex strings.

Primitives, not a turn-key signer. These give you m^e mod n, the PKCS#1 padding frame, and a canonical byte stream — you compose the digest, the DigestInfo, and reference resolution yourself. Prefer a vetted library where one exists for your platform.

Crypto.modexp(base, exp, modulus)

Compute base^exp mod modulus over big-endian octet strings. The result is left-padded with zero octets to the modulus width (k = ceil(bits(modulus)/8)), matching the RSA convention where a signature or ciphertext is always k octets wide — so the output drops straight into the PKCS#1 helpers.

Parameters

base, exp, modulus : String | Array - big-endian octets (hex or byte array)

Returns

String - big-endian hex, k octets wide. Errors if the modulus is zero.
# 4^13 mod 497 = 445 (0x01bd); modulus 0x01f1 is 2 octets, so output is 2 octets
Crypto.modexp("04", "0d", "01f1")   # => "01bd"
Crypto.pkcs1_pad(data, key_size, block_type?)

Apply PKCS#1 v1.5 padding (RFC 8017): EM = 0x00 || BT || PS || 0x00 || data, producing an encoded message exactly key_size octets long. block_type defaults to 1 (signature padding — PS is all 0xFF); pass 2 for encryption padding (PS is random non-zero octets). data must be at most key_size - 11 octets.

Parameters

data : String | Array - octets to pad
key_size : Int - target octet length of the encoded message
block_type : Int? - 1 (signatures, default) or 2 (encryption)

Returns

String - the key_size-octet encoded message as hex
Crypto.pkcs1_unpad(encoded_message)

Strip PKCS#1 v1.5 padding, returning the embedded data. Validates the 0x00 || BT prefix, the minimum 8-octet padding string, and (for block type 1) that every padding octet is 0xFF.

Returns

String - the recovered data octets as hex. Errors on malformed padding.
# RSA sign/verify round-trip: sign a digest with the private exponent d,
# verify by raising the signature to the public exponent e.
digest     = Crypto.sha256(canonical_xml)        # 32-byte hex digest
em         = Crypto.pkcs1_pad(digest, key_octets) # block type 1
signature  = Crypto.modexp(em, rsa_d, rsa_n)      # sign

recovered  = Crypto.pkcs1_unpad(Crypto.modexp(signature, rsa_e, rsa_n))
assert(recovered == digest)
Xml.c14n_exclusive(xml, inclusive_prefixes_or_options?)

Canonicalize an XML document with the W3C Exclusive XML Canonicalization 1.0 algorithm (http://www.w3.org/2001/10/xml-exc-c14n#) — the canonical form signed by XML-DSig. Empty elements expand to start/end pairs, attributes and namespace declarations are sorted, the XML declaration and comments are removed, and (the "exclusive" part) namespace declarations that are not visibly utilized are dropped rather than inherited from ancestors. DOCTYPE declarations are rejected (XXE defense).

Parameters

xml : String - the XML document to canonicalize
2nd arg : String | Array | Hash? - either the InclusiveNamespaces PrefixList directly (e.g. "ds saml", #default = default namespace), or an options hash: inclusive_prefixes, id (canonicalize only the subtree whose Id attribute matches — resolves Reference URI="#..."), enveloped_signature (Bool — drop any descendant <ds:Signature>).

Returns

String - the canonical UTF-8 serialization
Xml.c14n_exclusive("<?xml version=\"1.0\"?><doc b='2' a='1'/>")
# => "<doc a=\"1\" b=\"2\"></doc>"

# An ancestor namespace the signed element doesn't use (n2) is dropped:
xml = "<n0:r xmlns:n0=\"http://a\" xmlns:n2=\"http://c\"><n1:e xmlns:n1=\"http://b\">x</n1:e></n0:r>"
Xml.c14n_exclusive(xml)
# => "<n0:r xmlns:n0=\"http://a\"><n1:e xmlns:n1=\"http://b\">x</n1:e></n0:r>"

# Canonicalize the SAML-referenced element with its signature stripped:
Xml.c14n_exclusive(saml_response, {"id": "_assertion1", "enveloped_signature": true})

Comments are omitted (the no-comments form).

Xml.get_element_by_id(xml, id)

Return the element whose Id/ID/id attribute equals id, serialized as a standalone XML fragment with the inherited ancestor namespaces injected onto its root (so it re-parses and re-canonicalizes identically to the in-context subtree). Errors if no such element exists.

Returns

String - the element subtree as standalone XML
Xml.get_elements_by_tag(xml, local_name)

Return every element with the given local name (namespace prefix ignored), each as a standalone XML fragment. Used to locate elements with no Id, such as <ds:SignedInfo>.

Returns

Array<String>
X509.public_key(cert)

Parse an X.509 certificate and extract its RSA public key. Accepts PEM (-----BEGIN CERTIFICATE-----), bare base64 (as found in a SAML metadata <ds:X509Certificate>), hex, or a raw DER byte array.

Returns

Hash - { "algorithm": "RSA", "n": hex, "e": hex, "bits": Int }. The n/e hex strings drop straight into Crypto.modexp.
key = X509.public_key(idp_metadata_cert)
em  = Crypto.modexp(signature, key["e"], key["n"])   # RSA verify step
X509.fingerprint(cert, algorithm?)

Certificate fingerprint (hash of the DER bytes) as hex. algorithm is "sha256" (default) or "sha1". Fingerprints the whole certificate; for TLS pinning that survives renewal, pin the key with X509.spki_pin instead.

Returns

String - hex digest
X509.spki_pin(cert)

The public-key pin for TLS certificate pinning: base64(SHA-256(SubjectPublicKeyInfo)), returned as "sha256/<base64>" — the exact form an Android Network Security Config <pin-set> or any HPKP-style pinner expects.

It pins the key, not the certificate, which is what lets a pinned client survive a certificate renewal: as long as the renewal reuses the key, the pin is unchanged. Pin the certificate (or its fingerprint) instead and the client breaks on every ~90-day rotation.

pin = X509.spki_pin(File.read("cert.pem"))
# => "sha256/UKm/R6MKhCiukXKhnWjBQSRBSWRwGQBLCCa/8w27Dxs="

Pinning is a footgun; treat it as one. A wrong or lost pin bricks the installed app with no server-side fix. Always ship a backup pin (a second, offline key), and for a public web app already on HSTS + Certificate Transparency, weigh whether the one threat it closes — a rogue or compromised CA — is worth the operational risk. Browsers removed HPKP for this reason. Soli gives you the pin string but does not wire pinning into the shells by default; that is a deliberate per-deployment decision.

Returns

String - "sha256/<base64>"
Deflate.deflate(data) / Deflate.inflate(data)

Raw DEFLATE (RFC 1951, no zlib/gzip wrapper) — the compression used by the SAML 2.0 HTTP-Redirect binding. Byte conventions mirror Base64: deflate takes a String (its UTF-8 bytes) or byte array and returns a byte array; inflate returns a String when the result is valid UTF-8, else a byte array. Designed to pipe through Base64.

# Inbound: decode a SAMLRequest from a redirect URL
xml = Deflate.inflate(Base64.decode(params["SAMLRequest"]))

# Outbound: encode one
param = Base64.encode(Deflate.deflate(authn_request_xml))
RsaKey.private_from_pem(pem)

Parse an RSA private key — PKCS#8 (-----BEGIN PRIVATE KEY-----) or PKCS#1 (-----BEGIN RSA PRIVATE KEY-----) PEM — so a Service Provider can sign. (The verification side, X509.public_key, only yields (n, e).)

Returns

Hash - { "algorithm": "RSA", "n": hex, "e": hex, "d": hex, "bits": Int }. Sign with the private exponent: Crypto.modexp(padded, key["d"], key["n"]).
Hex.encode(data) / Hex.decode(hex)

Bridges the hex world (Crypto.modexp / sha256 / pkcs1_* all speak hex) and the byte/base64 world (Base64, and XML-DSig's base64 DigestValue / SignatureValue). encode takes a String/byte-array → hex; decode takes a hex string (optional 0x prefix) → byte array.

# hex digest -> base64 DigestValue
digest_value = Base64.encode(Hex.decode(Crypto.sha256(canonical_xml)))
# incoming base64 -> hex (to compare against a Crypto.* result)
Hex.encode(Base64.decode(incoming_b64))

Putting it together: signing an envelope

key = RsaKey.private_from_pem(sp_private_key_pem)

# 1. Digest the referenced element (enveloped transform; no Signature yet)
ref_canon  = Xml.c14n_exclusive(doc, {"id": "_obj1", "enveloped_signature": true})
digest_b64 = Base64.encode(Hex.decode(Crypto.sha256(ref_canon)))

# 2. Build SignedInfo (exc-c14n, rsa-sha256, Reference w/ enveloped+exc-c14n
#    transforms and DigestValue=digest_b64), then sign its canonical form:
si_hash = Crypto.sha256(Xml.c14n_exclusive(signed_info))
em      = Crypto.pkcs1_pad("3031300d060960864801650304020105000420" + si_hash, key["bits"] / 8)
sig_b64 = Base64.encode(Hex.decode(Crypto.modexp(em, key["d"], key["n"])))

# 3. Assemble <ds:Signature> and envelope it into the document.
#    (Cross-checked: a signxml verifier accepts Soli-produced signatures.)

Putting it together: verifying a SAML signature

# 1. Verify the Reference digest (enveloped transform + by-id exclusive c14n)
canonical = Xml.c14n_exclusive(saml, {"id": assertion_id, "enveloped_signature": true})
if Crypto.sha256(canonical) != digest_value_hex { return false }

# 2. Verify the signature over SignedInfo
signed_info = Xml.get_elements_by_tag(saml, "SignedInfo")[0]
si_hash     = Crypto.sha256(Xml.c14n_exclusive(signed_info))
key         = X509.public_key(idp_cert)
recovered   = Crypto.pkcs1_unpad(Crypto.modexp(signature_hex, key["e"], key["n"]))
# DigestInfo = SHA-256 DER prefix + the SignedInfo hash
recovered == "3031300d060960864801650304020105000420" + si_hash