Module: MixinBot::Utils::Crypto

Included in:
MixinBot::Utils
Defined in:
lib/mixin_bot/utils/crypto.rb

Overview

Cryptographic utility methods for Mixin Network operations.

This module provides essential cryptographic functions including:

  • JWT token generation for API authentication

  • Ed25519 and RSA key generation

  • PIN encryption/decryption

  • Transaction signing

  • UUID generation and derivation

  • Ghost key derivation for Safe API

Key Types

Mixin Network uses several types of cryptographic keys:

Session Key

Ed25519 or RSA key for API authentication

Spend Key

Ed25519 key for signing transactions (Safe API)

View Key

Ed25519 key for viewing transactions

PIN Key

For legacy PIN operations

Signature Algorithm

Safe API uses Ed25519 with Blake3 hashing for transaction signing. This provides quantum-resistant security with compact signatures.

Instance Method Summary collapse

Instance Method Details

#access_token(method, uri, body = '', **kwargs) ⇒ String

Generates a JWT access token for API authentication.

Creates a signed JWT token containing request details and credentials. The token is used in the Authorization header for API requests.

Token payload includes:

  • uid: user/bot ID

  • sid: session ID

  • iat: issued at timestamp

  • exp: expiration timestamp

  • jti: unique token ID

  • sig: SHA-256 hash of request (method + uri + body)

  • scp: scope (usually ‘FULL’)

Examples:

token = MixinBot.utils.access_token('GET', '/me', '')
# Use in request: Authorization: Bearer #{token}

Parameters:

  • method (String)

    HTTP method (GET, POST, etc.)

  • uri (String)

    request URI path

  • body (String) (defaults to: '')

    request body (empty for GET)

  • kwargs (Hash)

    additional options

Options Hash (**kwargs):

  • :exp_in (Integer) — default: 600

    token lifetime in seconds

  • :scp (String) — default: 'FULL'

    token scope

  • :app_id (String)

    bot application ID

  • :session_id (String)

    session ID

  • :private_key (String)

    session private key

Returns:

  • (String)

    JWT token

Raises:



64
65
66
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
92
93
94
95
96
# File 'lib/mixin_bot/utils/crypto.rb', line 64

def access_token(method, uri, body = '', **kwargs)
  sig = Digest::SHA256.hexdigest(method + uri + body.to_s)
  iat = Time.now.utc.to_i
  exp = (Time.now.utc + (kwargs[:exp_in] || 600)).to_i
  scp = kwargs[:scp] || 'FULL'
  jti = SecureRandom.uuid
  uid = kwargs[:app_id] || MixinBot.config.app_id
  sid = kwargs[:session_id] || MixinBot.config.session_id
  private_key = kwargs[:private_key] || MixinBot.config.session_private_key

  payload = {
    uid:,
    sid:,
    iat:,
    exp:,
    jti:,
    sig:,
    scp:
  }

  if private_key.blank?
    raise ConfigurationNotValidError, 'private_key is required'
  elsif private_key.size == 64
    jwk = JOSE::JWK.from_okp [:Ed25519, private_key]
    jws = JOSE::JWS.from({ 'alg' => 'EdDSA' })
  else
    jwk = JOSE::JWK.from_pem private_key
    jws = JOSE::JWS.from({ 'alg' => 'RS512' })
  end

  jwt = JOSE::JWT.from payload
  JOSE::JWT.sign(jwk, jws, jwt).compact
end

#chunked(source, size) ⇒ Object



285
286
287
# File 'lib/mixin_bot/utils/crypto.rb', line 285

def chunked(source, size)
  source.each_slice(size).to_a
end

#decrypt_pin(msg, shared_key:) ⇒ Object

decrypt the encrpted pin, just for test



315
316
317
318
319
320
321
322
323
324
325
# File 'lib/mixin_bot/utils/crypto.rb', line 315

def decrypt_pin(msg, shared_key:)
  msg = Base64.urlsafe_decode64 msg
  iv = msg[0..15]
  cipher = msg[16..47]
  alg = 'AES-256-CBC'
  decode_cipher = OpenSSL::Cipher.new(alg)
  decode_cipher.decrypt
  decode_cipher.iv = iv
  decode_cipher.key = shared_key
  decode_cipher.update(cipher)
end

#derive_ghost_private_key(public_key, view_key, spend_key, index) ⇒ Object



396
397
398
399
400
401
402
403
404
405
# File 'lib/mixin_bot/utils/crypto.rb', line 396

def derive_ghost_private_key(public_key, view_key, spend_key, index)
  mult_value = multiply_keys(public_key:, private_key: view_key)

  x = hash_scalar mult_value, index

  x_scalar = scalar_from_bytes x
  y_scalar = scalar_from_bytes spend_key

  (x_scalar + y_scalar).to_bytes(32)
end

#derive_ghost_public_key(private_key, view_key, spend_key, index) ⇒ Object



385
386
387
388
389
390
391
392
393
394
# File 'lib/mixin_bot/utils/crypto.rb', line 385

def derive_ghost_public_key(private_key, view_key, spend_key, index)
  mult_value = multiply_keys(public_key: view_key, private_key:)

  x = hash_scalar mult_value, index

  p1 = JOSE::JWA::Edwards25519Point.stdbase.decode spend_key
  p2 = JOSE::JWA::Edwards25519Point.stdbase * scalar_from_bytes(x).x.to_i

  (p1 + p2).encode
end

#encrypt_pin(pin, **kwargs) ⇒ Object

use timestamp(timestamp) for iterator as default: must be bigger than the previous, the first time must be greater than 0. After a new session created, it will be reset to 0.

Raises:



329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
# File 'lib/mixin_bot/utils/crypto.rb', line 329

def encrypt_pin(pin, **kwargs)
  pin = MixinBot.utils.decode_key pin

  shared_key = kwargs[:shared_key]
  raise ArgumentError, 'shared_key is required' if shared_key.blank?

  iterator ||= kwargs[:iterator] || Time.now.utc.to_i
  tszero = iterator % 0x100
  tsone = (iterator % 0x10000) >> 8
  tstwo = (iterator % 0x1000000) >> 16
  tsthree = (iterator % 0x100000000) >> 24
  tsstring = "#{tszero.chr}#{tsone.chr}#{tstwo.chr}#{tsthree.chr}\u0000\u0000\u0000\u0000"
  encrypt_content = pin + tsstring + tsstring
  pad_count = 16 - (encrypt_content.length % 16)
  padded_content =
    if pad_count.positive?
      encrypt_content + (pad_count.chr * pad_count)
    else
      encrypt_content
    end

  alg = 'AES-256-CBC'
  aes = OpenSSL::Cipher.new(alg)
  iv = OpenSSL::Cipher.new(alg).random_iv
  aes.encrypt
  aes.key = shared_key
  aes.iv = iv
  cipher = aes.update(padded_content)
  msg = iv + cipher
  Base64.urlsafe_encode64 msg, padding: false
end

#generate_ed25519_keyHash

Generates a new Ed25519 keypair.

Ed25519 is the recommended key type for Mixin Network. It provides strong security with compact keys and signatures.

Examples:

keys = MixinBot.utils.generate_ed25519_key
puts "Private: #{keys[:private_key]}"
puts "Public: #{keys[:public_key]}"

Returns:

  • (Hash)

    hash containing :private_key and :public_key (Base64-encoded)



111
112
113
114
115
116
117
# File 'lib/mixin_bot/utils/crypto.rb', line 111

def generate_ed25519_key
  ed25519_key = JOSE::JWA::Ed25519.keypair
  {
    private_key: Base64.urlsafe_encode64(ed25519_key[1], padding: false),
    public_key: Base64.urlsafe_encode64(ed25519_key[0], padding: false)
  }
end

#generate_group_conversation_id(user_ids:, name:, owner_id:, random_id: nil) ⇒ String

Generates a group conversation ID.

Creates a deterministic conversation ID for a group based on:

  • Owner ID

  • Group name

  • Participant IDs

  • Random ID (optional, for creating different groups with same members)

Examples:

conv_id = MixinBot.utils.generate_group_conversation_id(
  user_ids: ['user1', 'user2', 'user3'],
  name: 'My Group',
  owner_id: 'owner-id'
)

Parameters:

  • user_ids (Array<String>)

    array of participant user IDs

  • name (String)

    the group name

  • owner_id (String)

    the group owner’s user ID

  • random_id (String, nil) (defaults to: nil)

    optional random ID for uniqueness

Returns:

  • (String)

    the conversation UUID



258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/mixin_bot/utils/crypto.rb', line 258

def generate_group_conversation_id(user_ids:, name:, owner_id:, random_id: nil)
  random_id ||= SecureRandom.uuid

  # Start with owner_id and group name
  gid = unique_uuid(owner_id, name)

  # Combine with random_id
  gid = unique_uuid(gid, random_id)

  # Sort participants and combine with each one
  sorted_participants = user_ids.sort
  sorted_participants.each do |participant|
    gid = unique_uuid(gid, participant)
  end

  gid
end

#generate_rsa_keyHash

Generates a new RSA keypair.

RSA keys are supported for backward compatibility but Ed25519 is recommended for new applications.

Examples:

keys = MixinBot.utils.generate_rsa_key
puts keys[:private_key]

Returns:

  • (Hash)

    hash containing :private_key and :public_key (PEM format)



131
132
133
134
135
136
137
# File 'lib/mixin_bot/utils/crypto.rb', line 131

def generate_rsa_key
  rsa_key = OpenSSL::PKey::RSA.new 1024
  {
    private_key: rsa_key.to_pem,
    public_key: rsa_key.public_key.to_pem
  }
end

#generate_trace_from_hash(hash, output_index = 0) ⇒ Object



303
304
305
306
307
308
309
310
311
312
# File 'lib/mixin_bot/utils/crypto.rb', line 303

def generate_trace_from_hash(hash, output_index = 0)
  md5 = Digest::MD5.new
  md5 << hash
  md5 << [output_index].pack('c*') if output_index.positive? && output_index < 256
  digest = md5.digest
  digest[6] = ((digest[6].ord & 0x0f) | 0x30).chr
  digest[8] = ((digest[8].ord & 0x3f) | 0x80).chr

  MixinBot::UUID.new(raw: digest).unpacked
end

#generate_unique_uuid(uuid1, uuid2) ⇒ Object



190
191
192
193
194
195
196
197
198
199
200
# File 'lib/mixin_bot/utils/crypto.rb', line 190

def generate_unique_uuid(uuid1, uuid2)
  md5 = Digest::MD5.new
  md5 << [uuid1, uuid2].min
  md5 << [uuid1, uuid2].max
  digest = md5.digest
  digest6 = ((digest[6].ord & 0x0f) | 0x30).chr
  digest8 = ((digest[8].ord & 0x3f) | 0x80).chr
  cipher = digest[0...6] + digest6 + digest[7] + digest8 + digest[9..]

  MixinBot::UUID.new(raw: cipher).unpacked
end

#generate_user_checksum(sessions) ⇒ Object



276
277
278
279
280
281
282
283
# File 'lib/mixin_bot/utils/crypto.rb', line 276

def generate_user_checksum(sessions)
  list = Array(sessions).map do |s|
    s.is_a?(Hash) ? s['session_id'] || s[:session_id] : s.session_id
  end.compact.sort
  return '' if list.empty?

  Digest::MD5.hexdigest(list.join)
end

#hash_scalar(pkey, output_index) ⇒ Object



367
368
369
370
371
372
373
374
375
376
377
# File 'lib/mixin_bot/utils/crypto.rb', line 367

def hash_scalar(pkey, output_index)
  tmp = [output_index].pack('Q<').reverse

  hash1 = Digest::Blake3.digest(pkey + tmp)
  hash2 = Digest::Blake3.digest hash1

  hash3 = Digest::Blake3.digest(hash1 + hash2)
  hash4 = Digest::Blake3.digest hash3

  hash3 + hash4
end

#make_unique_string_slice(strings) ⇒ Object



289
290
291
# File 'lib/mixin_bot/utils/crypto.rb', line 289

def make_unique_string_slice(strings)
  strings.uniq
end

#multiply_keys(public_key:, private_key:) ⇒ Object



379
380
381
382
383
# File 'lib/mixin_bot/utils/crypto.rb', line 379

def multiply_keys(public_key:, private_key:)
  public_point = JOSE::JWA::Edwards25519Point.stdbase.decode public_key
  private_scalar = scalar_from_bytes private_key
  (public_point * private_scalar.x.to_i).encode
end

#scalar_from_bytes(raw) ⇒ JOSE::JWA::FieldElement

Converts raw bytes to a scalar for Ed25519 operations.

Used internally for cryptographic calculations.

Parameters:

  • raw (String)

    raw bytes

Returns:

  • (JOSE::JWA::FieldElement)

    the scalar value



159
160
161
162
163
164
165
# File 'lib/mixin_bot/utils/crypto.rb', line 159

def scalar_from_bytes(raw)
  JOSE::JWA::FieldElement.new(
    # https://github.com/potatosalad/ruby-jose/blob/e1be589b889f1e59ac233a5d19a3fa13f1e4b8a0/lib/jose/jwa/x25519.rb#L122C14-L122C48
    OpenSSL::BN.new(raw.reverse, 2),
    JOSE::JWA::Edwards25519Point::L
  )
end

#shared_public_key(key) ⇒ String

Derives a public key from a private key.

Used internally for cryptographic operations.

Parameters:

  • key (String)

    the private key (64 bytes)

Returns:

  • (String)

    the derived public key



147
148
149
# File 'lib/mixin_bot/utils/crypto.rb', line 147

def shared_public_key(key)
  (JOSE::JWA::Edwards25519Point.stdbase * scalar_from_bytes(key[...64]).x.to_i).encode
end

#sign(msg, key:) ⇒ Object



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/mixin_bot/utils/crypto.rb', line 167

def sign(msg, key:)
  msg = Digest::Blake3.digest msg

  pub = shared_public_key key

  y_scalar = scalar_from_bytes key

  key_digest = Digest::SHA512.digest key
  msg_digest = Digest::SHA512.digest(key_digest[-32...] + msg)

  z_scalar = scalar_from_bytes msg_digest[...64]

  r_point = JOSE::JWA::Edwards25519Point.stdbase * z_scalar.x.to_i

  hram_digest = Digest::SHA512.digest(r_point.encode + pub + msg)

  x_scalar = scalar_from_bytes hram_digest[...64]

  s_scalar = (x_scalar * y_scalar) + z_scalar

  r_point.encode + s_scalar.to_bytes(32).ljust(32, "\x00")
end

#tip_public_key(key, counter: 0) ⇒ Object

Raises:



361
362
363
364
365
# File 'lib/mixin_bot/utils/crypto.rb', line 361

def tip_public_key(key, counter: 0)
  raise ArgumentError, 'invalid key' if key.size < 32

  (key[0...32].bytes + MixinBot::Utils.encode_uint64(counter + 1)).pack('c*').unpack1('H*')
end

#unique_object_id(*args) ⇒ Object



293
294
295
296
297
298
299
300
301
# File 'lib/mixin_bot/utils/crypto.rb', line 293

def unique_object_id(*args)
  md5 = Digest::MD5.new
  args.flatten.compact.each { |s| md5 << s.to_s }
  digest = md5.digest
  digest = digest.dup
  digest[6] = ((digest[6].ord & 0x0f) | 0x30).chr
  digest[8] = ((digest[8].ord & 0x3f) | 0x80).chr
  MixinBot::UUID.new(raw: digest).unpacked
end

#unique_uuid(*uuids) ⇒ String

Generates a unique UUID from multiple UUIDs.

Creates a deterministic UUID by combining multiple UUIDs. The result is always the same for the same set of input UUIDs, regardless of order (after sorting).

This is used for:

  • Creating conversation IDs

  • Generating multisig addresses

  • Creating deterministic identifiers

Examples:

uuid = MixinBot.utils.unique_uuid(
  'user1-uuid',
  'user2-uuid',
  'user3-uuid'
)
puts uuid  # Always the same for these inputs

Parameters:

  • uuids (Array<String>)

    array of UUIDs to combine

Returns:

  • (String)

    the unique combined UUID



225
226
227
228
229
230
231
232
233
234
# File 'lib/mixin_bot/utils/crypto.rb', line 225

def unique_uuid(*uuids)
  uuids = uuids.flatten.compact
  uuids.sort
  r = uuids.first
  uuids.each_with_index do |uuid, i|
    r = generate_unique_uuid(r, uuid) if i.positive?
  end

  r
end