pakstr 0.10.0 → 0.12.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 +3 -2
- package/dist/commands/init.js +19 -13
- package/dist/commands/publish.js +35 -0
- package/dist/commands/run.js +20 -0
- package/dist/commands/sign.js +28 -0
- package/dist/core/identityProof.js +49 -0
- package/dist/core/zapStore.js +50 -3
- package/dist/runners/DockerSignRunner.js +36 -2
- package/package.json +1 -1
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);
|
|
@@ -51,6 +51,7 @@ async function runCLI(argv) {
|
|
|
51
51
|
await (0, run_1.runCommand)(configFlag ?? undefined, {
|
|
52
52
|
verifyPublish: args.includes("--verify-publish"),
|
|
53
53
|
publishOutJson: extractFlag(args, "--publish-out-json"),
|
|
54
|
+
dryRun: args.includes("--dry-run"),
|
|
54
55
|
});
|
|
55
56
|
return;
|
|
56
57
|
default:
|
|
@@ -75,7 +76,7 @@ Usage:
|
|
|
75
76
|
pakstr build [--config <path>]
|
|
76
77
|
pakstr sign [--config <path>]
|
|
77
78
|
pakstr publish [--config <path>] [--dry-run] [--verify] [--out-json <path>]
|
|
78
|
-
pakstr run [--config <path>] [--verify-publish] [--publish-out-json <path>]
|
|
79
|
+
pakstr run [--config <path>] [--dry-run] [--verify-publish] [--publish-out-json <path>]
|
|
79
80
|
|
|
80
81
|
pakstr --version
|
|
81
82
|
pakstr --help
|
package/dist/commands/init.js
CHANGED
|
@@ -53,11 +53,12 @@ async 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
|
|
57
|
-
|
|
56
|
+
const configDir = path_1.default.dirname(configPath);
|
|
57
|
+
const { nsec: devNsec, status: nsecStatus } = ensureDevNsec(configDir);
|
|
58
|
+
ensureGitIgnoresEnv(configDir);
|
|
58
59
|
// Write zapstore.yaml (relay-side publisher whitelist) with the publisher
|
|
59
60
|
// npub derived from the dev nsec. Never overwrites an existing one.
|
|
60
|
-
const zapstoreStatus = await ensureZapstoreYaml(devNsec);
|
|
61
|
+
const zapstoreStatus = await ensureZapstoreYaml(devNsec, configDir);
|
|
61
62
|
console.log("");
|
|
62
63
|
console.log("➡️ Next: run pakstr run");
|
|
63
64
|
if (nsecStatus === "generated") {
|
|
@@ -79,8 +80,8 @@ async function initCommand(options = {}) {
|
|
|
79
80
|
console.log(" sign updates for every app whose appId they know. Keep it safe.");
|
|
80
81
|
}
|
|
81
82
|
/** Generate a dev nsec into .env if none is present there. Returns the nsec string. */
|
|
82
|
-
function ensureDevNsec() {
|
|
83
|
-
const envPath = path_1.default.join(
|
|
83
|
+
function ensureDevNsec(dir) {
|
|
84
|
+
const envPath = path_1.default.join(dir, ".env");
|
|
84
85
|
const existing = fs_1.default.existsSync(envPath)
|
|
85
86
|
? fs_1.default.readFileSync(envPath, "utf8")
|
|
86
87
|
: "";
|
|
@@ -100,8 +101,8 @@ function containsPakstrNsec(envText) {
|
|
|
100
101
|
return new RegExp(`^${nostr_1.PAKSTR_NSEC_ENV}=`, "m").test(envText);
|
|
101
102
|
}
|
|
102
103
|
/** Ensure `.env` is listed in `.gitignore` so the nsec is never committed. */
|
|
103
|
-
function ensureGitIgnoresEnv() {
|
|
104
|
-
const gitignorePath = path_1.default.join(
|
|
104
|
+
function ensureGitIgnoresEnv(dir) {
|
|
105
|
+
const gitignorePath = path_1.default.join(dir, ".gitignore");
|
|
105
106
|
const existing = fs_1.default.existsSync(gitignorePath)
|
|
106
107
|
? fs_1.default.readFileSync(gitignorePath, "utf8")
|
|
107
108
|
: "";
|
|
@@ -117,15 +118,18 @@ function ensureGitIgnoresEnv() {
|
|
|
117
118
|
function containsEnvEntry(gitignoreText) {
|
|
118
119
|
return gitignoreText
|
|
119
120
|
.split(/\r?\n/)
|
|
120
|
-
.some(line =>
|
|
121
|
+
.some(line => {
|
|
122
|
+
const t = line.trim();
|
|
123
|
+
return t === ".env" || t.startsWith(".env/");
|
|
124
|
+
});
|
|
121
125
|
}
|
|
122
126
|
/**
|
|
123
127
|
* Write `zapstore.yaml` (relay-side publisher whitelist) with the publisher
|
|
124
128
|
* npub derived from the dev nsec. Never overwrites an existing file.
|
|
125
129
|
* Returns what happened.
|
|
126
130
|
*/
|
|
127
|
-
async function ensureZapstoreYaml(devNsec) {
|
|
128
|
-
const zapstorePath = path_1.default.join(
|
|
131
|
+
async function ensureZapstoreYaml(devNsec, dir) {
|
|
132
|
+
const zapstorePath = path_1.default.join(dir, "zapstore.yaml");
|
|
129
133
|
if (fs_1.default.existsSync(zapstorePath))
|
|
130
134
|
return "present";
|
|
131
135
|
let npub = "";
|
|
@@ -143,14 +147,16 @@ async function ensureZapstoreYaml(devNsec) {
|
|
|
143
147
|
}
|
|
144
148
|
const content = `# zapstore.yaml — relay-side publisher whitelist (NIP-82 / Zap Store).
|
|
145
149
|
# The relay fetches this file from your repo to verify the publisher's pubkey
|
|
146
|
-
# is authorized for this repository. Generated by \`pakstr init
|
|
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).
|
|
147
152
|
#
|
|
148
153
|
# \`pubkey\` is the npub matching the nsec used to publish (PAKSTR_NSEC, or
|
|
149
154
|
# PAKSTR_PUBLISH_NSEC if you set publish.publishKey). Replace it with your
|
|
150
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.
|
|
151
157
|
|
|
152
|
-
repository: #
|
|
153
|
-
pubkey: ${npub || "#
|
|
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...)"}
|
|
154
160
|
`;
|
|
155
161
|
fs_1.default.writeFileSync(zapstorePath, content, "utf8");
|
|
156
162
|
return "generated";
|
package/dist/commands/publish.js
CHANGED
|
@@ -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
|
};
|
package/dist/commands/run.js
CHANGED
|
@@ -17,6 +17,10 @@ const publish_1 = require("./publish");
|
|
|
17
17
|
* Each step is atomic and fail-fast (§7). The signed APK at build.out is
|
|
18
18
|
* preserved on a publish failure; only ephemeral/derived material is cleaned
|
|
19
19
|
* up. `init` is NOT part of `run`.
|
|
20
|
+
*
|
|
21
|
+
* `--dry-run` skips build + sign (no Docker, no APK) and only runs a dry-run
|
|
22
|
+
* publish with stub values and a mock relay — a safe offline smoke test of
|
|
23
|
+
* the Nostr event-signing path.
|
|
20
24
|
*/
|
|
21
25
|
async function runCommand(configPath, options = {}) {
|
|
22
26
|
// Step 1: load & validate config, pre-validate nsecs.
|
|
@@ -41,6 +45,22 @@ async function runCommand(configPath, options = {}) {
|
|
|
41
45
|
console.log("Web:", config.build.web);
|
|
42
46
|
console.log("Out:", path_1.default.resolve(config.build.out));
|
|
43
47
|
console.log("Publish:", config.publish.zapstoreEnabled ? "enabled" : "disabled");
|
|
48
|
+
if (options.dryRun) {
|
|
49
|
+
// Skip build + sign — no Docker, no APK. Only exercise the publish
|
|
50
|
+
// event-signing path with stub values and a mock relay.
|
|
51
|
+
if (config.publish.zapstoreEnabled) {
|
|
52
|
+
console.log("\n— Dry run: skipping build + sign, publish only —");
|
|
53
|
+
await (0, publish_1.publishCommand)(config.configPath || undefined, {
|
|
54
|
+
dryRun: true,
|
|
55
|
+
outJson: options.publishOutJson,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
console.log("\nDry run: publish disabled (publish.zapstore.enabled: false). Nothing to do.");
|
|
60
|
+
}
|
|
61
|
+
console.log("\n✅ pakstr run complete (dry-run)");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
44
64
|
// Step 3: build the unsigned APK.
|
|
45
65
|
console.log("\n— Step 1/3: build —");
|
|
46
66
|
await (0, build_1.buildCommand)(config.configPath || undefined);
|
package/dist/commands/sign.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/zapStore.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|