localvault 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,76 +2,137 @@ require "yaml"
2
2
  require "fileutils"
3
3
 
4
4
  module LocalVault
5
+ # Global configuration — paths, default vault, API credentials.
6
+ #
7
+ # Reads/writes +~/.localvault/config.yml+ (mode 0600).
8
+ # All directories are created with mode 0700.
9
+ # Override the root path with +LOCALVAULT_HOME+ env var.
10
+ #
11
+ # @example
12
+ # Config.root_path # => "~/.localvault"
13
+ # Config.default_vault # => "default"
14
+ # Config.token = "tok-..."
15
+ # Config.inventlist_handle = "nauman"
5
16
  module Config
6
17
  CONFIG_FILE = "config.yml"
7
18
 
19
+ # Root directory for all LocalVault data. Honors +LOCALVAULT_HOME+ env var.
20
+ #
21
+ # @return [String] absolute path, defaults to +~/.localvault+
8
22
  def self.root_path
9
23
  ENV.fetch("LOCALVAULT_HOME") { File.join(Dir.home, ".localvault") }
10
24
  end
11
25
 
26
+ # Path to the global config file.
27
+ #
28
+ # @return [String] absolute path to +config.yml+
12
29
  def self.config_path
13
30
  File.join(root_path, CONFIG_FILE)
14
31
  end
15
32
 
33
+ # Path to the directory containing all vaults.
34
+ #
35
+ # @return [String] absolute path to +vaults/+ directory
16
36
  def self.vaults_path
17
37
  File.join(root_path, "vaults")
18
38
  end
19
39
 
40
+ # Path to the directory containing identity keys.
41
+ #
42
+ # @return [String] absolute path to +keys/+ directory
20
43
  def self.keys_path
21
44
  File.join(root_path, "keys")
22
45
  end
23
46
 
47
+ # Load the config file as a hash.
48
+ #
49
+ # @return [Hash] parsed config, or empty hash if file is missing
24
50
  def self.load
25
51
  return {} unless File.exist?(config_path)
26
52
  YAML.safe_load_file(config_path, permitted_classes: [Symbol]) || {}
27
53
  end
28
54
 
55
+ # Write config data to disk (mode 0600).
56
+ #
57
+ # @param data [Hash] the config hash to persist
58
+ # @return [void]
29
59
  def self.save(data)
30
60
  FileUtils.mkdir_p(root_path, mode: 0o700)
31
61
  File.write(config_path, YAML.dump(data))
32
62
  File.chmod(0o600, config_path)
33
63
  end
34
64
 
65
+ # Name of the default vault.
66
+ #
67
+ # @return [String] vault name, defaults to "default"
35
68
  def self.default_vault
36
69
  load.fetch("default_vault", "default")
37
70
  end
38
71
 
72
+ # Set the default vault name.
73
+ #
74
+ # @param name [String] the vault name to use as default
75
+ # @return [void]
39
76
  def self.default_vault=(name)
40
77
  data = load
41
78
  data["default_vault"] = name
42
79
  save(data)
43
80
  end
44
81
 
82
+ # Create the root, vaults, and keys directories if they don't exist (mode 0700).
83
+ #
84
+ # @return [void]
45
85
  def self.ensure_directories!
46
86
  FileUtils.mkdir_p(root_path, mode: 0o700)
47
87
  FileUtils.mkdir_p(vaults_path, mode: 0o700)
48
88
  FileUtils.mkdir_p(keys_path, mode: 0o700)
49
89
  end
50
90
 
91
+ # Read the API authentication token.
92
+ #
93
+ # @return [String, nil] the stored token, or nil
51
94
  def self.token
52
95
  load["token"]
53
96
  end
54
97
 
98
+ # Set the API authentication token.
99
+ #
100
+ # @param t [String] the token to store
101
+ # @return [void]
55
102
  def self.token=(t)
56
103
  data = load
57
104
  data["token"] = t
58
105
  save(data)
59
106
  end
60
107
 
108
+ # Read the InventList user handle.
109
+ #
110
+ # @return [String, nil] the stored handle, or nil
61
111
  def self.inventlist_handle
62
112
  load["inventlist_handle"]
63
113
  end
64
114
 
115
+ # Set the InventList user handle.
116
+ #
117
+ # @param h [String] the handle to store
118
+ # @return [void]
65
119
  def self.inventlist_handle=(h)
66
120
  data = load
67
121
  data["inventlist_handle"] = h
68
122
  save(data)
69
123
  end
70
124
 
125
+ # Read the InventList API base URL.
126
+ #
127
+ # @return [String] the API URL, defaults to "https://inventlist.com"
71
128
  def self.api_url
72
129
  load.fetch("api_url", "https://inventlist.com")
73
130
  end
74
131
 
132
+ # Set the InventList API base URL.
133
+ #
134
+ # @param url [String] the API URL to store
135
+ # @return [void]
75
136
  def self.api_url=(url)
76
137
  data = load
77
138
  data["api_url"] = url
@@ -22,7 +22,20 @@ rescue LoadError => e
22
22
  end
23
23
 
24
24
  module LocalVault
25
+ # Cryptographic primitives for vault encryption and key derivation.
26
+ #
27
+ # Uses libsodium (via RbNaCl) exclusively:
28
+ # - Argon2id for passphrase → master key derivation (memory-hard KDF)
29
+ # - XSalsa20-Poly1305 for authenticated symmetric encryption
30
+ # - X25519 for asymmetric keypair generation (used by Identity + KeySlot)
31
+ #
32
+ # @example Derive a master key and encrypt
33
+ # salt = Crypto.generate_salt
34
+ # key = Crypto.derive_master_key("my passphrase", salt)
35
+ # ct = Crypto.encrypt("secret data", key)
36
+ # Crypto.decrypt(ct, key) # => "secret data"
25
37
  module Crypto
38
+ # Raised when decryption fails (wrong key, tampered data, or corrupt ciphertext).
26
39
  class DecryptionError < StandardError; end
27
40
 
28
41
  SALT_BYTES = 16
@@ -33,10 +46,18 @@ module LocalVault
33
46
  ARGON2_OPSLIMIT = 2
34
47
  ARGON2_MEMLIMIT = 67_108_864 # 64 MB
35
48
 
49
+ # Generate a random salt for key derivation.
50
+ #
51
+ # @return [String] 16 random bytes
36
52
  def self.generate_salt
37
53
  RbNaCl::Random.random_bytes(SALT_BYTES)
38
54
  end
39
55
 
56
+ # Derive a 32-byte master key from a passphrase using Argon2id.
57
+ #
58
+ # @param passphrase [String] the user's passphrase
59
+ # @param salt [String] 16-byte salt
60
+ # @return [String] 32-byte derived key
40
61
  def self.derive_master_key(passphrase, salt)
41
62
  RbNaCl::PasswordHash.argon2id(
42
63
  passphrase,
@@ -47,6 +68,11 @@ module LocalVault
47
68
  )
48
69
  end
49
70
 
71
+ # Encrypt plaintext with XSalsa20-Poly1305. Prepends a random nonce.
72
+ #
73
+ # @param plaintext [String] data to encrypt
74
+ # @param key [String] 32-byte symmetric key
75
+ # @return [String] nonce (24 bytes) + ciphertext
50
76
  def self.encrypt(plaintext, key)
51
77
  box = RbNaCl::SecretBox.new(key)
52
78
  nonce = RbNaCl::Random.random_bytes(NONCE_BYTES)
@@ -54,6 +80,12 @@ module LocalVault
54
80
  nonce + ciphertext
55
81
  end
56
82
 
83
+ # Decrypt ciphertext produced by +encrypt+. Expects nonce prepended.
84
+ #
85
+ # @param ciphertext_with_nonce [String] nonce (24 bytes) + ciphertext
86
+ # @param key [String] 32-byte symmetric key
87
+ # @return [String] decrypted plaintext
88
+ # @raise [DecryptionError] when the key is wrong or data is tampered
57
89
  def self.decrypt(ciphertext_with_nonce, key)
58
90
  box = RbNaCl::SecretBox.new(key)
59
91
  nonce = ciphertext_with_nonce[0, NONCE_BYTES]
@@ -63,6 +95,9 @@ module LocalVault
63
95
  raise DecryptionError, "Decryption failed: #{e.message}"
64
96
  end
65
97
 
98
+ # Generate an X25519 keypair for asymmetric encryption.
99
+ #
100
+ # @return [Hash{Symbol => String}] +:public_key+ and +:private_key+ as raw bytes
66
101
  def self.generate_keypair
67
102
  sk = RbNaCl::PrivateKey.generate
68
103
  {
@@ -71,10 +106,21 @@ module LocalVault
71
106
  }
72
107
  end
73
108
 
109
+ # Encrypt a private key with a master key (convenience wrapper around +encrypt+).
110
+ #
111
+ # @param private_key_bytes [String] raw private key bytes
112
+ # @param master_key [String] 32-byte symmetric key
113
+ # @return [String] nonce + ciphertext
74
114
  def self.encrypt_private_key(private_key_bytes, master_key)
75
115
  encrypt(private_key_bytes, master_key)
76
116
  end
77
117
 
118
+ # Decrypt a private key with a master key (convenience wrapper around +decrypt+).
119
+ #
120
+ # @param encrypted_bytes [String] nonce + ciphertext from +encrypt_private_key+
121
+ # @param master_key [String] 32-byte symmetric key
122
+ # @return [String] raw private key bytes
123
+ # @raise [DecryptionError] when the key is wrong or data is tampered
78
124
  def self.decrypt_private_key(encrypted_bytes, master_key)
79
125
  decrypt(encrypted_bytes, master_key)
80
126
  end
@@ -2,14 +2,43 @@ require "base64"
2
2
  require "fileutils"
3
3
 
4
4
  module LocalVault
5
+ # Manages the user's X25519 identity keypair for vault sharing and sync.
6
+ #
7
+ # The keypair is stored in +~/.localvault/keys/+:
8
+ # - +identity.priv+ (mode 0600) — base64-encoded private key
9
+ # - +identity.pub+ (mode 0644) — base64-encoded public key
10
+ #
11
+ # The public key is published to InventList so others can encrypt
12
+ # key slots for you. The private key never leaves the local machine.
13
+ #
14
+ # @example
15
+ # Identity.generate!
16
+ # Identity.public_key # => "base64..."
17
+ # Identity.private_key_bytes # => 32 raw bytes
18
+ # Identity.setup? # => true (if keypair + token exist)
5
19
  module Identity
20
+ # Path to the private key file.
21
+ #
22
+ # @return [String] absolute path to +identity.priv+
6
23
  def self.priv_key_path = File.join(Config.keys_path, "identity.priv")
24
+
25
+ # Path to the public key file.
26
+ #
27
+ # @return [String] absolute path to +identity.pub+
7
28
  def self.pub_key_path = File.join(Config.keys_path, "identity.pub")
8
29
 
30
+ # Check whether both key files exist on disk.
31
+ #
32
+ # @return [Boolean] true if both +identity.priv+ and +identity.pub+ exist
9
33
  def self.exists?
10
34
  File.exist?(priv_key_path) && File.exist?(pub_key_path)
11
35
  end
12
36
 
37
+ # Generate a new X25519 identity keypair and write to disk.
38
+ #
39
+ # @param force [Boolean] overwrite an existing keypair if true
40
+ # @return [Hash{Symbol => String}] +:public_key+ and +:private_key+ as raw bytes
41
+ # @raise [RuntimeError] when keypair exists and +force+ is false
13
42
  def self.generate!(force: false)
14
43
  raise "Keypair already exists. Use --force to overwrite." if exists? && !force
15
44
 
@@ -23,21 +52,33 @@ module LocalVault
23
52
  kp
24
53
  end
25
54
 
55
+ # Read the public key as a base64-encoded string.
56
+ #
57
+ # @return [String, nil] base64 public key, or nil if not generated
26
58
  def self.public_key
27
59
  return nil unless File.exist?(pub_key_path)
28
60
  File.read(pub_key_path).strip
29
61
  end
30
62
 
63
+ # Read the private key as a base64-encoded string.
64
+ #
65
+ # @return [String, nil] base64 private key, or nil if not generated
31
66
  def self.private_key_b64
32
67
  return nil unless File.exist?(priv_key_path)
33
68
  File.read(priv_key_path).strip
34
69
  end
35
70
 
71
+ # Read the private key as raw bytes (decoded from base64).
72
+ #
73
+ # @return [String, nil] 32 raw bytes, or nil if not generated
36
74
  def self.private_key_bytes
37
75
  b64 = private_key_b64
38
76
  b64 ? Base64.strict_decode64(b64) : nil
39
77
  end
40
78
 
79
+ # Check whether identity is fully configured (keypair exists and token is set).
80
+ #
81
+ # @return [Boolean] true if keypair exists and an API token is configured
41
82
  def self.setup?
42
83
  exists? && !Config.token.nil? && !Config.token.empty?
43
84
  end
@@ -2,12 +2,28 @@ require "rbnacl"
2
2
  require "base64"
3
3
 
4
4
  module LocalVault
5
+ # Encrypts/decrypts a vault's master key for a specific user's X25519 public key.
6
+ #
7
+ # Key slots enable multi-user vault access via sync. Each authorized user
8
+ # has a slot containing the vault's master key encrypted to their public key.
9
+ # Uses an ephemeral sender keypair (X25519 Box) — same construction as ShareCrypto.
10
+ #
11
+ # @example Create and decrypt a key slot
12
+ # slot = KeySlot.create(master_key, recipient_pub_b64)
13
+ # recovered = KeySlot.decrypt(slot, recipient_priv_bytes)
14
+ # recovered == master_key # => true
5
15
  module KeySlot
16
+ # Raised when decryption fails (wrong key, tampered data, or invalid format).
6
17
  class DecryptionError < StandardError; end
7
18
 
8
19
  # Encrypt a master key for a recipient's X25519 public key.
9
- # Returns a base64-encoded ciphertext string.
10
- # Uses an ephemeral sender keypair (same Box construction as ShareCrypto).
20
+ #
21
+ # Uses an ephemeral sender keypair so the recipient can decrypt
22
+ # without knowing who sent it.
23
+ #
24
+ # @param master_key [String] raw 32-byte master key to encrypt
25
+ # @param recipient_pub_key_b64 [String] base64-encoded X25519 public key
26
+ # @return [String] base64-encoded JSON payload containing the encrypted key slot
11
27
  def self.create(master_key, recipient_pub_key_b64)
12
28
  recipient_pub = RbNaCl::PublicKey.new(Base64.strict_decode64(recipient_pub_key_b64))
13
29
  ephemeral_sk = RbNaCl::PrivateKey.generate
@@ -25,7 +41,11 @@ module LocalVault
25
41
  end
26
42
 
27
43
  # Decrypt a key slot using the recipient's private key.
28
- # Returns the raw master key bytes.
44
+ #
45
+ # @param slot_b64 [String] base64-encoded key slot from +create+
46
+ # @param my_private_key_bytes [String] raw 32-byte X25519 private key
47
+ # @return [String] raw master key bytes
48
+ # @raise [DecryptionError] when the key is wrong, data is tampered, or format is invalid
29
49
  def self.decrypt(slot_b64, my_private_key_bytes)
30
50
  raw = Base64.strict_decode64(slot_b64)
31
51
  payload = JSON.parse(raw)
@@ -11,6 +11,10 @@ require_relative "tools"
11
11
  module LocalVault
12
12
  module MCP
13
13
  class Server
14
+ # Create an MCP server reading JSON-RPC from input, writing responses to output.
15
+ #
16
+ # @param input [IO] input stream for JSON-RPC messages (default: $stdin)
17
+ # @param output [IO] output stream for JSON-RPC responses (default: $stdout)
14
18
  def initialize(input: $stdin, output: $stdout)
15
19
  @input = input
16
20
  @output = output
@@ -18,6 +22,12 @@ module LocalVault
18
22
  @session_vault = load_session_vault # LOCALVAULT_SESSION fast-path
19
23
  end
20
24
 
25
+ # Start the MCP server loop, reading JSON-RPC messages line-by-line.
26
+ #
27
+ # Logs available unlocked vaults to stderr on startup. Blocks until
28
+ # input is exhausted or interrupted (Ctrl-C).
29
+ #
30
+ # @return [void]
21
31
  def start
22
32
  unlocked = unlocked_vault_names
23
33
  label = unlocked.empty? ? "no unlocked vaults (run: localvault show)" : "vaults=#{unlocked.join(', ')}"
@@ -41,6 +51,13 @@ module LocalVault
41
51
  $stderr.flush
42
52
  end
43
53
 
54
+ # Parse and dispatch a single JSON-RPC message.
55
+ #
56
+ # Handles +initialize+, +tools/list+, and +tools/call+ methods.
57
+ # Notifications (no "id" field) return nil.
58
+ #
59
+ # @param json_string [String] raw JSON-RPC message
60
+ # @return [Hash, nil] JSON-RPC response hash, or nil for notifications
44
61
  def handle_message(json_string)
45
62
  message = JSON.parse(json_string)
46
63
 
@@ -10,6 +10,8 @@ module LocalVault
10
10
  }
11
11
  }.freeze
12
12
 
13
+ # MCP tool definitions conforming to the MCP tools/list schema.
14
+ # Each entry specifies a tool name, description, and JSON Schema for input.
13
15
  DEFINITIONS = [
14
16
  {
15
17
  "name" => "get_secret",
@@ -59,7 +61,17 @@ module LocalVault
59
61
  }
60
62
  ].freeze
61
63
 
62
- # vault_resolver: callable that takes a vault name (String or nil) and returns a Vault or nil
64
+ # Dispatch an MCP tool call by name.
65
+ #
66
+ # Resolves the target vault via the provided callable, then executes the
67
+ # requested tool (get_secret, list_secrets, set_secret, or delete_secret).
68
+ #
69
+ # @param name [String] tool name (must match a DEFINITIONS entry)
70
+ # @param arguments [Hash] tool arguments (e.g. {"key" => "API_KEY", "vault" => "prod"})
71
+ # @param vault_resolver [#call] callable that accepts a vault name (String or nil)
72
+ # and returns a Vault instance or nil
73
+ # @return [Hash] MCP content result with "content" array and optional "isError"
74
+ # @raise [ArgumentError] if the tool name is unknown
63
75
  def self.call(name, arguments, vault_resolver)
64
76
  unless DEFINITIONS.any? { |t| t["name"] == name }
65
77
  raise ArgumentError, "Unknown tool: #{name}"
@@ -3,16 +3,32 @@ require "fileutils"
3
3
  require "shellwords"
4
4
 
5
5
  module LocalVault
6
- # Caches derived master keys in macOS Keychain (or a fallback file store).
7
- # Avoids re-prompting passphrase on every command.
6
+ # Caches derived master keys to avoid re-prompting passphrase on every command.
8
7
  #
9
- # Stored payload: "<base64_key>|<expiry_unix_ts>"
10
- # macOS: Keychain service "localvault", account: vault name
11
- # Linux/other: ~/.localvault/.sessions/<vault_name> (mode 0600)
8
+ # On macOS, uses the system Keychain (service "localvault", account = vault name).
9
+ # Falls back to file-based cache at +~/.localvault/.sessions/+ (mode 0600)
10
+ # when Keychain is unavailable (CI, sandboxed, Linux).
11
+ #
12
+ # Entries expire after +DEFAULT_TTL_HOURS+ (8 hours). Expired entries are
13
+ # cleaned up on read.
14
+ #
15
+ # Stored payload format: +"<base64_key>|<expiry_unix_ts>"+
16
+ #
17
+ # @example
18
+ # SessionCache.set("production", master_key)
19
+ # SessionCache.get("production") # => master_key bytes (or nil if expired)
20
+ # SessionCache.clear("production")
12
21
  module SessionCache
13
22
  DEFAULT_TTL_HOURS = 8
14
23
  KEYCHAIN_SERVICE = "localvault"
15
24
 
25
+ # Retrieve a cached master key for the given vault.
26
+ #
27
+ # Returns nil if no entry exists or the entry has expired. Expired entries
28
+ # are automatically cleaned up.
29
+ #
30
+ # @param vault_name [String] the vault name to look up
31
+ # @return [String, nil] raw master key bytes, or nil if not cached/expired
16
32
  def self.get(vault_name)
17
33
  payload = keychain_get(vault_name)
18
34
  return nil unless payload
@@ -31,16 +47,29 @@ module LocalVault
31
47
  nil
32
48
  end
33
49
 
50
+ # Cache a master key for the given vault with a time-to-live.
51
+ #
52
+ # @param vault_name [String] the vault name to cache
53
+ # @param master_key [String] raw master key bytes to store
54
+ # @param ttl_hours [Integer] hours until expiry (default: 8)
55
+ # @return [void]
34
56
  def self.set(vault_name, master_key, ttl_hours: DEFAULT_TTL_HOURS)
35
57
  expiry = Time.now.to_i + (ttl_hours * 3600).to_i
36
58
  payload = "#{Base64.strict_encode64(master_key)}|#{expiry}"
37
59
  keychain_set(vault_name, payload)
38
60
  end
39
61
 
62
+ # Remove the cached master key for a single vault.
63
+ #
64
+ # @param vault_name [String] the vault name to clear
65
+ # @return [void]
40
66
  def self.clear(vault_name)
41
67
  keychain_delete(vault_name)
42
68
  end
43
69
 
70
+ # Remove cached master keys for all known vaults.
71
+ #
72
+ # @return [void]
44
73
  def self.clear_all
45
74
  Store.list_vaults.each { |name| clear(name) }
46
75
  end
@@ -87,8 +116,7 @@ module LocalVault
87
116
  # Fall back to file store if Keychain fails (e.g., in CI or sandboxed env)
88
117
  unless success
89
118
  file = session_file(vault_name)
90
- File.write(file, payload)
91
- File.chmod(0o600, file)
119
+ File.write(file, payload, perm: 0o600)
92
120
  end
93
121
  else
94
122
  keychain_delete(vault_name)
@@ -3,13 +3,31 @@ require "base64"
3
3
  require "json"
4
4
 
5
5
  module LocalVault
6
+ # Asymmetric encryption for one-time vault sharing (direct share model).
7
+ #
8
+ # Encrypts a secrets hash for a recipient using their X25519 public key.
9
+ # Uses an ephemeral sender keypair so the sender's identity key is never
10
+ # transmitted. The recipient decrypts with their private key.
11
+ #
12
+ # This is used for the +localvault share --with @handle+ flow (one-time
13
+ # handoff). For ongoing team access, see KeySlot.
14
+ #
15
+ # @example Encrypt and decrypt a share
16
+ # payload = ShareCrypto.encrypt_for({"KEY" => "val"}, recipient_pub_b64)
17
+ # secrets = ShareCrypto.decrypt_from(payload, recipient_priv_bytes)
6
18
  module ShareCrypto
19
+ # Raised when decryption fails (wrong key, tampered payload, or invalid format).
7
20
  class DecryptionError < StandardError; end
8
21
 
9
22
  # Encrypt a secrets hash for a recipient using their X25519 public key.
10
- # Uses an ephemeral sender keypair (Box construction) so the sender's
11
- # identity private key is never transmitted.
12
- # Returns a base64-encoded JSON blob.
23
+ #
24
+ # Uses an ephemeral sender keypair (NaCl Box construction) so the sender's
25
+ # identity private key is never transmitted. The returned blob contains the
26
+ # ephemeral public key, nonce, and ciphertext.
27
+ #
28
+ # @param secrets [Hash] key-value pairs to encrypt (e.g. {"API_KEY" => "sk-..."})
29
+ # @param recipient_pub_key_b64 [String] recipient's X25519 public key, base64-encoded
30
+ # @return [String] base64-encoded JSON payload containing sender_pub, nonce, and ciphertext
13
31
  def self.encrypt_for(secrets, recipient_pub_key_b64)
14
32
  recipient_pub = RbNaCl::PublicKey.new(Base64.strict_decode64(recipient_pub_key_b64))
15
33
  ephemeral_sk = RbNaCl::PrivateKey.generate
@@ -27,8 +45,15 @@ module LocalVault
27
45
  Base64.strict_encode64(JSON.generate(payload))
28
46
  end
29
47
 
30
- # Decrypt an encrypted_payload using the recipient's private key.
31
- # Returns the decrypted secrets hash { "KEY" => "value", ... }.
48
+ # Decrypt a shared payload using the recipient's private key.
49
+ #
50
+ # Reverses the envelope produced by {.encrypt_for}, extracting the ephemeral
51
+ # sender public key and using NaCl Box to decrypt.
52
+ #
53
+ # @param encrypted_payload_b64 [String] base64-encoded payload from {.encrypt_for}
54
+ # @param my_private_key_bytes [String] recipient's raw X25519 private key bytes
55
+ # @return [Hash] decrypted secrets (e.g. {"API_KEY" => "sk-..."})
56
+ # @raise [DecryptionError] when the key is wrong, payload is tampered, or format is invalid
32
57
  def self.decrypt_from(encrypted_payload_b64, my_private_key_bytes)
33
58
  raw = Base64.strict_decode64(encrypted_payload_b64)
34
59
  payload = JSON.parse(raw)