seekrit-sdk 0.2.0 → 0.5.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 +4 -4
- data/README.md +22 -0
- data/lib/seekrit/client.rb +7 -2
- data/lib/seekrit/crypto.rb +19 -4
- data/lib/seekrit/errors.rb +13 -0
- data/lib/seekrit/interpolate.rb +130 -0
- data/lib/seekrit/version.rb +1 -1
- data/lib/seekrit.rb +1 -0
- data/testdata/vectors.json +382 -12
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 32c907a8f2923c14214ae739aaf8f183b7db26f001f3d3026a8d1ef0764c8fe1
|
|
4
|
+
data.tar.gz: 55c2748d6c1719bcfd0f98df7580664d4bbb1293582df109a446373e10151ae3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b7be6491946e39019f3fe2d4212df1aa9736e0145729396e24719b40420b126151cf4d97531b51962b51a8c1f73d70d0ad7c3b63d5ff8644be957949ecf98382
|
|
7
|
+
data.tar.gz: 9a281b0d885e7e887456f04474feafbf8c41e70d57a60cff07e920add154ac3fbcbe1f90f5ae8b56f1664e98adb9660fbfdfb9f647528d65140531589f05b893
|
data/README.md
CHANGED
|
@@ -82,6 +82,19 @@ Seekrit::Client.new(with: { "shared" => "dev" }).resolve
|
|
|
82
82
|
`#resolve` is **fail-closed**: any resolve or decrypt failure raises rather than
|
|
83
83
|
returning partial results.
|
|
84
84
|
|
|
85
|
+
## Secret references
|
|
86
|
+
|
|
87
|
+
A secret's value may reference another with `${OTHER_SECRET}`. References are
|
|
88
|
+
stored literally and expanded here, after the layers are merged — so a reference
|
|
89
|
+
picks up whichever layer won that name, and rotating the referenced secret
|
|
90
|
+
updates every value that uses it. `$${OTHER_SECRET}` is a literal; an unknown
|
|
91
|
+
name is left as written; a reference cycle raises. Full rules:
|
|
92
|
+
[seekrit.dev/docs/guides/references](https://seekrit.dev/docs/guides/references).
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
client = Seekrit::Client.new(interpolate: false) # get the stored text instead
|
|
96
|
+
```
|
|
97
|
+
|
|
85
98
|
## Zero-knowledge
|
|
86
99
|
|
|
87
100
|
`GET /v1/resolve` returns ciphertext plus a data-encryption key wrapped to your
|
|
@@ -94,3 +107,12 @@ CLI, `seekrit run`, and every other seekrit client. See
|
|
|
94
107
|
## License
|
|
95
108
|
|
|
96
109
|
MIT
|
|
110
|
+
|
|
111
|
+
## Service token v2
|
|
112
|
+
|
|
113
|
+
Keep one `SEEKRIT_TOKEN`: `skt_<id>_v2.<auth-secret>.<private-key>`.
|
|
114
|
+
The client sends only `skt_<id>_v2.<auth-secret>` to the API and uses the private
|
|
115
|
+
key locally to decrypt. Legacy tokens are rejected before any API request;
|
|
116
|
+
upgrade clients and replace legacy tokens through the dashboard. A stolen
|
|
117
|
+
HTTP credential cannot decrypt environment payloads, but stealing the full
|
|
118
|
+
local bundle still exposes the private key. Treat the full bundle as a secret.
|
data/lib/seekrit/client.rb
CHANGED
|
@@ -21,21 +21,26 @@ module Seekrit
|
|
|
21
21
|
# @param with [Hash] {group_slug => env_slug} overrides (the ?with= override).
|
|
22
22
|
# @param open_timeout [Numeric] connection timeout, seconds.
|
|
23
23
|
# @param read_timeout [Numeric] read timeout, seconds.
|
|
24
|
-
|
|
24
|
+
# @param interpolate [Boolean] expand ${OTHER_SECRET} references in resolved
|
|
25
|
+
# values (default true); false returns the stored text verbatim.
|
|
26
|
+
def initialize(token: nil, api_url: nil, with: {}, open_timeout: 10, read_timeout: 30,
|
|
27
|
+
interpolate: true)
|
|
25
28
|
@token = token || ENV["SEEKRIT_TOKEN"]
|
|
26
29
|
raise Error, "no service token: pass token: or set SEEKRIT_TOKEN" unless @token && !@token.empty?
|
|
27
30
|
|
|
28
31
|
@key = Crypto::TokenKey.parse(@token) # fail fast on a bad token
|
|
32
|
+
@token = Crypto.service_token_auth(@token)
|
|
29
33
|
@api_url = (api_url || ENV["SEEKRIT_API_URL"] || DEFAULT_API_URL).sub(%r{/+\z}, "")
|
|
30
34
|
@with = with || {}
|
|
31
35
|
@open_timeout = open_timeout
|
|
32
36
|
@read_timeout = read_timeout
|
|
37
|
+
@interpolate = interpolate
|
|
33
38
|
end
|
|
34
39
|
|
|
35
40
|
# Fetch, decrypt, and merge; returns { "NAME" => value }. Fail-closed.
|
|
36
41
|
# @return [Hash{String=>String}]
|
|
37
42
|
def resolve
|
|
38
|
-
Crypto.materialize(fetch, @key)
|
|
43
|
+
Crypto.materialize(fetch, @key, interpolate: @interpolate)
|
|
39
44
|
end
|
|
40
45
|
|
|
41
46
|
# Resolve and return a single secret's value, or +default+ if absent.
|
data/lib/seekrit/crypto.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "openssl"
|
|
4
4
|
require "base64"
|
|
5
5
|
|
|
6
|
+
require_relative "interpolate"
|
|
7
|
+
|
|
6
8
|
module Seekrit
|
|
7
9
|
# Zero-knowledge read path, byte-compatible with @seekrit/crypto and
|
|
8
10
|
# crates/seekrit-core. Three steps:
|
|
@@ -12,17 +14,24 @@ module Seekrit
|
|
|
12
14
|
# 3. Decrypt each secret (Crypto.decrypt_secret).
|
|
13
15
|
#
|
|
14
16
|
# Blob formats (all segments base64url, no padding):
|
|
15
|
-
# token skt_<id>
|
|
17
|
+
# token skt_<id>_v2.<auth secret>.<pkcs8 private key>
|
|
16
18
|
# wrapped DEK wd1.<ephemeral pub (raw SEC1)>.<hkdf salt>.<iv>.<ciphertext||tag>
|
|
17
19
|
# secret sc1.<iv>.<ciphertext||tag>
|
|
18
20
|
module Crypto
|
|
19
21
|
HKDF_INFO = "seekrit/wrap-dek/v1"
|
|
20
22
|
CURVE = "prime256v1" # NIST P-256 / secp256r1
|
|
21
23
|
# 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
|
|
24
|
+
TOKEN_RE = /\A(skt_[0-9A-Za-z]+)_(?:v2\.[A-Za-z0-9_-]{43}\.)?([A-Za-z0-9_-]+)\z/.freeze
|
|
23
25
|
|
|
24
26
|
module_function
|
|
25
27
|
|
|
28
|
+
def service_token_auth(token)
|
|
29
|
+
match = /\A(skt_[0-9A-Za-z]+_v2\.[A-Za-z0-9_-]{43})(?:\.[A-Za-z0-9_-]+)?\z/.match(token)
|
|
30
|
+
raise CryptoError, "a v2 service token is required for API access; replace legacy tokens in the dashboard" unless match
|
|
31
|
+
|
|
32
|
+
match[1]
|
|
33
|
+
end
|
|
34
|
+
|
|
26
35
|
def b64url_decode(text)
|
|
27
36
|
Base64.urlsafe_decode64(text + ("=" * ((4 - (text.length % 4)) % 4)))
|
|
28
37
|
end
|
|
@@ -144,7 +153,11 @@ module Seekrit
|
|
|
144
153
|
# Decrypt every layer and merge by precedence. Layers arrive lowest
|
|
145
154
|
# precedence first (composed groups, then the app environment); later layers
|
|
146
155
|
# overwrite earlier ones on a name collision.
|
|
147
|
-
|
|
156
|
+
#
|
|
157
|
+
# ${OTHER_SECRET} references are then expanded over the merged result (so a
|
|
158
|
+
# reference sees whichever layer won the name). Pass interpolate: false to
|
|
159
|
+
# get the stored text instead.
|
|
160
|
+
def materialize(resolve_response, key, interpolate: true)
|
|
148
161
|
merged = {}
|
|
149
162
|
resolve_response["layers"].each do |layer|
|
|
150
163
|
dek = key.unwrap_dek(layer["wrappedDek"])
|
|
@@ -153,7 +166,9 @@ module Seekrit
|
|
|
153
166
|
merged[secret["name"]] = decrypt_secret(dek, secret["ciphertext"], secret_aad(env_id, secret["name"]))
|
|
154
167
|
end
|
|
155
168
|
end
|
|
156
|
-
merged
|
|
169
|
+
return merged unless interpolate
|
|
170
|
+
|
|
171
|
+
Interpolate.interpolate_secrets(merged).values
|
|
157
172
|
end
|
|
158
173
|
end
|
|
159
174
|
end
|
data/lib/seekrit/errors.rb
CHANGED
|
@@ -9,6 +9,19 @@ module Seekrit
|
|
|
9
9
|
# from "tampered data" from "mismatched context".
|
|
10
10
|
class CryptoError < Error; end
|
|
11
11
|
|
|
12
|
+
# A ${OTHER_SECRET} reference could not be expanded: a cycle, or an expansion
|
|
13
|
+
# that grows without bound. An *unknown* reference is not an error — it is left
|
|
14
|
+
# as literal text (see Seekrit::Interpolate.interpolate_secrets).
|
|
15
|
+
class ReferenceError < Error
|
|
16
|
+
# @return [String] "CYCLE" or "TOO_LARGE".
|
|
17
|
+
attr_reader :code
|
|
18
|
+
|
|
19
|
+
def initialize(code, message)
|
|
20
|
+
@code = code
|
|
21
|
+
super(message)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
12
25
|
# The resolve API returned a non-2xx response.
|
|
13
26
|
class ApiError < Error
|
|
14
27
|
# @return [Integer] HTTP status code.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Seekrit
|
|
6
|
+
# Secret references: ${OTHER_SECRET} inside a secret value.
|
|
7
|
+
#
|
|
8
|
+
# Expansion happens here, in-process, after the layers are merged — the API
|
|
9
|
+
# only ever holds the ciphertext of the literal ${OTHER_SECRET} text. The rules
|
|
10
|
+
# are fixed by the shared golden fixture (testdata/vectors.json, key
|
|
11
|
+
# "interpolation"), which every seekrit client is tested against:
|
|
12
|
+
#
|
|
13
|
+
# * ${NAME} becomes NAME's value from the same merged set, recursively.
|
|
14
|
+
# * NAME must look like an environment variable ([A-Za-z_][A-Za-z0-9_]*);
|
|
15
|
+
# anything else (${FOO:-bar}, ${1}) is left exactly as written.
|
|
16
|
+
# * A name that is not in the set is left literal and listed in +unresolved+,
|
|
17
|
+
# so a stored value containing e.g. ${GITHUB_SHA} keeps working.
|
|
18
|
+
# * $${NAME} is an escape for the literal text ${NAME}.
|
|
19
|
+
# * A reference cycle raises ReferenceError.
|
|
20
|
+
module Interpolate
|
|
21
|
+
REFERENCE_NAME = /\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
|
|
22
|
+
|
|
23
|
+
# Cap on a single expanded value: nested references can multiply length.
|
|
24
|
+
MAX_EXPANDED_LENGTH = 1_048_576
|
|
25
|
+
|
|
26
|
+
# The outcome of expanding a variable set.
|
|
27
|
+
#
|
|
28
|
+
# @!attribute values
|
|
29
|
+
# @return [Hash{String=>String}] the set with every reference expanded
|
|
30
|
+
# @!attribute expanded
|
|
31
|
+
# @return [Array<String>] names whose value had a reference expanded
|
|
32
|
+
# @!attribute unresolved
|
|
33
|
+
# @return [Array<String>] referenced names absent from the set, sorted
|
|
34
|
+
Interpolation = Struct.new(:values, :expanded, :unresolved)
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# Split a value into literal runs and references — the single tokenizer the
|
|
39
|
+
# rules above are expressed in terms of. Yields [reference, text]: for a
|
|
40
|
+
# literal, +reference+ is nil; for a reference, +text+ is its raw ${NAME}
|
|
41
|
+
# source (used verbatim when the name turns out to be absent).
|
|
42
|
+
def scan(text)
|
|
43
|
+
return enum_for(:scan, text) unless block_given?
|
|
44
|
+
|
|
45
|
+
i = 0
|
|
46
|
+
while i < text.length
|
|
47
|
+
dollar = text.index("$", i)
|
|
48
|
+
if dollar.nil?
|
|
49
|
+
yield [nil, text[i..]]
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
yield [nil, text[i...dollar]] if dollar > i
|
|
53
|
+
|
|
54
|
+
# `$${` — escape: emit a literal `${` and skip all three characters, so
|
|
55
|
+
# the `{NAME}` that follows can never be read as a reference.
|
|
56
|
+
if text[dollar + 1, 2] == "${"
|
|
57
|
+
yield [nil, "${"]
|
|
58
|
+
i = dollar + 3
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
close = text[dollar + 1] == "{" ? text.index("}", dollar + 2) : nil
|
|
63
|
+
reference = close ? text[(dollar + 2)...close] : nil
|
|
64
|
+
if reference && REFERENCE_NAME.match?(reference)
|
|
65
|
+
yield [reference, text[dollar..close]]
|
|
66
|
+
i = close + 1
|
|
67
|
+
next
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A plain `$`, an unterminated `${`, or a non-name: literal. Advancing a
|
|
71
|
+
# single character lets a `${` later in the run still be recognized.
|
|
72
|
+
yield [nil, "$"]
|
|
73
|
+
i = dollar + 1
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Expand ${NAME} references throughout a merged variable set. The input hash
|
|
78
|
+
# is never mutated.
|
|
79
|
+
#
|
|
80
|
+
# @param values [Hash{String=>String}]
|
|
81
|
+
# @return [Interpolation]
|
|
82
|
+
# @raise [Seekrit::ReferenceError] on a reference cycle or unbounded growth
|
|
83
|
+
def interpolate_secrets(values)
|
|
84
|
+
state = { resolved: {}, unresolved: {}, stack: [] }
|
|
85
|
+
result = {}
|
|
86
|
+
expanded = []
|
|
87
|
+
values.each_key do |name|
|
|
88
|
+
value = resolve_reference(name, values, state)
|
|
89
|
+
result[name] = value
|
|
90
|
+
expanded << name if value != values[name]
|
|
91
|
+
end
|
|
92
|
+
Interpolation.new(result, expanded, state[:unresolved].keys.sort)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Expand one present name's value, memoized so each is expanded once.
|
|
96
|
+
# @api private
|
|
97
|
+
def resolve_reference(name, values, state)
|
|
98
|
+
cached = state[:resolved][name]
|
|
99
|
+
return cached if cached
|
|
100
|
+
|
|
101
|
+
stack = state[:stack]
|
|
102
|
+
at = stack.index(name)
|
|
103
|
+
unless at.nil?
|
|
104
|
+
chain = stack[at..] + [name]
|
|
105
|
+
raise ReferenceError.new("CYCLE", "secret reference cycle: #{chain.join(' -> ')}")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
stack.push(name)
|
|
109
|
+
out = +""
|
|
110
|
+
scan(values[name]) do |reference, text|
|
|
111
|
+
if reference.nil?
|
|
112
|
+
out << text
|
|
113
|
+
elsif values.key?(reference)
|
|
114
|
+
out << resolve_reference(reference, values, state)
|
|
115
|
+
else
|
|
116
|
+
state[:unresolved][reference] = true
|
|
117
|
+
out << text
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
stack.pop
|
|
121
|
+
|
|
122
|
+
if out.length > MAX_EXPANDED_LENGTH
|
|
123
|
+
raise ReferenceError.new("TOO_LARGE", "#{name} expands to more than #{MAX_EXPANDED_LENGTH} bytes")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
state[:resolved][name] = out
|
|
127
|
+
out
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
data/lib/seekrit/version.rb
CHANGED
data/lib/seekrit.rb
CHANGED
data/testdata/vectors.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"token": "
|
|
3
|
-
"
|
|
4
|
-
"
|
|
2
|
+
"token": "skt_6EYgmoh8LuiPPAbM0SaVtc_v2.bh8OFkiEBdTJM4MBX75AMCPsR2Arp1i32sg8Ej0Vw7o.MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgra6e_i8D2dIWlz-43EVbrHz-g698-kxY22MCp95L7VGhRANCAATA4nkyQKLq4hD5YvzXLtNtKAK_g-H4LF260zk0bInSWMbDfx0vTTtOg0tRWoDYWbuIN0vb1YMZ5z13xH5aBsDx",
|
|
3
|
+
"authToken": "skt_6EYgmoh8LuiPPAbM0SaVtc_v2.bh8OFkiEBdTJM4MBX75AMCPsR2Arp1i32sg8Ej0Vw7o",
|
|
4
|
+
"tokenId": "skt_6EYgmoh8LuiPPAbM0SaVtc",
|
|
5
|
+
"publicKeyJwk": "{\"key_ops\":[],\"ext\":true,\"kty\":\"EC\",\"x\":\"wOJ5MkCi6uIQ-WL81y7TbSgCv4Ph-CxdutM5NGyJ0lg\",\"y\":\"xsN_HS9NO06DS1FagNhZu4g3S9vVgxnnPXfEfloGwPE\",\"crv\":\"P-256\"}",
|
|
5
6
|
"resolve": {
|
|
6
7
|
"scope": {
|
|
7
8
|
"orgId": "org_test",
|
|
@@ -17,19 +18,19 @@
|
|
|
17
18
|
"environmentId": "env_grouptest0000000001",
|
|
18
19
|
"slug": "production",
|
|
19
20
|
"groupSlug": "shared",
|
|
20
|
-
"wrappedDek": "wd1.
|
|
21
|
+
"wrappedDek": "wd1.BGAAVA2nxO1UzcRZA_7ubZ9nLAGSf_Fc_6y07AHlv4N_q8dhQ0z4dwMHqUUaKPkkSnH8rnWkjnitlKHRmuIvoz8.dJ0ALJ9Wejh6nYiKM3adOA.VFgZWwqtT_ly6gP4.RIcWNi6id_QO9NtmCU4iFUEX9U9fUlqn2UNgmkgunpU_eVnKgMf6q8ui1PxcEBiw",
|
|
21
22
|
"secrets": [
|
|
22
23
|
{
|
|
23
24
|
"name": "SHARED",
|
|
24
|
-
"ciphertext": "sc1.
|
|
25
|
+
"ciphertext": "sc1.oIjnQEJskdJ_nweV.MGETgRWPDkwhHm9e_jp567-b19Fn8DJohrU"
|
|
25
26
|
},
|
|
26
27
|
{
|
|
27
28
|
"name": "DATABASE_URL",
|
|
28
|
-
"ciphertext": "sc1
|
|
29
|
+
"ciphertext": "sc1.Fo69NaVLlUDFtGsk.tr8o0e7mMSB7DWkfZuv0eLOsNq7Fm-3dd2zOiPSXSHiwXbg"
|
|
29
30
|
},
|
|
30
31
|
{
|
|
31
32
|
"name": "UNICODE",
|
|
32
|
-
"ciphertext": "sc1.
|
|
33
|
+
"ciphertext": "sc1.hu_o1qPHzmeJrT0q.5jIWUj9fTvGXjyeSyhtOMdvlWRenXqNy9F2uYg3XVgui0CYmdA"
|
|
33
34
|
}
|
|
34
35
|
]
|
|
35
36
|
},
|
|
@@ -37,19 +38,31 @@
|
|
|
37
38
|
"source": "app",
|
|
38
39
|
"environmentId": "env_apptest0000000000001",
|
|
39
40
|
"slug": "production",
|
|
40
|
-
"wrappedDek": "wd1.
|
|
41
|
+
"wrappedDek": "wd1.BHVKzCsJECbpyULjVjFe-vw5mmKEJ26MZz02wc1RfzgozIRx9CrCFJJ0RkYWT_iuvAAG2rN7WNg5uNt0RQ6X14Q.Wfuvupy_B16u453L04UpSA.YPq62FodkXeezDd3.HgXwlx5gZRAnqmyk0WSz4HW9qVrb-DfEsvD76HKXkgigAShwBJ9g1qiVtSUgUgCo",
|
|
41
42
|
"secrets": [
|
|
42
43
|
{
|
|
43
44
|
"name": "SHARED",
|
|
44
|
-
"ciphertext": "sc1.
|
|
45
|
+
"ciphertext": "sc1.hVO-Xgq16bV52VDQ.gePrTLVlrjIINJCs_gomYWnwqS4sD1CP"
|
|
45
46
|
},
|
|
46
47
|
{
|
|
47
48
|
"name": "API_KEY",
|
|
48
|
-
"ciphertext": "sc1.
|
|
49
|
+
"ciphertext": "sc1.KpB-GlAGsWiNUN5p.PY5HnW1tOA-ZCBSSslnqFld4sPGWLujiW_j8gQ"
|
|
49
50
|
},
|
|
50
51
|
{
|
|
51
52
|
"name": "EMPTY",
|
|
52
|
-
"ciphertext": "sc1.
|
|
53
|
+
"ciphertext": "sc1.qR6EjHPQkz6mZI37.PKrdRmvhq-N5bqYwWaK5Dg"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"name": "REFERENCING",
|
|
57
|
+
"ciphertext": "sc1.mc6E1roINLiI5H5U.jkSlIFcn3NxJLgStBEdpelOJ_aB9pouUIOu5YX7yIB0G_maOkBRA2UpFsFoXI1mRppEWwg"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"name": "ESCAPED_REF",
|
|
61
|
+
"ciphertext": "sc1.obgYCJstXaXFUJJg.tcI9_IOzvpSaXF96WPBowUKxph3dJ_7GvHs"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"name": "DANGLING_REF",
|
|
65
|
+
"ciphertext": "sc1.VJCnlvqadm3F6lge.kONf1u13yEIWc8pBjSs_XQDR9kmGi0wOTH35QE_QM8qJEhLuDg"
|
|
53
66
|
}
|
|
54
67
|
]
|
|
55
68
|
}
|
|
@@ -60,6 +73,363 @@
|
|
|
60
73
|
"DATABASE_URL": "postgres://group/db",
|
|
61
74
|
"UNICODE": "héllo-🌍-\n-tab\tend",
|
|
62
75
|
"API_KEY": "sk-app-12345",
|
|
63
|
-
"EMPTY": ""
|
|
76
|
+
"EMPTY": "",
|
|
77
|
+
"REFERENCING": "url=postgres://group/db;shared=from-app",
|
|
78
|
+
"ESCAPED_REF": "${SHARED}",
|
|
79
|
+
"DANGLING_REF": "build-${NOT_A_SECRET}"
|
|
80
|
+
},
|
|
81
|
+
"interpolation": {
|
|
82
|
+
"cases": [
|
|
83
|
+
{
|
|
84
|
+
"name": "substitutes a reference",
|
|
85
|
+
"input": {
|
|
86
|
+
"HOST": "db.internal",
|
|
87
|
+
"URL": "postgres://app@${HOST}:5432/db"
|
|
88
|
+
},
|
|
89
|
+
"expected": {
|
|
90
|
+
"HOST": "db.internal",
|
|
91
|
+
"URL": "postgres://app@db.internal:5432/db"
|
|
92
|
+
},
|
|
93
|
+
"unresolved": []
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"name": "expands recursively, repeats, and forward references",
|
|
97
|
+
"input": {
|
|
98
|
+
"ADDR": "${HOST}:${PORT}",
|
|
99
|
+
"HOST": "db.internal",
|
|
100
|
+
"PORT": "5432",
|
|
101
|
+
"PAIR": "${ADDR}|${ADDR}"
|
|
102
|
+
},
|
|
103
|
+
"expected": {
|
|
104
|
+
"ADDR": "db.internal:5432",
|
|
105
|
+
"HOST": "db.internal",
|
|
106
|
+
"PORT": "5432",
|
|
107
|
+
"PAIR": "db.internal:5432|db.internal:5432"
|
|
108
|
+
},
|
|
109
|
+
"unresolved": []
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"name": "leaves unknown references literal and reports them",
|
|
113
|
+
"input": {
|
|
114
|
+
"TAG": "build-${GITHUB_SHA}",
|
|
115
|
+
"OTHER": "${NOPE}-${GITHUB_SHA}"
|
|
116
|
+
},
|
|
117
|
+
"expected": {
|
|
118
|
+
"TAG": "build-${GITHUB_SHA}",
|
|
119
|
+
"OTHER": "${NOPE}-${GITHUB_SHA}"
|
|
120
|
+
},
|
|
121
|
+
"unresolved": [
|
|
122
|
+
"GITHUB_SHA",
|
|
123
|
+
"NOPE"
|
|
124
|
+
]
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"name": "expands around an unknown reference",
|
|
128
|
+
"input": {
|
|
129
|
+
"HOST": "db.internal",
|
|
130
|
+
"URL": "${HOST}/${MISSING}"
|
|
131
|
+
},
|
|
132
|
+
"expected": {
|
|
133
|
+
"HOST": "db.internal",
|
|
134
|
+
"URL": "db.internal/${MISSING}"
|
|
135
|
+
},
|
|
136
|
+
"unresolved": [
|
|
137
|
+
"MISSING"
|
|
138
|
+
]
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"name": "$${NAME} escapes to a literal ${NAME}",
|
|
142
|
+
"input": {
|
|
143
|
+
"NAME": "expanded",
|
|
144
|
+
"TEMPLATE": "$${NAME} stays, ${NAME} goes"
|
|
145
|
+
},
|
|
146
|
+
"expected": {
|
|
147
|
+
"NAME": "expanded",
|
|
148
|
+
"TEMPLATE": "${NAME} stays, expanded goes"
|
|
149
|
+
},
|
|
150
|
+
"unresolved": []
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"name": "leaves shell-style, non-name, and unterminated forms alone",
|
|
154
|
+
"input": {
|
|
155
|
+
"FOO": "value",
|
|
156
|
+
"DEFAULTED": "${FOO:-fallback}",
|
|
157
|
+
"POSITIONAL": "${1}",
|
|
158
|
+
"DOTTED": "${a.b}",
|
|
159
|
+
"EMPTY_REF": "${}",
|
|
160
|
+
"UNTERMINATED": "${FOO",
|
|
161
|
+
"PASSWORD": "p$$w0rd$"
|
|
162
|
+
},
|
|
163
|
+
"expected": {
|
|
164
|
+
"FOO": "value",
|
|
165
|
+
"DEFAULTED": "${FOO:-fallback}",
|
|
166
|
+
"POSITIONAL": "${1}",
|
|
167
|
+
"DOTTED": "${a.b}",
|
|
168
|
+
"EMPTY_REF": "${}",
|
|
169
|
+
"UNTERMINATED": "${FOO",
|
|
170
|
+
"PASSWORD": "p$$w0rd$"
|
|
171
|
+
},
|
|
172
|
+
"unresolved": []
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
"name": "recognizes a reference after an unterminated one",
|
|
176
|
+
"input": {
|
|
177
|
+
"A": "a",
|
|
178
|
+
"T": "${ ${A}"
|
|
179
|
+
},
|
|
180
|
+
"expected": {
|
|
181
|
+
"A": "a",
|
|
182
|
+
"T": "${ a"
|
|
183
|
+
},
|
|
184
|
+
"unresolved": []
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"name": "handles empty values and multibyte text",
|
|
188
|
+
"input": {
|
|
189
|
+
"EMPTY": "",
|
|
190
|
+
"A": "héllo-🌍",
|
|
191
|
+
"T": "🌍${A}${EMPTY}🌍"
|
|
192
|
+
},
|
|
193
|
+
"expected": {
|
|
194
|
+
"EMPTY": "",
|
|
195
|
+
"A": "héllo-🌍",
|
|
196
|
+
"T": "🌍héllo-🌍🌍"
|
|
197
|
+
},
|
|
198
|
+
"unresolved": []
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
"name": "a diamond is not a cycle",
|
|
202
|
+
"input": {
|
|
203
|
+
"BASE": "b",
|
|
204
|
+
"LEFT": "${BASE}-l",
|
|
205
|
+
"RIGHT": "${BASE}-r",
|
|
206
|
+
"TOP": "${LEFT}+${RIGHT}"
|
|
207
|
+
},
|
|
208
|
+
"expected": {
|
|
209
|
+
"BASE": "b",
|
|
210
|
+
"LEFT": "b-l",
|
|
211
|
+
"RIGHT": "b-r",
|
|
212
|
+
"TOP": "b-l+b-r"
|
|
213
|
+
},
|
|
214
|
+
"unresolved": []
|
|
215
|
+
}
|
|
216
|
+
],
|
|
217
|
+
"cycles": [
|
|
218
|
+
{
|
|
219
|
+
"name": "self reference",
|
|
220
|
+
"input": {
|
|
221
|
+
"A": "x${A}"
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
"name": "direct cycle",
|
|
226
|
+
"input": {
|
|
227
|
+
"A": "${B}",
|
|
228
|
+
"B": "${A}"
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
"name": "indirect cycle",
|
|
233
|
+
"input": {
|
|
234
|
+
"A": "${B}",
|
|
235
|
+
"B": "${C}",
|
|
236
|
+
"C": "${A}"
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
]
|
|
240
|
+
},
|
|
241
|
+
"substitution": {
|
|
242
|
+
"note": "Placeholder substitution over plaintext alone — no crypto involved. Every implementation of the engine (apps/proxy/src/substitute.rs and the fetch/httpx shims in the language SDKs) runs these, so the rules can never drift apart. The lookup each harness must install: a name in `denied` returns Denied, otherwise a name present in `values` returns that Value, otherwise Unknown. A case has either `expected` + `names`, or `error`.",
|
|
243
|
+
"cases": [
|
|
244
|
+
{
|
|
245
|
+
"name": "substitutes a placeholder and reports the name",
|
|
246
|
+
"input": "Bearer {{seekrit:OPENAI_API_KEY}}",
|
|
247
|
+
"values": {
|
|
248
|
+
"OPENAI_API_KEY": "sk-live"
|
|
249
|
+
},
|
|
250
|
+
"denied": [],
|
|
251
|
+
"expected": "Bearer sk-live",
|
|
252
|
+
"names": [
|
|
253
|
+
"OPENAI_API_KEY"
|
|
254
|
+
]
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
"name": "passes text with no placeholder through untouched",
|
|
258
|
+
"input": "nothing to see here",
|
|
259
|
+
"values": {
|
|
260
|
+
"OPENAI_API_KEY": "sk-live"
|
|
261
|
+
},
|
|
262
|
+
"denied": [],
|
|
263
|
+
"expected": "nothing to see here",
|
|
264
|
+
"names": []
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
"name": "repeats one name as many times as it appears",
|
|
268
|
+
"input": "{{seekrit:OPENAI_API_KEY}}::{{seekrit:OPENAI_API_KEY}}",
|
|
269
|
+
"values": {
|
|
270
|
+
"OPENAI_API_KEY": "sk-live"
|
|
271
|
+
},
|
|
272
|
+
"denied": [],
|
|
273
|
+
"expected": "sk-live::sk-live",
|
|
274
|
+
"names": [
|
|
275
|
+
"OPENAI_API_KEY"
|
|
276
|
+
]
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
"name": "reports two injected names, sorted",
|
|
280
|
+
"input": "{\"a\":\"{{seekrit:STRIPE_KEY}}\",\"b\":\"{{seekrit:OPENAI_API_KEY}}\"}",
|
|
281
|
+
"values": {
|
|
282
|
+
"OPENAI_API_KEY": "sk-live",
|
|
283
|
+
"STRIPE_KEY": "sk_test_1"
|
|
284
|
+
},
|
|
285
|
+
"denied": [],
|
|
286
|
+
"expected": "{\"a\":\"sk_test_1\",\"b\":\"sk-live\"}",
|
|
287
|
+
"names": [
|
|
288
|
+
"OPENAI_API_KEY",
|
|
289
|
+
"STRIPE_KEY"
|
|
290
|
+
]
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
"name": "a denied name aborts the pass",
|
|
294
|
+
"input": "{{seekrit:SOME_OTHER}}",
|
|
295
|
+
"values": {
|
|
296
|
+
"OPENAI_API_KEY": "sk-live"
|
|
297
|
+
},
|
|
298
|
+
"denied": [
|
|
299
|
+
"SOME_OTHER"
|
|
300
|
+
],
|
|
301
|
+
"error": {
|
|
302
|
+
"code": "denied",
|
|
303
|
+
"name": "SOME_OTHER"
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
"name": "an allowed but unresolved name aborts the pass",
|
|
308
|
+
"input": "{{seekrit:MISSING}}",
|
|
309
|
+
"values": {
|
|
310
|
+
"OPENAI_API_KEY": "sk-live"
|
|
311
|
+
},
|
|
312
|
+
"denied": [],
|
|
313
|
+
"error": {
|
|
314
|
+
"code": "unresolved",
|
|
315
|
+
"name": "MISSING"
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
"name": "a denial wins even when a valid placeholder came first",
|
|
320
|
+
"input": "{{seekrit:OPENAI_API_KEY}} then {{seekrit:SOME_OTHER}}",
|
|
321
|
+
"values": {
|
|
322
|
+
"OPENAI_API_KEY": "sk-live"
|
|
323
|
+
},
|
|
324
|
+
"denied": [
|
|
325
|
+
"SOME_OTHER"
|
|
326
|
+
],
|
|
327
|
+
"error": {
|
|
328
|
+
"code": "denied",
|
|
329
|
+
"name": "SOME_OTHER"
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
"name": "an unterminated marker is literal",
|
|
334
|
+
"input": "value {{seekrit:OPENAI_API_KEY",
|
|
335
|
+
"values": {
|
|
336
|
+
"OPENAI_API_KEY": "sk-live"
|
|
337
|
+
},
|
|
338
|
+
"denied": [],
|
|
339
|
+
"expected": "value {{seekrit:OPENAI_API_KEY",
|
|
340
|
+
"names": []
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
"name": "an illegal character in the name makes the marker literal",
|
|
344
|
+
"input": "{{seekrit:bad-name}}",
|
|
345
|
+
"values": {
|
|
346
|
+
"OPENAI_API_KEY": "sk-live"
|
|
347
|
+
},
|
|
348
|
+
"denied": [],
|
|
349
|
+
"expected": "{{seekrit:bad-name}}",
|
|
350
|
+
"names": []
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
"name": "an empty name is not a placeholder",
|
|
354
|
+
"input": "{{seekrit:}}",
|
|
355
|
+
"values": {
|
|
356
|
+
"OPENAI_API_KEY": "sk-live"
|
|
357
|
+
},
|
|
358
|
+
"denied": [],
|
|
359
|
+
"expected": "{{seekrit:}}",
|
|
360
|
+
"names": []
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
"name": "whitespace inside the marker is not a placeholder",
|
|
364
|
+
"input": "{{ seekrit:OPENAI_API_KEY }}",
|
|
365
|
+
"values": {
|
|
366
|
+
"OPENAI_API_KEY": "sk-live"
|
|
367
|
+
},
|
|
368
|
+
"denied": [],
|
|
369
|
+
"expected": "{{ seekrit:OPENAI_API_KEY }}",
|
|
370
|
+
"names": []
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
"name": "nesting is not expanded recursively",
|
|
374
|
+
"input": "{{seekrit:{{seekrit:OPENAI_API_KEY}}}}",
|
|
375
|
+
"values": {
|
|
376
|
+
"OPENAI_API_KEY": "sk-live"
|
|
377
|
+
},
|
|
378
|
+
"denied": [],
|
|
379
|
+
"expected": "{{seekrit:sk-live}}",
|
|
380
|
+
"names": [
|
|
381
|
+
"OPENAI_API_KEY"
|
|
382
|
+
]
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
"name": "a substituted value is never rescanned",
|
|
386
|
+
"input": "{{seekrit:OPENAI_API_KEY}}",
|
|
387
|
+
"values": {
|
|
388
|
+
"OPENAI_API_KEY": "{{seekrit:STRIPE_KEY}}",
|
|
389
|
+
"STRIPE_KEY": "sk_test_1"
|
|
390
|
+
},
|
|
391
|
+
"denied": [],
|
|
392
|
+
"expected": "{{seekrit:STRIPE_KEY}}",
|
|
393
|
+
"names": [
|
|
394
|
+
"OPENAI_API_KEY"
|
|
395
|
+
]
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
"name": "extra closing braces stay literal",
|
|
399
|
+
"input": "{{seekrit:OPENAI_API_KEY}}}}",
|
|
400
|
+
"values": {
|
|
401
|
+
"OPENAI_API_KEY": "sk-live"
|
|
402
|
+
},
|
|
403
|
+
"denied": [],
|
|
404
|
+
"expected": "sk-live}}",
|
|
405
|
+
"names": [
|
|
406
|
+
"OPENAI_API_KEY"
|
|
407
|
+
]
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
"name": "multibyte text around a placeholder survives",
|
|
411
|
+
"input": "🌍 {{seekrit:OPENAI_API_KEY}} héllo",
|
|
412
|
+
"values": {
|
|
413
|
+
"OPENAI_API_KEY": "sk-live"
|
|
414
|
+
},
|
|
415
|
+
"denied": [],
|
|
416
|
+
"expected": "🌍 sk-live héllo",
|
|
417
|
+
"names": [
|
|
418
|
+
"OPENAI_API_KEY"
|
|
419
|
+
]
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
"name": "an empty value substitutes to nothing",
|
|
423
|
+
"input": "[{{seekrit:EMPTY}}]",
|
|
424
|
+
"values": {
|
|
425
|
+
"EMPTY": ""
|
|
426
|
+
},
|
|
427
|
+
"denied": [],
|
|
428
|
+
"expected": "[]",
|
|
429
|
+
"names": [
|
|
430
|
+
"EMPTY"
|
|
431
|
+
]
|
|
432
|
+
}
|
|
433
|
+
]
|
|
64
434
|
}
|
|
65
435
|
}
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: seekrit-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- seekrit
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: |
|
|
14
14
|
Read-path SDK for seekrit, the zero-knowledge secrets manager. Authenticate
|
|
@@ -28,6 +28,7 @@ files:
|
|
|
28
28
|
- lib/seekrit/client.rb
|
|
29
29
|
- lib/seekrit/crypto.rb
|
|
30
30
|
- lib/seekrit/errors.rb
|
|
31
|
+
- lib/seekrit/interpolate.rb
|
|
31
32
|
- lib/seekrit/railtie.rb
|
|
32
33
|
- lib/seekrit/sdk.rb
|
|
33
34
|
- lib/seekrit/version.rb
|
|
@@ -37,8 +38,9 @@ licenses:
|
|
|
37
38
|
- MIT
|
|
38
39
|
metadata:
|
|
39
40
|
homepage_uri: https://seekrit.dev
|
|
40
|
-
documentation_uri: https://seekrit.dev/docs
|
|
41
|
+
documentation_uri: https://seekrit.dev/docs/guides/sdks
|
|
41
42
|
source_code_uri: https://github.com/seekritdev/ruby-sdk
|
|
43
|
+
bug_tracker_uri: https://github.com/seekritdev/ruby-sdk/issues
|
|
42
44
|
rubygems_mfa_required: 'true'
|
|
43
45
|
post_install_message:
|
|
44
46
|
rdoc_options: []
|