Module: Seekrit::Crypto

Defined in:
lib/seekrit/crypto.rb

Overview

Zero-knowledge read path, byte-compatible with @seekrit/crypto and crates/seekrit-core. Three steps:

1. Recover the service token's P-256 private key  (TokenKey.parse).
2. Unwrap each environment DEK                     (TokenKey#unwrap_dek).
3. Decrypt each secret                             (Crypto.decrypt_secret).

Blob formats (all segments base64url, no padding):

token       skt_<id>_<pkcs8 private key>
wrapped DEK wd1.<ephemeral pub (raw SEC1)>.<hkdf salt>.<iv>.<ciphertext||tag>
secret      sc1.<iv>.<ciphertext||tag>

Defined Under Namespace

Classes: TokenKey

Constant Summary collapse

HKDF_INFO =
"seekrit/wrap-dek/v1"
CURVE =

NIST P-256 / secp256r1

"prime256v1"
TOKEN_RE =

Only the first two underscores are separators; the key segment may contain "_".

/\A(skt_[0-9A-Za-z]+)_([A-Za-z0-9_-]+)\z/.freeze

Class Method Summary collapse

Class Method Details

.aes_gcm_decrypt(key, iv, ciphertext_and_tag, aad) ⇒ Object

Raises:



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/seekrit/crypto.rb', line 62

def aes_gcm_decrypt(key, iv, ciphertext_and_tag, aad)
  raise CryptoError, "ciphertext too short" if ciphertext_and_tag.bytesize < 16

  tag = ciphertext_and_tag[-16, 16]
  ciphertext = ciphertext_and_tag[0...-16]
  cipher = OpenSSL::Cipher.new("aes-256-gcm")
  cipher.decrypt
  cipher.key = key
  cipher.iv = iv
  cipher.auth_tag = tag
  # GCM with no AAD == empty AAD; skip the call so a zero-length AAD never
  # trips broken bindings (e.g. some LibreSSL builds).
  cipher.auth_data = aad unless aad.nil? || aad.empty?
  cipher.update(ciphertext) + cipher.final
end

.b64url_decode(text) ⇒ Object



26
27
28
# File 'lib/seekrit/crypto.rb', line 26

def b64url_decode(text)
  Base64.urlsafe_decode64(text + ("=" * ((4 - (text.length % 4)) % 4)))
end

.decrypt_secret(dek, blob, aad) ⇒ Object

Decrypt an sc1. secret ciphertext to its UTF-8 plaintext.



132
133
134
135
136
137
138
139
140
141
142
# File 'lib/seekrit/crypto.rb', line 132

def decrypt_secret(dek, blob, aad)
  iv_b64, ct_b64 = split_blob(blob, "sc1", 2)
  iv = b64url_decode(iv_b64)
  ct = b64url_decode(ct_b64)
  plaintext = begin
    aes_gcm_decrypt(dek, iv, ct, aad)
  rescue OpenSSL::Cipher::CipherError
    raise CryptoError, "secret decryption failed: wrong key, tampered data, or mismatched context"
  end
  plaintext.force_encoding(Encoding::UTF_8)
end

.hkdf_sha256(ikm, salt, info, length) ⇒ Object

HKDF-SHA256 (RFC 5869), implemented with HMAC so it works on every Ruby / OpenSSL / LibreSSL build regardless of OpenSSL::KDF.hkdf availability.



49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/seekrit/crypto.rb', line 49

def hkdf_sha256(ikm, salt, info, length)
  prk = OpenSSL::HMAC.digest("SHA256", salt, ikm)
  okm = +""
  block = +""
  counter = 0
  while okm.bytesize < length
    counter += 1
    block = OpenSSL::HMAC.digest("SHA256", prk, block + info + [counter].pack("C"))
    okm << block
  end
  okm[0, length]
end

.materialize(resolve_response, key) ⇒ Object

Decrypt every layer and merge by precedence. Layers arrive lowest precedence first (composed groups, then the app environment); later layers overwrite earlier ones on a name collision.



147
148
149
150
151
152
153
154
155
156
157
# File 'lib/seekrit/crypto.rb', line 147

def materialize(resolve_response, key)
  merged = {}
  resolve_response["layers"].each do |layer|
    dek = key.unwrap_dek(layer["wrappedDek"])
    env_id = layer["environmentId"]
    layer["secrets"].each do |secret|
      merged[secret["name"]] = decrypt_secret(dek, secret["ciphertext"], secret_aad(env_id, secret["name"]))
    end
  end
  merged
end

.secret_aad(environment_id, name) ⇒ Object

AAD binding a secret ciphertext to its environment and name.



43
44
45
# File 'lib/seekrit/crypto.rb', line 43

def secret_aad(environment_id, name)
  "#{environment_id}/#{name}"
end

.split_blob(blob, prefix, segments) ⇒ Object



30
31
32
33
34
35
36
37
38
39
40
# File 'lib/seekrit/crypto.rb', line 30

def split_blob(blob, prefix, segments)
  parts = blob.split(".")
  unless parts[0] == prefix
    raise CryptoError, %(expected a "#{prefix}" blob, got "#{parts[0]}")
  end
  if parts.length != segments + 1 || parts.any?(&:empty?)
    raise CryptoError, %(malformed "#{prefix}" blob)
  end

  parts[1..]
end