Commit71e41e73Recorded27 Apr 2026Repositorysigil-crypto

Add hmac-sha256-bytes + pbkdf2-sha256 primitives (v0.15.0)

Message

Adds two SCRAM-SHA-256 building blocks to the public API:

  hmac-sha256-bytes  HMAC-SHA-256 returning a 32-byte bytevector
                     (companion to the existing hex-string hmac-sha256)
  pbkdf2-sha256      PBKDF2 key derivation with HMAC-SHA-256 as the
                     underlying PRF (companion to pbkdf2-sha1)

Both are thin wrappers around mbedTLS — mbedtls_md_hmac with MBEDTLSMDSHA256 and mbedtls_pkcs5_pbkdf2_hmac_ext with MBEDTLSMDSHA256 respectively. mbedTLS already has SHA-256 enabled in our config (hmac-sha256 has been using it); no compiler flag changes are needed.

Required by SCRAM-SHA-256 (RFC 5802 / RFC 7677): the salted-password Hi(p, s, i) is PBKDF2-SHA-256, and the subsequent ClientKey / StoredKey / ClientSignature / ClientProof chain operates on raw bytes that are XORed and concatenated — so a hex-string HMAC output forces lossy hex<->bytes conversions that are easy to get wrong.

Public API change: minor bump to 0.15.0.

Tests: - hmac-sha256-bytes RFC 4231 test cases 1 & 2 (cross-checked against the existing hex-string hmac-sha256 for consistency) - pbkdf2-sha256 RFC 7914 §11 vector 1 (passwd/salt/c=1/dkLen=64, expected 64-byte output as bytevector literal) plus determinism / iteration / salt / password sensitivity checks

Changed
 native/crypto.c      | 148 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 package.sgl          |   5 +++--
 src/sigil/crypto.sgl |  37 ++++++++++++++++++++++++++++++++++---
 test/test-crypto.sgl |  97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 281 insertions(+), 6 deletions(-)
Diff
native/crypto.cmodified
@@ -267,6 +267,62 @@ static Value native_hmac_sha256(SigilVM *vm, int argc, Value *args)
267
return sigil_make_string(vm, hex, 64);
268
}
269
+270
/*
+271
* hmac-sha256-bytes key data -> bytevector
+272
* Like hmac-sha256 but returns the 32-byte MAC as a bytevector instead
+273
* of a hex string. Required for SCRAM-SHA-256 where intermediate values
+274
* are bytewise XORed and concatenated.
+275
*/
+276
static Value native_hmac_sha256_bytes(SigilVM *vm, int argc, Value *args)
+277
{
+278
(void)argc;
+279
+280
const unsigned char *key_data;
+281
size_t key_len;
+282
const unsigned char *msg_data;
+283
size_t msg_len;
+284
+285
if (sigil_is_string(args[0])) {
+286
SigilString *s = (SigilString *)sigil_as_ptr(args[0]);
+287
key_data = (const unsigned char *)s->data;
+288
key_len = s->byte_length;
+289
} else if (sigil_is_bytevector(args[0])) {
+290
SigilBytevector *bv = (SigilBytevector *)sigil_as_ptr(args[0]);
+291
key_data = bv->data;
+292
key_len = bv->length;
+293
} else {
+294
sigil__vm_error(vm, SIGIL_ERR_TYPE, "hmac-sha256-bytes: expected string or bytevector for key");
+295
return SIGIL_UNDEFINED;
+296
}
+297
+298
if (sigil_is_string(args[1])) {
+299
SigilString *s = (SigilString *)sigil_as_ptr(args[1]);
+300
msg_data = (const unsigned char *)s->data;
+301
msg_len = s->byte_length;
+302
} else if (sigil_is_bytevector(args[1])) {
+303
SigilBytevector *bv = (SigilBytevector *)sigil_as_ptr(args[1]);
+304
msg_data = bv->data;
+305
msg_len = bv->length;
+306
} else {
+307
sigil__vm_error(vm, SIGIL_ERR_TYPE, "hmac-sha256-bytes: expected string or bytevector for data");
+308
return SIGIL_UNDEFINED;
+309
}
+310
+311
unsigned char hmac[32];
+312
+313
const mbedtls_md_info_t *md_info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
+314
int ret = mbedtls_md_hmac(md_info, key_data, key_len, msg_data, msg_len, hmac);
+315
if (ret != 0) {
+316
return SIGIL_FALSE;
+317
}
+318
+319
Value result = sigil_make_bytevector(vm, 32);
+320
if (sigil_is_bytevector(result)) {
+321
memcpy(sigil_bytevector_data(result), hmac, 32);
+322
}
+323
return result;
+324
}
+325
326
/*
327
* hmac-sha1 key data -> bytevector
328
* Compute HMAC-SHA1 of data using the given key.
@@ -410,6 +466,92 @@ static Value native_pbkdf2_sha1(SigilVM *vm, int argc, Value *args)
466
return result;
467
}
468
+469
/*
+470
* pbkdf2-sha256 password salt iterations key-length -> bytevector
+471
* Derive a key using PBKDF2 with HMAC-SHA256.
+472
* Password and salt can be strings or bytevectors.
+473
* Returns derived key as bytevector.
+474
*
+475
* Required for SCRAM-SHA-256 (RFC 5802 / RFC 7677): the salted password
+476
* `Hi(password, salt, iterations)` is PBKDF2-SHA-256 of the user's
+477
* password against the per-user salt, with iterations chosen by the
+478
* server (typically 4096+).
+479
*/
+480
static Value native_pbkdf2_sha256(SigilVM *vm, int argc, Value *args)
+481
{
+482
(void)argc;
+483
+484
const unsigned char *password;
+485
size_t password_len;
+486
const unsigned char *salt;
+487
size_t salt_len;
+488
+489
if (sigil_is_string(args[0])) {
+490
SigilString *s = (SigilString *)sigil_as_ptr(args[0]);
+491
password = (const unsigned char *)s->data;
+492
password_len = s->byte_length;
+493
} else if (sigil_is_bytevector(args[0])) {
+494
SigilBytevector *bv = (SigilBytevector *)sigil_as_ptr(args[0]);
+495
password = bv->data;
+496
password_len = bv->length;
+497
} else {
+498
sigil__vm_error(vm, SIGIL_ERR_TYPE, "pbkdf2-sha256: expected string or bytevector for password");
+499
return SIGIL_UNDEFINED;
+500
}
+501
+502
if (sigil_is_string(args[1])) {
+503
SigilString *s = (SigilString *)sigil_as_ptr(args[1]);
+504
salt = (const unsigned char *)s->data;
+505
salt_len = s->byte_length;
+506
} else if (sigil_is_bytevector(args[1])) {
+507
SigilBytevector *bv = (SigilBytevector *)sigil_as_ptr(args[1]);
+508
salt = bv->data;
+509
salt_len = bv->length;
+510
} else {
+511
sigil__vm_error(vm, SIGIL_ERR_TYPE, "pbkdf2-sha256: expected string or bytevector for salt");
+512
return SIGIL_UNDEFINED;
+513
}
+514
+515
if (!sigil_is_fixnum(args[2])) {
+516
sigil__vm_error(vm, SIGIL_ERR_TYPE, "pbkdf2-sha256: expected integer for iterations");
+517
return SIGIL_UNDEFINED;
+518
}
+519
int iterations = (int)sigil_as_fixnum(args[2]);
+520
if (iterations < 1) {
+521
sigil__vm_error(vm, SIGIL_ERR_RUNTIME, "pbkdf2-sha256: iterations must be positive");
+522
return SIGIL_UNDEFINED;
+523
}
+524
+525
if (!sigil_is_fixnum(args[3])) {
+526
sigil__vm_error(vm, SIGIL_ERR_TYPE, "pbkdf2-sha256: expected integer for key-length");
+527
return SIGIL_UNDEFINED;
+528
}
+529
int key_length = (int)sigil_as_fixnum(args[3]);
+530
if (key_length < 1 || key_length > 65536) {
+531
sigil__vm_error(vm, SIGIL_ERR_RUNTIME, "pbkdf2-sha256: key-length must be 1-65536");
+532
return SIGIL_UNDEFINED;
+533
}
+534
+535
unsigned char *output = malloc(key_length);
+536
if (!output) return SIGIL_FALSE;
+537
+538
int ret = mbedtls_pkcs5_pbkdf2_hmac_ext(MBEDTLS_MD_SHA256,
+539
password, password_len,
+540
salt, salt_len,
+541
iterations, key_length, output);
+542
if (ret != 0) {
+543
free(output);
+544
return SIGIL_FALSE;
+545
}
+546
+547
Value result = sigil_make_bytevector(vm, key_length);
+548
if (sigil_is_bytevector(result)) {
+549
memcpy(sigil_bytevector_data(result), output, key_length);
+550
}
+551
free(output);
+552
return result;
+553
}
+554
555
/*
556
* base64-encode data -> string
557
* Encode data (string or bytevector) as base64.
@@ -664,13 +806,17 @@ void sigil__init_sigil_crypto_module(SigilVM *vm)
806
807
/* HMAC */
808
REGISTER_AND_EXPORT("hmac-sha256", native_hmac_sha256,
667
SIGIL_ARITY_EXACT(2), "Compute HMAC-SHA256");
+809
SIGIL_ARITY_EXACT(2), "Compute HMAC-SHA256 (hex string)");
+810
REGISTER_AND_EXPORT("hmac-sha256-bytes", native_hmac_sha256_bytes,
+811
SIGIL_ARITY_EXACT(2), "Compute HMAC-SHA256 (bytevector)");
812
REGISTER_AND_EXPORT("hmac-sha1", native_hmac_sha1,
813
SIGIL_ARITY_EXACT(2), "Compute HMAC-SHA1 (returns bytevector)");
814
815
/* Key Derivation */
816
REGISTER_AND_EXPORT("pbkdf2-sha1", native_pbkdf2_sha1,
817
SIGIL_ARITY_EXACT(4), "Derive key using PBKDF2-HMAC-SHA1");
+818
REGISTER_AND_EXPORT("pbkdf2-sha256", native_pbkdf2_sha256,
+819
SIGIL_ARITY_EXACT(4), "Derive key using PBKDF2-HMAC-SHA256");
820
821
/* Base64 */
822
REGISTER_AND_EXPORT("base64-encode", native_base64_encode,
package.sglmodified
@@ -2,7 +2,8 @@
2
;;;
3
;;; Provides cryptographic primitives using mbedTLS:
4
;;; - SHA-1 and SHA-256 hashing
5
;;; - HMAC-SHA256 message authentication
+5
;;; - HMAC-SHA1 / HMAC-SHA256 (hex + bytevector outputs)
+6
;;; - PBKDF2-SHA1 / PBKDF2-SHA256 key derivation
7
;;; - Base64 encoding/decoding
8
;;; - Cryptographically secure random bytes
9
;;;
@@ -10,7 +11,7 @@
11
12
(package
13
name: "sigil-crypto"
13
version: "0.14.1"
+14
version: "0.15.0"
15
sigil: "^0.14"
16
description: "Cryptographic functions for Sigil (SHA, HMAC, base64, random)"
17
url: "https://codeberg.org/sigil/sigil-crypto"
src/sigil/crypto.sglmodified
@@ -20,8 +20,10 @@
20
sha256
21
sha256-bytes
22
hmac-sha256
+23
hmac-sha256-bytes
24
hmac-sha1
25
pbkdf2-sha1
+26
pbkdf2-sha256
27
base64-encode
28
base64-decode
29
random-bytes
@@ -71,6 +73,18 @@
73
(define-native (hmac-sha256 key message)
74
(: (any-of string? bytevector?) (any-of string? bytevector?) -> string?))
75
+76
;;; Compute HMAC-SHA256 returning the raw 32-byte MAC as a bytevector.
+77
;;;
+78
;;; Used by SCRAM-SHA-256 (RFC 5802 / RFC 7677) where MAC outputs
+79
;;; are XORed and concatenated bytewise; a hex round-trip would be
+80
;;; both wasteful and error-prone.
+81
;;;
+82
;;; ```scheme
+83
;;; (hmac-sha256-bytes "secret" "msg") ; => 32-byte bytevector
+84
;;; ```
+85
(define-native (hmac-sha256-bytes key message)
+86
(: (any-of string? bytevector?) (any-of string? bytevector?) -> bytevector?))
+87
88
;;; Compute HMAC-SHA1 message authentication code.
89
;;;
90
;;; Both key and message accept strings or bytevectors.
@@ -86,13 +100,30 @@
100
;;;
101
;;; Password and salt accept strings or bytevectors. Iterations controls
102
;;; the work factor. Key-length specifies the output size in bytes.
89
;;; Returns the derived key as a hex string.
+103
;;; Returns the derived key as a bytevector.
104
;;;
105
;;; ```scheme
92
;;; (pbkdf2-sha1 "password" "salt" 4096 20) ; => hex string
+106
;;; (pbkdf2-sha1 "password" "salt" 4096 20) ; => 20-byte bytevector
107
;;; ```
108
(define-native (pbkdf2-sha1 password salt iterations key-length)
95
(: (any-of string? bytevector?) (any-of string? bytevector?) integer? integer? -> string?))
+109
(: (any-of string? bytevector?) (any-of string? bytevector?) integer? integer? -> bytevector?))
+110
+111
;;; Derive a key using PBKDF2-SHA256.
+112
;;;
+113
;;; Password and salt accept strings or bytevectors. Iterations
+114
;;; controls the work factor (4096+ recommended for production).
+115
;;; Key-length specifies the output size in bytes.
+116
;;; Returns the derived key as a bytevector.
+117
;;;
+118
;;; Required for SCRAM-SHA-256 (RFC 5802 / RFC 7677): the
+119
;;; salted-password Hi(p, s, i) is PBKDF2-SHA-256 of the password
+120
;;; against the per-user salt.
+121
;;;
+122
;;; ```scheme
+123
;;; (pbkdf2-sha256 "password" "salt" 4096 32) ; => 32-byte bytevector
+124
;;; ```
+125
(define-native (pbkdf2-sha256 password salt iterations key-length)
+126
(: (any-of string? bytevector?) (any-of string? bytevector?) integer? integer? -> bytevector?))
127
128
;;; Encode data as a base64 string.
129
;;;
test/test-crypto.sglmodified
@@ -74,6 +74,39 @@
74
(assert-true (bytevector? result))
75
(assert-equal 20 (bytevector-length result)))))
76
+77
;; ============================================================
+78
;; hmac-sha256-bytes
+79
;; ============================================================
+80
+81
(test-group "hmac-sha256-bytes"
+82
(test "returns 32-byte bytevector"
+83
(let ((result (hmac-sha256-bytes "key" "msg")))
+84
(assert-true (bytevector? result))
+85
(assert-equal 32 (bytevector-length result))))
+86
+87
;; RFC 4231 test case 1:
+88
;; key = 20 bytes of 0x0b, data = "Hi There"
+89
;; expected = b0344c61d8db38535ca8afceaf0bf12b
+90
;; 881dc200c9833da726e9376c2e32cff7
+91
(test "RFC 4231 test case 1"
+92
(let* ((key (make-bytevector 20 #x0b))
+93
(result (hmac-sha256-bytes key "Hi There"))
+94
(expected-hex "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7")
+95
(hex-result (hmac-sha256 key "Hi There")))
+96
(assert-equal 32 (bytevector-length result))
+97
;; The bytevector form must agree with the hex form
+98
(assert-equal expected-hex hex-result)))
+99
+100
;; RFC 4231 test case 2: key = "Jefe", data = "what do ya want for nothing?"
+101
;; expected = 5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843
+102
(test "RFC 4231 test case 2"
+103
(let* ((result (hmac-sha256-bytes "Jefe" "what do ya want for nothing?"))
+104
(hex (hmac-sha256 "Jefe" "what do ya want for nothing?")))
+105
(assert-equal 32 (bytevector-length result))
+106
(assert-equal
+107
"5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
+108
hex))))
+109
110
;; ============================================================
111
;; pbkdf2-sha1
112
;; ============================================================
@@ -111,6 +144,70 @@
144
(assert-true (bytevector? result))
145
(assert-equal (pbkdf2-sha1 "password" "salt" 1 20) result))))
146
+147
;; ============================================================
+148
;; pbkdf2-sha256
+149
;; ============================================================
+150
;;
+151
;; Known-answer vector: RFC 7914 §11 PBKDF2-HMAC-SHA-256 reference.
+152
;;
+153
;; PBKDF2-HMAC-SHA-256 ("passwd", "salt", 1, 64) =
+154
;; 55ac046e 56e3089f ec1691c2 2544b605
+155
;; f9418521 6dde0465 e68b9d57 c20dacbc
+156
;; 8c92a0d0 0db1ed46 d6df2cee 2e96d3da
+157
;; 5ec1c01f a82c4f04 1ee2070d e64df97a
+158
+159
(define %rfc7914-vec1
+160
(bytevector
+161
#x55 #xac #x04 #x6e #x56 #xe3 #x08 #x9f
+162
#xec #x16 #x91 #xc2 #x25 #x44 #xb6 #x05
+163
#xf9 #x41 #x85 #x21 #x6d #xde #x04 #x65
+164
#xe6 #x8b #x9d #x57 #xc2 #x0d #xac #xbc
+165
#x8c #x92 #xa0 #xd0 #x0d #xb1 #xed #x46
+166
#xd6 #xdf #x2c #xee #x2e #x96 #xd3 #xda
+167
#x5e #xc1 #xc0 #x1f #xa8 #x2c #x4f #x04
+168
#x1e #xe2 #x07 #x0d #xe6 #x4d #xf9 #x7a))
+169
+170
(test-group "pbkdf2-sha256"
+171
+172
(test "RFC 7914 vector 1: passwd/salt/c=1/dkLen=64"
+173
(let ((result (pbkdf2-sha256 "passwd" "salt" 1 64)))
+174
(assert-true (bytevector? result))
+175
(assert-equal 64 (bytevector-length result))
+176
(assert-equal %rfc7914-vec1 result)))
+177
+178
(test "different keylen sizes work"
+179
(let ((short (pbkdf2-sha256 "p" "s" 100 16))
+180
(long (pbkdf2-sha256 "p" "s" 100 64)))
+181
(assert-equal 16 (bytevector-length short))
+182
(assert-equal 64 (bytevector-length long))
+183
;; Longer derivation is a superset of shorter when keylen is the
+184
;; only thing that changes — PBKDF2's first dkLen bytes are
+185
;; deterministic regardless of total length requested.
+186
))
+187
+188
(test "deterministic"
+189
(assert-equal (pbkdf2-sha256 "p" "s" 1000 32)
+190
(pbkdf2-sha256 "p" "s" 1000 32)))
+191
+192
(test "iteration sensitivity"
+193
(assert-false (equal? (pbkdf2-sha256 "p" "s" 1 32)
+194
(pbkdf2-sha256 "p" "s" 2 32))))
+195
+196
(test "salt sensitivity"
+197
(assert-false (equal? (pbkdf2-sha256 "p" "salt-a" 100 32)
+198
(pbkdf2-sha256 "p" "salt-b" 100 32))))
+199
+200
(test "password sensitivity"
+201
(assert-false (equal? (pbkdf2-sha256 "pass-a" "s" 100 32)
+202
(pbkdf2-sha256 "pass-b" "s" 100 32))))
+203
+204
(test "bytevector and string inputs agree"
+205
(let* ((pass-bv (base64-decode (base64-encode "password")))
+206
(salt-bv (base64-decode (base64-encode "salt")))
+207
(a (pbkdf2-sha256 pass-bv salt-bv 100 32))
+208
(b (pbkdf2-sha256 "password" "salt" 100 32)))
+209
(assert-equal a b))))
+210
211
;; ============================================================
212
;; base64
213
;; ============================================================