tai.py (8643 lines) is now a thin facade over tai_pkg/ (33 feature modules, one class or feature per file). tai-orig.py preserves the pre-split single-file version byte for byte (sha256 verified against snapshot); import tai and ./tai.py behavior unchanged. Equivalence proof, all run on this tree immediately pre-commit: differential battery tai-orig.py vs facade 3718 checks 0 failures (surface, pure fuzz, tool dispatch, store/audit, release ops, fake-HOME install roundtrip, CLI runs); test_tai.py 261 OK; test_seal.py 16 OK; --version/--help byte-identical; dir(tai) zero removals. Fix included: opencode wire-protocol schema echo leaked into replies; opencode_parse now scans with JSONDecoder.raw_decode capped at 200 scans (13 OpencodeParseTests). Test count 261 = 237 HEAD + 8 pre-existing uncommitted ToolArgumentsTests (now committed with this change, disclosed) + 13 OpencodeParseTests + 3 EmbeddedStringsTests. Patch-target-only test migration (24 hunks), zero assertion changes. tai_pkg clean-regenerated after mid-verification tamper was found and reverted.
174 lines
7.4 KiB
Python
174 lines
7.4 KiB
Python
"""Vault encryption seal and passphrase management."""
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import struct
|
|
|
|
from tai_pkg.consts import Consts
|
|
|
|
__all__ = ["SealError", "Seal", "SealManager"]
|
|
|
|
|
|
class SealError(Exception):
|
|
pass
|
|
|
|
|
|
class Seal:
|
|
PREFIX = "tai1$"
|
|
ROUNDS = 200000
|
|
|
|
def __init__(self, home, passphrase):
|
|
self.enabled = bool(passphrase)
|
|
self.default_key = False
|
|
self.master = b""
|
|
self.enc_key = b""
|
|
self.mac_key = b""
|
|
if self.enabled:
|
|
self.master = self.load_master(home, passphrase)
|
|
self.enc_key = hmac.new(self.master, b"tai-enc-1", hashlib.sha256).digest()
|
|
self.mac_key = hmac.new(self.master, b"tai-mac-1", hashlib.sha256).digest()
|
|
|
|
def load_master(self, home, passphrase):
|
|
path = os.path.join(home, ".seal")
|
|
raw = ""
|
|
if os.path.exists(path):
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
raw = handle.read()
|
|
saved = {}
|
|
if raw:
|
|
try:
|
|
saved = json.loads(raw)
|
|
salt = base64.b64decode(saved["salt"])
|
|
except (ValueError, KeyError):
|
|
raise SealError("seal file is corrupt")
|
|
else:
|
|
salt = os.urandom(16)
|
|
master = hashlib.pbkdf2_hmac("sha256", passphrase.encode("utf-8"), salt, self.ROUNDS, 32)
|
|
check = hmac.new(master, b"tai-check-1", hashlib.sha256).hexdigest()
|
|
if raw:
|
|
if not hmac.compare_digest(saved.get("check", ""), check):
|
|
raise SealError("wrong TAI_PASSPHRASE")
|
|
return master
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|
handle.write(json.dumps({"salt": base64.b64encode(salt).decode("ascii"), "check": check}))
|
|
try:
|
|
os.chmod(path, 0o600)
|
|
except OSError:
|
|
pass
|
|
return master
|
|
|
|
def stream(self, nonce, length):
|
|
out = bytearray()
|
|
counter = 0
|
|
while len(out) < length:
|
|
out += hmac.new(self.enc_key, nonce + struct.pack(">I", counter), hashlib.sha256).digest()
|
|
counter += 1
|
|
return bytes(out[:length])
|
|
|
|
def lock(self, text):
|
|
if not self.enabled or text.startswith(self.PREFIX):
|
|
return text
|
|
nonce = os.urandom(16)
|
|
body = text.encode("utf-8")
|
|
keystream = self.stream(nonce, len(body))
|
|
cipher = bytes(piece ^ keystream[pos] for pos, piece in enumerate(body))
|
|
mac = hmac.new(self.mac_key, nonce + cipher, hashlib.sha256).digest()
|
|
parts = [base64.b64encode(blob).decode("ascii") for blob in (nonce, cipher, mac)]
|
|
return self.PREFIX + "$".join(parts)
|
|
|
|
def unlock(self, text):
|
|
if not text.startswith(self.PREFIX):
|
|
raise SealError("not sealed")
|
|
try:
|
|
_tag, nonce64, cipher64, mac64 = text.split("$", 3)
|
|
nonce = base64.b64decode(nonce64)
|
|
cipher = base64.b64decode(cipher64)
|
|
mac = base64.b64decode(mac64)
|
|
except ValueError:
|
|
raise SealError("sealed value is corrupt")
|
|
expect = hmac.new(self.mac_key, nonce + cipher, hashlib.sha256).digest()
|
|
if not hmac.compare_digest(mac, expect):
|
|
raise SealError("seal check failed")
|
|
keystream = self.stream(nonce, len(cipher))
|
|
plain = bytes(piece ^ keystream[pos] for pos, piece in enumerate(cipher))
|
|
return plain.decode("utf-8")
|
|
|
|
def safe_unlock(self, text):
|
|
try:
|
|
return self.unlock(text)
|
|
except SealError:
|
|
return "[sealed: decrypt failed]"
|
|
|
|
|
|
class SealManager:
|
|
"""SealManager."""
|
|
|
|
@staticmethod
|
|
def resolve_passphrase():
|
|
raw = os.environ.get("TAI_PASSPHRASE")
|
|
if raw is None:
|
|
return Consts.DEFAULT_PASSPHRASE, True
|
|
return raw, False
|
|
|
|
@staticmethod
|
|
def rotate_seal(config, old_seal, new_passphrase):
|
|
db = sqlite3.connect(config.db_path)
|
|
rows = db.execute("SELECT id, text FROM events").fetchall()
|
|
tables = [row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall()]
|
|
secret_cols = [row[1] for row in db.execute("PRAGMA table_info(secrets)").fetchall()] if "secrets" in tables else []
|
|
has_profile = "profile" in secret_cols
|
|
secret_rows = db.execute("SELECT profile, name, value FROM secrets").fetchall() if has_profile else [(None, name, value) for name, value in db.execute("SELECT name, value FROM secrets").fetchall()] if "secrets" in tables else []
|
|
meta_rows = db.execute("SELECT profile, name, meta FROM secrets WHERE meta IS NOT NULL").fetchall() if has_profile and "meta" in secret_cols else [(None, name, meta) for name, meta in db.execute("SELECT name, meta FROM secrets WHERE meta IS NOT NULL").fetchall()] if "meta" in secret_cols else []
|
|
planned_rows = db.execute("SELECT id, prompt FROM schedules").fetchall() if "schedules" in tables else []
|
|
db.close()
|
|
texts = {}
|
|
for row_id, text in rows:
|
|
texts[row_id] = old_seal.unlock(text) if text.startswith(Seal.PREFIX) else text
|
|
secret_texts = {}
|
|
for profile, name, value in secret_rows:
|
|
secret_texts[(profile, name)] = old_seal.unlock(value) if value.startswith(Seal.PREFIX) else value
|
|
secret_metas = {}
|
|
for profile, name, meta in meta_rows:
|
|
secret_metas[(profile, name)] = old_seal.unlock(meta) if meta.startswith(Seal.PREFIX) else meta
|
|
planned_texts = {}
|
|
for row_id, prompt in planned_rows:
|
|
planned_texts[row_id] = old_seal.unlock(prompt) if prompt.startswith(Seal.PREFIX) else prompt
|
|
files = {}
|
|
for entry in os.listdir(config.profiles_dir):
|
|
if not entry.endswith((".sys.md", ".session.json", ".bots.json")):
|
|
continue
|
|
path = os.path.join(config.profiles_dir, entry)
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
raw = handle.read()
|
|
files[path] = old_seal.unlock(raw) if raw.startswith(Seal.PREFIX) else raw
|
|
try:
|
|
os.remove(os.path.join(config.home, ".seal"))
|
|
except OSError:
|
|
pass
|
|
fresh = Seal(config.home, new_passphrase)
|
|
db = sqlite3.connect(config.db_path)
|
|
for row_id, plain in texts.items():
|
|
db.execute("UPDATE events SET text = ? WHERE id = ?", (fresh.lock(plain), row_id))
|
|
for (profile, name), plain in secret_texts.items():
|
|
if profile is None:
|
|
db.execute("UPDATE secrets SET value = ? WHERE name = ?", (fresh.lock(plain), name))
|
|
else:
|
|
db.execute("UPDATE secrets SET value = ? WHERE profile = ? AND name = ?", (fresh.lock(plain), profile, name))
|
|
for (profile, name), plain in secret_metas.items():
|
|
if profile is None:
|
|
db.execute("UPDATE secrets SET meta = ? WHERE name = ?", (fresh.lock(plain), name))
|
|
else:
|
|
db.execute("UPDATE secrets SET meta = ? WHERE profile = ? AND name = ?", (fresh.lock(plain), profile, name))
|
|
for row_id, plain in planned_texts.items():
|
|
db.execute("UPDATE schedules SET prompt = ? WHERE id = ?", (fresh.lock(plain), row_id))
|
|
db.commit()
|
|
db.close()
|
|
for path, plain in files.items():
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|
handle.write(fresh.lock(plain))
|
|
return fresh
|