mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-31 09:52:03 +00:00
index0 is the per-vault activity ledger under `_system.index0`: an append-only event stream per writer, daily checkpoints rolling those events up, and derived canonical views (children_of, counts_by_kind, recent_activity). It was the last piece carried through verbatim rather than computed — which was safe for round-tripping but meant a TypeScript client never UPDATED it. A vault written by this port went stale the moment it was touched, and a Python client reading it afterwards saw an activity log missing everything the TS client had done. Worse, `nostr restore` kept the local block verbatim, so a restore silently discarded the remote's history. Hash parity is the entire contract here: event ids, integrity hashes, checkpoint summaries and view hashes are SHA-256 over Python's canonical JSON, and the merge picks winners by comparing those hashes — one byte of encoding disagreement means the two implementations permanently disagree about which record wins. Everything routes through the existing canonicalJson (Python's ensure_ascii escaping, code-point key ordering, CPython number formatting) rather than JSON.stringify, and the tests compare whole structures via canonical JSON rather than toEqual, so they pass only if the bytes that get hashed are identical. Verified against Python fixtures covering the append chain, compaction, manifest metadata, views and a two-replica merge — including non-ASCII labels and tags, a non-numeric entry id, two writers across two days, and a managed-account scope path. Two Python quirks are replicated deliberately rather than "fixed", because diverging would change which events exist rather than merely how they hash: `str(None)` is "None" (so a JSON null in a required field is a non-empty string there), and a stored integrity_hash is preserved even when it disagrees — this normalizes, it does not verify, and silently rewriting a mismatched hash would hide the tampering the hash exists to reveal. Events are emitted by DIFFING the index at the single mutation funnel each surface goes through, rather than from each call site as Python does. That is deliberate: a call-site emitter is something a new code path can forget, and a silently incomplete ledger is worse than an absent one because it reads as "nothing happened". The event vocabulary matches Python's, so a stream from either implementation reads the same. Emission is best-effort — the atlas is derived state, and it must never fail a committed vault write. mergeIndexPayloads now defaults to merging index0 with the same deterministic rules; "preserve-current" and "reject" remain for callers that want them, and `nostr restore` no longer discards the remote's history. Also made gen_high_risk deterministic. Fernet embeds a random IV and the current time, so that fixture regenerated differently every run and would have failed the fixture-drift CI job each time — it now pins both, with a note that production must never do this. Verified end to end: the TS binary performed four mutations, and Python read the resulting atlas, normalized it without dropping a record, and compacted it into checkpoints and views. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
1608 lines
56 KiB
Python
1608 lines
56 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate deterministic parity fixtures for the TypeScript port.
|
|
|
|
Writes JSON fixture files to js/packages/test-vectors/fixtures/. The
|
|
TypeScript core must reproduce every expected output byte-for-byte; see
|
|
docs/typescript_web_extension_port_plan.md sections 6, 8 and 21.
|
|
|
|
Safety: every fixture derives from the public BIP-39 test mnemonics below.
|
|
Never point this script at a real profile or seed.
|
|
|
|
Determinism: running this twice on the same commit must produce identical
|
|
bytes. Anything time- or randomness-dependent (entry timestamps, the vault
|
|
fixture nonce, KDF salts) is pinned to fixed values. The pinned nonce/salt
|
|
are FIXTURE-ONLY conveniences — production code draws them from os.urandom.
|
|
|
|
Usage:
|
|
.venv/bin/python scripts/generate_ts_port_fixtures.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(REPO / "src"))
|
|
|
|
from bip_utils import Bip39SeedGenerator # noqa: E402
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM # noqa: E402
|
|
|
|
from local_bip85.bip85 import BIP85 # noqa: E402
|
|
from nostr.coincurve_keys import Keys # noqa: E402
|
|
from nostr.key_manager import NOSTR_KEY_APP_ID # noqa: E402
|
|
from seedpass.core.password_generation import ( # noqa: E402
|
|
CURRENT_PASSWORD_GEN_VERSION,
|
|
LEGACY_PASSWORD_GEN_VERSION,
|
|
PasswordGenerator,
|
|
PasswordPolicy,
|
|
)
|
|
from seedpass.core.totp import TotpManager # noqa: E402
|
|
from utils.fingerprint import generate_fingerprint # noqa: E402
|
|
from utils.key_derivation import derive_index_key, derive_totp_secret # noqa: E402
|
|
|
|
FIXTURES_DIR = REPO / "js" / "packages" / "test-vectors" / "fixtures"
|
|
FIXTURE_VERSION = 1
|
|
FIXED_UNIX = 1700000000 # 2023-11-14T22:13:20Z — pins entry timestamps
|
|
|
|
# Public, well-known BIP-39 test vectors. Never real funds, never a real vault.
|
|
MNEMONICS = {
|
|
"abandon12": (
|
|
"abandon abandon abandon abandon abandon abandon "
|
|
"abandon abandon abandon abandon abandon about"
|
|
),
|
|
"legal12": (
|
|
"legal winner thank year wave sausage worth useful "
|
|
"legal winner thank yellow"
|
|
),
|
|
"zoo24": (
|
|
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo "
|
|
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote"
|
|
),
|
|
}
|
|
PRIMARY = "abandon12"
|
|
|
|
POLICIES = {
|
|
"default": {},
|
|
"exclude_ambiguous": {"exclude_ambiguous": True},
|
|
"safe_special": {"special_mode": "safe"},
|
|
"no_special": {"include_special_chars": False},
|
|
"custom_special": {"allowed_special_chars": "!@#"},
|
|
"high_minima": {
|
|
"min_uppercase": 5,
|
|
"min_lowercase": 5,
|
|
"min_digits": 5,
|
|
"min_special": 5,
|
|
},
|
|
}
|
|
|
|
# (text, ec_level) — spans all four EC levels, versions 1..17, and the
|
|
# version-7 boundary where version-information blocks start being written.
|
|
QR_CASES = [
|
|
(
|
|
"otpauth://totp/email-2fa?secret=JBSWY3DPEHPK3PXP"
|
|
"&issuer=SeedPass&period=30&digits=6",
|
|
"M",
|
|
),
|
|
("nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", "M"),
|
|
("JBSWY3DPEHPK3PXP", "L"),
|
|
("https://example.com/a/fairly/long/path?with=query¶ms=yes", "Q"),
|
|
("x", "H"),
|
|
("A" * 200, "M"),
|
|
("A" * 600, "L"),
|
|
("mixed \u00e9\u00e8 unicode \u2713 bytes", "M"),
|
|
("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyMaterialHere user@host", "M"),
|
|
("0123456789" * 8, "Q"),
|
|
]
|
|
|
|
# (policy, length, index) — lengths cross the 32-byte v1 stream-wrap boundary
|
|
PASSWORD_CASES = [
|
|
("default", 8, 0),
|
|
("default", 16, 0),
|
|
("default", 16, 1),
|
|
("default", 16, 7),
|
|
("default", 16, 4095),
|
|
("default", 20, 3),
|
|
("default", 32, 2),
|
|
("default", 33, 2),
|
|
("default", 64, 5),
|
|
("default", 128, 11),
|
|
("exclude_ambiguous", 16, 0),
|
|
("exclude_ambiguous", 40, 2),
|
|
("safe_special", 16, 0),
|
|
("safe_special", 40, 2),
|
|
("no_special", 16, 0),
|
|
("no_special", 40, 2),
|
|
("custom_special", 16, 0),
|
|
("high_minima", 24, 0),
|
|
]
|
|
|
|
|
|
class _SeedDeriver:
|
|
"""Mirrors EncryptionManager.derive_seed_from_mnemonic without a vault."""
|
|
|
|
def derive_seed_from_mnemonic(self, mnemonic, passphrase=""):
|
|
return Bip39SeedGenerator(mnemonic).Generate(passphrase)
|
|
|
|
|
|
def _write(name: str, data: dict) -> None:
|
|
path = FIXTURES_DIR / name
|
|
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
|
|
print(f"wrote {path.relative_to(REPO)}")
|
|
|
|
|
|
def _bip85(mnemonic: str) -> BIP85:
|
|
return BIP85(Bip39SeedGenerator(mnemonic).Generate())
|
|
|
|
|
|
def gen_bip39() -> dict:
|
|
cases = []
|
|
for mid, mnemonic in MNEMONICS.items():
|
|
seed = Bip39SeedGenerator(mnemonic).Generate()
|
|
cases.append(
|
|
{
|
|
"id": mid,
|
|
"mnemonic": mnemonic,
|
|
"passphrase": "",
|
|
"seed_hex": seed.hex(),
|
|
}
|
|
)
|
|
return {"description": "BIP-39 mnemonic -> 64-byte seed", "cases": cases}
|
|
|
|
|
|
def gen_bip85_entropy() -> dict:
|
|
cases = []
|
|
specs = [
|
|
# (app_no, index, entropy_bytes, word_count) — word_count only for 39
|
|
(32, 0, 64, None),
|
|
(32, 1, 64, None),
|
|
(32, 0, 32, None),
|
|
(39, 0, 16, 12),
|
|
(39, 0, 32, 24),
|
|
(39, 3, 16, 12),
|
|
(NOSTR_KEY_APP_ID, 0, 32, None),
|
|
]
|
|
for mid in ("abandon12", "zoo24"):
|
|
bip85 = _bip85(MNEMONICS[mid])
|
|
for app_no, index, nbytes, word_count in specs:
|
|
entropy = bip85.derive_entropy(
|
|
index=index,
|
|
entropy_bytes=nbytes,
|
|
app_no=app_no,
|
|
word_count=word_count,
|
|
)
|
|
cases.append(
|
|
{
|
|
"mnemonic_id": mid,
|
|
"app_no": app_no,
|
|
"index": index,
|
|
"entropy_bytes": nbytes,
|
|
"word_count": word_count,
|
|
"entropy_hex": entropy.hex(),
|
|
}
|
|
)
|
|
return {
|
|
"description": (
|
|
"BIP-85 entropy: BIP32-SLIP10-secp256k1 path derivation, then "
|
|
"HMAC-SHA512(key=b'bip-entropy-from-k', child_privkey)[:entropy_bytes]. "
|
|
"Paths: app 39 -> m/83696968'/39'/0'/{word_count}'/{index}'; "
|
|
"app 32 -> m/83696968'/32'/{index}'; "
|
|
"other -> m/83696968'/{app_no}'/{index}'"
|
|
),
|
|
"cases": cases,
|
|
}
|
|
|
|
|
|
def gen_passwords(gen_version: int) -> dict:
|
|
cases = []
|
|
for mid in (PRIMARY,):
|
|
mnemonic = MNEMONICS[mid]
|
|
bip85 = _bip85(mnemonic)
|
|
for policy_name, length, index in PASSWORD_CASES:
|
|
policy = PasswordPolicy(**POLICIES[policy_name])
|
|
pg = PasswordGenerator(_SeedDeriver(), mnemonic, bip85, policy=policy)
|
|
password = pg.generate_password(
|
|
length=length, index=index, gen_version=gen_version
|
|
)
|
|
cases.append(
|
|
{
|
|
"mnemonic_id": mid,
|
|
"policy": policy_name,
|
|
"policy_params": POLICIES[policy_name],
|
|
"length": length,
|
|
"index": index,
|
|
"password": password,
|
|
}
|
|
)
|
|
return {
|
|
"description": f"Deterministic password derivation, gen_version={gen_version}",
|
|
"gen_version": gen_version,
|
|
"cases": cases,
|
|
}
|
|
|
|
|
|
def gen_totp() -> dict:
|
|
cases = []
|
|
for mid in (PRIMARY, "zoo24"):
|
|
mnemonic = MNEMONICS[mid]
|
|
for index in (0, 1, 7):
|
|
secret = derive_totp_secret(mnemonic, index)
|
|
codes = {
|
|
str(ts): TotpManager.current_code_from_secret(secret, ts)
|
|
for ts in (0, FIXED_UNIX, FIXED_UNIX + 30)
|
|
}
|
|
cases.append(
|
|
{
|
|
"mnemonic_id": mid,
|
|
"index": index,
|
|
"secret_b32": secret,
|
|
"period": 30,
|
|
"digits": 6,
|
|
"codes_at": codes,
|
|
}
|
|
)
|
|
return {
|
|
"description": (
|
|
"TOTP secret derivation: path m/83696968'/39'/1414485072'/{index}' "
|
|
"(1414485072 = 0x544F5450 = int.from_bytes(b'TOTP')), entropy = "
|
|
"HMAC-SHA512(b'bip-entropy-from-k', k), secret = "
|
|
"base32(SHA256(entropy[:32])[:20]); codes are RFC 6238 SHA-1 6-digit"
|
|
),
|
|
"cases": cases,
|
|
}
|
|
|
|
|
|
def gen_nostr_keys() -> dict:
|
|
cases = []
|
|
for mid in (PRIMARY, "legal12"):
|
|
bip85 = _bip85(MNEMONICS[mid])
|
|
for index in (0, 1, 7):
|
|
entropy = bip85.derive_entropy(
|
|
index=index, entropy_bytes=32, app_no=NOSTR_KEY_APP_ID
|
|
)
|
|
keys = Keys(priv_k=entropy.hex())
|
|
cases.append(
|
|
{
|
|
"mnemonic_id": mid,
|
|
"account_index": index,
|
|
"private_key_hex": keys.private_key_hex(),
|
|
"public_key_hex": keys.public_key_hex(),
|
|
"npub": Keys.hex_to_bech32(keys.public_key_hex(), "npub"),
|
|
"nsec": Keys.hex_to_bech32(keys.private_key_hex(), "nsec"),
|
|
}
|
|
)
|
|
return {
|
|
"description": (
|
|
"Nostr keys: BIP-85 app 1237, 32 bytes -> secp256k1 private key; "
|
|
"public key is x-only (compressed pubkey minus prefix byte); "
|
|
"npub/nsec are bech32"
|
|
),
|
|
"cases": cases,
|
|
}
|
|
|
|
|
|
def gen_managed_seeds() -> dict:
|
|
cases = []
|
|
for mid in (PRIMARY, "zoo24"):
|
|
bip85 = _bip85(MNEMONICS[mid])
|
|
for words in (12, 24):
|
|
for index in (0, 1):
|
|
child = bip85.derive_mnemonic(index=index, words_num=words)
|
|
cases.append(
|
|
{
|
|
"mnemonic_id": mid,
|
|
"words": words,
|
|
"index": index,
|
|
"child_mnemonic": child,
|
|
"child_fingerprint": generate_fingerprint(child),
|
|
}
|
|
)
|
|
return {
|
|
"description": (
|
|
"BIP-85 child mnemonics (managed accounts / derived seed phrases), "
|
|
"BIP-39 English wordlist"
|
|
),
|
|
"cases": cases,
|
|
}
|
|
|
|
|
|
def gen_fingerprints() -> dict:
|
|
cases = [
|
|
{
|
|
"mnemonic_id": mid,
|
|
"mnemonic": mnemonic,
|
|
"fingerprint": generate_fingerprint(mnemonic),
|
|
}
|
|
for mid, mnemonic in MNEMONICS.items()
|
|
]
|
|
# Normalization: fingerprint input is strip().lower()
|
|
cases.append(
|
|
{
|
|
"mnemonic_id": "abandon12-mixed-case-padded",
|
|
"mnemonic": " " + MNEMONICS[PRIMARY].upper() + " ",
|
|
"fingerprint": generate_fingerprint(" " + MNEMONICS[PRIMARY].upper() + " "),
|
|
}
|
|
)
|
|
return {
|
|
"description": (
|
|
"Profile fingerprint: SHA256(mnemonic.strip().lower()) hex, "
|
|
"first 16 chars, uppercased"
|
|
),
|
|
"cases": cases,
|
|
}
|
|
|
|
|
|
def gen_index_keys() -> dict:
|
|
cases = []
|
|
for mid, mnemonic in MNEMONICS.items():
|
|
key_b64 = derive_index_key(mnemonic).decode()
|
|
cases.append({"mnemonic_id": mid, "index_key_urlsafe_b64": key_b64})
|
|
return {
|
|
"description": (
|
|
"Vault index key (seed-only mode): seed = BIP39(mnemonic); "
|
|
"master = HKDF-SHA256(seed, salt=None, info=b'seedpass:v1:master'); "
|
|
"key = HKDF-SHA256(master, salt=None, info=b'seedpass:v1:storage'); "
|
|
"urlsafe base64"
|
|
),
|
|
"cases": cases,
|
|
}
|
|
|
|
|
|
def _build_entries_index(post_add=None) -> dict:
|
|
"""Build a real entries index with one entry of each kind, timestamps pinned.
|
|
|
|
``post_add(em)`` runs extra EntryManager operations before the index is
|
|
read back (used by the modification-parity fixture).
|
|
"""
|
|
from seedpass.core.backup import BackupManager
|
|
from seedpass.core.config_manager import ConfigManager
|
|
from seedpass.core.entry_management import EntryManager
|
|
from seedpass.core.encryption import EncryptionManager
|
|
from seedpass.core.vault import Vault
|
|
from utils.key_derivation import derive_key_from_password
|
|
|
|
mnemonic = MNEMONICS[PRIMARY]
|
|
|
|
# Pin every timestamp EntryManager writes
|
|
EntryManager._now_unix = staticmethod(lambda: FIXED_UNIX)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
dir_path = Path(tmp)
|
|
fp = generate_fingerprint(mnemonic)
|
|
seed_key = derive_key_from_password("fixture-password", fp)
|
|
EncryptionManager(seed_key, dir_path).encrypt_parent_seed(mnemonic)
|
|
|
|
index_key = derive_index_key(mnemonic)
|
|
enc_mgr = EncryptionManager(index_key, dir_path)
|
|
vault = Vault(enc_mgr, dir_path)
|
|
cfg_mgr = ConfigManager(vault, dir_path)
|
|
backup_mgr = BackupManager(dir_path, cfg_mgr)
|
|
em = EntryManager(vault, backup_mgr)
|
|
|
|
em.add_entry(
|
|
"example.com",
|
|
16,
|
|
username="alice",
|
|
url="https://example.com",
|
|
notes="password note",
|
|
tags=["web"],
|
|
)
|
|
em.add_totp("example-totp", mnemonic, deterministic=True, tags=["otp"])
|
|
em.add_totp(
|
|
"imported-totp",
|
|
secret="JBSWY3DPEHPK3PXP",
|
|
period=45,
|
|
digits=8,
|
|
)
|
|
em.add_ssh_key("example-ssh", mnemonic, notes="ssh note")
|
|
em.add_nostr_key("example-nostr", mnemonic)
|
|
em.add_key_value("api-token", "token", "abc123", tags=["api"])
|
|
em.add_document("example-doc", "hello fixture world", file_type="txt")
|
|
em.add_seed("example-seed", mnemonic, words_num=24)
|
|
em.add_managed_account("example-managed", mnemonic)
|
|
em.add_pgp_key("example-pgp", mnemonic, user_id="[email protected]")
|
|
|
|
if post_add is not None:
|
|
post_add(em)
|
|
|
|
index = vault.load_index()
|
|
entries = {
|
|
k: v for k, v in index.items() if k != "_system"
|
|
}
|
|
return entries
|
|
|
|
|
|
def gen_entries_and_vault() -> tuple[dict, dict]:
|
|
entries = _build_entries_index()
|
|
|
|
plaintext = json.dumps(entries, indent=2, sort_keys=True).encode()
|
|
key = base64.urlsafe_b64decode(derive_index_key(MNEMONICS[PRIMARY]))
|
|
# FIXTURE-ONLY fixed nonce so the blob is reproducible; production uses
|
|
# os.urandom(12) per encryption (see EncryptionManager.encrypt_data).
|
|
nonce = hashlib.sha256(b"seedpass-ts-fixture-nonce").digest()[:12]
|
|
blob = b"V3|" + nonce + AESGCM(key).encrypt(nonce, plaintext, None)
|
|
|
|
entries_fixture = {
|
|
"description": (
|
|
"Decrypted entries index (one entry of each kind), _system "
|
|
"stripped, timestamps pinned to FIXED_UNIX"
|
|
),
|
|
"fixed_unix": FIXED_UNIX,
|
|
"mnemonic_id": PRIMARY,
|
|
"entries": entries,
|
|
}
|
|
vault_fixture = {
|
|
"description": (
|
|
"Encrypted vault payload, format b'V3|' + nonce(12) + AES-GCM "
|
|
"ciphertext+tag, key = raw bytes of index_key fixture. Nonce is "
|
|
"fixed for fixture reproducibility only."
|
|
),
|
|
"mnemonic_id": PRIMARY,
|
|
"nonce_hex": nonce.hex(),
|
|
"payload_b64": base64.b64encode(blob).decode(),
|
|
"plaintext_sha256": hashlib.sha256(plaintext).hexdigest(),
|
|
"plaintext_canonical_json": "entries_index.json#entries (indent=2, sort_keys)",
|
|
}
|
|
return entries_fixture, vault_fixture
|
|
|
|
|
|
def gen_password_kdf() -> dict:
|
|
from utils.key_derivation import (
|
|
KdfConfig,
|
|
derive_key_from_password,
|
|
derive_key_from_password_argon2,
|
|
)
|
|
|
|
fp = generate_fingerprint(MNEMONICS[PRIMARY])
|
|
pbkdf2_cases = []
|
|
for password in ("fixture-password", "correct horse battery staple", " pässwörd "):
|
|
for iterations in (50_000, 100_000):
|
|
key = derive_key_from_password(password, fp, iterations=iterations)
|
|
pbkdf2_cases.append(
|
|
{
|
|
"password": password,
|
|
"fingerprint": fp,
|
|
"iterations": iterations,
|
|
"key_urlsafe_b64": key.decode(),
|
|
}
|
|
)
|
|
|
|
argon2_cases = []
|
|
for params in (
|
|
{"time_cost": 2, "memory_cost": 64 * 1024, "parallelism": 8},
|
|
{"time_cost": 1, "memory_cost": 8 * 1024, "parallelism": 1},
|
|
):
|
|
kdf = KdfConfig(
|
|
name="argon2id",
|
|
version=1,
|
|
params=params,
|
|
salt_b64=base64.b64encode(b"fixture-salt-16b").decode(),
|
|
)
|
|
key = derive_key_from_password_argon2("fixture-password", kdf)
|
|
argon2_cases.append(
|
|
{
|
|
"password": "fixture-password",
|
|
"kdf": {
|
|
"name": kdf.name,
|
|
"version": kdf.version,
|
|
"params": kdf.params,
|
|
"salt_b64": kdf.salt_b64,
|
|
},
|
|
"key_urlsafe_b64": key.decode(),
|
|
}
|
|
)
|
|
|
|
return {
|
|
"description": (
|
|
"Password-based key derivation. PBKDF2: NFKD-normalized+stripped "
|
|
"password, salt = SHA256(fingerprint)[:16], PBKDF2-HMAC-SHA256, "
|
|
"32 bytes, urlsafe b64. Argon2id: NFKD+strip, salt from config, "
|
|
"hash_len 32, urlsafe b64. Salts pinned for fixtures only."
|
|
),
|
|
"pbkdf2_cases": pbkdf2_cases,
|
|
"argon2id_cases": argon2_cases,
|
|
}
|
|
|
|
|
|
def gen_legacy_payloads() -> dict:
|
|
from cryptography.fernet import Fernet
|
|
|
|
mnemonic = MNEMONICS[PRIMARY]
|
|
key_b64 = derive_index_key(mnemonic)
|
|
raw_key = base64.urlsafe_b64decode(key_b64)
|
|
fernet = Fernet(key_b64)
|
|
|
|
plaintext = b'{"legacy": true, "hello": "fixture"}'
|
|
iv = hashlib.sha256(b"seedpass-ts-fixture-fernet-iv").digest()[:16]
|
|
token = fernet._encrypt_from_parts(plaintext, FIXED_UNIX, iv)
|
|
|
|
nonce = hashlib.sha256(b"seedpass-ts-fixture-v2-nonce").digest()[:12]
|
|
v2_gcm = b"V2:" + nonce + AESGCM(raw_key).encrypt(nonce, plaintext, None)
|
|
v2_fernet = b"V2:" + token
|
|
|
|
# Serialized parent-seed file: JSON {"kdf": ..., "ct": b64(V3 blob)},
|
|
# encrypted with the password-derived key (not the index key).
|
|
from utils.key_derivation import derive_key_from_password
|
|
|
|
fp = generate_fingerprint(mnemonic)
|
|
seed_key_b64 = derive_key_from_password("fixture-password", fp)
|
|
seed_key = base64.urlsafe_b64decode(seed_key_b64)
|
|
seed_nonce = hashlib.sha256(b"seedpass-ts-fixture-seed-nonce").digest()[:12]
|
|
seed_ct = b"V3|" + seed_nonce + AESGCM(seed_key).encrypt(
|
|
seed_nonce, mnemonic.encode(), None
|
|
)
|
|
kdf_dict = {
|
|
"name": "pbkdf2-sha256",
|
|
"version": 1,
|
|
"params": {"iterations": 100000},
|
|
"salt_b64": "",
|
|
}
|
|
wrapper = json.dumps(
|
|
{"kdf": kdf_dict, "ct": base64.b64encode(seed_ct).decode()},
|
|
separators=(",", ":"),
|
|
).encode()
|
|
|
|
return {
|
|
"description": (
|
|
"Legacy/migration payload formats the TS port must read: raw "
|
|
"Fernet token, V2-prefixed AES-GCM, V2-prefixed Fernet "
|
|
"(wrong-header case), and the JSON kdf/ct file wrapper around a "
|
|
"V3 blob. IVs/nonces/timestamps pinned for fixtures only."
|
|
),
|
|
"mnemonic_id": PRIMARY,
|
|
"plaintext_utf8": plaintext.decode(),
|
|
"fernet_token_b64": base64.b64encode(token).decode(),
|
|
"v2_gcm_payload_b64": base64.b64encode(v2_gcm).decode(),
|
|
"v2_fernet_payload_b64": base64.b64encode(v2_fernet).decode(),
|
|
"parent_seed_file": {
|
|
"password": "fixture-password",
|
|
"fingerprint": generate_fingerprint(mnemonic),
|
|
"wrapper_b64": base64.b64encode(wrapper).decode(),
|
|
"expected_seed_mnemonic_id": PRIMARY,
|
|
},
|
|
}
|
|
|
|
|
|
def _derive_key_index(mnemonic: str) -> bytes:
|
|
from utils.key_hierarchy import kd
|
|
|
|
seed = Bip39SeedGenerator(mnemonic).Generate()
|
|
master = kd(seed, b"seedpass:v1:master")
|
|
return kd(master, b"seedpass:v1:index")
|
|
|
|
|
|
def gen_nostr_snapshot(vault_payload_b64: str) -> dict:
|
|
import gzip as gzip_mod
|
|
import hmac as hmac_mod
|
|
|
|
from nostr.snapshot import prepare_snapshot
|
|
|
|
mnemonic = MNEMONICS[PRIMARY]
|
|
encrypted = base64.b64decode(vault_payload_b64)
|
|
|
|
# mtime=0 pins the gzip header; production uses current time, which only
|
|
# affects the compressed bytes, not decompression.
|
|
compressed = gzip_mod.compress(encrypted, mtime=0)
|
|
limit = 800
|
|
manifest, chunks = prepare_snapshot(encrypted, limit)
|
|
# Re-chunk the pinned compression so chunk bytes/hashes are deterministic
|
|
pinned_chunks = [compressed[i : i + limit] for i in range(0, len(compressed), limit)]
|
|
metas = [
|
|
{
|
|
"id": f"seedpass-chunk-{i:04d}",
|
|
"size": len(c),
|
|
"hash": hashlib.sha256(c).hexdigest(),
|
|
"event_id": None,
|
|
}
|
|
for i, c in enumerate(pinned_chunks)
|
|
]
|
|
|
|
nonce = hashlib.sha256(b"seedpass-ts-fixture-manifest-nonce").digest()[:16]
|
|
key_index = _derive_key_index(mnemonic)
|
|
manifest_id = hmac_mod.new(
|
|
key_index, b"manifest|" + nonce, hashlib.sha256
|
|
).hexdigest()
|
|
|
|
manifest_json = json.dumps(
|
|
{
|
|
"ver": 1,
|
|
"algo": "gzip",
|
|
"chunks": metas,
|
|
"delta_since": FIXED_UNIX,
|
|
"nonce": base64.b64encode(nonce).decode(),
|
|
"index0": None,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"description": (
|
|
"Nostr snapshot model: gzip(encrypted index) split into <=limit "
|
|
"chunks; chunk id 'seedpass-chunk-%04d', sha256 hash; manifest "
|
|
"kind 30070 (d-tag = manifest id), chunks kind 30071 (d-tag = "
|
|
"chunk id, content = b64), deltas kind 30072. Manifest id = "
|
|
"HMAC-SHA256(key_index, b'manifest|' + nonce) hex, key_index = "
|
|
"HKDF chain master->'seedpass:v1:index'. gzip mtime pinned to 0 "
|
|
"for the fixture."
|
|
),
|
|
"mnemonic_id": PRIMARY,
|
|
"event_kinds": {"manifest": 30070, "snapshot_chunk": 30071, "delta": 30072},
|
|
"chunk_limit": limit,
|
|
"encrypted_b64": vault_payload_b64,
|
|
"compressed_b64": base64.b64encode(compressed).decode(),
|
|
"chunks_b64": [base64.b64encode(c).decode() for c in pinned_chunks],
|
|
"chunk_metas": metas,
|
|
"key_index_hex": key_index.hex(),
|
|
"manifest_nonce_b64": base64.b64encode(nonce).decode(),
|
|
"manifest_id": manifest_id,
|
|
"manifest_json": manifest_json,
|
|
"unpinned_chunk_count": len(chunks),
|
|
}
|
|
|
|
|
|
def _merge_case(name: str, current: dict, incoming: dict, source_tag: str) -> dict:
|
|
import copy
|
|
|
|
from seedpass.core.sync_conflict import merge_index_payloads
|
|
|
|
merged = merge_index_payloads(
|
|
copy.deepcopy(current), copy.deepcopy(incoming), source_tag=source_tag
|
|
)
|
|
return {
|
|
"name": name,
|
|
"current": current,
|
|
"incoming": incoming,
|
|
"source_tag": source_tag,
|
|
"merged": merged,
|
|
}
|
|
|
|
|
|
def gen_sync_merge() -> dict:
|
|
t = FIXED_UNIX
|
|
|
|
def entry(label: str, ts: int, **kw) -> dict:
|
|
base = {
|
|
"type": "password",
|
|
"kind": "password",
|
|
"label": label,
|
|
"length": 16,
|
|
"archived": False,
|
|
"notes": "",
|
|
"tags": [],
|
|
"modified_ts": ts,
|
|
}
|
|
base.update(kw)
|
|
return base
|
|
|
|
cases = [
|
|
_merge_case(
|
|
"newer-incoming-wins",
|
|
{"schema_version": 4, "entries": {"0": entry("site", t)}},
|
|
{"schema_version": 4, "entries": {"0": entry("site-renamed", t + 10)}},
|
|
"tag-newer",
|
|
),
|
|
_merge_case(
|
|
"older-incoming-loses",
|
|
{"schema_version": 4, "entries": {"0": entry("site", t + 10)}},
|
|
{"schema_version": 4, "entries": {"0": entry("site-old", t)}},
|
|
"tag-older",
|
|
),
|
|
_merge_case(
|
|
"equal-ts-field-union",
|
|
{
|
|
"schema_version": 4,
|
|
"entries": {
|
|
"0": entry("site", t, notes="", tags=["a", "c"], username="")
|
|
},
|
|
},
|
|
{
|
|
"schema_version": 4,
|
|
"entries": {
|
|
"0": entry(
|
|
"site", t, notes="from-incoming", tags=["b", "a"],
|
|
username="alice", archived=True,
|
|
)
|
|
},
|
|
},
|
|
"tag-equal",
|
|
),
|
|
_merge_case(
|
|
"incoming-delete-creates-tombstone",
|
|
{"schema_version": 4, "entries": {"0": entry("site", t), "1": entry("keep", t)}},
|
|
{
|
|
"schema_version": 4,
|
|
"entries": {"0": {**entry("site", t + 5), "_deleted": True}},
|
|
},
|
|
"tag-delete",
|
|
),
|
|
_merge_case(
|
|
"entry-newer-than-tombstone-survives",
|
|
{
|
|
"schema_version": 4,
|
|
"entries": {},
|
|
"_sync_meta": {
|
|
"tombstones": {
|
|
"0": {"deleted_ts": t, "entry_hash": "", "event_hash": "", "source": "x"}
|
|
}
|
|
},
|
|
},
|
|
{"schema_version": 4, "entries": {"0": entry("revived", t + 20)}},
|
|
"tag-revive",
|
|
),
|
|
_merge_case(
|
|
"tombstone-beats-older-entry",
|
|
{
|
|
"schema_version": 4,
|
|
"entries": {},
|
|
"_sync_meta": {
|
|
"tombstones": {
|
|
"0": {"deleted_ts": t + 20, "entry_hash": "", "event_hash": "", "source": "x"}
|
|
}
|
|
},
|
|
},
|
|
{"schema_version": 4, "entries": {"0": entry("stale", t)}},
|
|
"tag-stale",
|
|
),
|
|
_merge_case(
|
|
"tombstone-vs-tombstone-newer-wins",
|
|
{
|
|
"schema_version": 4,
|
|
"entries": {},
|
|
"_sync_meta": {
|
|
"tombstones": {
|
|
"0": {"deleted_ts": t + 1, "entry_hash": "aa", "event_hash": "", "source": "one"}
|
|
}
|
|
},
|
|
},
|
|
{
|
|
"schema_version": 4,
|
|
"entries": {},
|
|
"_sync_meta": {
|
|
"tombstones": {
|
|
"0": {"deleted_ts": t + 9, "entry_hash": "bb", "event_hash": "", "source": "two"}
|
|
}
|
|
},
|
|
},
|
|
"tag-tt",
|
|
),
|
|
_merge_case(
|
|
"empty-current-adopts-incoming",
|
|
{},
|
|
{"schema_version": 4, "entries": {"3": entry("new", t)}},
|
|
"tag-adopt",
|
|
),
|
|
]
|
|
return {
|
|
"description": (
|
|
"merge_index_payloads parity cases. merged output includes the "
|
|
"normalized empty _system.index0 skeleton and _sync_meta with "
|
|
"strategy modified_ts_hash_tombstone_v2."
|
|
),
|
|
"cases": cases,
|
|
}
|
|
|
|
|
|
def gen_delta_replay() -> dict:
|
|
import copy
|
|
|
|
from seedpass.core.index0 import ensure_index0_payload
|
|
from seedpass.core.sync_conflict import merge_index_payloads
|
|
|
|
key = base64.urlsafe_b64decode(derive_index_key(MNEMONICS[PRIMARY]))
|
|
t = FIXED_UNIX
|
|
|
|
def entry(label: str, ts: int) -> dict:
|
|
return {
|
|
"type": "password", "kind": "password", "label": label,
|
|
"length": 16, "archived": False, "notes": "", "tags": [],
|
|
"modified_ts": ts,
|
|
}
|
|
|
|
snapshot_index = {"schema_version": 4, "entries": {"0": entry("base", t)}}
|
|
delta1 = {"schema_version": 4, "entries": {"0": entry("base-renamed", t + 10), "1": entry("added", t + 10)}}
|
|
delta2 = {"schema_version": 4, "entries": {"1": {**entry("added", t + 20), "_deleted": True}}}
|
|
|
|
def encrypt(payload: dict, nonce_seed: bytes) -> bytes:
|
|
nonce = hashlib.sha256(nonce_seed).digest()[:12]
|
|
plaintext = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
|
return b"V3|" + nonce + AESGCM(key).encrypt(nonce, plaintext, None)
|
|
|
|
enc_deltas = [
|
|
encrypt(delta1, b"seedpass-ts-fixture-delta-1"),
|
|
encrypt(delta2, b"seedpass-ts-fixture-delta-2"),
|
|
]
|
|
|
|
# Reproduce decrypt_and_save_index_from_nostr(merge=True) semantics
|
|
state = ensure_index0_payload(copy.deepcopy(snapshot_index))
|
|
for enc, payload in zip(enc_deltas, [delta1, delta2]):
|
|
source_tag = hashlib.sha256(enc).hexdigest()[:16]
|
|
incoming = ensure_index0_payload(copy.deepcopy(payload))
|
|
state = merge_index_payloads(state, incoming, source_tag=source_tag)
|
|
|
|
return {
|
|
"description": (
|
|
"Delta replay: start from a snapshot index, decrypt each V3 delta "
|
|
"payload, merge in order with source_tag = "
|
|
"sha256(encrypted_delta)[:16]. Nonces pinned (fixture-only)."
|
|
),
|
|
"mnemonic_id": PRIMARY,
|
|
"snapshot_index": snapshot_index,
|
|
"delta_payloads_b64": [base64.b64encode(e).decode() for e in enc_deltas],
|
|
"delta_plaintexts": [delta1, delta2],
|
|
"final_state": state,
|
|
}
|
|
|
|
|
|
def gen_portable_backup(entries: dict) -> dict:
|
|
from utils.checksum import canonical_json_dumps, json_checksum
|
|
|
|
mnemonic = MNEMONICS[PRIMARY]
|
|
index_data = entries
|
|
canonical = canonical_json_dumps(index_data)
|
|
checksum = json_checksum(index_data)
|
|
|
|
key = base64.urlsafe_b64decode(derive_index_key(mnemonic))
|
|
nonce = hashlib.sha256(b"seedpass-ts-fixture-export-nonce").digest()[:12]
|
|
payload = b"V3|" + nonce + AESGCM(key).encrypt(nonce, canonical.encode(), None)
|
|
|
|
encrypted_wrapper = {
|
|
"format_version": 1,
|
|
"created_at": FIXED_UNIX,
|
|
"fingerprint": generate_fingerprint(mnemonic),
|
|
"encryption_mode": "seed-only",
|
|
"cipher": "aes-gcm",
|
|
"checksum": checksum,
|
|
"payload": base64.b64encode(payload).decode(),
|
|
}
|
|
plaintext_wrapper = {
|
|
"format_version": 1,
|
|
"created_at": FIXED_UNIX,
|
|
"fingerprint": generate_fingerprint(mnemonic),
|
|
"encryption_mode": "none",
|
|
"cipher": "none",
|
|
"checksum": checksum,
|
|
"payload": base64.b64encode(canonical.encode()).decode(),
|
|
}
|
|
|
|
return {
|
|
"description": (
|
|
"Portable backup wrapper (portable_backup.py, format_version 1). "
|
|
"payload = V3 AES-GCM over canonical JSON of the index, key = "
|
|
"index key of the parent seed; checksum = "
|
|
"sha256(canonical_json(index)). Nonce/created_at pinned "
|
|
"(fixture-only)."
|
|
),
|
|
"mnemonic_id": PRIMARY,
|
|
"index": index_data,
|
|
"canonical_json_sha256": checksum,
|
|
"encrypted_wrapper": encrypted_wrapper,
|
|
"plaintext_wrapper": plaintext_wrapper,
|
|
}
|
|
|
|
|
|
def gen_entry_secrets() -> dict:
|
|
"""Expected reveal values for the fixture vault, via the same low-level
|
|
functions EntryManager retrieval uses."""
|
|
from nostr.coincurve_keys import Keys
|
|
from seedpass.core.password_generation import (
|
|
PasswordGenerator,
|
|
PasswordPolicy,
|
|
derive_seed_phrase,
|
|
)
|
|
|
|
mnemonic = MNEMONICS[PRIMARY]
|
|
bip85 = _bip85(mnemonic)
|
|
|
|
# Entry 0: password, length 16, gen_version 2, derivation index 0
|
|
pg = PasswordGenerator(_SeedDeriver(), mnemonic, bip85, policy=PasswordPolicy())
|
|
password = pg.generate_password(length=16, index=0, gen_version=2)
|
|
|
|
# Entry 1: deterministic TOTP, derivation index 0
|
|
totp_secret = derive_totp_secret(mnemonic, 0)
|
|
totp_code = TotpManager.current_code_from_secret(totp_secret, FIXED_UNIX)
|
|
|
|
# Entry 4: nostr key entry — DEFAULT BIP-85 app 39 (not 1237)
|
|
entropy = bip85.derive_entropy(index=4, entropy_bytes=32)
|
|
nostr_keys = Keys(priv_k=entropy.hex())
|
|
nsec = Keys.hex_to_bech32(nostr_keys.private_key_hex(), "nsec")
|
|
npub = Keys.hex_to_bech32(nostr_keys.public_key_hex(), "npub")
|
|
|
|
# Entry 7: seed entry, index 7, 24 words; entry 8: managed account, 12 words
|
|
seed_phrase = derive_seed_phrase(bip85, 7, 24)
|
|
managed_phrase = derive_seed_phrase(bip85, 8, 12)
|
|
|
|
return {
|
|
"description": (
|
|
"Expected plaintext values for entries in entries_index.json, "
|
|
"computed via the same functions EntryManager retrieval uses. "
|
|
"Nostr entry keys use the DEFAULT BIP-85 app 39 path, unlike the "
|
|
"sync client identity (app 1237)."
|
|
),
|
|
"mnemonic_id": PRIMARY,
|
|
"password_entry_0": password,
|
|
"totp_entry_1_code_at": {str(FIXED_UNIX): totp_code},
|
|
"nostr_entry_4": {"nsec": nsec, "npub": npub},
|
|
"seed_entry_7_mnemonic": seed_phrase,
|
|
"managed_entry_8_mnemonic": managed_phrase,
|
|
}
|
|
|
|
|
|
def gen_nostr_events(snapshot: dict, delta_replay: dict) -> dict:
|
|
"""Signed Nostr events for the snapshot/chunk/delta model.
|
|
|
|
Event ids are deterministic (NIP-01 serialization); BIP-340 signatures
|
|
use random aux data and are NOT. To keep the generator byte-deterministic
|
|
we reuse the previously committed signature whenever the event id is
|
|
unchanged — a fresh signature is only produced for new/changed events.
|
|
"""
|
|
import json as json_mod
|
|
|
|
from nostr_sdk import EventBuilder, EventId, Keys, Kind, Tag, Timestamp
|
|
|
|
# Sync client identity: BIP-85 app 1237, account index 0 (nostr_keys.json)
|
|
bip85 = _bip85(MNEMONICS[PRIMARY])
|
|
entropy = bip85.derive_entropy(index=0, entropy_bytes=32, app_no=NOSTR_KEY_APP_ID)
|
|
keys = Keys.parse(entropy.hex())
|
|
|
|
existing_sigs: dict[str, str] = {}
|
|
existing_path = FIXTURES_DIR / "nostr_events.json"
|
|
if existing_path.exists():
|
|
try:
|
|
for ev in json_mod.loads(existing_path.read_text())["events"]:
|
|
existing_sigs[ev["event"]["id"]] = ev["event"]["sig"]
|
|
except Exception:
|
|
pass
|
|
|
|
def build(name: str, kind: int, content: str, tags: list) -> dict:
|
|
builder = (
|
|
EventBuilder(Kind(kind), content)
|
|
.tags(tags)
|
|
.custom_created_at(Timestamp.from_secs(FIXED_UNIX))
|
|
)
|
|
event = json_mod.loads(builder.sign_with_keys(keys).as_json())
|
|
cached_sig = existing_sigs.get(event["id"])
|
|
if cached_sig:
|
|
event["sig"] = cached_sig
|
|
return {"name": name, "event": event}
|
|
|
|
events = [
|
|
build(
|
|
"chunk-0",
|
|
30071,
|
|
snapshot["chunks_b64"][0],
|
|
[Tag.identifier(snapshot["chunk_metas"][0]["id"])],
|
|
),
|
|
build(
|
|
"manifest",
|
|
30070,
|
|
snapshot["manifest_json"],
|
|
[Tag.identifier(snapshot["manifest_id"])],
|
|
),
|
|
build(
|
|
"delta-1",
|
|
30072,
|
|
delta_replay["delta_payloads_b64"][0],
|
|
[Tag.event(EventId.parse(snapshot["manifest_id"]))],
|
|
),
|
|
]
|
|
|
|
return {
|
|
"description": (
|
|
"Signed Nostr events from the Python-side nostr_sdk (rust-nostr) "
|
|
"with pinned created_at. Ids are NIP-01-deterministic; signatures "
|
|
"are cached across generator runs (BIP-340 aux randomness)."
|
|
),
|
|
"mnemonic_id": PRIMARY,
|
|
"signer_private_key_hex": entropy.hex(),
|
|
"signer_public_key_hex": keys.public_key().to_hex(),
|
|
"events": events,
|
|
}
|
|
|
|
|
|
MOD_UNIX = FIXED_UNIX + 100
|
|
|
|
|
|
def gen_entry_mods() -> dict:
|
|
"""Apply a modification sequence via the real EntryManager and capture
|
|
the result; TS replicates the same ops and must match byte-for-byte."""
|
|
from seedpass.core.entry_management import EntryManager
|
|
|
|
def mods(em: EntryManager) -> None:
|
|
# Later timestamp so touched entries visibly differ from creation
|
|
EntryManager._now_unix = staticmethod(lambda: MOD_UNIX)
|
|
em.modify_entry(
|
|
0,
|
|
username="alice2",
|
|
url="https://example.org",
|
|
notes="updated note",
|
|
tags=["web", "prod"],
|
|
min_digits=3,
|
|
special_mode="safe",
|
|
)
|
|
em.modify_entry(1, period=60, digits=8)
|
|
em.modify_entry(5, label="api-token-renamed", value="rotated-value")
|
|
em.archive_entry(6)
|
|
em.restore_entry(6)
|
|
em.archive_entry(3)
|
|
em.add_link(0, 1, relation="totp", note="2fa for site")
|
|
em.add_link(0, 5, relation="related_to")
|
|
em.add_link(5, 0)
|
|
em.remove_link(0, 5)
|
|
|
|
entries = _build_entries_index(post_add=mods)
|
|
return {
|
|
"description": (
|
|
"Entries index after a modification sequence (modify_entry, "
|
|
"archive/restore, add_link/remove_link) applied by the real "
|
|
"EntryManager at MOD_UNIX. TS replays the same ops from the "
|
|
"creation state and must match."
|
|
),
|
|
"fixed_unix": FIXED_UNIX,
|
|
"mod_unix": MOD_UNIX,
|
|
"mnemonic_id": PRIMARY,
|
|
"entries": entries,
|
|
}
|
|
|
|
|
|
def gen_migrations() -> dict:
|
|
"""Legacy index payloads at every historical schema version, with the
|
|
result Python's apply_migrations produces for each."""
|
|
import copy
|
|
|
|
from seedpass.core.migrations import LATEST_VERSION, apply_migrations
|
|
|
|
# v0: no schema_version, passwords keyed by index, "website" not "label"
|
|
v0 = {
|
|
"passwords": {
|
|
"0": {
|
|
"website": "legacy-site.example",
|
|
"length": 16,
|
|
"username": "olduser",
|
|
"url": "https://legacy.example",
|
|
},
|
|
"1": {"website": "second-site.example", "length": 12},
|
|
}
|
|
}
|
|
# v1: schema_version present, still the passwords shape
|
|
v1 = {"schema_version": 1, **copy.deepcopy(v0)}
|
|
# v2: entries exist, but without custom_fields/origin/tags
|
|
v2 = {
|
|
"schema_version": 2,
|
|
"entries": {
|
|
"0": {
|
|
"type": "password",
|
|
"kind": "password",
|
|
"label": "v2-site.example",
|
|
"length": 20,
|
|
"notes": "",
|
|
"username": "u",
|
|
},
|
|
"1": {
|
|
"type": "totp",
|
|
"kind": "totp",
|
|
"label": "v2-totp",
|
|
"index": 0,
|
|
"period": 30,
|
|
"digits": 6,
|
|
"notes": "",
|
|
},
|
|
},
|
|
}
|
|
# v3: has custom_fields/origin but no tags
|
|
v3 = copy.deepcopy(v2)
|
|
v3["schema_version"] = 3
|
|
for entry in v3["entries"].values():
|
|
entry["custom_fields"] = []
|
|
entry["origin"] = ""
|
|
|
|
cases = []
|
|
for name, payload in (("v0", v0), ("v1", v1), ("v2", v2), ("v3", v3)):
|
|
cases.append(
|
|
{
|
|
"name": name,
|
|
"input": copy.deepcopy(payload),
|
|
"migrated": apply_migrations(copy.deepcopy(payload)),
|
|
}
|
|
)
|
|
|
|
return {
|
|
"description": (
|
|
"Schema migrations 0->4 as applied by seedpass.core.migrations."
|
|
" v0->v1 injects schema_version; v1->v2 renames passwords to"
|
|
" entries and website to label; v2->v3 adds custom_fields/origin"
|
|
" defaults; v3->v4 adds tags defaults."
|
|
),
|
|
"latest_version": LATEST_VERSION,
|
|
"cases": cases,
|
|
}
|
|
|
|
|
|
def gen_ssh_keys() -> dict:
|
|
from seedpass.core.password_generation import derive_ssh_key_pair
|
|
|
|
cases = []
|
|
for mid in (PRIMARY, "zoo24"):
|
|
mnemonic = MNEMONICS[mid]
|
|
bip85 = _bip85(mnemonic)
|
|
for index in (0, 1, 7):
|
|
priv_pem, pub_pem = derive_ssh_key_pair(mnemonic, index)
|
|
entropy = bip85.derive_entropy(index=index, entropy_bytes=32, app_no=32)
|
|
cases.append(
|
|
{
|
|
"mnemonic_id": mid,
|
|
"index": index,
|
|
"entropy_hex": entropy.hex(),
|
|
"private_key_pem": priv_pem,
|
|
"public_key_pem": pub_pem,
|
|
}
|
|
)
|
|
return {
|
|
"description": (
|
|
"SSH key pairs: BIP-85 app 32, 32 bytes of entropy used directly "
|
|
"as an Ed25519 private key. Serialized as PKCS#8 PEM (private) "
|
|
"and SubjectPublicKeyInfo PEM (public), matching Python's "
|
|
"cryptography output byte-for-byte."
|
|
),
|
|
"cases": cases,
|
|
}
|
|
|
|
|
|
def gen_pgp_keys() -> dict:
|
|
from seedpass.core.password_generation import derive_pgp_key
|
|
|
|
cases = []
|
|
for mid in (PRIMARY, "zoo24"):
|
|
bip85 = _bip85(MNEMONICS[mid])
|
|
for index, user_id in ((0, "[email protected]"), (3, ""), (7, "Test User <[email protected]>")):
|
|
priv, pub, fp = derive_pgp_key(bip85, index, "ed25519", user_id)
|
|
cases.append(
|
|
{
|
|
"mnemonic_id": mid,
|
|
"index": index,
|
|
"user_id": user_id,
|
|
"fingerprint": fp,
|
|
"private_key_armored": priv,
|
|
"public_key_armored": pub,
|
|
}
|
|
)
|
|
return {
|
|
"description": (
|
|
"Deterministic PGP (Ed25519/EdDSA) keys from BIP-85 app 32, "
|
|
"created 2000-01-01T00:00:00Z, as PGPy serializes them. EdDSA "
|
|
"signatures are deterministic, so the armored output is "
|
|
"byte-reproducible."
|
|
),
|
|
"created_at": 946684800,
|
|
"cases": cases,
|
|
}
|
|
|
|
|
|
def gen_kdf_metadata() -> dict:
|
|
return {
|
|
"description": (
|
|
"Vault KDF metadata shapes the TS port must parse. Salts here are "
|
|
"pinned for the fixture; production salts come from os.urandom."
|
|
),
|
|
"current_kdf_version": 1,
|
|
"configs": [
|
|
{
|
|
"name": "argon2id",
|
|
"version": 1,
|
|
"params": {"time_cost": 2, "memory_cost": 65536, "parallelism": 8},
|
|
"salt_b64": base64.b64encode(b"fixture-salt-16b").decode(),
|
|
},
|
|
{
|
|
"name": "pbkdf2-sha256",
|
|
"version": 1,
|
|
"params": {"iterations": 100000, "dklen": 32},
|
|
"salt_note": "salt = SHA256(fingerprint)[:16] — deterministic",
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
def gen_qr() -> dict:
|
|
"""QR reference matrices for the TypeScript encoder.
|
|
|
|
Byte mode is forced: the library auto-selects alphanumeric mode for an
|
|
all-uppercase string, which is a different encoding of the same text
|
|
rather than a different answer to the same question, and the TS encoder
|
|
is byte-mode only.
|
|
|
|
The chosen mask is recorded so the TS tests can pin it. The two
|
|
implementations disagree about mask SELECTION -- the spec scores the
|
|
finished symbol, this library scores one with its format modules blanked
|
|
(`makeImpl(test=True, ...)`) -- and every one of the eight masks is a
|
|
valid, scannable code, so the tests compare matrices at a fixed mask
|
|
rather than reproducing the quirk.
|
|
"""
|
|
import qrcode
|
|
import qrcode.util as qr_util
|
|
from qrcode.constants import (
|
|
ERROR_CORRECT_H,
|
|
ERROR_CORRECT_L,
|
|
ERROR_CORRECT_M,
|
|
ERROR_CORRECT_Q,
|
|
)
|
|
|
|
levels = {
|
|
"L": ERROR_CORRECT_L,
|
|
"M": ERROR_CORRECT_M,
|
|
"Q": ERROR_CORRECT_Q,
|
|
"H": ERROR_CORRECT_H,
|
|
}
|
|
format_ec = {"L": 1, "M": 0, "Q": 3, "H": 2}
|
|
|
|
def format_bits(level: str, mask: int) -> int:
|
|
data = (format_ec[level] << 3) | mask
|
|
rem = data
|
|
for _ in range(10):
|
|
rem = (rem << 1) ^ ((rem >> 9) * 0x537)
|
|
return ((data << 10) | rem) ^ 0x5412
|
|
|
|
def read_mask(matrix, level: str) -> int:
|
|
bits = 0
|
|
for i in range(15):
|
|
if i < 6:
|
|
r, c = i, 8
|
|
elif i < 8:
|
|
r, c = i + 1, 8
|
|
elif i == 8:
|
|
r, c = 8, 7
|
|
else:
|
|
r, c = 8, 14 - i
|
|
if matrix[r][c]:
|
|
bits |= 1 << i
|
|
for mask in range(8):
|
|
if format_bits(level, mask) == bits:
|
|
return mask
|
|
raise AssertionError("no mask matched the encoded format information")
|
|
|
|
cases: list[dict] = []
|
|
for text, level in QR_CASES:
|
|
q = qrcode.QRCode(error_correction=levels[level], border=0)
|
|
q.add_data(qr_util.QRData(text, mode=qr_util.MODE_8BIT_BYTE))
|
|
q.make(fit=True)
|
|
matrix = [[bool(v) for v in row] for row in q.get_matrix()]
|
|
cases.append(
|
|
{
|
|
"text": text,
|
|
"level": level,
|
|
"version": q.version,
|
|
"size": len(matrix),
|
|
"mask": read_mask(matrix, level),
|
|
"modules": matrix,
|
|
}
|
|
)
|
|
return {
|
|
"description": "QR matrices (byte mode) from the qrcode library",
|
|
"cases": cases,
|
|
}
|
|
|
|
|
|
def gen_semantic() -> dict:
|
|
"""Reference records and rankings for the retrieval index.
|
|
|
|
Two properties are being pinned. The rankings must match so a query
|
|
answered on one implementation is answered the same way on the other. And
|
|
the records must contain no secret: the file this produces is plaintext
|
|
beside an encrypted vault, so anything indexed is readable without the
|
|
master password. The key_value case below carries a value that must not
|
|
appear anywhere in the output.
|
|
"""
|
|
import tempfile
|
|
|
|
from seedpass.core.semantic_index import SemanticIndex
|
|
|
|
entries = [
|
|
{"id": 0, "kind": "password", "label": "first-ever.example",
|
|
"username": "zero", "notes": "the very first entry a profile creates",
|
|
"tags": ["edge"]},
|
|
{"id": 1, "kind": "password", "label": "bank.example", "username": "alice",
|
|
"url": "https://bank.example", "notes": "main current account",
|
|
"tags": ["money", "daily"]},
|
|
{"id": 2, "kind": "key_value", "label": "deploy-token", "key": "DEPLOY_TOKEN",
|
|
"value": "SECRET-MUST-NOT-APPEAR", "notes": "ci pipeline", "tags": ["ops"]},
|
|
{"id": 3, "kind": "document", "label": "recovery notes",
|
|
"content": "how to recover the bank account if locked out", "tags": ["docs"]},
|
|
{"id": 4, "kind": "totp", "label": "email-2fa", "issuer": "Fastmail",
|
|
"notes": "", "tags": ["email"]},
|
|
{"id": 5, "kind": "ssh", "label": "prod-server", "fingerprint": "SHA256:abcdef",
|
|
"notes": "production access", "tags": ["ops"]},
|
|
{"id": 6, "kind": "nostr", "label": "social", "npub": "npub1example",
|
|
"notes": "", "tags": []},
|
|
{"id": 7, "kind": "seed", "label": "child seed", "notes": "not an indexed kind"},
|
|
{"id": 8, "kind": "document", "label": "", "content": "", "notes": "", "tags": []},
|
|
{"id": 9, "kind": "password", "label": "linked", "notes": "", "tags": [],
|
|
"links": [{"relation": "depends_on", "note": "see recovery notes"}]},
|
|
{"kind": "document", "label": "no id at all", "content": "skipped"},
|
|
]
|
|
queries = [
|
|
"bank",
|
|
"bank account",
|
|
"ops production",
|
|
"email",
|
|
"recover locked",
|
|
"nothing matches here",
|
|
"DEPLOY_TOKEN",
|
|
"first entry",
|
|
]
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
index = SemanticIndex(Path(tmpdir))
|
|
index.build(entries)
|
|
records = json.loads(
|
|
(Path(tmpdir) / "semantic_index" / "records.json").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
)
|
|
results = {q: index.search(q, k=10) for q in queries}
|
|
kind_filtered = index.search("ops", k=10, kind="ssh")
|
|
|
|
blob = json.dumps(records)
|
|
assert "SECRET-MUST-NOT-APPEAR" not in blob, "the index leaked a stored secret"
|
|
|
|
return {
|
|
"description": "semantic index records and rankings",
|
|
"entries": entries,
|
|
"records": records,
|
|
"queries": results,
|
|
"kind_filtered": kind_filtered,
|
|
}
|
|
|
|
|
|
def gen_high_risk() -> dict:
|
|
"""Interop vectors for the high-risk partition.
|
|
|
|
Both implementations read and write these two files, so the fixture is
|
|
what proves the format is genuinely shared rather than merely
|
|
self-consistent. A partition only one side can open would be worse than
|
|
none: the entries are SSH keys, PGP keys and seeds, and a user who cannot
|
|
open them has lost them.
|
|
|
|
The salt and partition key are fixed so the envelope is reproducible;
|
|
production draws both at random.
|
|
"""
|
|
import tempfile
|
|
|
|
from cryptography.fernet import Fernet
|
|
|
|
from seedpass.core import agent_secret_isolation as iso
|
|
from seedpass.core import high_risk_partition_store as store
|
|
|
|
factor = "second-factor-passphrase"
|
|
salt = bytes(range(16))
|
|
partition_key = base64.urlsafe_b64encode(bytes(range(32))).decode("ascii")
|
|
|
|
# Fernet embeds a random IV and the current time, so `encrypt` would emit
|
|
# different bytes on every run and the fixture-drift CI job would fail
|
|
# each time it regenerated. `_encrypt_from_parts` pins both. Production
|
|
# must never do this -- a repeated IV under one key breaks CBC -- which is
|
|
# why it appears only here, in a generator producing test vectors from a
|
|
# fixed key.
|
|
fixed_iv = bytes(range(16))
|
|
fixed_time = 1700000000
|
|
wrapping_key = iso._derive_wrapping_key(factor, salt, iso.PARTITION_KDF_ITERATIONS)
|
|
envelope = {
|
|
"version": iso.PARTITION_ENVELOPE_VERSION,
|
|
"kdf": "pbkdf2-sha256",
|
|
"iterations": iso.PARTITION_KDF_ITERATIONS,
|
|
"salt_b64": base64.b64encode(salt).decode("ascii"),
|
|
"wrapped_partition_key": Fernet(wrapping_key)
|
|
._encrypt_from_parts(partition_key.encode("utf-8"), fixed_time, fixed_iv)
|
|
.decode("utf-8"),
|
|
}
|
|
tag = iso._partition_key_tag(partition_key)
|
|
|
|
entries = {
|
|
"3": {
|
|
"kind": "ssh", "type": "ssh", "label": "prod-server", "index": 3,
|
|
"notes": "deploy", "archived": False, "modified_ts": 1700000000,
|
|
},
|
|
"5": {
|
|
"kind": "pgp", "type": "pgp", "label": "signing", "index": 5,
|
|
"notes": "", "archived": False, "modified_ts": 1700000100,
|
|
},
|
|
}
|
|
|
|
# Same reason: build the partition blob with a pinned IV and time rather
|
|
# than calling save_partition_entries, whose Fernet output is random.
|
|
payload = json.dumps(
|
|
{
|
|
"schema_version": 1,
|
|
"partition": "high_risk",
|
|
"updated_at_utc": float(fixed_time),
|
|
"entries": entries,
|
|
},
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
file_key = hashlib.sha256(tag.encode("utf-8")).digest()
|
|
blob = Fernet(base64.urlsafe_b64encode(file_key))._encrypt_from_parts(
|
|
payload, fixed_time, fixed_iv
|
|
)
|
|
# Prove the pinned construction is what the real writer/reader accept.
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
profile = Path(tmpdir) / "PROFILE"
|
|
profile.mkdir()
|
|
(profile / store.PARTITION_FILENAME).write_bytes(blob)
|
|
assert store.load_partition_entries(profile, tag) == entries
|
|
|
|
return {
|
|
"description": "high-risk partition envelope and file, for cross-impl checks",
|
|
"factor": factor,
|
|
"partition_key": partition_key,
|
|
"tag": tag,
|
|
"envelope": envelope,
|
|
"entries": entries,
|
|
"partition_file_b64": base64.b64encode(blob).decode("ascii"),
|
|
}
|
|
|
|
|
|
def gen_index0() -> dict:
|
|
"""index0/atlas reference state for the TypeScript port.
|
|
|
|
Everything in index0 is hashed with SHA-256 over Python's canonical JSON,
|
|
and the merge picks winners by comparing those hashes -- so a single byte
|
|
of encoding disagreement makes the two implementations permanently
|
|
disagree about which record wins. These vectors are compared whole, via
|
|
canonical JSON, rather than field by field.
|
|
|
|
The inputs deliberately include non-ASCII labels and tags (ensure_ascii
|
|
escaping), a non-numeric entry id (view ordering must degrade, not throw),
|
|
two writers on two days (checkpoint grouping and retention), and a managed
|
|
account (the second scope path).
|
|
"""
|
|
from seedpass.core import index0 as index0_mod
|
|
|
|
fp_dir = "/home/user/.seedpass/C557EEC878DFD852"
|
|
managed_dir = "/home/user/.seedpass/C557EEC878DFD852/accounts/AABBCCDDEEFF0011"
|
|
|
|
entries = {
|
|
"0": {"kind": "password", "label": "bank.example", "modified_ts": 1700000100,
|
|
"tags": ["money", "daily"],
|
|
"links": [{"target_id": "2", "relation": "depends_on", "note": "recovery"}]},
|
|
"1": {"kind": "totp", "label": "email-2fa", "modified_ts": 1700000200,
|
|
"archived": True},
|
|
"2": {"kind": "ssh", "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713",
|
|
"modified_ts": 1700000300, "tags": ["ops", "\u00fcn\u00ef"]},
|
|
"10": {"kind": "document", "label": "notes", "modified_ts": 1700000400},
|
|
"not-a-number": {"kind": "password", "label": "weird id",
|
|
"modified_ts": 1700000500},
|
|
}
|
|
|
|
payload = {"schema_version": 4, "entries": entries}
|
|
specs = [
|
|
(1700000100, "entry.created", "0", "password"),
|
|
(1700000200, "entry.created", "1", "totp"),
|
|
(1700086400, "entry.updated", "0", "password"),
|
|
(1700086500, "entry.archived", "1", "totp"),
|
|
]
|
|
for i, (ts, event_type, subject_id, subject_kind) in enumerate(specs):
|
|
payload = index0_mod.append_index0_event(
|
|
payload,
|
|
event_type=event_type,
|
|
subject_type="entry",
|
|
subject_id=subject_id,
|
|
subject_kind=subject_kind,
|
|
modified_ts=ts,
|
|
fingerprint_dir=fp_dir,
|
|
tags=["auto"],
|
|
summary=f"event {i} \u2014 \u00fcn\u00ef",
|
|
)
|
|
payload = index0_mod.append_index0_event(
|
|
payload,
|
|
event_type="entry.created",
|
|
subject_type="entry",
|
|
subject_id="7",
|
|
subject_kind="seed",
|
|
modified_ts=1700090000,
|
|
fingerprint_dir=managed_dir,
|
|
)
|
|
|
|
compacted = index0_mod.compact_index0_payload(payload, fingerprint_dir=fp_dir)
|
|
other = index0_mod.append_index0_event(
|
|
{"schema_version": 4, "entries": entries},
|
|
event_type="entry.created",
|
|
subject_type="entry",
|
|
subject_id="99",
|
|
subject_kind="password",
|
|
modified_ts=1700099999,
|
|
fingerprint_dir=fp_dir,
|
|
)
|
|
scope = index0_mod.derive_index0_context(fp_dir)["scope_path"]
|
|
|
|
return {
|
|
"description": "index0/atlas events, checkpoints, views and merge",
|
|
"entries": entries,
|
|
"context_root": index0_mod.derive_index0_context(fp_dir),
|
|
"context_managed": index0_mod.derive_index0_context(managed_dir),
|
|
"appended": payload,
|
|
"compacted": compacted,
|
|
"manifest_meta": index0_mod.build_manifest_index0_metadata(
|
|
payload, fingerprint_dir=fp_dir
|
|
),
|
|
"other": other,
|
|
"merged": index0_mod.merge_system_index0(
|
|
compacted["_system"]["index0"], other["_system"]["index0"]
|
|
),
|
|
"views": index0_mod.list_canonical_views(compacted["_system"]["index0"]),
|
|
"one_view": index0_mod.get_canonical_view(
|
|
compacted["_system"]["index0"],
|
|
view_type="counts_by_kind",
|
|
scope_path=scope,
|
|
),
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
FIXTURES_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
commit = subprocess.run(
|
|
["git", "rev-parse", "HEAD"], cwd=REPO, capture_output=True, text=True
|
|
).stdout.strip()
|
|
|
|
files: dict[str, dict] = {
|
|
"bip39_seeds.json": gen_bip39(),
|
|
"bip85_entropy.json": gen_bip85_entropy(),
|
|
"passwords_v1.json": gen_passwords(LEGACY_PASSWORD_GEN_VERSION),
|
|
"passwords_v2.json": gen_passwords(CURRENT_PASSWORD_GEN_VERSION),
|
|
"totp.json": gen_totp(),
|
|
"nostr_keys.json": gen_nostr_keys(),
|
|
"managed_seeds.json": gen_managed_seeds(),
|
|
"fingerprints.json": gen_fingerprints(),
|
|
"index_keys.json": gen_index_keys(),
|
|
"kdf_metadata.json": gen_kdf_metadata(),
|
|
"migrations.json": gen_migrations(),
|
|
"ssh_keys.json": gen_ssh_keys(),
|
|
"pgp_keys.json": gen_pgp_keys(),
|
|
"password_kdf.json": gen_password_kdf(),
|
|
"legacy_payloads.json": gen_legacy_payloads(),
|
|
"qr.json": gen_qr(),
|
|
"semantic.json": gen_semantic(),
|
|
"high_risk.json": gen_high_risk(),
|
|
"index0.json": gen_index0(),
|
|
}
|
|
entries_fixture, vault_fixture = gen_entries_and_vault()
|
|
files["entries_index.json"] = entries_fixture
|
|
files["vault_v3_payload.json"] = vault_fixture
|
|
files["nostr_snapshot.json"] = gen_nostr_snapshot(vault_fixture["payload_b64"])
|
|
files["portable_backup.json"] = gen_portable_backup(entries_fixture["entries"])
|
|
files["entry_secrets.json"] = gen_entry_secrets()
|
|
files["entry_mods.json"] = gen_entry_mods()
|
|
files["sync_merge.json"] = gen_sync_merge()
|
|
files["delta_replay.json"] = gen_delta_replay()
|
|
files["nostr_events.json"] = gen_nostr_events(
|
|
files["nostr_snapshot.json"], files["delta_replay.json"]
|
|
)
|
|
|
|
for name, data in files.items():
|
|
data["fixture_version"] = FIXTURE_VERSION
|
|
_write(name, data)
|
|
|
|
manifest = {
|
|
"fixture_version": FIXTURE_VERSION,
|
|
"python_commit": commit,
|
|
"generator": "scripts/generate_ts_port_fixtures.py",
|
|
"redaction_policy": (
|
|
"All fixtures derive from public BIP-39 test mnemonics "
|
|
"(abandon.../legal.../zoo...). Secret-shaped outputs are "
|
|
"generated test material, never from a live profile."
|
|
),
|
|
"determinism": (
|
|
"Byte-identical across runs on the same commit: timestamps pinned "
|
|
"to fixed_unix, vault nonce and KDF salts pinned (fixture-only)."
|
|
),
|
|
"files": sorted(files.keys()),
|
|
}
|
|
_write("manifest.json", manifest)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|