mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-31 18:02:05 +00:00
parity: PGP key derivation, byte-for-byte with PGPy
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]>
This commit is contained in:
1 parent
5dcd779a6f
commit
c2d7306bb7
12 files changed
+457
-13
No files matched your search
@@ -17,7 +17,7 @@ P0 gate is green, Python remains the normative reference
|
||||
| Cross-implementation profile interop | green |
|
||||
| Sync protocol parity | green |
|
||||
| Agent security model | green (TS ahead of Python) |
|
||||
| Feature parity for daily use | partial |
|
||||
| Feature parity for daily use | partial (all derivations green) |
|
||||
| Packaging + release | not started |
|
||||
| Migration + rollback story | not started |
|
||||
|
||||
@@ -47,14 +47,15 @@ P0 gate is green, Python remains the normative reference
|
||||
and the TS CLI restores it with secrets intact. **Green.**
|
||||
|
||||
5. **Feature parity for real daily use.** **Partial.** Remaining:
|
||||
- PGP key material (entries round-trip; derivation not ported — highest
|
||||
parity risk, PGPy serialization)
|
||||
- document import/export commands
|
||||
- `semantic` and `api` command groups (deliberately deferred; decide
|
||||
whether they block cutover or ship post-cutover)
|
||||
- PGP RSA keys: unsupported by design (not byte-reproducible). Decide
|
||||
whether existing RSA entries block cutover; ed25519 is at parity.
|
||||
|
||||
Done since this list was written: SSH key derivation (PEM byte-for-byte)
|
||||
and schema migrations 0→4, so legacy Python profiles now open in TS.
|
||||
Done since this list was written: SSH and PGP key derivation (both
|
||||
byte-for-byte) and schema migrations 0→4, so legacy Python profiles now
|
||||
open in TS.
|
||||
|
||||
6. **Packaging and distribution exist.** Not started. Needs: an installable
|
||||
artifact (npm bin, single-file build, or both), checksums and signatures
|
||||
|
||||
@@ -31,7 +31,7 @@ until a later milestone.
|
||||
| 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 | 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. |
|
||||
| PGP key derivation | P1/P2 | green | `pgp_keys.json` | Ed25519/EdDSA from BIP-85 app 32, creation time pinned to 2000-01-01Z. OpenPGP packets are emitted directly (no OpenPGP dependency): new-format headers, secret-key/user-id/self-signature packets, ASCII armor with CRC-24. Armored private and public blocks match PGPy byte-for-byte across two seeds, three indices and three user-id shapes. **RSA is deliberately unsupported** — PyCryptodome's seeded prime search is not reproducible, so TS refuses rather than emitting a different key. CLI: `entry add pgp`, reveal, `entry pgp-public`. |
|
||||
| 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). |
|
||||
| Deterministic conflict merge | P0 | green | `sync_merge.json` | Full `merge_index_payloads` port: ts precedence, canonical-hash tie-breaks (Python-compatible ensure_ascii canonical JSON), equal-ts field union incl. the `custom_fields: null` quirk, tombstone lifecycle, retention cap, `_sync_meta`. |
|
||||
@@ -51,7 +51,7 @@ until a later milestone.
|
||||
|
||||
| Environment | Status |
|
||||
|---|---|
|
||||
| Node 22 (vitest) | green — 173/173 core + 44 CLI |
|
||||
| Node 22 (vitest) | green — 193/193 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 |
|
||||
|
||||
@@ -83,7 +83,7 @@ the same values:
|
||||
|
||||
Entry coverage in phases A/B: password (plain and policy-constrained), TOTP
|
||||
(deterministic and imported), key_value, document, seed, managed_account,
|
||||
nostr, ssh.
|
||||
nostr, ssh, pgp — every creatable kind.
|
||||
|
||||
## Known intentional divergences
|
||||
|
||||
|
||||
@@ -52,7 +52,9 @@ import {
|
||||
addManagedAccountEntry,
|
||||
addNostrKeyEntry,
|
||||
addSshKeyEntry,
|
||||
addPgpKeyEntry,
|
||||
deriveSshKeyPair,
|
||||
derivePgpKey,
|
||||
sshPublicKeyOpenSsh,
|
||||
} from "@seedpass/core";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
@@ -407,6 +409,40 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
|
||||
await finishAdd(vault, id);
|
||||
});
|
||||
|
||||
commonAddOptions(
|
||||
add
|
||||
.command("pgp <label>")
|
||||
.description("derived Ed25519 PGP key entry")
|
||||
.option("--user-id <uid>", "PGP user id", ""),
|
||||
).action(
|
||||
async (
|
||||
label: string,
|
||||
o: { userId: string; notes?: string; tags?: string[]; archived?: boolean },
|
||||
) => {
|
||||
const vault = await openFromOptions(program.opts());
|
||||
const id = addPgpKeyEntry(vault.index, label, {
|
||||
...commonOpts(o),
|
||||
userId: o.userId,
|
||||
});
|
||||
await finishAdd(vault, id);
|
||||
},
|
||||
);
|
||||
|
||||
entry
|
||||
.command("pgp-public <refOrQuery>")
|
||||
.description("print a PGP entry's public key block (not secret material)")
|
||||
.action(async (refOrQuery: string) => {
|
||||
const vault = await openFromOptions(program.opts());
|
||||
const hit = resolveEntry(vault.index, refOrQuery);
|
||||
if (hit.entry.kind !== "pgp") throw new Error(`${hit.ref} is not a pgp entry`);
|
||||
const key = derivePgpKey(vault.mnemonic, hit.entry.index, {
|
||||
userId: hit.entry.user_id,
|
||||
keyType: hit.entry.key_type,
|
||||
});
|
||||
io.out(JSON.stringify({ fingerprint: key.fingerprint }));
|
||||
io.out(key.publicKeyArmored.trimEnd());
|
||||
});
|
||||
|
||||
entry
|
||||
.command("ssh-public <refOrQuery>")
|
||||
.description("print an SSH entry's public key (not secret material)")
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
hexToBech32,
|
||||
bytesToHex,
|
||||
deriveSshKeyPair,
|
||||
derivePgpKey,
|
||||
type Entry,
|
||||
type VaultIndex,
|
||||
} from "@seedpass/core";
|
||||
@@ -82,10 +83,16 @@ export function materializeSecret(
|
||||
// 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(
|
||||
"PGP key material is not ported yet (see the compatibility matrix)",
|
||||
);
|
||||
case "pgp": {
|
||||
const key = derivePgpKey(mnemonic, entry.index, {
|
||||
userId: entry.user_id,
|
||||
keyType: entry.key_type,
|
||||
});
|
||||
return {
|
||||
value: key.privateKeyArmored,
|
||||
descriptor: `PGP private key for ${entry.label}`,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
const kind: string = (entry as { kind: string }).kind;
|
||||
throw new Error(`unsupported entry kind: ${kind}`);
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Deterministic PGP (Ed25519/EdDSA) key derivation.
|
||||
*
|
||||
* Parity target: src/seedpass/core/password_generation.py::derive_pgp_key,
|
||||
* which builds a PGPy key from BIP-85 app-32 entropy with a pinned creation
|
||||
* time (2000-01-01T00:00:00Z).
|
||||
*
|
||||
* Everything here is byte-reproducible: the key material is deterministic,
|
||||
* EdDSA signatures are deterministic (RFC 8032), and the packet layout is
|
||||
* fixed. That makes byte-for-byte parity with PGPy achievable without an
|
||||
* OpenPGP dependency — the packets are emitted directly.
|
||||
*
|
||||
* Only the ed25519 key type is ported. Python also supports RSA via a
|
||||
* seeded DRNG; RSA keygen is not reproducible without reimplementing
|
||||
* PyCryptodome's prime search, so it is deliberately unsupported here
|
||||
* rather than silently producing a different key.
|
||||
*/
|
||||
|
||||
import { ed25519 } from "@noble/curves/ed25519.js";
|
||||
import { sha1 } from "@noble/hashes/legacy.js";
|
||||
import { sha256 } from "@noble/hashes/sha2.js";
|
||||
import { base64 } from "@scure/base";
|
||||
import { Bip85 } from "./bip85.js";
|
||||
import { bytesToHex, concatBytes, utf8 } from "../util/bytes.js";
|
||||
|
||||
/** PGPy pins created to 2000-01-01T00:00:00Z. */
|
||||
export const PGP_CREATED_AT = 946684800;
|
||||
|
||||
const ALGO_EDDSA = 22;
|
||||
const HASH_SHA256 = 8;
|
||||
const SIGTYPE_POSITIVE_CERT = 0x13;
|
||||
/** OID 1.3.6.1.4.1.11591.15.1 (Ed25519). */
|
||||
const ED25519_OID = new Uint8Array([0x2b, 0x06, 0x01, 0x04, 0x01, 0xda, 0x47, 0x0f, 0x01]);
|
||||
|
||||
function be16(n: number): Uint8Array {
|
||||
return new Uint8Array([(n >> 8) & 0xff, n & 0xff]);
|
||||
}
|
||||
|
||||
function be32(n: number): Uint8Array {
|
||||
return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
||||
}
|
||||
|
||||
/** OpenPGP MPI: 2-byte bit length, then the minimal big-endian bytes. */
|
||||
function mpi(value: Uint8Array): Uint8Array {
|
||||
let start = 0;
|
||||
while (start < value.length && value[start] === 0) start++;
|
||||
const trimmed = value.slice(start);
|
||||
if (trimmed.length === 0) return be16(0);
|
||||
const first = trimmed[0]!;
|
||||
let bits = (trimmed.length - 1) * 8;
|
||||
for (let b = 7; b >= 0; b--) {
|
||||
if (first & (1 << b)) {
|
||||
bits += b + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return concatBytes(be16(bits), trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* New-format packet header (RFC 4880 §4.2.2), which is what PGPy emits.
|
||||
* Lengths below 192 take one byte, below 8384 two, otherwise a five-byte
|
||||
* form introduced by 0xff.
|
||||
*/
|
||||
function packet(tag: number, body: Uint8Array): Uint8Array {
|
||||
const header = new Uint8Array([0xc0 | tag]);
|
||||
const n = body.length;
|
||||
if (n < 192) {
|
||||
return concatBytes(header, new Uint8Array([n]), body);
|
||||
}
|
||||
if (n < 8384) {
|
||||
const offset = n - 192;
|
||||
return concatBytes(
|
||||
header,
|
||||
new Uint8Array([(offset >> 8) + 192, offset & 0xff]),
|
||||
body,
|
||||
);
|
||||
}
|
||||
return concatBytes(header, new Uint8Array([0xff]), be32(n), body);
|
||||
}
|
||||
|
||||
function subpacket(type: number, data: Uint8Array): Uint8Array {
|
||||
return concatBytes(new Uint8Array([data.length + 1, type]), data);
|
||||
}
|
||||
|
||||
/** Public-key packet body: shared by the key packet and the fingerprint. */
|
||||
function publicKeyBody(publicKey: Uint8Array, createdAt: number): Uint8Array {
|
||||
const point = concatBytes(new Uint8Array([0x40]), publicKey);
|
||||
return concatBytes(
|
||||
new Uint8Array([4]),
|
||||
be32(createdAt),
|
||||
new Uint8Array([ALGO_EDDSA, ED25519_OID.length]),
|
||||
ED25519_OID,
|
||||
mpi(point),
|
||||
);
|
||||
}
|
||||
|
||||
/** v4 fingerprint: SHA-1 over 0x99 || len16 || public-key packet body. */
|
||||
export function pgpFingerprint(publicKey: Uint8Array, createdAt = PGP_CREATED_AT): string {
|
||||
const body = publicKeyBody(publicKey, createdAt);
|
||||
const digest = sha1(concatBytes(new Uint8Array([0x99]), be16(body.length), body));
|
||||
return bytesToHex(digest).toUpperCase();
|
||||
}
|
||||
|
||||
function secretKeyBody(
|
||||
privateKey: Uint8Array,
|
||||
publicKey: Uint8Array,
|
||||
createdAt: number,
|
||||
): Uint8Array {
|
||||
const secretMpi = mpi(privateKey);
|
||||
// Unencrypted secret material carries a 16-bit sum of the MPI bytes.
|
||||
let checksum = 0;
|
||||
for (const b of secretMpi) checksum = (checksum + b) & 0xffff;
|
||||
return concatBytes(
|
||||
publicKeyBody(publicKey, createdAt),
|
||||
new Uint8Array([0x00]), // s2k usage: unencrypted
|
||||
secretMpi,
|
||||
be16(checksum),
|
||||
);
|
||||
}
|
||||
|
||||
/** CRC-24 over the packet stream, as required by the ASCII armor footer. */
|
||||
function crc24(data: Uint8Array): number {
|
||||
let crc = 0xb704ce;
|
||||
for (const byte of data) {
|
||||
crc ^= byte << 16;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
crc <<= 1;
|
||||
if (crc & 0x1000000) crc ^= 0x1864cfb;
|
||||
}
|
||||
}
|
||||
return crc & 0xffffff;
|
||||
}
|
||||
|
||||
function armor(label: string, data: Uint8Array): string {
|
||||
const body = base64.encode(data).replace(/(.{64})/g, "$1\n").replace(/\n$/, "");
|
||||
const checksum = base64.encode(
|
||||
new Uint8Array([(crc24(data) >> 16) & 0xff, (crc24(data) >> 8) & 0xff, crc24(data) & 0xff]),
|
||||
);
|
||||
return `-----BEGIN PGP ${label}-----\n\n${body}\n=${checksum}\n-----END PGP ${label}-----\n`;
|
||||
}
|
||||
|
||||
export interface PgpKeyPair {
|
||||
privateKeyArmored: string;
|
||||
publicKeyArmored: string;
|
||||
fingerprint: string;
|
||||
privateKey: Uint8Array;
|
||||
publicKey: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a deterministic PGP key pair.
|
||||
*
|
||||
* `keyType` accepts only "ed25519"; see the module note on RSA.
|
||||
*/
|
||||
export function derivePgpKey(
|
||||
mnemonic: string,
|
||||
index: number,
|
||||
options: { userId?: string; keyType?: string; createdAt?: number } = {},
|
||||
): PgpKeyPair {
|
||||
const keyType = (options.keyType ?? "ed25519").toLowerCase();
|
||||
if (keyType !== "ed25519") {
|
||||
throw new Error(
|
||||
`PGP key type "${keyType}" is not supported by the TypeScript port ` +
|
||||
`(only ed25519; RSA generation is not byte-reproducible)`,
|
||||
);
|
||||
}
|
||||
const createdAt = options.createdAt ?? PGP_CREATED_AT;
|
||||
const userId = options.userId ?? "";
|
||||
|
||||
const privateKey = Bip85.fromMnemonic(mnemonic).deriveEntropy({
|
||||
index,
|
||||
entropyBytes: 32,
|
||||
appNo: 32,
|
||||
});
|
||||
const publicKey = ed25519.getPublicKey(privateKey);
|
||||
|
||||
const pubBody = publicKeyBody(publicKey, createdAt);
|
||||
const secBody = secretKeyBody(privateKey, publicKey, createdAt);
|
||||
const fingerprint = pgpFingerprint(publicKey, createdAt);
|
||||
const fpBytes = new Uint8Array(
|
||||
(fingerprint.match(/../g) ?? []).map((h) => parseInt(h, 16)),
|
||||
);
|
||||
const keyId = fpBytes.slice(12);
|
||||
|
||||
// Self-certification over the primary key and user id. Subpacket set and
|
||||
// order mirror what PGPy emits for these usage/preference arguments.
|
||||
const hashedSubpackets = concatBytes(
|
||||
subpacket(2, be32(createdAt)), // creation time
|
||||
subpacket(27, new Uint8Array([0x0e])), // key flags: sign + encrypt comms + storage
|
||||
subpacket(11, new Uint8Array([9])), // preferred symmetric: AES256
|
||||
subpacket(21, new Uint8Array([HASH_SHA256])), // preferred hash: SHA256
|
||||
subpacket(22, new Uint8Array([2])), // preferred compression: ZLIB
|
||||
subpacket(30, new Uint8Array([1])), // features: MDC
|
||||
subpacket(33, concatBytes(new Uint8Array([4]), fpBytes)), // issuer fingerprint
|
||||
);
|
||||
const unhashedSubpackets = subpacket(16, keyId); // issuer key id
|
||||
|
||||
const sigHeader = concatBytes(
|
||||
new Uint8Array([4, SIGTYPE_POSITIVE_CERT, ALGO_EDDSA, HASH_SHA256]),
|
||||
be16(hashedSubpackets.length),
|
||||
hashedSubpackets,
|
||||
);
|
||||
|
||||
const uidBytes = utf8(userId);
|
||||
const toHash = concatBytes(
|
||||
new Uint8Array([0x99]),
|
||||
be16(pubBody.length),
|
||||
pubBody,
|
||||
new Uint8Array([0xb4]),
|
||||
be32(uidBytes.length),
|
||||
uidBytes,
|
||||
sigHeader,
|
||||
new Uint8Array([0x04, 0xff]),
|
||||
be32(sigHeader.length),
|
||||
);
|
||||
const digest = sha256(toHash);
|
||||
const signature = ed25519.sign(digest, privateKey);
|
||||
|
||||
const sigBody = concatBytes(
|
||||
sigHeader,
|
||||
be16(unhashedSubpackets.length),
|
||||
unhashedSubpackets,
|
||||
digest.slice(0, 2),
|
||||
mpi(signature.slice(0, 32)),
|
||||
mpi(signature.slice(32, 64)),
|
||||
);
|
||||
|
||||
const uidPacket = packet(13, uidBytes);
|
||||
const sigPacket = packet(2, sigBody);
|
||||
const privateStream = concatBytes(packet(5, secBody), uidPacket, sigPacket);
|
||||
const publicStream = concatBytes(packet(6, pubBody), uidPacket, sigPacket);
|
||||
|
||||
return {
|
||||
privateKeyArmored: armor("PRIVATE KEY BLOCK", privateStream),
|
||||
publicKeyArmored: armor("PUBLIC KEY BLOCK", publicStream),
|
||||
fingerprint,
|
||||
privateKey,
|
||||
publicKey,
|
||||
};
|
||||
}
|
||||
@@ -32,6 +32,12 @@ export {
|
||||
type NostrKeys,
|
||||
} from "./derive/nostr.js";
|
||||
export { generateFingerprint } from "./derive/fingerprint.js";
|
||||
export {
|
||||
derivePgpKey,
|
||||
pgpFingerprint,
|
||||
PGP_CREATED_AT,
|
||||
type PgpKeyPair,
|
||||
} from "./derive/pgp.js";
|
||||
export {
|
||||
deriveSshKeyPair,
|
||||
deriveSshEntropy,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* PGP key derivation parity against PGPy's output.
|
||||
*
|
||||
* The key material is deterministic and EdDSA signatures are deterministic,
|
||||
* so the armored blocks are expected to match byte-for-byte.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pgpKeyCases, pgpCreatedAt, mnemonics } from "@seedpass/test-vectors";
|
||||
import { derivePgpKey, pgpFingerprint, PGP_CREATED_AT } from "@seedpass/core";
|
||||
|
||||
describe("PGP key derivation", () => {
|
||||
it("agrees with Python on the pinned creation time", () => {
|
||||
expect(PGP_CREATED_AT).toBe(pgpCreatedAt);
|
||||
});
|
||||
|
||||
it.each(pgpKeyCases)("$mnemonic_id index $index fingerprint", (c) => {
|
||||
const pair = derivePgpKey(mnemonics[c.mnemonic_id]!, c.index, { userId: c.user_id });
|
||||
expect(pair.fingerprint).toBe(c.fingerprint);
|
||||
expect(pgpFingerprint(pair.publicKey)).toBe(c.fingerprint);
|
||||
});
|
||||
|
||||
it.each(pgpKeyCases)("$mnemonic_id index $index armored private key", (c) => {
|
||||
const pair = derivePgpKey(mnemonics[c.mnemonic_id]!, c.index, { userId: c.user_id });
|
||||
expect(pair.privateKeyArmored).toBe(c.private_key_armored);
|
||||
});
|
||||
|
||||
it.each(pgpKeyCases)("$mnemonic_id index $index armored public key", (c) => {
|
||||
const pair = derivePgpKey(mnemonics[c.mnemonic_id]!, c.index, { userId: c.user_id });
|
||||
expect(pair.publicKeyArmored).toBe(c.public_key_armored);
|
||||
});
|
||||
|
||||
it("refuses unsupported key types rather than deriving a different key", () => {
|
||||
expect(() =>
|
||||
derivePgpKey(mnemonics["abandon12"]!, 0, { keyType: "rsa" }),
|
||||
).toThrow(/not supported/);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@
|
||||
"password_kdf.json",
|
||||
"passwords_v1.json",
|
||||
"passwords_v2.json",
|
||||
"pgp_keys.json",
|
||||
"portable_backup.json",
|
||||
"ssh_keys.json",
|
||||
"sync_merge.json",
|
||||
@@ -27,6 +28,6 @@
|
||||
],
|
||||
"fixture_version": 1,
|
||||
"generator": "scripts/generate_ts_port_fixtures.py",
|
||||
"python_commit": "b4f20d417a678a3b26ef173167a44c961f0b6c6c",
|
||||
"python_commit": "5dcd779a6f9ce93e0065a974b896e7e2c2b170a0",
|
||||
"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,55 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"fingerprint": "A1FD9970389E8B5DD9F32FC5690C0D46AC5AC415",
|
||||
"index": 0,
|
||||
"mnemonic_id": "abandon12",
|
||||
"private_key_armored": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\nxVgEOG1DgBYJKwYBBAHaRw8BAQdAkZTLTsNAGTw+TqfXWFto1FaCK/PRyTYNWPjn\nNbYaDpcAAP9pQKAlkDpTjHzrG9McVv8KADX2ubTR8dIGw638+TlcMRFDzRNmaXh0\ndXJlQGV4YW1wbGUuY29twoQEExYIACwFAjhtQ4ACGw4CCwkCFQgCFgICHgEWIQSh\n/ZlwOJ6LXdnzL8VpDA1GrFrEFQAKCRBpDA1GrFrEFe9mAP9IEU0fQzBTW02Q3hLI\ntZmxACnMcSrYOonKRDalHeVYLAD+P6HW8hh6MScyxx6wFSELuIdu8bZrZJeLHXCd\nYrWyHQk=\n=4wML\n-----END PGP PRIVATE KEY BLOCK-----\n",
|
||||
"public_key_armored": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxjMEOG1DgBYJKwYBBAHaRw8BAQdAkZTLTsNAGTw+TqfXWFto1FaCK/PRyTYNWPjn\nNbYaDpfNE2ZpeHR1cmVAZXhhbXBsZS5jb23ChAQTFggALAUCOG1DgAIbDgILCQIV\nCAIWAgIeARYhBKH9mXA4notd2fMvxWkMDUasWsQVAAoJEGkMDUasWsQV72YA/0gR\nTR9DMFNbTZDeEsi1mbEAKcxxKtg6icpENqUd5VgsAP4/odbyGHoxJzLHHrAVIQu4\nh27xtmtkl4sdcJ1itbIdCQ==\n=ouu/\n-----END PGP PUBLIC KEY BLOCK-----\n",
|
||||
"user_id": "[email protected]"
|
||||
},
|
||||
{
|
||||
"fingerprint": "56A49AC503F357A039E66FBAFC4E95D1D939DB41",
|
||||
"index": 3,
|
||||
"mnemonic_id": "abandon12",
|
||||
"private_key_armored": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\nxVgEOG1DgBYJKwYBBAHaRw8BAQdAlusLgX7VuAW6RER7CGb3kcGuq+60/aYu1Z9X\nxegaGvgAAQDnLfInZCMHVXnP62Qm8j+pWeUj4fHalHAdGPUW7icwUw+RzQDChAQT\nFggALAUCOG1DgAIbDgILCQIVCAIWAgIeARYhBFakmsUD81egOeZvuvxOldHZOdtB\nAAoJEPxOldHZOdtB/LgBANMy16X21C7eXQP4Gb+1Uq2vAzyCiftyoL+vN03VxuYD\nAQCKUMr/KfTrrkiPzXWYrRIpd1Vz2e4fuaaHsUSRs/SpCw==\n=TVns\n-----END PGP PRIVATE KEY BLOCK-----\n",
|
||||
"public_key_armored": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxjMEOG1DgBYJKwYBBAHaRw8BAQdAlusLgX7VuAW6RER7CGb3kcGuq+60/aYu1Z9X\nxegaGvjNAMKEBBMWCAAsBQI4bUOAAhsOAgsJAhUIAhYCAh4BFiEEVqSaxQPzV6A5\n5m+6/E6V0dk520EACgkQ/E6V0dk520H8uAEA0zLXpfbULt5dA/gZv7VSra8DPIKJ\n+3Kgv683TdXG5gMBAIpQyv8p9OuuSI/NdZitEil3VXPZ7h+5poexRJGz9KkL\n=kO2i\n-----END PGP PUBLIC KEY BLOCK-----\n",
|
||||
"user_id": ""
|
||||
},
|
||||
{
|
||||
"fingerprint": "D90DFDCD71D3EDA25D2988034274C4BD6C0BE425",
|
||||
"index": 7,
|
||||
"mnemonic_id": "abandon12",
|
||||
"private_key_armored": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\nxVgEOG1DgBYJKwYBBAHaRw8BAQdAucdMbY9nhDIXqCy/UmQSpPOS86xTw8JmdcPx\n/al1S+UAAQDNkTt5UqUBi3Ru4QsjIFUVb78vNE6yCmbsGb8Qr2eYgQ0VzRJUZXN0\nIFVzZXIgPHRAZS5jbz7ChAQTFggALAUCOG1DgAIbDgILCQIVCAIWAgIeARYhBNkN\n/c1x0+2iXSmIA0J0xL1sC+QlAAoJEEJ0xL1sC+QlE9IA/jyKLvnR0eVWFPnur95Z\nendZ/SgeVk1SZf/7bsCZPErtAP981xFy0RxUN22P8YmMegcacHmNjjK/aE4dgLnz\n1Yo0CA==\n=8KEz\n-----END PGP PRIVATE KEY BLOCK-----\n",
|
||||
"public_key_armored": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxjMEOG1DgBYJKwYBBAHaRw8BAQdAucdMbY9nhDIXqCy/UmQSpPOS86xTw8JmdcPx\n/al1S+XNElRlc3QgVXNlciA8dEBlLmNvPsKEBBMWCAAsBQI4bUOAAhsOAgsJAhUI\nAhYCAh4BFiEE2Q39zXHT7aJdKYgDQnTEvWwL5CUACgkQQnTEvWwL5CUT0gD+PIou\n+dHR5VYU+e6v3ll6d1n9KB5WTVJl//tuwJk8Su0A/3zXEXLRHFQ3bY/xiYx6Bxpw\neY2OMr9oTh2AufPVijQI\n=2pjl\n-----END PGP PUBLIC KEY BLOCK-----\n",
|
||||
"user_id": "Test User <[email protected]>"
|
||||
},
|
||||
{
|
||||
"fingerprint": "47BE49AAB11F6DCB279D62ECC0FB1165C17E88A1",
|
||||
"index": 0,
|
||||
"mnemonic_id": "zoo24",
|
||||
"private_key_armored": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\nxVgEOG1DgBYJKwYBBAHaRw8BAQdAx1kImQEAloaiNQruTKiotGmAVg3FzYEOp1Y/\nl6uPdvIAAQC64iWutMskN5yZk6hL1U2gd2P7sO/S5UEhG3ka4pzGxhIMzRNmaXh0\ndXJlQGV4YW1wbGUuY29twoQEExYIACwFAjhtQ4ACGw4CCwkCFQgCFgICHgEWIQRH\nvkmqsR9tyyedYuzA+xFlwX6IoQAKCRDA+xFlwX6IoWI5AQC3/sTXQHoOKPiPDEfa\nVubphoUPnqPB5sSSyDy7+yn5EAD/R9YavLTvpbai3qJ1ie6AJwv5eUap6iW/+Wyt\nDf6kiAg=\n=liuC\n-----END PGP PRIVATE KEY BLOCK-----\n",
|
||||
"public_key_armored": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxjMEOG1DgBYJKwYBBAHaRw8BAQdAx1kImQEAloaiNQruTKiotGmAVg3FzYEOp1Y/\nl6uPdvLNE2ZpeHR1cmVAZXhhbXBsZS5jb23ChAQTFggALAUCOG1DgAIbDgILCQIV\nCAIWAgIeARYhBEe+SaqxH23LJ51i7MD7EWXBfoihAAoJEMD7EWXBfoihYjkBALf+\nxNdAeg4o+I8MR9pW5umGhQ+eo8HmxJLIPLv7KfkQAP9H1hq8tO+ltqLeonWJ7oAn\nC/l5RqnqJb/5bK0N/qSICA==\n=yr1d\n-----END PGP PUBLIC KEY BLOCK-----\n",
|
||||
"user_id": "[email protected]"
|
||||
},
|
||||
{
|
||||
"fingerprint": "4B8DC6C9F0B545A24EEA6C5666C292A5BF2B363A",
|
||||
"index": 3,
|
||||
"mnemonic_id": "zoo24",
|
||||
"private_key_armored": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\nxVgEOG1DgBYJKwYBBAHaRw8BAQdAONRHiXm78/sEeZcIAyKiUKtCm6jkmWInRa4D\ntczel2oAAQD/jMxuhlf8SLcOX/riCIQ3LXl7gIkj1hDkk7o8wjz/rBDzzQDChAQT\nFggALAUCOG1DgAIbDgILCQIVCAIWAgIeARYhBEuNxsnwtUWiTupsVmbCkqW/KzY6\nAAoJEGbCkqW/KzY6iTYBANFecrbHgskgDzdNzNWH8RcKdJQrexHmwMD5SRkk959P\nAP4uE6iTgN/74it/2UrfzrIoWEZt4LlkLb6RzlqZuqswAA==\n=Hfbe\n-----END PGP PRIVATE KEY BLOCK-----\n",
|
||||
"public_key_armored": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxjMEOG1DgBYJKwYBBAHaRw8BAQdAONRHiXm78/sEeZcIAyKiUKtCm6jkmWInRa4D\ntczel2rNAMKEBBMWCAAsBQI4bUOAAhsOAgsJAhUIAhYCAh4BFiEES43GyfC1RaJO\n6mxWZsKSpb8rNjoACgkQZsKSpb8rNjqJNgEA0V5ytseCySAPN03M1YfxFwp0lCt7\nEebAwPlJGST3n08A/i4TqJOA3/viK3/ZSt/OsihYRm3guWQtvpHOWpm6qzAA\n=LnpU\n-----END PGP PUBLIC KEY BLOCK-----\n",
|
||||
"user_id": ""
|
||||
},
|
||||
{
|
||||
"fingerprint": "94445E12BC9E19AAB560E6B76E3AC22AFCB6D2A9",
|
||||
"index": 7,
|
||||
"mnemonic_id": "zoo24",
|
||||
"private_key_armored": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\nxVgEOG1DgBYJKwYBBAHaRw8BAQdAYpwMrnTUCa1NpM3bD3SjnYvUdxHQsqZvtvl3\nK4KTcLkAAQDHYRMgnfrdAVQpd8rTkfWO9KNvtEwABOrDLx3uzCkbhw/+zRJUZXN0\nIFVzZXIgPHRAZS5jbz7ChAQTFggALAUCOG1DgAIbDgILCQIVCAIWAgIeARYhBJRE\nXhK8nhmqtWDmt246wir8ttKpAAoJEG46wir8ttKpqIQA/1XQgXc+1ncTirpIQCaw\n499Cpq3nuOB7R8wbPaSCYShfAP9SEtn2rRTUA+dWRZPx+wdFU0AD4Qo6gHBZipgP\npik8DA==\n=hcQE\n-----END PGP PRIVATE KEY BLOCK-----\n",
|
||||
"public_key_armored": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxjMEOG1DgBYJKwYBBAHaRw8BAQdAYpwMrnTUCa1NpM3bD3SjnYvUdxHQsqZvtvl3\nK4KTcLnNElRlc3QgVXNlciA8dEBlLmNvPsKEBBMWCAAsBQI4bUOAAhsOAgsJAhUI\nAhYCAh4BFiEElEReEryeGaq1YOa3bjrCKvy20qkACgkQbjrCKvy20qmohAD/VdCB\ndz7WdxOKukhAJrDj30Kmree44HtHzBs9pIJhKF8A/1IS2fatFNQD51ZFk/H7B0VT\nQAPhCjqAcFmKmA+mKTwM\n=K1M2\n-----END PGP PUBLIC KEY BLOCK-----\n",
|
||||
"user_id": "Test User <[email protected]>"
|
||||
}
|
||||
],
|
||||
"created_at": 946684800,
|
||||
"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.",
|
||||
"fixture_version": 1
|
||||
}
|
||||
@@ -31,6 +31,7 @@ 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";
|
||||
import pgpKeysJson from "../fixtures/pgp_keys.json";
|
||||
|
||||
export interface Bip39Case {
|
||||
id: string;
|
||||
@@ -244,6 +245,18 @@ export interface SshKeyCase {
|
||||
|
||||
export const sshKeyCases = (sshKeysJson as { cases: SshKeyCase[] }).cases;
|
||||
|
||||
export interface PgpKeyCase {
|
||||
mnemonic_id: string;
|
||||
index: number;
|
||||
user_id: string;
|
||||
fingerprint: string;
|
||||
private_key_armored: string;
|
||||
public_key_armored: string;
|
||||
}
|
||||
|
||||
export const pgpKeyCases = (pgpKeysJson as { cases: PgpKeyCase[] }).cases;
|
||||
export const pgpCreatedAt = (pgpKeysJson as { created_at: number }).created_at;
|
||||
|
||||
export interface MigrationCase {
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
|
||||
@@ -183,6 +183,7 @@ def py_create_profile(app_dir: Path, seed: str, password: str) -> str:
|
||||
em.add_managed_account("python-managed", seed)
|
||||
em.add_nostr_key("python-nostr", seed)
|
||||
em.add_ssh_key("python-ssh", seed)
|
||||
em.add_pgp_key("python-pgp", seed, user_id="[email protected]")
|
||||
|
||||
mgr = FingerprintManager(app_dir)
|
||||
if fp not in mgr.fingerprints:
|
||||
@@ -276,6 +277,16 @@ 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 == "pgp":
|
||||
from seedpass.core.password_generation import derive_pgp_key
|
||||
|
||||
priv, _pub, _fp = derive_pgp_key(
|
||||
bip85,
|
||||
int(entry.get("index", int(idx))),
|
||||
entry.get("key_type", "ed25519"),
|
||||
entry.get("user_id", ""),
|
||||
)
|
||||
out[label] = priv
|
||||
elif kind == "ssh":
|
||||
from seedpass.core.password_generation import derive_ssh_key_pair
|
||||
|
||||
@@ -378,6 +389,10 @@ def phase_b(tmp: Path) -> None:
|
||||
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)
|
||||
run_cli(app_dir, "entry", "add", "nostr", "ts-nostr", env_extra=env)
|
||||
run_cli(
|
||||
app_dir, "entry", "add", "pgp", "ts-pgp", "--user-id", "[email protected]",
|
||||
env_extra=env,
|
||||
)
|
||||
run_cli(app_dir, "entry", "add", "document", "ts-doc", "ts document body", env_extra=env)
|
||||
run_cli(app_dir, "entry", "add", "seed", "ts-seed", "--words", "24", env_extra=env)
|
||||
run_cli(
|
||||
|
||||
@@ -1133,6 +1133,36 @@ def gen_ssh_keys() -> dict:
|
||||
}
|
||||
|
||||
|
||||
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": (
|
||||
@@ -1177,6 +1207,7 @@ def main() -> None:
|
||||
"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(),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user