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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE.txt +21 -0
- data/README.md +155 -0
- data/ext/phylax/extconf.rb +20 -0
- data/ext/phylax/phylax.c +1024 -0
- data/lib/phylax/version.rb +5 -0
- data/lib/phylax.rb +252 -0
- metadata +121 -0
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: []
|