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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e36b3af31987a52e24e023cfedfdf29832bfd6a707b16d64097b7ae398e8768
4
- data.tar.gz: 267d89e6743ef4db98f7892ff887965a962c01404ab60d4183fc48cc7315ae40
3
+ metadata.gz: 32c907a8f2923c14214ae739aaf8f183b7db26f001f3d3026a8d1ef0764c8fe1
4
+ data.tar.gz: 55c2748d6c1719bcfd0f98df7580664d4bbb1293582df109a446373e10151ae3
5
5
  SHA512:
6
- metadata.gz: a49c25989cb360f7acb086246be37855958925fc2efb39b908c53430e992da4efe8d67610264af5912ba5b667b7e5f35c5aa940bb3b0140710742eb282348651
7
- data.tar.gz: d3273a2237dc9d0632052a2fd20b704f41cb06513110526af61b615fdf31767762c154641537235803dc297ec901e9de3047dc3fbcaaf582ddf3341443da7b1e
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.
@@ -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
- 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
 
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.
@@ -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>_<pkcs8 private key>
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
- def materialize(resolve_response, key)
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
@@ -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.5.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,8 @@
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_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.BEoGSn3SoICO6tA13EjveZHvxTTRVijE0OUpBkzVkFU28q_jfgY4P9IkyrpjMVJK27zogh61rwC_NUBjoe2TKhY.5EI1On9xCtVuntRHMA6xFA.JNqiqNRxDpNN3hkP.8RxEujnGaEIUIVq9VVcOZlpC07-gxgyz25VCtffNZWSRh8J8iFfMA6p-_ubXSfv5",
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.4XNyaS_ilRPDTlIg.LhAGzqP_cm4fx_dKeST8_3NITle_77x0Qjo"
25
+ "ciphertext": "sc1.oIjnQEJskdJ_nweV.MGETgRWPDkwhHm9e_jp567-b19Fn8DJohrU"
25
26
  },
26
27
  {
27
28
  "name": "DATABASE_URL",
28
- "ciphertext": "sc1.-THIwVQA7yVq6SWt.1RaVPddl8QDuhc2wValcmtArCxafHBCAk24Fv00gB_FNc6k"
29
+ "ciphertext": "sc1.Fo69NaVLlUDFtGsk.tr8o0e7mMSB7DWkfZuv0eLOsNq7Fm-3dd2zOiPSXSHiwXbg"
29
30
  },
30
31
  {
31
32
  "name": "UNICODE",
32
- "ciphertext": "sc1.7qB3RU8qhHKpqwbB.BsKeZYWDv4Qp9cWIB0I684pLazslk2nFtIq-OvgDjIaTaw3Pbw"
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.BMywXqAVq3Ee3XX7mGBt2KLcRRwPLQn9FjlXc19Z8HNdDPsg-KyKLwg_uz4tgQ8MdVevMgtjfBkOiqmAi7ENI2Y.FLBNZ3z8-8CnYoBN-Azr9g.2x8wkp6BSw9QjnG2.3b32qenHIHC92eCehRW6QGWNFTlIe3nTKPje0WsRFWSHkjV6PYIxCXmlQaAJJkp3",
41
+ "wrappedDek": "wd1.BHVKzCsJECbpyULjVjFe-vw5mmKEJ26MZz02wc1RfzgozIRx9CrCFJJ0RkYWT_iuvAAG2rN7WNg5uNt0RQ6X14Q.Wfuvupy_B16u453L04UpSA.YPq62FodkXeezDd3.HgXwlx5gZRAnqmyk0WSz4HW9qVrb-DfEsvD76HKXkgigAShwBJ9g1qiVtSUgUgCo",
41
42
  "secrets": [
42
43
  {
43
44
  "name": "SHARED",
44
- "ciphertext": "sc1.QF697pJ61KM9Xqj2.HH9PNlW7aIIWZvpLm6yc8ZCRRemlxoOS"
45
+ "ciphertext": "sc1.hVO-Xgq16bV52VDQ.gePrTLVlrjIINJCs_gomYWnwqS4sD1CP"
45
46
  },
46
47
  {
47
48
  "name": "API_KEY",
48
- "ciphertext": "sc1.x4G_KqJP7ags8SBf.obw89lhKqcMAy1vzjdTjch88R7Trx9JtFWS2Rw"
49
+ "ciphertext": "sc1.KpB-GlAGsWiNUN5p.PY5HnW1tOA-ZCBSSslnqFld4sPGWLujiW_j8gQ"
49
50
  },
50
51
  {
51
52
  "name": "EMPTY",
52
- "ciphertext": "sc1.qdub6o6QejpKH97e.KFe1fiDvtHF3agstKhL5ng"
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.2.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-07-19 00:00:00.000000000 Z
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: []