Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Plugin interface and Nitrokey Webcrypt support #1567

Draft
wants to merge 63 commits into
base: main
Choose a base branch
from

Conversation

szszszsz
Copy link

@szszszsz szszszsz commented Sep 29, 2022

Hi!

This is a working draft implementation of the plugin system loosely described at Nitrokey#1 . Please take a look at the below description of the details, as well as the usage example.
Instead of a global plugin object I've decided to pass it through calls, to minimize potential state-related issues.

Requesting for the review and pointers.

Notes:

  • Right now only P256 is supported, but this should be easy to extend to other ECCs, and with a slight modifications to RSA as well.
  • Similarly to the OpenPGP cards, only one identity is supported, but this should be easy to extend to multiple identities using fingerprints, and over multiple devices utilizing private key data stubs.
  • Implemented Nitrokey WebCrypt tests are passing. All basic operations are working.
  • Regular OpenPGPjs tests are passing. OpenPGPJS Unit Tests.pdf
  • Dependency on the Nitrokey Webcrypt is added to keep everything in place, and for easier testing. This can be removed before the final merge, along with the tests, and kept separately. The gist of the proposed change is in the first commit.
  • Similarly, the usage example placed in Readme can be moved somewhere else, or removed completely. However it seems to illustrate the idea quite well.

To do:

  • This PR is based on OpenPGPjs 5.3.1. It probably needs to be rebased.
  • Revert serializing the key generation calls (this was needed to make sure that the main key and subkey will not switch places during import).
  • Update Typescript definitions for the main calls, and plugin structure.
  • Plugin structure Javascript documentation for the generated docs (to be confirmed).
  • Make callbacks more generic (currently taking parameters of ECC curves).
  • Make Nitrokey Webcrypt tests optional (if decided for them to stay).

Other ideas:

  • Merge plugin object into the global/local config structure. To discuss, whether this should be mixed, and passed deep down to cryptographic primitives.

A plugin interface for the private keys' operations is available, which allows to increase the security by storing the
private key on another medium than browser or PC, thus providing much greater, if not complete protection from the
secret material leaks. Additionally, this can increase usage control by introducing user confirmation
(e.g. through a touch button) for the private key operations.

The plugin interface is realized by a plugin object containing callbacks to the defined private key operations: sign, decrypt,
agree, generateKeyPair. The detailed description of each is provided in the Readme example . The plugin object is then passed
to all related main OpenPGPjs operations. If that's omitted, the standard OpenPGPjs behavior will be executed.

In the following implementation the host of the private key operations will be a Nitrokey WebCrypt device
(e.g. Nitrokey 3). Nitrokey WebCrypt uses Webauthn standard to create a communication channel with the security key
handling it, which by design should allow to run it on any modern browser and platform.
At the moment it is tested only in the Chrome-based browsers. Support for the Node.js requires additional implementation.
More details at:

Copy link
Member

@twiss twiss left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this!

Some high-level comments:

Other ideas:

  • Merge plugin object into the global/local config structure. To discuss, whether this should be mixed, and passed deep down to cryptographic primitives.

Indeed I would move plugin to config.plugin, or maybe config.hooks. That way, we don't have to pass two objects everywhere.

Further, I would propose that we make the arguments of each plugin function / hook into a single object, with descriptive property names. That way, it can be extended in the future more easily, if necessary. For example, we could have:

const hooks = {
  generateKey({ algorithmName, curveName, rsaBits }) {
    // ...
  }
};

Further, the current sketch of that function returns a private key as well (of all zeros). But, in this case we shouldn't store a private key at all, instead it should probably be a GNU-dummy key. We have some support for that, but not for generating it, I think that will need to be added.

And similarly, the other functions currently take a private key, I assume that should be removed and replaced with a fingerprint, to reference the key on the device?

And finally, some more specific comments below 😊

src/crypto/public_key/elliptic/curves.js Outdated Show resolved Hide resolved
src/crypto/public_key/elliptic/curves.js Outdated Show resolved Hide resolved
src/crypto/public_key/elliptic/curves.js Outdated Show resolved Hide resolved
src/key/helper.js Outdated Show resolved Hide resolved
docs/CleartextMessage.html Outdated Show resolved Hide resolved
@twiss
Copy link
Member

twiss commented Sep 29, 2022

Thinking about this a bit more, I think what I wrote before is not quite sufficient.

The difficult part with this use case is that it's not just a hook that replaces existing functions, but the behavior for these is a bit different, since it's referring to keys on a device, instead of keys handled by OpenPGP.js. That means not just that we have to store the generated key as a dummy key, but also that when selecting keys to use, we should also select dummy keys, instead of ignoring them.

So, I think this plugin should also be named a bit more specifically, for example config.hardwareKeys, or so. Then, we can check if that's set, and if so, generate/use dummy keys.

@twiss
Copy link
Member

twiss commented Sep 29, 2022

Though actually - it seems like hardware keys are not stored exactly the same as dummy keys. https://lists.gnupg.org/pipermail/gnupg-users/2015-February/052747.html has some documentation. Currently, we only implement 1001 - "Do not store the secret part at all". We probably also need to implement 1002 - "A stub to access smartcards", for this. We could store gnu-divert-to-card in the s2k.type (see src/type/s2k.js), and then create an secretKeyPacket.isStoredInHardware() function or some such, analogous to isDummy for gnu-dummy. Then, we could generate and use such keys if config.hardwareKeys is defined (or add a special parameter for key generation).

@szszszsz
Copy link
Author

szszszsz commented Oct 12, 2022

Hey @twiss @larabr !

Thank you for the review! I got myself busy with something else, but now I would like to finish this PR asap. I have addressed some of the concerns, and minimized the patch.

Further work:

  • move plugin to config.hardwareKeys
  • set S2K identifier to mark the dummy stub key on generation
  • implement secretKeyPacket.isStoredInHardware()
  • tests for hardware keys used without config.hardwareKeys field
  • fix failing node test Public key encrypted symmetric key packet
  • finish JSdoc

Questions:

  1. Regarding the config object - should I pass it as a whole to functions, that did not accept it before, or unpack it? I think the former sounds better for the future extensibility.
  2. As for the secretKeyPacket.isStoredInHardware() , can you elaborate where do you plan to use it? I presume you would like to check that value instead of whether the plugin field in config is set.

The general idea as far as I see is to generate the keys with the plugin field set once, and then allow the future operations on them without constantly setting it on use (as done right now for decryption and signing), but instead having it all defined in the global config object and use isStoredInHardware() to determine that. Please correct me if I see that wrong.

I am open for a video call if that would be faster for you - please contact me over szczepan@nitrokey.com in that case.

@szszszsz
Copy link
Author

Please review the latest changes. Would that s2k behavior work for you?

@szszszsz szszszsz marked this pull request as ready for review October 17, 2022 08:52
@larabr
Copy link
Collaborator

larabr commented Oct 17, 2022

Hey @szszszsz , thanks for the changes, just an heads up that @twiss is on vacation until next week, so the review will have to wait a bit

@szszszsz
Copy link
Author

@twiss Hey hey! Friendly ping.

Copy link
Member

@twiss twiss left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey 👋 Sorry for the delay! I started writing a response but didn't post it and forgot about it 🙃

Overall, I think the structure is going in the right direction. However, I think the API still needs a bit more thought and care, as this will essentially become part of our public API, and can't actually be changed that easily later (as we'd also have to change the WebCrypt OpenPGP.js plugin which will live in a separate repo).

I've left some more concrete comments below. But, please also spend some time yourself thinking about whether this API will keep working for you in the future (other algorithms, other devices, etc).

Also, on a much more nitpicky note, please use camel case everywhere, and generally please try to match the style of the surrounding code 😊

src/hardwareKeys.js Outdated Show resolved Hide resolved
* Return the creation date of the keys
* @returns {Date} The keys creation date
*/
date() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have a date parameter, do we need this function?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

await openpgp.generateKey({
        curve: 'p256',
        userIDs: [{ name: 'Jon Smith', email: 'jon@example.com' }],
        format: 'object',
        date: plugin.date(),
        config: { hardwareKeys: plugin }
      });

I was wondering here, if providing date function in the hardwareKeys object would not allow to skip it in the list parameter, which potentially could be less error prone usage wise.
Perhaps I could check that anyway by testing dynamically the member existence, which solves the problem.

Also, as you mentioned elsewhere, technical details should not be exposed hence this will be removed from the hardwareKeys API.

src/hardwareKeys.js Outdated Show resolved Hide resolved
src/hardwareKeys.js Outdated Show resolved Hide resolved
* }>} Generated signature, 32 bytes in each field
* @async
*/
async sign({ oid, hashAlgo, data, Q, d, hashed }) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. This API seems designed only for ECDSA? To make it generic, I would accept algorithmName, curveName as below.
  2. The return object's properties might need to depend on the algorithm and curve, so you could just say that it returns an onbject.
  3. Internally, we allow the function to use either the data+hashAlgo or the hash, in case there's an integrated hash+sign operation (such as in Web Crypto). However, here I assume you're always signing the hash?
  4. And again, descriptive parameter names would be nicer, and I think "recipient" in the JSDoc doesn't apply here - I guess this was copied from somewhere?

So I would do:

Suggested change
async sign({ oid, hashAlgo, data, Q, d, hashed }) {
async sign({ algorithmName, curveName, publicKey, privateKey, hash }) {

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good points - will do. I am not testing this with the RSA yet, but planning so in the future.

Comment on lines +84 to +95
* The secret key material is not present in the result. Instead, the IV field contains the serial number of the device,
* to which the secret key material processing is delegated to. The privateKey field is returned for the backwards
* compatibility.
*
* @param {Object} obj - An object argument for destructuring
* @param {enums.publicKey} obj.algorithmName - Type of the algorithm
* @param {string} obj.curveName - Curve name
* @param {number} obj.rsaBits - RSA key length in bits
* @returns {Promise<{
* publicKey: Uint8Array,
* privateKey: Uint8Array
* }>} Generated key material
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't return a dummy privateKey property here, since it's obviously meant to stay on the device - so I would just return the public key only.

Comment on lines +271 to +273
if (hardwareKeys_with_data) {
hardwareKeys_with_data.algo = algo;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of setting the algorithm on this object here, please just pass the algorithm name below. (Also, algo is a number here, it would probably be nicer to pass a string to the plugin. Otherwise, the parameter should be called algorithm instead of algorithmName.)

@@ -276,17 +281,17 @@ export function generateParams(algo, bits, oid) {
}));
}
case enums.publicKey.ecdsa:
return publicKey.elliptic.generate(oid).then(({ oid, Q, secret }) => ({
return publicKey.elliptic.generate(oid, hardwareKeys_with_data).then(({ oid, Q, secret }) => ({
Copy link
Member

@twiss twiss Jan 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They can be hardcoded here, e.g.

Suggested change
return publicKey.elliptic.generate(oid, hardwareKeys_with_data).then(({ oid, Q, secret }) => ({
return publicKey.elliptic.generate('ecdsa', oid, config).then(({ oid, Q, secret }) => ({

etc.

Or, if you prefer passing the algorithm enum value, you can just pass algo here, of course.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer passing around enums whenever possible (following up to #1410)

src/crypto/public_key/elliptic/curves.js Outdated Show resolved Hide resolved
src/key/helper.js Outdated Show resolved Hide resolved
@twiss
Copy link
Member

twiss commented Feb 14, 2023

Hello 👋 Just a heads up, #1597 will cause some merge conflicts with this PR, since it refactors the S2K code. So we'll need to rebase this PR, if it's merged after that one. I don't necessarily want to burden you with that, we can rebase this PR; but if you did some work on it in the past three weeks, it might be good to push it here first, so that we can (either merge it if it's finished - but no rush, ofc - and then rebase #1597, or) rebase the code in this PR after merging that one 😊

@szszszsz
Copy link
Author

Hey, thank you for the tip! About the ready code, I've followed some style code advises, and will update this PR's branch in a moment.
From the brief look it should not be that much of conflicting code - I will check later if I could rebase on that PR.
More updates are incoming in the following days, but I need to clear out some doubts first.

openpgp.d.ts Outdated Show resolved Hide resolved
PartialConfig have it optional

Co-authored-by: larabr <larabr+github@protonmail.com>
openpgp.d.ts Outdated Show resolved Hide resolved
src/crypto/signature.js Outdated Show resolved Hide resolved
@szszszsz
Copy link
Author

Regarding the CI error:

npm ERR! cipm can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with npm install before continuing.

Would you like me to merge in the current package.lock from the main branch?

@szszszsz szszszsz marked this pull request as draft February 15, 2023 17:02
@twiss
Copy link
Member

twiss commented Feb 15, 2023

I think the issue might actually be that since you added nitrokey_webcrypt to the dependencies in package.json, that you have to update package-lock.json as well.

(Although, as you already said, it should probably be removed before merging, and then the package-lock.json should be reverted as well, but you can update it for now for testing indeed.)

@szszszsz
Copy link
Author

szszszsz commented Feb 15, 2023

I've just run npm install to update (indeed it added Webcrypt to lock file), and npm ci for the local tests. This should go in the CI now.

@larabr
Copy link
Collaborator

larabr commented Feb 15, 2023

Thanks for working on this @szszszsz, it'll open up interesting possibilities! Sorry to chime in only now, I would like to have your opinion on a couple of points.

First off, with the current implementation, if config.hardwareKeys is set, but I am generating e.g. a RSA key (or EdDSA), the hardware won't be used, and I won't get any indication of that. I would expect the operation to throw telling me that the hardware key does not support RSA keys, "forcing" me to then pass config: { hardwareKeys: null } (assuming openpgp.config.hardwareKeys is set globally). Otherwise, I might get a false sense of security, and/or think the key was stored on hardware when it wasn't, and lose the key data as a result.

Secondly, I was wondering if a higher-level interface would work for you? I am thinking of the possibility of having the lib call out to config.hardwareKeys already in e.g. publicKeyDecrypt/generateParams/etc., for each algorithm (to address the point above). Similarly to what you're doing here, but without assuming it's an ECDH key: one possibility is for HardwareKey's functions to get the algo value and switch on it like OpenPGP.js does.
The advantage I see is that (1) we'd have a centralised spot where the requests are "proxied", making it easy to follow the flow of execution in all cases, and (2) the hardwareKey integration would be decoupled from our low-level crypto modules. In fact, I am a bit uncomfortable with this type of change, where parameters are "nullified". Instead, I think that if generateParams were to redirect directly to config.hardwareKeys, then the implemented interface could simply avoid returning privateKeys, and it wouldn't risk disrupting the existing logic (where the single params are always assumed to be defined/non-null).
The downside is that the HardwareKey interface will need to take care of e.g. the ECDH KDF derivation, but that relies on standard constructs, and we can help you with that part of the integration if needed. I also have the impression -- but this is totally speculative -- that some HardwareKeys might already carry out the KDF/padding steps if they were developed with built-in OpenPGP support.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants