mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-29 08:52:04 +00:00
The last unported derivation, and the one the plan flagged as highest risk. It turned out to be fully reproducible: the key material is deterministic, EdDSA signatures are deterministic (RFC 8032), and the packet layout is fixed -- so the armored blocks match exactly, with no OpenPGP dependency. core/derive/pgp.ts emits the packets directly: new-format headers, the secret-key packet (unencrypted MPI plus 16-bit checksum), the user-id packet, and the self-certification signature with PGPy's exact subpacket set and order, wrapped in ASCII armor with a CRC-24 footer. Verified against PGPy across two seeds, three indices and three user-id shapes -- armored private key, armored public key, and fingerprint. RSA is deliberately unsupported: PyCryptodome's seeded prime search is not reproducible, so the TS port refuses the key type rather than silently deriving a different key. Two things worth recording from the build: the v4 fingerprint reproduces from first principles (SHA-1 over the public-key packet), which is what made byte-parity look feasible before committing to it; and the only mismatch in the first run was packet framing -- PGPy uses new-format headers where I had emitted old-format. CLI: `entry add pgp`, reveal (armored private key), `entry pgp-public`. Cross-impl phases A/B now cover a pgp entry in both directions, completing coverage of every creatable entry kind. 193 core + 47 CLI tests green; 30/30 cross-implementation checks green. Co-Authored-By: Claude Fable 5 <[email protected]>
1251 lines
43 KiB
Python
1251 lines
43 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,
|
|
},
|
|
}
|
|
|
|
# (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 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(),
|
|
}
|
|
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()
|