How to deploy npm package with 2FA enabled on write

Stéphane Gariépy
6 min readMay 3, 2019

--

Many people asked for help to achieve CI deployment of npm packages when 2FA on write is enabled. Since the last major event with eslint-scope (see Postmortem for Malicious Packages Published on July 12th, 2018), using 2FA might be essential to many users and organizations. Obviously, with npm cli by itself it’s impossible, but with other tools it is.

This is experimental, more like a proof of concept. I don’t know at this time if this works well on real development environment with a mid-size team or larger.

Prerequisites:

  • You did a npm adduser or have access to a ~/.npmrc file somewhere;
  • Hashicorp Vault server available;
  • A Git repo (ie: GitHub);
  • A CI server or service (ex: Travis);
  • If Travis is used, then Travis CLI is also required.

Configure npm

The purpose of this guide is to work with 2FA enabled, let’s do this. Start with a login:

$ npm login

This last command will ask for your username, password and email. Next, enable 2FA:

$ npm profile enable-2fa auth-and-writes

After providing your password, a QR Code will show up. Right below it, the code (or more specificaly, the secret key) is provided, that’s what we need.

Or enter code: YT3HASBL17JN55U665G3FKBW2HZC
And an OTP code from your authenticator:

You can use an app to keep this key, like BitWarden, or Google Authenticator, using the QR Code. Copy this code at some nonpermanent place, we will need it soon when configuring Vault. For now, you can provide the code from an authenticator to the prompt asking for it.

Configuring Vault

If you don’t have Vault installed, you can follow this guide. This same procedure works on Ubuntu 18.04.

Now that Vault is working, unsealed and you’ve logged in, we have a bit of configurations to do, starting with enabling TOTP.

$ vault secrets enable totp

To create the TOTP Key within Vault, we write to totp/keys giving npm as a name. The accountName part is where you can put your username or email address used to login npm. It’s only an internal designation, you can use whatever you want. Same thing for issuer which is the provider of the key. Here it’s npm. Also, set the secret= value to the secret key npm gave us when enabling 2FA.

$ vault write totp/keys/npm url="otpauth://totp/accountName?secret=YT3HASBL17JN55U665G3FKBW2HZC&issuer=npm"

To test it, use the read command, you should receive a 6-digit code:

$ vault read totp/code/npm
Key Value
--- -----
code 836039

This code should be equal to a code provided at the same time from another authenticator (ie: Google Authentificator).

We need to set permission that will be used by a token. We only need a read permission (create permission, for something that generates a code, is non-sense). For permissions, you can create a file and keep it for later use:

path "totp/code/npm" {
capabilities = ["read"]
}

Save this to something like npm-totp.hcl and add permission to Vault with a name meaningful to you. I used npm-totp:

$ vault policy write npm-totp ./npm-totp.hcl

We need a token to access this code with last created permission, providing its name:

$ vault token create -policy="npm-totp"

And the output, where we need the token for later use, we’ll need it for CI configuration:

Key                  Value
--- -----
token 9bb1d8ad-5a6c-4501-afe6-cde2915e4dd5
token_accessor aac74ed9-d958-4aa4-832f-8658c6fddbc2
token_duration 768h
token_renewable true
token_policies ["default" "npm-totp"]
identity_policies []
policies ["default" "npm-totp"]

You can change token duration if you want, but I think the default value of 768h is fine in our case. Check Vault documentation if you want to change this.

Project creation

In some repository provider, create a new project (or maybe you can use an existing project). In my case, I used GitHub to use it with Travis.

The minimal requirements for this project are:

  • public GitHub repo
  • npm init and a proper package.json for publishing;
  • A Travis configuration file .travis.yml.

Initialize your npm project the way you want. It’s highly recommended that you scope this one if you’re testing.

$ npm init

In the package.json file, you may need to add if you don’t have any private registry:

"publishConfig": {
"access": "public"
},

Also, Travis is used for testing and deploying, hence you may need to provide unit test. Still in package.json, change script.test to supply the jest command:

"scripts": {
"test": "jest test.js"
},

Create a file test.js, since it’s not the purpose of this article to show how to write tests, here a simple one:

describe('Some dummy test', () => {
it('should always pass', () => {
expect(true).toBe(true);
});
});

Do not forget to add jest as a dev-dependency:

$ npm i -D jest

Travis configuration

When you use Travis, you can use the built-in npm deploy. All you need to do, is to provide a username and a token from npmjs:

deploy:
provider: npm
email: "YOUR_EMAIL_ADDRESS"
api_key: "YOUR_AUTH_TOKEN"

But what if you want to provide an OTP for your 2FA write permission? You can’t. We will use the script provider instead. Create a file .travis.yml, and add:

language: node_js
node_js: lts/carbon
addons:
apt:
packages:
- jq
deploy:
provider: script
script: bash scripts/deploy.sh
on:
tags: true

You can adapt this to your needs. Next, setup npm in your Travis config:

$ travis setup npm

You will have to answer some questions. It’s recommended to answer yes (default answer) to last three questions.

NPM email address:
NPM api key:
release only tagged commits? |yes|
Release only from sgyio/npm-deploy? |yes|
Encrypt API key? |yes|

Again, with Travis CLI, we add some encrypted environment keys. Replace token value with your actual token from Vault:

$ travis encrypt VAULT_NPM_TOTP_TOKEN=9bb1d8ad-5a6c-4501-afe6-cde2915e4dd5 --add env.global

Next, in this command, replace somevaultserver to your Vault server host:

$ travis encrypt VAULT_URL="https://somevaultserver/v1/totp/code/npm" --add env.global

And finaly, look into your ~/.npmrc file, pick your token and set it as a value of NPM_TOKEN:

$ travis encrypt NPM_TOKEN="e29a1d20-9f2d-41f6-9cb4-92a3c25c8711" --add env.global

In .travis.yml you will have new lines added, similar to this:

env:
global:
- secure: PBnUSFhKeEo1Y6QnxEdB...9kymx0acWCRCasB/hROEl1XATUQ4K2Y=
- secure: Cx/pkaHyWXj1BwlQUeTV...hVGkqt/4I8aoPJql7md4Eh9nE6S1uP4=
- secure: zQeXQw16ikcajF5zGrtu...HRLf7ZJpIGflk73t/mN9fV11evf8s8E=

We will use these in the deployment script.

Deployment script

Here’s the script, deploy.sh within the scripts directory:

#!/bin/shecho "//registry.npmjs.org/:_authToken=\${NPM_TOKEN}" > .npmrctotp=$(curl -s -X GET $VAULT_URL -H "X-Vault-Token: ${VAULT_NPM_TOTP_TOKEN}" | jq --raw-output ".data.code")npm publish . --otp=$totp

The first line, the one with echo, will add a line to .npmrc file with the token from env variable we added to .travis.yml earlier.

The second line, calls the Vault endpoint for code with curl. We use VAULT_URL to give the endpoint, and VAULT_NPM_TOTP_TOKEN is added to X-Vault-Token header. All this command is piped to jq.

Remember we added the jq package in .travis.yml? This is for simplicity, because we receive the code payload in JSON format, and we need to extract only the code value out of it. The payload from the Vault endpoint looks like this:

{
"request_id": "50bc118a-fa60-4e5c-b9e4-a3abd22b1a61",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"code": "457993"
},
"wrap_info": null,
"warnings": null,
"auth": null
}

We need the value of data object and then the value of code property, so we pipe this to jq. Here an example:

$ echo '{ "data": {"code": "123456"}}' | jq ".data.code"
"123456"

Because this command will return "123456", or "457993" in our script, we add --raw-outputto remove double-quotes. This code is set as a value to $totp for use in the last line.

In this last line, it is where we finaly make the publish of the package. We add the --otp option with our code contained in $totp variable.

npm publish . --otp=$totp

Et voilà! the package will deploy when you push a tag.

--

--