mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-01 18:32:05 +00:00
parity: SSH key derivation (Ed25519, PEM byte-for-byte)
- core/derive/ssh.ts: BIP-85 app 32 entropy used directly as the Ed25519 private key, serialized as PKCS#8 and SubjectPublicKeyInfo PEM. Both DER shapes are fixed for Ed25519, so they are emitted from literal prefixes rather than adding an ASN.1 dependency to the crypto path. - Output matches Python's `cryptography` byte-for-byte across two seeds and three indices, including PEM line wrapping. - CLI: `entry add ssh`, reveal (private key PEM), and `entry ssh-public` which prints the public key without touching secret material. The OpenSSH authorized_keys line is a TS-only convenience -- Python emits only PEM, which ssh(1) cannot consume directly -- and is marked as such in the compatibility matrix. - Cross-impl phases A and B now include an ssh entry in both directions. One harness fix: PEM values carry a trailing newline that the CLI capture strips, so the cross-impl comparison normalizes trailing whitespace. The unit tests still compare exactly. 170 core + 46 CLI tests green; 16/16 cross-implementation checks green. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
7407d65294
commit
1eee053ff0
11 files changed
+271
-7
No files matched your search
@@ -30,7 +30,7 @@ until a later milestone.
|
||||
| Password KDF (PBKDF2-SHA256) | P0 | green | `password_kdf.json` | NFKD+strip normalization incl. a unicode/whitespace case; 50k and 100k iterations; fingerprint-derived salt. |
|
||||
| Password KDF (Argon2id) | P0 | green | `password_kdf.json` | `@noble/hashes` argon2id (pure JS, audited family); default (t=2, m=64MiB, p=8) and light params verified. |
|
||||
| KDF metadata parsing | P0 | green | `kdf_metadata.json`, `legacy_payloads.json` | KdfConfig schema with Python-default fallbacks. |
|
||||
| SSH key derivation | P1 | todo | — | Ed25519 from BIP-85 app 32; PEM serialization parity risk. |
|
||||
| SSH key derivation | P1 | green | `ssh_keys.json` | Ed25519 from BIP-85 app 32; PKCS#8 and SubjectPublicKeyInfo PEM match Python `cryptography` byte-for-byte (fixed DER prefixes, no ASN.1 dependency). CLI: `entry add ssh`, reveal (private PEM), `entry ssh-public` (PEM or OpenSSH line — the OpenSSH format is a TS-only convenience Python does not emit). Cross-impl phases A/B include an ssh entry. |
|
||||
| PGP key derivation | P1/P2 | todo | — | Highest parity risk (PGPy serialization); import/roundtrip first. |
|
||||
| Nostr snapshot chunk/manifest model | P0 | green | `nostr_snapshot.json` | Kinds 30070/30071/30072; gzip chunking, chunk-hash verify, reassembly of Python-produced chunks, manifest JSON parse, manifest id HMAC (key_index chain). Compressed bytes are deliberately not byte-pinned (deflate encoders differ); cross-decompression verified both ways. |
|
||||
| Relay transport + sync flows | P0 | green | — (live mock relay) | RelayPool over platform WebSocket (multi-relay publish/fetch, dedupe, signature verification on ingest); publish/fetch snapshot with chunk-hash verify, delta publish/replay. End-to-end tested against an in-process NIP-01 relay incl. corrupted-chunk rejection, and at the CLI level: sync -> destroy local vault -> restore. jsdom skips transport tests (real-browser run: Milestone 6 CI). |
|
||||
@@ -51,7 +51,7 @@ until a later milestone.
|
||||
|
||||
| Environment | Status |
|
||||
|---|---|
|
||||
| Node 22 (vitest) | green — 162/162 core + 44 CLI |
|
||||
| Node 22 (vitest) | green — 170/170 core + 44 CLI |
|
||||
| jsdom browser-like env | green — 148/148 core (3 transport tests Node-only) |
|
||||
| Real Chromium/Firefox (vitest browser mode) | todo — lands with Milestone 6 web app CI |
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@ import {
|
||||
addSeedEntry,
|
||||
addManagedAccountEntry,
|
||||
addNostrKeyEntry,
|
||||
addSshKeyEntry,
|
||||
deriveSshKeyPair,
|
||||
sshPublicKeyOpenSsh,
|
||||
} from "@seedpass/core";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
@@ -395,6 +398,31 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
|
||||
await finishAdd(vault, id);
|
||||
});
|
||||
|
||||
commonAddOptions(
|
||||
add.command("ssh <label>").description("derived Ed25519 SSH key entry"),
|
||||
).action(async (label: string, o: { notes?: string; tags?: string[]; archived?: boolean }) => {
|
||||
const vault = await openFromOptions(program.opts());
|
||||
const id = addSshKeyEntry(vault.index, label, commonOpts(o));
|
||||
await finishAdd(vault, id);
|
||||
});
|
||||
|
||||
entry
|
||||
.command("ssh-public <refOrQuery>")
|
||||
.description("print an SSH entry's public key (not secret material)")
|
||||
.option("--format <fmt>", "pem or openssh", "openssh")
|
||||
.option("--comment <text>", "comment for the openssh format", "")
|
||||
.action(async (refOrQuery: string, o: { format: string; comment: string }) => {
|
||||
const vault = await openFromOptions(program.opts());
|
||||
const hit = resolveEntry(vault.index, refOrQuery);
|
||||
if (hit.entry.kind !== "ssh") throw new Error(`${hit.ref} is not an ssh entry`);
|
||||
const pair = deriveSshKeyPair(vault.mnemonic, hit.entry.index);
|
||||
io.out(
|
||||
o.format === "pem"
|
||||
? pair.publicKeyPem.trimEnd()
|
||||
: sshPublicKeyOpenSsh(pair.publicKey, o.comment || hit.entry.label),
|
||||
);
|
||||
});
|
||||
|
||||
entry
|
||||
.command("modify <refOrQuery>")
|
||||
.description("update entry fields (kind-checked); prints the new metadata")
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
totpCodeAt,
|
||||
hexToBech32,
|
||||
bytesToHex,
|
||||
deriveSshKeyPair,
|
||||
type Entry,
|
||||
type VaultIndex,
|
||||
} from "@seedpass/core";
|
||||
@@ -73,10 +74,15 @@ export function materializeSecret(
|
||||
const value = bip85.deriveMnemonic(entry.index, entry.word_count);
|
||||
return { value, descriptor: `derived mnemonic for ${entry.label}` };
|
||||
}
|
||||
case "ssh":
|
||||
case "ssh": {
|
||||
const pair = deriveSshKeyPair(mnemonic, entry.index);
|
||||
// The private key is the secret; the public key is printed alongside
|
||||
// only by `entry ssh-public`, which is not a secret-bearing command.
|
||||
return { value: pair.privateKeyPem, descriptor: `SSH private key for ${entry.label}` };
|
||||
}
|
||||
case "pgp":
|
||||
throw new Error(
|
||||
`${entry.kind} key material is not ported yet (see the compatibility matrix)`,
|
||||
"PGP key material is not ported yet (see the compatibility matrix)",
|
||||
);
|
||||
default: {
|
||||
const kind: string = (entry as { kind: string }).kind;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Deterministic SSH (Ed25519) key derivation.
|
||||
*
|
||||
* Parity target: src/seedpass/core/password_generation.py::derive_ssh_key_pair.
|
||||
* BIP-85 app 32 yields 32 bytes used directly as the Ed25519 private key;
|
||||
* Python serializes with `cryptography` as PKCS#8 (private) and
|
||||
* SubjectPublicKeyInfo (public) PEM.
|
||||
*
|
||||
* Both DER structures are fixed-shape for Ed25519, so they are emitted
|
||||
* literally rather than pulling in an ASN.1 encoder:
|
||||
*
|
||||
* PKCS#8: 30 2e 02 01 00 30 05 06 03 2b 65 70 04 22 04 20 || key(32)
|
||||
* SPKI: 30 2a 30 05 06 03 2b 65 70 03 21 00 || pubkey(32)
|
||||
*
|
||||
* (2b 65 70 is OID 1.3.101.112, id-Ed25519.)
|
||||
*/
|
||||
|
||||
import { ed25519 } from "@noble/curves/ed25519.js";
|
||||
import { base64 } from "@scure/base";
|
||||
import { Bip85 } from "./bip85.js";
|
||||
import { concatBytes } from "../util/bytes.js";
|
||||
|
||||
const PKCS8_ED25519_PREFIX = new Uint8Array([
|
||||
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22,
|
||||
0x04, 0x20,
|
||||
]);
|
||||
const SPKI_ED25519_PREFIX = new Uint8Array([
|
||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||
]);
|
||||
|
||||
/** PEM with the 64-character line wrapping OpenSSL and `cryptography` use. */
|
||||
function toPem(label: string, der: Uint8Array): string {
|
||||
const body = base64.encode(der).replace(/(.{64})/g, "$1\n").replace(/\n$/, "");
|
||||
return `-----BEGIN ${label}-----\n${body}\n-----END ${label}-----\n`;
|
||||
}
|
||||
|
||||
export interface SshKeyPair {
|
||||
privateKeyPem: string;
|
||||
publicKeyPem: string;
|
||||
/** Raw 32-byte private key (the BIP-85 entropy). */
|
||||
privateKey: Uint8Array;
|
||||
/** Raw 32-byte Ed25519 public key. */
|
||||
publicKey: Uint8Array;
|
||||
}
|
||||
|
||||
/** 32 bytes of BIP-85 entropy for an SSH key (app 32). */
|
||||
export function deriveSshEntropy(bip85: Bip85, index: number): Uint8Array {
|
||||
return bip85.deriveEntropy({ index, entropyBytes: 32, appNo: 32 });
|
||||
}
|
||||
|
||||
export function deriveSshKeyPair(mnemonic: string, index: number): SshKeyPair {
|
||||
const privateKey = deriveSshEntropy(Bip85.fromMnemonic(mnemonic), index);
|
||||
const publicKey = ed25519.getPublicKey(privateKey);
|
||||
return {
|
||||
privateKey,
|
||||
publicKey,
|
||||
privateKeyPem: toPem("PRIVATE KEY", concatBytes(PKCS8_ED25519_PREFIX, privateKey)),
|
||||
publicKeyPem: toPem("PUBLIC KEY", concatBytes(SPKI_ED25519_PREFIX, publicKey)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenSSH `authorized_keys` one-liner for the same key.
|
||||
*
|
||||
* Python does not emit this format; it is provided because a PEM Ed25519
|
||||
* key is not directly usable by ssh(1). Marked as a TS-only addition in the
|
||||
* compatibility matrix.
|
||||
*/
|
||||
export function sshPublicKeyOpenSsh(publicKey: Uint8Array, comment = ""): string {
|
||||
const type = new TextEncoder().encode("ssh-ed25519");
|
||||
const be32 = (n: number) =>
|
||||
new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
||||
const blob = concatBytes(be32(type.length), type, be32(publicKey.length), publicKey);
|
||||
const encoded = base64.encode(blob);
|
||||
return comment ? `ssh-ed25519 ${encoded} ${comment}` : `ssh-ed25519 ${encoded}`;
|
||||
}
|
||||
@@ -31,6 +31,12 @@ export {
|
||||
type NostrKeys,
|
||||
} from "./derive/nostr.js";
|
||||
export { generateFingerprint } from "./derive/fingerprint.js";
|
||||
export {
|
||||
deriveSshKeyPair,
|
||||
deriveSshEntropy,
|
||||
sshPublicKeyOpenSsh,
|
||||
type SshKeyPair,
|
||||
} from "./derive/ssh.js";
|
||||
export { deriveIndexKey, deriveIndexKeyBytes } from "./vault/indexKey.js";
|
||||
export { decryptV3, encryptV3, isV3Payload } from "./vault/aead.js";
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/** SSH key derivation parity: PEM output must match Python byte-for-byte. */
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sshKeyCases, mnemonics } from "@seedpass/test-vectors";
|
||||
import {
|
||||
deriveSshKeyPair,
|
||||
deriveSshEntropy,
|
||||
sshPublicKeyOpenSsh,
|
||||
Bip85,
|
||||
bytesToHex,
|
||||
} from "@seedpass/core";
|
||||
|
||||
describe("SSH key derivation", () => {
|
||||
it.each(sshKeyCases)("$mnemonic_id index $index", (c) => {
|
||||
const mnemonic = mnemonics[c.mnemonic_id]!;
|
||||
const pair = deriveSshKeyPair(mnemonic, c.index);
|
||||
expect(bytesToHex(pair.privateKey)).toBe(c.entropy_hex);
|
||||
expect(pair.privateKeyPem).toBe(c.private_key_pem);
|
||||
expect(pair.publicKeyPem).toBe(c.public_key_pem);
|
||||
});
|
||||
|
||||
it("uses BIP-85 app 32 for its entropy", () => {
|
||||
const c = sshKeyCases[0]!;
|
||||
const bip85 = Bip85.fromMnemonic(mnemonics[c.mnemonic_id]!);
|
||||
expect(bytesToHex(deriveSshEntropy(bip85, c.index))).toBe(c.entropy_hex);
|
||||
});
|
||||
|
||||
it("emits an OpenSSH public key line (TS-only convenience)", () => {
|
||||
const pair = deriveSshKeyPair(mnemonics["abandon12"]!, 0);
|
||||
const line = sshPublicKeyOpenSsh(pair.publicKey, "seedpass");
|
||||
expect(line).toMatch(/^ssh-ed25519 [A-Za-z0-9+/=]+ seedpass$/);
|
||||
// The blob embeds the same 32-byte key the PEM carries
|
||||
const blob = Buffer.from(line.split(" ")[1]!, "base64");
|
||||
expect(blob.subarray(blob.length - 32).toString("hex")).toBe(
|
||||
bytesToHex(pair.publicKey),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -20,12 +20,13 @@
|
||||
"passwords_v1.json",
|
||||
"passwords_v2.json",
|
||||
"portable_backup.json",
|
||||
"ssh_keys.json",
|
||||
"sync_merge.json",
|
||||
"totp.json",
|
||||
"vault_v3_payload.json"
|
||||
],
|
||||
"fixture_version": 1,
|
||||
"generator": "scripts/generate_ts_port_fixtures.py",
|
||||
"python_commit": "0f8ce35c47c8ff1a7e29dc5dc187216f1856f563",
|
||||
"python_commit": "7407d65294493ed096b6b419513984a248f8f087",
|
||||
"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."
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"entropy_hex": "6940a025903a538c7ceb1bd31c56ff0a0035f6b9b4d1f1d206c3adfcf9395c31",
|
||||
"index": 0,
|
||||
"mnemonic_id": "abandon12",
|
||||
"private_key_pem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIGlAoCWQOlOMfOsb0xxW/woANfa5tNHx0gbDrfz5OVwx\n-----END PRIVATE KEY-----\n",
|
||||
"public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAkZTLTsNAGTw+TqfXWFto1FaCK/PRyTYNWPjnNbYaDpc=\n-----END PUBLIC KEY-----\n"
|
||||
},
|
||||
{
|
||||
"entropy_hex": "551c3eafc4cc71d6dd06c966e69eb13bdf9e21ab0589c98ed9486d7932a3fbb5",
|
||||
"index": 1,
|
||||
"mnemonic_id": "abandon12",
|
||||
"private_key_pem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIFUcPq/EzHHW3QbJZuaesTvfniGrBYnJjtlIbXkyo/u1\n-----END PRIVATE KEY-----\n",
|
||||
"public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAp4TvNWJo6d+IQ37EREuQ4jkETh7c5Uc6LikXtkueqTA=\n-----END PUBLIC KEY-----\n"
|
||||
},
|
||||
{
|
||||
"entropy_hex": "cd913b7952a5018b746ee10b232055156fbf2f344eb20a66ec19bf10af679881",
|
||||
"index": 7,
|
||||
"mnemonic_id": "abandon12",
|
||||
"private_key_pem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIM2RO3lSpQGLdG7hCyMgVRVvvy80TrIKZuwZvxCvZ5iB\n-----END PRIVATE KEY-----\n",
|
||||
"public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAucdMbY9nhDIXqCy/UmQSpPOS86xTw8JmdcPx/al1S+U=\n-----END PUBLIC KEY-----\n"
|
||||
},
|
||||
{
|
||||
"entropy_hex": "bae225aeb4cb24379c9993a84bd54da07763fbb0efd2e541211b791ae29cc6c6",
|
||||
"index": 0,
|
||||
"mnemonic_id": "zoo24",
|
||||
"private_key_pem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEILriJa60yyQ3nJmTqEvVTaB3Y/uw79LlQSEbeRrinMbG\n-----END PRIVATE KEY-----\n",
|
||||
"public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAx1kImQEAloaiNQruTKiotGmAVg3FzYEOp1Y/l6uPdvI=\n-----END PUBLIC KEY-----\n"
|
||||
},
|
||||
{
|
||||
"entropy_hex": "9ade061f2a5ee6ec10ff5a49ea01c4e5a452b9046421ed5d3bb318b3869af16c",
|
||||
"index": 1,
|
||||
"mnemonic_id": "zoo24",
|
||||
"private_key_pem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIJreBh8qXubsEP9aSeoBxOWkUrkEZCHtXTuzGLOGmvFs\n-----END PRIVATE KEY-----\n",
|
||||
"public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAodtezCRKKgTztQs16oiVNhXFHxnBm25VaOUFqqzNjmM=\n-----END PUBLIC KEY-----\n"
|
||||
},
|
||||
{
|
||||
"entropy_hex": "c76113209dfadd01542977cad391f58ef4a36fb44c0004eac32f1deecc291b87",
|
||||
"index": 7,
|
||||
"mnemonic_id": "zoo24",
|
||||
"private_key_pem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIMdhEyCd+t0BVCl3ytOR9Y70o2+0TAAE6sMvHe7MKRuH\n-----END PRIVATE KEY-----\n",
|
||||
"public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAYpwMrnTUCa1NpM3bD3SjnYvUdxHQsqZvtvl3K4KTcLk=\n-----END PUBLIC KEY-----\n"
|
||||
}
|
||||
],
|
||||
"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.",
|
||||
"fixture_version": 1
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import entrySecretsJson from "../fixtures/entry_secrets.json";
|
||||
import nostrEventsJson from "../fixtures/nostr_events.json";
|
||||
import entryModsJson from "../fixtures/entry_mods.json";
|
||||
import migrationsJson from "../fixtures/migrations.json";
|
||||
import sshKeysJson from "../fixtures/ssh_keys.json";
|
||||
|
||||
export interface Bip39Case {
|
||||
id: string;
|
||||
@@ -233,6 +234,16 @@ export const nostrEvents = nostrEventsJson as {
|
||||
events: { name: string; event: SignedNostrEvent }[];
|
||||
};
|
||||
|
||||
export interface SshKeyCase {
|
||||
mnemonic_id: string;
|
||||
index: number;
|
||||
entropy_hex: string;
|
||||
private_key_pem: string;
|
||||
public_key_pem: string;
|
||||
}
|
||||
|
||||
export const sshKeyCases = (sshKeysJson as { cases: SshKeyCase[] }).cases;
|
||||
|
||||
export interface MigrationCase {
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
|
||||
@@ -156,6 +156,7 @@ def py_create_profile(app_dir: Path, seed: str, password: str) -> str:
|
||||
em.add_totp("python-totp", seed, deterministic=True)
|
||||
em.add_key_value("python-api", "token", "py-secret-value")
|
||||
em.add_seed("python-cold-seed", seed, words_num=24)
|
||||
em.add_ssh_key("python-ssh", seed)
|
||||
|
||||
mgr = FingerprintManager(app_dir)
|
||||
if fp not in mgr.fingerprints:
|
||||
@@ -233,6 +234,11 @@ def py_secrets_for(index: dict, seed: str) -> dict[str, str]:
|
||||
)
|
||||
elif kind == "managed_account":
|
||||
out[label] = derive_seed_phrase(bip85, int(entry.get("index", int(idx))), 12)
|
||||
elif kind == "ssh":
|
||||
from seedpass.core.password_generation import derive_ssh_key_pair
|
||||
|
||||
priv, _pub = derive_ssh_key_pair(seed, int(entry.get("index", int(idx))))
|
||||
out[label] = priv
|
||||
elif kind == "nostr":
|
||||
entropy = bip85.derive_entropy(
|
||||
index=int(entry.get("index", int(idx))), entropy_bytes=32
|
||||
@@ -282,7 +288,13 @@ def phase_a(tmp: Path) -> None:
|
||||
|
||||
py_secrets = py_secrets_for(py_index, SEED_A)
|
||||
ts_secrets = ts_secrets_for(app_dir, list(py_secrets), SEED_A)
|
||||
mismatches = [k for k in py_secrets if py_secrets[k] != ts_secrets.get(k)]
|
||||
# PEM values carry a trailing newline that the CLI capture strips; compare
|
||||
# on trailing whitespace-insensitive values.
|
||||
mismatches = [
|
||||
k
|
||||
for k in py_secrets
|
||||
if py_secrets[k].rstrip("\n") != (ts_secrets.get(k) or "").rstrip("\n")
|
||||
]
|
||||
check(
|
||||
"every secret derives identically",
|
||||
not mismatches,
|
||||
@@ -322,6 +334,7 @@ def phase_b(tmp: Path) -> None:
|
||||
run_cli(app_dir, "entry", "add", "totp", "ts-totp", env_extra=env)
|
||||
run_cli(app_dir, "entry", "add", "key-value", "ts-api", "token", "ts-secret-value", env_extra=env)
|
||||
run_cli(app_dir, "entry", "add", "managed-account", "ts-managed", env_extra=env)
|
||||
run_cli(app_dir, "entry", "add", "ssh", "ts-ssh", env_extra=env)
|
||||
|
||||
from utils.fingerprint import generate_fingerprint
|
||||
|
||||
@@ -337,7 +350,13 @@ def phase_b(tmp: Path) -> None:
|
||||
|
||||
py_secrets = py_secrets_for(py_index, SEED_B)
|
||||
ts_secrets = ts_secrets_for(app_dir, list(py_secrets), SEED_B)
|
||||
mismatches = [k for k in py_secrets if py_secrets[k] != ts_secrets.get(k)]
|
||||
# PEM values carry a trailing newline that the CLI capture strips; compare
|
||||
# on trailing whitespace-insensitive values.
|
||||
mismatches = [
|
||||
k
|
||||
for k in py_secrets
|
||||
if py_secrets[k].rstrip("\n") != (ts_secrets.get(k) or "").rstrip("\n")
|
||||
]
|
||||
check("every secret derives identically", not mismatches, f"mismatched: {mismatches}")
|
||||
|
||||
# Python's schema validation should accept the TS-written index
|
||||
|
||||
@@ -1103,6 +1103,36 @@ def gen_migrations() -> dict:
|
||||
}
|
||||
|
||||
|
||||
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_kdf_metadata() -> dict:
|
||||
return {
|
||||
"description": (
|
||||
@@ -1146,6 +1176,7 @@ def main() -> None:
|
||||
"index_keys.json": gen_index_keys(),
|
||||
"kdf_metadata.json": gen_kdf_metadata(),
|
||||
"migrations.json": gen_migrations(),
|
||||
"ssh_keys.json": gen_ssh_keys(),
|
||||
"password_kdf.json": gen_password_kdf(),
|
||||
"legacy_payloads.json": gen_legacy_payloads(),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user