obsigil 0.3.0

A shared-secret JWT alternative: a mandate-token format splitting a public, advisory manifest from a secret-sealed, authenticated mandate (AES-SIV / AES-GCM-SIV), with fields in canonical CBOR
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
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
218
219
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
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
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
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
#!/usr/bin/env python3
"""Generate obsigil test vectors for the canonical-CBOR model.

Both halves of a token are a single canonical CBOR map (RFC 8949 §4.2):
reserved fields at negative integer keys (tid -1, exp -2, aud -3, sub -4,
iss -5), application data at non-negative integer / text-string keys, keys
sorted by their encoded bytes, integers and lengths shortest-form, definite
lengths. We build the per-half octets with a tiny canonical encoder, `seal`
them with the obsigil CLI, and assemble the token; every line is self-checked
against the CLI before it is written (positives reproduce and verify / open;
negatives exit non-zero). Because the verifier now rejects non-canonical
CBOR, a positive whose octets are not canonical fails its own self-check.

Usage:  OBSIGIL_BIN=/path/to/obsigil python3 tools/generate.py
"""

import json
import os
import struct
import subprocess
import uuid

BIN = os.environ.get("OBSIGIL_BIN", "obsigil")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

TID = "019ed29a-378d-72f0-b462-4929cd2bfcad"  # a fixed UUIDv7
NIL = "00000000-0000-0000-0000-000000000000"  # version 0 — not a v7
# Version field 7 but variant nibble 0 (NCS, 0b00) — not a well-formed UUIDv7
# (the Reserved fields `tid`, spec §8.2, requires version 7 AND the
# RFC 4122 variant 0b10).
TID_BADVAR = "019ed29a-378d-72f0-0462-4929cd2bfcad"
V4 = "00000000-0000-4000-8000-000000000000"  # version 4 (the common UUID)
V8 = "00000000-0000-8000-8000-000000000000"  # version 8
# Version 7 but the Microsoft variant (top bits 0b110), not RFC 4122 (0b10).
V7_MSVAR = "019ed29a-378d-72f0-c462-4929cd2bfcad"

# Reserved field -> negative integer key (the Reserved fields section,
# spec §8; sign-namespaced per the Serialization section, §7).
RKEY = {"tid": -1, "exp": -2, "aud": -3, "sub": -4, "iss": -5}
RESERVED = set(RKEY)


# ------------------------------------------------------------- canonical CBOR
def _head(major, n):
    """A CBOR head: major type (0-7) << 5 | shortest-form argument `n`."""
    if n < 24:
        return bytes([(major << 5) | n])
    if n < 0x100:
        return bytes([(major << 5) | 24, n])
    if n < 0x10000:
        return bytes([(major << 5) | 25]) + n.to_bytes(2, "big")
    if n < 0x1_0000_0000:
        return bytes([(major << 5) | 26]) + n.to_bytes(4, "big")
    return bytes([(major << 5) | 27]) + n.to_bytes(8, "big")


def cbor(v):
    """Canonical CBOR (RFC 8949 §4.2) for the value types obsigil uses."""
    if isinstance(v, bool):  # bool is a subclass of int — test it first
        return bytes([0xF5 if v else 0xF4])
    if isinstance(v, int):
        return _head(0, v) if v >= 0 else _head(1, -1 - v)
    if isinstance(v, float):
        # Shortest-form float (RFC 8949 §4.2): the smallest of f16/f32/f64
        # that round-trips the value exactly (matches ciborium / fxamacker).
        for head, fmt in ((0xF9, ">e"), (0xFA, ">f")):
            try:
                packed = struct.pack(fmt, v)
            except (OverflowError, struct.error):
                continue
            if struct.unpack(fmt, packed)[0] == v:
                return bytes([head]) + packed
        return bytes([0xFB]) + struct.pack(">d", v)
    if isinstance(v, bytes):
        return _head(2, len(v)) + v
    if isinstance(v, str):
        b = v.encode("utf-8")
        return _head(3, len(b)) + b
    if isinstance(v, list):
        return _head(4, len(v)) + b"".join(cbor(x) for x in v)
    if isinstance(v, dict):
        items = sorted((cbor(k), cbor(val)) for k, val in v.items())
        return _head(5, len(items)) + b"".join(k + val for k, val in items)
    raise TypeError(f"unsupported CBOR value: {v!r}")


def reserved_map(fields):
    """A CBOR map dict from a logical fields dict: reserved fields at their
    negative integer keys (tid as 16-byte binary), app fields at text keys."""
    m = {}
    if "tid" in fields:
        m[RKEY["tid"]] = uuid.UUID(fields["tid"]).bytes
    for name in ("exp", "aud", "sub", "iss"):
        if name in fields:
            m[RKEY[name]] = fields[name]
    for k, val in fields.items():
        if k not in RESERVED:
            m[k] = val
    return m


def octets(fields):
    return cbor(reserved_map(fields)).hex()


# ------------------------------------------------------------------- CLI glue
def run(args, check=True, stdin=None):
    r = subprocess.run([BIN, *args], capture_output=True, text=True, input=stdin)
    if check and r.returncode != 0:
        raise SystemExit(f"CLI failed ({r.returncode}): obsigil {' '.join(args)}\n{r.stderr}")
    return r


def seal(octets_hex, key, alg, enc):
    return run(["seal", "--octets", octets_hex, "-k", key, "--alg", alg, "-e", enc]).stdout.strip()


def open_half(text, key, alg, enc):
    # `--half=...` (equals form) so a ciphertext text starting with `-`/`_`
    # (the b64url alphabet) is taken as a value, not parsed as a flag.
    r = run(["open", f"--half={text}", "-k", key, "--alg", alg, "-e", enc], check=False)
    return r.stdout.strip() if r.returncode == 0 else None


# Token-positional ops read the token from stdin (`-`), so a token starting
# with `-` (the b64url alphabet) is never mistaken for a flag.
def verify_ok(token, audience=None):
    args = ["clauses", "-", "-k", "mandate", "--now", "1000000000"]
    if audience:
        args += ["-a", audience]
    return run(args, check=False, stdin=token).returncode == 0


def open_manifest_ok(token):
    # The vector op keyword stays `open-manifest`; the CLI subcommand is `claims`.
    return run(["claims", "-"], check=False, stdin=token).returncode == 0


def sep(enc):
    return "." if enc == "b64" else "~"


def mandate_token(octets_hex, alg="0", enc="b64", key="mandate"):
    return sep(enc) + alg + seal(octets_hex, key, alg, enc)


def manifest_token(octets_hex, alg="0", enc="b64", key="manifest"):
    return seal(octets_hex, key, alg, enc) + alg + sep(enc)


def map_token(map_dict, alg="0", enc="b64", key="mandate"):
    """A mandate-only token whose plaintext is the canonical CBOR of an
    explicit int/text-keyed map — for wrong-type and unknown-key cases."""
    return mandate_token(cbor(map_dict).hex(), alg, enc, key)


# Deliberately NON-canonical CBOR plaintexts (raw bytes, bypassing `cbor`'s
# canonical map encoding), each a mandate-only token the verifier must reject.
def _entry(k, v):
    return cbor(k) + cbor(v)


def _tid():
    return uuid.UUID(TID).bytes


def raw_token(raw_bytes, alg="0", enc="b64", key="mandate"):
    return mandate_token(raw_bytes.hex(), alg, enc, key)


def dup_key():
    # 3-pair map (0xa3) with a duplicate -2 (exp) key.
    return bytes([0xA3]) + _entry(-1, _tid()) + _entry(-2, 4000000000) + _entry(-2, 1)


def unsorted_keys():
    # -2 (0x21) before -1 (0x20): canonical requires -1 first.
    return bytes([0xA2]) + _entry(-2, 4000000000) + _entry(-1, _tid())


def nonshortest_int():
    # exp 4000000000 in an 8-byte int head (0x1b) rather than the 4-byte (0x1a).
    exp_long = bytes([0x1B]) + (4000000000).to_bytes(8, "big")
    return bytes([0xA2]) + _entry(-1, _tid()) + cbor(-2) + exp_long


def indefinite_map():
    # 0xbf ... 0xff: indefinite length; canonical requires definite.
    return bytes([0xBF]) + _entry(-1, _tid()) + _entry(-2, 4000000000) + bytes([0xFF])


def trailing_bytes():
    return cbor(reserved_map(MAND)) + bytes([0x00])


def nonshortest_len():
    # tid (16 bytes) with a non-shortest length head (0x58 0x10, a 1-byte
    # length) instead of the direct 0x50 — non-canonical (the
    # Serialization section, §7).
    return bytes([0xA2]) + cbor(-1) + bytes([0x58, 0x10]) + _tid() + _entry(-2, 4000000000)


def nonshortest_float():
    # An application float 1.5 encoded as an 8-byte float64; the canonical
    # form is float16 (0xf9 0x3e00), so a non-shortest float is non-canonical.
    f64 = bytes([0xFB]) + struct.pack(">d", 1.5)
    return bytes([0xA3]) + _entry(-1, _tid()) + _entry(-2, 4000000000) + cbor("score") + f64


def nan_float():
    # An application float that is NaN — the canonical quiet NaN, float16
    # 0xf9 0x7e00. NaN has no single canonical bit pattern across encoders, so
    # obsigil forbids it (the Serialization section, §7); even this
    # form MUST be rejected.
    nan = bytes([0xF9, 0x7E, 0x00])
    return bytes([0xA3]) + _entry(-1, _tid()) + _entry(-2, 4000000000) + cbor("score") + nan


def manifest_dup():
    # A manifest map (0xa2) with a duplicate -5 (iss) key.
    return bytes([0xA2]) + _entry(-5, "auth.example") + _entry(-5, "other")


def disallowed_keytype():
    # map(1) whose sole key is a zero-length byte string (0x40, major type 2):
    # a top-level map-key type obsigil forbids — only non-negative integer and
    # text-string keys are application keys (the Serialization section's
    # key-type rule, §7). Value is 0.
    return bytes([0xA1, 0x40, 0x00])


def invalid_utf8_text():
    # map(1){ app key 0 -> text(1) carrying the byte 0xFF }: a CBOR text string
    # (major type 3) MUST be well-formed UTF-8 (the Serialization
    # section, §7), and 0xFF is not.
    return bytes([0xA1, 0x00, 0x61, 0xFF])


def nested_byte_key():
    # map(3){ 0 -> map(1){ h'00' -> 1 }, tid, exp }: the application value at
    # key 0 is a NESTED map keyed by a 1-byte byte string (0x41 0x00, major
    # type 2). A non-integer/text map key is forbidden at EVERY map depth, not
    # just the half's top-level map (the Serialization section, §7) — Go cannot represent a byte-slice
    # map key — so this token MUST be rejected. The top-level keys (0, -1, -2)
    # and the canonical ordering (0x00 < 0x20 < 0x21) are otherwise valid, so
    # the violation is reachable only by recursing into the application value.
    return (
        bytes([0xA3])
        + _entry(0, {bytes([0x00]): 1})
        + _entry(-1, _tid())
        + _entry(-2, 4000000000)
    )


def tid_in_manifest():
    # A manifest map(2) carrying a mandate-only reserved key: -1/tid (16 bytes)
    # beside -5/iss, canonically ordered (0x20 before 0x24). A manifest that
    # carries tid is malformed, so the reader yields no claims (the
    # Reserved fields section, §8).
    return bytes([0xA2]) + _entry(-1, _tid()) + _entry(-5, "x")


_B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"


def set_b64_trailing(token):
    """Set an unused trailing bit of the final b64 symbol of a `.0<half>`
    token while preserving its significant bits, so a lenient decoder would
    decode the identical ciphertext (and wrongly accept); a strict decoder
    rejects the non-zero trailing bits (the Token structure section's
    strict b64 codec, §4)."""
    half = token[2:]
    unused = {2: 4, 3: 2}[len(half) % 4]  # symbols mod 4 -> unused low bits
    idx = _B64.index(token[-1])
    return token[:-1] + _B64[(idx & ~((1 << unused) - 1)) | 1]


# ----------------------------------------------------------- positive builder
def positive(enc, manifest=None, mandate=None):
    """A positive vector. Octets are canonical CBOR; the assembled token is
    self-checked — a present mandate must verify and a present manifest must
    open, both of which the verifier/opener reject if non-canonical."""
    vec = {"encoding": enc}
    left = right = ""
    if manifest:
        oct_ = octets(manifest["fields"])
        text = seal(oct_, "manifest", manifest["alg"], enc)
        assert open_half(text, "manifest", manifest["alg"], enc) == oct_
        vec["manifest"] = {"alg": manifest["alg"], "octets": oct_, "fields": manifest["fields"]}
        left = text + manifest["alg"]
    if mandate:
        oct_ = octets(mandate["fields"])
        text = seal(oct_, "mandate", mandate["alg"], enc)
        assert open_half(text, "mandate", mandate["alg"], enc) == oct_
        vec["mandate"] = {"alg": mandate["alg"], "octets": oct_, "fields": mandate["fields"]}
        right = mandate["alg"] + text
    vec["token"] = left + sep(enc) + right
    if mandate and {"exp", "tid"} <= mandate["fields"].keys():
        aud = mandate["fields"].get("aud")
        assert verify_ok(vec["token"], aud[0] if aud else None), f"should verify: {vec['token']}"
    if manifest:
        assert open_manifest_ok(vec["token"]), f"manifest should open: {vec['token']}"
    return vec


# ----------------------------------------------------------------- positives
M_ISS = {"iss": "auth.example"}
MAND = {"exp": 4000000000, "tid": TID}

positives = [
    # Minimal full token, b64 and hex, both halves AES-SIV.
    positive("b64", manifest={"alg": "0", "fields": M_ISS}, mandate={"alg": "0", "fields": MAND}),
    positive("hex", manifest={"alg": "0", "fields": M_ISS}, mandate={"alg": "0", "fields": MAND}),
    # Mixed algorithms: AES-SIV manifest, AES-GCM-SIV mandate.
    positive("b64", manifest={"alg": "0", "fields": M_ISS}, mandate={"alg": "1", "fields": MAND}),
    # Both halves AES-GCM-SIV.
    positive("b64", manifest={"alg": "1", "fields": M_ISS}, mandate={"alg": "1", "fields": MAND}),
    # Degenerate shapes: manifest-only and mandate-only.
    positive("b64", manifest={"alg": "0", "fields": M_ISS}),
    positive("b64", mandate={"alg": "0", "fields": MAND}),
    positive("hex", mandate={"alg": "1", "fields": MAND}),
    # Rich mandate: aud (multi), sub, and application data; manifest app data.
    positive(
        "b64",
        manifest={"alg": "0", "fields": {"iss": "auth.example", "theme": "dark"}},
        mandate={"alg": "0", "fields": {"exp": 4000000000, "tid": TID,
                                        "aud": ["api", "billing"], "sub": "u42",
                                        "role": "admin"}},
    ),
    # Manifest advisory exp (the Reserved fields `exp`, §8.3) + mandate
    # iss clause (the Reserved fields `iss`, §8.6).
    positive(
        "b64",
        manifest={"alg": "0", "fields": {"iss": "auth.example", "exp": 4100000000}},
        mandate={"alg": "0", "fields": {"exp": 4000000000, "tid": TID, "iss": "auth.example"}},
    ),
    # Non-ASCII (UTF-8) field values.
    positive(
        "b64",
        manifest={"alg": "0", "fields": {"iss": "issüer.example"}},
        mandate={"alg": "0", "fields": {"exp": 4000000000, "tid": TID, "sub": "ñoño"}},
    ),
    # Maximal diversity: hex, AES-SIV manifest + AES-GCM-SIV mandate with app.
    positive(
        "hex",
        manifest={"alg": "0", "fields": M_ISS},
        mandate={"alg": "1", "fields": {"exp": 4000000000, "tid": TID, "sub": "u42",
                                        "role": "admin"}},
    ),
    # Application float value, shortest-form float16 (RFC 8949 §4.2).
    positive("b64", mandate={"alg": "0", "fields": {"exp": 4000000000, "tid": TID, "score": 1.5}}),
]

# ----------------------------------------------------------------- negatives
negatives = []


def neg(op, token, reason, **policy):
    negatives.append({"op": op, "token": token, **policy, "reason": reason})


valid = mandate_token(octets(MAND))

# structural (parse)
neg("parse", "a.b.c", "more than one separator")
neg("parse", "abcdefgh", "no separator")
neg("parse", ".", "both halves absent (bare separator)")
neg("parse", ".0", "degenerate half: lone algorithm code, empty ciphertext")
neg("parse", "0.", "degenerate half: manifest-side lone algorithm code, empty ciphertext")
neg("parse", "abc0,0def", "single delimiter outside {., ~}: no valid separator")
# An algorithm-code character outside the ALG set (0-9 / a-z, the Token
# structure section, §4): take a
# valid `.0<mandate>` token and replace its code `0` with an uppercase `A`.
neg("parse", "." + "A" + valid[2:], "out-of-range algorithm-code character")
# algorithm / length / text-encoding (verify) — unchanged by the CBOR model
neg("verify", valid[:1] + "2" + valid[2:], "unrecognized algorithm code", key="mandate", now=1000000000)
neg("verify", ".0AAAA", "half below the 17-byte floor", key="mandate", now=1000000000)
neg("verify", valid + "=", "non-canonical b64: padding", key="mandate", now=1000000000)
neg("verify", valid[:-1] + "*", "non-canonical b64: out-of-alphabet character", key="mandate", now=1000000000)
_hex = mandate_token(octets(MAND), enc="hex")
neg("verify", _hex[:2] + _hex[2:].upper(), "non-canonical hex: uppercase", key="mandate", now=1000000000)
neg("verify", _hex[:-1], "non-canonical hex: odd length", key="mandate", now=1000000000)
neg("verify", _hex[:-1] + "g", "non-canonical hex: out-of-alphabet letter (g)", key="mandate", now=1000000000)
neg("verify", ".0" + "A" * 17, "non-canonical b64: half length is 1 modulo 4", key="mandate", now=1000000000)
neg("verify", set_b64_trailing(valid), "non-canonical b64: non-zero unused trailing bits", key="mandate", now=1000000000)
# authentication / key (verify)
neg("verify", valid, "wrong key (authentication fails)", key="07" * 64, now=1000000000)
# reserved-clause policy (verify)
neg("verify", mandate_token(octets({"exp": 1000000000, "tid": TID})), "expired exp", key="mandate", now=2000000000)
neg("verify", mandate_token(octets({"exp": 4000000000, "tid": TID, "aud": ["api"]})),
    "audience mismatch", key="mandate", now=1000000000, audience="other")
neg("verify", mandate_token(octets({"exp": 4000000000, "tid": TID, "aud": []})),
    "empty aud array", key="mandate", now=1000000000)
neg("verify", mandate_token(octets({"exp": 4000000000})), "missing tid", key="mandate", now=1000000000)
neg("verify", mandate_token(octets({"exp": 4000000000, "tid": NIL})),
    "tid is not a UUIDv7 (version 0)", key="mandate", now=1000000000)
neg("verify", mandate_token(octets({"exp": 4000000000, "tid": TID_BADVAR})),
    "tid is version 7 but not the RFC 4122 variant (Reserved fields tid, §8.2)", key="mandate", now=1000000000)
neg("verify", mandate_token(octets({"exp": 4000000000, "tid": V4})),
    "tid is a UUIDv4 (version 4), not a UUIDv7", key="mandate", now=1000000000)
neg("verify", mandate_token(octets({"exp": 4000000000, "tid": V8})),
    "tid is a UUIDv8 (version 8), not a UUIDv7", key="mandate", now=1000000000)
neg("verify", mandate_token(octets({"exp": 4000000000, "tid": V7_MSVAR})),
    "tid is version 7 but the Microsoft variant (0b110), not RFC 4122", key="mandate", now=1000000000)
neg("verify", mandate_token(octets({"tid": TID})), "missing exp", key="mandate", now=1000000000)
neg("verify", manifest_token(octets(M_ISS)), "empty mandate", key="mandate", now=1000000000)
# reserved-clause type strictness (verify): wrong CBOR types (the Reserved fields section, §8)
neg("verify", map_token({RKEY["exp"]: 4000000000, RKEY["tid"]: _tid(), RKEY["aud"]: "api"}),
    "aud is a text string, not an array (Reserved fields aud, §8.4)", key="mandate", now=1000000000)
neg("verify", map_token({RKEY["exp"]: 4000000000, RKEY["tid"]: "not-bytes"}),
    "tid is a text string, not a 16-byte byte string", key="mandate", now=1000000000)
neg("verify", map_token({RKEY["exp"]: 4000000000, RKEY["tid"]: _tid()[:8]}),
    "tid is a byte string shorter than 16 bytes (8)", key="mandate", now=1000000000)
neg("verify", map_token({RKEY["exp"]: 4000000000, RKEY["tid"]: b"\x00" * 32}),
    "tid is a byte string longer than 16 bytes (32)", key="mandate", now=1000000000)
neg("verify", map_token({RKEY["exp"]: 4000000000, RKEY["tid"]: b""}),
    "tid is an empty byte string", key="mandate", now=1000000000)
neg("verify", map_token({RKEY["exp"]: "4000000000", RKEY["tid"]: _tid()}),
    "exp is a text string, not an integer", key="mandate", now=1000000000)
neg("verify", map_token({RKEY["exp"]: 4000000000.0, RKEY["tid"]: _tid()}),
    "exp is a CBOR float, not a NumericDate integer", key="mandate", now=1000000000)
neg("verify", map_token({RKEY["exp"]: 4000000000, RKEY["tid"]: _tid(), RKEY["iss"]: 123}),
    "iss is an integer, not a text string", key="mandate", now=1000000000)
neg("verify", map_token({RKEY["exp"]: 4000000000, RKEY["tid"]: _tid(), RKEY["sub"]: 123}),
    "sub is an integer, not a text string", key="mandate", now=1000000000)
neg("verify", map_token({RKEY["exp"]: 4000000000, RKEY["tid"]: _tid(), RKEY["aud"]: [1]}),
    "aud is an array containing a non-text element", key="mandate", now=1000000000)
# sign-split namespace (the Serialization section, §7): an unrecognized negative key fails closed
neg("verify", map_token({-9: 1, RKEY["exp"]: 4000000000, RKEY["tid"]: _tid()}),
    "unrecognized negative key fails closed", key="mandate", now=1000000000)
# non-canonical CBOR (the Serialization section, §7; the limits-and-robustness rule, §16.10)
neg("verify", raw_token(dup_key()), "duplicate CBOR map key", key="mandate", now=1000000000)
neg("verify", raw_token(unsorted_keys()), "CBOR map keys out of canonical order", key="mandate", now=1000000000)
neg("verify", raw_token(nonshortest_int()), "non-shortest CBOR integer", key="mandate", now=1000000000)
neg("verify", raw_token(indefinite_map()), "indefinite-length CBOR map", key="mandate", now=1000000000)
neg("verify", raw_token(trailing_bytes()), "trailing bytes after the CBOR map", key="mandate", now=1000000000)
neg("verify", raw_token(nonshortest_len()), "non-shortest CBOR length header", key="mandate", now=1000000000)
neg("verify", raw_token(nonshortest_float()), "non-shortest CBOR float (f64 for an f16-representable value)", key="mandate", now=1000000000)
neg("verify", raw_token(nan_float()), "NaN application float (forbidden: no canonical bit pattern)", key="mandate", now=1000000000)
# disallowed map-key type (top-level AND nested) and invalid UTF-8 text (the Serialization section, §7)
neg("verify", raw_token(disallowed_keytype()), "disallowed CBOR map-key type (byte string)", key="mandate", now=1000000000)
neg("verify", raw_token(nested_byte_key()), "disallowed CBOR map-key type (nested byte string)", key="mandate", now=1000000000)
neg("verify", raw_token(invalid_utf8_text()), "text string is not valid UTF-8", key="mandate", now=1000000000)
# manifest (open-manifest)
neg("open-manifest", manifest_token(octets({"role": "x"})), "manifest missing required iss")
neg("open-manifest", manifest_token(octets(M_ISS), key="mandate"),
    "manifest sealed under the wrong key (authentication fails)")
_mani = manifest_token(octets(M_ISS))  # "<text>0."
neg("open-manifest", _mani[:-2] + "=0.", "non-canonical b64: padding (manifest)")
neg("open-manifest", manifest_token(manifest_dup().hex()), "non-canonical CBOR in manifest (duplicate map key)")
# a reserved key in the wrong half: a manifest carrying tid is malformed, so
# the keyless read yields no claims (the Reserved fields section, §8)
neg("open-manifest", manifest_token(tid_in_manifest().hex()), "reserved key in wrong half (tid in manifest)")
# excessive clock-skew leeway must not extend exp (the limits-and-robustness rule, §16.10)
neg("verify", mandate_token(octets({"exp": 1000, "tid": TID})),
    "excessive leeway must not extend exp (Limits and robustness, §16.10)",
    key="mandate", now=2000000000, leeway=9999999999)


# --------------------------------------------------------------- self-check
# The vector `op` keywords are the logical operations; the CLI renamed its
# subcommands in v1.0 (verify -> clauses, open-manifest -> claims; parse is
# unchanged), so map each op to its subcommand for the self-check invocation.
OP_CMD = {"verify": "clauses", "open-manifest": "claims", "parse": "parse"}


def op_rejects(row):
    op, token = row["op"], row["token"]
    args = [OP_CMD[op], "-"]
    if op == "verify":
        args += ["-k", row.get("key", "mandate")]
        if "now" in row:
            args += ["--now", str(row["now"])]
        if "audience" in row:
            args += ["-a", row["audience"]]
        if "leeway" in row:
            args += ["--leeway", str(row["leeway"])]
    return run(args, check=False, stdin=token).returncode == 1


for row in negatives:
    assert op_rejects(row), f"negative should be rejected: {row}"


def write_jsonl(name, rows):
    path = os.path.join(ROOT, name)
    with open(path, "w", encoding="utf-8") as f:
        for r in rows:
            f.write(json.dumps(r, ensure_ascii=False) + "\n")
    return path


p1 = write_jsonl("test-vectors.jsonl", positives)
p2 = write_jsonl("negative-test-vectors.jsonl", negatives)
print(f"wrote {len(positives)} positive vectors -> {os.path.basename(p1)}")
print(f"wrote {len(negatives)} negative vectors -> {os.path.basename(p2)}")
print("all self-checks passed (positives reproduce + verify/open; negatives reject)")