Class: Threencr::TokenCrypt

Inherits:
Object
  • Object
show all
Defined in:
lib/3ncr.rb

Overview

A 3ncr.org v1 encrypter / decrypter bound to a 32-byte AES key.

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(key) ⇒ TokenCrypt

Returns a new instance of TokenCrypt.



98
99
100
# File 'lib/3ncr.rb', line 98

def initialize(key)
  @key = key
end

Class Method Details

.from_argon2id(secret, salt) ⇒ Object

Derive the AES key from a low-entropy secret via Argon2id using the 3ncr.org v1 recommended parameters (m=19456 KiB, t=2, p=1).

salt must be at least 16 bytes. For deterministic derivation across implementations, pass the same salt.

Raises:

  • (ArgumentError)


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/3ncr.rb', line 69

def self.from_argon2id(secret, salt)
  raise ArgumentError, "salt must be a String" unless salt.is_a?(String)

  salt_bytes = salt.b
  if salt_bytes.bytesize < ARGON2ID_MIN_SALT_BYTES
    raise ArgumentError,
          "salt must be at least #{ARGON2ID_MIN_SALT_BYTES} bytes, got #{salt_bytes.bytesize}"
  end

  secret_bytes = secret.is_a?(String) ? secret.b : String(secret).b

  # +Argon2id::Password.create+ generates its own random salt, so we drop
  # to the gem's underlying +hash_encoded+ primitive (private but stable
  # across releases) to derive with our caller-supplied salt. The encoded
  # string is then re-parsed via +Password.new+ to extract the raw
  # +output+ bytes.
  encoded = Argon2id::Password.send(
    :hash_encoded,
    ARGON2ID_TIME_COST,
    ARGON2ID_MEMORY_KIB,
    ARGON2ID_PARALLELISM,
    secret_bytes,
    salt_bytes,
    AES_KEY_SIZE
  )
  key = Argon2id::Password.new(encoded).output
  new(key)
end

.from_raw_key(key) ⇒ Object

Build a TokenCrypt from a raw 32-byte AES-256 key.

Use this when your secret is already high-entropy and exactly 32 bytes (for example, loaded from a key-management service).

Raises:

  • (ArgumentError)


41
42
43
44
45
46
47
48
49
50
# File 'lib/3ncr.rb', line 41

def self.from_raw_key(key)
  raise ArgumentError, "key must be a String" unless key.is_a?(String)

  bytes = key.b
  unless bytes.bytesize == AES_KEY_SIZE
    raise ArgumentError, "key must be exactly #{AES_KEY_SIZE} bytes, got #{bytes.bytesize}"
  end

  new(bytes)
end

.from_sha3(secret) ⇒ Object

Derive the AES key from a high-entropy secret via a single SHA3-256 hash.

Suitable for random pre-shared keys, UUIDs, or long random API tokens —inputs that already carry at least 128 bits of unique entropy. For low-entropy inputs such as user passwords, prefer from_argon2id.



58
59
60
61
62
# File 'lib/3ncr.rb', line 58

def self.from_sha3(secret)
  bytes = secret.is_a?(String) ? secret.b : String(secret).b
  key = OpenSSL::Digest.new("SHA3-256").digest(bytes)
  new(key)
end

Instance Method Details

#decrypt_if_3ncr(value) ⇒ Object

Decrypt value if it carries the 3ncr.org/1# header; otherwise return it unchanged. This makes it safe to route every configuration value through it regardless of whether it was encrypted.

Raises:

  • (ArgumentError)


121
122
123
124
125
126
# File 'lib/3ncr.rb', line 121

def decrypt_if_3ncr(value)
  raise ArgumentError, "value must be a String" unless value.is_a?(String)
  return value unless value.start_with?(HEADER_V1)

  decrypt(value[HEADER_V1.length..])
end

#encrypt_3ncr(plaintext) ⇒ Object

Encrypt a UTF-8 string and return a 3ncr.org/1#… value.

Raises:

  • (ArgumentError)


103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/3ncr.rb', line 103

def encrypt_3ncr(plaintext)
  raise ArgumentError, "plaintext must be a String" unless plaintext.is_a?(String)

  iv = SecureRandom.bytes(IV_SIZE)
  cipher = OpenSSL::Cipher.new("aes-256-gcm")
  cipher.encrypt
  cipher.key = @key
  cipher.iv = iv
  data = plaintext.b
  # Ruby 3.1's OpenSSL raises on +update("")+; skip it for empty input.
  ciphertext = data.empty? ? cipher.final : cipher.update(data) + cipher.final
  payload = iv + ciphertext + cipher.auth_tag(TAG_SIZE)
  HEADER_V1 + Base64.strict_encode64(payload).delete("=")
end