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:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-14 16:46:24 -04:00
1 parent 5dcd779a6f
commit c2d7306bb7
12 files changed
+457 -13

No files matched your search

+6 -5
View File
@@ -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
+3 -3
View File
@@ -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
+36
View File
@@ -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)")
+11 -4
View File
@@ -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}`);
+241
View File
@@ -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,
};
}
+6
View File
@@ -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,
+38
View File
@@ -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
}
+13
View File
@@ -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>;
+15
View File
@@ -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(
+31
View File
@@ -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(),
}