tally
shamir's secret sharing over gf(256).
git clone https://owenrusk.dev/tally.git
commit 89f7c82083049e6f0418cce76e666e2d4b393c88 parent f34ae17f113f029d4c1057b0d7786e3a3d25b797 author Owen Rusk <owen@papermothgames.com> date 2026-10-08 17:34:07 -0500
split: -o DIR writes each share to its own file mode 600, and it won't overwrite one that's already there.
| README.md | +3 | -0 |
| tally/cli.py | +23 | -2 |
| tests/test_cli.py | +22 | -0 |
diff --git a/README.md b/README.md index 47ceda5..e8e69e6 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,12 @@ or run it in place with `python -m tally`. tally split -k 3 -n 5 secret.txt tally split -k 2 -n 3 < secret.txt + tally split -k 3 -n 5 -o shares/ secret.txt reads the secret from the file, or from stdin if there's no file, and prints n shares, one per line. 2 <= k <= n <= 255. +with `-o DIR` each share goes to its own file instead, `DIR/share-1.txt` and on, mode 600. it won't overwrite a share that's already there. + the secret is never an argument. arguments end up in shell history and in `ps`. ## join diff --git a/tally/cli.py b/tally/cli.py index 9b77cd6..f506ec4 100644 --- a/tally/cli.py +++ b/tally/cli.py @@ -1,5 +1,7 @@ import argparse +import os import sys +from pathlib import Path from . import join, split @@ -11,11 +13,29 @@ def cmd_split(args: argparse.Namespace) -> int: else: with open(args.file, "rb") as f: secret = f.read()- for line in split(secret, args.k, args.n):- print(line)+ lines = split(secret, args.k, args.n) + if args.out is None: + for line in lines: + print(line) + else: + _write_shares(Path(args.out), lines) return 0 +def _write_shares(out: Path, lines: list[str]) -> None: + paths = [out / f"share-{x}.txt" for x in range(1, len(lines) + 1)] + taken = [p for p in paths if p.exists()] + if taken: + raise ValueError(f"{taken[0]} already exists, not overwriting it") + out.mkdir(mode=0o700, parents=True, exist_ok=True) + for path, line in zip(paths, lines): + # 600, and O_EXCL in case something appeared since the check + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as f: + f.write(line + "\n") + print(f"tally: wrote {len(paths)} shares to {out}", file=sys.stderr) + + def _stdin_shares() -> list[str]: # blank lines and # comments are skipped, so a file of shares can carry notes lines = (line.strip() for line in sys.stdin) @@ -34,6 +54,7 @@ def main(argv: list[str] | None = None) -> int: p = sub.add_parser("split", help="split a secret into n shares") p.add_argument("-k", type=int, required=True, help="shares needed to rebuild it") p.add_argument("-n", type=int, required=True, help="shares to make") + p.add_argument("-o", "--out", metavar="DIR", help="write each share to DIR/share-<x>.txt instead of stdout") p.add_argument("file", nargs="?", help="file to read the secret from (default: stdin)") p.set_defaults(func=cmd_split) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8f2e3f9..d3076ae 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -65,6 +65,28 @@ class CliTest(unittest.TestCase): self.assertEqual(join.returncode, 1) self.assertIn("different splits", join.stderr.decode()) + def test_out_dir(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp, "shares") + split = tally("split", "-k", "2", "-n", "3", "-o", str(out), stdin=b"not a real secret") + self.assertEqual(split.returncode, 0, split.stderr) + self.assertEqual(split.stdout, b"") + files = sorted(out.iterdir()) + self.assertEqual([f.name for f in files], ["share-1.txt", "share-2.txt", "share-3.txt"]) + for f in files: + self.assertEqual(f.stat().st_mode & 0o777, 0o600) + join = tally("join", stdin=files[0].read_bytes() + files[2].read_bytes()) + self.assertEqual(join.stdout, b"not a real secret") + + def test_out_dir_wont_overwrite(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + Path(tmp, "share-2.txt").write_text("keep me\n") + split = tally("split", "-k", "2", "-n", "3", "-o", tmp, stdin=b"not a real secret") + self.assertEqual(split.returncode, 1) + self.assertIn(b"not overwriting", split.stderr) + self.assertEqual(os.listdir(tmp), ["share-2.txt"]) + self.assertEqual(Path(tmp, "share-2.txt").read_text(), "keep me\n") + def test_plain_errors(self) -> None: for args in [("split", "-k", "4", "-n", "3"), ("split", "-k", "2", "-n", "3", "/nonexistent"), ("join", "hello")]: result = tally(*args, stdin=b"x")