owenrusk.dev

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")