securedgram 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3bf805a1ecbce0996c3ce4cdf1aa210891fe154f8ca4e8b5614a7a215690de51
4
+ data.tar.gz: c2d304666ee6cbc675481e20c9f39ba93adcc6935e78796709676c4affe82ad6
5
+ SHA512:
6
+ metadata.gz: 2cfc3f6c8324167a13c30c010175f0880fc42775dd05a8ea77ad09f2fe9e42145cb3f6fa1a7ceedb2f39058e441b20545b35326bf756fbb721e442294a82c17b
7
+ data.tar.gz: b7a57be23bc29c7fcb9482bc9c907a5c54ec16bcd063b31cad364bf0462dabf866c6d5e59021473aacf5d21ddfd6ad08e740ba1f03fd18c686bbbedb8c820eb2
data/.env.example ADDED
@@ -0,0 +1,40 @@
1
+ # SecureDGram Environment Configuration
2
+ # Copy this file to .env and fill in your values.
3
+ # NEVER commit .env to version control.
4
+
5
+ # User to run the daemon as (will drop privileges to this user)
6
+ SECUREDGRAM_USER=nobody
7
+
8
+ # Log destination: syslog | stdout | /path/to/file.log
9
+ # Default: syslog (zero-setup, works out of the box)
10
+ # For production file logging: /var/log/securedgram/daemon.log
11
+ SECUREDGRAM_LOG=syslog
12
+
13
+ # PID file path
14
+ # Default: securedgram.pid (current directory)
15
+ # For production: /var/run/securedgram/daemon.pid (requires directory setup)
16
+ SECUREDGRAM_PIDFILE=securedgram.pid
17
+
18
+ # UDP bind address
19
+ SECUREDGRAM_ADDRESS=0.0.0.0
20
+
21
+ # UDP listen port
22
+ SECUREDGRAM_PORT=61773
23
+
24
+ # 32-byte shared secret as a 64-character hex string
25
+ # Generate with: ruby -e "require 'securerandom'; puts SecureRandom.hex(32)"
26
+ SECUREDGRAM_SECRET=
27
+
28
+ # Auth time window in seconds (messages outside +/- this window are rejected)
29
+ SECUREDGRAM_WINDOW=10
30
+
31
+ # Max send retries before giving up on an outbound message
32
+ SECUREDGRAM_MAX_RETRIES=10
33
+
34
+ # Seconds to wait before retransmitting an unACKed outbound message
35
+ SECUREDGRAM_RETRY_INTERVAL=5
36
+
37
+ # SQLite3 database file path
38
+ # Default: securedgram.db (current directory)
39
+ # For production: /var/lib/securedgram/securedgram.db (requires directory setup)
40
+ SECUREDGRAM_DB=securedgram.db
data/CONTRIBUTORS ADDED
@@ -0,0 +1,22 @@
1
+ # SecureDGram Contributors
2
+
3
+ ## Author
4
+
5
+ Aaron D. Gifford <https://aarongifford.com/>
6
+
7
+ Original design and implementation of the encrypted UDP messaging daemon,
8
+ DaemonUtils daemonization framework, and project architecture.
9
+
10
+ ## Contributors
11
+
12
+ Claude Code Opus 4.6 <https://claude.ai/>
13
+
14
+ SQLite3 database integration, CLI tools (sg-send, sg-recv, sg-clean),
15
+ HUP signal configuration reload, read tracking, crash recovery logic,
16
+ syslog support, gem packaging restructure,
17
+ documentation (README, DESIGN, SECURITY), tests, and code review.
18
+
19
+ ## Copyright Holders
20
+
21
+ - Aaron D. Gifford -- SecureDGram daemon and CLI tools
22
+ - InfoWest, Inc. -- DaemonUtils module (lib/securedgram/daemon_utils.rb)
data/DESIGN.md ADDED
@@ -0,0 +1,289 @@
1
+ # SecureDGram - Design Document
2
+
3
+ ## Overview
4
+
5
+ SecureDGram is an encrypted UDP messaging daemon written in Ruby. It listens for incoming UDP datagrams, decrypts and authenticates them using ChaCha20-Poly1305 AEAD encryption with a pre-shared key, validates timestamps to prevent replay attacks, parses the JSON payload, stores messages in a SQLite3 database, and sends encrypted ACK responses back to the sender. External processes can queue outbound messages by inserting rows into the database.
6
+
7
+ ## Architecture
8
+
9
+ The application is packaged as a Ruby gem with five library modules under `lib/securedgram/` and four executables under `exe/`:
10
+
11
+ ### 1. SecureDGram::DaemonUtils (`lib/securedgram/daemon_utils.rb`)
12
+
13
+ A general-purpose Unix daemon framework that handles:
14
+
15
+ - **Double-fork daemonization**: Classic Unix technique to fully detach the process from the controlling terminal. The parent forks, the child calls `setsid`, then forks again. The grandchild becomes the daemon.
16
+ - **PID file management**: Creation, reading, and cleanup of PID files to track the running daemon and prevent duplicate instances.
17
+ - **Process lifecycle**: Start, stop, restart, status, and poll commands via CLI.
18
+ - **Privilege dropping**: When started as root, the daemon performs privileged initialization (e.g., binding to low ports), then drops to a configured unprivileged user.
19
+ - **Signal handling**: Traps HUP, QUIT, INT, TERM, and USR1 via a self-pipe pattern (signals write to a pipe, a dedicated thread reads and dispatches). This avoids unsafe operations inside signal handlers. HUP triggers a `.env` re-read, hot-reload of safe parameters (secret, window, max retries), and log reopening.
20
+ - **Process title**: Sets the process title via FFI binding to `setproctitle` (FreeBSD/macOS).
21
+
22
+ The module expects daemon classes to implement a contract:
23
+ - `initialize(options)` - Constructor
24
+ - `run()` - Main loop iteration; returns `true` to continue, `false` to stop
25
+ - `setup_logging()` - (Re)open log files; returns a Logger
26
+ - Optional: `root_init`, `pre_fork`, `post_fork`, `quit(signal)`, `reconfig`, `exit_code(code)`
27
+
28
+ ### 2. SecureDGram::Crypto (`lib/securedgram/crypto.rb`)
29
+
30
+ Two module functions provide the cryptographic envelope:
31
+
32
+ #### `SecureDGram::Crypto.encrypt(key, plaintext, timestamp)`
33
+ 1. Generates a 12-byte random nonce
34
+ 2. Packs the timestamp as a big-endian 64-bit nanosecond epoch value
35
+ 3. Prepends the packed timestamp to the plaintext
36
+ 4. Encrypts with ChaCha20-Poly1305-IETF AEAD (no additional authenticated data)
37
+ 5. Returns: `nonce (12 bytes) || ciphertext+tag`
38
+
39
+ #### `SecureDGram::Crypto.decrypt(key, ciphertext)`
40
+ 1. Splits off the 12-byte nonce prefix
41
+ 2. Decrypts with ChaCha20-Poly1305-IETF AEAD
42
+ 3. Extracts the 8-byte timestamp prefix from the plaintext
43
+ 4. Returns: `(plaintext, timestamp)` as a Ruby Time object with nanosecond precision
44
+
45
+ ### 3. SecureDGram::EnvLoader (`lib/securedgram/env_loader.rb`)
46
+
47
+ Shared `.env` file parser used by the daemon and all CLI tools. Supports:
48
+ - Comment lines (starting with `#`) and blank lines
49
+ - Stripping of matching surrounding quotes (single or double)
50
+ - `||=` semantics by default (existing ENV values preserved)
51
+ - `force: true` mode for HUP reload (overwrites existing ENV values)
52
+
53
+ ### 4. SecureDGram::UDPServer (`lib/securedgram/udp_server.rb`)
54
+
55
+ The daemon implementation that plugs into DaemonUtils.
56
+
57
+ #### Logging
58
+
59
+ Supports three log destinations via `SECUREDGRAM_LOG`:
60
+ - **`syslog`** (default): Uses `Syslog::Logger` with `LOG_DAEMON` facility. Zero-setup, works immediately after gem install.
61
+ - **`stdout`**: Standard `Logger` to STDOUT. Useful for foreground debugging.
62
+ - **File path**: Standard `Logger` to the specified file. Supports log rotation via HUP signal (daemon reopens the file handle).
63
+
64
+ #### Main Loop
65
+
66
+ The main loop (`run()`) executes four phases per iteration:
67
+
68
+ **Phase 1 -- `db_send_outbound`**: Polls the `outbound_messages` table for rows with `state = 'pending'`. For each: generates `message_id` if NULL, validates/injects it into the JSON payload, encrypts, and sends via UDP. Updates state to `sent` on success, `send_failed` on error.
69
+
70
+ **Phase 2 -- `receive_udp`**: Non-blocking `recvfrom_nonblock` loop. For each datagram: decrypts, validates timestamp window, parses JSON. If it's an ACK (`json['type'] == 'ACK'`), updates the corresponding outbound row to `acknowledged`. If it's a regular message, INSERTs into `inbound_messages` and queues an ACK in memory.
71
+
72
+ **Phase 3 -- `send_acks`**: Processes the in-memory ACK queue (`@ack_queue_out`). Encrypts and sends each ACK, then updates the inbound row to `ack_sent`.
73
+
74
+ **Phase 4 -- `process_inbound`**: Stub for future application logic.
75
+
76
+ The loop sleeps 100ms between iterations. All database operations are single auto-commit statements to minimize lock contention.
77
+
78
+ ### 5. SQLite3 Message Store
79
+
80
+ All messages (inbound and outbound) are persisted in a SQLite3 database. The database uses WAL (Write-Ahead Logging) mode so that external processes can read/write concurrently with the daemon.
81
+
82
+ #### Outbound Messages Table (`outbound_messages`)
83
+
84
+ | Column | Type | Purpose |
85
+ |--------|------|---------|
86
+ | `id` | INTEGER PK | Auto-increment row ID |
87
+ | `message_id` | TEXT UNIQUE | 24-char hex ID (nullable -- daemon generates if NULL) |
88
+ | `dst_addr` | TEXT | Destination IP address |
89
+ | `dst_port` | INTEGER | Destination UDP port |
90
+ | `payload` | TEXT | JSON payload to encrypt and send |
91
+ | `state` | TEXT | Lifecycle state (see below) |
92
+ | `retry_count` | INTEGER | Number of send attempts |
93
+ | `created_at` | TEXT | Row creation timestamp (ISO 8601) |
94
+ | `sent_at` | TEXT | When successfully sent |
95
+ | `ack_received_at` | TEXT | When ACK was received |
96
+ | `last_error` | TEXT | Most recent error message |
97
+ | `updated_at` | TEXT | Last modification timestamp |
98
+
99
+ **Outbound state machine:**
100
+ ```
101
+ pending --> sending --> sent --> acknowledged
102
+ | |
103
+ +------------+--> send_failed
104
+ ```
105
+
106
+ #### Inbound Messages Table (`inbound_messages`)
107
+
108
+ | Column | Type | Purpose |
109
+ |--------|------|---------|
110
+ | `id` | INTEGER PK | Auto-increment row ID |
111
+ | `message_id` | TEXT UNIQUE | 24-char hex ID from sender |
112
+ | `src_addr` | TEXT | Sender IP address |
113
+ | `src_port` | INTEGER | Sender UDP port |
114
+ | `payload` | TEXT | Decrypted JSON payload |
115
+ | `crypto_timestamp` | TEXT | Timestamp from encrypted envelope (nanosecond precision) |
116
+ | `state` | TEXT | Lifecycle state (see below) |
117
+ | `ack_sent` | INTEGER | Boolean flag: 1 if ACK sent |
118
+ | `read_count` | INTEGER | Number of times read by `sg-recv` (0 = unread) |
119
+ | `received_at` | TEXT | When daemon received the message |
120
+ | `ack_sent_at` | TEXT | When ACK was sent |
121
+ | `updated_at` | TEXT | Last modification timestamp |
122
+
123
+ **Inbound state machine:**
124
+ ```
125
+ received --> ack_sent
126
+ |
127
+ +--> ack_failed
128
+ ```
129
+
130
+ ## Wire Protocol
131
+
132
+ ```
133
+ Datagram format (encrypted):
134
+ +-------+----------------------------+
135
+ | Nonce | Ciphertext + Poly1305 Tag |
136
+ | 12 B | variable length |
137
+ +-------+----------------------------+
138
+
139
+ Plaintext structure (inside ciphertext):
140
+ +-----------+-------------------+
141
+ | Timestamp | JSON Payload |
142
+ | 8 bytes | variable length |
143
+ +-----------+-------------------+
144
+ ```
145
+
146
+ - **Nonce**: 12 bytes, randomly generated per message
147
+ - **Timestamp**: 8-byte big-endian unsigned integer encoding nanosecond-precision Unix epoch
148
+ - **JSON Payload**: Must contain at minimum a `message_id` field (24-char lowercase hex)
149
+ - **Replay window**: Configurable via `SECUREDGRAM_WINDOW` (default 10 seconds)
150
+
151
+ ## Security Properties
152
+
153
+ - **Confidentiality**: ChaCha20 stream cipher
154
+ - **Integrity + Authentication**: Poly1305 MAC (AEAD construction)
155
+ - **Replay protection**: Configurable timestamp validity window
156
+ - **Pre-shared key**: 32-byte (256-bit) symmetric key shared between client and server
157
+ - **Privilege separation**: Daemon drops from root to an unprivileged user after binding
158
+
159
+ ## Database Concurrency
160
+
161
+ - **WAL mode**: Readers never block writers; writers never block readers
162
+ - **`busy_timeout = 1000ms`**: SQLite retries internally for up to 1 second on contention
163
+ - **`synchronous = NORMAL`**: Safe with WAL; durable against application crashes
164
+ - **No explicit transactions**: Each statement auto-commits for minimal lock duration
165
+ - **BusyException handling**: All DB operations rescue `SQLite3::BusyException`, log a warning, and skip to the next cycle (retry in ~100ms)
166
+ - External processes SHOULD also use WAL mode and set `busy_timeout`
167
+
168
+ ## Crash Recovery
169
+
170
+ On startup (`init_db`), the daemon performs two recovery operations:
171
+
172
+ 1. **Stuck outbound messages**: Any rows with `state = 'sending'` (transient state from a previous crash) are reset to `pending` for re-send.
173
+ 2. **Un-ACKed inbound messages**: Any rows with `state = 'received'` (message stored but ACK never sent) are re-queued for ACK sending.
174
+
175
+ ## Configuration
176
+
177
+ All settings are loaded from environment variables, populated from a `.env` file at startup. On `HUP` signal, the `.env` file is re-read and hot-reloadable parameters take effect immediately:
178
+
179
+ | Variable | Purpose | Default | HUP Reload |
180
+ |---|---|---|---|
181
+ | `SECUREDGRAM_USER` | Unix user to run as after privilege drop | `nobody` | Restart only |
182
+ | `SECUREDGRAM_LOG` | Log destination (syslog, stdout, or file path) | `syslog` | Restart only (reopens handle) |
183
+ | `SECUREDGRAM_PIDFILE` | Path to the PID file | `securedgram.pid` | Restart only |
184
+ | `SECUREDGRAM_ADDRESS` | UDP bind address | `0.0.0.0` | Restart only |
185
+ | `SECUREDGRAM_PORT` | UDP listen port | `61773` | Restart only |
186
+ | `SECUREDGRAM_SECRET` | 32-byte pre-shared key (64 hex chars) | (empty) | Hot-reload |
187
+ | `SECUREDGRAM_WINDOW` | Timestamp validity window (seconds) | `10` | Hot-reload |
188
+ | `SECUREDGRAM_MAX_RETRIES` | Max send retries before marking failed | `10` | Hot-reload |
189
+ | `SECUREDGRAM_RETRY_INTERVAL` | Seconds before retransmitting unACKed message | `5` | Hot-reload |
190
+ | `SECUREDGRAM_DB` | SQLite3 database file path | `securedgram.db` | Restart only |
191
+
192
+ All values can also be overridden via CLI flags (see `--help`).
193
+
194
+ **Hot-reload** parameters are applied immediately when the daemon receives a HUP signal. **Restart only** parameters are logged as warnings if changed but require a full `stop`/`start` cycle to take effect (the bound socket, open database handle, or dropped privileges cannot be changed in-place). Log handles are reopened on HUP regardless (supports log rotation for file logging).
195
+
196
+ ## Dependencies
197
+
198
+ | Gem | Purpose |
199
+ |---|---|
200
+ | `rbnacl` | Libsodium bindings for ChaCha20-Poly1305 AEAD |
201
+ | `ffi` | Foreign function interface (used for `setproctitle` and by rbnacl) |
202
+ | `sqlite3` | SQLite3 database bindings |
203
+ | `json` | JSON parsing (stdlib) |
204
+ | `logger` | Logging (stdlib) |
205
+ | `syslog/logger` | Syslog logging (stdlib) |
206
+
207
+ System dependencies: **libsodium** and **sqlite3** must be installed.
208
+
209
+ ## Process Lifecycle
210
+
211
+ ```
212
+ CLI: securedgram start
213
+ |
214
+ v
215
+ [Parse options & .env]
216
+ |
217
+ v
218
+ [Check PID file - is daemon already running?]
219
+ |
220
+ v
221
+ [DaemonUtils.daemonize()]
222
+ |
223
+ fork (1st)
224
+ |
225
+ v
226
+ setsid + fork (2nd)
227
+ |
228
+ v
229
+ [Redirect stdio to /dev/null]
230
+ |
231
+ v
232
+ [root_init - privileged setup]
233
+ |
234
+ v
235
+ [Drop privileges to configured user]
236
+ |
237
+ v
238
+ [Setup signal handler thread]
239
+ |
240
+ v
241
+ [post_fork]
242
+ |-- Bind UDP socket
243
+ |-- Open SQLite3 database (WAL mode)
244
+ |-- Create tables if not exist
245
+ |-- Crash recovery (reset stuck states, re-queue ACKs)
246
+ |
247
+ v
248
+ [Main loop (4 phases per iteration)]
249
+ Phase 1: Poll DB -> encrypt -> send outbound
250
+ Phase 2: Receive UDP -> decrypt -> store inbound / handle ACKs
251
+ Phase 3: Send ACKs from memory -> update DB
252
+ Phase 4: Application logic (stub)
253
+ |
254
+ v
255
+ [Signal TERM/INT/QUIT -> close DB -> clean shutdown]
256
+ ```
257
+
258
+ ## Gem Structure
259
+
260
+ ```
261
+ lib/
262
+ securedgram.rb Top-level require (loads all modules)
263
+ securedgram/
264
+ version.rb SecureDGram::VERSION constant
265
+ env_loader.rb SecureDGram::EnvLoader (.env parser)
266
+ crypto.rb SecureDGram::Crypto (encrypt/decrypt)
267
+ daemon_utils.rb SecureDGram::DaemonUtils (double-fork, signals, PID)
268
+ udp_server.rb SecureDGram::UDPServer (the daemon)
269
+ exe/
270
+ securedgram Main daemon CLI
271
+ sg-send Inject outbound messages
272
+ sg-recv Read messages from DB
273
+ sg-clean Purge old messages
274
+ data/
275
+ schema.sql Database schema for manual creation
276
+ test/
277
+ test_helper.rb Minitest setup
278
+ test_crypto.rb Encrypt/decrypt round-trip tests
279
+ test_env_loader.rb .env parsing edge-case tests
280
+ ```
281
+
282
+ ## Known Limitations / Notes
283
+
284
+ - The receive loop uses `recvfrom_nonblock` with a 100ms sleep between poll cycles.
285
+ - The `simple_daemonize` method in DaemonUtils exists but is not used by SecureDGram.
286
+ - The sys-proctable gem is optional; the code falls back to `ps` for process name validation.
287
+ - ACKs use an in-memory queue (`@ack_queue_out`) for fast turnaround. If the daemon crashes between receiving a message and sending its ACK, the ACK is re-queued from the DB on restart.
288
+ - The `process_inbound` method is a stub for future application logic.
289
+ - `Syslog::Logger` is not available on Windows (POSIX only). File and stdout logging work on all platforms.
data/LICENSE ADDED
@@ -0,0 +1,12 @@
1
+ Fair License
2
+
3
+ SecureDGram - Encrypted UDP Datagram Messaging Daemon
4
+
5
+ Copyright (c) 2016 Aaron D. Gifford
6
+ DaemonUtils module: Copyright (c) InfoWest, Inc.
7
+
8
+ Usage of the works is permitted provided that this instrument is
9
+ retained with the works, so that any entity that uses the works is
10
+ notified of this instrument.
11
+
12
+ DISCLAIMER: THE WORKS ARE WITHOUT WARRANTY.