seekrit-sdk 0.1.0
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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +96 -0
- data/lib/seekrit/client.rb +104 -0
- data/lib/seekrit/crypto.rb +159 -0
- data/lib/seekrit/errors.rb +26 -0
- data/lib/seekrit/railtie.rb +27 -0
- data/lib/seekrit/sdk.rb +6 -0
- data/lib/seekrit/version.rb +5 -0
- data/lib/seekrit.rb +19 -0
- data/testdata/vectors.json +65 -0
- metadata +63 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ba399e476a1d0cf22163313d57429d3e0755a0abe529a0d6753c5aa15b59d5f1
|
|
4
|
+
data.tar.gz: 5a4d6d97926a59c8d69dae239e5de3ad8ff3cba09918cc90c04acfcbc6b5766e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7392e7806c33b25da0c2667c6fab4bee773d557dfac3865a2cb6cc0e65298e5138ad3bd10996fbfc9f423303d74447d108b3fabe5e762edf6155ac22512ba557
|
|
7
|
+
data.tar.gz: 413ce7dc3f15b63ad2d16e5baac4bdf5fa723f3f3375df590c3922da733302477493f5de8237fa39c7a92d7feef3011059bb9f9b84179a9ab1ef74856fa90fcd
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 seekrit
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# seekrit — Ruby SDK
|
|
2
|
+
|
|
3
|
+
Read-path SDK for [seekrit](https://seekrit.dev). Authenticate with a service
|
|
4
|
+
token, resolve your environment, and get **decrypted** secrets — the API only
|
|
5
|
+
ever returns ciphertext; decryption happens in your process.
|
|
6
|
+
|
|
7
|
+
> This repo is a **read-only mirror** published from seekrit's monorepo so the
|
|
8
|
+
> code that holds your token and decrypts plaintext is auditable. Don't commit
|
|
9
|
+
> here — it's overwritten on each sync. Issues and PRs welcome.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
gem install seekrit-sdk
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or in a Gemfile:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem "seekrit-sdk"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Requires Ruby 3.0+. **No runtime dependencies** — uses only the standard
|
|
24
|
+
library (`openssl`, `net/http`, `json`).
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
The gem is `seekrit-sdk`; the module is `Seekrit`. Require it as `seekrit/sdk`
|
|
29
|
+
(or `seekrit` — both work):
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
require "seekrit/sdk"
|
|
33
|
+
|
|
34
|
+
client = Seekrit::Client.new # token from ENV["SEEKRIT_TOKEN"]
|
|
35
|
+
secrets = client.resolve # { "DATABASE_URL" => "postgres://…", … }
|
|
36
|
+
|
|
37
|
+
db_url = client.get("DATABASE_URL")
|
|
38
|
+
api_key = client.get("API_KEY", "")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Load everything into `ENV`:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
Seekrit.load_env! # existing ENV vars win by default
|
|
45
|
+
ENV["DATABASE_URL"] # => "postgres://…"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Rails
|
|
49
|
+
|
|
50
|
+
The gem ships a Railtie. Opt in from `config/application.rb` or an initializer:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
config.seekrit.autoload = true # load $SEEKRIT_TOKEN's secrets into ENV on boot
|
|
54
|
+
config.seekrit.override = false # keep existing ENV vars (default)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Nothing hits the network at boot unless `autoload` is set.
|
|
58
|
+
|
|
59
|
+
### Configuration
|
|
60
|
+
|
|
61
|
+
| Keyword | Env var | Default |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| `token:` | `SEEKRIT_TOKEN` | — (required) |
|
|
64
|
+
| `api_url:` | `SEEKRIT_API_URL` | `https://api.seekrit.dev` |
|
|
65
|
+
| `with:` | — | `{}` |
|
|
66
|
+
| `open_timeout:` / `read_timeout:` | — | `10` / `30` (seconds) |
|
|
67
|
+
|
|
68
|
+
A service token binds to a single app environment (plus its composed group
|
|
69
|
+
slices). Pass `with:` to pull a different environment slice of a composed group:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
Seekrit::Client.new(with: { "shared" => "dev" }).resolve
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Errors
|
|
76
|
+
|
|
77
|
+
- `Seekrit::ApiError` — non-2xx from the API; has `#status` and `#code`
|
|
78
|
+
(`"unauthorized"`, `"forbidden"`, `"not_found"`, …).
|
|
79
|
+
- `Seekrit::CryptoError` — a token or ciphertext could not be parsed/decrypted.
|
|
80
|
+
- `Seekrit::Error` — base class (also covers network failures).
|
|
81
|
+
|
|
82
|
+
`#resolve` is **fail-closed**: any resolve or decrypt failure raises rather than
|
|
83
|
+
returning partial results.
|
|
84
|
+
|
|
85
|
+
## Zero-knowledge
|
|
86
|
+
|
|
87
|
+
`GET /v1/resolve` returns ciphertext plus a data-encryption key wrapped to your
|
|
88
|
+
token's public key. This SDK recovers the token's private key, unwraps the DEK
|
|
89
|
+
(ECDH P-256 → HKDF-SHA256 → AES-256-GCM), and decrypts each secret
|
|
90
|
+
(AES-256-GCM, AAD-bound to `environmentId/NAME`) — the exact scheme used by the
|
|
91
|
+
CLI, `seekrit run`, and every other seekrit client. See
|
|
92
|
+
[seekrit.dev/docs](https://seekrit.dev/docs/concepts/encryption).
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "crypto"
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
|
|
10
|
+
module Seekrit
|
|
11
|
+
# A read-only seekrit client bound to one service token. A service token
|
|
12
|
+
# selects exactly one app environment (plus its composed group slices).
|
|
13
|
+
#
|
|
14
|
+
# client = Seekrit::Client.new # token from ENV["SEEKRIT_TOKEN"]
|
|
15
|
+
# secrets = client.resolve # { "DATABASE_URL" => "...", ... }
|
|
16
|
+
class Client
|
|
17
|
+
DEFAULT_API_URL = "https://api.seekrit.dev"
|
|
18
|
+
|
|
19
|
+
# @param token [String, nil] skt_... service token; defaults to ENV["SEEKRIT_TOKEN"].
|
|
20
|
+
# @param api_url [String, nil] API base URL; defaults to ENV["SEEKRIT_API_URL"] or the hosted API.
|
|
21
|
+
# @param with [Hash] {group_slug => env_slug} overrides (the ?with= override).
|
|
22
|
+
# @param open_timeout [Numeric] connection timeout, seconds.
|
|
23
|
+
# @param read_timeout [Numeric] read timeout, seconds.
|
|
24
|
+
def initialize(token: nil, api_url: nil, with: {}, open_timeout: 10, read_timeout: 30)
|
|
25
|
+
@token = token || ENV["SEEKRIT_TOKEN"]
|
|
26
|
+
raise Error, "no service token: pass token: or set SEEKRIT_TOKEN" unless @token && !@token.empty?
|
|
27
|
+
|
|
28
|
+
@key = Crypto::TokenKey.parse(@token) # fail fast on a bad token
|
|
29
|
+
@api_url = (api_url || ENV["SEEKRIT_API_URL"] || DEFAULT_API_URL).sub(%r{/+\z}, "")
|
|
30
|
+
@with = with || {}
|
|
31
|
+
@open_timeout = open_timeout
|
|
32
|
+
@read_timeout = read_timeout
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Fetch, decrypt, and merge; returns { "NAME" => value }. Fail-closed.
|
|
36
|
+
# @return [Hash{String=>String}]
|
|
37
|
+
def resolve
|
|
38
|
+
Crypto.materialize(fetch, @key)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Resolve and return a single secret's value, or +default+ if absent.
|
|
42
|
+
def get(name, default = nil)
|
|
43
|
+
resolve.fetch(name, default)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Load resolved secrets into +env+ (default ENV). Existing keys are kept
|
|
47
|
+
# unless +override:+ is true. Returns the resolved secrets.
|
|
48
|
+
def into_env(env = ENV, override: false)
|
|
49
|
+
merged = resolve
|
|
50
|
+
merged.each do |name, value|
|
|
51
|
+
env[name] = value if override || !env.key?(name)
|
|
52
|
+
end
|
|
53
|
+
merged
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def fetch
|
|
59
|
+
uri = URI("#{@api_url}/v1/resolve")
|
|
60
|
+
unless @with.empty?
|
|
61
|
+
uri.query = URI.encode_www_form(@with.sort.map { |group, env| ["with", "#{group}:#{env}"] })
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
request = Net::HTTP::Get.new(uri)
|
|
65
|
+
request["authorization"] = "Bearer #{@token}"
|
|
66
|
+
request["accept"] = "application/json"
|
|
67
|
+
|
|
68
|
+
response = Net::HTTP.start(
|
|
69
|
+
uri.host, uri.port,
|
|
70
|
+
use_ssl: uri.scheme == "https",
|
|
71
|
+
open_timeout: @open_timeout, read_timeout: @read_timeout
|
|
72
|
+
) { |http| http.request(request) }
|
|
73
|
+
|
|
74
|
+
status = response.code.to_i
|
|
75
|
+
raise api_error(status, response.body) unless (200..299).cover?(status)
|
|
76
|
+
|
|
77
|
+
JSON.parse(response.body)
|
|
78
|
+
rescue SocketError, Timeout::Error, IOError => e
|
|
79
|
+
raise Error, "resolve request failed: #{e.message}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def api_error(status, body)
|
|
83
|
+
code = "internal"
|
|
84
|
+
message = "HTTP #{status}"
|
|
85
|
+
begin
|
|
86
|
+
error = JSON.parse(body)["error"]
|
|
87
|
+
if error.is_a?(Hash) && error["code"]
|
|
88
|
+
code = error["code"]
|
|
89
|
+
message = error["message"] || message
|
|
90
|
+
end
|
|
91
|
+
rescue JSON::ParserError, TypeError
|
|
92
|
+
# non-JSON body — keep the fallback
|
|
93
|
+
end
|
|
94
|
+
ApiError.new(status, code, message)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Convenience: load a token's secrets straight into ENV.
|
|
99
|
+
# Seekrit.load_env!
|
|
100
|
+
def self.load_env!(**opts)
|
|
101
|
+
override = opts.delete(:override) { false }
|
|
102
|
+
Client.new(**opts).into_env(ENV, override: override)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
module Seekrit
|
|
7
|
+
# Zero-knowledge read path, byte-compatible with @seekrit/crypto and
|
|
8
|
+
# crates/seekrit-core. Three steps:
|
|
9
|
+
#
|
|
10
|
+
# 1. Recover the service token's P-256 private key (TokenKey.parse).
|
|
11
|
+
# 2. Unwrap each environment DEK (TokenKey#unwrap_dek).
|
|
12
|
+
# 3. Decrypt each secret (Crypto.decrypt_secret).
|
|
13
|
+
#
|
|
14
|
+
# Blob formats (all segments base64url, no padding):
|
|
15
|
+
# token skt_<id>_<pkcs8 private key>
|
|
16
|
+
# wrapped DEK wd1.<ephemeral pub (raw SEC1)>.<hkdf salt>.<iv>.<ciphertext||tag>
|
|
17
|
+
# secret sc1.<iv>.<ciphertext||tag>
|
|
18
|
+
module Crypto
|
|
19
|
+
HKDF_INFO = "seekrit/wrap-dek/v1"
|
|
20
|
+
CURVE = "prime256v1" # NIST P-256 / secp256r1
|
|
21
|
+
# Only the first two underscores are separators; the key segment may contain "_".
|
|
22
|
+
TOKEN_RE = /\A(skt_[0-9A-Za-z]+)_([A-Za-z0-9_-]+)\z/.freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def b64url_decode(text)
|
|
27
|
+
Base64.urlsafe_decode64(text + ("=" * ((4 - (text.length % 4)) % 4)))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def split_blob(blob, prefix, segments)
|
|
31
|
+
parts = blob.split(".")
|
|
32
|
+
unless parts[0] == prefix
|
|
33
|
+
raise CryptoError, %(expected a "#{prefix}" blob, got "#{parts[0]}")
|
|
34
|
+
end
|
|
35
|
+
if parts.length != segments + 1 || parts.any?(&:empty?)
|
|
36
|
+
raise CryptoError, %(malformed "#{prefix}" blob)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
parts[1..]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# AAD binding a secret ciphertext to its environment and name.
|
|
43
|
+
def secret_aad(environment_id, name)
|
|
44
|
+
"#{environment_id}/#{name}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# HKDF-SHA256 (RFC 5869), implemented with HMAC so it works on every Ruby /
|
|
48
|
+
# OpenSSL / LibreSSL build regardless of OpenSSL::KDF.hkdf availability.
|
|
49
|
+
def hkdf_sha256(ikm, salt, info, length)
|
|
50
|
+
prk = OpenSSL::HMAC.digest("SHA256", salt, ikm)
|
|
51
|
+
okm = +""
|
|
52
|
+
block = +""
|
|
53
|
+
counter = 0
|
|
54
|
+
while okm.bytesize < length
|
|
55
|
+
counter += 1
|
|
56
|
+
block = OpenSSL::HMAC.digest("SHA256", prk, block + info + [counter].pack("C"))
|
|
57
|
+
okm << block
|
|
58
|
+
end
|
|
59
|
+
okm[0, length]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def aes_gcm_decrypt(key, iv, ciphertext_and_tag, aad)
|
|
63
|
+
raise CryptoError, "ciphertext too short" if ciphertext_and_tag.bytesize < 16
|
|
64
|
+
|
|
65
|
+
tag = ciphertext_and_tag[-16, 16]
|
|
66
|
+
ciphertext = ciphertext_and_tag[0...-16]
|
|
67
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
68
|
+
cipher.decrypt
|
|
69
|
+
cipher.key = key
|
|
70
|
+
cipher.iv = iv
|
|
71
|
+
cipher.auth_tag = tag
|
|
72
|
+
# GCM with no AAD == empty AAD; skip the call so a zero-length AAD never
|
|
73
|
+
# trips broken bindings (e.g. some LibreSSL builds).
|
|
74
|
+
cipher.auth_data = aad unless aad.nil? || aad.empty?
|
|
75
|
+
cipher.update(ciphertext) + cipher.final
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The P-256 private key recovered from a skt_... service token.
|
|
79
|
+
class TokenKey
|
|
80
|
+
attr_reader :token_id
|
|
81
|
+
|
|
82
|
+
def initialize(token_id, ec_key)
|
|
83
|
+
@token_id = token_id
|
|
84
|
+
@ec_key = ec_key
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.parse(token)
|
|
88
|
+
match = Crypto::TOKEN_RE.match(token)
|
|
89
|
+
raise CryptoError, "not a valid seekrit service token" unless match
|
|
90
|
+
|
|
91
|
+
der = Crypto.b64url_decode(match[2])
|
|
92
|
+
ec_key = begin
|
|
93
|
+
key = OpenSSL::PKey.read(der)
|
|
94
|
+
raise CryptoError, "service token key is not an EC private key" unless key.is_a?(OpenSSL::PKey::EC)
|
|
95
|
+
|
|
96
|
+
key
|
|
97
|
+
rescue OpenSSL::PKey::PKeyError, ArgumentError
|
|
98
|
+
begin
|
|
99
|
+
OpenSSL::PKey::EC.new(der)
|
|
100
|
+
rescue StandardError
|
|
101
|
+
raise CryptoError, "service token private key is corrupted"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
new(match[1], ec_key)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Recover a 32-byte environment DEK from a wd1. blob.
|
|
108
|
+
def unwrap_dek(wrapped)
|
|
109
|
+
eph_b64, salt_b64, iv_b64, ct_b64 = Crypto.split_blob(wrapped, "wd1", 4)
|
|
110
|
+
eph = Crypto.b64url_decode(eph_b64)
|
|
111
|
+
salt = Crypto.b64url_decode(salt_b64)
|
|
112
|
+
iv = Crypto.b64url_decode(iv_b64)
|
|
113
|
+
ct = Crypto.b64url_decode(ct_b64)
|
|
114
|
+
|
|
115
|
+
group = OpenSSL::PKey::EC::Group.new(Crypto::CURVE)
|
|
116
|
+
peer_point = OpenSSL::PKey::EC::Point.new(group, OpenSSL::BN.new(eph, 2))
|
|
117
|
+
shared = @ec_key.dh_compute_key(peer_point)
|
|
118
|
+
wrapping_key = Crypto.hkdf_sha256(shared, salt, Crypto::HKDF_INFO, 32)
|
|
119
|
+
|
|
120
|
+
dek = begin
|
|
121
|
+
Crypto.aes_gcm_decrypt(wrapping_key, iv, ct, "")
|
|
122
|
+
rescue OpenSSL::Cipher::CipherError
|
|
123
|
+
raise CryptoError, "DEK unwrap failed: wrong private key or tampered grant"
|
|
124
|
+
end
|
|
125
|
+
raise CryptoError, "unwrapped DEK has wrong length" unless dek.bytesize == 32
|
|
126
|
+
|
|
127
|
+
dek
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Decrypt an sc1. secret ciphertext to its UTF-8 plaintext.
|
|
132
|
+
def decrypt_secret(dek, blob, aad)
|
|
133
|
+
iv_b64, ct_b64 = split_blob(blob, "sc1", 2)
|
|
134
|
+
iv = b64url_decode(iv_b64)
|
|
135
|
+
ct = b64url_decode(ct_b64)
|
|
136
|
+
plaintext = begin
|
|
137
|
+
aes_gcm_decrypt(dek, iv, ct, aad)
|
|
138
|
+
rescue OpenSSL::Cipher::CipherError
|
|
139
|
+
raise CryptoError, "secret decryption failed: wrong key, tampered data, or mismatched context"
|
|
140
|
+
end
|
|
141
|
+
plaintext.force_encoding(Encoding::UTF_8)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Decrypt every layer and merge by precedence. Layers arrive lowest
|
|
145
|
+
# precedence first (composed groups, then the app environment); later layers
|
|
146
|
+
# overwrite earlier ones on a name collision.
|
|
147
|
+
def materialize(resolve_response, key)
|
|
148
|
+
merged = {}
|
|
149
|
+
resolve_response["layers"].each do |layer|
|
|
150
|
+
dek = key.unwrap_dek(layer["wrappedDek"])
|
|
151
|
+
env_id = layer["environmentId"]
|
|
152
|
+
layer["secrets"].each do |secret|
|
|
153
|
+
merged[secret["name"]] = decrypt_secret(dek, secret["ciphertext"], secret_aad(env_id, secret["name"]))
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
merged
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Seekrit
|
|
4
|
+
# Base class for every error raised by this SDK.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# A token, wrapped DEK, or secret ciphertext could not be parsed or decrypted.
|
|
8
|
+
# Deliberately unspecific: a decrypt failure does not distinguish "wrong key"
|
|
9
|
+
# from "tampered data" from "mismatched context".
|
|
10
|
+
class CryptoError < Error; end
|
|
11
|
+
|
|
12
|
+
# The resolve API returned a non-2xx response.
|
|
13
|
+
class ApiError < Error
|
|
14
|
+
# @return [Integer] HTTP status code.
|
|
15
|
+
attr_reader :status
|
|
16
|
+
# @return [String] error.code from the API body (e.g. "unauthorized",
|
|
17
|
+
# "forbidden", "not_found"), or "internal" if the body did not parse.
|
|
18
|
+
attr_reader :code
|
|
19
|
+
|
|
20
|
+
def initialize(status, code, message)
|
|
21
|
+
@status = status
|
|
22
|
+
@code = code
|
|
23
|
+
super("#{status} #{code}: #{message}")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "client"
|
|
4
|
+
|
|
5
|
+
module Seekrit
|
|
6
|
+
# Optional Rails integration. Loaded automatically when Rails is present
|
|
7
|
+
# (see lib/seekrit.rb). Opt-in: resolved secrets are only loaded into ENV if
|
|
8
|
+
# you enable it, so nothing hits the network at boot unless you ask.
|
|
9
|
+
#
|
|
10
|
+
# # config/application.rb (or an initializer)
|
|
11
|
+
# config.seekrit.autoload = true # load $SEEKRIT_TOKEN's secrets into ENV on boot
|
|
12
|
+
# config.seekrit.override = false # keep existing ENV vars (default)
|
|
13
|
+
#
|
|
14
|
+
# Or call it yourself whenever you like:
|
|
15
|
+
#
|
|
16
|
+
# Seekrit.load_env!
|
|
17
|
+
class Railtie < ::Rails::Railtie
|
|
18
|
+
config.seekrit = ActiveSupport::OrderedOptions.new
|
|
19
|
+
config.seekrit.autoload = false
|
|
20
|
+
config.seekrit.override = false
|
|
21
|
+
|
|
22
|
+
initializer "seekrit.load_env", before: :load_environment_config do |app|
|
|
23
|
+
cfg = app.config.seekrit
|
|
24
|
+
Seekrit.load_env!(override: cfg.override) if cfg.autoload && ENV["SEEKRIT_TOKEN"]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/seekrit/sdk.rb
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Entrypoint matching the `seekrit-sdk` gem name, so `require "seekrit/sdk"`
|
|
4
|
+
# and Bundler's auto-require of `gem "seekrit-sdk"` both work. `require "seekrit"`
|
|
5
|
+
# works too — both load the same code, and the module is `Seekrit`.
|
|
6
|
+
require_relative "../seekrit"
|
data/lib/seekrit.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "seekrit/version"
|
|
4
|
+
require_relative "seekrit/errors"
|
|
5
|
+
require_relative "seekrit/crypto"
|
|
6
|
+
require_relative "seekrit/client"
|
|
7
|
+
|
|
8
|
+
# seekrit — read-path SDK for the zero-knowledge secrets manager.
|
|
9
|
+
#
|
|
10
|
+
# require "seekrit"
|
|
11
|
+
#
|
|
12
|
+
# client = Seekrit::Client.new # token from ENV["SEEKRIT_TOKEN"]
|
|
13
|
+
# secrets = client.resolve # { "DATABASE_URL" => "...", ... }
|
|
14
|
+
#
|
|
15
|
+
# Secrets are decrypted in-process; the API only ever sees ciphertext.
|
|
16
|
+
module Seekrit
|
|
17
|
+
# Load the Rails integration only when Rails is present.
|
|
18
|
+
require_relative "seekrit/railtie" if defined?(::Rails::Railtie)
|
|
19
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"token": "skt_r36AUXMxoX0xkQoH8vimHN_MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg1bTaK96XZQzEUpfGe5dlc-sp3n7mF8WpZNVJweEJZ06hRANCAATgUoWLRDgyG0qsUzdQXzr1VSL-p9kSp_zhAULqF2qHUAWDPcuoX-f4JTUX6lyzPEs_0chQ9nKvgmEJUpXx9_q5",
|
|
3
|
+
"tokenId": "skt_r36AUXMxoX0xkQoH8vimHN",
|
|
4
|
+
"publicKeyJwk": "{\"key_ops\":[],\"ext\":true,\"kty\":\"EC\",\"x\":\"4FKFi0Q4MhtKrFM3UF869VUi_qfZEqf84QFC6hdqh1A\",\"y\":\"BYM9y6hf5_glNRfqXLM8Sz_RyFD2cq-CYQlSlfH3-rk\",\"crv\":\"P-256\"}",
|
|
5
|
+
"resolve": {
|
|
6
|
+
"scope": {
|
|
7
|
+
"orgId": "org_test",
|
|
8
|
+
"orgSlug": "acme",
|
|
9
|
+
"appId": "app_test",
|
|
10
|
+
"appSlug": "web",
|
|
11
|
+
"envId": "env_apptest0000000000001",
|
|
12
|
+
"envSlug": "production"
|
|
13
|
+
},
|
|
14
|
+
"layers": [
|
|
15
|
+
{
|
|
16
|
+
"source": "group",
|
|
17
|
+
"environmentId": "env_grouptest0000000001",
|
|
18
|
+
"slug": "production",
|
|
19
|
+
"groupSlug": "shared",
|
|
20
|
+
"wrappedDek": "wd1.BEoGSn3SoICO6tA13EjveZHvxTTRVijE0OUpBkzVkFU28q_jfgY4P9IkyrpjMVJK27zogh61rwC_NUBjoe2TKhY.5EI1On9xCtVuntRHMA6xFA.JNqiqNRxDpNN3hkP.8RxEujnGaEIUIVq9VVcOZlpC07-gxgyz25VCtffNZWSRh8J8iFfMA6p-_ubXSfv5",
|
|
21
|
+
"secrets": [
|
|
22
|
+
{
|
|
23
|
+
"name": "SHARED",
|
|
24
|
+
"ciphertext": "sc1.4XNyaS_ilRPDTlIg.LhAGzqP_cm4fx_dKeST8_3NITle_77x0Qjo"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "DATABASE_URL",
|
|
28
|
+
"ciphertext": "sc1.-THIwVQA7yVq6SWt.1RaVPddl8QDuhc2wValcmtArCxafHBCAk24Fv00gB_FNc6k"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "UNICODE",
|
|
32
|
+
"ciphertext": "sc1.7qB3RU8qhHKpqwbB.BsKeZYWDv4Qp9cWIB0I684pLazslk2nFtIq-OvgDjIaTaw3Pbw"
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"source": "app",
|
|
38
|
+
"environmentId": "env_apptest0000000000001",
|
|
39
|
+
"slug": "production",
|
|
40
|
+
"wrappedDek": "wd1.BMywXqAVq3Ee3XX7mGBt2KLcRRwPLQn9FjlXc19Z8HNdDPsg-KyKLwg_uz4tgQ8MdVevMgtjfBkOiqmAi7ENI2Y.FLBNZ3z8-8CnYoBN-Azr9g.2x8wkp6BSw9QjnG2.3b32qenHIHC92eCehRW6QGWNFTlIe3nTKPje0WsRFWSHkjV6PYIxCXmlQaAJJkp3",
|
|
41
|
+
"secrets": [
|
|
42
|
+
{
|
|
43
|
+
"name": "SHARED",
|
|
44
|
+
"ciphertext": "sc1.QF697pJ61KM9Xqj2.HH9PNlW7aIIWZvpLm6yc8ZCRRemlxoOS"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "API_KEY",
|
|
48
|
+
"ciphertext": "sc1.x4G_KqJP7ags8SBf.obw89lhKqcMAy1vzjdTjch88R7Trx9JtFWS2Rw"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "EMPTY",
|
|
52
|
+
"ciphertext": "sc1.qdub6o6QejpKH97e.KFe1fiDvtHF3agstKhL5ng"
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
"expectedManagedValues": {
|
|
59
|
+
"SHARED": "from-app",
|
|
60
|
+
"DATABASE_URL": "postgres://group/db",
|
|
61
|
+
"UNICODE": "héllo-🌍-\n-tab\tend",
|
|
62
|
+
"API_KEY": "sk-app-12345",
|
|
63
|
+
"EMPTY": ""
|
|
64
|
+
}
|
|
65
|
+
}
|
metadata
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: seekrit-sdk
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- seekrit
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-19 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: |
|
|
14
|
+
Read-path SDK for seekrit, the zero-knowledge secrets manager. Authenticate
|
|
15
|
+
with a service token, resolve your environment, and get decrypted secrets —
|
|
16
|
+
the API only ever returns ciphertext; decryption happens in your process.
|
|
17
|
+
Uses only the Ruby standard library (openssl, net/http, json). Optional Rails
|
|
18
|
+
integration included. Require as "seekrit" or "seekrit/sdk"; the module is
|
|
19
|
+
Seekrit.
|
|
20
|
+
email:
|
|
21
|
+
executables: []
|
|
22
|
+
extensions: []
|
|
23
|
+
extra_rdoc_files: []
|
|
24
|
+
files:
|
|
25
|
+
- LICENSE
|
|
26
|
+
- README.md
|
|
27
|
+
- lib/seekrit.rb
|
|
28
|
+
- lib/seekrit/client.rb
|
|
29
|
+
- lib/seekrit/crypto.rb
|
|
30
|
+
- lib/seekrit/errors.rb
|
|
31
|
+
- lib/seekrit/railtie.rb
|
|
32
|
+
- lib/seekrit/sdk.rb
|
|
33
|
+
- lib/seekrit/version.rb
|
|
34
|
+
- testdata/vectors.json
|
|
35
|
+
homepage: https://seekrit.dev
|
|
36
|
+
licenses:
|
|
37
|
+
- MIT
|
|
38
|
+
metadata:
|
|
39
|
+
homepage_uri: https://seekrit.dev
|
|
40
|
+
documentation_uri: https://seekrit.dev/docs
|
|
41
|
+
source_code_uri: https://github.com/seekritdev/ruby-sdk
|
|
42
|
+
rubygems_mfa_required: 'true'
|
|
43
|
+
post_install_message:
|
|
44
|
+
rdoc_options: []
|
|
45
|
+
require_paths:
|
|
46
|
+
- lib
|
|
47
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
48
|
+
requirements:
|
|
49
|
+
- - ">="
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '3.0'
|
|
52
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
53
|
+
requirements:
|
|
54
|
+
- - ">="
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: '0'
|
|
57
|
+
requirements: []
|
|
58
|
+
rubygems_version: 3.5.22
|
|
59
|
+
signing_key:
|
|
60
|
+
specification_version: 4
|
|
61
|
+
summary: Read-path SDK for seekrit — resolve and decrypt secrets client-side with
|
|
62
|
+
a service token.
|
|
63
|
+
test_files: []
|