asherah 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/NATIVE_VERSION +1 -0
- data/README.md +336 -56
- data/ext/asherah/asherah.c +2 -0
- data/ext/asherah/extconf.rb +6 -4
- data/ext/asherah/fetch_native.rb +196 -0
- data/lib/asherah/config.rb +64 -74
- data/lib/asherah/error.rb +11 -25
- data/lib/asherah/hooks.rb +239 -0
- data/lib/asherah/native.rb +146 -0
- data/lib/asherah/session.rb +176 -0
- data/lib/asherah/session_factory.rb +35 -0
- data/lib/asherah/version.rb +1 -1
- data/lib/asherah.rb +286 -100
- metadata +44 -34
- data/.env.secrets.example +0 -9
- data/.rspec +0 -3
- data/.rubocop.yml +0 -112
- data/.ruby-version +0 -1
- data/CHANGELOG.md +0 -135
- data/CODE_OF_CONDUCT.md +0 -77
- data/CONTRIBUTING.md +0 -118
- data/Gemfile +0 -14
- data/LICENSE.txt +0 -21
- data/Rakefile +0 -29
- data/SECURITY.md +0 -19
- data/asherah.gemspec +0 -39
- data/ext/asherah/checksums.yml +0 -5
- data/ext/asherah/native_file.rb +0 -64
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 31eda555f1f9ab86b17f12a4bff59bdcaa08b886c7ccbfba9e5fb341b73d3c1c
|
|
4
|
+
data.tar.gz: 0e85ad61fc1c9b192cd1d1fe670cfb6dc4feec26b174acc3e83de808fa74255a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 52e79b34969c3c5ff56e844d9a471ee4db1db13fa0b729f5b68be0d8a27b016a9513f0e731b6073dca0fd3a8dd6db964441b1b48fc8b4e2084e64c8075777fd6
|
|
7
|
+
data.tar.gz: cbb446aeffbf134ce576c49646e5c63edcf5b0b64a690e2fab93f0f8f578d592344167fc35427f9ce2a80cb762e1049bfd049e0a90b4a2c5c95e12f6efac78ac
|
data/NATIVE_VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
v0.6.109
|
data/README.md
CHANGED
|
@@ -1,106 +1,386 @@
|
|
|
1
|
-
#
|
|
1
|
+
# asherah
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Ruby bindings for [Asherah](https://github.com/godaddy/asherah-ffi) envelope encryption with automatic key rotation.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
- [Design and Architecture](https://github.com/godaddy/asherah/blob/master/docs/DesignAndArchitecture.md)
|
|
8
|
-
- [Key Caching](https://github.com/godaddy/asherah/blob/master/docs/KeyCaching.md)
|
|
9
|
-
- [Key Management Service](https://github.com/godaddy/asherah/blob/master/docs/KeyManagementService.md)
|
|
10
|
-
- [Metastore](https://github.com/godaddy/asherah/blob/master/docs/Metastore.md)
|
|
11
|
-
- [System Requirements](https://github.com/godaddy/asherah/blob/master/docs/SystemRequirements.md)
|
|
12
|
-
|
|
13
|
-
## Supported Platforms
|
|
14
|
-
|
|
15
|
-
Currently supported platforms are Linux and Darwin operating systems for x64 and arm64 CPU architectures.
|
|
5
|
+
Published to [RubyGems](https://rubygems.org/gems/asherah) with prebuilt native libraries for Linux x64/ARM64 (glibc and musl/Alpine) and macOS x64/ARM64. A fallback source gem is available for other platforms (requires the Rust toolchain to compile).
|
|
16
6
|
|
|
17
7
|
## Installation
|
|
18
8
|
|
|
19
|
-
|
|
9
|
+
```bash
|
|
10
|
+
gem install asherah
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or add to your Gemfile:
|
|
20
14
|
|
|
21
15
|
```ruby
|
|
22
16
|
gem 'asherah'
|
|
23
17
|
```
|
|
24
18
|
|
|
25
|
-
|
|
26
|
-
bundle install
|
|
27
|
-
```
|
|
19
|
+
The gem uses FFI to load the native Asherah library. Platform-specific gems ship the prebuilt library; the source gem builds it during installation.
|
|
28
20
|
|
|
29
|
-
|
|
21
|
+
## Documentation
|
|
30
22
|
|
|
31
|
-
|
|
32
|
-
|
|
23
|
+
Task-oriented walkthroughs under [`docs/`](./docs/):
|
|
24
|
+
|
|
25
|
+
| Guide | When to read |
|
|
26
|
+
|---|---|
|
|
27
|
+
| [Getting started](./docs/getting-started.md) | `gem install` through round-trip encrypt/decrypt. |
|
|
28
|
+
| [Framework integration](./docs/framework-integration.md) | Rails, Sidekiq, Sinatra, Rack middleware, AWS Lambda. |
|
|
29
|
+
| [AWS production setup](./docs/aws-production-setup.md) | KMS keys, DynamoDB, IAM policy, region routing. |
|
|
30
|
+
| [Testing](./docs/testing.md) | RSpec/Minitest fixtures, Testcontainers, mocking patterns. |
|
|
31
|
+
| [Troubleshooting](./docs/troubleshooting.md) | Common errors with what to check first. |
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
The simplest way to use Asherah is the static module API. Call `setup` once at startup and `shutdown` on exit:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
require "asherah"
|
|
39
|
+
|
|
40
|
+
Asherah.setup(
|
|
41
|
+
"ServiceName" => "my-service",
|
|
42
|
+
"ProductID" => "my-product",
|
|
43
|
+
"Metastore" => "memory", # testing only
|
|
44
|
+
"KMS" => "static" # testing only
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
ciphertext = Asherah.encrypt_string("partition-id", "sensitive data")
|
|
48
|
+
plaintext = Asherah.decrypt_string("partition-id", ciphertext)
|
|
49
|
+
|
|
50
|
+
Asherah.shutdown
|
|
33
51
|
```
|
|
34
52
|
|
|
35
|
-
|
|
53
|
+
The static API manages a session cache internally. Sessions are created on first use per partition and reused for subsequent calls.
|
|
54
|
+
|
|
55
|
+
### Block-style configuration
|
|
36
56
|
|
|
37
|
-
|
|
57
|
+
For an API compatible with the canonical GoDaddy Asherah Ruby gem, use `configure` with a block:
|
|
38
58
|
|
|
39
59
|
```ruby
|
|
40
60
|
Asherah.configure do |config|
|
|
41
|
-
config.
|
|
42
|
-
config.
|
|
43
|
-
config.
|
|
44
|
-
config.
|
|
61
|
+
config.service_name = "my-service"
|
|
62
|
+
config.product_id = "my-product"
|
|
63
|
+
config.kms = "static" # testing only
|
|
64
|
+
config.metastore = "memory" # testing only
|
|
45
65
|
end
|
|
66
|
+
|
|
67
|
+
ciphertext = Asherah.encrypt_string("partition-id", "sensitive data")
|
|
68
|
+
plaintext = Asherah.decrypt_string("partition-id", ciphertext)
|
|
69
|
+
|
|
70
|
+
Asherah.shutdown
|
|
46
71
|
```
|
|
47
72
|
|
|
48
|
-
|
|
73
|
+
## Session-Based API
|
|
49
74
|
|
|
50
|
-
|
|
75
|
+
For direct control over session lifecycle, use `SessionFactory` and `Session`:
|
|
51
76
|
|
|
52
77
|
```ruby
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
78
|
+
require "asherah"
|
|
79
|
+
|
|
80
|
+
Asherah.configure do |config|
|
|
81
|
+
config.service_name = "my-service"
|
|
82
|
+
config.product_id = "my-product"
|
|
83
|
+
config.kms = "static" # testing only
|
|
84
|
+
config.metastore = "memory" # testing only
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
factory = Asherah::SessionFactory.new(
|
|
88
|
+
Asherah::Native.asherah_factory_new_with_config(config_json)
|
|
89
|
+
)
|
|
90
|
+
session = factory.get_session("partition-id")
|
|
91
|
+
|
|
92
|
+
ciphertext = session.encrypt_bytes("sensitive data")
|
|
93
|
+
plaintext = session.decrypt_bytes(ciphertext)
|
|
94
|
+
|
|
95
|
+
session.close
|
|
96
|
+
factory.close
|
|
57
97
|
```
|
|
58
98
|
|
|
59
|
-
|
|
99
|
+
Or via the static API's internal factory (the typical pattern):
|
|
60
100
|
|
|
61
101
|
```ruby
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
```
|
|
102
|
+
Asherah.setup("ServiceName" => "my-service", "ProductID" => "my-product",
|
|
103
|
+
"Metastore" => "memory", "KMS" => "static") # testing only
|
|
65
104
|
|
|
66
|
-
|
|
105
|
+
# The static API acquires and caches sessions automatically
|
|
106
|
+
ct = Asherah.encrypt("partition-id", "data")
|
|
107
|
+
pt = Asherah.decrypt("partition-id", ct)
|
|
67
108
|
|
|
68
|
-
|
|
109
|
+
Asherah.shutdown
|
|
110
|
+
```
|
|
69
111
|
|
|
70
|
-
|
|
112
|
+
## Async API
|
|
71
113
|
|
|
72
|
-
###
|
|
114
|
+
### Session-level async (true async via Rust tokio)
|
|
73
115
|
|
|
74
|
-
|
|
116
|
+
The session's async methods dispatch work to Rust's tokio runtime and receive results via FFI callbacks:
|
|
75
117
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
- Go 1.24+ installed
|
|
118
|
+
```ruby
|
|
119
|
+
session = factory.get_session("partition-id")
|
|
79
120
|
|
|
80
|
-
|
|
121
|
+
ct = session.encrypt_bytes_async(data)
|
|
122
|
+
pt = session.decrypt_bytes_async(ct)
|
|
81
123
|
|
|
82
|
-
|
|
83
|
-
TEST_DB_PASSWORD=pass bin/cross-language-test.sh
|
|
124
|
+
session.close
|
|
84
125
|
```
|
|
85
126
|
|
|
86
|
-
|
|
127
|
+
### Static-level async (thread-based)
|
|
128
|
+
|
|
129
|
+
The static API's async methods run in a Ruby `Thread`:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
thread = Asherah.encrypt_async("partition-id", data) do |result|
|
|
133
|
+
puts "Encrypted: #{result.bytesize} bytes"
|
|
134
|
+
end
|
|
135
|
+
thread.join
|
|
136
|
+
```
|
|
87
137
|
|
|
88
|
-
|
|
138
|
+
### Async Behavior
|
|
139
|
+
|
|
140
|
+
The session-level async methods (`encrypt_bytes_async`, `decrypt_bytes_async`) are true async. The encrypt/decrypt work runs on Rust's tokio worker threads and completes via an FFI callback. The Ruby interpreter is NOT blocked during the native call.
|
|
141
|
+
|
|
142
|
+
However, the implementation uses `Queue#pop` to synchronize the callback result back to the calling Ruby thread. This means `queue.pop` blocks the calling Ruby thread until the result arrives. True concurrency requires multiple Ruby threads or Ractors dispatching async calls in parallel.
|
|
143
|
+
|
|
144
|
+
If the FFI callback never fires (e.g. the worker pool deadlocks), the
|
|
145
|
+
async call raises `Asherah::Error::Timeout` after 30 seconds rather
|
|
146
|
+
than blocking indefinitely. Override the bound by setting the
|
|
147
|
+
`ASHERAH_RUBY_ASYNC_TIMEOUT` environment variable (in seconds) before
|
|
148
|
+
the gem is loaded.
|
|
149
|
+
|
|
150
|
+
The static-level async methods (`Asherah.encrypt_async`, `Asherah.decrypt_async`) simply run the sync operation in a new `Thread`.
|
|
151
|
+
|
|
152
|
+
## Input contract
|
|
153
|
+
|
|
154
|
+
**Partition ID** (`nil`, `""`): always rejected as programming errors
|
|
155
|
+
with `ArgumentError` (`"partition_id cannot be empty"`). No row is ever
|
|
156
|
+
written to the metastore under a degenerate partition ID.
|
|
157
|
+
|
|
158
|
+
**Plaintext** to encrypt:
|
|
159
|
+
- `nil` → `ArgumentError` from explicit guards in the public API before
|
|
160
|
+
any FFI call.
|
|
161
|
+
- Empty `String` (`""`) and empty bytes (`"".b`) are **valid**
|
|
162
|
+
plaintexts. `Asherah.encrypt_string` / `session.encrypt_bytes`
|
|
163
|
+
produce a real `DataRowRecord` envelope; the matching decrypt returns
|
|
164
|
+
exactly `""` or empty bytes.
|
|
165
|
+
|
|
166
|
+
**Ciphertext** to decrypt:
|
|
167
|
+
- `nil` → `ArgumentError`.
|
|
168
|
+
- Empty `String` → `Asherah::Error::DecryptFailed` (not valid
|
|
169
|
+
`DataRowRecord` JSON).
|
|
170
|
+
|
|
171
|
+
**Do not short-circuit empty plaintext encryption in caller code** —
|
|
172
|
+
empty data is real data, encrypting it produces a genuine envelope, and
|
|
173
|
+
skipping encryption leaks the fact that the value was empty. See
|
|
174
|
+
[docs/input-contract.md](../docs/input-contract.md) for the full
|
|
175
|
+
rationale.
|
|
176
|
+
|
|
177
|
+
## Migration from Canonical Ruby SDK
|
|
178
|
+
|
|
179
|
+
This replaces the original `asherah` gem which was built on Go via Cobhan FFI. The API is drop-in compatible:
|
|
180
|
+
|
|
181
|
+
| | Canonical (Go/Cobhan) | This binding (Rust/FFI) |
|
|
182
|
+
|---|---|---|
|
|
183
|
+
| Implementation | Go + Cobhan FFI | Rust + Ruby FFI gem |
|
|
184
|
+
| `Asherah.configure` | Supported | Supported (same API) |
|
|
185
|
+
| `Asherah.encrypt` / `decrypt` | Supported | Supported (same API) |
|
|
186
|
+
| `SessionFactory` | Supported | Supported (same API) |
|
|
187
|
+
| Memory protection | None | memguard (locked, wiped pages) |
|
|
188
|
+
| Async support | None | Session-level true async |
|
|
189
|
+
|
|
190
|
+
Migration steps:
|
|
191
|
+
1. Update the `asherah` gem version in your Gemfile
|
|
192
|
+
2. No code changes required -- the API is compatible
|
|
193
|
+
3. Both read the same metastore tables -- no data migration required
|
|
194
|
+
|
|
195
|
+
## Performance
|
|
196
|
+
|
|
197
|
+
Benchmarked on Apple M4 Max, 64-byte payload, hot session cache:
|
|
198
|
+
|
|
199
|
+
| Operation | Latency |
|
|
200
|
+
|---|---|
|
|
201
|
+
| Encrypt | ~1,170 ns |
|
|
202
|
+
| Decrypt | ~1,110 ns |
|
|
203
|
+
|
|
204
|
+
## Configuration
|
|
205
|
+
|
|
206
|
+
### `setup` (hash style)
|
|
207
|
+
|
|
208
|
+
Keys are PascalCase strings matching the Asherah configuration format:
|
|
209
|
+
|
|
210
|
+
| Key | Type | Required | Description |
|
|
211
|
+
|---|---|---|---|
|
|
212
|
+
| `ServiceName` | `String` | Yes | Service identifier for key hierarchy |
|
|
213
|
+
| `ProductID` | `String` | Yes | Product identifier for key hierarchy |
|
|
214
|
+
| `Metastore` | `String` | Yes | `"rdbms"`, `"dynamodb"`, `"memory"` (testing) |
|
|
215
|
+
| `KMS` | `String` | Yes | `"static"` or `"aws"` |
|
|
216
|
+
| `ConnectionString` | `String` | No | RDBMS connection string |
|
|
217
|
+
| `DynamoDBEndpoint` | `String` | No | Custom DynamoDB endpoint |
|
|
218
|
+
| `DynamoDBRegion` | `String` | No | DynamoDB region — drives endpoint URL resolution and (when `DynamoDBSigningRegion` is unset) SigV4 signing |
|
|
219
|
+
| `DynamoDBSigningRegion` | `String` | No | SigV4 signing region. When set distinct from `DynamoDBRegion`, the URL is built from `DynamoDBRegion` but SigV4 signs as `DynamoDBSigningRegion` |
|
|
220
|
+
| `DynamoDBTableName` | `String` | No | DynamoDB table name |
|
|
221
|
+
| `RegionMap` | `Hash` | No | AWS KMS region-to-ARN map |
|
|
222
|
+
| `PreferredRegion` | `String` | No | Preferred AWS KMS region |
|
|
223
|
+
| `AwsProfileName` | `String` | No | AWS shared-credentials profile for KMS, DynamoDB, and Secrets Manager clients (native Rust SDK) |
|
|
224
|
+
| `EnableRegionSuffix` | `Boolean` | No | Append region suffix to key IDs |
|
|
225
|
+
| `EnableSessionCaching` | `Boolean` | No | Enable session caching (default: true) |
|
|
226
|
+
| `SessionCacheMaxSize` | `Integer` | No | Max cached sessions |
|
|
227
|
+
| `SessionCacheDuration` | `Integer` | No | Cache TTL in milliseconds |
|
|
228
|
+
| `ExpireAfter` | `Integer` | No | Key expiration in seconds |
|
|
229
|
+
| `CheckInterval` | `Integer` | No | Key check interval in seconds |
|
|
230
|
+
| `Verbose` | `Boolean` | No | Enable verbose logging (default: false) |
|
|
231
|
+
| `PoolMaxOpen` | `Integer` | No | Max open DB connections (default: 0 = unlimited) |
|
|
232
|
+
| `PoolMaxIdle` | `Integer` | No | Max idle connections to retain (default: 2) |
|
|
233
|
+
| `PoolMaxLifetime` | `Integer` | No | Max connection lifetime in seconds (default: 0 = unlimited) |
|
|
234
|
+
| `PoolMaxIdleTime` | `Integer` | No | Max idle time per connection in seconds (default: 0 = unlimited) |
|
|
235
|
+
|
|
236
|
+
For AWS KMS, DynamoDB, or Secrets Manager, when `AwsProfileName` is omitted the native Rust credential chain applies (including `AWS_PROFILE` and shared config under `~/.aws/`). Setting `AwsProfileName` / `aws_profile_name` selects a named profile explicitly.
|
|
237
|
+
|
|
238
|
+
### `configure` (block style)
|
|
239
|
+
|
|
240
|
+
Uses snake_case attribute accessors:
|
|
241
|
+
|
|
242
|
+
| Attribute | Maps to |
|
|
243
|
+
|---|---|
|
|
244
|
+
| `service_name` | `ServiceName` |
|
|
245
|
+
| `product_id` | `ProductID` |
|
|
246
|
+
| `metastore` | `Metastore` |
|
|
247
|
+
| `kms` | `KMS` |
|
|
248
|
+
| `connection_string` | `ConnectionString` |
|
|
249
|
+
| `dynamo_db_endpoint` | `DynamoDBEndpoint` |
|
|
250
|
+
| `dynamo_db_region` | `DynamoDBRegion` |
|
|
251
|
+
| `dynamo_db_signing_region` | `DynamoDBSigningRegion` |
|
|
252
|
+
| `dynamo_db_table_name` | `DynamoDBTableName` |
|
|
253
|
+
| `region_map` | `RegionMap` |
|
|
254
|
+
| `preferred_region` | `PreferredRegion` |
|
|
255
|
+
| `aws_profile_name` | `AwsProfileName` |
|
|
256
|
+
| `enable_region_suffix` | `EnableRegionSuffix` |
|
|
257
|
+
| `enable_session_caching` | `EnableSessionCaching` |
|
|
258
|
+
| `session_cache_max_size` | `SessionCacheMaxSize` |
|
|
259
|
+
| `session_cache_duration` | `SessionCacheDuration` |
|
|
260
|
+
| `expire_after` | `ExpireAfter` |
|
|
261
|
+
| `check_interval` | `CheckInterval` |
|
|
262
|
+
| `verbose` | `Verbose` |
|
|
263
|
+
| `pool_max_open` | `PoolMaxOpen` |
|
|
264
|
+
| `pool_max_idle` | `PoolMaxIdle` |
|
|
265
|
+
| `pool_max_lifetime` | `PoolMaxLifetime` |
|
|
266
|
+
| `pool_max_idle_time` | `PoolMaxIdleTime` |
|
|
267
|
+
|
|
268
|
+
## API Reference
|
|
269
|
+
|
|
270
|
+
### `Asherah` (module-level static API)
|
|
271
|
+
|
|
272
|
+
| Method | Description |
|
|
273
|
+
|---|---|
|
|
274
|
+
| `setup(config_hash)` | Initialize with PascalCase config hash |
|
|
275
|
+
| `configure { \|c\| ... }` | Initialize with block-style snake_case config |
|
|
276
|
+
| `setup_async(config_hash, &block)` | Async `setup` in a Thread |
|
|
277
|
+
| `shutdown` | Release all resources and cached sessions |
|
|
278
|
+
| `shutdown_async(&block)` | Async `shutdown` in a Thread |
|
|
279
|
+
| `get_setup_status` | Returns `true` if initialized |
|
|
280
|
+
| `encrypt(partition, data)` | Encrypt bytes, returns DRR JSON bytes |
|
|
281
|
+
| `encrypt_string(partition, text)` | Encrypt string, returns DRR JSON string |
|
|
282
|
+
| `encrypt_async(partition, data, &block)` | Encrypt in a Thread |
|
|
283
|
+
| `decrypt(partition, drr)` | Decrypt DRR JSON bytes to plaintext |
|
|
284
|
+
| `decrypt_string(partition, drr)` | Decrypt DRR JSON string to plaintext string |
|
|
285
|
+
| `decrypt_async(partition, drr, &block)` | Decrypt in a Thread |
|
|
286
|
+
| `setenv(hash)` / `set_env(hash)` | Set environment variables |
|
|
287
|
+
|
|
288
|
+
### `Asherah::SessionFactory`
|
|
289
|
+
|
|
290
|
+
| Method | Description |
|
|
291
|
+
|---|---|
|
|
292
|
+
| `get_session(partition_id)` | Create a session for a partition |
|
|
293
|
+
| `close` | Release the factory |
|
|
294
|
+
| `closed?` | Returns `true` if closed |
|
|
295
|
+
|
|
296
|
+
### `Asherah::Session`
|
|
297
|
+
|
|
298
|
+
| Method | Description |
|
|
299
|
+
|---|---|
|
|
300
|
+
| `encrypt_bytes(data)` | Encrypt bytes, returns DRR JSON bytes |
|
|
301
|
+
| `decrypt_bytes(json)` | Decrypt DRR JSON bytes to plaintext bytes |
|
|
302
|
+
| `encrypt_bytes_async(data)` | True async encrypt via Rust tokio |
|
|
303
|
+
| `decrypt_bytes_async(json)` | True async decrypt via Rust tokio |
|
|
304
|
+
| `close` | Release the session |
|
|
305
|
+
| `closed?` | Returns `true` if closed |
|
|
306
|
+
|
|
307
|
+
## Observability hooks
|
|
308
|
+
|
|
309
|
+
### Log hook
|
|
310
|
+
|
|
311
|
+
Asherah ships first-class stdlib `Logger` integration. The simplest way to
|
|
312
|
+
wire up logging is to hand it any Logger-compatible instance — stdlib
|
|
313
|
+
`Logger`, `ActiveSupport::Logger`, `SemanticLogger`, `Ougai`, etc. — and the
|
|
314
|
+
bridge dispatches each record via `Logger#add(severity, message, target)`
|
|
315
|
+
so the logger's own filter rules and formatters apply.
|
|
89
316
|
|
|
90
|
-
|
|
317
|
+
```ruby
|
|
318
|
+
require "logger"
|
|
319
|
+
log = Logger.new($stdout)
|
|
320
|
+
log.level = Logger::WARN
|
|
321
|
+
Asherah.set_log_hook(log)
|
|
91
322
|
|
|
323
|
+
# ...later
|
|
324
|
+
Asherah.clear_log_hook
|
|
92
325
|
```
|
|
93
|
-
|
|
94
|
-
|
|
326
|
+
|
|
327
|
+
For raw access pass a block; the event is a `Hash` with both a
|
|
328
|
+
`Logger::Severity` integer and a matching lowercase symbol:
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
Asherah.set_log_hook do |event|
|
|
332
|
+
# event[:severity] => Logger::DEBUG | INFO | WARN | ERROR
|
|
333
|
+
# event[:level] => :debug | :info | :warn | :error (symbol, for case dispatch)
|
|
334
|
+
# event[:target] => "asherah::session"
|
|
335
|
+
# event[:message] => "..."
|
|
336
|
+
next if event[:severity] < Logger::WARN
|
|
337
|
+
warn "[asherah #{event[:level]}] #{event[:target]}: #{event[:message]}"
|
|
338
|
+
end
|
|
95
339
|
```
|
|
96
340
|
|
|
97
|
-
|
|
341
|
+
The Rust `log` crate has a TRACE level that stdlib `Logger` does not; Asherah
|
|
342
|
+
maps `trace` records to `Logger::DEBUG` so the value is still meaningful.
|
|
343
|
+
The block may fire from any thread (Rust tokio worker threads, DB driver
|
|
344
|
+
threads), so implementations must be thread-safe and should not block.
|
|
345
|
+
Exceptions raised from the callback are caught and silently swallowed —
|
|
346
|
+
propagating an exception across the FFI boundary is undefined behavior.
|
|
98
347
|
|
|
348
|
+
### Metrics hook
|
|
349
|
+
|
|
350
|
+
Receive timing observations (`:encrypt`, `:decrypt`, `:store`, `:load`) and
|
|
351
|
+
cache events (`:cache_hit`, `:cache_miss`, `:cache_stale`) via
|
|
352
|
+
`Asherah.set_metrics_hook`. Installing a hook implicitly enables the global
|
|
353
|
+
metrics gate; clearing it disables the gate.
|
|
354
|
+
|
|
355
|
+
```ruby
|
|
356
|
+
Asherah.set_metrics_hook do |event|
|
|
357
|
+
case event[:type]
|
|
358
|
+
when :encrypt, :decrypt, :store, :load
|
|
359
|
+
# event[:duration_ns] is the elapsed time in nanoseconds, event[:name] is nil
|
|
360
|
+
Statsd.timing("asherah.#{event[:type]}", event[:duration_ns] / 1_000_000.0)
|
|
361
|
+
when :cache_hit, :cache_miss, :cache_stale
|
|
362
|
+
# event[:name] is the cache identifier, event[:duration_ns] is 0
|
|
363
|
+
Statsd.increment("asherah.#{event[:type]}.#{event[:name]}")
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# ...later
|
|
368
|
+
Asherah.clear_metrics_hook
|
|
369
|
+
```
|
|
99
370
|
|
|
100
|
-
|
|
371
|
+
| Event type | `:duration_ns` | `:name` |
|
|
372
|
+
|---|---|---|
|
|
373
|
+
| `:encrypt` | elapsed ns | `nil` |
|
|
374
|
+
| `:decrypt` | elapsed ns | `nil` |
|
|
375
|
+
| `:store` | elapsed ns | `nil` |
|
|
376
|
+
| `:load` | elapsed ns | `nil` |
|
|
377
|
+
| `:cache_hit` | `0` | cache identifier |
|
|
378
|
+
| `:cache_miss` | `0` | cache identifier |
|
|
379
|
+
| `:cache_stale` | `0` | cache identifier |
|
|
101
380
|
|
|
102
|
-
|
|
381
|
+
The same threading caveats apply as for the log hook — implementations must
|
|
382
|
+
be thread-safe and non-blocking, and exceptions are caught.
|
|
103
383
|
|
|
104
384
|
## License
|
|
105
385
|
|
|
106
|
-
|
|
386
|
+
Licensed under the Apache License, Version 2.0.
|
data/ext/asherah/extconf.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
create_makefile('asherah/asherah')
|
|
3
|
+
require "mkmf"
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
# Create a no-op Makefile (we don't compile C; we download a prebuilt binary)
|
|
6
|
+
create_makefile("asherah/asherah")
|
|
7
|
+
|
|
8
|
+
require_relative "fetch_native"
|
|
9
|
+
AsherahFetchNative.download
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open-uri"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "digest"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
|
|
8
|
+
# Downloads the prebuilt native library for the current platform from
|
|
9
|
+
# GitHub Releases during `gem install` (fallback gem only — platform gems
|
|
10
|
+
# ship the binary directly and never run this).
|
|
11
|
+
module AsherahFetchNative
|
|
12
|
+
REPO = "godaddy/asherah-ffi"
|
|
13
|
+
MAX_ATTEMPTS = 3
|
|
14
|
+
RETRY_DELAY = 5 # seconds, doubles each retry
|
|
15
|
+
ROOT_DIR = File.expand_path("../../", __dir__)
|
|
16
|
+
NATIVE_DIR = File.join(ROOT_DIR, "lib", "asherah", "native")
|
|
17
|
+
|
|
18
|
+
# Map Ruby platform identifiers to our release asset names.
|
|
19
|
+
# Keys: [os, cpu] from RbConfig. Values: [asset_name, local_name].
|
|
20
|
+
PLATFORM_MAP = {
|
|
21
|
+
["linux", "x86_64"] => ["libasherah-x64.so", "libasherah_ffi.so"],
|
|
22
|
+
["linux", "aarch64"] => ["libasherah-arm64.so", "libasherah_ffi.so"],
|
|
23
|
+
["linux-musl", "x86_64"] => ["libasherah-x64-musl.so", "libasherah_ffi.so"],
|
|
24
|
+
["linux-musl", "aarch64"] => ["libasherah-arm64-musl.so", "libasherah_ffi.so"],
|
|
25
|
+
["darwin", "x86_64"] => ["libasherah-x64.dylib", "libasherah_ffi.dylib"],
|
|
26
|
+
["darwin", "arm64"] => ["libasherah-arm64.dylib", "libasherah_ffi.dylib"],
|
|
27
|
+
["mingw", "x86_64"] => ["libasherah-x64.dll", "asherah_ffi.dll"],
|
|
28
|
+
["mingw", "aarch64"] => ["libasherah-arm64.dll", "asherah_ffi.dll"],
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
def download
|
|
33
|
+
asset_name, local_name = resolve_platform
|
|
34
|
+
dest = File.join(NATIVE_DIR, local_name)
|
|
35
|
+
|
|
36
|
+
if File.exist?(dest)
|
|
37
|
+
puts "#{dest} already exists, skipping download"
|
|
38
|
+
return
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
version = resolve_version
|
|
42
|
+
url = "https://github.com/#{REPO}/releases/download/#{version}/#{asset_name}"
|
|
43
|
+
|
|
44
|
+
puts "Downloading native library: #{url}"
|
|
45
|
+
content = download_with_retry(url)
|
|
46
|
+
|
|
47
|
+
if content.bytesize < 1024
|
|
48
|
+
abort "ERROR: Downloaded file is too small (#{content.bytesize} bytes) — likely a 404 or error page"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
verify_checksum(content, asset_name, version)
|
|
52
|
+
|
|
53
|
+
FileUtils.mkdir_p(NATIVE_DIR)
|
|
54
|
+
File.binwrite(dest, content)
|
|
55
|
+
File.chmod(0o755, dest) unless Gem.win_platform?
|
|
56
|
+
puts "Installed native library: #{dest} (#{content.bytesize} bytes)"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def resolve_platform
|
|
62
|
+
host_os = RbConfig::CONFIG["host_os"]
|
|
63
|
+
host_cpu = RbConfig::CONFIG["host_cpu"]
|
|
64
|
+
|
|
65
|
+
os = case host_os
|
|
66
|
+
when /linux.*musl/ then "linux-musl"
|
|
67
|
+
when /linux/ then musl_libc? ? "linux-musl" : "linux"
|
|
68
|
+
when /darwin/ then "darwin"
|
|
69
|
+
when /mswin|mingw/ then "mingw"
|
|
70
|
+
else host_os
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
cpu = case host_cpu
|
|
74
|
+
when /x86_64|x64|amd64/ then "x86_64"
|
|
75
|
+
when /aarch64|arm64/ then os == "darwin" ? "arm64" : "aarch64"
|
|
76
|
+
else host_cpu
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
key = [os, cpu]
|
|
80
|
+
result = PLATFORM_MAP[key]
|
|
81
|
+
abort "ERROR: Unsupported platform #{os}-#{cpu} (#{RUBY_PLATFORM})" unless result
|
|
82
|
+
result
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# True when running on musl libc (e.g. Alpine Linux). Modern Ruby on
|
|
86
|
+
# Alpine reports host_os=linux-musl directly, but older builds may
|
|
87
|
+
# report just "linux" — fall back to inspecting RUBY_PLATFORM and
|
|
88
|
+
# the dynamic loader.
|
|
89
|
+
def musl_libc?
|
|
90
|
+
return true if RUBY_PLATFORM.include?("musl")
|
|
91
|
+
return true if File.exist?("/lib/ld-musl-x86_64.so.1") || File.exist?("/lib/ld-musl-aarch64.so.1")
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def resolve_version
|
|
96
|
+
# NATIVE_VERSION is stamped into published fallback gems by the publish
|
|
97
|
+
# workflow with the release tag (e.g. "v0.6.73"). It is not committed
|
|
98
|
+
# to the repo — the gem version and the native binary version are
|
|
99
|
+
# intentionally decoupled.
|
|
100
|
+
# Environment variable override: NATIVE_VERSION=v0.6.22 bundle install
|
|
101
|
+
env_version = ENV["NATIVE_VERSION"]
|
|
102
|
+
if env_version && !env_version.strip.empty?
|
|
103
|
+
tag = env_version.strip
|
|
104
|
+
tag = "v#{tag}" unless tag.start_with?("v")
|
|
105
|
+
puts "Using native version from environment: #{tag}"
|
|
106
|
+
return tag
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Published fallback gems have NATIVE_VERSION stamped by the publish workflow
|
|
110
|
+
native_version_file = File.join(ROOT_DIR, "NATIVE_VERSION")
|
|
111
|
+
if File.exist?(native_version_file)
|
|
112
|
+
tag = File.read(native_version_file).strip
|
|
113
|
+
unless tag.empty?
|
|
114
|
+
puts "Using native version: #{tag}"
|
|
115
|
+
return tag
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
abort <<~MSG
|
|
120
|
+
ERROR: Native binary version not specified.
|
|
121
|
+
|
|
122
|
+
Set the NATIVE_VERSION environment variable to the release tag:
|
|
123
|
+
NATIVE_VERSION=0.6.73 bundle install
|
|
124
|
+
|
|
125
|
+
Or create a NATIVE_VERSION file in the asherah-ruby directory:
|
|
126
|
+
echo "v0.6.73" > asherah-ruby/NATIVE_VERSION
|
|
127
|
+
|
|
128
|
+
Available releases: https://github.com/#{REPO}/releases
|
|
129
|
+
MSG
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def download_with_retry(url)
|
|
133
|
+
attempt = 0
|
|
134
|
+
delay = RETRY_DELAY
|
|
135
|
+
|
|
136
|
+
loop do
|
|
137
|
+
attempt += 1
|
|
138
|
+
begin
|
|
139
|
+
return URI.parse(url).open(
|
|
140
|
+
"Accept" => "application/octet-stream",
|
|
141
|
+
redirect: true,
|
|
142
|
+
read_timeout: 60,
|
|
143
|
+
open_timeout: 30
|
|
144
|
+
).read
|
|
145
|
+
rescue OpenURI::HTTPError => e
|
|
146
|
+
if e.message.include?("404")
|
|
147
|
+
abort "ERROR: Release asset not found at #{url}\n" \
|
|
148
|
+
" Ensure the release exists and includes this platform's binary."
|
|
149
|
+
end
|
|
150
|
+
raise unless attempt < MAX_ATTEMPTS
|
|
151
|
+
|
|
152
|
+
puts "Download failed (attempt #{attempt}/#{MAX_ATTEMPTS}): #{e.message}"
|
|
153
|
+
puts "Retrying in #{delay}s..."
|
|
154
|
+
sleep delay
|
|
155
|
+
delay *= 2
|
|
156
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, Errno::ECONNREFUSED => e
|
|
157
|
+
raise unless attempt < MAX_ATTEMPTS
|
|
158
|
+
|
|
159
|
+
puts "Download failed (attempt #{attempt}/#{MAX_ATTEMPTS}): #{e.class}: #{e.message}"
|
|
160
|
+
puts "Retrying in #{delay}s..."
|
|
161
|
+
sleep delay
|
|
162
|
+
delay *= 2
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def verify_checksum(content, asset_name, version)
|
|
168
|
+
sums_url = "https://github.com/#{REPO}/releases/download/#{version}/SHA256SUMS"
|
|
169
|
+
begin
|
|
170
|
+
sums = URI.parse(sums_url).open(read_timeout: 15, open_timeout: 10).read
|
|
171
|
+
expected = nil
|
|
172
|
+
sums.each_line do |line|
|
|
173
|
+
hash, name = line.strip.split(/\s+/, 2)
|
|
174
|
+
if name == asset_name
|
|
175
|
+
expected = hash
|
|
176
|
+
break
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
if expected
|
|
181
|
+
actual = Digest::SHA256.hexdigest(content)
|
|
182
|
+
if actual != expected
|
|
183
|
+
abort "ERROR: SHA256 checksum mismatch for #{asset_name}\n" \
|
|
184
|
+
" Expected: #{expected}\n" \
|
|
185
|
+
" Actual: #{actual}"
|
|
186
|
+
end
|
|
187
|
+
puts "SHA256 checksum verified: #{actual}"
|
|
188
|
+
else
|
|
189
|
+
puts "WARNING: No checksum found for #{asset_name} in SHA256SUMS"
|
|
190
|
+
end
|
|
191
|
+
rescue OpenURI::HTTPError, Net::OpenTimeout, Net::ReadTimeout => e
|
|
192
|
+
puts "WARNING: Could not verify checksum (#{e.class}: #{e.message})"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|