seekrit-sdk 0.2.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e36b3af31987a52e24e023cfedfdf29832bfd6a707b16d64097b7ae398e8768
4
- data.tar.gz: 267d89e6743ef4db98f7892ff887965a962c01404ab60d4183fc48cc7315ae40
3
+ metadata.gz: f230fc82fb27db0d54bfa68297258f0548223d055215a38f73c0e409c8bfa165
4
+ data.tar.gz: 2f04d0d66d69fccdb7505e59e54dc6eee087f9c16663bbb575c80048991d07d9
5
5
  SHA512:
6
- metadata.gz: a49c25989cb360f7acb086246be37855958925fc2efb39b908c53430e992da4efe8d67610264af5912ba5b667b7e5f35c5aa940bb3b0140710742eb282348651
7
- data.tar.gz: d3273a2237dc9d0632052a2fd20b704f41cb06513110526af61b615fdf31767762c154641537235803dc297ec901e9de3047dc3fbcaaf582ddf3341443da7b1e
6
+ metadata.gz: 3ee87b6ed2a34832fcc53b6b9bba22bdb3c1c46b743dfb145a3a93f294e391bb13a73ecb903342ea5033e53403e75db25d120ecd6cf16df1f6c841b527396348
7
+ data.tar.gz: 8d8a7ce8d752735509690df6acac4fd9849c632072abf6d6e89661e81e46ae674e0dd93498a6f3c403f3e43cc7be91e0e36536c2b4914fae2c642aea9d3f77d2
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
@@ -21,7 +21,10 @@ 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
- def initialize(token: nil, api_url: nil, with: {}, open_timeout: 10, read_timeout: 30)
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
 
@@ -30,12 +33,13 @@ module Seekrit
30
33
  @with = with || {}
31
34
  @open_timeout = open_timeout
32
35
  @read_timeout = read_timeout
36
+ @interpolate = interpolate
33
37
  end
34
38
 
35
39
  # Fetch, decrypt, and merge; returns { "NAME" => value }. Fail-closed.
36
40
  # @return [Hash{String=>String}]
37
41
  def resolve
38
- Crypto.materialize(fetch, @key)
42
+ Crypto.materialize(fetch, @key, interpolate: @interpolate)
39
43
  end
40
44
 
41
45
  # Resolve and return a single secret's value, or +default+ if absent.
@@ -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:
@@ -144,7 +146,11 @@ module Seekrit
144
146
  # Decrypt every layer and merge by precedence. Layers arrive lowest
145
147
  # precedence first (composed groups, then the app environment); later layers
146
148
  # overwrite earlier ones on a name collision.
147
- def materialize(resolve_response, key)
149
+ #
150
+ # ${OTHER_SECRET} references are then expanded over the merged result (so a
151
+ # reference sees whichever layer won the name). Pass interpolate: false to
152
+ # get the stored text instead.
153
+ def materialize(resolve_response, key, interpolate: true)
148
154
  merged = {}
149
155
  resolve_response["layers"].each do |layer|
150
156
  dek = key.unwrap_dek(layer["wrappedDek"])
@@ -153,7 +159,9 @@ module Seekrit
153
159
  merged[secret["name"]] = decrypt_secret(dek, secret["ciphertext"], secret_aad(env_id, secret["name"]))
154
160
  end
155
161
  end
156
- merged
162
+ return merged unless interpolate
163
+
164
+ Interpolate.interpolate_secrets(merged).values
157
165
  end
158
166
  end
159
167
  end
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Seekrit
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/seekrit.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "seekrit/version"
4
4
  require_relative "seekrit/errors"
5
+ require_relative "seekrit/interpolate"
5
6
  require_relative "seekrit/crypto"
6
7
  require_relative "seekrit/client"
7
8
 
@@ -1,7 +1,7 @@
1
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\"}",
2
+ "token": "skt_6EYgmoh8LuiPPAbM0SaVtc_MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgra6e_i8D2dIWlz-43EVbrHz-g698-kxY22MCp95L7VGhRANCAATA4nkyQKLq4hD5YvzXLtNtKAK_g-H4LF260zk0bInSWMbDfx0vTTtOg0tRWoDYWbuIN0vb1YMZ5z13xH5aBsDx",
3
+ "tokenId": "skt_6EYgmoh8LuiPPAbM0SaVtc",
4
+ "publicKeyJwk": "{\"key_ops\":[],\"ext\":true,\"kty\":\"EC\",\"x\":\"wOJ5MkCi6uIQ-WL81y7TbSgCv4Ph-CxdutM5NGyJ0lg\",\"y\":\"xsN_HS9NO06DS1FagNhZu4g3S9vVgxnnPXfEfloGwPE\",\"crv\":\"P-256\"}",
5
5
  "resolve": {
6
6
  "scope": {
7
7
  "orgId": "org_test",
@@ -17,19 +17,19 @@
17
17
  "environmentId": "env_grouptest0000000001",
18
18
  "slug": "production",
19
19
  "groupSlug": "shared",
20
- "wrappedDek": "wd1.BEoGSn3SoICO6tA13EjveZHvxTTRVijE0OUpBkzVkFU28q_jfgY4P9IkyrpjMVJK27zogh61rwC_NUBjoe2TKhY.5EI1On9xCtVuntRHMA6xFA.JNqiqNRxDpNN3hkP.8RxEujnGaEIUIVq9VVcOZlpC07-gxgyz25VCtffNZWSRh8J8iFfMA6p-_ubXSfv5",
20
+ "wrappedDek": "wd1.BGAAVA2nxO1UzcRZA_7ubZ9nLAGSf_Fc_6y07AHlv4N_q8dhQ0z4dwMHqUUaKPkkSnH8rnWkjnitlKHRmuIvoz8.dJ0ALJ9Wejh6nYiKM3adOA.VFgZWwqtT_ly6gP4.RIcWNi6id_QO9NtmCU4iFUEX9U9fUlqn2UNgmkgunpU_eVnKgMf6q8ui1PxcEBiw",
21
21
  "secrets": [
22
22
  {
23
23
  "name": "SHARED",
24
- "ciphertext": "sc1.4XNyaS_ilRPDTlIg.LhAGzqP_cm4fx_dKeST8_3NITle_77x0Qjo"
24
+ "ciphertext": "sc1.oIjnQEJskdJ_nweV.MGETgRWPDkwhHm9e_jp567-b19Fn8DJohrU"
25
25
  },
26
26
  {
27
27
  "name": "DATABASE_URL",
28
- "ciphertext": "sc1.-THIwVQA7yVq6SWt.1RaVPddl8QDuhc2wValcmtArCxafHBCAk24Fv00gB_FNc6k"
28
+ "ciphertext": "sc1.Fo69NaVLlUDFtGsk.tr8o0e7mMSB7DWkfZuv0eLOsNq7Fm-3dd2zOiPSXSHiwXbg"
29
29
  },
30
30
  {
31
31
  "name": "UNICODE",
32
- "ciphertext": "sc1.7qB3RU8qhHKpqwbB.BsKeZYWDv4Qp9cWIB0I684pLazslk2nFtIq-OvgDjIaTaw3Pbw"
32
+ "ciphertext": "sc1.hu_o1qPHzmeJrT0q.5jIWUj9fTvGXjyeSyhtOMdvlWRenXqNy9F2uYg3XVgui0CYmdA"
33
33
  }
34
34
  ]
35
35
  },
@@ -37,19 +37,31 @@
37
37
  "source": "app",
38
38
  "environmentId": "env_apptest0000000000001",
39
39
  "slug": "production",
40
- "wrappedDek": "wd1.BMywXqAVq3Ee3XX7mGBt2KLcRRwPLQn9FjlXc19Z8HNdDPsg-KyKLwg_uz4tgQ8MdVevMgtjfBkOiqmAi7ENI2Y.FLBNZ3z8-8CnYoBN-Azr9g.2x8wkp6BSw9QjnG2.3b32qenHIHC92eCehRW6QGWNFTlIe3nTKPje0WsRFWSHkjV6PYIxCXmlQaAJJkp3",
40
+ "wrappedDek": "wd1.BHVKzCsJECbpyULjVjFe-vw5mmKEJ26MZz02wc1RfzgozIRx9CrCFJJ0RkYWT_iuvAAG2rN7WNg5uNt0RQ6X14Q.Wfuvupy_B16u453L04UpSA.YPq62FodkXeezDd3.HgXwlx5gZRAnqmyk0WSz4HW9qVrb-DfEsvD76HKXkgigAShwBJ9g1qiVtSUgUgCo",
41
41
  "secrets": [
42
42
  {
43
43
  "name": "SHARED",
44
- "ciphertext": "sc1.QF697pJ61KM9Xqj2.HH9PNlW7aIIWZvpLm6yc8ZCRRemlxoOS"
44
+ "ciphertext": "sc1.hVO-Xgq16bV52VDQ.gePrTLVlrjIINJCs_gomYWnwqS4sD1CP"
45
45
  },
46
46
  {
47
47
  "name": "API_KEY",
48
- "ciphertext": "sc1.x4G_KqJP7ags8SBf.obw89lhKqcMAy1vzjdTjch88R7Trx9JtFWS2Rw"
48
+ "ciphertext": "sc1.KpB-GlAGsWiNUN5p.PY5HnW1tOA-ZCBSSslnqFld4sPGWLujiW_j8gQ"
49
49
  },
50
50
  {
51
51
  "name": "EMPTY",
52
- "ciphertext": "sc1.qdub6o6QejpKH97e.KFe1fiDvtHF3agstKhL5ng"
52
+ "ciphertext": "sc1.qR6EjHPQkz6mZI37.PKrdRmvhq-N5bqYwWaK5Dg"
53
+ },
54
+ {
55
+ "name": "REFERENCING",
56
+ "ciphertext": "sc1.mc6E1roINLiI5H5U.jkSlIFcn3NxJLgStBEdpelOJ_aB9pouUIOu5YX7yIB0G_maOkBRA2UpFsFoXI1mRppEWwg"
57
+ },
58
+ {
59
+ "name": "ESCAPED_REF",
60
+ "ciphertext": "sc1.obgYCJstXaXFUJJg.tcI9_IOzvpSaXF96WPBowUKxph3dJ_7GvHs"
61
+ },
62
+ {
63
+ "name": "DANGLING_REF",
64
+ "ciphertext": "sc1.VJCnlvqadm3F6lge.kONf1u13yEIWc8pBjSs_XQDR9kmGi0wOTH35QE_QM8qJEhLuDg"
53
65
  }
54
66
  ]
55
67
  }
@@ -60,6 +72,363 @@
60
72
  "DATABASE_URL": "postgres://group/db",
61
73
  "UNICODE": "héllo-🌍-\n-tab\tend",
62
74
  "API_KEY": "sk-app-12345",
63
- "EMPTY": ""
75
+ "EMPTY": "",
76
+ "REFERENCING": "url=postgres://group/db;shared=from-app",
77
+ "ESCAPED_REF": "${SHARED}",
78
+ "DANGLING_REF": "build-${NOT_A_SECRET}"
79
+ },
80
+ "interpolation": {
81
+ "cases": [
82
+ {
83
+ "name": "substitutes a reference",
84
+ "input": {
85
+ "HOST": "db.internal",
86
+ "URL": "postgres://app@${HOST}:5432/db"
87
+ },
88
+ "expected": {
89
+ "HOST": "db.internal",
90
+ "URL": "postgres://app@db.internal:5432/db"
91
+ },
92
+ "unresolved": []
93
+ },
94
+ {
95
+ "name": "expands recursively, repeats, and forward references",
96
+ "input": {
97
+ "ADDR": "${HOST}:${PORT}",
98
+ "HOST": "db.internal",
99
+ "PORT": "5432",
100
+ "PAIR": "${ADDR}|${ADDR}"
101
+ },
102
+ "expected": {
103
+ "ADDR": "db.internal:5432",
104
+ "HOST": "db.internal",
105
+ "PORT": "5432",
106
+ "PAIR": "db.internal:5432|db.internal:5432"
107
+ },
108
+ "unresolved": []
109
+ },
110
+ {
111
+ "name": "leaves unknown references literal and reports them",
112
+ "input": {
113
+ "TAG": "build-${GITHUB_SHA}",
114
+ "OTHER": "${NOPE}-${GITHUB_SHA}"
115
+ },
116
+ "expected": {
117
+ "TAG": "build-${GITHUB_SHA}",
118
+ "OTHER": "${NOPE}-${GITHUB_SHA}"
119
+ },
120
+ "unresolved": [
121
+ "GITHUB_SHA",
122
+ "NOPE"
123
+ ]
124
+ },
125
+ {
126
+ "name": "expands around an unknown reference",
127
+ "input": {
128
+ "HOST": "db.internal",
129
+ "URL": "${HOST}/${MISSING}"
130
+ },
131
+ "expected": {
132
+ "HOST": "db.internal",
133
+ "URL": "db.internal/${MISSING}"
134
+ },
135
+ "unresolved": [
136
+ "MISSING"
137
+ ]
138
+ },
139
+ {
140
+ "name": "$${NAME} escapes to a literal ${NAME}",
141
+ "input": {
142
+ "NAME": "expanded",
143
+ "TEMPLATE": "$${NAME} stays, ${NAME} goes"
144
+ },
145
+ "expected": {
146
+ "NAME": "expanded",
147
+ "TEMPLATE": "${NAME} stays, expanded goes"
148
+ },
149
+ "unresolved": []
150
+ },
151
+ {
152
+ "name": "leaves shell-style, non-name, and unterminated forms alone",
153
+ "input": {
154
+ "FOO": "value",
155
+ "DEFAULTED": "${FOO:-fallback}",
156
+ "POSITIONAL": "${1}",
157
+ "DOTTED": "${a.b}",
158
+ "EMPTY_REF": "${}",
159
+ "UNTERMINATED": "${FOO",
160
+ "PASSWORD": "p$$w0rd$"
161
+ },
162
+ "expected": {
163
+ "FOO": "value",
164
+ "DEFAULTED": "${FOO:-fallback}",
165
+ "POSITIONAL": "${1}",
166
+ "DOTTED": "${a.b}",
167
+ "EMPTY_REF": "${}",
168
+ "UNTERMINATED": "${FOO",
169
+ "PASSWORD": "p$$w0rd$"
170
+ },
171
+ "unresolved": []
172
+ },
173
+ {
174
+ "name": "recognizes a reference after an unterminated one",
175
+ "input": {
176
+ "A": "a",
177
+ "T": "${ ${A}"
178
+ },
179
+ "expected": {
180
+ "A": "a",
181
+ "T": "${ a"
182
+ },
183
+ "unresolved": []
184
+ },
185
+ {
186
+ "name": "handles empty values and multibyte text",
187
+ "input": {
188
+ "EMPTY": "",
189
+ "A": "héllo-🌍",
190
+ "T": "🌍${A}${EMPTY}🌍"
191
+ },
192
+ "expected": {
193
+ "EMPTY": "",
194
+ "A": "héllo-🌍",
195
+ "T": "🌍héllo-🌍🌍"
196
+ },
197
+ "unresolved": []
198
+ },
199
+ {
200
+ "name": "a diamond is not a cycle",
201
+ "input": {
202
+ "BASE": "b",
203
+ "LEFT": "${BASE}-l",
204
+ "RIGHT": "${BASE}-r",
205
+ "TOP": "${LEFT}+${RIGHT}"
206
+ },
207
+ "expected": {
208
+ "BASE": "b",
209
+ "LEFT": "b-l",
210
+ "RIGHT": "b-r",
211
+ "TOP": "b-l+b-r"
212
+ },
213
+ "unresolved": []
214
+ }
215
+ ],
216
+ "cycles": [
217
+ {
218
+ "name": "self reference",
219
+ "input": {
220
+ "A": "x${A}"
221
+ }
222
+ },
223
+ {
224
+ "name": "direct cycle",
225
+ "input": {
226
+ "A": "${B}",
227
+ "B": "${A}"
228
+ }
229
+ },
230
+ {
231
+ "name": "indirect cycle",
232
+ "input": {
233
+ "A": "${B}",
234
+ "B": "${C}",
235
+ "C": "${A}"
236
+ }
237
+ }
238
+ ]
239
+ },
240
+ "substitution": {
241
+ "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`.",
242
+ "cases": [
243
+ {
244
+ "name": "substitutes a placeholder and reports the name",
245
+ "input": "Bearer {{seekrit:OPENAI_API_KEY}}",
246
+ "values": {
247
+ "OPENAI_API_KEY": "sk-live"
248
+ },
249
+ "denied": [],
250
+ "expected": "Bearer sk-live",
251
+ "names": [
252
+ "OPENAI_API_KEY"
253
+ ]
254
+ },
255
+ {
256
+ "name": "passes text with no placeholder through untouched",
257
+ "input": "nothing to see here",
258
+ "values": {
259
+ "OPENAI_API_KEY": "sk-live"
260
+ },
261
+ "denied": [],
262
+ "expected": "nothing to see here",
263
+ "names": []
264
+ },
265
+ {
266
+ "name": "repeats one name as many times as it appears",
267
+ "input": "{{seekrit:OPENAI_API_KEY}}::{{seekrit:OPENAI_API_KEY}}",
268
+ "values": {
269
+ "OPENAI_API_KEY": "sk-live"
270
+ },
271
+ "denied": [],
272
+ "expected": "sk-live::sk-live",
273
+ "names": [
274
+ "OPENAI_API_KEY"
275
+ ]
276
+ },
277
+ {
278
+ "name": "reports two injected names, sorted",
279
+ "input": "{\"a\":\"{{seekrit:STRIPE_KEY}}\",\"b\":\"{{seekrit:OPENAI_API_KEY}}\"}",
280
+ "values": {
281
+ "OPENAI_API_KEY": "sk-live",
282
+ "STRIPE_KEY": "sk_test_1"
283
+ },
284
+ "denied": [],
285
+ "expected": "{\"a\":\"sk_test_1\",\"b\":\"sk-live\"}",
286
+ "names": [
287
+ "OPENAI_API_KEY",
288
+ "STRIPE_KEY"
289
+ ]
290
+ },
291
+ {
292
+ "name": "a denied name aborts the pass",
293
+ "input": "{{seekrit:SOME_OTHER}}",
294
+ "values": {
295
+ "OPENAI_API_KEY": "sk-live"
296
+ },
297
+ "denied": [
298
+ "SOME_OTHER"
299
+ ],
300
+ "error": {
301
+ "code": "denied",
302
+ "name": "SOME_OTHER"
303
+ }
304
+ },
305
+ {
306
+ "name": "an allowed but unresolved name aborts the pass",
307
+ "input": "{{seekrit:MISSING}}",
308
+ "values": {
309
+ "OPENAI_API_KEY": "sk-live"
310
+ },
311
+ "denied": [],
312
+ "error": {
313
+ "code": "unresolved",
314
+ "name": "MISSING"
315
+ }
316
+ },
317
+ {
318
+ "name": "a denial wins even when a valid placeholder came first",
319
+ "input": "{{seekrit:OPENAI_API_KEY}} then {{seekrit:SOME_OTHER}}",
320
+ "values": {
321
+ "OPENAI_API_KEY": "sk-live"
322
+ },
323
+ "denied": [
324
+ "SOME_OTHER"
325
+ ],
326
+ "error": {
327
+ "code": "denied",
328
+ "name": "SOME_OTHER"
329
+ }
330
+ },
331
+ {
332
+ "name": "an unterminated marker is literal",
333
+ "input": "value {{seekrit:OPENAI_API_KEY",
334
+ "values": {
335
+ "OPENAI_API_KEY": "sk-live"
336
+ },
337
+ "denied": [],
338
+ "expected": "value {{seekrit:OPENAI_API_KEY",
339
+ "names": []
340
+ },
341
+ {
342
+ "name": "an illegal character in the name makes the marker literal",
343
+ "input": "{{seekrit:bad-name}}",
344
+ "values": {
345
+ "OPENAI_API_KEY": "sk-live"
346
+ },
347
+ "denied": [],
348
+ "expected": "{{seekrit:bad-name}}",
349
+ "names": []
350
+ },
351
+ {
352
+ "name": "an empty name is not a placeholder",
353
+ "input": "{{seekrit:}}",
354
+ "values": {
355
+ "OPENAI_API_KEY": "sk-live"
356
+ },
357
+ "denied": [],
358
+ "expected": "{{seekrit:}}",
359
+ "names": []
360
+ },
361
+ {
362
+ "name": "whitespace inside the marker is not a placeholder",
363
+ "input": "{{ seekrit:OPENAI_API_KEY }}",
364
+ "values": {
365
+ "OPENAI_API_KEY": "sk-live"
366
+ },
367
+ "denied": [],
368
+ "expected": "{{ seekrit:OPENAI_API_KEY }}",
369
+ "names": []
370
+ },
371
+ {
372
+ "name": "nesting is not expanded recursively",
373
+ "input": "{{seekrit:{{seekrit:OPENAI_API_KEY}}}}",
374
+ "values": {
375
+ "OPENAI_API_KEY": "sk-live"
376
+ },
377
+ "denied": [],
378
+ "expected": "{{seekrit:sk-live}}",
379
+ "names": [
380
+ "OPENAI_API_KEY"
381
+ ]
382
+ },
383
+ {
384
+ "name": "a substituted value is never rescanned",
385
+ "input": "{{seekrit:OPENAI_API_KEY}}",
386
+ "values": {
387
+ "OPENAI_API_KEY": "{{seekrit:STRIPE_KEY}}",
388
+ "STRIPE_KEY": "sk_test_1"
389
+ },
390
+ "denied": [],
391
+ "expected": "{{seekrit:STRIPE_KEY}}",
392
+ "names": [
393
+ "OPENAI_API_KEY"
394
+ ]
395
+ },
396
+ {
397
+ "name": "extra closing braces stay literal",
398
+ "input": "{{seekrit:OPENAI_API_KEY}}}}",
399
+ "values": {
400
+ "OPENAI_API_KEY": "sk-live"
401
+ },
402
+ "denied": [],
403
+ "expected": "sk-live}}",
404
+ "names": [
405
+ "OPENAI_API_KEY"
406
+ ]
407
+ },
408
+ {
409
+ "name": "multibyte text around a placeholder survives",
410
+ "input": "🌍 {{seekrit:OPENAI_API_KEY}} héllo",
411
+ "values": {
412
+ "OPENAI_API_KEY": "sk-live"
413
+ },
414
+ "denied": [],
415
+ "expected": "🌍 sk-live héllo",
416
+ "names": [
417
+ "OPENAI_API_KEY"
418
+ ]
419
+ },
420
+ {
421
+ "name": "an empty value substitutes to nothing",
422
+ "input": "[{{seekrit:EMPTY}}]",
423
+ "values": {
424
+ "EMPTY": ""
425
+ },
426
+ "denied": [],
427
+ "expected": "[]",
428
+ "names": [
429
+ "EMPTY"
430
+ ]
431
+ }
432
+ ]
64
433
  }
65
434
  }
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.2.0
4
+ version: 0.4.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-07-19 00:00:00.000000000 Z
11
+ date: 2026-08-20 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