pakstr 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -32,7 +32,7 @@ async function runCLI(argv) {
32
32
  const configFlag = extractFlag(args, "--config");
33
33
  switch (command) {
34
34
  case "init":
35
- (0, init_1.initCommand)({ force: args.includes("--force"), out: configFlag });
35
+ await (0, init_1.initCommand)({ force: args.includes("--force"), out: configFlag });
36
36
  return;
37
37
  case "build":
38
38
  await (0, build_1.buildCommand)(configFlag ?? undefined);
@@ -23,7 +23,7 @@ const HEADER = `# pakstr.yaml — one file, one command, one APK.
23
23
  # has it can derive every app identity for every known appId. Losing it breaks
24
24
  # update continuity. It cannot rotate the signer of an already-published app.
25
25
  `;
26
- function initCommand(options = {}) {
26
+ async function initCommand(options = {}) {
27
27
  const configPath = path_1.default.resolve(options.out ?? path_1.default.join(process.cwd(), pakstrConfig_1.PAKSTR_CONFIG_FILENAME));
28
28
  if (fs_1.default.existsSync(configPath) && !options.force) {
29
29
  throw new Error(`${configPath} already exists. Use --force to overwrite, or remove it first.`);
@@ -53,8 +53,12 @@ function initCommand(options = {}) {
53
53
  }
54
54
  // Generate a fresh dev nsec into a gitignored `.env` so `pakstr run` works
55
55
  // locally with no manual setup. Never overwrites an existing PAKSTR_NSEC.
56
- const nsecStatus = ensureDevNsec();
57
- ensureGitIgnoresEnv();
56
+ const configDir = path_1.default.dirname(configPath);
57
+ const { nsec: devNsec, status: nsecStatus } = ensureDevNsec(configDir);
58
+ ensureGitIgnoresEnv(configDir);
59
+ // Write zapstore.yaml (relay-side publisher whitelist) with the publisher
60
+ // npub derived from the dev nsec. Never overwrites an existing one.
61
+ const zapstoreStatus = await ensureZapstoreYaml(devNsec, configDir);
58
62
  console.log("");
59
63
  console.log("➡️ Next: run pakstr run");
60
64
  if (nsecStatus === "generated") {
@@ -63,34 +67,42 @@ function initCommand(options = {}) {
63
67
  else if (nsecStatus === "present") {
64
68
  console.log(` ${nostr_1.PAKSTR_NSEC_ENV} already in .env — left untouched.`);
65
69
  }
70
+ if (zapstoreStatus === "generated") {
71
+ console.log(" zapstore.yaml written (relay-side publisher whitelist). Commit it.");
72
+ }
73
+ else if (zapstoreStatus === "present") {
74
+ console.log(" zapstore.yaml already exists — left untouched.");
75
+ }
66
76
  console.log("");
67
77
  console.log(` For real releases, put the SAME ${nostr_1.PAKSTR_NSEC_ENV} in your CI secret manager`);
68
78
  console.log(" (reusing it is what lets an update install over the previous APK).");
69
79
  console.log(` ${nostr_1.PAKSTR_NSEC_ENV} is a release-signing root secret: anyone who has it can`);
70
80
  console.log(" sign updates for every app whose appId they know. Keep it safe.");
71
81
  }
72
- /** Generate a dev nsec into .env if none is present there. Returns what happened. */
73
- function ensureDevNsec() {
74
- const envPath = path_1.default.join(process.cwd(), ".env");
82
+ /** Generate a dev nsec into .env if none is present there. Returns the nsec string. */
83
+ function ensureDevNsec(dir) {
84
+ const envPath = path_1.default.join(dir, ".env");
75
85
  const existing = fs_1.default.existsSync(envPath)
76
86
  ? fs_1.default.readFileSync(envPath, "utf8")
77
87
  : "";
78
- if (containsPakstrNsec(existing))
79
- return "present";
88
+ if (containsPakstrNsec(existing)) {
89
+ const m = existing.match(new RegExp(`^${nostr_1.PAKSTR_NSEC_ENV}=(\\S+)`, "m"));
90
+ return { nsec: m?.[1] ?? "", status: "present" };
91
+ }
80
92
  const nsec = (0, nostr_1.encodeNsec)((0, crypto_1.randomBytes)(32));
81
93
  const line = `${nostr_1.PAKSTR_NSEC_ENV}=${nsec}\n`;
82
94
  const header = existing.length === 0 ? "# Local development secrets — never commit this file.\n" : "";
83
95
  fs_1.default.writeFileSync(envPath, header + (existing.length ? "\n" + line : line), {
84
96
  flag: existing.length === 0 ? "wx" : "a",
85
97
  });
86
- return "generated";
98
+ return { nsec, status: "generated" };
87
99
  }
88
100
  function containsPakstrNsec(envText) {
89
101
  return new RegExp(`^${nostr_1.PAKSTR_NSEC_ENV}=`, "m").test(envText);
90
102
  }
91
103
  /** Ensure `.env` is listed in `.gitignore` so the nsec is never committed. */
92
- function ensureGitIgnoresEnv() {
93
- const gitignorePath = path_1.default.join(process.cwd(), ".gitignore");
104
+ function ensureGitIgnoresEnv(dir) {
105
+ const gitignorePath = path_1.default.join(dir, ".gitignore");
94
106
  const existing = fs_1.default.existsSync(gitignorePath)
95
107
  ? fs_1.default.readFileSync(gitignorePath, "utf8")
96
108
  : "";
@@ -106,7 +118,48 @@ function ensureGitIgnoresEnv() {
106
118
  function containsEnvEntry(gitignoreText) {
107
119
  return gitignoreText
108
120
  .split(/\r?\n/)
109
- .some(line => line.trim() === ".env" || line.trim().startsWith(".env"));
121
+ .some(line => {
122
+ const t = line.trim();
123
+ return t === ".env" || t.startsWith(".env/");
124
+ });
125
+ }
126
+ /**
127
+ * Write `zapstore.yaml` (relay-side publisher whitelist) with the publisher
128
+ * npub derived from the dev nsec. Never overwrites an existing file.
129
+ * Returns what happened.
130
+ */
131
+ async function ensureZapstoreYaml(devNsec, dir) {
132
+ const zapstorePath = path_1.default.join(dir, "zapstore.yaml");
133
+ if (fs_1.default.existsSync(zapstorePath))
134
+ return "present";
135
+ let npub = "";
136
+ if (devNsec) {
137
+ try {
138
+ const secret = (0, nostr_1.decodeNsec)(devNsec);
139
+ const hex = await (0, nostr_1.getNpubHex)(secret);
140
+ npub = (0, nostr_1.pubkeyHexToNpub)(hex);
141
+ }
142
+ catch {
143
+ // If we can't derive the npub, write the file with a placeholder so the
144
+ // user knows to fill it in.
145
+ npub = "";
146
+ }
147
+ }
148
+ const content = `# zapstore.yaml — relay-side publisher whitelist (NIP-82 / Zap Store).
149
+ # The relay fetches this file from your repo to verify the publisher's pubkey
150
+ # is authorized for this repository. Generated by \`pakstr init\` (never
151
+ # overwritten on re-runs; remove this file first if you need a fresh one).
152
+ #
153
+ # \`pubkey\` is the npub matching the nsec used to publish (PAKSTR_NSEC, or
154
+ # PAKSTR_PUBLISH_NSEC if you set publish.publishKey). Replace it with your
155
+ # real publisher npub for production releases if different from your dev nsec.
156
+ # Note: write access to this repo grants publish authority — protect branch access.
157
+
158
+ repository: # Set to your app's source code repository URL (e.g. https://github.com/org/app)
159
+ pubkey: ${npub || "# Set to your publisher npub (npub1...)"}
160
+ `;
161
+ fs_1.default.writeFileSync(zapstorePath, content, "utf8");
162
+ return "generated";
110
163
  }
111
164
  function renderConfig(c) {
112
165
  const perms = ` [${pakstrConfig_1.PERMISSION_ALIASES.map(p => `"${p}"`).join(", ")}]`;
@@ -9,6 +9,8 @@ const path_1 = __importDefault(require("path"));
9
9
  const pakstrConfig_1 = require("../core/pakstrConfig");
10
10
  const zapStore_1 = require("../core/zapStore");
11
11
  const blossom_1 = require("../core/blossom");
12
+ const nostr_1 = require("../core/nostr");
13
+ const identityProof_1 = require("../core/identityProof");
12
14
  /**
13
15
  * `pakstr publish` — upload the signed APK to Blossom and publish NIP-82
14
16
  * events (32267/30063/3063) to the configured relay. Spec §5.4 / §8.
@@ -34,6 +36,7 @@ async function publishCommand(configPath, options = {}) {
34
36
  let apkSize;
35
37
  let signerCertificateSha256;
36
38
  let filename;
39
+ let identityProof;
37
40
  if (options.dryRun) {
38
41
  // No network, no APK required. Synthesize a stub descriptor so the events
39
42
  // can still be built and printed for preview.
@@ -42,6 +45,14 @@ async function publishCommand(configPath, options = {}) {
42
45
  blossomUrl = `${config.publish.blossom.replace(/\/$/, "")}/${apkSha256}`;
43
46
  signerCertificateSha256 = "0".repeat(64);
44
47
  filename = path_1.default.basename(apkPath);
48
+ // Stub identity proof so the 30509 event path is exercised end-to-end.
49
+ identityProof = {
50
+ certHash: signerCertificateSha256,
51
+ signature: "dry-run-stub==",
52
+ createdAt: Math.floor(Date.now() / 1000),
53
+ expiry: Math.floor(Date.now() / 1000) + 365 * 24 * 3600,
54
+ pubkeyHex: await (0, nostr_1.getNpubHex)(publishNsec.bytes),
55
+ };
45
56
  console.log("📦 (dry-run) Blossom URL:", blossomUrl);
46
57
  }
47
58
  else {
@@ -53,6 +64,25 @@ async function publishCommand(configPath, options = {}) {
53
64
  throw new Error(`Signer certificate sidecar not found: ${certShaPath}. Run \`pakstr sign\` first.`);
54
65
  }
55
66
  signerCertificateSha256 = fs_1.default.readFileSync(certShaPath, "utf8").trim();
67
+ // Read the NIP-C1 identity proof sidecar (optional; generated by
68
+ // `pakstr sign` when a publish nsec is available).
69
+ const proofPath = `${apkPath}.identity-proof`;
70
+ if (fs_1.default.existsSync(proofPath)) {
71
+ let raw;
72
+ try {
73
+ raw = fs_1.default.readFileSync(proofPath, "utf8").trim();
74
+ }
75
+ catch (e) {
76
+ throw new Error(`Failed to read identity proof sidecar ${proofPath}: ${e instanceof Error ? e.message : String(e)}`);
77
+ }
78
+ identityProof = (0, identityProof_1.parseIdentityProofSidecar)(raw, proofPath, signerCertificateSha256);
79
+ }
80
+ else {
81
+ // No sidecar found — warn so users can spot a missing 30509 proof
82
+ // in CI output (the sign step may have run without a publish nsec).
83
+ console.log("⚠️ No .identity-proof sidecar found — the kind 30509 identity proof will not be published.");
84
+ console.log(" Re-run \`pakstr sign\` with a publish nsec available to generate it.");
85
+ }
56
86
  const blossom = await (0, blossom_1.uploadToBlossom)({
57
87
  serverUrl: config.publish.blossom,
58
88
  filePath: apkPath,
@@ -85,12 +115,16 @@ async function publishCommand(configPath, options = {}) {
85
115
  },
86
116
  publishNsec,
87
117
  target: { relayUrl: config.publish.relay },
118
+ identityProof,
88
119
  }, transport);
89
120
  console.log(options.dryRun ? "✅ PUBLISHED (dry-run — no network)" : "✅ PUBLISHED");
90
121
  console.log("🪪 Publisher npub:", result.npub);
91
122
  console.log("🧾 Asset event:", result.assetEventId);
92
123
  console.log("🧾 Release event:", result.releaseEventId);
93
124
  console.log("🧾 App metadata event:", result.appMetadataEventId);
125
+ if (result.identityProofEventId) {
126
+ console.log("🧾 Identity proof event:", result.identityProofEventId);
127
+ }
94
128
  console.log("🔗 Blossom URL:", blossomUrl);
95
129
  console.log("📡 Relay:", config.publish.relay);
96
130
  if (options.verify && !options.dryRun) {
@@ -127,6 +161,7 @@ async function publishCommand(configPath, options = {}) {
127
161
  assetEventId: result.assetEventId,
128
162
  releaseEventId: result.releaseEventId,
129
163
  appMetadataEventId: result.appMetadataEventId,
164
+ identityProofEventId: result.identityProofEventId,
130
165
  publishedAt: new Date().toISOString(),
131
166
  dryRun: !!options.dryRun,
132
167
  };
@@ -21,6 +21,21 @@ async function signCommand(configPath) {
21
21
  throw new Error(`Unsigned APK not found at ${unsignedApkPath}. Run \`pakstr build\` first.`);
22
22
  }
23
23
  const nsec = (0, nostr_1.requireSigningNsec)(process.env);
24
+ // Resolve the publish nsec to derive the publisher pubkey for the NIP-C1
25
+ // identity proof (kind 30509). If publishing is disabled, skip the proof.
26
+ let identityProofPubkey;
27
+ if (config.publish.zapstoreEnabled) {
28
+ try {
29
+ const pubNsec = (0, nostr_1.resolvePublishNsec)(config.publish.publishKey, process.env);
30
+ identityProofPubkey = await (0, nostr_1.getNpubHex)(pubNsec.bytes);
31
+ }
32
+ catch (e) {
33
+ // If the publish nsec isn't available yet, skip the identity proof
34
+ // but warn so the user knows it was skipped (not silently dropped).
35
+ console.log(`⚠️ NIP-C1 identity proof skipped: publish nsec not available (${e instanceof Error ? e.message : String(e)}).`);
36
+ console.log(` Re-run \`pakstr sign\` once the publish nsec is set to generate the 30509 proof.`);
37
+ }
38
+ }
24
39
  console.log("\n🔐 pakstr sign");
25
40
  console.log("📦 Unsigned:", unsignedApkPath);
26
41
  console.log("🔑 Deriving signing key from PAKSTR_NSEC +", config.app.appId);
@@ -30,10 +45,23 @@ async function signCommand(configPath) {
30
45
  signedApkPath: unsignedApkPath,
31
46
  appId: config.app.appId,
32
47
  nsec: nsec.bytes,
48
+ identityProofPubkey,
33
49
  });
34
50
  // Write the signer certificate SHA-256 to a sidecar so `pakstr publish`/`run`
35
51
  // can include it in the Zap Store asset event (apk_certificate_hash).
36
52
  fs_1.default.writeFileSync(`${unsignedApkPath}.signer-sha256`, result.signerSha256, "utf8");
53
+ // Write the NIP-C1 identity proof to a sidecar so `pakstr publish`/`run`
54
+ // can include it as a kind 30509 event. If no proof was produced
55
+ // (e.g. publish nsec not available), clean up any stale sidecar from
56
+ // a previous sign run so publish doesn't emit a stale 30509 event.
57
+ const proofSidecarPath = `${unsignedApkPath}.identity-proof`;
58
+ if (result.identityProof) {
59
+ fs_1.default.writeFileSync(proofSidecarPath, JSON.stringify(result.identityProof), "utf8");
60
+ console.log("🧾 NIP-C1 identity proof generated (kind 30509)");
61
+ }
62
+ else {
63
+ fs_1.default.rmSync(proofSidecarPath, { force: true });
64
+ }
37
65
  console.log("✅ SIGNED");
38
66
  console.log("📦 Signed APK:", result.signedApkPath);
39
67
  console.log("🧾 Signer certificate SHA-256:", result.signerSha256);
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseIdentityProofSidecar = parseIdentityProofSidecar;
4
+ /**
5
+ * Validate and parse an identity proof from a raw JSON string (the
6
+ * `.identity-proof` sidecar). Throws on any malformed input.
7
+ *
8
+ * @param raw - the raw file content (JSON)
9
+ * @param proofPath - for error messages
10
+ * @param signerCertSha256 - the expected cert hash (from `.signer-sha256`)
11
+ * @returns the validated IdentityProof
12
+ */
13
+ function parseIdentityProofSidecar(raw, proofPath, signerCertSha256) {
14
+ let parsed;
15
+ try {
16
+ parsed = JSON.parse(raw);
17
+ }
18
+ catch (e) {
19
+ throw new Error(`Identity proof sidecar ${proofPath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
20
+ }
21
+ const p = parsed;
22
+ const certHash = typeof p?.certHash === "string" ? p.certHash : undefined;
23
+ const signature = typeof p?.signature === "string" ? p.signature : undefined;
24
+ const createdAt = typeof p?.createdAt === "number" ? p.createdAt : undefined;
25
+ const expiry = typeof p?.expiry === "number" ? p.expiry : undefined;
26
+ const pubkeyHex = typeof p?.pubkeyHex === "string" ? p.pubkeyHex : undefined;
27
+ if (!certHash || !/^[0-9a-f]{64}$/.test(certHash)) {
28
+ throw new Error(`Identity proof sidecar ${proofPath} has an invalid certHash (expected 64 lowercase hex chars)`);
29
+ }
30
+ if (!signature || !/^[A-Za-z0-9+/]+={0,2}$/.test(signature) || signature.length % 4 !== 0) {
31
+ throw new Error(`Identity proof sidecar ${proofPath} has an invalid signature (expected valid base64)`);
32
+ }
33
+ if (!pubkeyHex || !/^[0-9a-f]{64}$/.test(pubkeyHex)) {
34
+ throw new Error(`Identity proof sidecar ${proofPath} has an invalid pubkeyHex (expected 64 lowercase hex chars)`);
35
+ }
36
+ if (typeof createdAt !== "number" || typeof expiry !== "number" || expiry <= createdAt) {
37
+ throw new Error(`Identity proof sidecar ${proofPath} has invalid timestamps (createdAt=${createdAt}, expiry=${expiry})`);
38
+ }
39
+ if (createdAt > Math.floor(Date.now() / 1000) + 300) {
40
+ throw new Error(`Identity proof sidecar ${proofPath} has a createdAt too far in the future (createdAt=${createdAt}) — possible tampering`);
41
+ }
42
+ if (expiry < Math.floor(Date.now() / 1000)) {
43
+ throw new Error(`Identity proof sidecar ${proofPath} has expired (expiry=${expiry})`);
44
+ }
45
+ if (certHash !== signerCertSha256) {
46
+ throw new Error(`Identity proof certHash does not match the APK signer certificate: ${certHash} vs ${signerCertSha256}`);
47
+ }
48
+ return { certHash, signature, createdAt, expiry, pubkeyHex };
49
+ }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MockRelayTransport = exports.WebsocketRelayTransport = exports.KIND_ASSET = exports.KIND_RELEASE = exports.KIND_APP_METADATA = exports.DEFAULT_COMMUNITY = void 0;
3
+ exports.MockRelayTransport = exports.WebsocketRelayTransport = exports.IDENTITY_PROOF_MESSAGE = exports.KIND_IDENTITY_PROOF = exports.KIND_ASSET = exports.KIND_RELEASE = exports.KIND_APP_METADATA = exports.DEFAULT_COMMUNITY = void 0;
4
+ exports.formatIdentityProofMessage = formatIdentityProofMessage;
4
5
  exports.buildSignedPublishEvents = buildSignedPublishEvents;
5
6
  exports.publishToZapStore = publishToZapStore;
6
7
  exports.resolvePublishSecret = resolvePublishSecret;
@@ -14,11 +15,29 @@ exports.DEFAULT_COMMUNITY = "acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e5
14
15
  exports.KIND_APP_METADATA = 32267;
15
16
  exports.KIND_RELEASE = 30063;
16
17
  exports.KIND_ASSET = 3063;
18
+ /** NIP-C1: Cryptographic Identity Proof (SPKI). Links the APK signing
19
+ * certificate to the publisher Nostr identity. */
20
+ exports.KIND_IDENTITY_PROOF = 30509;
21
+ /**
22
+ * The NIP-C1 signed message format. This MUST be byte-for-byte
23
+ * identical to the Java constant in DeterministicSigningTool.java
24
+ * (IDENTITY_PROOF_MESSAGE). Any whitespace/wording difference breaks
25
+ * signature verification.
26
+ */
27
+ exports.IDENTITY_PROOF_MESSAGE = "Verifying at %d until %d that I control the following Nostr public key: %s";
28
+ /** Build the NIP-C1 message from the proof fields. */
29
+ function formatIdentityProofMessage(createdAt, expiry, pubkeyHex) {
30
+ return exports.IDENTITY_PROOF_MESSAGE
31
+ .replace("%d", String(createdAt))
32
+ .replace("%d", String(expiry))
33
+ .replace("%s", pubkeyHex);
34
+ }
17
35
  /** Android platform identifier for the template's generic APK. */
18
36
  const PLATFORM = "android-arm64-v8a";
19
37
  const MIN_SDK = 28;
20
38
  const TARGET_SDK = 36;
21
- /** Build the three NIP-82 events (unsigned → signed) for an app release. */
39
+ /** Build the NIP-82 events (unsigned → signed) for an app release.
40
+ * Includes a NIP-C1 identity proof (kind 30509) if `identityProof` is set. */
22
41
  async function buildSignedPublishEvents(input) {
23
42
  const pubkeyHex = await (0, nostr_1.getNpubHex)(input.publishNsec.bytes);
24
43
  const npub = (0, nostr_1.pubkeyHexToNpub)(pubkeyHex);
@@ -70,7 +89,30 @@ async function buildSignedPublishEvents(input) {
70
89
  ],
71
90
  content: "",
72
91
  }, input.publishNsec.bytes);
73
- return { npub, pubkeyHex, appMetadata, release, asset };
92
+ // kind 30509 NIP-C1 Identity Proof (optional, links the APK signing
93
+ // certificate to the publisher Nostr identity). The signature is produced
94
+ // by the Java DeterministicSigningTool using the derived P-256 key.
95
+ let identityProofEvent;
96
+ if (input.identityProof) {
97
+ // The proof binds the APK signing cert to a specific Nostr pubkey.
98
+ // If the publish nsec changed between sign and publish, the content would
99
+ // carry a different pubkey than what was signed — the proof would be invalid.
100
+ if (input.identityProof.pubkeyHex !== pubkeyHex) {
101
+ throw new Error(`NIP-C1 identity proof is bound to pubkey ${input.identityProof.pubkeyHex} but the current publish nsec's pubkey is ${pubkeyHex}. Re-run \`pakstr sign\` with the current publish nsec to regenerate the proof.`);
102
+ }
103
+ identityProofEvent = await (0, nostr_1.signNostrEvent)({
104
+ kind: exports.KIND_IDENTITY_PROOF,
105
+ created_at: input.identityProof.createdAt,
106
+ tags: [
107
+ ["d", input.identityProof.certHash],
108
+ ["signature", input.identityProof.signature],
109
+ ["expiry", String(input.identityProof.expiry)],
110
+ ],
111
+ // Use the proof's pubkeyHex (matches what the P-256 key signed).
112
+ content: formatIdentityProofMessage(input.identityProof.createdAt, input.identityProof.expiry, input.identityProof.pubkeyHex),
113
+ }, input.publishNsec.bytes);
114
+ }
115
+ return { npub, pubkeyHex, appMetadata, release, asset, identityProof: identityProofEvent ?? undefined };
74
116
  }
75
117
  /** Publish all three events to the relay via the given transport. */
76
118
  async function publishToZapStore(input, transport) {
@@ -78,16 +120,21 @@ async function publishToZapStore(input, transport) {
78
120
  await transport.publish(built.asset, input.target.relayUrl);
79
121
  await transport.publish(built.release, input.target.relayUrl);
80
122
  await transport.publish(built.appMetadata, input.target.relayUrl);
123
+ if (built.identityProof) {
124
+ await transport.publish(built.identityProof, input.target.relayUrl);
125
+ }
81
126
  return {
82
127
  npub: built.npub,
83
128
  pubkeyHex: built.pubkeyHex,
84
129
  appMetadataEventId: built.appMetadata.id,
85
130
  releaseEventId: built.release.id,
86
131
  assetEventId: built.asset.id,
132
+ identityProofEventId: built.identityProof?.id,
87
133
  events: {
88
134
  appMetadata: built.appMetadata,
89
135
  release: built.release,
90
136
  asset: built.asset,
137
+ identityProof: built.identityProof,
91
138
  },
92
139
  relayUrl: input.target.relayUrl,
93
140
  };
@@ -41,9 +41,15 @@ class DockerSignRunner {
41
41
  APP_NSEC: nsecString,
42
42
  KEYSTORE_PASSWORD: password,
43
43
  };
44
- // Explicitly forward the two signing env vars into the container; docker
44
+ if (request.identityProofPubkey) {
45
+ env.IDENTITY_PROOF_PUBKEY = request.identityProofPubkey.toLowerCase();
46
+ }
47
+ // Explicitly forward the signing env vars into the container; docker
45
48
  // run does not auto-forward the parent environment.
46
49
  const signingEnvNames = ["APP_NSEC", "KEYSTORE_PASSWORD"];
50
+ if (request.identityProofPubkey) {
51
+ signingEnvNames.push("IDENTITY_PROOF_PUBKEY");
52
+ }
47
53
  const stdout = this.execute("docker", [
48
54
  "run",
49
55
  "--rm",
@@ -61,6 +67,21 @@ class DockerSignRunner {
61
67
  "/out/signed.apk",
62
68
  ], { env, secrets: [nsecString, password], streamOutput: true }).stdout;
63
69
  const signerSha256 = parseSignerSha(stdout);
70
+ const identityProof = parseIdentityProof(stdout);
71
+ if (request.identityProofPubkey && !identityProof) {
72
+ throw new Error("Identity proof was requested (publish nsec available) but the signing tool produced no PAKSTR_IDENTITY_PROOF line. " +
73
+ "Check the Java tool output above for errors.");
74
+ }
75
+ // Assert the proof binds to the requested publisher pubkey and the
76
+ // derived signing cert — catch divergence early, not later in publish.
77
+ if (identityProof && request.identityProofPubkey) {
78
+ if (identityProof.pubkeyHex.toLowerCase() !== request.identityProofPubkey.toLowerCase()) {
79
+ throw new Error(`Identity proof pubkeyHex (${identityProof.pubkeyHex}) does not match the requested publisher pubkey (${request.identityProofPubkey})`);
80
+ }
81
+ if (identityProof.certHash.toLowerCase() !== signerSha256) {
82
+ throw new Error(`Identity proof certHash (${identityProof.certHash}) does not match the signer certificate (${signerSha256})`);
83
+ }
84
+ }
64
85
  // Copy the signed APK out of the output volume to the host.
65
86
  const hostSigned = path_1.default.join(os_1.default.tmpdir(), `pakstr-signed-${(0, crypto_2.randomUUID)()}.apk`);
66
87
  this.copyFromVolume(outVolume, "/out/signed.apk", hostSigned);
@@ -75,7 +96,7 @@ class DockerSignRunner {
75
96
  // fs.rename fails with EXDEV. COPYFILE_FICLONE is a harmless hint.
76
97
  fs_1.default.copyFileSync(hostSigned, finalPath, fs_1.default.constants.COPYFILE_FICLONE);
77
98
  fs_1.default.rmSync(hostSigned, { force: true });
78
- result = { signedApkPath: finalPath, signerSha256 };
99
+ result = { signedApkPath: finalPath, signerSha256, identityProof: identityProof ?? undefined };
79
100
  }
80
101
  catch (error) {
81
102
  primaryError = error;
@@ -162,6 +183,19 @@ function parseSignerSha(stdout) {
162
183
  }
163
184
  return match[1].toLowerCase();
164
185
  }
186
+ /** Parse the PAKSTR_IDENTITY_PROOF line from sign.sh stdout, if present. */
187
+ function parseIdentityProof(stdout) {
188
+ const match = stdout.match(/PAKSTR_IDENTITY_PROOF certHash=([0-9a-fA-F]{64}),pubkeyHex=([0-9a-fA-F]{64}),signature=([A-Za-z0-9+/]+={0,2}),createdAt=(\d+),expiry=(\d+)/);
189
+ if (!match)
190
+ return null;
191
+ return {
192
+ certHash: match[1].toLowerCase(),
193
+ pubkeyHex: match[2].toLowerCase(),
194
+ signature: match[3],
195
+ createdAt: parseInt(match[4], 10),
196
+ expiry: parseInt(match[5], 10),
197
+ };
198
+ }
165
199
  /** Re-encode the secret bytes as a bech32 nsec string for the container env. */
166
200
  function nsecToString(secret) {
167
201
  // Lazy import to avoid a circular type dependency at module load.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pakstr",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "CLI for packaging Nostr web apps into Android APKs",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",