envless-sdk 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +171 -0
  4. data/lib/envless/sdk/api_error.rb +54 -0
  5. data/lib/envless/sdk/bodies.rb +49 -0
  6. data/lib/envless/sdk/client.rb +39 -0
  7. data/lib/envless/sdk/clock.rb +24 -0
  8. data/lib/envless/sdk/connection_pool.rb +83 -0
  9. data/lib/envless/sdk/connector.rb +26 -0
  10. data/lib/envless/sdk/constants.rb +262 -0
  11. data/lib/envless/sdk/crypto/cipher.rb +58 -0
  12. data/lib/envless/sdk/crypto/codec.rb +23 -0
  13. data/lib/envless/sdk/crypto/envelope.rb +41 -0
  14. data/lib/envless/sdk/crypto/keys.rb +42 -0
  15. data/lib/envless/sdk/crypto/passphrase.rb +38 -0
  16. data/lib/envless/sdk/crypto/passphrase_rules.rb +42 -0
  17. data/lib/envless/sdk/crypto/values.rb +31 -0
  18. data/lib/envless/sdk/crypto/workspace_key.rb +40 -0
  19. data/lib/envless/sdk/crypto_messages.rb +31 -0
  20. data/lib/envless/sdk/default_client.rb +55 -0
  21. data/lib/envless/sdk/defaults.rb +32 -0
  22. data/lib/envless/sdk/envelope.rb +48 -0
  23. data/lib/envless/sdk/environment.rb +42 -0
  24. data/lib/envless/sdk/errors.rb +23 -0
  25. data/lib/envless/sdk/exchange.rb +27 -0
  26. data/lib/envless/sdk/helpers.rb +49 -0
  27. data/lib/envless/sdk/http.rb +46 -0
  28. data/lib/envless/sdk/listing.rb +54 -0
  29. data/lib/envless/sdk/messages.rb +43 -0
  30. data/lib/envless/sdk/net_http_adapter.rb +84 -0
  31. data/lib/envless/sdk/numbers.rb +44 -0
  32. data/lib/envless/sdk/options.rb +56 -0
  33. data/lib/envless/sdk/pagination.rb +31 -0
  34. data/lib/envless/sdk/paths.rb +70 -0
  35. data/lib/envless/sdk/received.rb +28 -0
  36. data/lib/envless/sdk/redirects.rb +56 -0
  37. data/lib/envless/sdk/resource.rb +50 -0
  38. data/lib/envless/sdk/resources/environments.rb +61 -0
  39. data/lib/envless/sdk/resources/invites.rb +28 -0
  40. data/lib/envless/sdk/resources/keys.rb +36 -0
  41. data/lib/envless/sdk/resources/me.rb +11 -0
  42. data/lib/envless/sdk/resources/members.rb +36 -0
  43. data/lib/envless/sdk/resources/product_access.rb +30 -0
  44. data/lib/envless/sdk/resources/products.rb +62 -0
  45. data/lib/envless/sdk/resources/projects.rb +42 -0
  46. data/lib/envless/sdk/resources/roles.rb +40 -0
  47. data/lib/envless/sdk/resources/variable_changes.rb +57 -0
  48. data/lib/envless/sdk/resources/variables.rb +49 -0
  49. data/lib/envless/sdk/resources/versions.rb +60 -0
  50. data/lib/envless/sdk/resources/webhook_deliveries.rb +33 -0
  51. data/lib/envless/sdk/resources/webhooks.rb +44 -0
  52. data/lib/envless/sdk/resources/workspace.rb +26 -0
  53. data/lib/envless/sdk/resources/workspace_domain.rb +31 -0
  54. data/lib/envless/sdk/resources/workspaces.rb +24 -0
  55. data/lib/envless/sdk/results.rb +43 -0
  56. data/lib/envless/sdk/retry_policy.rb +36 -0
  57. data/lib/envless/sdk/rotation/entry_rotator.rb +61 -0
  58. data/lib/envless/sdk/rotation/key_ring.rb +34 -0
  59. data/lib/envless/sdk/rotation/ledger.rb +36 -0
  60. data/lib/envless/sdk/rotation/runner.rb +45 -0
  61. data/lib/envless/sdk/rotation/variable_rotator.rb +38 -0
  62. data/lib/envless/sdk/text.rb +71 -0
  63. data/lib/envless/sdk/transport.rb +62 -0
  64. data/lib/envless/sdk/update_notice.rb +66 -0
  65. data/lib/envless/sdk/urls.rb +70 -0
  66. data/lib/envless/sdk/variable_names.rb +55 -0
  67. data/lib/envless/sdk/version.rb +5 -0
  68. data/lib/envless/sdk/webhooks/headers.rb +49 -0
  69. data/lib/envless/sdk/webhooks/verify.rb +58 -0
  70. data/lib/envless/sdk/wire.rb +77 -0
  71. data/lib/envless/sdk.rb +77 -0
  72. data/sig/envless/sdk/adapter.rbs +60 -0
  73. data/sig/envless/sdk/client.rbs +31 -0
  74. data/sig/envless/sdk/crypto.rbs +92 -0
  75. data/sig/envless/sdk/errors.rbs +37 -0
  76. data/sig/envless/sdk/http.rbs +31 -0
  77. data/sig/envless/sdk/pagination.rbs +81 -0
  78. data/sig/envless/sdk/results.rbs +62 -0
  79. data/sig/envless/sdk/rotation.rbs +86 -0
  80. data/sig/envless/sdk/settings.rbs +81 -0
  81. data/sig/envless/sdk/stdlib.rbs +8 -0
  82. data/sig/envless/sdk/text.rbs +52 -0
  83. data/sig/envless/sdk/transport.rbs +79 -0
  84. data/sig/envless/sdk/urls.rbs +73 -0
  85. data/sig/envless/sdk/webhooks.rbs +23 -0
  86. data/sig/envless/sdk.rbs +36 -0
  87. data/sig/generated/constants.rbs +57 -0
  88. data/sig/generated/resources.rbs +209 -0
  89. data/sig/generated/types.rbs +466 -0
  90. data/sig/manifest.yaml +3 -0
  91. metadata +134 -0
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Constants
5
+ CIPHERTEXT_MESSAGES = {UNSUPPORTED: "Unsupported ciphertext format."}
6
+
7
+ CIPHERTEXT_PREFIXES = ["v1:", "v2:"]
8
+
9
+ COLLECTIONS = {
10
+ ACCESS: "access",
11
+ EVENTS: "events",
12
+ WORKSPACES: "workspaces",
13
+ PRODUCTS: "products",
14
+ PROJECTS: "projects",
15
+ ENVIRONMENTS: "environments",
16
+ VARIABLES: "variables",
17
+ HISTORY: "history",
18
+ VERSIONS: "versions",
19
+ ENTRIES: "entries",
20
+ MEMBERS: "members",
21
+ INVITES: "invites",
22
+ ROLES: "roles",
23
+ KEYS: "keys",
24
+ WEBHOOKS: "webhooks",
25
+ DELIVERIES: "deliveries"
26
+ }
27
+
28
+ CONTENT_TYPES = {JSON: "application/json"}
29
+
30
+ CRYPTO = {
31
+ PBKDF2_ITERATIONS: 200000,
32
+ PBKDF2_ITERATIONS_MIN: 100000,
33
+ PBKDF2_ITERATIONS_MAX: 1000000,
34
+ HASH: "SHA-256",
35
+ AES_ALGORITHM: "AES-GCM",
36
+ KDF_ALGORITHM: "PBKDF2",
37
+ HMAC_ALGORITHM: "HMAC",
38
+ AES_LENGTH: 256,
39
+ IV_LENGTH: 12,
40
+ SALT_LENGTH: 16,
41
+ WORKSPACE_SALT_NAMESPACE: "envless:workspace:",
42
+ CIPHERTEXT_VERSION_PREFIX: "v1:",
43
+ CIPHERTEXT_VERSION_PREFIX_V2: "v2:"
44
+ }
45
+
46
+ EMPTY_PAGINATION = {limit: 0, offset: 0, totalCount: 0, hasMore: false}
47
+
48
+ ENCRYPTION_PARAMETERS = {
49
+ pbkdf2Iterations: 200000,
50
+ hash: "SHA-256",
51
+ aesAlgorithm: "AES-GCM",
52
+ kdfAlgorithm: "PBKDF2",
53
+ aesLength: 256,
54
+ ivLength: 12,
55
+ saltLength: 16,
56
+ saltNamespace: "envless:workspace:",
57
+ ciphertextVersionPrefix: "v1:"
58
+ }
59
+
60
+ ENV_KEY_PATTERN = /\A[A-Za-z_][A-Za-z0-9_]*\z/
61
+
62
+ ENV_VARS = {TOKEN: "ENVLESS_TOKEN", BASE_URL: "ENVLESS_API_ENDPOINT", PASSPHRASE: "ENVLESS_PASSPHRASE"}
63
+
64
+ ERROR_CODES = {BODY_INVALID: "request.body_invalid", SERVER_ERROR: "server.error"}
65
+
66
+ HEADER_KEYS = {
67
+ AUTHORIZATION: "Authorization",
68
+ ACCEPT: "Accept",
69
+ CONTENT_TYPE: "Content-Type",
70
+ USER_AGENT: "User-Agent",
71
+ RETRY_AFTER: "retry-after"
72
+ }
73
+
74
+ HTTP_METHODS = {GET: "GET", POST: "POST", PUT: "PUT", PATCH: "PATCH", DELETE: "DELETE"}
75
+
76
+ INPUT_LIMITS = {
77
+ VARIABLE_NAME: {MIN: 1, MAX: 100}
78
+ }
79
+
80
+ KEY_SCOPE_LEVELS = {NONE: "none", READ: "read", WRITE: "write"}
81
+
82
+ KEY_SCOPE_RESOURCES = {
83
+ WORKSPACE: "workspace",
84
+ PRODUCTS: "products",
85
+ PRODUCT_ACCESS: "product-access",
86
+ PROJECTS: "projects",
87
+ ENVIRONMENTS: "environments",
88
+ VARIABLES: "variables",
89
+ VERSIONS: "versions",
90
+ MEMBERS: "members",
91
+ ROLES: "roles",
92
+ KEYS: "keys",
93
+ WEBHOOKS: "webhooks"
94
+ }
95
+
96
+ LOCAL_REFUSAL = {STATUS: 400, VARIABLE_RESOURCE: "variable", NAME_FIELD: "name"}
97
+
98
+ PAGINATION = {MAX_LIMIT: 100, DEFAULT_LIMIT: 25}
99
+
100
+ PASSPHRASE_CHARACTER_CLASSES = {
101
+ LOWERCASE: /[a-z]/,
102
+ UPPERCASE: /[A-Z]/,
103
+ DIGIT: /[0-9]/,
104
+ SYMBOL: /[^a-zA-Z0-9]/
105
+ }
106
+
107
+ PASSPHRASE_STRENGTH = {
108
+ MIN_LENGTH: 16,
109
+ MIN_CHARACTER_CLASSES: 3,
110
+ LONG_LENGTH: 24,
111
+ LONG_MIN_CHARACTER_CLASSES: 2,
112
+ MAX_REPEATED_RUN: 3,
113
+ MAX_SEQUENTIAL_RUN: 3,
114
+ WEAK_FRAGMENT_COVERAGE: 0.5
115
+ }
116
+
117
+ PASSPHRASE_STRENGTH_ERRORS = {
118
+ REQUIRED: "Passphrase is required.",
119
+ TOO_SHORT: "Passphrase must be at least {{count}} characters long.",
120
+ LOW_VARIETY: "Passphrase must combine at least {{count}} of these: lowercase letters, uppercase letters, numbers, symbols. A longer passphrase needs fewer of them.",
121
+ REPEATED: "Passphrase must not repeat the same character {{count}} times in a row.",
122
+ SEQUENCE: "Passphrase must not contain an obvious run like 1234 or abcd.",
123
+ COMMON: "Passphrase contains a well known weak pattern. Pick something less predictable."
124
+ }
125
+
126
+ PATHS = {
127
+ ME: "/me",
128
+ WORKSPACE: "/workspace",
129
+ WORKSPACES: "/workspaces",
130
+ WORKSPACE_DOMAIN: "/workspace/domain",
131
+ WORKSPACE_DOMAIN_VERIFY: "/workspace/domain/verify",
132
+ PRODUCTS: "/products",
133
+ PERMISSIONS: "/permissions",
134
+ ROLES: "/roles",
135
+ INVITES: "/invites",
136
+ MEMBERS: "/members",
137
+ MEMBERS_ME: "/members/me",
138
+ PROJECTS: "/projects",
139
+ VARIABLES: "/variables",
140
+ VERSIONS: "/versions",
141
+ KEYS: "/keys",
142
+ WEBHOOKS: "/webhooks",
143
+ WEBHOOK_EVENT_TYPES: "/webhooks/event-types"
144
+ }
145
+
146
+ QUERY_KEYS = {
147
+ LIMIT: "limit",
148
+ OFFSET: "offset",
149
+ SEARCH: "search",
150
+ ORDER: "order",
151
+ PRODUCT: "product",
152
+ STATUS: "status"
153
+ }
154
+
155
+ REENCRYPT_BATCH_SIZE = 100
156
+
157
+ RETRY = {
158
+ STATUSES: [408, 429, 500, 502, 503, 504],
159
+ METHODS: ["GET", "HEAD", "PUT", "DELETE"],
160
+ BASE_BACKOFF_MS: 250,
161
+ MAX_BACKOFF_MS: 30000
162
+ }
163
+
164
+ ROTATION_FIELDS = {VALUE: "value", DEFAULT_VALUE: "defaultValue"}
165
+
166
+ ROTATION_MESSAGES = {
167
+ NOT_CIPHERTEXT: "The stored value is not ciphertext, so it was left untouched.",
168
+ DEFAULT_STRANDED: "The entry has no ciphertext value to rewrite it with, so this encrypted default still needs the old passphrase."
169
+ }
170
+
171
+ ROTATION_SCOPES = {VARIABLE: "variable", VERSION_ENTRY: "versionEntry"}
172
+
173
+ SEGMENTS = {
174
+ ACCESS: "access",
175
+ APPLY: "apply",
176
+ BULK: "bulk",
177
+ DELIVERIES: "deliveries",
178
+ DUPLICATE: "duplicate",
179
+ ENTRIES: "entries",
180
+ ENVIRONMENTS: "environments",
181
+ EXPOSE: "expose",
182
+ HISTORY: "history",
183
+ PROJECTS: "projects",
184
+ REDELIVER: "redeliver",
185
+ REENCRYPT: "reencrypt",
186
+ RESEND: "resend",
187
+ ROLLBACK: "rollback",
188
+ ROTATE: "rotate",
189
+ VARIABLES: "variables",
190
+ VERSIONS: "versions"
191
+ }
192
+
193
+ VARIABLE_NAME_ERRORS = {
194
+ REQUIRED: "A variable name is required.",
195
+ TOO_LONG: "Variable name must be at most {{count}} characters long.",
196
+ SHAPE: "Variable name can only use letters, numbers and underscores, and cannot start with a number."
197
+ }
198
+
199
+ VARIABLE_TYPES = {STRING: "string", NUMBER: "number", BOOLEAN: "boolean"}
200
+
201
+ VARIABLE_VISIBILITY = {SERVER: "server", CLIENT: "client"}
202
+
203
+ WEAK_PASSPHRASE_FRAGMENTS = {
204
+ PASSWORD: "password",
205
+ PASSPHRASE: "passphrase",
206
+ QWERTY: "qwerty",
207
+ ASDFGH: "asdfgh",
208
+ ZXCVBN: "zxcvbn",
209
+ LETMEIN: "letmein",
210
+ CHANGEME: "changeme",
211
+ ILOVEYOU: "iloveyou",
212
+ TRUSTNO: "trustno1",
213
+ ABC123: "abc123",
214
+ ENVLESS: "envless",
215
+ SECRET123: "secret123",
216
+ ADMIN123: "admin123",
217
+ CORRECTHORSE: "correcthorse"
218
+ }
219
+
220
+ WEBHOOK_HEADERS = {
221
+ ID: "webhook-id",
222
+ TIMESTAMP: "webhook-timestamp",
223
+ SIGNATURE: "webhook-signature",
224
+ EVENT: "Envless-Event",
225
+ DELIVERY: "Envless-Delivery"
226
+ }
227
+
228
+ WEBHOOK_SIGNATURE = {VERSION: "v1", SEPARATOR: ",", TOLERANCE_SECONDS: 300}
229
+
230
+ PAGE_LIMITS = PAGINATION
231
+
232
+ SCOPE_LEVELS = KEY_SCOPE_LEVELS
233
+
234
+ SCOPE_RESOURCES = KEY_SCOPE_RESOURCES
235
+
236
+ VARIABLE_TYPE = VARIABLE_TYPES
237
+
238
+ VARIABLE_VISIBILITY_LEVELS = VARIABLE_VISIBILITY
239
+
240
+ WEBHOOK_SIGNATURE_HEADERS = WEBHOOK_HEADERS
241
+
242
+ BASE64_NON_ALPHABET_PATTERN = /[^A-Za-z0-9+\/]/
243
+
244
+ ENDPOINT_SCHEME_PATTERN = /\A[a-z][a-z0-9+.-]*:\/\//i
245
+
246
+ LOCAL_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0", "::1"]
247
+
248
+ RETRYABLE_METHODS = Set["GET", "HEAD", "PUT", "DELETE"]
249
+
250
+ RETRYABLE_STATUSES = Set[408, 429, 500, 502, 503, 504]
251
+
252
+ constants.each { |name| Ractor.make_shareable(const_get(name)) }
253
+ end
254
+
255
+ ENCRYPTION_PARAMETERS = Constants::ENCRYPTION_PARAMETERS
256
+ PAGE_LIMITS = Constants::PAGE_LIMITS
257
+ SCOPE_LEVELS = Constants::SCOPE_LEVELS
258
+ SCOPE_RESOURCES = Constants::SCOPE_RESOURCES
259
+ VARIABLE_TYPE = Constants::VARIABLE_TYPE
260
+ VARIABLE_VISIBILITY_LEVELS = Constants::VARIABLE_VISIBILITY_LEVELS
261
+ WEBHOOK_SIGNATURE_HEADERS = Constants::WEBHOOK_SIGNATURE_HEADERS
262
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Crypto
5
+ module Cipher
6
+ CRYPTO = Constants::CRYPTO
7
+ ALGORITHM = "aes-256-gcm"
8
+ TAG_BYTES = 16
9
+
10
+ module_function
11
+
12
+ def encrypt_with_key(key, plaintext)
13
+ iv = OpenSSL::Random.random_bytes(CRYPTO[:IV_LENGTH])
14
+ cipher = OpenSSL::Cipher.new(ALGORITHM).encrypt
15
+ cipher.key = Keys.key_of(key).to_bytes
16
+ cipher.iv = iv
17
+ cipher.auth_data = ""
18
+ bytes = Keys.text(plaintext, "plaintext")
19
+ sealed = (bytes.empty? ? "".b : cipher.update(bytes)) + cipher.final
20
+
21
+ "#{CRYPTO[:CIPHERTEXT_VERSION_PREFIX]}#{Codec.to_base64(iv + sealed + cipher.auth_tag)}"
22
+ end
23
+
24
+ def decrypt(key, encoded)
25
+ combined = Codec.from_base64(Envelope.parse(encoded).payload)
26
+ raise DecryptionError, CryptoMessages::TOO_SHORT if combined.bytesize < CRYPTO[:IV_LENGTH] + 1
27
+
28
+ secret = Keys.key_of(key)
29
+ sealed = combined.byteslice(CRYPTO[:IV_LENGTH]..).to_s
30
+ raise DecryptionError, CryptoMessages::UNDECRYPTABLE if sealed.bytesize < TAG_BYTES
31
+
32
+ Text.decode(unseal(secret, combined.byteslice(0, CRYPTO[:IV_LENGTH]).to_s, sealed))
33
+ end
34
+
35
+ def unseal(key, iv, sealed)
36
+ cipher = OpenSSL::Cipher.new(ALGORITHM).decrypt
37
+ cipher.key = key.to_bytes
38
+ cipher.iv = iv
39
+ cipher.auth_tag = sealed.byteslice(-TAG_BYTES, TAG_BYTES).to_s
40
+ cipher.auth_data = ""
41
+ body = sealed.byteslice(0, sealed.bytesize - TAG_BYTES).to_s
42
+ (body.empty? ? "".b : cipher.update(body)) + cipher.final
43
+ rescue OpenSSL::Cipher::CipherError
44
+ raise DecryptionError, CryptoMessages::UNDECRYPTABLE
45
+ end
46
+
47
+ def decrypt_with_key(key, ciphertext)
48
+ iterations = Envelope.parse(ciphertext).iterations
49
+
50
+ if iterations && iterations != CRYPTO[:PBKDF2_ITERATIONS]
51
+ raise DecryptionError, format(CryptoMessages::PINNED_COST, count: iterations)
52
+ end
53
+
54
+ decrypt(key, ciphertext)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Crypto
5
+ module Codec
6
+ module_function
7
+
8
+ def to_base64(bytes)
9
+ [bytes].pack("m0")
10
+ end
11
+
12
+ def from_base64(value)
13
+ cleaned = Text.utf8(value).gsub(Constants::BASE64_NON_ALPHABET_PATTERN, "")
14
+ usable = cleaned[0, cleaned.length - ((cleaned.length % 4 == 1) ? 1 : 0)].to_s
15
+ "#{usable}#{"=" * (-usable.length % 4)}".unpack1("m").to_s.b
16
+ end
17
+
18
+ def from_base64_url(value)
19
+ from_base64(Text.utf8(value).tr("-_", "+/"))
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Crypto
5
+ CiphertextEnvelope = Data.define(:payload, :iterations)
6
+
7
+ module Envelope
8
+ CRYPTO = Constants::CRYPTO
9
+ V1 = CRYPTO[:CIPHERTEXT_VERSION_PREFIX]
10
+ V2 = CRYPTO[:CIPHERTEXT_VERSION_PREFIX_V2]
11
+ UNSUPPORTED = Constants::CIPHERTEXT_MESSAGES[:UNSUPPORTED]
12
+
13
+ module_function
14
+
15
+ def parse(encoded)
16
+ raise DecryptionError, UNSUPPORTED unless encoded.is_a?(String)
17
+
18
+ text = Text.utf8(encoded)
19
+ return versioned(text.delete_prefix(V2)) if text.start_with?(V2)
20
+ return CiphertextEnvelope.new(payload: text.delete_prefix(V1), iterations: nil) if text.start_with?(V1)
21
+
22
+ raise DecryptionError, UNSUPPORTED
23
+ end
24
+
25
+ def versioned(rest)
26
+ separator = rest.index(":")
27
+ raise DecryptionError, UNSUPPORTED if separator.nil?
28
+
29
+ CiphertextEnvelope.new(payload: rest[(separator + 1)..].to_s, iterations: iterations(rest[0, separator].to_s))
30
+ end
31
+
32
+ def iterations(text)
33
+ value = Numbers.js_number(text)
34
+ raise DecryptionError, UNSUPPORTED unless value.finite? && value == value.floor
35
+ raise DecryptionError, UNSUPPORTED unless value.between?(CRYPTO[:PBKDF2_ITERATIONS_MIN], CRYPTO[:PBKDF2_ITERATIONS_MAX])
36
+
37
+ value.to_i
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Crypto
5
+ module Keys
6
+ CRYPTO = Constants::CRYPTO
7
+ DIGEST = CRYPTO[:HASH].delete("-")
8
+
9
+ module_function
10
+
11
+ def key_of(key)
12
+ key.is_a?(WorkspaceKey) ? key : WorkspaceKey.new(key)
13
+ end
14
+
15
+ def workspace_salt(workspace_unique_id)
16
+ material = "#{CRYPTO[:WORKSPACE_SALT_NAMESPACE]}#{text(workspace_unique_id, "workspace_unique_id")}"
17
+ OpenSSL::Digest.digest(DIGEST, material).byteslice(0, CRYPTO[:SALT_LENGTH]).to_s
18
+ end
19
+
20
+ def derive_key(passphrase, salt, iterations = CRYPTO[:PBKDF2_ITERATIONS])
21
+ secret = text(passphrase, "passphrase")
22
+ raw = OpenSSL::KDF.pbkdf2_hmac(secret, salt:, iterations:, length: WorkspaceKey::BYTES, hash: DIGEST)
23
+ WorkspaceKey.new(raw)
24
+ end
25
+
26
+ def derive_workspace_key(passphrase, workspace_unique_id)
27
+ raise ArgumentError, CryptoMessages::PASSPHRASE_REQUIRED if passphrase.nil? || passphrase == ""
28
+ raise ArgumentError, CryptoMessages::WORKSPACE_REQUIRED if workspace_unique_id.nil? || workspace_unique_id == ""
29
+
30
+ derive_key(passphrase, workspace_salt(workspace_unique_id))
31
+ end
32
+
33
+ def import_workspace_key(value)
34
+ WorkspaceKey.new(Codec.from_base64_url(text(value, "key")))
35
+ end
36
+
37
+ def text(value, name)
38
+ Text.strict(value, name)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Crypto
5
+ module Passphrase
6
+ RULES = Constants::PASSPHRASE_STRENGTH
7
+ ERRORS = Constants::PASSPHRASE_STRENGTH_ERRORS
8
+
9
+ module_function
10
+
11
+ def strength(passphrase)
12
+ return refused(ERRORS[:REQUIRED]) unless passphrase.is_a?(String) && !passphrase.empty?
13
+
14
+ text = Text.utf8(passphrase)
15
+ units = Text.utf16_units(text)
16
+ return refused(with_count(ERRORS[:TOO_SHORT], RULES[:MIN_LENGTH])) if units.size < RULES[:MIN_LENGTH]
17
+
18
+ required = (units.size >= RULES[:LONG_LENGTH]) ? RULES[:LONG_MIN_CHARACTER_CLASSES] : RULES[:MIN_CHARACTER_CLASSES]
19
+ present = Constants::PASSPHRASE_CHARACTER_CLASSES.values.count { |pattern| pattern.match?(text) }
20
+
21
+ return refused(with_count(ERRORS[:LOW_VARIETY], required)) if present < required
22
+ return refused(with_count(ERRORS[:REPEATED], RULES[:MAX_REPEATED_RUN] + 1)) if PassphraseRules.repeated_run?(units)
23
+ return refused(ERRORS[:SEQUENCE]) if PassphraseRules.sequential_run?(units)
24
+ return refused(ERRORS[:COMMON]) if PassphraseRules.weak_fragment?(text)
25
+
26
+ Validation.new(valid: true, error: nil)
27
+ end
28
+
29
+ def refused(error)
30
+ Validation.new(valid: false, error:)
31
+ end
32
+
33
+ def with_count(message, count)
34
+ message.sub("{{count}}", count.to_s)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Crypto
5
+ module PassphraseRules
6
+ RULES = Constants::PASSPHRASE_STRENGTH
7
+ NOT_ALPHANUMERIC = /[^a-z0-9]/
8
+
9
+ module_function
10
+
11
+ def repeated_run?(units)
12
+ run = 1
13
+
14
+ (1...units.size).any? do |index|
15
+ run = (units.fetch(index) == units.fetch(index - 1)) ? run + 1 : 1
16
+ run > RULES[:MAX_REPEATED_RUN]
17
+ end
18
+ end
19
+
20
+ def sequential_run?(units)
21
+ ascending = 1
22
+ descending = 1
23
+
24
+ (1...units.size).any? do |index|
25
+ step = units.fetch(index) - units.fetch(index - 1)
26
+ ascending = (step == 1) ? ascending + 1 : 1
27
+ descending = (step == -1) ? descending + 1 : 1
28
+ [ascending, descending].max > RULES[:MAX_SEQUENTIAL_RUN]
29
+ end
30
+ end
31
+
32
+ def weak_fragment?(text)
33
+ skeleton = text.downcase.gsub(NOT_ALPHANUMERIC, "")
34
+ return false if skeleton.empty?
35
+
36
+ Constants::WEAK_PASSPHRASE_FRAGMENTS.values.any? do |fragment|
37
+ skeleton.length - skeleton.gsub(fragment, "").length >= skeleton.length * RULES[:WEAK_FRAGMENT_COVERAGE]
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Crypto
5
+ module Values
6
+ module_function
7
+
8
+ def encrypt_value(plaintext, passphrase, workspace_unique_id)
9
+ Cipher.encrypt_with_key(Keys.derive_workspace_key(passphrase, workspace_unique_id), plaintext)
10
+ end
11
+
12
+ def decrypt_value(ciphertext, passphrase, workspace_unique_id)
13
+ iterations = Envelope.parse(ciphertext).iterations
14
+ key = if iterations.nil?
15
+ Keys.derive_workspace_key(passphrase, workspace_unique_id)
16
+ else
17
+ Keys.derive_key(passphrase, Keys.workspace_salt(workspace_unique_id), iterations)
18
+ end
19
+
20
+ Cipher.decrypt(key, ciphertext)
21
+ end
22
+
23
+ def ciphertext?(value)
24
+ return false unless value.is_a?(String)
25
+
26
+ text = Text.utf8(value)
27
+ Constants::CIPHERTEXT_PREFIXES.any? { |prefix| text.start_with?(prefix) }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ class WorkspaceKey
5
+ BITS = Constants::CRYPTO[:AES_LENGTH]
6
+ BYTES = BITS / 8
7
+
8
+ def initialize(raw)
9
+ raise ArgumentError, CryptoMessages::KEY_SHAPE unless raw.is_a?(String) && raw.encoding == Encoding::BINARY
10
+
11
+ bytes = raw.b
12
+ raise ArgumentError, format(CryptoMessages::KEY_LENGTH, bits: BITS, count: bytes.bytesize) unless bytes.bytesize == BYTES
13
+
14
+ @raw = bytes.freeze
15
+ freeze
16
+ end
17
+
18
+ def to_bytes
19
+ @raw.dup
20
+ end
21
+
22
+ def ==(other)
23
+ other.is_a?(WorkspaceKey) && OpenSSL.fixed_length_secure_compare(@raw, other.to_bytes)
24
+ end
25
+
26
+ alias_method :eql?, :==
27
+
28
+ def hash
29
+ [WorkspaceKey, @raw].hash
30
+ end
31
+
32
+ def inspect
33
+ "#<#{self.class.name} #{Defaults::REDACTED}>"
34
+ end
35
+
36
+ def to_s
37
+ inspect
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module CryptoMessages
5
+ PASSPHRASE_REQUIRED = "A workspace passphrase is required. An empty passphrase would produce ciphertext anyone " \
6
+ "can decrypt."
7
+ WORKSPACE_REQUIRED = "A workspace id is required. The encryption salt is derived from it."
8
+ PINNED_COST = "This value pins %<count>s PBKDF2 iterations, which the supplied key was not derived with. Use " \
9
+ "decrypt_value with the passphrase instead, so the key is derived at the cost the value was written with."
10
+ TOO_SHORT = "Ciphertext payload is too short."
11
+ UNDECRYPTABLE = "The value could not be decrypted: the passphrase or key is not the one it was encrypted with, " \
12
+ "or the ciphertext was altered."
13
+ KEY_LENGTH = "Invalid %<bits>s-bit key: got %<count>s bytes."
14
+ KEY_SHAPE = "A workspace key must be an Envless::WorkspaceKey from derive_workspace_key or import_workspace_key, or " \
15
+ "its 32 raw bytes as a binary String (String#b). Text is refused, so a passphrase is never taken for key bytes."
16
+ TEXT_SHAPE = "%<name>s must be a String."
17
+ UTF8_SHAPE = "%<name>s must be valid UTF-8 text. Binary data does not survive the round trip, so encode it first, " \
18
+ "for example as base64."
19
+ WEAK_ROTATION = "Refusing to rotate: the new passphrase is too weak. %<error>s"
20
+ WEBHOOK_MISSING_HEADERS = "Missing webhook-id, webhook-timestamp or webhook-signature header."
21
+ WEBHOOK_TIMESTAMP_SHAPE = "webhook-timestamp is not a unix timestamp in seconds."
22
+ WEBHOOK_OUT_OF_TOLERANCE = "Webhook timestamp is %<drift>ss out of tolerance (%<tolerance>ss)."
23
+ WEBHOOK_MISSING_SECRET = "A webhook signing secret is required. Pass the secret shown when the webhook was " \
24
+ "created, as secret:."
25
+ WEBHOOK_MISMATCH = "Webhook signature does not match the payload."
26
+ WEBHOOK_NOT_JSON = "The webhook payload is not JSON."
27
+ WEBHOOK_PAYLOAD_SHAPE = "Pass the raw request body as a String, exactly as it arrived. A parsed or re-encoded " \
28
+ "body no longer matches its signature."
29
+ WEBHOOK_TOLERANCE_SHAPE = "tolerance_seconds: must be a finite number of seconds, 0 or more."
30
+ end
31
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ @default_client = nil
5
+ @default_lock = Mutex.new
6
+
7
+ class << self
8
+ def new(...)
9
+ Client.new(...)
10
+ end
11
+
12
+ def create_client(...)
13
+ Client.new(...)
14
+ end
15
+
16
+ def init(...)
17
+ created = Client.new(...)
18
+ @default_lock.synchronize { @default_client = created }
19
+ end
20
+
21
+ def client
22
+ @default_lock.synchronize { @default_client ||= Client.new }
23
+ end
24
+
25
+ def reset_client
26
+ @default_lock.synchronize { @default_client = nil }
27
+ end
28
+
29
+ def me = client.me
30
+
31
+ def workspace = client.workspace
32
+
33
+ def workspaces = client.workspaces
34
+
35
+ def products = client.products
36
+
37
+ def projects = client.projects
38
+
39
+ def environments = client.environments
40
+
41
+ def variables = client.variables
42
+
43
+ def versions = client.versions
44
+
45
+ def members = client.members
46
+
47
+ def invites = client.invites
48
+
49
+ def roles = client.roles
50
+
51
+ def keys = client.keys
52
+
53
+ def webhooks = client.webhooks
54
+ end
55
+ end