Code signing for NPM packages
Using your favourite package manager:
pnpm add -D sceau
yarn add -D sceau
npm install -D sceau
First off, you'll need a signature private key.
You can generate one from the CLI:
$ sceau keygen
Run the following command in your terminal to use this private key:
export SCEAU_PRIVATE_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
Associated public key:
0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
This will give you an environment variable definition for the private key, and the associated public key.
Note about keys: The underlying signature is Ed25519, which stores the public key as part of the private key.
If you misplace your public key, it can be obtained from the private key: the public key is the LAST half (64 hex characters) of the private key.
To sign a package, run the following command:
$ sceau sign
This will:
- Collect a list of files to be published (based on the
files
option in package.json and the .npmignore file) - Hash and sign each file into a manifest
- Inject some metadata, like:
- A URL to the JSON schema of the resulting file, serving as a version identifier
- The current time
- A permalink to the sources (see CI usage)
- A permalink to the build process (see CI usage)
- Sign the whole thing
- Store it in a
sceau.json
file next to package.json
Links to source and build process are injected to provide transparency and traceability to a package being built.
The idea is that the signing step would occur in a public CI/CD context.
You can specify the URLs to the sources and build process either via the command-line, or via environment variables:
CLI argument | Environment variable | Description |
---|---|---|
--source |
SCEAU_SOURCE_URL |
Permalink to the source code |
--build |
SCEAU_BUILD_URL |
Permalink to the build process |
Note: those two URLs are automatically populated for you if sceau runs in a GitHub Actions context. PRs for more CI contributions are welcome!
If those are not provided, sceau will still sign your package, but the URLs
will be set to unknown://local
.
Sceau will write to a file, but will also print to the standard output, so this signature process can be audited by third parties.
Because sceau should run right before NPM packs your artifacts to publish them,
you should run the signature step in the prepack
script:
{
"files": [
// ...
"sceau.json"
],
"scripts": {
"prepack": "sceau sign"
}
}
Note that it's also required to add sceau.json
to the list of files, otherwise
the signature would be left behind when your package is packed.
--packageDir
lets you specify a path to a pacakge to sign.
By default, sceau will try to look for a package to sign at the current working
directory.
--file
lets you choose the output file (defaults to sceau.json
). It should
be a path relative to the package directory (where package.json is located).
Example:
$ sceau sign --packageDir packages/my-package --file build/signature.json
This will sign package <cwd>/packages/my-package
, and store the output at
<cwd>/packages/my-package/build/signature.json
.
--ignore
lets you specify RegExp patterns to match against file paths.
Files matching those patterns won't be included in the manifest and therefore
won't be part of the final signature.
Note that the output file is always automatically included in those patterns.
You can verify a package signed with sceau using the following command:
$ sceau verify --packageDir path/to/package
You should provide the public key to verify a signature against, obtained from a trusted source (ideally one not under GitHub's or NPM's control, in case those were to be compromised).
$ sceau verify --publicKey 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
If the package uses a non-standard <packageDir>/sceau.json
signature file,
you can specify its location (relative to the package directory):
$ sceau verify --file build/signature.json
By default, calling verify on an unsigned package will only print that fact and exit cleanly. If you expect a package to be signed, you can use strict verification, that will fail on unsigned packages:
$ sceau verify --strict
Sceau can be imported as a library (ESM only).
You can access the commands using the same arguments as the CLI:
import {
generateKeyPair,
signCommand,
verifyCommand,
SCEAU_FILE_NAME,
} from 'sceau'
// Random keypair
const { publicKey, privateKey } = await generateKeyPair()
// Seeded keypair
const { publicKey, privateKey } = await generateKeyPair('baadf00d...')
await signCommand({
file: SCEAU_FILE_NAME,
quiet: true,
privateKey: '...', // will default to SCEAU_PRIVATE_KEY env if unspecified
packageDir: 'packages/my-package',
build: 'https://url-to-build-process.example.com',
source: 'https://url-to-sources.example.com',
})
await verifyCommand({
file: SCEAU_FILE_NAME,
strict: true,
packageDir: 'packages/my-package',
publicKey: '...', // will default to SCEAU_PUBLIC_KEY env if unspecified
})
Sceau signs itself when released.
You can verify a sceau install (using itself too):
sceau verify --strict --publicKey c30d5d28b88136c77168fb78bf117948127c4e22f987ab60cd083bbd6c7ac0c9
We use semantic-release, which injects the NPM package version just
before publishing. In order to sign the final package.json file,
we run sceau sign
as the prepack
lifecycle hook.
The private key is passed as an environment variable to the calling step (semantic-release).
Cryptography is provided by libsodium.
- Hash: BLAKE2b, 64 byte output, no key, default parameters
- Signature: Ed25519ph (SHA-512 pre-hash), with manifest header.
See signature.ts for more details.
Sceau is French for seal (the ones found on letters, not in oceans).
It's pronounced like so.
MIT - Made with ❤️ by François Best
Using this package at work ? Sponsor me to help with support and maintenance.