mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-28 08:22:03 +00:00
The Tests workflow was red on every OS and Python version for one reason:
`black --check` found 28 unformatted files. Part predates this branch (the
tui_v3 screens, core/api.py, core/index0.py); part is mine from this session.
Formatting only — verified rather than assumed, because a formatter touching
363 files is exactly where an unnoticed behaviour change would hide:
- 1223 Python tests pass, unchanged
- regenerating the fixtures after reformatting the generator produces
byte-identical output, so no hashed value moved
- differential fuzz: no divergences
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
1741 lines
58 KiB
Python
1741 lines
58 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()
|