phylax 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.
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phylax
4
+ VERSION = "0.1.0"
5
+ end
data/lib/phylax.rb ADDED
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "phylax/version"
4
+ require "phylax/phylax" # native extension: Phylax primitives + Digest/HMAC/SecretBox + errors
5
+
6
+ # phylax — misuse-resistant cryptography for Ruby, backed by the Windows CNG
7
+ # provider (bcrypt.dll) and DPAPI (crypt32.dll).
8
+ #
9
+ # It implements no cryptography itself; it is a thin, hard-to-misuse binding to
10
+ # the operating system's own validated primitives.
11
+ #
12
+ # key = Phylax::SecretBox.generate_key # 32 secure random bytes
13
+ # box = Phylax::SecretBox.new(key)
14
+ # sealed = box.seal("attack at dawn") # fresh nonce per call, authenticated
15
+ # box.open(sealed) # => "attack at dawn"
16
+ #
17
+ # Phylax.sha256("data") # raw 32-byte digest
18
+ # Phylax.hmac_sha256(key, "msg") # keyed MAC
19
+ # Phylax.pbkdf2(password: pw, salt: s, iterations: 600_000, length: 32)
20
+ # Phylax.random_bytes(16)
21
+ # Phylax.protect(secret) # DPAPI, bound to this user
22
+ module Phylax
23
+ # The only hash algorithms the gem accepts, mapped to their C-side index.
24
+ HASHES = { sha256: 0, sha384: 1, sha512: 2 }.freeze
25
+
26
+ # The C bridge methods are internal plumbing; the public, validated API below
27
+ # wraps them. Keep them off the public surface so callers can't skip the
28
+ # keyword/size checks (e.g. a negative PBKDF2 iteration count).
29
+ private_class_method :__hash, :__hmac, :__pbkdf2, :__protect, :__unprotect
30
+
31
+ module_function
32
+
33
+ # --- one-shot hashing ----------------------------------------------------
34
+
35
+ def sha256(data) = __hash(0, data)
36
+ def sha384(data) = __hash(1, data)
37
+ def sha512(data) = __hash(2, data)
38
+
39
+ # --- one-shot HMAC -------------------------------------------------------
40
+
41
+ def hmac_sha256(key, data) = __hmac(0, key, data)
42
+ def hmac_sha384(key, data) = __hmac(1, key, data)
43
+ def hmac_sha512(key, data) = __hmac(2, key, data)
44
+
45
+ # --- PBKDF2 key derivation ----------------------------------------------
46
+
47
+ # Derive a key from a password and salt. Defaults to HMAC-SHA256.
48
+ # password: secret bytes (String)
49
+ # salt: non-secret bytes (String); must not be empty, >= 16 advised
50
+ # iterations: PRF iteration count (Integer >= 1)
51
+ # length: desired key length in bytes (Integer >= 1)
52
+ # hash: :sha256 / :sha384 / :sha512
53
+ def pbkdf2(password:, salt:, iterations:, length:, hash: :sha256)
54
+ idx = HASHES.fetch(hash) { raise ArgumentError, "unknown hash #{hash.inspect}" }
55
+ raise ArgumentError, "iterations must be >= 1" if iterations < 1
56
+ raise ArgumentError, "length must be >= 1" if length < 1
57
+ raise ArgumentError, "salt must not be empty" if String(salt).empty?
58
+
59
+ __pbkdf2(idx, password, salt, iterations, length)
60
+ end
61
+
62
+ # --- DPAPI: protect secrets at rest -------------------------------------
63
+
64
+ # Encrypt +data+ with a key the OS derives from the current user (default)
65
+ # or the machine. The returned opaque blob can only be unprotected on the
66
+ # same machine (and, for :user scope, by the same user). An optional
67
+ # +entropy+ string acts as a second secret that must be supplied identically
68
+ # to #unprotect.
69
+ def protect(data, scope: :user, entropy: nil)
70
+ __protect(data, machine_scope!(scope), entropy)
71
+ end
72
+
73
+ # Reverse of #protect. Raises Phylax::AuthenticationError if the blob was
74
+ # tampered with, the wrong entropy was given, or it belongs to another
75
+ # user/machine. (DPAPI infers the scope from the blob; +scope+ is validated
76
+ # for symmetry but does not change the operation.)
77
+ def unprotect(data, scope: :user, entropy: nil)
78
+ machine_scope!(scope)
79
+ __unprotect(data, entropy)
80
+ end
81
+
82
+ def machine_scope!(scope)
83
+ case scope
84
+ when :user then false
85
+ when :machine then true
86
+ else raise ArgumentError, "scope must be :user or :machine, got #{scope.inspect}"
87
+ end
88
+ end
89
+ private_class_method :machine_scope!
90
+
91
+ # Base class for every error phylax raises.
92
+ class Error < StandardError; end
93
+
94
+ # The data could not be authenticated: a SecretBox ciphertext (or its AAD)
95
+ # was tampered with or decrypted with the wrong key, or a DPAPI blob failed
96
+ # its integrity/credential check. Never trust any output when this is raised.
97
+ class AuthenticationError < Error; end
98
+
99
+ # A Windows API returned a failure we surface verbatim. #api names the call;
100
+ # #code is the NTSTATUS or Win32 error value.
101
+ class OSError < Error
102
+ def api = @api
103
+ def code = @code
104
+ end
105
+
106
+ # Streaming message digest (SHA-256/384/512).
107
+ #
108
+ # d = Phylax::Digest.new(:sha256)
109
+ # d << "chunk one" << "chunk two"
110
+ # d.hexdigest # non-destructive; you may keep updating
111
+ class Digest
112
+ # Bytes in the final digest, keyed by algorithm symbol.
113
+ SIZES = { sha256: 32, sha384: 48, sha512: 64 }.freeze
114
+
115
+ attr_reader :name
116
+
117
+ def initialize(name)
118
+ idx = HASHES.fetch(name) { raise ArgumentError, "unknown hash #{name.inspect}" }
119
+ @name = name
120
+ _init(idx)
121
+ end
122
+
123
+ def <<(data)
124
+ update(data)
125
+ end
126
+
127
+ # Discard any fed data and start over.
128
+ def reset
129
+ _init(HASHES.fetch(@name))
130
+ self
131
+ end
132
+
133
+ def hexdigest
134
+ digest.unpack1("H*")
135
+ end
136
+
137
+ # Convenience: hash +data+ in one call.
138
+ def self.digest(name, data)
139
+ new(name).update(data).digest
140
+ end
141
+
142
+ def self.hexdigest(name, data)
143
+ digest(name, data).unpack1("H*")
144
+ end
145
+
146
+ def size
147
+ digest_length
148
+ end
149
+ alias length size
150
+
151
+ def inspect
152
+ "#<Phylax::Digest #{@name}>"
153
+ end
154
+
155
+ private :_init
156
+ end
157
+
158
+ # Streaming keyed MAC (HMAC-SHA-256/384/512).
159
+ #
160
+ # m = Phylax::HMAC.new(:sha256, key)
161
+ # m << "part one" << "part two"
162
+ # m.hexdigest
163
+ class HMAC
164
+ attr_reader :name
165
+
166
+ def initialize(name, key)
167
+ idx = HASHES.fetch(name) { raise ArgumentError, "unknown hash #{name.inspect}" }
168
+ @name = name
169
+ _init(idx, key)
170
+ end
171
+
172
+ def <<(data)
173
+ update(data)
174
+ end
175
+
176
+ def hexdigest
177
+ digest.unpack1("H*")
178
+ end
179
+
180
+ def size
181
+ digest_length
182
+ end
183
+ alias length size
184
+
185
+ # Compute the MAC of +data+ under +key+ and compare it to +expected_tag+ in
186
+ # constant time. Returns true/false and never raises on mismatch — the safe
187
+ # way to verify a MAC.
188
+ def self.verify(name, key, data, expected_tag)
189
+ tag = new(name, key).update(data).digest
190
+ Phylax.secure_compare(tag, expected_tag)
191
+ end
192
+
193
+ def inspect
194
+ "#<Phylax::HMAC #{@name}>"
195
+ end
196
+
197
+ private :_init
198
+ end
199
+
200
+ # Authenticated symmetric encryption (AES-256-GCM) with a misuse-resistant
201
+ # API: a fresh 96-bit nonce is generated on every #seal and framed into the
202
+ # output, so nonce reuse is impossible by construction, and #open refuses to
203
+ # return anything that fails authentication.
204
+ class SecretBox
205
+ KEY_BYTES = 32
206
+ NONCE_BYTES = 12
207
+ TAG_BYTES = 16
208
+ OVERHEAD = NONCE_BYTES + TAG_BYTES # bytes #seal adds over the plaintext
209
+
210
+ # A new random 32-byte key.
211
+ def self.generate_key
212
+ Phylax.random_bytes(KEY_BYTES)
213
+ end
214
+
215
+ # Derive a box's key from a password via PBKDF2-HMAC-SHA256. +salt+ is
216
+ # required and should be random and stored alongside the ciphertext.
217
+ def self.from_password(password, salt:, iterations: 600_000)
218
+ key = Phylax.pbkdf2(password: password, salt: salt,
219
+ iterations: iterations, length: KEY_BYTES, hash: :sha256)
220
+ new(key)
221
+ end
222
+
223
+ # +key+ must be exactly 32 bytes (e.g. from .generate_key or .from_password).
224
+ def initialize(key)
225
+ key = String(key)
226
+ unless key.bytesize == KEY_BYTES
227
+ raise ArgumentError, "key must be exactly #{KEY_BYTES} bytes, got #{key.bytesize}"
228
+ end
229
+ _init(key)
230
+ end
231
+
232
+ # Encrypt and authenticate +plaintext+. Optional +aad+ (additional
233
+ # authenticated data) is authenticated but not encrypted, and must be
234
+ # supplied identically to #open. Returns nonce ‖ ciphertext ‖ tag.
235
+ def seal(plaintext, aad: nil)
236
+ _seal(plaintext, aad)
237
+ end
238
+
239
+ # Verify and decrypt a blob from #seal. Raises Phylax::AuthenticationError
240
+ # if the ciphertext, tag, nonce, or aad don't match (or the blob is too
241
+ # short to be authentic).
242
+ def open(sealed, aad: nil)
243
+ _open(sealed, aad)
244
+ end
245
+
246
+ def inspect
247
+ "#<Phylax::SecretBox>" # never expose key material
248
+ end
249
+
250
+ private :_init, :_seal, :_open
251
+ end
252
+ end
metadata ADDED
@@ -0,0 +1,121 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: phylax
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ned
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '13.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '13.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake-compiler
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.2'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: vcvars
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.1'
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: 0.1.1
64
+ type: :development
65
+ prerelease: false
66
+ version_requirements: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - "~>"
69
+ - !ruby/object:Gem::Version
70
+ version: '0.1'
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: 0.1.1
74
+ description: |
75
+ phylax (Greek "guardian") is a thin, safe-by-default binding to the Windows
76
+ Cryptography API: Next Generation (CNG / bcrypt.dll) and DPAPI. It does not
77
+ implement any cryptography of its own — it exposes the operating system's own
78
+ validated primitives through an ergonomic, hard-to-misuse Ruby API: a
79
+ cryptographically secure RNG, SHA-2 hashing and HMAC (one-shot and streaming),
80
+ PBKDF2 key derivation, authenticated AES-256-GCM encryption (SecretBox, with
81
+ nonces generated and framed automatically so reuse is impossible), a
82
+ constant-time comparison, and DPAPI protect/unprotect for secrets at rest.
83
+ Windows MSVC (mswin) Ruby only.
84
+ executables: []
85
+ extensions:
86
+ - ext/phylax/extconf.rb
87
+ extra_rdoc_files: []
88
+ files:
89
+ - CHANGELOG.md
90
+ - LICENSE.txt
91
+ - README.md
92
+ - ext/phylax/extconf.rb
93
+ - ext/phylax/phylax.c
94
+ - lib/phylax.rb
95
+ - lib/phylax/version.rb
96
+ homepage: https://github.com/main-path/phylax
97
+ licenses:
98
+ - MIT
99
+ metadata:
100
+ homepage_uri: https://github.com/main-path/phylax
101
+ changelog_uri: https://github.com/main-path/phylax/blob/main/CHANGELOG.md
102
+ bug_tracker_uri: https://github.com/main-path/phylax/issues
103
+ rubygems_mfa_required: 'true'
104
+ rdoc_options: []
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '3.0'
112
+ required_rubygems_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ requirements: []
118
+ rubygems_version: 3.6.9
119
+ specification_version: 4
120
+ summary: Misuse-resistant cryptography for Ruby, backed by the Windows CNG provider.
121
+ test_files: []