modularot 0.1.0__py3-none-any.whl

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.
modularot/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """
2
+ modularot -- modular KEM-based 1-out-of-n Oblivious Transfer (semi-honest).
3
+
4
+ The protocol is KEM-agnostic; the only KEM-specific operation (sampling an
5
+ oblivious public key) is injected as a callable. See `ot_protocol` for the
6
+ construction and `oblivious_sampling` for the samplers.
7
+
8
+ Quick start (any liboqs lattice KEM)::
9
+
10
+ from modularot import run_ot, oqs_kem_factory, resolve_oqs
11
+
12
+ result = run_ot(
13
+ make_kem=oqs_kem_factory("ML-KEM-768"),
14
+ sampler=resolve_oqs("ML-KEM-768"),
15
+ label="ML-KEM-768",
16
+ choice=1, # branch the receiver wants
17
+ n=4, # 1-out-of-4
18
+ )
19
+ assert result["ok"] and result["secure"]
20
+
21
+ `OTSender`, `OTReceiver` and `KEMAdapter` are exported for custom KEMs; see
22
+ `modularot.custom_kems` (NTRU_FO, GGH_FO) for examples.
23
+ """
24
+
25
+ from .ot_protocol import (
26
+ OTReceiver,
27
+ OTSender,
28
+ run_ot,
29
+ receiver_cannot_read_other,
30
+ )
31
+ from .ot_protocol import _oqs_kem_factory as oqs_kem_factory
32
+ from .kem_adapter.adapter import KEM, KEMAdapter, KEMInfo
33
+ from .oblivious_sampling import resolve_oqs, is_supported_oqs
34
+
35
+ __version__ = "0.1.0"
36
+
37
+ __all__ = [
38
+ "OTReceiver",
39
+ "OTSender",
40
+ "run_ot",
41
+ "receiver_cannot_read_other",
42
+ "oqs_kem_factory",
43
+ "KEM",
44
+ "KEMAdapter",
45
+ "KEMInfo",
46
+ "resolve_oqs",
47
+ "is_supported_oqs",
48
+ "__version__",
49
+ ]
modularot/cli.py ADDED
@@ -0,0 +1,381 @@
1
+ """
2
+ Command-line interface for modularot: the modular KEM-based 1-out-of-n OT.
3
+
4
+ Subcommands:
5
+ run -- run one 1-out-of-n OT transfer (KEM, n, choice, messages).
6
+ bench -- per KEM and per n: request/transfer/finalize timing, bandwidth
7
+ and KeyGen-vs-oblivious-sample cost. Console tables + CSV.
8
+ list-kems -- list the names usable with `run --kem` / `bench --kems`.
9
+
10
+ Usage (`modularot` entry point or `python -m modularot.cli`):
11
+ modularot run # ML-KEM-768, 1-out-of-2, placeholder messages
12
+ modularot run --kem ML-KEM-768 --n 4 --choice 2
13
+ modularot run --n 3 --message "a" --message "b" --message "c" --choice 1
14
+ modularot run --kem GGH_FO --ggh-dim 200 --quiet --output secret.bin
15
+ modularot list-kems
16
+ modularot bench # default KEM subset + NTRU_FO + GGH_FO
17
+ modularot bench --all # every supported liboqs mechanism, + custom
18
+ modularot bench --kems ML-KEM-768 sntrup761 --no-custom
19
+ modularot bench --ns 2 4 8 16 32 --reps 5 --csv out.csv
20
+ """
21
+
22
+ import argparse
23
+ import csv
24
+ import statistics
25
+ import sys
26
+ import time
27
+
28
+ import oqs
29
+
30
+ from .ot_protocol import run_ot, _oqs_kem_factory, _fmt_ns
31
+ from .oblivious_sampling import resolve_oqs, is_supported_oqs, sample_oblivious_ntru_fo
32
+ from .kem_adapter.adapter import KEMAdapter
33
+
34
+ # One per (family, security level): short run covering every encoding family.
35
+ DEFAULT_KEMS = [
36
+ "ML-KEM-512", "ML-KEM-768", "ML-KEM-1024", # structured, q=3329
37
+ "FrodoKEM-640-AES", "FrodoKEM-976-AES", "FrodoKEM-1344-AES", # unstructured LWE
38
+ "NTRU-HPS-2048-509", "NTRU-HRSS-701", # NTRU, bit-packed
39
+ "sntrup761", # NTRU Prime, mixed-radix
40
+ ]
41
+
42
+ DEFAULT_NS = [2, 4, 8, 16, 32]
43
+
44
+ CUSTOM_KEM_NAMES = {"NTRU_FO", "GGH_FO"}
45
+
46
+
47
+ def _ntrufo_entry(N: int, q: int = 8192, p: int = 3):
48
+ """(label, kem_factory, sampler) for NTRU_FO; each factory call builds an
49
+ independent instance."""
50
+ from .custom_kems.ntru_fo.ntru_fo import NTRU_FO
51
+
52
+ def factory():
53
+ impl = NTRU_FO(N=N, p=p, q=q)
54
+ return KEMAdapter(gen=impl.generate_keys, encap=impl.encapsulate,
55
+ decap=impl.decapsulate)
56
+
57
+ return (f"NTRU_FO-{N}", factory, lambda pk: sample_oblivious_ntru_fo(pk, q=q))
58
+
59
+
60
+ def _gghfo_entry(dimension: int):
61
+ """(label, kem_factory, sampler) for GGH-HNF FO (GGH_private=True). Needs
62
+ `pip install modularot[custom-kems]`; dimension=200 is teaching-scale.
63
+ Classic GGH has no sound oblivious sampler."""
64
+ from .custom_kems.ggh_fo import ggh_fo
65
+ from .oblivious_sampling import sample_oblivious_ggh_hnf
66
+
67
+ def factory():
68
+ return KEMAdapter(
69
+ gen=lambda: ggh_fo.gen(dimension=dimension),
70
+ encap=ggh_fo.encaps,
71
+ decap=ggh_fo.decaps,
72
+ )
73
+
74
+ return (f"GGH_FO-{dimension}", factory, sample_oblivious_ggh_hnf)
75
+
76
+
77
+ def _build_entry(kem: str, ntrufo_n: int, ggh_dim: int):
78
+ """(label, kem_factory, sampler) for one KEM name -- custom or liboqs."""
79
+ if kem == "NTRU_FO":
80
+ try:
81
+ return _ntrufo_entry(ntrufo_n)
82
+ except Exception as e:
83
+ raise SystemExit(f"cannot build NTRU_FO: {e}")
84
+ if kem == "GGH_FO":
85
+ try:
86
+ return _gghfo_entry(ggh_dim)
87
+ except Exception as e:
88
+ raise SystemExit(
89
+ f"cannot build GGH_FO (needs the GGH_crypto package): {e}")
90
+
91
+ enabled = set(oqs.get_enabled_kem_mechanisms())
92
+ if kem not in enabled:
93
+ raise SystemExit(f"KEM '{kem}' not enabled in this liboqs build "
94
+ f"(see `modularot list-kems`)")
95
+ if not is_supported_oqs(kem):
96
+ raise SystemExit(f"KEM '{kem}' has no oblivious sampler, refused "
97
+ f"rather than weaken security")
98
+ return (kem, _oqs_kem_factory(kem), resolve_oqs(kem))
99
+
100
+
101
+ def _median(xs):
102
+ return int(statistics.median(xs))
103
+
104
+
105
+ def warmup(kem_factory) -> None:
106
+ """Absorb lazy-init overhead outside the timed region."""
107
+ kem = kem_factory()
108
+ pk, sk = kem.Gen()
109
+ ss, ct = kem.encaps(pk)
110
+ kem.decaps(sk, ct)
111
+
112
+
113
+ def bench_sampler_vs_keygen(kem_factory, sampler, reps: int) -> dict:
114
+ """Median cost of one real KeyGen vs one oblivious sample."""
115
+ keygen_ns, sample_ns = [], []
116
+ for _ in range(reps):
117
+ kem = kem_factory()
118
+ t0 = time.perf_counter_ns()
119
+ pk, _sk = kem.Gen()
120
+ keygen_ns.append(time.perf_counter_ns() - t0)
121
+
122
+ t0 = time.perf_counter_ns()
123
+ sampler(pk)
124
+ sample_ns.append(time.perf_counter_ns() - t0)
125
+ return {"keygen": _median(keygen_ns), "sample": _median(sample_ns)}
126
+
127
+
128
+ def bench_cell(label: str, kem_factory, sampler, n: int, reps: int) -> dict:
129
+ """Median timings over `reps` runs of a full 1-out-of-n OT session."""
130
+ runs = []
131
+ for r in range(reps):
132
+ # security check on the first rep only
133
+ runs.append(run_ot(kem_factory, sampler, label,
134
+ choice=0, n=n, check_security=(r == 0),
135
+ verbose=False))
136
+ first = runs[0]
137
+ return {
138
+ "KEM": label, "n": n,
139
+ "request": _median([r["request"] for r in runs]),
140
+ "transfer": _median([r["transfer"] for r in runs]),
141
+ "finalize": _median([r["finalize"] for r in runs]),
142
+ "TOTAL": _median([r["TOTAL"] for r in runs]),
143
+ "pk_bw": first["pk_bw"], "ct_bw": first["ct_bw"],
144
+ "ok": all(r["ok"] for r in runs), "secure": first["secure"],
145
+ }
146
+
147
+
148
+ # ── `run`: use the library from the command line ────────────────────────────
149
+
150
+ def cmd_run(args) -> None:
151
+ label, factory, sampler = _build_entry(args.kem, args.ntrufo_n, args.ggh_dim)
152
+
153
+ n = args.n
154
+ if not 0 <= args.choice < n:
155
+ raise SystemExit(f"--choice must be in [0, {n - 1}]")
156
+
157
+ if args.message and args.message_file:
158
+ raise SystemExit("use either --message or --message-file, not both")
159
+ if args.message:
160
+ if len(args.message) != n:
161
+ raise SystemExit(
162
+ f"need exactly {n} --message values (one per branch, in "
163
+ f"order), got {len(args.message)}")
164
+ messages = [m.encode() for m in args.message]
165
+ elif args.message_file:
166
+ if len(args.message_file) != n:
167
+ raise SystemExit(
168
+ f"need exactly {n} --message-file values (one per branch, "
169
+ f"in order), got {len(args.message_file)}")
170
+ messages = [open(p, "rb").read() for p in args.message_file]
171
+ else:
172
+ messages = None # run_ot falls back to placeholder messages
173
+
174
+ result = run_ot(factory, sampler, label, choice=args.choice, n=n,
175
+ messages=messages, check_security=not args.no_security_check,
176
+ verbose=not args.quiet)
177
+
178
+ if args.quiet:
179
+ sys.stdout.buffer.write(result["message"] or b"")
180
+
181
+ if args.output:
182
+ with open(args.output, "wb") as f:
183
+ f.write(result["message"] or b"")
184
+ if not args.quiet:
185
+ print(f"Decrypted message written to {args.output}")
186
+
187
+ if not result["ok"]:
188
+ raise SystemExit(1)
189
+
190
+
191
+ # ── `list-kems` ───────────────────────────────────────────────────────────
192
+
193
+ def cmd_list_kems(args) -> None:
194
+ enabled = sorted(a for a in oqs.get_enabled_kem_mechanisms() if is_supported_oqs(a))
195
+ print(f"liboqs KEMs with an oblivious sampler ({len(enabled)}, usable with --kem/--kems):")
196
+ for k in enabled:
197
+ print(f" {k}")
198
+ print("\nCustom KEMs (need `pip install modularot[custom-kems]`):")
199
+ print(" NTRU_FO ring degree via --ntrufo-n (default 701)")
200
+ print(" GGH_FO dimension via --ggh-dim (default 200)")
201
+
202
+
203
+ # ── `bench`: scalability sweep ───────────────────────────────────────────────
204
+
205
+ def cmd_bench(args) -> None:
206
+ enabled = set(oqs.get_enabled_kem_mechanisms())
207
+ if args.all:
208
+ kems = sorted(a for a in enabled if is_supported_oqs(a))
209
+ else:
210
+ kems = args.kems or DEFAULT_KEMS
211
+ missing = [a for a in kems if a not in enabled]
212
+ unsupported = [a for a in kems if a in enabled and not is_supported_oqs(a)]
213
+ if missing:
214
+ print(f"skipping (not enabled in this liboqs build): {', '.join(missing)}")
215
+ if unsupported:
216
+ print(f"skipping (no oblivious sampler): {', '.join(unsupported)}")
217
+ kems = [a for a in kems if a in enabled and is_supported_oqs(a)]
218
+
219
+ # (label, kem_factory, sampler) — oqs mechanisms plus the custom KEMs
220
+ entries = [(alg, _oqs_kem_factory(alg), resolve_oqs(alg)) for alg in kems]
221
+
222
+ if not args.no_custom:
223
+ try:
224
+ entries.append(_ntrufo_entry(args.ntrufo_n))
225
+ except Exception as e:
226
+ print(f"skipping NTRU_FO: {e}")
227
+ try:
228
+ entries.append(_gghfo_entry(args.ggh_dim))
229
+ except Exception as e:
230
+ print(f"skipping GGH_FO (needs the GGH_crypto package): {e}")
231
+
232
+ if not entries:
233
+ raise SystemExit("no benchmarkable KEMs selected")
234
+
235
+ print(f"KEMs: {', '.join(label for label, _, _ in entries)}")
236
+ print(f"n: {args.ns} reps/cell: {args.reps}\n")
237
+
238
+ # ── table 1: real KeyGen vs oblivious sample ──────────────────────────────
239
+ print("═" * 78)
240
+ print("KeyGen vs oblivious sample (median, one call)")
241
+ print("═" * 78)
242
+ print(f"{'KEM':<22} {'KeyGen':>14} {'ObliviousSample':>16} {'ratio':>9}")
243
+ print("─" * 78)
244
+ ratios = {}
245
+ for label, factory, sampler in entries:
246
+ warmup(factory)
247
+ r = bench_sampler_vs_keygen(factory, sampler, args.reps)
248
+ ratio = r["keygen"] / max(r["sample"], 1)
249
+ ratios[label] = r
250
+ print(f"{label:<22} {_fmt_ns(r['keygen']):>14} {_fmt_ns(r['sample']):>16} "
251
+ f"{ratio:>8.1f}x")
252
+ print("─" * 78)
253
+
254
+ # ── table 2: full-session scaling over n ─────────────────────────────────
255
+ rows = []
256
+ for label, factory, sampler in entries:
257
+ print(f"\n{'═' * 98}")
258
+ print(f"{label} — 1-out-of-n scaling (medians of {args.reps} runs)")
259
+ print(f"{'═' * 98}")
260
+ print(f"{'n':>4} {'Request':>12} {'Transfer':>12} {'Finalize':>12} "
261
+ f"{'TOTAL':>12} {'BW R->S':>12} {'BW S->R':>12} OK SEC")
262
+ print("─" * 98)
263
+ for n in args.ns:
264
+ cell = bench_cell(label, factory, sampler, n, args.reps)
265
+ rows.append(cell)
266
+ print(f"{n:>4} {_fmt_ns(cell['request']):>12} {_fmt_ns(cell['transfer']):>12} "
267
+ f"{_fmt_ns(cell['finalize']):>12} {_fmt_ns(cell['TOTAL']):>12} "
268
+ f"{cell['pk_bw']:>10} B {cell['ct_bw']:>10} B "
269
+ f"{'✓' if cell['ok'] else '✗'} {'✓' if cell['secure'] else '✗'}")
270
+ print("─" * 98)
271
+
272
+ # ── CSV for plots ─────────────────────────────────────────────────────────
273
+ # units in the header, fixed-precision milliseconds
274
+ fields = ["kem", "n",
275
+ "request_ms", "transfer_ms", "finalize_ms", "total_ms",
276
+ "pk_bytes", "ct_bytes", "total_bytes",
277
+ "keygen_ms", "obliv_sample_ms", "keygen_over_sample",
278
+ "ok", "secure"]
279
+
280
+ def _ms(ns: int) -> float:
281
+ return round(ns / 1e6, 4)
282
+
283
+ with open(args.csv, "w", newline="") as f:
284
+ w = csv.DictWriter(f, fieldnames=fields)
285
+ w.writeheader()
286
+ for row in rows:
287
+ r = ratios[row["KEM"]]
288
+ w.writerow({
289
+ "kem": row["KEM"], "n": row["n"],
290
+ "request_ms": _ms(row["request"]),
291
+ "transfer_ms": _ms(row["transfer"]),
292
+ "finalize_ms": _ms(row["finalize"]),
293
+ "total_ms": _ms(row["TOTAL"]),
294
+ "pk_bytes": row["pk_bw"],
295
+ "ct_bytes": row["ct_bw"],
296
+ "total_bytes": row["pk_bw"] + row["ct_bw"],
297
+ "keygen_ms": _ms(r["keygen"]),
298
+ "obliv_sample_ms": _ms(r["sample"]),
299
+ "keygen_over_sample": round(r["keygen"] / max(r["sample"], 1), 2),
300
+ "ok": row["ok"], "secure": row["secure"],
301
+ })
302
+ print(f"\nCSV written to {args.csv} ({len(rows)} rows)")
303
+
304
+
305
+ # ── argument parsing / dispatch ──────────────────────────────────────────────
306
+
307
+ def _add_run_args(p: argparse.ArgumentParser) -> None:
308
+ p.add_argument("--kem", default="ML-KEM-768",
309
+ help="KEM mechanism name, liboqs or custom (NTRU_FO, "
310
+ "GGH_FO); see `modularot list-kems` (default: ML-KEM-768)")
311
+ p.add_argument("--n", type=int, default=2,
312
+ help="number of branches, 1-out-of-n (default: 2)")
313
+ p.add_argument("--choice", type=int, default=0,
314
+ help="branch the receiver wants to learn (default: 0)")
315
+ p.add_argument("--message", action="append", metavar="MSG",
316
+ help="sender's message for one branch (utf-8 text); pass "
317
+ "once per branch, in branch order (default: "
318
+ "placeholder demo messages)")
319
+ p.add_argument("--message-file", action="append", metavar="PATH",
320
+ help="like --message but read raw bytes from a file; "
321
+ "pass once per branch, in branch order")
322
+ p.add_argument("--output", metavar="PATH",
323
+ help="write the receiver's decrypted message to this "
324
+ "file (default: printed to stdout)")
325
+ p.add_argument("--ggh-dim", type=int, metavar="DIM", default=200,
326
+ help="dimension for --kem GGH_FO (default: 200)")
327
+ p.add_argument("--ntrufo-n", type=int, metavar="N", default=701,
328
+ help="ring degree for --kem NTRU_FO (default: 701)")
329
+ p.add_argument("--no-security-check", action="store_true",
330
+ help="skip the adversarial curious-receiver check")
331
+ p.add_argument("--quiet", action="store_true",
332
+ help="print nothing but the raw decrypted message bytes")
333
+
334
+
335
+ def _add_bench_args(p: argparse.ArgumentParser) -> None:
336
+ p.add_argument("--kems", nargs="+", default=None,
337
+ help="mechanisms to benchmark (default: representative subset)")
338
+ p.add_argument("--all", action="store_true",
339
+ help="benchmark every supported enabled mechanism")
340
+ p.add_argument("--ns", nargs="+", type=int, default=DEFAULT_NS,
341
+ help=f"branch counts (default: {DEFAULT_NS})")
342
+ p.add_argument("--reps", type=int, default=5,
343
+ help="repetitions per cell, median reported (default: 5)")
344
+ p.add_argument("--no-custom", action="store_true",
345
+ help="skip the custom NTRU_FO and GGH_FO KEMs (on by default)")
346
+ p.add_argument("--ggh-dim", type=int, metavar="DIM", default=200,
347
+ help="dimension for the custom GGH_FO KEM (default: 200; "
348
+ "teaching-scale, needs the GGH_crypto package)")
349
+ p.add_argument("--ntrufo-n", type=int, metavar="N", default=701,
350
+ help="ring degree for the custom NTRU_FO KEM (default: 701)")
351
+ p.add_argument("--csv", default="ot_scaling.csv",
352
+ help="CSV output path (default: ot_scaling.csv)")
353
+
354
+
355
+ def build_parser() -> argparse.ArgumentParser:
356
+ ap = argparse.ArgumentParser(
357
+ prog="modularot",
358
+ description="Modular KEM-based 1-out-of-n Oblivious Transfer (semi-honest)")
359
+ sub = ap.add_subparsers(dest="command", required=True)
360
+
361
+ rp = sub.add_parser("run", help="run one 1-out-of-n OT transfer")
362
+ _add_run_args(rp)
363
+ rp.set_defaults(func=cmd_run)
364
+
365
+ bp = sub.add_parser("bench", help="scaling benchmark across KEMs and branch counts")
366
+ _add_bench_args(bp)
367
+ bp.set_defaults(func=cmd_bench)
368
+
369
+ lp = sub.add_parser("list-kems", help="list KEM names usable with run/bench")
370
+ lp.set_defaults(func=cmd_list_kems)
371
+
372
+ return ap
373
+
374
+
375
+ def main() -> None:
376
+ args = build_parser().parse_args()
377
+ args.func(args)
378
+
379
+
380
+ if __name__ == "__main__":
381
+ main()
File without changes
File without changes