@dk/hipp 0.1.5 → 0.1.7

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.
Files changed (4) hide show
  1. package/README.md +97 -24
  2. package/hipp.js +261 -1
  3. package/hipp.pub +3 -0
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # 🚀 HIPP: High Integrity Package Publisher
1
+ # HIPP: High Integrity Package Publisher
2
2
 
3
3
  By Dmytri Kleiner <dev@dmytri.to>
4
4
 
@@ -7,43 +7,51 @@ friction of version-bump commits. It treats your **Git Tags** as the single
7
7
  source of truth, enforcing a "Ground State" where your `package.json` version
8
8
  remains permanently at `0.0.0`.
9
9
 
10
+ HIPP provides **cryptographic signing** and **out-of-band verification** to
11
+ guarantee that the package in the npm registry exactly matches your git tag.
12
+
10
13
  ---
11
14
 
12
- ## 🧐 Why HIPP?
15
+ ## Why HIPP?
16
+
17
+ ### Integrity
13
18
 
14
19
  Traditional NPM versioning requires you to store your "Version of Truth" inside
15
20
  your source code files (`package.json`). This creates a **State Conflict** that
16
21
  leads to several systemic problems:
17
22
 
18
- ### 1. Integrity Failure in standard workflow
19
23
  `npm version` and `git tag` are two distinct, non-atomic actions. If you tag a
20
24
  commit but forget to update the JSON (or vice-versa), your registry package and
21
25
  your Git history diverge. This scenario makes it impossible to guarantee that
22
- the code in the registry matches the code at that tag.
26
+ the code in the registry matches the code at that tag.
23
27
 
24
28
  **HIPP ensures they are fundamentally linked by extracting the version directly
25
- from the Git Tag.**
29
+ from the Git Tag, and cryptographically signing the package contents.**
30
+
31
+ ### No "Chore" Noise
26
32
 
27
- ### 2. Chore" Noise & Merge Conflicts
28
33
  Every release usually requires a "chore: bump version" commit. When multiple
29
34
  branches are developed simultaneously, these version changes cause constant,
30
- trivial merge conflicts.
35
+ trivial merge conflicts.
31
36
 
32
37
  **HIPP makes your `package.json` version immutable (0.0.0), so it never
33
38
  conflicts and your git history stays clean.**
34
39
 
35
40
  ---
36
41
 
37
- ## 🛠 Usage
42
+ ## Usage
43
+
44
+ ### Setup
38
45
 
39
- ### 1. The Setup Set your project's `package.json` version to `0.0.0`.
40
- This is the **HIPP Doctrine**.
46
+ 1. Set your project's `package.json` version to `0.0.0`. This is the **HIPP Doctrine**.
41
47
 
42
48
  ```json
43
49
  { "name": "your-package", "version": "0.0.0" }
44
50
  ```
45
51
 
46
- ### 2. Tag and Publish with HIPP
52
+ 2. Ensure `package-lock.json` exists and is tracked by git.
53
+
54
+ ### Tag and Publish
47
55
 
48
56
  ```bash
49
57
  git tag v1.0.0
@@ -51,26 +59,82 @@ npx @dk/hipp
51
59
  ```
52
60
 
53
61
  HIPP will:
54
- 1. **Verify**: Ensure the `0.0.0` doctrine is being followed.
55
- 2. **Clean Check**: Ensure your git status is clean (no uncommitted local
56
- "drift").
57
- 3. **Validate**: Extract and verify the latest tag against Semver rules.
58
- 4. **Confirm**: Ask for a 🚀 confirmation before ignition.
59
- 5. **Restore**: Automatically return your local files to `0.0.0` after the
60
- smoke clears.
62
+
63
+ 1. **Key Generation**: Generate Ed25519 signing keys if needed (`hipp.priv`, `hipp.pub`)
64
+ 2. **Verify**: Ensure the `0.0.0` doctrine is being followed
65
+ 3. **Clean Check**: Ensure your git status is clean
66
+ 4. **Validate**: Extract and verify the latest tag against Semver rules
67
+ 5. **Sign**: Create a cryptographic manifest of your package content
68
+ 6. **Publish**: Publish to npm from a staging directory (never mutating your source)
69
+ 7. **Confirm**: Ask for confirmation before ignition (skip with `-y`)
70
+
71
+ ### Signing Keys
72
+
73
+ On first run, HIPP generates an Ed25519 keypair:
74
+
75
+ - **`hipp.priv`** - Your private signing key. **Never committed to git.** Added to `.gitignore` automatically.
76
+ - **`hipp.pub`** - Your public verification key. **Committed to git** automatically.
77
+
78
+ The private key holder can sign packages. The public key verifies signatures.
61
79
 
62
80
  ### Options
63
- * `-y, --yes`: Skip the confirmation (ideal for CI/CD pipelines).
64
81
 
65
- If you need to pass additional flags to npm publish (like access or a custom registry), use the -- separator:
66
- Bash
82
+ * `-y, --yes`: Skip the confirmation prompt (ideal for CI/CD pipelines).
83
+
84
+ To pass additional flags to npm publish (like access or a custom registry), use `--`:
85
+
86
+ ```bash
87
+ npx @dk/hipp -- --access public --tag beta
88
+ ```
89
+
90
+ ---
91
+
92
+ ## Verification
93
+
94
+ HIPP provides out-of-band verification to guarantee package integrity:
95
+
96
+ ```bash
97
+ npx @dk/hipp verify @dk/your-package[@version]
98
+ ```
99
+
100
+ ### How Verification Works
101
+
102
+ 1. **Fetch from npm**: Downloads the package tarball and extracts the manifest
103
+ 2. **Fetch from git**: Clones the repository at the tagged commit and extracts the public key
104
+ 3. **Hash Verification**: Computes the hash of the git content and compares with the manifest
105
+ 4. **Signature Verification**: Verifies the manifest signature using the public key
106
+
107
+ If both checks pass, you can be certain that:
108
+ - The package contents in npm exactly match the git tag
109
+ - The manifest was signed by the holder of the private key
67
110
 
68
- `npx @dk/hipp -- --access public --tag beta`
111
+ ### Integrity Rules
69
112
 
113
+ HIPP enforces strict integrity rules:
114
+
115
+ - `package.json` version must be `0.0.0`
116
+ - `package-lock.json` must exist and be tracked by git
117
+ - `npm ci --ignore-scripts --dry-run` must succeed
118
+ - Repository must be clean
119
+ - HEAD must be on a branch with an upstream
120
+ - HEAD must exactly match upstream
121
+ - HEAD must have an exact v-prefixed semver tag
122
+ - The exact tag must exist on origin and match locally
123
+ - HEAD commit must be contained in an origin remote branch
124
+ - Only git-tracked files are staged
125
+ - Only staged `package.json` is rewritten
126
+
127
+ ---
128
+
129
+ ## Security
130
+
131
+ HIPP uses **Ed25519** public-key signatures. The private key never leaves your
132
+ machine. The public key is distributed via git. Anyone can verify a signed
133
+ package, but only private key holders can publish.
70
134
 
71
135
  ---
72
136
 
73
- ## ⚖️ License
137
+ ## License
74
138
 
75
139
  **0BSD** (BSD Zero Clause License) By Dmytri Kleiner <dev@dmytri.to>
76
140
 
@@ -83,5 +147,14 @@ FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
83
147
  INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
84
148
  LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
85
149
  OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
86
- PERFORMANCE OF THIS SOFTWARE. ```
150
+ PERFORMANCE OF THIS SOFTWARE.
151
+ ```json
152
+ {
153
+ "origin": "git@github.com:dmytri/hipp.git",
154
+ "tag": "v0.1.7"
155
+ }
156
+ ```
157
+
158
+ ```npx @dk/hipp @dk/hipp@0.1.7```
87
159
 
160
+ <!-- HIPP-MANIFEST -->```eyJoYXNoIjoiODYxNDgxZmM3Yzk0ZTM4YTJhZjdjNDc3ZmJkMDcyMTA2YTMyYTI2MjdkYmM1ZTg4MTI1OTk1ODMyYjdlMzhhMiIsInNpZ25hdHVyZSI6IkVnUTBSZUMzU0hKanovdFZoWlY0V3U1SVJOMDJTSlFPWUtxOFBrdHFpcUhvWnRTbE5pQWxrN25nWDcySldxL0ZRa2JTVE10TldlZUNrSG9hczAwR0N3PT0ifQ==```<!-- /HIPP-MANIFEST -->
package/hipp.js CHANGED
@@ -42,6 +42,126 @@ function sha256(input) {
42
42
  return crypto.createHash('sha256').update(input).digest('hex');
43
43
  }
44
44
 
45
+ function getPrivateKeyPath() {
46
+ return path.join(process.cwd(), 'hipp.priv');
47
+ }
48
+
49
+ function getPublicKeyPath() {
50
+ return path.join(process.cwd(), 'hipp.pub');
51
+ }
52
+
53
+ function generateKeyPair() {
54
+ const { generateKeyPairSync } = crypto;
55
+ const { privateKey, publicKey } = generateKeyPairSync('ed25519', {
56
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
57
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
58
+ });
59
+ return { privateKey, publicKey };
60
+ }
61
+
62
+ function loadOrGenerateKeys() {
63
+ const privPath = getPrivateKeyPath();
64
+ const pubPath = getPublicKeyPath();
65
+
66
+ if (fs.existsSync(privPath) && fs.existsSync(pubPath)) {
67
+ return {
68
+ privateKey: fs.readFileSync(privPath, 'utf8'),
69
+ publicKey: fs.readFileSync(pubPath, 'utf8'),
70
+ };
71
+ }
72
+
73
+ log.info('🔑 Generating Ed25519 keypair...');
74
+ const { privateKey, publicKey } = generateKeyPair();
75
+
76
+ fs.writeFileSync(privPath, privateKey, { mode: 0o600 });
77
+ fs.writeFileSync(pubPath, publicKey);
78
+
79
+ log.success('🔑 Keypair generated.');
80
+
81
+ const gitignorePath = path.join(process.cwd(), '.gitignore');
82
+ let gitignore = '';
83
+ if (fs.existsSync(gitignorePath)) {
84
+ gitignore = fs.readFileSync(gitignorePath, 'utf8');
85
+ }
86
+ if (!gitignore.includes('hipp.priv')) {
87
+ fs.writeFileSync(gitignorePath, gitignore.trimEnd() + '\nhipp.priv\n');
88
+ log.info('📝 Added hipp.priv to .gitignore');
89
+ }
90
+
91
+ return { privateKey, publicKey };
92
+ }
93
+
94
+ function signContent(data, privateKey) {
95
+ const signature = crypto.sign(null, Buffer.from(data), {
96
+ key: privateKey,
97
+ dsaEncoding: 'ieee-p1363',
98
+ });
99
+ return signature.toString('base64');
100
+ }
101
+
102
+ function verifySignature(data, signature, publicKey) {
103
+ const verify = crypto.verify;
104
+ return verify(null, Buffer.from(data), {
105
+ key: publicKey,
106
+ dsaEncoding: 'ieee-p1363',
107
+ }, Buffer.from(signature, 'base64'));
108
+ }
109
+
110
+ function createManifest(hash, signature) {
111
+ return Buffer.from(JSON.stringify({ hash, signature })).toString('base64');
112
+ }
113
+
114
+ function parseManifest(manifestBase64) {
115
+ try {
116
+ return JSON.parse(Buffer.from(manifestBase64, 'base64').toString('utf8'));
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ function buildSignData(hash, origin, tag) {
123
+ return `${hash}\n${origin}\n${tag}\n`;
124
+ }
125
+
126
+ const MANIFEST_START = '<!-- HIPP-MANIFEST -->';
127
+ const MANIFEST_END = '<!-- /HIPP-MANIFEST -->';
128
+
129
+ function appendManifestToReadme(readmeContent, manifest) {
130
+ return `${readmeContent}${MANIFEST_START}\`\`\`${manifest}\`\`\`${MANIFEST_END}\n`;
131
+ }
132
+
133
+ function extractManifestFromReadme(readmeContent) {
134
+ const startIdx = readmeContent.indexOf(MANIFEST_START);
135
+ if (startIdx === -1) return null;
136
+ const endIdx = readmeContent.indexOf(MANIFEST_END, startIdx);
137
+ if (endIdx === -1) return null;
138
+ const content = readmeContent.slice(startIdx + MANIFEST_START.length, endIdx).trim();
139
+ const match = content.match(/^```(.+)```$/s);
140
+ if (!match) return null;
141
+ return match[1];
142
+ }
143
+
144
+ function stripManifestFromReadme(readmeContent) {
145
+ const startIdx = readmeContent.indexOf(MANIFEST_START);
146
+ const endIdx = readmeContent.indexOf(MANIFEST_END, startIdx);
147
+ if (startIdx === -1 || endIdx === -1) return readmeContent;
148
+
149
+ const endLineIdx = readmeContent.indexOf('\n', endIdx);
150
+ const endOfManifest = endLineIdx !== -1 ? endLineIdx + 1 : readmeContent.length;
151
+
152
+ const beforeWithNewline = readmeContent.slice(0, startIdx);
153
+ const lastNewlineBefore = beforeWithNewline.lastIndexOf('\n');
154
+ const before = lastNewlineBefore !== -1 ? beforeWithNewline.slice(0, lastNewlineBefore) : beforeWithNewline;
155
+
156
+ const after = readmeContent.slice(endOfManifest);
157
+ return before + '\n' + after;
158
+ }
159
+
160
+ function computeReadmeHash(readmeContent) {
161
+ const stripped = stripManifestFromReadme(readmeContent);
162
+ return sha256(stripped);
163
+ }
164
+
45
165
  function safeStageName(name) {
46
166
  return name.replace(/[^a-zA-Z0-9._-]/g, '-');
47
167
  }
@@ -237,6 +357,106 @@ function copyTrackedFiles(stageDir, files) {
237
357
  }
238
358
  }
239
359
 
360
+ async function runVerify(packageSpec) {
361
+ const [pkgName, pkgVersion] = packageSpec.split('@');
362
+ log.info(`🔍 HIPP Verify: ${pkgName}${pkgVersion ? '@' + pkgVersion : ''}`);
363
+
364
+ const registryUrl = 'https://registry.npmjs.org';
365
+ const fetchUrl = `${registryUrl}/${encodeURIComponent(pkgName)}/${pkgVersion ? pkgVersion : 'latest'}`;
366
+
367
+ log.info(`📦 Fetching from npm...`);
368
+ let tarballUrl;
369
+ try {
370
+ const fetchResult = runCmd('curl', ['-s', '-L', '-w', '%{url_effective}', '-o', '/dev/null', fetchUrl]);
371
+ tarballUrl = fetchResult.stdout.trim();
372
+ } catch {
373
+ fail(`❌ Failed to fetch package info for ${pkgName}`);
374
+ }
375
+
376
+ const tarballPath = path.join(os.tmpdir(), `hipp-verify-${safeStageName(pkgName)}-tgz`);
377
+ try {
378
+ log.info(`📦 Downloading tarball...`);
379
+ const curlResult = runCmd('curl', ['-s', '-L', '-o', tarballPath, tarballUrl]);
380
+ if (curlResult.status !== 0) {
381
+ fail(`❌ Failed to download tarball`);
382
+ }
383
+
384
+ log.info(`📦 Extracting tarball...`);
385
+ runCmd('tar', ['-xzf', tarballPath, '-C', os.tmpdir()], { stdio: 'pipe' });
386
+
387
+ const packageDir = path.join(os.tmpdir(), 'package');
388
+ const stagedReadmePath = path.join(packageDir, 'README.md');
389
+
390
+ if (!fs.existsSync(stagedReadmePath)) {
391
+ fail(`❌ README.md not found in package`);
392
+ }
393
+
394
+ const stagedReadme = fs.readFileSync(stagedReadmePath, 'utf8');
395
+ const manifestBase64 = extractManifestFromReadme(stagedReadme);
396
+ if (!manifestBase64) {
397
+ fail(`❌ Manifest not found in README`);
398
+ }
399
+
400
+ const manifest = parseManifest(manifestBase64);
401
+ if (!manifest || !manifest.hash || !manifest.signature) {
402
+ fail(`❌ Invalid manifest format`);
403
+ }
404
+
405
+ log.info(`📦 Extracting tarball to staging...`);
406
+ runCmd('tar', ['-xzf', tarballPath, '-C', os.tmpdir()], { stdio: 'pipe' });
407
+
408
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `hipp-verify-git-`));
409
+ try {
410
+ log.info(`🌿 Fetching from git origin...`);
411
+
412
+ const originUrl = manifest.origin;
413
+ const tag = manifest.tag;
414
+
415
+ git(['clone', '--branch', tag, '--depth', '1', originUrl, tmpDir], { stdio: 'pipe' });
416
+
417
+ const clonedReadmePath = path.join(tmpDir, 'README.md');
418
+ if (!fs.existsSync(clonedReadmePath)) {
419
+ fail(`❌ README.md not found in git at tag ${tag}`);
420
+ }
421
+
422
+ const clonedReadme = fs.readFileSync(clonedReadmePath, 'utf8');
423
+ const clonedHash = computeReadmeHash(clonedReadme);
424
+
425
+ if (clonedHash !== manifest.hash) {
426
+ fail(`❌ Hash mismatch: git content does not match npm manifest`);
427
+ }
428
+
429
+ log.success(`🔒 Content hash verified: ${manifest.hash.slice(0, 12)}...`);
430
+
431
+ const publicKeyPath = path.join(tmpDir, 'hipp.pub');
432
+ if (!fs.existsSync(publicKeyPath)) {
433
+ fail(`❌ hipp.pub not found in git at tag ${tag}`);
434
+ }
435
+
436
+ const publicKey = fs.readFileSync(publicKeyPath, 'utf8');
437
+ const signData = buildSignData(manifest.hash, originUrl, tag);
438
+ const signatureValid = verifySignature(signData, manifest.signature, publicKey);
439
+
440
+ if (!signatureValid) {
441
+ fail(`❌ Signature verification failed`);
442
+ }
443
+
444
+ log.success(`🔏 Signature verified`);
445
+ log.success(`✅ Package ${pkgName} verified successfully!`);
446
+ log.info(`📍 Origin: ${originUrl}`);
447
+ log.info(`📍 Tag: ${tag}`);
448
+ } finally {
449
+ fs.rmSync(tmpDir, { recursive: true, force: true });
450
+ }
451
+ } finally {
452
+ fs.rmSync(tarballPath, { recursive: true, force: true });
453
+ const packageExtractDir = path.join(os.tmpdir(), 'package');
454
+ if (fs.existsSync(packageExtractDir)) {
455
+ fs.rmSync(packageExtractDir, { recursive: true, force: true });
456
+ }
457
+ }
458
+ }
459
+
240
460
  async function confirmPrompt(name, version) {
241
461
  const rl = readline.createInterface({
242
462
  input: process.stdin,
@@ -266,6 +486,19 @@ async function run() {
266
486
  }
267
487
 
268
488
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
489
+
490
+ loadOrGenerateKeys();
491
+
492
+ const pubPath = getPublicKeyPath();
493
+ try {
494
+ git(['ls-files', '--error-unmatch', 'hipp.pub']);
495
+ } catch {
496
+ log.info('📝 Committing hipp.pub to repo...');
497
+ git(['add', 'hipp.pub']);
498
+ git(['commit', '-m', 'Add hipp public key for package signing']);
499
+ log.success('📝 hipp.pub committed.');
500
+ }
501
+
269
502
  ensureCleanRepo(pkg);
270
503
 
271
504
  const { rawTag, version } = getVersionFromExactTagOnHead();
@@ -296,11 +529,31 @@ async function run() {
296
529
  log.info(`🏗️ Staging tracked files to ${stageDir}...`);
297
530
  copyTrackedFiles(stageDir, trackedFiles);
298
531
 
532
+ const { privateKey } = loadOrGenerateKeys();
533
+
299
534
  const stagedPkgPath = path.join(stageDir, 'package.json');
300
535
  const stagedPkg = JSON.parse(fs.readFileSync(stagedPkgPath, 'utf8'));
301
536
  stagedPkg.version = version;
302
537
  fs.writeFileSync(stagedPkgPath, JSON.stringify(stagedPkg, null, 2) + '\n');
303
538
 
539
+ const stagedReadmePath = path.join(stageDir, 'README.md');
540
+ let stagedReadme = '';
541
+ if (fs.existsSync(stagedReadmePath)) {
542
+ stagedReadme = fs.readFileSync(stagedReadmePath, 'utf8');
543
+ }
544
+
545
+ stagedReadme += '\n```json\n{\n "origin": "' + provenance.remoteUrl + '",\n "tag": "' + rawTag + '"\n}\n```\n\n```npx @dk/hipp ' + pkg.name + '@' + version + '```\n\n';
546
+
547
+ const readmeHash = computeReadmeHash(stagedReadme);
548
+ const dataToSign = buildSignData(readmeHash, provenance.remoteUrl, rawTag);
549
+ const signature = signContent(dataToSign, privateKey);
550
+ const manifest = createManifest(readmeHash, signature);
551
+ stagedReadme = appendManifestToReadme(stagedReadme, manifest);
552
+
553
+ fs.writeFileSync(stagedReadmePath, stagedReadme);
554
+
555
+ log.success('🔏 Manifest signed.');
556
+
304
557
  log.info('🔥 Ignition...');
305
558
 
306
559
  const result = spawnSync('npm', ['publish', ...npmArgs], {
@@ -330,11 +583,18 @@ async function run() {
330
583
  }
331
584
  }
332
585
 
333
- if (process.argv.includes('--help') || process.argv.includes('-h')) {
586
+ const isVerify = process.argv.includes('verify');
587
+ const verifyIndex = process.argv.indexOf('verify');
588
+ const packageSpec = verifyIndex !== -1 ? process.argv[verifyIndex + 1] : null;
589
+
590
+ if (isVerify && packageSpec) {
591
+ runVerify(packageSpec);
592
+ } else if (process.argv.includes('--help') || process.argv.includes('-h')) {
334
593
  console.log(`\x1b[36mHIPP - High Integrity Package Publisher\x1b[0m
335
594
 
336
595
  Usage:
337
596
  npx hipp [options] [-- npm-options]
597
+ npx hipp verify <package>[@version]
338
598
 
339
599
  Options:
340
600
  -y, --yes Skip confirmation prompt
package/hipp.pub ADDED
@@ -0,0 +1,3 @@
1
+ -----BEGIN PUBLIC KEY-----
2
+ MCowBQYDK2VwAyEA4/mM4ila/URYTYCmHIA33cuDFWtauGHo6TImvHimVa8=
3
+ -----END PUBLIC KEY-----
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dk/hipp",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "High Integrity Package Publisher",
5
5
  "main": "hipp.js",
6
6
  "bin": {