Module: Phylax

Defined in:
lib/phylax.rb,
lib/phylax/version.rb,
ext/phylax/phylax.c

Overview

phylax — misuse-resistant cryptography for Ruby, backed by the Windows CNG provider (bcrypt.dll) and DPAPI (crypt32.dll).

It implements no cryptography itself; it is a thin, hard-to-misuse binding to the operating system’s own validated primitives.

key = Phylax::SecretBox.generate_key        # 32 secure random bytes
box = Phylax::SecretBox.new(key)
sealed = box.seal("attack at dawn")         # fresh nonce per call, authenticated
box.open(sealed)                            # => "attack at dawn"

Phylax.sha256("data")                       # raw 32-byte digest
Phylax.hmac_sha256(key, "msg")              # keyed MAC
Phylax.pbkdf2(password: pw, salt: s, iterations: 600_000, length: 32)
Phylax.random_bytes(16)
Phylax.protect(secret)                      # DPAPI, bound to this user

Defined Under Namespace

Classes: AuthenticationError, Digest, Error, HMAC, OSError, SecretBox

Constant Summary collapse

HASHES =

The only hash algorithms the gem accepts, mapped to their C-side index.

{ sha256: 0, sha384: 1, sha512: 2 }.freeze
VERSION =
"0.1.0"

Class Method Summary collapse

Class Method Details

.__hash(vidx, data) ⇒ Object

Phylax.__hash(idx, data) -> raw digest String (one-shot).



189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'ext/phylax/phylax.c', line 189

static VALUE
phylax_hash(VALUE self, VALUE vidx, VALUE data)
{
    int i = alg_index(vidx);
    PUCHAR pin, pout;
    ULONG nin;
    BCRYPT_HASH_HANDLE h = NULL;
    NTSTATUS st;
    VALUE out;

    StringValue(data);
    nin = ulen(data);
    out = rb_str_new(NULL, g_dlen[i]);
    rb_enc_associate(out, rb_ascii8bit_encoding());
    pin  = (PUCHAR)RSTRING_PTR(data);   /* pointers fetched after the last alloc */
    pout = (PUCHAR)RSTRING_PTR(out);

    st = BCryptCreateHash(g_hash[i], &h, NULL, 0, NULL, 0, 0);
    if (NT_SUCCESS(st)) {
        st = BCryptHashData(h, pin, nin, 0);
        if (NT_SUCCESS(st))
            st = BCryptFinishHash(h, pout, g_dlen[i], 0);
    }
    if (h) BCryptDestroyHash(h);
    if (!NT_SUCCESS(st))
        raise_nt("BCryptHash", st);

    return out;
}

.__hmac(vidx, key, data) ⇒ Object

Phylax.__hmac(idx, key, data) -> raw MAC String (one-shot).



220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
# File 'ext/phylax/phylax.c', line 220

static VALUE
phylax_hmac(VALUE self, VALUE vidx, VALUE key, VALUE data)
{
    int i = alg_index(vidx);
    PUCHAR pkey, pin, pout;
    ULONG nkey, nin;
    BCRYPT_HASH_HANDLE h = NULL;
    NTSTATUS st;
    VALUE out;

    StringValue(key);
    StringValue(data);
    nkey = ulen(key);
    nin  = ulen(data);
    out = rb_str_new(NULL, g_dlen[i]);
    rb_enc_associate(out, rb_ascii8bit_encoding());
    pkey = (PUCHAR)RSTRING_PTR(key);    /* pointers fetched after the last alloc */
    pin  = (PUCHAR)RSTRING_PTR(data);
    pout = (PUCHAR)RSTRING_PTR(out);

    st = BCryptCreateHash(g_hmac[i], &h, NULL, 0, pkey, nkey, 0);
    if (NT_SUCCESS(st)) {
        st = BCryptHashData(h, pin, nin, 0);
        if (NT_SUCCESS(st))
            st = BCryptFinishHash(h, pout, g_dlen[i], 0);
    }
    if (h) BCryptDestroyHash(h);
    if (!NT_SUCCESS(st))
        raise_nt("BCryptHash(HMAC)", st);

    return out;
}

.__pbkdf2(vidx, password, salt, viters, vlen) ⇒ Object

Phylax.__pbkdf2(idx, password, salt, iterations, length) -> derived key. Password/salt/output are copied into private heap buffers so the GVL can be released for the (potentially long) derivation without exposing the Ruby strings to GC compaction. Secret buffers are wiped before free.



277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
# File 'ext/phylax/phylax.c', line 277

static VALUE
phylax_pbkdf2(VALUE self, VALUE vidx, VALUE password, VALUE salt,
              VALUE viters, VALUE vlen)
{
    int i = alg_index(vidx);
    PUCHAR ppw, psalt;
    ULONG npw, nsalt;
    long len = NUM2LONG(vlen);
    long long signed_iters = NUM2LL(viters); /* RangeError if it overflows int64 */
    unsigned long long iters;
    struct pbkdf2_job job;
    VALUE out;

    /* Validate in C too: __pbkdf2 is reachable directly, bypassing the Ruby
     * guards, and a negative count would wrap (via the old NUM2ULL) into a
     * ~2^64 iteration run that spins uninterruptibly under the released GVL. */
    if (signed_iters < 1)
        rb_raise(rb_eArgError, "phylax: iterations must be >= 1");
    iters = (unsigned long long)signed_iters;

    if (len < 1)
        rb_raise(rb_eArgError, "phylax: length must be >= 1");
    if ((unsigned long long)len > 0xFFFFFFFFull)
        rb_raise(rb_eArgError, "phylax: length too large (> 4 GiB)");

    StringValue(password);
    StringValue(salt);
    npw   = ulen(password);
    nsalt = ulen(salt);
    ppw   = (PUCHAR)RSTRING_PTR(password);  /* copied into heap below, before any */
    psalt = (PUCHAR)RSTRING_PTR(salt);      /* further Ruby allocation            */

    job.hPrf    = g_hmac[i];
    job.pwlen   = npw;
    job.saltlen = nsalt;
    job.iters   = iters;
    job.outlen  = (ULONG)len;
    job.pw   = (PUCHAR)malloc(npw   ? npw   : 1);
    job.salt = (PUCHAR)malloc(nsalt ? nsalt : 1);
    job.out  = (PUCHAR)malloc((size_t)len);
    if (!job.pw || !job.salt || !job.out) {
        free(job.pw); free(job.salt); free(job.out);
        rb_raise(rb_eNoMemError, "phylax: out of memory in pbkdf2");
    }
    memcpy(job.pw, ppw, npw);
    memcpy(job.salt, psalt, nsalt);

    /* NULL ubf: the derivation is bounded CPU work that cannot be unblocked
     * mid-call, so it runs to completion rather than pretending to interrupt. */
    rb_thread_call_without_gvl(pbkdf2_nogvl, &job, NULL, NULL);

    SecureZeroMemory(job.pw, npw);
    free(job.pw);
    free(job.salt);

    if (!NT_SUCCESS(job.st)) {
        SecureZeroMemory(job.out, (size_t)len);
        free(job.out);
        raise_nt("BCryptDeriveKeyPBKDF2", job.st);
    }

    /* Wipe and free the derived key even if the String allocation raises (OOM). */
    {
        struct strcopy sc = { (const char *)job.out, len };
        int state = 0;
        out = rb_protect(strcopy_body, (VALUE)&sc, &state);
        SecureZeroMemory(job.out, (size_t)len);
        free(job.out);
        if (state) rb_jump_tag(state);
    }
    rb_enc_associate(out, rb_ascii8bit_encoding());
    return out;
}

.__protect(data, machine, entropy) ⇒ Object

Phylax.__protect(data, machine_scope_bool, entropy_or_nil) -> blob



851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
# File 'ext/phylax/phylax.c', line 851

static VALUE
phylax_protect(VALUE self, VALUE data, VALUE machine, VALUE entropy)
{
    DATA_BLOB in, ent, out;
    DWORD flags = CRYPTPROTECT_UI_FORBIDDEN;
    PUCHAR pin, pent = NULL;
    ULONG nin, nent = 0;
    VALUE result;

    StringValue(data);
    nin = ulen(data);
    if (!NIL_P(entropy)) { StringValue(entropy); nent = ulen(entropy); }
    pin = (PUCHAR)RSTRING_PTR(data);
    if (!NIL_P(entropy)) pent = (PUCHAR)RSTRING_PTR(entropy);
    if (RTEST(machine)) flags |= CRYPTPROTECT_LOCAL_MACHINE;

    in.cbData = nin;  in.pbData = pin;
    ent.cbData = nent; ent.pbData = pent;
    out.cbData = 0;   out.pbData = NULL;

    if (!CryptProtectData(&in, NULL, pent ? &ent : NULL, NULL, NULL, flags, &out)) {
        raise_gle("CryptProtectData", GetLastError(), 0);
    }

    if ((unsigned long long)out.cbData > (unsigned long long)LONG_MAX) {
        LocalFree(out.pbData);
        rb_raise(rb_eArgError, "phylax: DPAPI blob too large");
    }

    /* LocalFree the OS blob even if the String allocation raises (OOM). */
    {
        struct strcopy sc = { (const char *)out.pbData, (long)out.cbData };
        int state = 0;
        result = rb_protect(strcopy_body, (VALUE)&sc, &state);
        LocalFree(out.pbData);
        if (state) rb_jump_tag(state);
    }
    rb_enc_associate(result, rb_ascii8bit_encoding());
    return result;
}

.__unprotect(data, entropy) ⇒ Object

Phylax.__unprotect(data, entropy_or_nil) -> plaintext



893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
# File 'ext/phylax/phylax.c', line 893

static VALUE
phylax_unprotect(VALUE self, VALUE data, VALUE entropy)
{
    DATA_BLOB in, ent, out;
    PUCHAR pin, pent = NULL;
    ULONG nin, nent = 0;
    VALUE result;

    StringValue(data);
    nin = ulen(data);
    if (!NIL_P(entropy)) { StringValue(entropy); nent = ulen(entropy); }
    pin = (PUCHAR)RSTRING_PTR(data);
    if (!NIL_P(entropy)) pent = (PUCHAR)RSTRING_PTR(entropy);

    in.cbData = nin;  in.pbData = pin;
    ent.cbData = nent; ent.pbData = pent;
    out.cbData = 0;   out.pbData = NULL;

    if (!CryptUnprotectData(&in, NULL, pent ? &ent : NULL, NULL, NULL,
                            CRYPTPROTECT_UI_FORBIDDEN, &out)) {
        raise_gle("CryptUnprotectData", GetLastError(), 1);
    }

    if ((unsigned long long)out.cbData > (unsigned long long)LONG_MAX) {
        SecureZeroMemory(out.pbData, out.cbData);
        LocalFree(out.pbData);
        rb_raise(rb_eArgError, "phylax: DPAPI blob too large");
    }

    /* Wipe the recovered plaintext and free the OS blob even if the String
     * allocation raises (OOM). */
    {
        struct strcopy sc = { (const char *)out.pbData, (long)out.cbData };
        int state = 0;
        result = rb_protect(strcopy_body, (VALUE)&sc, &state);
        SecureZeroMemory(out.pbData, out.cbData);
        LocalFree(out.pbData);
        if (state) rb_jump_tag(state);
    }
    rb_enc_associate(result, rb_ascii8bit_encoding());
    return result;
}

.hmac_sha256(key, data) ⇒ Object

— one-shot HMAC ——————————————————-



41
# File 'lib/phylax.rb', line 41

def hmac_sha256(key, data) = __hmac(0, key, data)

.hmac_sha384(key, data) ⇒ Object



42
# File 'lib/phylax.rb', line 42

def hmac_sha384(key, data) = __hmac(1, key, data)

.hmac_sha512(key, data) ⇒ Object



43
# File 'lib/phylax.rb', line 43

def hmac_sha512(key, data) = __hmac(2, key, data)

.pbkdf2(password:, salt:, iterations:, length:, hash: :sha256) ⇒ Object

Derive a key from a password and salt. Defaults to HMAC-SHA256.

password:   secret bytes (String)
salt:       non-secret bytes (String); must not be empty, >= 16 advised
iterations: PRF iteration count (Integer >= 1)
length:     desired key length in bytes (Integer >= 1)
hash:       :sha256 / :sha384 / :sha512

Raises:

  • (ArgumentError)


53
54
55
56
57
58
59
60
# File 'lib/phylax.rb', line 53

def pbkdf2(password:, salt:, iterations:, length:, hash: :sha256)
  idx = HASHES.fetch(hash) { raise ArgumentError, "unknown hash #{hash.inspect}" }
  raise ArgumentError, "iterations must be >= 1" if iterations < 1
  raise ArgumentError, "length must be >= 1"     if length < 1
  raise ArgumentError, "salt must not be empty"  if String(salt).empty?

  __pbkdf2(idx, password, salt, iterations, length)
end

.protect(data, scope: :user, entropy: nil) ⇒ Object

Encrypt data with a key the OS derives from the current user (default) or the machine. The returned opaque blob can only be unprotected on the same machine (and, for :user scope, by the same user). An optional entropy string acts as a second secret that must be supplied identically to #unprotect.



69
70
71
# File 'lib/phylax.rb', line 69

def protect(data, scope: :user, entropy: nil)
  __protect(data, machine_scope!(scope), entropy)
end

.random_bytes(rn) ⇒ Object

Phylax.random_bytes(n) -> String (BINARY, length n)



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'ext/phylax/phylax.c', line 161

static VALUE
phylax_random_bytes(VALUE self, VALUE rn)
{
    long n = NUM2LONG(rn);
    VALUE out;
    NTSTATUS st;

    if (n < 0)
        rb_raise(rb_eArgError, "phylax: negative length");
    if ((unsigned long long)n > 0xFFFFFFFFull)
        rb_raise(rb_eArgError, "phylax: length too large (> 4 GiB)");

    out = rb_str_new(NULL, n);
    rb_enc_associate(out, rb_ascii8bit_encoding());
    if (n == 0)
        return out;

    st = BCryptGenRandom(NULL, (PUCHAR)RSTRING_PTR(out), (ULONG)n,
                         BCRYPT_USE_SYSTEM_PREFERRED_RNG);
    if (!NT_SUCCESS(st))
        raise_nt("BCryptGenRandom", st);   /* never returns the zeroed buffer */

    return out;
}

.secure_compare(a, b) ⇒ Object

Phylax.secure_compare(a, b) -> true/false. Constant time w.r.t. content for equal-length inputs; short-circuits false on differing length (length is not secret for tag comparison — documented).



356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
# File 'ext/phylax/phylax.c', line 356

static VALUE
phylax_secure_compare(VALUE self, VALUE a, VALUE b)
{
    long la, lb, i;
    const volatile unsigned char *pa, *pb;
    volatile unsigned char acc = 0;

    StringValue(a);
    StringValue(b);
    la = RSTRING_LEN(a);
    lb = RSTRING_LEN(b);
    if (la != lb)
        return Qfalse;

    pa = (const volatile unsigned char *)RSTRING_PTR(a);
    pb = (const volatile unsigned char *)RSTRING_PTR(b);
    for (i = 0; i < la; i++)
        acc |= (unsigned char)(pa[i] ^ pb[i]);

    return acc == 0 ? Qtrue : Qfalse;
}

.sha256(data) ⇒ Object

— one-shot hashing —————————————————-



35
# File 'lib/phylax.rb', line 35

def sha256(data) = __hash(0, data)

.sha384(data) ⇒ Object



36
# File 'lib/phylax.rb', line 36

def sha384(data) = __hash(1, data)

.sha512(data) ⇒ Object



37
# File 'lib/phylax.rb', line 37

def sha512(data) = __hash(2, data)

.unprotect(data, scope: :user, entropy: nil) ⇒ Object

Reverse of #protect. Raises Phylax::AuthenticationError if the blob was tampered with, the wrong entropy was given, or it belongs to another user/machine. (DPAPI infers the scope from the blob; scope is validated for symmetry but does not change the operation.)



77
78
79
80
# File 'lib/phylax.rb', line 77

def unprotect(data, scope: :user, entropy: nil)
  machine_scope!(scope)
  __unprotect(data, entropy)
end