@wpelevator/wp-release 0.13.0 → 0.15.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.
Files changed (62) hide show
  1. package/README.md +83 -25
  2. package/dist/changeset-config.d.ts +8 -6
  3. package/dist/changeset-config.js +7 -5
  4. package/dist/changeset-packages.d.ts +12 -2
  5. package/dist/changeset-packages.js +13 -1
  6. package/dist/changeset-release.d.ts +5 -2
  7. package/dist/changeset-release.js +12 -6
  8. package/dist/cli.d.ts +16 -7
  9. package/dist/cli.js +58 -11
  10. package/dist/commands/args.d.ts +11 -21
  11. package/dist/commands/args.js +24 -35
  12. package/dist/commands/changeset-add.d.ts +1 -1
  13. package/dist/commands/changeset-add.js +3 -2
  14. package/dist/commands/changeset-status.d.ts +1 -1
  15. package/dist/commands/changeset-status.js +2 -1
  16. package/dist/commands/changeset-tag.d.ts +1 -1
  17. package/dist/commands/changeset-tag.js +4 -2
  18. package/dist/commands/changeset-version.d.ts +1 -1
  19. package/dist/commands/changeset-version.js +2 -1
  20. package/dist/commands/checksums.js +2 -2
  21. package/dist/commands/command.d.ts +5 -0
  22. package/dist/commands/deploy-git.d.ts +1 -1
  23. package/dist/commands/deploy-git.js +2 -1
  24. package/dist/commands/deploy-svn.d.ts +11 -15
  25. package/dist/commands/deploy-svn.js +26 -5
  26. package/dist/commands/keygen.d.ts +3 -0
  27. package/dist/commands/keygen.js +6 -1
  28. package/dist/commands/package-deploys.d.ts +8 -0
  29. package/dist/commands/package-deploys.js +5 -0
  30. package/dist/commands/sign.d.ts +10 -1
  31. package/dist/commands/sign.js +29 -31
  32. package/dist/commands/verify.d.ts +3 -0
  33. package/dist/commands/verify.js +43 -24
  34. package/dist/commands/zip.d.ts +23 -8
  35. package/dist/commands/zip.js +171 -73
  36. package/dist/ed25519.d.ts +25 -0
  37. package/dist/ed25519.js +44 -0
  38. package/dist/file-selection.d.ts +21 -0
  39. package/dist/file-selection.js +35 -0
  40. package/dist/git.d.ts +17 -0
  41. package/dist/git.js +23 -0
  42. package/dist/index.d.ts +11 -5
  43. package/dist/index.js +6 -3
  44. package/dist/minisign.d.ts +41 -0
  45. package/dist/minisign.js +128 -0
  46. package/dist/package-deploy.d.ts +28 -0
  47. package/dist/package-deploy.js +41 -6
  48. package/dist/package-zip.d.ts +52 -0
  49. package/dist/package-zip.js +78 -0
  50. package/dist/release-repository.d.ts +4 -3
  51. package/dist/release-repository.js +5 -4
  52. package/dist/signing.d.ts +100 -9
  53. package/dist/signing.js +165 -41
  54. package/dist/ssh-signature.d.ts +43 -0
  55. package/dist/ssh-signature.js +156 -0
  56. package/dist/summary.d.ts +3 -3
  57. package/dist/summary.js +2 -2
  58. package/dist/tag-format.d.ts +21 -12
  59. package/dist/tag-format.js +25 -39
  60. package/dist/version-files.d.ts +6 -7
  61. package/dist/version-files.js +10 -11
  62. package/package.json +2 -2
@@ -0,0 +1,78 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { CHECKSUMS_EXTENSION, createChecksums, writeChecksums, } from "./checksums.js";
3
+ import { ValidationError } from "./errors.js";
4
+ import { ignoreOwnFile, ignoreOwnSignature, loadFileRules, selectFiles, } from "./file-selection.js";
5
+ import { createZip } from "./zip.js";
6
+ const ZIP_EXTENSION = '.zip';
7
+ /**
8
+ * Get the checksums manifest path next to an archive, with .checksums.json
9
+ * instead of .zip.
10
+ *
11
+ * @param zipPath Archive path.
12
+ * @return Manifest path.
13
+ */
14
+ export function getChecksumsPath(zipPath) {
15
+ const name = zipPath.endsWith(ZIP_EXTENSION)
16
+ ? zipPath.slice(0, -ZIP_EXTENSION.length)
17
+ : zipPath;
18
+ return name + CHECKSUMS_EXTENSION;
19
+ }
20
+ /**
21
+ * Fail before writing anything when an output file exists.
22
+ *
23
+ * @param paths Output paths.
24
+ * @param force Whether existing files are overwritten.
25
+ * @throws {ValidationError} If a file exists and force isn't set.
26
+ */
27
+ function checkOverwrite(paths, force) {
28
+ const existing = paths.filter((path) => !force && existsSync(path));
29
+ if (existing.length) {
30
+ throw new ValidationError(`${existing.join(' and ')} already exists, pass --force to overwrite it.`);
31
+ }
32
+ }
33
+ /**
34
+ * Write a ZIP of the selected files of a plugin, theme or package, and
35
+ * optionally the checksums manifest of the same files next to it. The zip
36
+ * command, with a directory or a configured package release, and the
37
+ * snapshot builds of a monorepo all use it, so that every ZIP of a package
38
+ * selects its files the same way. Output files inside the source directory,
39
+ * and their signatures, are never archived.
40
+ *
41
+ * @param input Files, archive path and manifest options.
42
+ * @return Paths of the written files and the number of archived files.
43
+ * @throws {ValidationError} Without selected files, or when an output file exists without force.
44
+ */
45
+ export async function zipPackage(input) {
46
+ const { sourceDir, zipPath, force = false } = input;
47
+ const checksumsPath = input.checksums ? getChecksumsPath(zipPath) : null;
48
+ const outputs = [zipPath, checksumsPath].filter((path) => null !== path);
49
+ const rules = await loadFileRules(input.selection);
50
+ outputs.forEach((path) => ignoreOwnSignature(ignoreOwnFile(rules, sourceDir, path), sourceDir, path));
51
+ const files = await selectFiles(sourceDir, rules);
52
+ if (!files.length) {
53
+ throw new ValidationError(`No files to archive in ${sourceDir}.`);
54
+ }
55
+ checkOverwrite(outputs, force);
56
+ await createZip({
57
+ sourceDir,
58
+ files,
59
+ zipPath,
60
+ rootFolder: input.rootFolder,
61
+ force,
62
+ });
63
+ if (checksumsPath) {
64
+ const manifest = await createChecksums({
65
+ sourceDir,
66
+ rules,
67
+ slug: input.slug,
68
+ version: input.version ?? null,
69
+ type: input.type,
70
+ });
71
+ await writeChecksums(checksumsPath, manifest, force);
72
+ }
73
+ return {
74
+ zipPath,
75
+ ...(checksumsPath ? { checksumsPath } : {}),
76
+ files: files.length,
77
+ };
78
+ }
@@ -66,11 +66,12 @@ export declare class ReleaseRepository {
66
66
  */
67
67
  createTag(tag: string): Promise<void>;
68
68
  /**
69
- * Push a tag to the origin remote.
69
+ * Push a tag to a remote.
70
70
  *
71
- * @param tag Tag name.
71
+ * @param tag Tag name.
72
+ * @param remote Remote name.
72
73
  */
73
- pushTag(tag: string): Promise<void>;
74
+ pushTag(tag: string, remote: string): Promise<void>;
74
75
  /**
75
76
  * Find the commit that added a file.
76
77
  *
@@ -78,12 +78,13 @@ export class ReleaseRepository {
78
78
  await this.run(['tag', tag]);
79
79
  }
80
80
  /**
81
- * Push a tag to the origin remote.
81
+ * Push a tag to a remote.
82
82
  *
83
- * @param tag Tag name.
83
+ * @param tag Tag name.
84
+ * @param remote Remote name.
84
85
  */
85
- async pushTag(tag) {
86
- await this.run(['push', 'origin', tag]);
86
+ async pushTag(tag, remote) {
87
+ await this.run(['push', remote, tag]);
87
88
  }
88
89
  /**
89
90
  * Find the commit that added a file.
package/dist/signing.d.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  import type { KeyObject } from 'node:crypto';
2
+ /**
3
+ * Extension of the signature files in the format that WordPress core's
4
+ * verify_file_signature() checks.
5
+ */
2
6
  export declare const SIGNATURE_EXTENSION = ".sig";
3
7
  /**
4
8
  * Environment variable with the base64 Ed25519 signing key.
@@ -7,11 +11,12 @@ export declare const SIGNING_KEY_ENV = "WP_RELEASE_SIGNING_KEY";
7
11
  /**
8
12
  * Generate an Ed25519 key pair in the base64 formats used by wp-release.
9
13
  *
10
- * @return Private seed for WP_RELEASE_SIGNING_KEY and public verification key.
14
+ * @return Private seed for WP_RELEASE_SIGNING_KEY, the base64 public key and the public key of each signature format.
11
15
  */
12
16
  export declare function generateSigningKeyPair(): {
13
17
  privateKey: string;
14
18
  publicKey: string;
19
+ publicKeys: Record<SignatureFormat, string>;
15
20
  };
16
21
  /**
17
22
  * Get an Ed25519 private key from the base64 format that sodium and the
@@ -24,11 +29,13 @@ export declare function generateSigningKeyPair(): {
24
29
  */
25
30
  export declare function parseSigningKey(encoded: string): KeyObject;
26
31
  /**
27
- * Get an Ed25519 public key from its base64 form.
32
+ * Get an Ed25519 public key from the base64 32-byte key, a minisign public
33
+ * key or an OpenSSH ssh-ed25519 public key. They're forms of the same key,
34
+ * so any of them checks the signatures of every format.
28
35
  *
29
- * @param encoded Base64 32-byte public key.
36
+ * @param encoded Public key.
30
37
  * @return Public key.
31
- * @throws {ValidationError} If the key has an unsupported length.
38
+ * @throws {ValidationError} If the key isn't in one of the forms.
32
39
  */
33
40
  export declare function parsePublicKey(encoded: string): KeyObject;
34
41
  /**
@@ -55,24 +62,108 @@ export declare function getFileDigest(path: string): Promise<Buffer>;
55
62
  * @return Base64 signature.
56
63
  */
57
64
  export declare function signFile(path: string, privateKey: KeyObject): Promise<string>;
65
+ /** Names of the signature formats. */
66
+ export type SignatureFormat = 'wordpress' | 'minisign' | 'ssh';
67
+ /** How a signature format writes and checks signature files. */
68
+ export interface SignatureFormatHandler {
69
+ /** Extension of the signature file, appended to the signed file name. */
70
+ extension: string;
71
+ /** Get the contents of the signature file of a file. */
72
+ create: (path: string, privateKey: KeyObject) => Promise<string>;
73
+ /** Check a file against its signature file. */
74
+ verify: (path: string, signaturePath: string, publicKey: KeyObject) => Promise<boolean>;
75
+ /** Get the public key in the form that the verifiers of the format take. */
76
+ getPublicKey: (key: KeyObject) => string;
77
+ }
78
+ export declare const DEFAULT_SIGNATURE_FORMAT: SignatureFormat;
79
+ /**
80
+ * Signature formats, all Ed25519 signatures by the same key:
81
+ *
82
+ * - wordpress: base64 signature of the SHA-384 digest in <file>.sig, which
83
+ * WordPress core's verify_file_signature() checks.
84
+ * - minisign: <file>.minisig, which `minisign -V` checks.
85
+ * - ssh: armored SSH signature in <file>.sshsig, which `ssh-keygen -Y verify`
86
+ * checks with the "file" namespace.
87
+ */
88
+ export declare const SIGNATURE_FORMATS: Record<SignatureFormat, SignatureFormatHandler>;
58
89
  /**
59
- * Write the signature of a file to a .sig file next to it.
90
+ * Check that a value is the name of a signature format.
91
+ *
92
+ * @param value Value to check.
93
+ * @return Whether it's a signature format.
94
+ */
95
+ export declare function isSignatureFormat(value: string): value is SignatureFormat;
96
+ /**
97
+ * Get the signature file path of a file.
98
+ *
99
+ * @param path File path.
100
+ * @param format Signature format.
101
+ * @return Signature file path.
102
+ */
103
+ export declare function getSignaturePath(path: string, format?: SignatureFormat): string;
104
+ /**
105
+ * Get the public key of a key in the form of each signature format.
106
+ *
107
+ * @param key Private or public key.
108
+ * @return Public keys by format.
109
+ */
110
+ export declare function getPublicKeys(key: KeyObject): Record<SignatureFormat, string>;
111
+ /**
112
+ * Write the signature of a file to a signature file next to it.
60
113
  *
61
114
  * @param path File path.
62
115
  * @param privateKey Private key from parseSigningKey().
63
116
  * @param force Whether to overwrite an existing signature file.
117
+ * @param format Signature format.
64
118
  * @return Signature file path.
65
119
  * @throws {ValidationError} If the signature file exists and force isn't set.
66
120
  */
67
- export declare function writeSignature(path: string, privateKey: KeyObject, force?: boolean): Promise<string>;
121
+ export declare function writeSignature(path: string, privateKey: KeyObject, force?: boolean, format?: SignatureFormat): Promise<string>;
122
+ export interface SignFilesInput {
123
+ /** Absolute paths of the files to sign. */
124
+ paths: string[];
125
+ /** Signature formats to write, the WordPress .sig by default. */
126
+ formats?: SignatureFormat[];
127
+ /** Base64 signing key, from the WP_RELEASE_SIGNING_KEY environment variable by default. */
128
+ key?: string;
129
+ /** Overwrite existing signature files. */
130
+ force?: boolean;
131
+ }
132
+ export interface SignFilesResult {
133
+ /** Absolute paths of the written signature files. */
134
+ signatures: string[];
135
+ /** Base64 public key of the signing key. */
136
+ publicKey: string;
137
+ /** Public key of the signing key in the form of each signature format. */
138
+ publicKeys: Record<SignatureFormat, string>;
139
+ }
140
+ /**
141
+ * Validate the signing key and destinations before writing files. The input
142
+ * files need not exist yet, so zip can check before creating its artifacts.
143
+ *
144
+ * @param input Files, formats, key and whether to overwrite.
145
+ * @return Parsed signing key.
146
+ * @throws {ValidationError} Without a valid key or when a signature already exists without force.
147
+ */
148
+ export declare function checkSigningInput(input: SignFilesInput): KeyObject;
149
+ /**
150
+ * Sign files with a signature file of each format next to each, in order,
151
+ * so that a failure leaves a predictable set of files.
152
+ *
153
+ * @param input Files, formats, key and whether to overwrite.
154
+ * @return The written signature files and the public keys.
155
+ * @throws {ValidationError} Without a key, for an invalid key, a missing file or an existing signature file without force.
156
+ */
157
+ export declare function signFiles(input: SignFilesInput): Promise<SignFilesResult>;
68
158
  /**
69
- * Check a file against a .sig file, which holds one base64 signature per
70
- * line. The file is valid when any of the signatures match, so that a file
159
+ * Check a file against a signature file. A wordpress .sig file can hold one
160
+ * signature per line, and is valid when any of them match, so that a file
71
161
  * can carry the signatures of a key rotation window.
72
162
  *
73
163
  * @param path File path.
74
164
  * @param signaturePath Signature file path.
75
165
  * @param publicKey Public key from parsePublicKey().
166
+ * @param format Signature format.
76
167
  * @return Whether a signature matches.
77
168
  */
78
- export declare function verifyFileSignature(path: string, signaturePath: string, publicKey: KeyObject): Promise<boolean>;
169
+ export declare function verifyFileSignature(path: string, signaturePath: string, publicKey: KeyObject, format?: SignatureFormat): Promise<boolean>;
package/dist/signing.js CHANGED
@@ -1,7 +1,14 @@
1
- import { createHash, createPrivateKey, generateKeyPairSync, createPublicKey, sign, verify, } from 'node:crypto';
2
- import { createReadStream } from 'node:fs';
3
- import { readFile, writeFile } from 'node:fs/promises';
1
+ import { createPrivateKey, generateKeyPairSync, sign, verify, } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { readFile, stat, writeFile } from 'node:fs/promises';
4
+ import { PUBLIC_KEY_BYTES, createRawPublicKey, digestFile, getRawPublicKey, } from "./ed25519.js";
4
5
  import { ValidationError } from "./errors.js";
6
+ import { MINISIGN_EXTENSION, createMinisignSignature, getMinisignPublicKey, parseMinisignPublicKey, verifyMinisignSignature, } from "./minisign.js";
7
+ import { SSH_SIGNATURE_EXTENSION, createSshSignature, getSshPublicKey, parseSshPublicKey, verifySshSignature, } from "./ssh-signature.js";
8
+ /**
9
+ * Extension of the signature files in the format that WordPress core's
10
+ * verify_file_signature() checks.
11
+ */
5
12
  export const SIGNATURE_EXTENSION = '.sig';
6
13
  /**
7
14
  * Environment variable with the base64 Ed25519 signing key.
@@ -9,15 +16,12 @@ export const SIGNATURE_EXTENSION = '.sig';
9
16
  export const SIGNING_KEY_ENV = 'WP_RELEASE_SIGNING_KEY';
10
17
  const SEED_BYTES = 32;
11
18
  const SECRET_KEY_BYTES = 64;
12
- const PUBLIC_KEY_BYTES = 32;
13
19
  // DER prefix of a PKCS#8 Ed25519 private key, followed by the 32-byte seed.
14
20
  const PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
15
- // DER prefix of a SPKI Ed25519 public key, followed by the 32-byte key.
16
- const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
17
21
  /**
18
22
  * Generate an Ed25519 key pair in the base64 formats used by wp-release.
19
23
  *
20
- * @return Private seed for WP_RELEASE_SIGNING_KEY and public verification key.
24
+ * @return Private seed for WP_RELEASE_SIGNING_KEY, the base64 public key and the public key of each signature format.
21
25
  */
22
26
  export function generateSigningKeyPair() {
23
27
  const { privateKey, publicKey } = generateKeyPairSync('ed25519');
@@ -29,6 +33,7 @@ export function generateSigningKeyPair() {
29
33
  return {
30
34
  privateKey: Buffer.from(privateJwk.d, 'base64url').toString('base64'),
31
35
  publicKey: Buffer.from(publicJwk.x, 'base64url').toString('base64'),
36
+ publicKeys: getPublicKeys(publicKey),
32
37
  };
33
38
  }
34
39
  /**
@@ -61,22 +66,22 @@ export function parseSigningKey(encoded) {
61
66
  });
62
67
  }
63
68
  /**
64
- * Get an Ed25519 public key from its base64 form.
69
+ * Get an Ed25519 public key from the base64 32-byte key, a minisign public
70
+ * key or an OpenSSH ssh-ed25519 public key. They're forms of the same key,
71
+ * so any of them checks the signatures of every format.
65
72
  *
66
- * @param encoded Base64 32-byte public key.
73
+ * @param encoded Public key.
67
74
  * @return Public key.
68
- * @throws {ValidationError} If the key has an unsupported length.
75
+ * @throws {ValidationError} If the key isn't in one of the forms.
69
76
  */
70
77
  export function parsePublicKey(encoded) {
71
- const bytes = decodeKey(encoded);
78
+ const bytes = parseSshPublicKey(encoded) ??
79
+ parseMinisignPublicKey(encoded) ??
80
+ decodeKey(encoded);
72
81
  if (PUBLIC_KEY_BYTES !== bytes.length) {
73
- throw new ValidationError(`The public key must be a base64 ${PUBLIC_KEY_BYTES}-byte Ed25519 key, got ${bytes.length} bytes.`);
82
+ throw new ValidationError(`The public key must be a base64 ${PUBLIC_KEY_BYTES}-byte Ed25519 key, a minisign public key or an ssh-ed25519 public key, got ${bytes.length} bytes.`);
74
83
  }
75
- return createPublicKey({
76
- key: Buffer.concat([SPKI_PREFIX, bytes]),
77
- format: 'der',
78
- type: 'spki',
79
- });
84
+ return createRawPublicKey(bytes);
80
85
  }
81
86
  /**
82
87
  * Get the base64 public key for a private key, in the format that
@@ -86,11 +91,7 @@ export function parsePublicKey(encoded) {
86
91
  * @return Base64 public key.
87
92
  */
88
93
  export function getPublicKey(privateKey) {
89
- const der = createPublicKey(privateKey).export({
90
- format: 'der',
91
- type: 'spki',
92
- });
93
- return der.subarray(SPKI_PREFIX.length).toString('base64');
94
+ return getRawPublicKey(privateKey).toString('base64');
94
95
  }
95
96
  /**
96
97
  * Get the raw SHA-384 digest of a file, which is what WordPress core's
@@ -100,11 +101,7 @@ export function getPublicKey(privateKey) {
100
101
  * @return Digest bytes.
101
102
  */
102
103
  export async function getFileDigest(path) {
103
- const hash = createHash('sha384');
104
- for await (const chunk of createReadStream(path)) {
105
- hash.update(chunk);
106
- }
107
- return hash.digest();
104
+ return digestFile(path, 'sha384');
108
105
  }
109
106
  /**
110
107
  * Sign a file with a detached Ed25519 signature of its raw SHA-384 digest.
@@ -117,18 +114,99 @@ export async function signFile(path, privateKey) {
117
114
  return sign(null, await getFileDigest(path), privateKey).toString('base64');
118
115
  }
119
116
  /**
120
- * Write the signature of a file to a .sig file next to it.
117
+ * Check a file against a .sig file, which holds one base64 signature per
118
+ * line. The file is valid when any of the signatures match, so that a file
119
+ * can carry the signatures of a key rotation window.
120
+ *
121
+ * @param path File path.
122
+ * @param signaturePath Signature file path.
123
+ * @param publicKey Public key.
124
+ * @return Whether a signature matches.
125
+ */
126
+ async function verifyWordPressSignature(path, signaturePath, publicKey) {
127
+ const digest = await getFileDigest(path);
128
+ const signatures = (await readFile(signaturePath, 'utf8'))
129
+ .split('\n')
130
+ .map((line) => line.trim())
131
+ .filter(Boolean);
132
+ return signatures.some((signature) => verify(null, digest, publicKey, Buffer.from(signature, 'base64')));
133
+ }
134
+ export const DEFAULT_SIGNATURE_FORMAT = 'wordpress';
135
+ /**
136
+ * Signature formats, all Ed25519 signatures by the same key:
137
+ *
138
+ * - wordpress: base64 signature of the SHA-384 digest in <file>.sig, which
139
+ * WordPress core's verify_file_signature() checks.
140
+ * - minisign: <file>.minisig, which `minisign -V` checks.
141
+ * - ssh: armored SSH signature in <file>.sshsig, which `ssh-keygen -Y verify`
142
+ * checks with the "file" namespace.
143
+ */
144
+ export const SIGNATURE_FORMATS = {
145
+ wordpress: {
146
+ extension: SIGNATURE_EXTENSION,
147
+ create: async (path, privateKey) => (await signFile(path, privateKey)) + '\n',
148
+ verify: verifyWordPressSignature,
149
+ getPublicKey,
150
+ },
151
+ minisign: {
152
+ extension: MINISIGN_EXTENSION,
153
+ create: createMinisignSignature,
154
+ verify: verifyMinisignSignature,
155
+ getPublicKey: getMinisignPublicKey,
156
+ },
157
+ ssh: {
158
+ extension: SSH_SIGNATURE_EXTENSION,
159
+ create: createSshSignature,
160
+ verify: verifySshSignature,
161
+ getPublicKey: getSshPublicKey,
162
+ },
163
+ };
164
+ /**
165
+ * Check that a value is the name of a signature format.
166
+ *
167
+ * @param value Value to check.
168
+ * @return Whether it's a signature format.
169
+ */
170
+ export function isSignatureFormat(value) {
171
+ return Object.hasOwn(SIGNATURE_FORMATS, value);
172
+ }
173
+ /**
174
+ * Get the signature file path of a file.
175
+ *
176
+ * @param path File path.
177
+ * @param format Signature format.
178
+ * @return Signature file path.
179
+ */
180
+ export function getSignaturePath(path, format = DEFAULT_SIGNATURE_FORMAT) {
181
+ return path + SIGNATURE_FORMATS[format].extension;
182
+ }
183
+ /**
184
+ * Get the public key of a key in the form of each signature format.
185
+ *
186
+ * @param key Private or public key.
187
+ * @return Public keys by format.
188
+ */
189
+ export function getPublicKeys(key) {
190
+ return {
191
+ wordpress: SIGNATURE_FORMATS.wordpress.getPublicKey(key),
192
+ minisign: SIGNATURE_FORMATS.minisign.getPublicKey(key),
193
+ ssh: SIGNATURE_FORMATS.ssh.getPublicKey(key),
194
+ };
195
+ }
196
+ /**
197
+ * Write the signature of a file to a signature file next to it.
121
198
  *
122
199
  * @param path File path.
123
200
  * @param privateKey Private key from parseSigningKey().
124
201
  * @param force Whether to overwrite an existing signature file.
202
+ * @param format Signature format.
125
203
  * @return Signature file path.
126
204
  * @throws {ValidationError} If the signature file exists and force isn't set.
127
205
  */
128
- export async function writeSignature(path, privateKey, force = false) {
129
- const signaturePath = path + SIGNATURE_EXTENSION;
130
- const signature = await signFile(path, privateKey);
131
- await writeFile(signaturePath, signature + '\n', {
206
+ export async function writeSignature(path, privateKey, force = false, format = DEFAULT_SIGNATURE_FORMAT) {
207
+ const signaturePath = getSignaturePath(path, format);
208
+ const signature = await SIGNATURE_FORMATS[format].create(path, privateKey);
209
+ await writeFile(signaturePath, signature, {
132
210
  flag: force ? 'w' : 'wx',
133
211
  }).catch((error) => {
134
212
  throw 'EEXIST' === error.code
@@ -138,20 +216,66 @@ export async function writeSignature(path, privateKey, force = false) {
138
216
  return signaturePath;
139
217
  }
140
218
  /**
141
- * Check a file against a .sig file, which holds one base64 signature per
142
- * line. The file is valid when any of the signatures match, so that a file
219
+ * Validate the signing key and destinations before writing files. The input
220
+ * files need not exist yet, so zip can check before creating its artifacts.
221
+ *
222
+ * @param input Files, formats, key and whether to overwrite.
223
+ * @return Parsed signing key.
224
+ * @throws {ValidationError} Without a valid key or when a signature already exists without force.
225
+ */
226
+ export function checkSigningInput(input) {
227
+ const encoded = input.key ?? process.env[SIGNING_KEY_ENV];
228
+ const formats = input.formats ?? [DEFAULT_SIGNATURE_FORMAT];
229
+ if (!encoded?.trim()) {
230
+ throw new ValidationError(`Set the ${SIGNING_KEY_ENV} environment variable to the base64 Ed25519 signing key.`);
231
+ }
232
+ const privateKey = parseSigningKey(encoded);
233
+ const existing = input.paths
234
+ .flatMap((path) => formats.map((format) => getSignaturePath(path, format)))
235
+ .filter((path) => !input.force && existsSync(path));
236
+ if (existing.length) {
237
+ throw new ValidationError(`${existing.join(' and ')} already exists, pass --force to overwrite it.`);
238
+ }
239
+ return privateKey;
240
+ }
241
+ /**
242
+ * Sign files with a signature file of each format next to each, in order,
243
+ * so that a failure leaves a predictable set of files.
244
+ *
245
+ * @param input Files, formats, key and whether to overwrite.
246
+ * @return The written signature files and the public keys.
247
+ * @throws {ValidationError} Without a key, for an invalid key, a missing file or an existing signature file without force.
248
+ */
249
+ export async function signFiles(input) {
250
+ const formats = input.formats ?? [DEFAULT_SIGNATURE_FORMAT];
251
+ const privateKey = checkSigningInput(input);
252
+ await Promise.all(input.paths.map(async (path) => {
253
+ if (!(await stat(path).catch(() => null))?.isFile()) {
254
+ throw new ValidationError(`${path} is not a file.`);
255
+ }
256
+ }));
257
+ const signatures = [];
258
+ const jobs = input.paths.flatMap((path) => formats.map((format) => ({ path, format })));
259
+ for (const { path, format } of jobs) {
260
+ signatures.push(await writeSignature(path, privateKey, input.force, format));
261
+ }
262
+ return {
263
+ signatures,
264
+ publicKey: getPublicKey(privateKey),
265
+ publicKeys: getPublicKeys(privateKey),
266
+ };
267
+ }
268
+ /**
269
+ * Check a file against a signature file. A wordpress .sig file can hold one
270
+ * signature per line, and is valid when any of them match, so that a file
143
271
  * can carry the signatures of a key rotation window.
144
272
  *
145
273
  * @param path File path.
146
274
  * @param signaturePath Signature file path.
147
275
  * @param publicKey Public key from parsePublicKey().
276
+ * @param format Signature format.
148
277
  * @return Whether a signature matches.
149
278
  */
150
- export async function verifyFileSignature(path, signaturePath, publicKey) {
151
- const digest = await getFileDigest(path);
152
- const signatures = (await readFile(signaturePath, 'utf8'))
153
- .split('\n')
154
- .map((line) => line.trim())
155
- .filter(Boolean);
156
- return signatures.some((signature) => verify(null, digest, publicKey, Buffer.from(signature, 'base64')));
279
+ export async function verifyFileSignature(path, signaturePath, publicKey, format = DEFAULT_SIGNATURE_FORMAT) {
280
+ return SIGNATURE_FORMATS[format].verify(path, signaturePath, publicKey);
157
281
  }
@@ -0,0 +1,43 @@
1
+ import type { KeyObject } from 'node:crypto';
2
+ export declare const SSH_SIGNATURE_EXTENSION = ".sshsig";
3
+ /**
4
+ * Namespace of the signatures, which `ssh-keygen -Y verify -n` must match,
5
+ * so that a signature for files can't be used as one for something else.
6
+ */
7
+ export declare const SSH_SIGNATURE_NAMESPACE = "file";
8
+ /**
9
+ * Get the OpenSSH public key of a key, the line of an authorized_keys or
10
+ * allowed_signers file.
11
+ *
12
+ * @param key Private or public key.
13
+ * @return OpenSSH public key.
14
+ */
15
+ export declare function getSshPublicKey(key: KeyObject): string;
16
+ /**
17
+ * Get the raw public key from an OpenSSH Ed25519 public key line, with or
18
+ * without a comment.
19
+ *
20
+ * @param encoded OpenSSH public key.
21
+ * @return Public key bytes, or null when it isn't an OpenSSH Ed25519 key.
22
+ */
23
+ export declare function parseSshPublicKey(encoded: string): Buffer | null;
24
+ /**
25
+ * Create an SSH signature of a file, the armored format that
26
+ * `ssh-keygen -Y sign` writes, with the "file" namespace and a SHA-512
27
+ * digest.
28
+ *
29
+ * @param path File path.
30
+ * @param privateKey Private key.
31
+ * @return Contents of the signature file.
32
+ */
33
+ export declare function createSshSignature(path: string, privateKey: KeyObject): Promise<string>;
34
+ /**
35
+ * Check a file against an SSH signature file. The signature must be made by
36
+ * the public key, for the "file" namespace, with a SHA-512 or SHA-256 digest.
37
+ *
38
+ * @param path File path.
39
+ * @param signaturePath Signature file path.
40
+ * @param publicKey Public key.
41
+ * @return Whether the signature matches.
42
+ */
43
+ export declare function verifySshSignature(path: string, signaturePath: string, publicKey: KeyObject): Promise<boolean>;