AtlatestRepositorysigil-crypto

sigil-crypto / tree / testtest-minisign.sgl

1;;; (sigil crypto minisign) against signatures made by the real minisign.
2;;;
3;;; Fixtures live in test/fixtures/minisign and were made by
4;;; make-fixtures.sh with minisign 0.12, which also checked every one with
5;;; `minisign -V` before keeping it. Two come from other people's keys:
6;;; production registry.json under the Sigil registry root key, and the
7;;; minisign 0.12 source tarball under the minisign author's key.
8;;;
9;;; Every negative asserts the reason `minisign-failure-reason` gives, so a test
10;;; cannot pass because verification failed for some other cause.
12(import (sigil test)
13 (sigil crypto)
14 (sigil crypto minisign)
15 (sigil fs)
16 (sigil io)
17 (sigil math)
18 (sigil string))
20(define fixture-dir "test/fixtures/minisign/")
22(define (fixture name) (read-file-bytes (string-append fixture-dir name)))
23(define (fixture-text name) (utf8->string (fixture name)))
24(define (nth lst k) (if (= k 0) (car lst) (nth (cdr lst) (- k 1))))
26(define root-key-line "RWRa9dPSUBFexBbLdzZIfuAmuCYL736UeHC7IbdAOIYpgGIyDmWRaQHY")
27(define minisign-author-key-line "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3")
29(define key-a (minisign-parse-public-key (fixture "a.pub")))
30(define key-b (minisign-parse-public-key (fixture "b.pub")))
31(define root-key (minisign-parse-public-key root-key-line))
32(define author-key (minisign-parse-public-key minisign-author-key-line))
34(define (sig name) (minisign-parse-signature (fixture name)))
36;; The lines of a text fixture, and a way to rebuild it with one changed.
37(define (lines-of name) (string-split (fixture-text name) "\n"))
38(define (join-lines lines) (string-join lines "\n"))
39(define (with-line lines index new)
40 (let loop ((i 0) (ls lines) (acc '()))
41 (if (null? ls)
42 (reverse acc)
43 (loop (+ i 1) (cdr ls) (cons (if (= i index) new (car ls)) acc)))))
45;; Replace the character at `index` of `s` with `c`.
46(define (string-with-char s index c)
47 (string-append (substring s 0 index) (string c)
48 (substring s (+ index 1) (string-length s))))
50;; A different base64 alphabet character than `c`.
51(define (other-b64-char c) (if (char=? c #\A) #\B #\A))
53(define (flip-byte bv index)
54 (let ((out (bytevector-copy bv)))
55 (bytevector-u8-set! out index (bitwise-xor (bytevector-u8-ref out index) 1))
56 out))
58;; Rebuild a signature file's second line with bytes [start, end) of the
59;; decoded signature replaced by `new-bytes`.
60(define (resign-line line start new-bytes)
61 (let* ((raw (base64-decode line))
62 (out (bytevector-copy raw)))
63 (let loop ((i 0))
64 (when (< i (bytevector-length new-bytes))
65 (bytevector-u8-set! out (+ start i) (bytevector-u8-ref new-bytes i))
66 (loop (+ i 1))))
67 (base64-encode out)))
69;; #t when thunk raises an error whose message contains `needle`.
70(define (raises-with? needle thunk)
71 (guard (e (#t (and (error-object? e)
72 (string-contains? (error-object-message e) needle))))
73 (thunk)
74 #f))
76(define (raises? thunk)
77 (guard (e (#t #t)) (thunk) #f))
79(test-group "minisign fixtures are present"
80 ;; Presence probe with a positive answer: an empty or missing fixture
81 ;; directory must fail here, not quietly weaken the tests below.
82 (test "hello.txt reads back"
83 (assert-equal "hello, minisign\n" (fixture-text "hello.txt")))
84 (test "binary.bin is all 1024 bytes"
85 (assert-equal 1024 (bytevector-length (fixture "binary.bin")))))
87(test-group "minisign public keys"
88 (test "a .pub file parses and its id matches the file's comment"
89 (let* ((first-line (car (lines-of "a.pub")))
90 (id (substring first-line (- (string-length first-line) 16)
91 (string-length first-line))))
92 (assert-equal id (minisign-public-key-id key-a))))
93 (test "the bare base64 line parses to the same key"
94 (let ((bare (minisign-parse-public-key (cadr (lines-of "a.pub")))))
95 (assert-equal (minisign-public-key-id key-a) (minisign-public-key-id bare))
96 (assert-equal (minisign-public-key-bytes key-a) (minisign-public-key-bytes bare))))
97 (test "the registry root key has the id registry.json lists"
98 (assert-equal "C45E1150D2D3F55A" (minisign-public-key-id root-key)))
99 (test "public key bytes are 32 bytes"
100 (assert-equal 32 (bytevector-length (minisign-public-key-bytes key-a))))
101 (test "a key line one character short raises (length)"
102 (assert-true (raises-with? "wrong base64 length" (lambda () (minisign-parse-public-key
103 (substring root-key-line 0 55))))))
104 (test "a key line with a character outside base64 raises"
105 (assert-true (raises-with? "character outside base64" (lambda () (minisign-parse-public-key
106 (string-with-char root-key-line 20 #\!))))))
107 (test "a key whose algorithm is not Ed raises"
108 ;; "RW" starts the base64 of "Ed"; "RX" makes the algorithm "Et".
109 (assert-true (raises-with? "algorithm is not Ed" (lambda () (minisign-parse-public-key
110 (string-with-char root-key-line 1 #\X))))))
111 (test "three non-blank lines raise"
112 (assert-true (raises-with? "must be one base64 line" (lambda () (minisign-parse-public-key
113 (string-append (fixture-text "a.pub")
114 root-key-line "\n"))))))
115 (test "an empty string raises"
116 (assert-true (raises-with? "must be one base64 line" (lambda () (minisign-parse-public-key ""))))))
118(test-group "minisign signature parsing"
119 (test "ED is prehashed"
120 (assert-eq 'prehashed (minisign-signature-algorithm (sig "hello.txt.ED.minisig"))))
121 (test "Ed is legacy"
122 (assert-eq 'legacy (minisign-signature-algorithm (sig "hello.txt.Ed.minisig"))))
123 (test "the signature names key a"
124 (assert-equal (minisign-public-key-id key-a)
125 (minisign-signature-key-id (sig "hello.txt.ED.minisig"))))
126 (test "fewer than four lines raise"
127 (let ((ls (lines-of "hello.txt.ED.minisig")))
128 (assert-true (raises-with? "fewer than four lines" (lambda () (minisign-parse-signature
129 (join-lines (list (car ls) (cadr ls) (caddr ls)))))))))
130 (test "a first line without the untrusted prefix raises"
131 (let ((ls (lines-of "hello.txt.ED.minisig")))
132 (assert-true (raises-with? "first line must start with" (lambda () (minisign-parse-signature
133 (join-lines (with-line ls 0 "comment: x"))))))))
134 (test "a third line without the trusted prefix raises"
135 (let ((ls (lines-of "hello.txt.ED.minisig")))
136 (assert-true (raises-with? "third line must start with" (lambda () (minisign-parse-signature
137 (join-lines (with-line ls 2 "trusted: x"))))))))
138 (test "content after the global signature raises"
139 (assert-true (raises-with? "content after the global signature" (lambda () (minisign-parse-signature
140 (string-append (fixture-text "hello.txt.ED.minisig")
141 "extra\n"))))))
142 (test "a CR inside the trusted comment raises"
143 (let ((ls (lines-of "hello.txt.ED.minisig")))
144 (assert-true (raises-with? "trusted comment contains a CR or NUL" (lambda () (minisign-parse-signature
145 (join-lines (with-line ls 2 (string-append "trusted comment: a" (string #\return) "b")))))))))
146 (test "an unsupported algorithm raises"
147 ;; "RU" starts the base64 of "ED"; "RV" makes
148 ;; the second byte T, so the algorithm reads "ET".
149 (let* ((ls (lines-of "hello.txt.ED.minisig"))
150 (line (cadr ls)))
151 (assert-true (raises-with? "unsupported signature algorithm" (lambda () (minisign-parse-signature
152 (join-lines (with-line ls 1 (string-with-char line 1 #\V)))))))))
153 (test "non-zero base64 pad bits raise"
154 ;; The 74-byte signature ends in one '='; its last data character
155 ;; carries 2 pad bits. Setting them keeps the alphabet valid.
156 (let* ((ls (lines-of "hello.txt.ED.minisig"))
157 (line (cadr ls))
158 (last-data (string-ref line 98))
159 (bumped (integer->char (+ 1 (char->integer last-data)))))
160 (assert-true (raises-with? "non-canonical base64 padding" (lambda () (minisign-parse-signature
161 (join-lines (with-line ls 1 (string-with-char line 98 bumped))))))))))
163(test-group "minisign verification: signatures made by minisign"
164 (test "prehashed ED over a text file"
165 (assert-equal "sigil-crypto fixture: prehashed"
166 (minisign-verify key-a (sig "hello.txt.ED.minisig") (fixture "hello.txt"))))
167 (test "legacy Ed over a text file"
168 (assert-equal "sigil-crypto fixture: legacy"
169 (minisign-verify key-a (sig "hello.txt.Ed.minisig") (fixture "hello.txt"))))
170 (test "prehashed ED over an empty file"
171 (assert-equal "sigil-crypto fixture: empty file"
172 (minisign-verify key-a (sig "empty.txt.ED.minisig") (fixture "empty.txt"))))
173 (test "prehashed ED over every byte value"
174 (assert-equal "sigil-crypto fixture: binary"
175 (minisign-verify key-a (sig "binary.bin.ED.minisig") (fixture "binary.bin"))))
176 (test "legacy Ed over every byte value"
177 (assert-equal "sigil-crypto fixture: binary legacy"
178 (minisign-verify key-a (sig "binary.bin.Ed.minisig") (fixture "binary.bin"))))
179 (test "a UTF-8 trusted comment comes back intact"
180 (assert-equal "fixture ✓ unicode — trusted"
181 (minisign-verify key-a (sig "hello.txt.unicode.minisig") (fixture "hello.txt"))))
182 (test "a string message verifies like its bytes"
183 (assert-equal "sigil-crypto fixture: prehashed"
184 (minisign-verify key-a (sig "hello.txt.ED.minisig") "hello, minisign\n")))
185 (test "a signature file with CRLF line endings verifies"
186 (let ((crlf (string-join (lines-of "hello.txt.ED.minisig")
187 (string #\return #\newline))))
188 (assert-equal "sigil-crypto fixture: prehashed"
189 (minisign-verify key-a (minisign-parse-signature crlf) (fixture "hello.txt")))))
190 (test "minisign-verify-prehashed accepts ED"
191 (assert-equal "sigil-crypto fixture: prehashed"
192 (minisign-verify-prehashed key-a (sig "hello.txt.ED.minisig") (fixture "hello.txt")))))
194(test-group "minisign verification: real published signatures"
195 (test "production registry.json under the registry root key"
196 (assert-equal "registry=pkg.usesigil.org path=/v1/meta/registry.json seq=1 ts=2026-07-30T08:04:54Z"
197 (minisign-verify-prehashed root-key (sig "registry.json.minisig")
198 (fixture "registry.json"))))
199 (test "minisign 0.12's source tarball under the minisign author's key"
200 (assert-true (string? (minisign-verify author-key (sig "minisign-0.12.tar.gz.minisig")
201 (fixture "minisign-0.12.tar.gz")))))
202 (test "a one-bit change to registry.json is a bad signature"
203 (assert-eq 'bad-signature
204 (minisign-failure-reason root-key (sig "registry.json.minisig")
205 (flip-byte (fixture "registry.json") 100) #t)))
206 (test "a one-bit change to the tarball is a bad signature"
207 (assert-eq 'bad-signature
208 (minisign-failure-reason author-key (sig "minisign-0.12.tar.gz.minisig")
209 (flip-byte (fixture "minisign-0.12.tar.gz") 10000) #f)))
210 (test "registry.json.minisig does not verify under a fixture key"
211 (assert-eq 'key-id-mismatch
212 (minisign-failure-reason key-a (sig "registry.json.minisig") (fixture "registry.json") #f))))
214(test-group "minisign verification: negatives, each with its reason"
215 (test "valid control: no failure reason"
216 (assert-eq #f (minisign-failure-reason key-a (sig "hello.txt.ED.minisig") (fixture "hello.txt") #f)))
217 (test "tampered message, prehashed"
218 (assert-eq 'bad-signature
219 (minisign-failure-reason key-a (sig "hello.txt.ED.minisig") "hello, minisigN\n" #f)))
220 (test "tampered message, legacy"
221 (assert-eq 'bad-signature
222 (minisign-failure-reason key-a (sig "hello.txt.Ed.minisig") "hello, minisigN\n" #f)))
223 (test "tampered binary message, every 64th byte"
224 (let loop ((i 0))
225 (when (< i 1024)
226 (assert-eq 'bad-signature
227 (minisign-failure-reason key-a (sig "binary.bin.ED.minisig")
228 (flip-byte (fixture "binary.bin") i) #f))
229 (loop (+ i 64)))))
230 (test "a message with one extra byte"
231 (assert-eq 'bad-signature
232 (minisign-failure-reason key-a (sig "hello.txt.ED.minisig") "hello, minisign\n\n" #f)))
233 (test "the right signature for a different file"
234 (assert-eq 'bad-signature
235 (minisign-failure-reason key-a (sig "empty.txt.ED.minisig") (fixture "hello.txt") #f)))
236 (test "tampered trusted comment"
237 (let ((ls (lines-of "hello.txt.ED.minisig")))
238 (assert-eq 'bad-trusted-comment-signature
239 (minisign-failure-reason key-a
240 (minisign-parse-signature
241 (join-lines (with-line ls 2 "trusted comment: sigil-crypto fixture: prehashed!")))
242 (fixture "hello.txt") #f))))
243 (test "a trusted comment moved from another valid signature"
244 ;; Both signatures are genuine; splicing one's comment onto the other
245 ;; must fail the global signature.
246 (let ((ls (lines-of "hello.txt.ED.minisig"))
247 (other (lines-of "empty.txt.ED.minisig")))
248 (assert-eq 'bad-trusted-comment-signature
249 (minisign-failure-reason key-a
250 (minisign-parse-signature
251 (join-lines (with-line (with-line ls 2 (caddr other)) 3 (nth other 3))))
252 (fixture "hello.txt") #f))))
253 (test "tampered signature bytes"
254 (let* ((ls (lines-of "hello.txt.ED.minisig"))
255 (line (cadr ls)))
256 (assert-eq 'bad-signature
257 (minisign-failure-reason key-a
258 (minisign-parse-signature
259 (join-lines (with-line ls 1 (string-with-char line 50 (other-b64-char (string-ref line 50))))))
260 (fixture "hello.txt") #f))))
261 (test "tampered global signature bytes"
262 (let* ((ls (lines-of "hello.txt.ED.minisig"))
263 (line (nth ls 3)))
264 (assert-eq 'bad-trusted-comment-signature
265 (minisign-failure-reason key-a
266 (minisign-parse-signature
267 (join-lines (with-line ls 3 (string-with-char line 40 (other-b64-char (string-ref line 40))))))
268 (fixture "hello.txt") #f))))
269 (test "a signature by key b checked with key a"
270 (assert-eq 'key-id-mismatch
271 (minisign-failure-reason key-a (sig "hello.txt.keyb.minisig") (fixture "hello.txt") #f)))
272 (test "key b's signature relabelled with key a's id is still refused"
273 ;; The key id is not authentication: with the id matching, the
274 ;; Ed25519 check is what rejects it.
275 (let* ((ls (lines-of "hello.txt.keyb.minisig"))
276 (a-id (bytevector-copy (base64-decode (cadr (lines-of "hello.txt.ED.minisig"))) 2 10))
277 (relabelled (resign-line (cadr ls) 2 a-id)))
278 (assert-eq 'bad-signature
279 (minisign-failure-reason key-a
280 (minisign-parse-signature (join-lines (with-line ls 1 relabelled)))
281 (fixture "hello.txt") #f))))
282 (test "legacy refused when prehashed is required"
283 (assert-eq 'legacy-refused
284 (minisign-failure-reason key-a (sig "hello.txt.Ed.minisig") (fixture "hello.txt") #t)))
285 (test "minisign-verify-prehashed returns #f for a valid legacy signature"
286 (assert-false (minisign-verify-prehashed key-a (sig "hello.txt.Ed.minisig") (fixture "hello.txt"))))
287 (test "a legacy signature relabelled as prehashed fails"
288 ;; Changing Ed to ED makes the verifier hash first, so the same
289 ;; Ed25519 signature no longer matches the file. (The algorithm bytes
290 ;; are not signed; the other direction is the next test.)
291 (let* ((ls (lines-of "hello.txt.Ed.minisig"))
292 (relabelled (resign-line (cadr ls) 0 (bytevector 69 68))))
293 (assert-eq 'bad-signature
294 (minisign-failure-reason key-a
295 (minisign-parse-signature (join-lines (with-line ls 1 relabelled)))
296 (fixture "hello.txt") #f))))
297 (test "a prehashed signature relabelled as legacy: -V accepts the digest, -V -H refuses"
298 ;; Documented behaviour, shared with `minisign -V`: relabelled ED -> Ed
299 ;; is a valid legacy signature over the 64-byte BLAKE2b digest of the
300 ;; file. minisign-verify-prehashed is the defence.
301 (let* ((ls (lines-of "hello.txt.ED.minisig"))
302 (relabelled (minisign-parse-signature
303 (join-lines (with-line ls 1 (resign-line (cadr ls) 0 (bytevector 69 100))))))
304 (digest (blake2b-512 (fixture "hello.txt"))))
305 (assert-equal "sigil-crypto fixture: prehashed" (minisign-verify key-a relabelled digest))
306 (assert-false (minisign-verify key-a relabelled (fixture "hello.txt")))
307 (assert-false (minisign-verify-prehashed key-a relabelled digest))
308 (assert-eq 'legacy-refused (minisign-failure-reason key-a relabelled digest #t))))
309 (test "minisign-verify returns #f, not a comment, on failure"
310 (assert-false (minisign-verify key-a (sig "hello.txt.ED.minisig") "tampered"))))
312;; minisign 0.12's line limits (sig_load, minisign.c:170-203): a line,
313;; counting a trailing CR but not the LF, may be 1022 bytes for a comment
314;; and 8190 for the trusted comment line. Each limit is tested at the
315;; boundary, both sides, with and without CR.
316(define (x-line prefix total)
317 (string-append prefix (make-string (- total (string-length prefix)) #\x)))
319(define (sig-with-line index new)
320 (join-lines (with-line (lines-of "hello.txt.ED.minisig") index new)))
322(test-group "minisign line limits match minisign 0.12"
323 (test "an untrusted comment line of 1022 bytes parses"
324 (assert-true (minisign-signature? (minisign-parse-signature
325 (sig-with-line 0 (x-line "untrusted comment: " 1022))))))
326 (test "an untrusted comment line of 1023 bytes raises"
327 (assert-true (raises-with? "untrusted comment is too long"
328 (lambda () (minisign-parse-signature
329 (sig-with-line 0 (x-line "untrusted comment: " 1023)))))))
330 (test "1021 bytes plus CR parses; 1022 bytes plus CR raises"
331 (assert-true (minisign-signature? (minisign-parse-signature
332 (sig-with-line 0 (string-append (x-line "untrusted comment: " 1021) (string #\return))))))
333 (assert-true (raises-with? "untrusted comment is too long"
334 (lambda () (minisign-parse-signature
335 (sig-with-line 0 (string-append (x-line "untrusted comment: " 1022) (string #\return))))))))
336 (test "a NUL in the untrusted comment raises"
337 (assert-true (raises-with? "untrusted comment contains a NUL"
338 (lambda () (minisign-parse-signature
339 (sig-with-line 0 (string-append "untrusted comment: a" (string (integer->char 0)) "b")))))))
340 (test "a trusted comment line of 8190 bytes parses, 8191 raises"
341 (assert-true (minisign-signature? (minisign-parse-signature
342 (sig-with-line 2 (x-line "trusted comment: " 8190)))))
343 (assert-true (raises-with? "trusted comment is too long"
344 (lambda () (minisign-parse-signature
345 (sig-with-line 2 (x-line "trusted comment: " 8191)))))))
346 (test "a trusted comment line of 8190 bytes plus CR raises"
347 (assert-true (raises-with? "trusted comment is too long"
348 (lambda () (minisign-parse-signature
349 (sig-with-line 2 (string-append (x-line "trusted comment: " 8190) (string #\return))))))))
350 (test "a public key comment line of 1022 bytes parses, 1023 raises"
351 (let ((key-line (cadr (lines-of "a.pub"))))
352 (assert-true (minisign-public-key? (minisign-parse-public-key
353 (string-append (x-line "untrusted comment: " 1022) "\n" key-line "\n"))))
354 (assert-true (raises-with? "public key comment line is too long"
355 (lambda () (minisign-parse-public-key
356 (string-append (x-line "untrusted comment: " 1023) "\n" key-line "\n")))))))
357 (test "a blank line before a public key file's comment raises"
358 ;; minisign reads the first line as the comment, whatever it is.
359 (assert-true (raises-with? "must be one base64 line"
360 (lambda () (minisign-parse-public-key
361 (string-append "\n" (fixture-text "a.pub"))))))))
363(test-group "minisign argument checking"
364 (test "minisign-failure-reason refuses an unparsed key"
365 (assert-true (raises-with? "not a parsed public key" (lambda () (minisign-failure-reason root-key-line (sig "hello.txt.ED.minisig") "x" #f)))))
366 (test "minisign-failure-reason refuses an unparsed signature"
367 (assert-true (raises-with? "not a parsed signature" (lambda () (minisign-failure-reason key-a (fixture-text "hello.txt.ED.minisig") "x" #f))))))