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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/README.md +171 -0
- data/lib/envless/sdk/api_error.rb +54 -0
- data/lib/envless/sdk/bodies.rb +49 -0
- data/lib/envless/sdk/client.rb +39 -0
- data/lib/envless/sdk/clock.rb +24 -0
- data/lib/envless/sdk/connection_pool.rb +83 -0
- data/lib/envless/sdk/connector.rb +26 -0
- data/lib/envless/sdk/constants.rb +262 -0
- data/lib/envless/sdk/crypto/cipher.rb +58 -0
- data/lib/envless/sdk/crypto/codec.rb +23 -0
- data/lib/envless/sdk/crypto/envelope.rb +41 -0
- data/lib/envless/sdk/crypto/keys.rb +42 -0
- data/lib/envless/sdk/crypto/passphrase.rb +38 -0
- data/lib/envless/sdk/crypto/passphrase_rules.rb +42 -0
- data/lib/envless/sdk/crypto/values.rb +31 -0
- data/lib/envless/sdk/crypto/workspace_key.rb +40 -0
- data/lib/envless/sdk/crypto_messages.rb +31 -0
- data/lib/envless/sdk/default_client.rb +55 -0
- data/lib/envless/sdk/defaults.rb +32 -0
- data/lib/envless/sdk/envelope.rb +48 -0
- data/lib/envless/sdk/environment.rb +42 -0
- data/lib/envless/sdk/errors.rb +23 -0
- data/lib/envless/sdk/exchange.rb +27 -0
- data/lib/envless/sdk/helpers.rb +49 -0
- data/lib/envless/sdk/http.rb +46 -0
- data/lib/envless/sdk/listing.rb +54 -0
- data/lib/envless/sdk/messages.rb +43 -0
- data/lib/envless/sdk/net_http_adapter.rb +84 -0
- data/lib/envless/sdk/numbers.rb +44 -0
- data/lib/envless/sdk/options.rb +56 -0
- data/lib/envless/sdk/pagination.rb +31 -0
- data/lib/envless/sdk/paths.rb +70 -0
- data/lib/envless/sdk/received.rb +28 -0
- data/lib/envless/sdk/redirects.rb +56 -0
- data/lib/envless/sdk/resource.rb +50 -0
- data/lib/envless/sdk/resources/environments.rb +61 -0
- data/lib/envless/sdk/resources/invites.rb +28 -0
- data/lib/envless/sdk/resources/keys.rb +36 -0
- data/lib/envless/sdk/resources/me.rb +11 -0
- data/lib/envless/sdk/resources/members.rb +36 -0
- data/lib/envless/sdk/resources/product_access.rb +30 -0
- data/lib/envless/sdk/resources/products.rb +62 -0
- data/lib/envless/sdk/resources/projects.rb +42 -0
- data/lib/envless/sdk/resources/roles.rb +40 -0
- data/lib/envless/sdk/resources/variable_changes.rb +57 -0
- data/lib/envless/sdk/resources/variables.rb +49 -0
- data/lib/envless/sdk/resources/versions.rb +60 -0
- data/lib/envless/sdk/resources/webhook_deliveries.rb +33 -0
- data/lib/envless/sdk/resources/webhooks.rb +44 -0
- data/lib/envless/sdk/resources/workspace.rb +26 -0
- data/lib/envless/sdk/resources/workspace_domain.rb +31 -0
- data/lib/envless/sdk/resources/workspaces.rb +24 -0
- data/lib/envless/sdk/results.rb +43 -0
- data/lib/envless/sdk/retry_policy.rb +36 -0
- data/lib/envless/sdk/rotation/entry_rotator.rb +61 -0
- data/lib/envless/sdk/rotation/key_ring.rb +34 -0
- data/lib/envless/sdk/rotation/ledger.rb +36 -0
- data/lib/envless/sdk/rotation/runner.rb +45 -0
- data/lib/envless/sdk/rotation/variable_rotator.rb +38 -0
- data/lib/envless/sdk/text.rb +71 -0
- data/lib/envless/sdk/transport.rb +62 -0
- data/lib/envless/sdk/update_notice.rb +66 -0
- data/lib/envless/sdk/urls.rb +70 -0
- data/lib/envless/sdk/variable_names.rb +55 -0
- data/lib/envless/sdk/version.rb +5 -0
- data/lib/envless/sdk/webhooks/headers.rb +49 -0
- data/lib/envless/sdk/webhooks/verify.rb +58 -0
- data/lib/envless/sdk/wire.rb +77 -0
- data/lib/envless/sdk.rb +77 -0
- data/sig/envless/sdk/adapter.rbs +60 -0
- data/sig/envless/sdk/client.rbs +31 -0
- data/sig/envless/sdk/crypto.rbs +92 -0
- data/sig/envless/sdk/errors.rbs +37 -0
- data/sig/envless/sdk/http.rbs +31 -0
- data/sig/envless/sdk/pagination.rbs +81 -0
- data/sig/envless/sdk/results.rbs +62 -0
- data/sig/envless/sdk/rotation.rbs +86 -0
- data/sig/envless/sdk/settings.rbs +81 -0
- data/sig/envless/sdk/stdlib.rbs +8 -0
- data/sig/envless/sdk/text.rbs +52 -0
- data/sig/envless/sdk/transport.rbs +79 -0
- data/sig/envless/sdk/urls.rbs +73 -0
- data/sig/envless/sdk/webhooks.rbs +23 -0
- data/sig/envless/sdk.rbs +36 -0
- data/sig/generated/constants.rbs +57 -0
- data/sig/generated/resources.rbs +209 -0
- data/sig/generated/types.rbs +466 -0
- data/sig/manifest.yaml +3 -0
- 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
|