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

Updating Crypto to use WebCrypto API and to replace RSA with ECC #446

Merged
merged 68 commits into from
Dec 8, 2022

Conversation

CMCDragonkai
Copy link
Member

@CMCDragonkai CMCDragonkai commented Sep 13, 2022

Description

This PR focuses on updating the crypto utilities used by PK. We've been hitting problems using RSA and node-forge utilities, and we should start using the standardised WebCrypto API. This won't fully solve cross platform cryptography because that will need to wait until we hit mobile platforms and deal with it by using WASM or other utilities.

There are some new features coming into this PR:

  1. Public key encryption now allows arbitrary message size. There is no longer a limit on large the data to be encrypted is. The existing limit is only 446 bytes.
  2. Public key encryption supports static-static, ephemeral-static, and ephemeral-ephemeral.
  3. Because Ed25519 public keys are 32 bytes, NodeId is now finally the public key. This means you no longer have to acquire the public key separately from the NodeId. Once you have the NodeId you can use it for public key verification, and for encryption.
  4. The @peculiar/webcrypto is being monkey patched to globalThis.crypto. This ensures that every library is using the same webcrypto backend and this includes CSPRNG and encryption/decryption facilities.

Massive performance improvements in all areas:

Old performance (node-forge):

# TYPE keys.asymmetric_crypto_ops gauge
keys.asymmetric_crypto_ops{name="encrypt 446 B of data"} 418.07
keys.asymmetric_crypto_ops{name="decrypt 446 B of data"} 6.49
keys.asymmetric_crypto_ops{name="sign 446 B of data"} 6.53
keys.asymmetric_crypto_ops{name="sign 1 KiB of data"} 6.57
keys.asymmetric_crypto_ops{name="sign 10 KiB of data"} 6.61
keys.asymmetric_crypto_ops{name="verify 446 B of data"} 449.55
keys.asymmetric_crypto_ops{name="verify 1 KiB of data"} 445.57
keys.asymmetric_crypto_ops{name="verify 10 KiB of data"} 428

# TYPE keys.asymmetric_crypto_margin gauge
keys.asymmetric_crypto_margin{name="encrypt 446 B of data"} 0.39
keys.asymmetric_crypto_margin{name="decrypt 446 B of data"} 0.37
keys.asymmetric_crypto_margin{name="sign 446 B of data"} 0.48
keys.asymmetric_crypto_margin{name="sign 1 KiB of data"} 0.45
keys.asymmetric_crypto_margin{name="sign 10 KiB of data"} 0.25
keys.asymmetric_crypto_margin{name="verify 446 B of data"} 0.74
keys.asymmetric_crypto_margin{name="verify 1 KiB of data"} 0.99
keys.asymmetric_crypto_margin{name="verify 10 KiB of data"} 0.28

# TYPE keys.asymmetric_crypto_samples counter
keys.asymmetric_crypto_samples{name="encrypt 446 B of data"} 88
keys.asymmetric_crypto_samples{name="decrypt 446 B of data"} 36
keys.asymmetric_crypto_samples{name="sign 446 B of data"} 36
keys.asymmetric_crypto_samples{name="sign 1 KiB of data"} 36
keys.asymmetric_crypto_samples{name="sign 10 KiB of data"} 36
keys.asymmetric_crypto_samples{name="verify 446 B of data"} 87
keys.asymmetric_crypto_samples{name="verify 1 KiB of data"} 87
keys.asymmetric_crypto_samples{name="verify 10 KiB of data"} 90

# TYPE keys.key_generation_ops gauge
keys.key_generation_ops{name="generate root asymmetric keypair"} 1
keys.key_generation_ops{name="generate deterministic root keypair"} 0
keys.key_generation_ops{name="generate 256 bit symmetric key"} 6204

# TYPE keys.key_generation_margin gauge
keys.key_generation_margin{name="generate root asymmetric keypair"} 29.86
keys.key_generation_margin{name="generate deterministic root keypair"} 0.33
keys.key_generation_margin{name="generate 256 bit symmetric key"} 1.72

# TYPE keys.key_generation_samples counter
keys.key_generation_samples{name="generate root asymmetric keypair"} 9
keys.key_generation_samples{name="generate deterministic root keypair"} 5
keys.key_generation_samples{name="generate 256 bit symmetric key"} 81

# TYPE keys.random_bytes_ops gauge
keys.random_bytes_ops{name="generate 512 B of random bytes"} 5346
keys.random_bytes_ops{name="random 1 KiB of data"} 4164
keys.random_bytes_ops{name="random 10 KiB of data"} 784

# TYPE keys.random_bytes_margin gauge
keys.random_bytes_margin{name="generate 512 B of random bytes"} 0.5
keys.random_bytes_margin{name="random 1 KiB of data"} 0.56
keys.random_bytes_margin{name="random 10 KiB of data"} 0.61

# TYPE keys.random_bytes_samples counter
keys.random_bytes_samples{name="generate 512 B of random bytes"} 94
keys.random_bytes_samples{name="random 1 KiB of data"} 93
keys.random_bytes_samples{name="random 10 KiB of data"} 93

# TYPE keys.recovery_code_ops gauge
keys.recovery_code_ops{name="generate 24 word recovery code"} 6419
keys.recovery_code_ops{name="generate 12 word recovery code"} 6704

# TYPE keys.recovery_code_margin gauge
keys.recovery_code_margin{name="generate 24 word recovery code"} 0.89
keys.recovery_code_margin{name="generate 12 word recovery code"} 0.63

# TYPE keys.recovery_code_samples counter
keys.recovery_code_samples{name="generate 24 word recovery code"} 85
keys.recovery_code_samples{name="generate 12 word recovery code"} 87

# TYPE keys.symmetric_crypto_ops gauge
keys.symmetric_crypto_ops{name="encrypt 512 B of data"} 4332
keys.symmetric_crypto_ops{name="encrypt 1 KiB of data"} 3838
keys.symmetric_crypto_ops{name="encrypt 10 KiB of data"} 1327
keys.symmetric_crypto_ops{name="decrypt 512 B of data"} 10484
keys.symmetric_crypto_ops{name="decrypt 1 KiB of data"} 8120
keys.symmetric_crypto_ops{name="decrypt 10 KiB of data"} 1600

# TYPE keys.symmetric_crypto_margin gauge
keys.symmetric_crypto_margin{name="encrypt 512 B of data"} 1.73
keys.symmetric_crypto_margin{name="encrypt 1 KiB of data"} 0.8
keys.symmetric_crypto_margin{name="encrypt 10 KiB of data"} 1.27
keys.symmetric_crypto_margin{name="decrypt 512 B of data"} 1.27
keys.symmetric_crypto_margin{name="decrypt 1 KiB of data"} 1.39
keys.symmetric_crypto_margin{name="decrypt 10 KiB of data"} 1.66

# TYPE keys.symmetric_crypto_samples counter
keys.symmetric_crypto_samples{name="encrypt 512 B of data"} 86
keys.symmetric_crypto_samples{name="encrypt 1 KiB of data"} 88
keys.symmetric_crypto_samples{name="encrypt 10 KiB of data"} 87
keys.symmetric_crypto_samples{name="decrypt 512 B of data"} 86
keys.symmetric_crypto_samples{name="decrypt 1 KiB of data"} 85
keys.symmetric_crypto_samples{name="decrypt 10 KiB of data"} 84

New performance (web crypto):

# TYPE keys.asymmetric_crypto_ops gauge
keys.asymmetric_crypto_ops{name="encrypt 512 B of data"} 357
keys.asymmetric_crypto_ops{name="encrypt 1 KiB of data"} 366
keys.asymmetric_crypto_ops{name="encrypt 10 KiB of data"} 368
keys.asymmetric_crypto_ops{name="decrypt 512 B of data"} 411
keys.asymmetric_crypto_ops{name="decrypt 1 KiB of data"} 414
keys.asymmetric_crypto_ops{name="decrypt 10 KiB of data"} 417
keys.asymmetric_crypto_ops{name="sign 512 B of data"} 1802
keys.asymmetric_crypto_ops{name="sign 1 KiB of data"} 1778
keys.asymmetric_crypto_ops{name="sign 10 KiB of data"} 1684
keys.asymmetric_crypto_ops{name="verify 512 B of data"} 393
keys.asymmetric_crypto_ops{name="verify 1 KiB of data"} 398
keys.asymmetric_crypto_ops{name="verify 10 KiB of data"} 386

# TYPE keys.asymmetric_crypto_margin gauge
keys.asymmetric_crypto_margin{name="encrypt 512 B of data"} 0.61
keys.asymmetric_crypto_margin{name="encrypt 1 KiB of data"} 0.64
keys.asymmetric_crypto_margin{name="encrypt 10 KiB of data"} 0.3
keys.asymmetric_crypto_margin{name="decrypt 512 B of data"} 0.48
keys.asymmetric_crypto_margin{name="decrypt 1 KiB of data"} 0.68
keys.asymmetric_crypto_margin{name="decrypt 10 KiB of data"} 0.57
keys.asymmetric_crypto_margin{name="sign 512 B of data"} 0.8
keys.asymmetric_crypto_margin{name="sign 1 KiB of data"} 0.92
keys.asymmetric_crypto_margin{name="sign 10 KiB of data"} 0.72
keys.asymmetric_crypto_margin{name="verify 512 B of data"} 0.57
keys.asymmetric_crypto_margin{name="verify 1 KiB of data"} 0.42
keys.asymmetric_crypto_margin{name="verify 10 KiB of data"} 0.31

# TYPE keys.asymmetric_crypto_samples counter
keys.asymmetric_crypto_samples{name="encrypt 512 B of data"} 87
keys.asymmetric_crypto_samples{name="encrypt 1 KiB of data"} 89
keys.asymmetric_crypto_samples{name="encrypt 10 KiB of data"} 90
keys.asymmetric_crypto_samples{name="decrypt 512 B of data"} 86
keys.asymmetric_crypto_samples{name="decrypt 1 KiB of data"} 87
keys.asymmetric_crypto_samples{name="decrypt 10 KiB of data"} 88
keys.asymmetric_crypto_samples{name="sign 512 B of data"} 87
keys.asymmetric_crypto_samples{name="sign 1 KiB of data"} 86
keys.asymmetric_crypto_samples{name="sign 10 KiB of data"} 88
keys.asymmetric_crypto_samples{name="verify 512 B of data"} 87
keys.asymmetric_crypto_samples{name="verify 1 KiB of data"} 88
keys.asymmetric_crypto_samples{name="verify 10 KiB of data"} 89

# TYPE keys.key_generation_ops gauge
keys.key_generation_ops{name="generate root asymmetric keypair"} 3563
keys.key_generation_ops{name="generate deterministic root keypair"} 107
keys.key_generation_ops{name="generate 256 bit symmetric key"} 319065

# TYPE keys.key_generation_margin gauge
keys.key_generation_margin{name="generate root asymmetric keypair"} 0.6
keys.key_generation_margin{name="generate deterministic root keypair"} 1.74
keys.key_generation_margin{name="generate 256 bit symmetric key"} 0.6

# TYPE keys.key_generation_samples counter
keys.key_generation_samples{name="generate root asymmetric keypair"} 85
keys.key_generation_samples{name="generate deterministic root keypair"} 83
keys.key_generation_samples{name="generate 256 bit symmetric key"} 89

# TYPE keys.random_bytes_ops gauge
keys.random_bytes_ops{name="random 512 B of data"} 332050
keys.random_bytes_ops{name="random 1 KiB of data"} 294369
keys.random_bytes_ops{name="random 10 KiB of data"} 134212

# TYPE keys.random_bytes_margin gauge
keys.random_bytes_margin{name="random 512 B of data"} 1.95
keys.random_bytes_margin{name="random 1 KiB of data"} 3.01
keys.random_bytes_margin{name="random 10 KiB of data"} 0.88

# TYPE keys.random_bytes_samples counter
keys.random_bytes_samples{name="random 512 B of data"} 85
keys.random_bytes_samples{name="random 1 KiB of data"} 76
keys.random_bytes_samples{name="random 10 KiB of data"} 85

# TYPE keys.recovery_code_ops gauge
keys.recovery_code_ops{name="generate 24 word recovery code"} 68387
keys.recovery_code_ops{name="generate 12 word recovery code"} 80916

# TYPE keys.recovery_code_margin gauge
keys.recovery_code_margin{name="generate 24 word recovery code"} 1.44
keys.recovery_code_margin{name="generate 12 word recovery code"} 1.58

# TYPE keys.recovery_code_samples counter
keys.recovery_code_samples{name="generate 24 word recovery code"} 80
keys.recovery_code_samples{name="generate 12 word recovery code"} 85

# TYPE keys.symmetric_crypto_ops gauge
keys.symmetric_crypto_ops{name="encrypt 512 B of data"} 38859
keys.symmetric_crypto_ops{name="encrypt 1 KiB of data"} 34177
keys.symmetric_crypto_ops{name="encrypt 10 KiB of data"} 29253
keys.symmetric_crypto_ops{name="decrypt 512 B of data"} 47148
keys.symmetric_crypto_ops{name="decrypt 1 KiB of data"} 43245
keys.symmetric_crypto_ops{name="decrypt 10 KiB of data"} 31158

# TYPE keys.symmetric_crypto_margin gauge
keys.symmetric_crypto_margin{name="encrypt 512 B of data"} 1.1
keys.symmetric_crypto_margin{name="encrypt 1 KiB of data"} 1.34
keys.symmetric_crypto_margin{name="encrypt 10 KiB of data"} 1.27
keys.symmetric_crypto_margin{name="decrypt 512 B of data"} 1.7
keys.symmetric_crypto_margin{name="decrypt 1 KiB of data"} 1.73
keys.symmetric_crypto_margin{name="decrypt 10 KiB of data"} 1.89

# TYPE keys.symmetric_crypto_samples counter
keys.symmetric_crypto_samples{name="encrypt 512 B of data"} 83
keys.symmetric_crypto_samples{name="encrypt 1 KiB of data"} 76
keys.symmetric_crypto_samples{name="encrypt 10 KiB of data"} 84
keys.symmetric_crypto_samples{name="decrypt 512 B of data"} 76
keys.symmetric_crypto_samples{name="decrypt 1 KiB of data"} 81
keys.symmetric_crypto_samples{name="decrypt 10 KiB of data"} 75

Newer performance (libsodium):

# TYPE keys.asymmetric_crypto_ops gauge
keys.asymmetric_crypto_ops{name="encrypt 512 B of data"} 7590
keys.asymmetric_crypto_ops{name="encrypt 1 KiB of data"} 7544
keys.asymmetric_crypto_ops{name="encrypt 10 KiB of data"} 6904
keys.asymmetric_crypto_ops{name="decrypt 512 B of data"} 10385
keys.asymmetric_crypto_ops{name="decrypt 1 KiB of data"} 10200
keys.asymmetric_crypto_ops{name="decrypt 10 KiB of data"} 9059
keys.asymmetric_crypto_ops{name="sign 512 B of data"} 20480
keys.asymmetric_crypto_ops{name="sign 1 KiB of data"} 19640
keys.asymmetric_crypto_ops{name="sign 10 KiB of data"} 11166
keys.asymmetric_crypto_ops{name="verify 512 B of data"} 15596
keys.asymmetric_crypto_ops{name="verify 1 KiB of data"} 15538
keys.asymmetric_crypto_ops{name="verify 10 KiB of data"} 12118

# TYPE keys.key_generation_ops gauge
keys.key_generation_ops{name="generate root asymmetric keypair"} 43471
keys.key_generation_ops{name="generate deterministic root keypair"} 115
keys.key_generation_ops{name="generate 256 bit symmetric key"} 1564369

# TYPE keys.random_bytes_ops gauge
keys.random_bytes_ops{name="random 512 B of data"} 424548
keys.random_bytes_ops{name="random 1 KiB of data"} 226078
keys.random_bytes_ops{name="random 10 KiB of data"} 24618

# TYPE keys.recovery_code_ops gauge
keys.recovery_code_ops{name="generate 24 word recovery code"} 71881
keys.recovery_code_ops{name="generate 12 word recovery code"} 81700

# TYPE keys.symmetric_crypto_ops gauge
keys.symmetric_crypto_ops{name="encrypt 512 B of data"} 380489
keys.symmetric_crypto_ops{name="encrypt 1 KiB of data"} 291515
keys.symmetric_crypto_ops{name="encrypt 10 KiB of data"} 63876
keys.symmetric_crypto_ops{name="decrypt 512 B of data"} 555805
keys.symmetric_crypto_ops{name="decrypt 1 KiB of data"} 416915
keys.symmetric_crypto_ops{name="decrypt 10 KiB of data"} 80024

# TYPE keys.x509_ops gauge
keys.x509_ops{name="generate certificate"} 126

Issues Fixed

Tasks

  • 1. Prototype the usage of ED25519 as the root key
  • 2. Prototype the usage of @scure/bip39 for generating recovery code and deterministically generating the ED25519 root key
  • 3. Prototype how the root key pair will be stored as an encrypted JWK (using JWE)
  • 4. Prototype how to standardise randomness source across all libraries
    * Note that panva/jose does not allow randomness to be standardised, this means the JOSE library will need to be replaced in the future
    * For now all libraries will mostly end up using node's native randomness generation because we are running in node runtime
    * The panva/jose library can be replaced... it's just mostly implementing JOSE RFCs that's the issue, but we are only using a limited set, otherwise can fork the library to provide an alternative implementation. Alternatively we would need to monkey patch a global webcrypto runtime
  • 5. Prototype how to generate symmetric key
  • 6. Prototype the usage of webcrypto based symmetric encryption and decryption
  • 7. Prototype the derivation of x25519 encryption/decryption keys from the root ed25519 keys, and the usage of these keys for asymmetric encryption
  • 8. Prototype how to generation of x509 certificates using the ed25519 root keys, and the replacement of node-forge with @peculiar/x509
  • 9. Test the new x509 certificates for TLS
    • Note that browsers do not support Ed25519 based x509 certs, even though web servers and curl does. This means we cannot have an HTTPS page with our root certificates, unless we derive a P-256 EC key from the Ed25519 root key and use that in the interim. Alternatively we don't bother with an HTTPS status page for now
    • Get the certificate information from CertManager and plug this into the TLS configuration.
  • 10. Benchmark new crypto against existing crypto
  • 11. Revert back to the staging's nix hash since we are not using 16.17's implementation of webcrypto (and its experimental ed25519 capability)
  • 12. Refactored key utilities to use libsodium instead of webcrypto
  • 13. Create KeyRing class which extracts all root key pair and KEM mechanism out of KeyManager.
  • 14. Test KeyRing by extracting out tests from KeyManager.test.ts.
  • 15. Applied memory locking to sensitive buffers so that key memory will not be swapped out.
  • 16. Create CertificateManager to extract out root certificate functionality out of KeyManager. It must take the KeyRing and DB as dependencies.
  • 17. Test CertificateManager by extracting out tests from KeyManager.test.ts.
  • 18. Replace all injections of KeyManager with KeyRing if they only require the NodeId.
    • verify and encrypt bin commands need to have the receiver public key added as a parameter. This needs to be propagated through the GRPC protobuf messages to the service handler.
    • CommandStart and CommandBootstrap need's it's configs updated.
    • When sending keys over the protobuf messages, replace the PEM types with the stringified JWT types.
    • Tests still need to be updated with the KeyRing changes.
  • 19. Not in scope to prototype the Observable of KeyRing, continue using the EventBus for the KeyRing.
    * This requires changing KeyManagerChangeData to CertificateManagerChangeData for now, as that's where the origin of renewing identity will come from.
  • 20. Remove all key pair fixtures from the test code, as it is now cheap to generate new keys.
  • 21. Remove KeyManager for now, and plan a new issue for a new KeyManager intended for secure computation usage and the management of arbitrary subkeys.
  • [ ] 22. Sigchain needs to use the CryptoKey by using keysUtils.importKey - Sigchain is being refactored, see Replace JOSE with our own tokens domain and specialise tokens for Sigchain, Notifications, Identities and Sessions #481
    • src/claims/utils.ts:36 createClaim takes the private key as the PEM format, this needs to be updated to take a private key directly.
  • 23. Consider the integration of WorkerManager for slow tasks specifically password hashing to prevent blocking the main thread. Benchmark if this is reasonable. Certificate signing also seems slow too, look into this too.
  • 24. The CertManager may have an expired current certificate occurring due not starting the CertManager for a while. This means we need to immediately renew the certificate upon start. Right now this is not guaranteed. Need to add in some renewal logic that occurs automatically if the current certificate is now expired.
  • 25. Remove everything that relies on PEM keys, and use JWK keys instead, plus do not show raw unencrypted private key JWK for any commands, anything that exports the root key pair should be encrypting the private key.
    • Use dictionary output formatter recursively when doing pk agent status command
    • Use wrapWithPassword when outputting the private key to show the key pair pk keys root, these should be showing the JWK for public key, and JWE for the private key, use dictionary formatting as well during human format, and JSON otherwise
    • New commands pk keys private, pk keys public, pk keys keypair. The private and keypair commands should be taking a password for wrapping. This should take --password-path or take from input prompt. The --format json should produce a useful JSON dictionary. For keypair it should be { publicKey: JWK, privateKey: JWKEncrypted }.
    • Verify that pk agent status produces a recursive dictionary output for public key JWK.
    • Verify that pk agent start and pk agent bootstrap can use all new key ring configuration and cert manager configuration.
  • 26. It is possible to DI a randomSource into wherever IdSortable and IdRandom is being constructed. This ensures that js-id is using keys/utils/random.ts instead of its own provided randomness.
  • 27. Verify TLS cert verification is working properly: Updating Crypto to use WebCrypto API and to replace RSA with ECC #446 (comment)
  • 28. Verify that Nodes no longer requires the public key, they can just use the NodeId.
  • 29. Replace JOSE with our own implementation of JWS since we cannot use it anymore. See Updating Crypto to use WebCrypto API and to replace RSA with ECC #446 (comment)
    • - src/tokens domain replacing JOSE JWS
    • - Testing src/tokens
    • - src/claims domain specialising src/tokens
    • - Testing src/claims
    • - Input validation of tokens using parsing functions
    • - Input validation of claims using parsing functions
    • - Adapting Sigchain to use the new claims and tokens
    • - Testing Sigchain with the new claims and tokens structure
    • - Indexing the claims in Sigchain for faster access for link identity and link node
    • - Updating identities to use the new tokens and claims
    • - Moving IdentityInfo and NodeInfo into gestalts
    • - Updating gestalts to record indexed information acquired from discovery - Gestalt Link Schema Refactoring - Derived from JOSE replacement #492
    • - Updating discovery to use the tokens and claims, in particular claim links and verifying claim links - Discovery Refactoring - Derived from JOSE replacement #493
    • - Updating the GRPC handlers dealing with cross signing node links
    • - Updating notifications to use the new tokens
      • - Changing General.json, VaultShare.json, and GestaltInvite.json to use parse/generate utilities instead of JSON schema. These utilities should go into the notifications/utils.ts. It can reference the validation/errors.ts.
      • - The messages should be using the tokens domain, and they should be "signed tokens"
    • - Updating sessions to use the new tokens
      • - Replace the usages of JOSE in the 2 utilities createSessionToken and verifySessionToken with calls to the tokens domain. The session token should then be a SignedToken. The signature is being signed by a symmetric key. Not the private key.
    • - Decentralising input validation parsers to avoid this SPOF
  • 30. Bring in hashing algorithms from https://sodium-friends.github.io/docs/docs/sha and use these. These can go into src/keys/utils/hashing.ts. (Replace node forge uses with these). Note that multiformats hashing may require a "webcrypto" polyfill, but we don't know for sure
  • 31. Do gestaltGraph.setNode() for the agent's own node at the startup of the agent. - Updating Crypto to use WebCrypto API and to replace RSA with ECC #446 (comment)
  • 32. Address the Sigchain.getClaims and Sigchain.getSignedClaims pagination testing problems: Sigchain Class API should provide paginated ordered claims by returning Array-POJO and indexed access #327 (comment)

Testing

Tests must start using fast check arbitraries and where suitable model based testing:

  • Identities
  • nodes
  • gestalts
  • discovery
  • notifications
  • vaults
  • grpc - Mostly fine? problem with utils
  • client
  • agent
  • bin
    • Some tests may be missing such as the identities invite
    • agent
    • keys
    • notifications
    • secrets
    • vaults
    • identities
    • nodes

Minimal tests for networking, grpc, nodes because we are likely to change it quite a bit in our next major rework of the networking with QUIC and RPC with JSONRPC.

Final checklist

  • Domain specific tests
  • Full tests
  • Updated inline-comment documentation
  • Lint fixed
  • Squash and rebased
  • Sanity check the final build

@ghost
Copy link

ghost commented Sep 13, 2022

👇 Click on the image for a new way to code review
  • Make big changes easier — review code in small groups of related files

  • Know where to start — see the whole change at a glance

  • Take a code tour — explore the change with an interactive tour

  • Make comments and review — all fully sync’ed with github

    Try it now!

Review these changes using an interactive CodeSee Map

Legend

CodeSee Map Legend

@CMCDragonkai
Copy link
Member Author

This upgrades our pkgs.nix commit to the latest master commit in Nixpkgs: 4f07c962034d929496b460bca69ba10c79c30245 in order to get node 16.17 which has the official support for ed25519.

This implies some changes to the rest of the nix derived dependencies and we have to update pkg as well.

The gitlab-runner is also due for an update as it will be building the latest PK to avoid having to redownload dependencies.

We'll see if there are other issues.

@CMCDragonkai CMCDragonkai self-assigned this Sep 13, 2022
@CMCDragonkai
Copy link
Member Author

Node has native implementations of Ed25519 (for signing and verification) and X25519 for encryption/descryption.

However there's also a native JS version of this:

It's time to do some benchmarking between the 2 to know which one is best to replace node-forge.

@CMCDragonkai
Copy link
Member Author

Notes from https://github.com/paulmillr/noble-ed25519

Random Bytes

Random source is hardcoded in the library to use either web crypto or node's crypto (but not node's web crypto).

  randomBytes: (bytesLength: number = 32): Uint8Array => {
    if (crypto.web) {
      return crypto.web.getRandomValues(new Uint8Array(bytesLength));
    } else if (crypto.node) {
      const { randomBytes } = crypto.node;
      return new Uint8Array(randomBytes(bytesLength).buffer);
    } else {
      throw new Error("The environment doesn't have randomBytes function");
    }
  },

However it is possible to override this by directly monkey patching the library like:

import * as ed from '@noble/ed25519';
ed.utils.randomBytes = (bytesLength: number = 32): Uint8Array => {
  // Bring your own random byte generator...
};

Note that this function is assumed to be synchronous. The reason for this is that web crypto's random byte generator
does not support promises, while Node's supports callbacks.

According to node, it is recommended to use the async version, however this is only a performance issue if generating
very large amount of bytes, and even then it's recommended to "stream" it, by chunking all the random bytes up.

This means, even when synchronous, it's would be easy to make a streamable by making it a generator:

async function* generateRandomBytes(length, chunk): AsyncGenerator<Uint8Array> {
  // partition length to chunk, and yield chunks
  // while sleeping to allow the interpreter to proceed
  yield randomBytes(chunk);
  await sleep(0);
}

As a side note, have a look at https://nodejs.org/api/cli.html#uv_threadpool_sizesize to increase the number of libuv
threads.

Ed25519 Private & Public Keys

Ed25519 is digital signature scheme doesn't bundle in encryption unlike RSA.

Generating a private key is really easy, it's just a matter of generating 32 random bytes.

import * as ed from '@noble/ed25519';
const privateKey: Uint8Array = ed.utils.randomPrivateKey();
// This is the equivalent of the above
const privateKeyAlso: Uint8Array = ed.utils.randomBytes(32);

Doing this is really fast... much faster than RSA.

Note that in web crypto, it says that if you want to generate keys, you want to actually use generateKey, which does
in fact return a promise. Node's generateKey does actually support ed25519 keys right now. That's something to explore
to compare with this.

The resulting key is just a Uint8Array.

Now in order to get the public key, it's a simple math operation:

const publicKey: Uint8Array = await ed.getPublicKey(privateKey);

The privateKey can be a Uint8Array or a hex string.

Also the size of both the private and public keys is always 32 bytes.

Right now our NodeId is also 32 bytes exactly. So we could fit the ed25519 public key directly into the NodeId.

Ok so the basic signing and verification works like this:

const message = ed.utils.randomBytes(1000);

// A signature is always 64 bytes
const signature = await ed.sign(message, privateKey);

(await ed.verify(signature, message, publicKey)) === true;

That's basically it.

X25519

Now how do we do encryption and decryption? It's quite different from RSA which has bundled it's own encryption utility in it.

Basically this is the diffie-hellman key exchange. This is a key agreement protocol that allows two parties to derive a shared secret key without ever having to exchange it.

The shared secret key is what is used for symmetric encryption. The key point is that this shared secret is not known... and actually it's not communicated over any channel.

It's sufficient for the 2 parties to share only their public key, and both parties will end up creating the same shared secret without any communication.

To do this using the ed25519 keys:

import * as ed from '@noble/ed25519';
const privateKey: Uint8Array = ed.utils.randomPrivateKey();
const publicKey: Uint8Array = await ed.getPublicKey(privateKey);
// This is also 3 bytes long, and it is deterministic
const sharedSecret: Uint8Array = await ed.getSharedSecret(privateKey, publicKey);

The shared secret is a X25519 shared key.

Here's a demonstration:

import * as ed from '@noble/ed25519';

async function main() {
  const alicePrivateKey = ed.utils.randomPrivateKey();
  const alicePublicKey = await ed.getPublicKey(alicePrivateKey);
  const alice = {
    private: alicePrivateKey,
    public: alicePublicKey,
  };

  const bobPrivateKey = ed.utils.randomPrivateKey();
  const bobPublicKey = await ed.getPublicKey(bobPrivateKey);
  const bob = {
    private: bobPrivateKey,
    public: bobPublicKey,
  };

  // Imagine Alice and Bob exchange public keys

  const aliceSharedSecret = await ed.getSharedSecret(
    alice.private,
    bob.public
  );

  const bobSharedSecret = await ed.getSharedSecret(
    bob.private,
    alice.public
  );

  for (let i = 0; i < aliceSharedSecret.byteLength; i++) {
    if (aliceSharedSecret[i] !== bobSharedSecret[i]) {
      console.log('Shared secrets are not equal');
    }
  }

  // The secrets are the same!

}

void main();

The shared secret is always 32 bytes.

Now to actually do the encryption, we need a symmetric cipher system.

The noble library does not handle this.

At this point I believe that the shared secret is not directly used, instead it is
passed in to a KDF to produce a nonce or a derived secret.

The derived secret (nonce) and the original shared secret is what is used for encryption and decryption.

The encryption/decryption should still work with AES-256-GCM.

The X25519 just means we used the DH exchange.

@CMCDragonkai
Copy link
Member Author

So even if we were to use the noble library, we'd still have to use the webcrypto library in nodejs anyway. Which means that since ed25519 is already available inside node... we may not really need to bring in the extra library.

@CMCDragonkai
Copy link
Member Author

We should also test the TLS system's support for the Ed25519 or X25519.

@CMCDragonkai
Copy link
Member Author

One other issue is that webcrypto doesn't deal with x509 certs.

So it seems we'd need at least 3 libraries:

  1. Something to deal with x25519/ed25519 - noble does this, but node's webcrypto has it already
  2. Something to deal with symmetric crypto - have to use node web crypto anyway
  3. Something to deal with certificates x509... etc - see peculiar ventures/x509, node's crypto has support for dealing with certificates directly

@CMCDragonkai
Copy link
Member Author

I've dived into how to get Ed25519 into our X.509 PKI:

Some notes:

  1. https://security.stackexchange.com/a/211484/37070 - explains why Curve25519 is preferred over NIST curves, basically people don't trust the NIST curves since it may be influenced by NSA.
  2. Both X25519 and Ed25519 use Curve25519. Browsers currently do not support Ed25519 X.509 certificates. https://chromestatus.com/feature/4913922408710144
  3. OpenSSL does support Ed25519, as can be seen by https://blog.pinterjann.is/ed25519-certificates.html which should mean that TLS in general should support Ed25519 certificates, and therefore Ed25519 would be the root key.
  4. This means if we run our own web server, and use curl or other tools, if they all using the recent OpenSSL and TLS protocol, they should be able to to communicate using Ed25519 certificates. But browsers will not be able to communicate with such web servers. This means we should be able to use it in our P2P network, however if we expect to run an HTTPs web page in PK, and expect users to use Chrome or other browsers, they will not be able to visit these pages at the moment.
  5. However Chrome and Firefox does in fact support X25519 (but not Ed25519), but only for key exchange... This does not mean it supports Ed25519 certificates. This question https://security.stackexchange.com/questions/236931/whats-the-deal-with-x25519-support-in-chrome-firefox shows that you can use wget to contact the web server. So it seems to mean that the X.509 cert has to still be signed with something else...
  6. X25519 certificates pyca/cryptography#6072 (comment) - says that there are no such thing as X25519 signatures defined in crypto standards. There are ways to convert between Ed25519 and X25519 keys. So we should be able to use X25519 public keys in the X.509 certificate. Ok so the certificate still uses some other key, x25519 is only used for the key exchange. How do we trigger key exchange for the browser then? Or do we convert our ed25519 key to some other key to be used in the TLS cert?
  7. It seems if you want to support browsers, you still need to use RSA or NIST curves, and it's not possible to use Ed25519 yet, and X25519 cannot be used to sign... so basically no browser support... This is not a problem yet, since PK communication is all occurring between each other. I'm just thinking about issue HTTP status page for Polykey Agent #412, it seems you'd have to derive some NIST curve key if you want browsers to accept it. Is there a way to do this?

@CMCDragonkai
Copy link
Member Author

Ok there's another issue, the webcrypto standard doesn't yet support X25519 or Ed25519. NodeJS added them in before it's been standardised by the web. There's a proposal for it here: https://github.com/tQsW/webcrypto-curve25519/blob/master/explainer.md

This means webcrypto implementation in nodejs isn't standardised. Which could meant that it would be worth continue using the noble version of Ed25519.

At the same time, as I illustrated above. If we want to use the TLS certificate for web browsers. We would need to derive another key that is based on NIST curve which would then used for the web TLS. This https://crypto.stackexchange.com/questions/50249/converting-a-c25519-curve-into-a-nist-supported-curve-for-fips-crypto demonstrates that we would use HKDF on the private key to generate an appropriate P256 key, which is then presented as the TLS certificate to browser.

However between 2 PK nodes, they should just use the Ed25519 X.509 certificate directly.

Now since the browser won't be trusting the Ed25519 keys, they'd be trusting the derived keys directly. One could present in the certificate that the new P256 key is signed (trusted) by the root Ed25519 key... that would then produce a "certificate chain". And this would be used until browsers had native support for Ed25519 X.509 certs. At that point, we discard the P256 certificate.

@CMCDragonkai
Copy link
Member Author

Let's go through the bootstrapping process.

  1. Recovery Code is randomly generated. This is a random 12/24 word sequence.
  2. The recovery code is used by PBKDF2 (as specified by bip39) to generate random key seed. The PBKDF2 is used because it has the ability to do key stretching. The number of iterations is hardcoded to 2048. The output length is hardcoded to 64 bytes (512 bits).
  3. This pseudo random seed is then used to derive a root key. With RSA, we are simply using it as the random seed. But with Ed25519, it's possible to just directly use this random seed by truncating it to 32 bytes, and we have the Ed25519 private key.
const seedByteString = pkcs5.pbkdf2(
  'fan rocket alarm yellow jeans please reunion eye dumb prepare party wreck timber nasty during nature timber pond goddess border slam flower tuition success',
  'mnemonic',
  2048,
  64,
  md.sha512.create(),
);

// Always 64 bytes
const seedBuffer = Buffer.from(b, 'binary');

Compare with: https://iancoleman.io/bip39/

  1. According to https://crypto.stackexchange.com/a/20963/102416, you can also use HKDF (https://github.com/paulmillr/noble-hashes) on the binary seed to derive the actual encryption keys. Not sure about the advantages here, but basically this means PBKDF2 + HKDF which should be able to generate multiple subkeys for various uses. See: https://crypto.stackexchange.com/questions/35817/key-derivation-with-curve25519-for-data-encryption
  2. At some point we will have the Ed25519 private key. This will be known as the root key or KEK (key encryption key).
  3. The public key is then derived from this private key.
  4. The combination of the public and private keypair will be stored as JWK JSON file. The JWK file will be encrypted with the root password. Currently this is done by writing the public key as a public PEM file, and then using purpose built encryption of RSA private key with aes256, salt size 8 and 10000 which also stores it as a PEM file. With Ed25519 private key, we can reconstruct our own scheme here or replace it with something using JWE/JWK. Basically this requires a symmetric cipher which requires deterministically deriving a symmetric key from the password, this again can make use of PBKDF2 as we used already for the recovery code... The storage format will just be changed to JWK (and/or PEM file). Will need to use jose or otherwise for this.
  5. From this point onwards, everything else is pretty similar. We still generate symmetric keys for everything else. These symmetric keys are randomly generated, and are not derived from the master key. Multiple symmetric keys would be used for different purposes. Some will be ephemeral, some will be static. If they are static they may need to be stored in the DB.
  6. Note that the master database key, is currently also stored separately from the root key. This is what allows the database to be "re-encrypted" without affecting the root key, while the root key can change without affecting the database. Instead of randomly generating the DB key, it could also be derived, but a "sequence" could be applied to generate the next database key. This could help recovery of the database if the database key were to be lost, all you'd need to do is to keep incrementing a sequential counter to eventually get the database key that was used to encrypt the database.

Ok this takes care of the bootstrapping and root key. What about general encryption/decryption? This is where webcrypto is introduced as a replacement.

As for TLS, to avoid node specific dependencies here, it would be worthwhile to check out https://www.npmjs.com/package/@peculiar/x509. This gives us a library for general purpose usage.

JOSE can still be used, but we may require something more general later.

@CMCDragonkai
Copy link
Member Author

Ok now I'm going to try out the webcrypto in nodejs to see how it compares to noble.

@CMCDragonkai
Copy link
Member Author

CMCDragonkai commented Sep 14, 2022

Ok I've worked out the webcrypto API. It is very clunky... and not very flexible.

One of the first things I noticed that is that you cannot generate an Ed25519 key directly with deterministic random values. That is our bootstrap process above cannot be done at all since we are going from recovery code to root key.

There's only 2 ways to get an Ed25519 private key:

import { webcrypto } from 'crypto';

function main () {
  const keyPair = await webcrypto.subtle.generateKey(
    { name: 'Ed25519' },
    true,
    [ 'sign', 'verify' ]
  ) as CryptoKeyPair;

  // The API does not allow us to export the private key as raw bytes
  // It does however allow us to export as JWK
  // Only the public key can be exported as raw bytes
  // JWK is the modern format, better than pkcs8 or spki
  const privateKeyJWK = await webcrypto.subtle.exportKey(
    'jwk',
    keyPair.privateKey
  );

  // Since we cannot export the raw bytes, we also cannot import it as raw bytes
  // I tried actually, and it doesn't allow it, so we can only import via jwk
  // or the pkcs8
  const privateKeyAgain = await webcrypto.subtle.importKey(
    'jwk',
    privateKeyJWK,
    'Ed25519',
    true,
    ['sign']
  );
}

void main();

Basically this already means that webcrypto is out, and we cannot use it in this way.

Furthermore, the webcrypto API is very restrictive... probably to avoid footguns. Notice that when generating the key it is only allowed to sign and verify. There's no way to tell webcrypto to also derive keys, or to encrypt something with the key material, you have to give it a different key suitable for that.

It has a concept of key wrapping, which is basically encrypting a key with another key. This is useful for example when we want to store the private root key on disk, and need to encrypt it with the password. This is basically a key derivation function plus encryption.

So to do this:

  const aeskey = await webcrypto.subtle.generateKey({
    name: 'AES-KW',
    length: 256,
  }, true, ['wrapKey', 'unwrapKey']);

  const aeskey2 = await webcrypto.subtle.generateKey({
    name: 'AES-GCM',
    length: 256,
  }, true, ['wrapKey', 'unwrapKey', 'encrypt', 'decrypt']);

  // This basically exports it as JWK using the first 2 parameters
  // then uses the second 2 parameters to "encrypt it"
  // thus giving us an ArrayBuffer
  // is this known as a JWE?
  const wrappedPrivate = await webcrypto.subtle.wrapKey(
    'jwk',
    keyPair.privateKey,
    aeskey,
    'AES-KW'
  );

  const randomBytes = webcrypto.getRandomValues(new Uint8Array(12));

  const wrappedPrivate2 = await webcrypto.subtle.wrapKey(
    'jwk',
    keyPair.privateKey,
    aeskey2,
    {
      name: 'AES-GCM',
      iv: randomBytes
    }
  );

  const enc = new TextEncoder();
  const wrappedPrivate3 = await webcrypto.subtle.encrypt(
    {
      name: 'AES-GCM',
      iv: randomBytes
    },
    aeskey2,
    enc.encode(JSON.stringify(privateKeyJWK))
  );

  const d2 = await webcrypto.subtle.decrypt(
    {
      name: 'AES-GCM',
      iv: randomBytes,
    },
    aeskey2,
    wrappedPrivate2
  );

  const d3 = await webcrypto.subtle.decrypt(
    {
      name: 'AES-GCM',
      iv: randomBytes,
    },
    aeskey2,
    wrappedPrivate3
  );

So basically the AES-KW is preferred over AES-GCM for doing key wrapping, but you can't directly use AES-KW for encryption cause it's not allowed... even though that's what it is doing under the wraps.

At any case, it does show that we can easily use to it do AES-GCM encryption.


For cross platform compatibility, it seems webcrypto itself is quite restrictive. Many things should work without relying on node:

  1. noble's ed25519 can be used instead since it's pure JS and it's fast unlike webcrypto
  2. for certificate management - we can try out Peculiar Venture's certificate library (replacing node forge)
  3. tls will still be node's tls for now
  4. for random values - we can continue doing platform adapters
  5. For hashing - we can use noble's hashing (which has pbkdf2 and hkdf), or webcrypto hashing, it appears to have what we need, or we can use multihash
  6. For base encoding - we stick with multibase
  7. For direct encryption/decryption - platform adapters are needed, but webcrypto is likely a good choice in this case, since aes-gcm is well supported across the board

@CMCDragonkai
Copy link
Member Author

CMCDragonkai commented Sep 14, 2022

We can start prototyping the bootstrapping method as stated above using all the things we have worked out. While also exploring the X509 library, we will need the ability to create custom extensions as that's what we are using right now for our custom root certificate chain mechanism.

PeculiarVentures also has a PKI.js which seems to be able to do the same thing. Not sure about the difference.

@CMCDragonkai
Copy link
Member Author

While reworking the bip39, I found https://github.com/paulmillr/scure-bip39.

I found that it ends up using noble/hashes and scure/base and we are already using noble/hashes.

It can replace the bip39 library we are downloading, and minimise the mount of dependencies we are using.

Furthermore, our bip39 isn't entirely correct because it's not using the same normalisation technique.

So I'm thinking we just use scure/bip39 and then use that instead.

Originally we couldn't just use bip39.mnemonicToSeed function because we had to use as a PRNG seed to the RSA generation.

But with ed25519, it's simple to just use the first 32 bytes of the returned seed as the private key of Ed25519.

Also note that we don't bother with the optional passphrase as per https://vault12.com/securemycrypto/crypto-security-basics/bip39/what-is-a-bip39-passphrase

How much security does a passphrase add? Because the BIP39 seed phrase itself offers an incredibly high level of protection against being guessed, the addition of a passphrase actually doesn't significantly reduce the risk of a brute-force guessing attack. Instead, the primary purpose for a passphrase is to add an extra layer of security to protect against the possibility that your seed phrase may be accidentally revealed to someone.

Plus it's just a bit confusing.

@CMCDragonkai
Copy link
Member Author

Just a note that optional passphrase can be used as the "25th" seed word. That can be used to create hierarchical key pairs. This concept is actually standardised under BIP32, and is used to create different wallets and probably used again for BIP44. See #352 for more information. We'll leave this to a later date and keep with our usage of BIP39 without any additional passphrase.

@CMCDragonkai
Copy link
Member Author

CMCDragonkai commented Sep 15, 2022

So we are replacing:

async function generateDeterministicKeyPair(
  bits: number,
  recoveryCode: string,
): Promise<KeyPair> {
  const prng = random.createInstance();
  prng.seedFileSync = (needed: number) => {
    // Using bip39 seed generation parameters
    // no passphrase is considered here
    return pkcs5.pbkdf2(
      recoveryCode,
      'mnemonic',
      2048,
      needed,
      md.sha512.create(),
    );
  };
  const generateKeyPair = promisify(pki.rsa.generateKeyPair).bind(pki.rsa);
  return await generateKeyPair({ bits, prng });
}

With:

import * as noblePbkdf2 from '@noble/hashes/pbkdf2';
import { sha512 as nobleSha512 } from '@noble/hashes/sha512';

  const key = await noblePbkdf2.pbkdf2Async(
    nobleSha512,
    recoveryCode,
    'mnemonic',
    { c: 2048, dkLen: 64 },
  );

And now with @scure/bip39 we can just do:

async function generateDeterministicKeyPair(recoveryCode: RecoveryCode) {
  // This uses BIP39 standard, the result is 64 byte seed
  // This is deterministic, and does not use any random source
  const recoverySeed = await bip39.mnemonicToSeed(recoveryCode);
  // Slice it to 32 bytes, as ed25519 private key is only 32 bytes
  const privateKey = recoverySeed.slice(0, 32);
  const publicKey = await nobleEd.getPublicKey(privateKey);
  return {
    publicKey,
    privateKey
  };
}

In this case we are keeping to using Uint8Array to avoid using Buffer. We are likely to use that later, but for the utilities it would be nice to stay within the ECMAScript.

The result is:

{
  publicKey: Uint8Array(32) [
    151, 163,  77,  81, 151,  95,  39, 172,
    147, 178,  25, 239,  26, 216, 192, 124,
    166,  67, 165,  82,  30,  67,  34, 130,
     48, 223,  81,  46,  62, 128, 212, 143
  ],
  privateKey: Uint8Array(32) [
    117,  17, 102,  57, 202, 239,  89, 220,
    118,  95, 144,  86,  80,  13,  33, 250,
    136,  79, 145,  86, 201,  53, 239,  72,
     82, 187, 195, 221, 218, 233, 145,  32
  ]
}

@CMCDragonkai
Copy link
Member Author

CMCDragonkai commented Sep 15, 2022

To override both the random sources it has to be done globally for scure and noble libraries:

// @ts-ignore - this overrides the random source used by @noble and @scure libraries
utils.randomBytes = (size: number = 32) => getRandomBytesSync(size);
nobleEd.utils.randomBytes = (size: number = 32) => getRandomBytesSync(size);

// Note that NodeJS Buffer is also Uint8Array
function getRandomBytesSync(size: number): Uint8Array {
  console.log('CUSTOM CALLED');
  const randomArray = webcrypto.getRandomValues(new Uint8Array(size));
  return randomArray;
  // return Buffer.from(randomArray, randomArray.byteOffset, randomArray.byteLength);
}

That means, as long as the keys utils is imported, it will be overrided. But this makes sense, so it's always important to use the keys/utils for this.

@CMCDragonkai
Copy link
Member Author

One issue is that panva/jose doesn't make it esay to override the random source. The random source is in src/runtime/X/random.ts. Where X could be some runtime.

The actually compiled code could be jose/dist/node/cjs/runtime/random.

This is not as easy as overriding the above, as it is exported as default, plus jose has some restrictions on the way it exports things.

Cisco's node-jose is a little more flexible... but it seems we'd want to avoid doing anything non-deterministic in jose if we can't override the random source.

@CMCDragonkai
Copy link
Member Author

CMCDragonkai commented Sep 15, 2022

Creating the JWK itself does not actually involve JOSE. You can just do this:

const d = base64.base64url.baseEncode(rootKeyPair.privateKey);
const x = base64.base64url.baseEncode(rootKeyPair.publicKey);

const privateKey = {
  alg: 'EdDSA',
  kty: 'OKP', // Octet key pair
  crv: 'Ed25519', // Curve
  d: d, // Private key
  x: x, // Public key
  ext: true, // Extractable (always true in nodejs)
  key_ops: ['sign', 'verify'], // Key operations
};

// Note that if you don't pass `d`, then it becomes a public key, rather than a private key

console.log(JSON.stringify(privateKey));

This produces a JSON string with what we need.

It's only if we want to use JOSE operations, that we would then use jose.importJWK to turn it into a browser native key object.

Ok but let's see how we can encrypt this with the root password, and whether this means turning it into a JWE (and if so, does it end up using the random source in jose).

The jose.exportJWK itself does not maintain all the above information, so it seems this is something we would mostly manage ourselves.

@CMCDragonkai
Copy link
Member Author

CMCDragonkai commented Sep 16, 2022

So JOSE has its own random source which cannot be overridden and also its own crypto algos. In the future we should swap out panva/jose with our own jose. We cannot use node-jose because that isn't sufficient either. At any case, after reading the JW* specs, it seems easy to do.

Now about the encrypted JWK.

As discussed earlier, this is a JWK:

const privateKey = {
  alg: 'EdDSA',
  kty: 'OKP', // Octet key pair
  crv: 'Ed25519', // Curve
  d: d, // Private key
  x: x, // Public key
  ext: true, // Extractable (always true in nodejs)
  key_ops: ['sign', 'verify'], // Key operations
};

An encrypted JWK according to the spec is a JWE with some additional metadata.

This is a JWE that we will want to create:

{
  ciphertext: 'MR1pf9NzhxB-xCtlYIqwEuak0r8dG_K1nIMNwr_Ln0Npz2DLNkRf9aonJi9W8kEs9xDB1fyegbDXCXBQ0b5sU8TBGjUDe6vB2LkFCeDG8sF3POKHbB_-OHY0q5hndha8UVrNEgqPZGhZ9d3FZS1KVkO42W9AQ9CiRGJdtwZK6fU8G797TJqcVn7fS6T8KQim-JQQasockkTF8_J8luwKR-BkTKHmCBIRasYyUDfWrX7yA-1DR94',
  iv: 'w3JgzTnfOviK5zOi',
  tag: '5dtd4arJj4BEREULuk3RtQ',
  encrypted_key: 'WQwwLYia9hOwA_zfqlsXjng8eDF9p_Oz7R63vpN3qaDwu7xM7oa7dg',
  protected: 'eyJhbGciOiJQQkVTMi1IUzUxMitBMjU2S1ciLCJlbmMiOiJBMjU2R0NNIiwiY3R5IjoiandrK2pzb24iLCJwMmMiOjIwNDgsInAycyI6IjZVMDJJeFJ0T19TVjNRb1pEbFNVV1EifQ'
}

To be precise there are 3 kinds of JWE serialisations: compact, flattened, and general. The above is the flattened serialisation. Compact is just one entire encoded string. The flattened is still JSON. General is used for if you want to encrypt something for multiple recipients.

In order to produce that JWE from the JWK we just do this:

const privateKeyString = JSON.stringify(privateKey);

const jwe = new jose.FlattenedEncrypt(Buffer.from(privateKeyString));

jwe.setProtectedHeader({
  alg: 'PBES2-HS512+A256KW', // PBES2-HS512 is a scheme using PBKDF2 with HMAC512, then A256KW is used for key wrapping
  enc: 'A256GCM', // this is the symmetric encryption cipher
  cty: 'jwk+json' // this is the "type" of JWE, in this case, telling us that this is an encrypted JWK as a JSON object
});

// Now according to the JWK RFC, the internal steps are quite complicated
// But what happens is:
// 1. A random CEK is generated
// 2. The random CEK uses A256GCM to encrypt the plaintext (the JSON string of JWK)
// 3. The root password is passed into PBKDF2 to derive a key
// 4. This derived key encrypts the CEK with A256KW

const rootPassword = Buffer.from('some password');

// Here jwe.encrypt is doing all of those steps under the hood, it is also randomly generating all the parameters, like `iv` for A256GCM, and also the count and salt for PBKDF2
const encryptedJWK = await jwe.encrypt(rootPassword);

const decryptedJWK = await jwe.flattenedDecrypt(encryptedJWK, rootPassword);

const privateKeyAgain = JSON.parse(decryptedJWK.plaintext.toString());

// Example of outputting the protected header information (it's not encrypted, only protected integrity through authenticated encryption, this is facilitated by the `encryptedJWK.tag` property)
console.log(jose.decodeProtectedHeader(encryptedJWK));

Note that JOSE is setting up certain parameters automatically. The PBKDF2 count is defaulted to 2048, and the PBKDF2 salt is randomly generated and also put in to the resulting protectedHeader. The code doing the defaulting is here: https://github.com/panva/jose/blob/0d63529495b8568956a46031163a2850456d4a4a/src/runtime/node/pbes2kw.ts#L35-L36

The decryptedJWK looks like this:

{
  plaintext: <Buffer 7b 22 61 6c 67 22 3a 22 45 64 44 53 41 22 2c 22 6b 74 79 22 3a 22 4f 4b 50 22 2c 22 63 72 76 22 3a 22 45 64 32 35 35 31 39 22 2c 22 64 22 3a 22 47 41 ... 132 more bytes>,
  protectedHeader: {
    alg: 'PBES2-HS512+A256KW',
    enc: 'A256GCM',
    cty: 'jwk+json',
    p2c: 2048,
    p2s: '6U02IxRtO_SV3QoZDlSUWQ'
  }
}

Some questions that might be asked:

  1. Why did it have to produce an intermediate CEK? Why not just directly use the PBKDF2 derived key. The answer is that it was meant to allow the CEK to be re-encrypted without re-encrypting the plaintext. This can be advantageous if the plaintext is large. However panva/jose does not support this functionality, so this advantage cannot be leveraged atm, and secondly our plaintext is just the JWK which is really small.
  2. What is the purpose of the auto-generated salt and count, and why is it not recommended to be set? The salt for PBKDF2 is used for preventing rainbow attacks, that means precomputed table of hashes. The salt is actually public, and stored with the JWE in the protectedHeader. Meaning that it makes it difficult for an attacker to crack the JWE (encrypted JWK) if they had it on hand.
    • Then why are we not using a salt in our BIP39 derivation of the root key itself? The reason is that it adds little benefit when the input is already high entropy (unlike the root password which is likely low entropy). It can however be useful in the future, we may add it in an additional layer of security.

Note that JWE does support direct encryption mode, where there's no intermediate CEK: See also: https://security.stackexchange.com/questions/80966/what-is-the-point-of-aes-key-wrap-with-json-web-encryption

Ok so anyway, the encryptedJWK JSON object can then be stringified and written to disk.

There's no designated file extension for this. We could just use .json. Like root_key.json or root_key.jwk. Right now we use root.key for a PKCS8 storage, we could name it root.key.json and root.pub.json for the public key. This basically means using JWK.

We could also just start a trend here: root.jwk as the encrypted private JWK. Then also root_pub.jwk for just the unencrypted public JWK.

Still need to explore what to store the certificate format.

It may still be a good idea to continue storing as the original PCKS8 and SPKI formats for cross compatibility... but maybe it's time to move to the future as well.

Also see: https://smallstep.com/docs/step-cli/reference/crypto/jwk

They suggest priv.json and pub.json. We could say root_priv.json and root_pub.json.

@CMCDragonkai
Copy link
Member Author

CMCDragonkai commented Sep 16, 2022

Ok now we can move to dealing with the encryption key or database key. We have a root key that is Ed25519. Which atm cannot be used to derive new keys, do key wrapping or any kind of encryption. In a way, it represents identity.

However our database is meant to be encrypted with symmetric encryption requiring a symmetric key. We can call this the "data encryption key". It functions similarly to the CEK discussed above in the JWK.

Atm, this data encryption key is randomly generated.

It does not need to be derived from the root key. And by derivation we mean to use something HKDF not PBKDF2.

The reason it does not need to be derived, is because if the root key were to change, then the data encryption key would also change, and thus require re-encryption of the entire database. And we do change the root key, then we would not be able to derive the same data encryption key.

Therefore the link between the root key and the data encryption key is separate. So the data encryption key is randomly generated, and will be saved to disk along with the database. This does mean that without the data encryption key you will not be able to decrypt the database.

This is basically how it is done already. So there's no change here. Atm, this database encryption key is in .polykey/state/keys/db.key. And this is a symmetric key used for AES256GCM symmetric encryption.

With the new ed25519 root key, we now need to use this to encrypt the data encryption key (the db.key. The normal ed25519 is not meant to be used for encryption. It should be converted to an x25519 key before being used for encryption (technically known as key wrapping).

Now here I'm confused by the specifics. There is some debate whether this is secure:

The paper seems to argue that this is fine. It seems we would do a diffie hellman exchange between the root private and root public (because we are our own recipient). To get a shared secret, then apply HKDF-Extract-like KDF. Then the resulting key can be used to encrypt a randomly generated symmetric key.


Note that hashicorp vault has this same concept known as the encryption key. Unlike hashi's vault, we don't do data encryption key rotation yet. Their rotation of the encryption key is chained, maintained in a key ring that keeps all the symmetric keys around including the old ones. Then they encrypt new values with the new key, while still being able to decrypt the old values. I'm guessing if old values get accessed, they get repaired to the new key: https://www.vaultproject.io/docs/internals/rotation

@CMCDragonkai
Copy link
Member Author

Ok I've figured out several ways of managing the data encryption key for the database.

The end result we're looking for is there to be a ~/.polykey/state/keys/db_key.jwk that is protected by the asymmetric root Ed25519 keypair, and also used to symmetrically encrypt/decrypt data inside the database. This key is not derived from the root key, as this allows some rotation capability... either the ability to rotate the root keypair and the ability to rotate the database key independently. This concept is known as hybrid cryptosystems.

To do this, we have to first generate a random 32 byte symmetric key for AES256GCM. Then basically use it for encryption/decryption:

  const DEK = getRandomBytesSync(32);
  const DEKJWK = {
    alg: "A256GCM",
    kty: "oct",
    k: base64.base64url.baseEncode(DEK),
    ext: true,
    key_ops: ["encrypt", "decrypt"],
  };

  // This imports as a Uint8Array, but if we need webcrypto encrypt/decrypt, we need to use CryptoKey
  // const DEKImported = await jose.importJWK(DEKJWK) as Uint8Array;

  const DEKImported = await webcrypto.subtle.importKey(
    'jwk',
    DEKJWK,
    'AES-GCM',
    true,
    ['encrypt', 'decrypt']
  );

  const iv = getRandomBytesSync(16);

  const cipherText = await webcrypto.subtle.encrypt(
    {
      name: 'AES-GCM',
      iv,
      tagLength: 128,
    },
    DEKImported,
    Buffer.from('hello world')
  );

  const combinedText = new Uint8Array(iv.length + cipherText.byteLength);
  const cipherArray = new Uint8Array(cipherText, 0, cipherText.byteLength);
  combinedText.set(iv);
  combinedText.set(cipherArray, iv.length);

  const iv_ = combinedText.subarray(0, iv.length);

  const plainText = await webcrypto.subtle.decrypt(
    {
      name: 'AES-GCM',
      iv: iv_,
      tagLength: 128
    },
    DEKImported,
    cipherText
  );

Ok great, so now how do we store this symmetric key on the disk safely and securely? Well we can imagine that we start with an initial keyring (which is basically files on disk, not a database). And we now have several options.

  1. We can follow the Ed25519 to X25519 paper https://eprint.iacr.org/2021/509.pdf which suggests first doing a Elliptic Curve DH exchange using X25519 (that is converted from Ed25519) to get a shared secret. Then passing it into HKDF-Extract to get some key material. If the HKDF-Expand is applied afterwards, we are able to derive multiple "KEKs", key encryption key. Finally use this KEK as a key to encrypt the DEK JWK using alg: 'dir' (direct mode, without an intermediate CEK).
  2. We can follow alg: 'ECDH-ES' algorithm in encrypted JWK JOSE standards. This is quite similar to the first option, except that it also generates an ephemeral public key. It requires us to pre-convert the Ed25519 keys to X25519 keypair, and it uses Concat KDF rather than HKDF. Finally it does everything internally (but according to the spec https://www.rfc-editor.org/rfc/rfc8037.html), so it's sort of a blackbox to us. Just like option 1, there's no intermediate CEK.
  3. We can use option 1, then combine instead alg: 'A256KW' instead of alg: 'dir' which basically ends up creating a intermediate CEK. This option is kind of dumb, cause we don't need this additional intermediate key.

So it's a choice between option 1 and option 2.

According to https://neilmadden.blog/2021/02/16/when-a-kem-is-not-enough/, JOSE's ECDH-ES is a rather "simple" KEM, and in the blog post, it goes into far more different kinds of variants of KEMs. Which eventually leads to the Signal Protocol.

Furthermore this gist https://gist.github.com/codedust/b69ebb3be60490e8ddc4f1cabf76ec90 says:

Do not use Public/Private key pair schemes when you're both the issuer and recipient, better use direct symmetric encryption in such case.

So it seems that we should just use the alg: 'dir' mode instead of ECDH-ES, this gives us some flexibility in the future if we need to modify how we do our KEM (key encapsulation mechanism).

So I'm going with option 1.

@CMCDragonkai
Copy link
Member Author

In option 1, we hit a question about what the salt should be?

Well according to https://soatok.blog/2021/11/17/understanding-hkdf/, we don't really need to apply a salt in the HKDF-Extract.

But we will apply an info tag at the HKDF-Expand step. And that pretty much covers it.

@CMCDragonkai
Copy link
Member Author

The exploration of option 2 is interesting and may be useful in the future, because we can certainly convert the Ed25519 keypair to a x25519 keypair. The pub key is mapped to 1 pub key. The x25519 private key is the same as the ed25519 private key.

This can be useful in the future if we need to apply it to further kinds of encryption/decryption tasks.

One of the things I'm concerned about is that, option 1 covers our usecase of KEM for the database key, and potentially other state keys.

But the Ed25519 put into TLS is a separate construct, and I believe OpenSSL will do its own thing. But as mentioned above browsers don't understand this yet (and you cannot use x25519 as the keypair either).

But if we want to use Ed25519 to eventually produce encrypted signed messages, we have to follow the KEM concepts in here https://neilmadden.blog/2021/02/16/when-a-kem-is-not-enough/.

Or better yet, at that point we may just follow the ECDH-ES algorithm instead in JOSE. But if we intend to do that, we need to confirm how we actually convert the recipient's Ed25519 public key to an X25519 key.

I believe this is it:

  const u = Point.fromHex(publicKey).toX25519();

This comes from:

/**
 * Calculates X25519 DH shared secret from ed25519 private & public keys.
 * Curve25519 used in X25519 consumes private keys as-is, while ed25519 hashes them with sha512.
 * Which means we will need to normalize ed25519 seeds to "hashed repr".
 * @param privateKey ed25519 private key
 * @param publicKey ed25519 public key
 * @returns X25519 shared key
 */
export async function getSharedSecret(privateKey: PrivKey, publicKey: Hex): Promise<Uint8Array> {
  const { head } = await getExtendedPublicKey(privateKey);
  const u = Point.fromHex(publicKey).toX25519();
  return curve25519.scalarMult(head, u);
}

@CMCDragonkai
Copy link
Member Author

The above could be useful for notification messages that we want to secure beyond the initial TLS connection. Or the exporting of vaults.

@CMCDragonkai
Copy link
Member Author

CMCDragonkai commented Sep 18, 2022

Investigating https://github.com/PeculiarVentures/x509's library, I have come across an incompatibility between peculiar's webcrypto polyfill and node's webcrypto library. Details here: PeculiarVentures/webcrypto#55 and PeculiarVentures/PKI.js#89 and PeculiarVentures/webcrypto#25

So it seems that if I want to use ed25519 with https://github.com/PeculiarVentures/x509, I have to use peculiar's webcrypto and not node's webcrypto.

Now peculiar's webcrypto might be a good idea anyway. I'd have to compare the performance. The main reason is that webcrypto API has not actually standardised on how ed25519 keys are to be used. Node's implementation is just an experiment.

This is different from node forge, that allows one to just use raw buffers.

What are the advantages of using peculiar's webcrypto? Well it's a bit more portable, we aren't limited to a specific node version such as v16.17.0. It might just be as fast... since it ultimately relies on node's crypto. Furthermore it has been tested to work on electron, and there's a browser version of the lbirary too as per the example here: https://codesandbox.io/s/generate-cert-forked-d94dr5?file=/src/main.js - see: PeculiarVentures/webcrypto#31

So if we do end up using peculiar's webcrypto... we then have to polyfill all the uses of it... otherwise libraries like jose, and noble will end up using the native crypto.

Would that just be a matter of setting the global property crypto to the webcrypto? Actually it's only jose that would have this problem in the end.

@CMCDragonkai
Copy link
Member Author

At this point, no WASM is involved here at all. It won't be needed (can investigate later for #422).

Instead this is likely what will happen...

New libraries:

  • @noble/ed25519 - for ed25519 and x25519
  • @noble/hashes - for hashing, replacing all the node-forge hashing functionality (KDFs and more)
  • @peculiar/webcrypto - polyfill webcrypto
  • @peculiar/x509 - replaces all node-forge's x509 functionality
  • @scure/bip39 - replaces bip39 (this brings in @scure/base and @noble/hashes anyway)

Remove libraries:

  • node-forge
  • bip39

Library that we keep using: jose

What will happen are:

  1. Changing root key to ed25519
  2. Changing to using JWK/JWE for our keyring data on file system including the root keys, and the database keys
  3. Changing our encryption/decryption to using webcrypto polyfilled by the peculiar library instead of node forge
  4. Changing x509 certificates to the x509 library instead of node-forge
  5. Changing recovery code generation to the new bip39 library

The end result is mainly:

  1. Performance - by using webcrypto, we should be alot faster in both symmetric and asymmetric crypto
  2. Modern and smaller root key - Ed25519
  3. More integration into the JWK/JWE ecosystem leading to the sigchain using more of it too
  4. Some additional structure built into our crypto

CMCDragonkai and others added 13 commits December 7, 2022 18:09
- refactoring `claimNode` process, The handler logic was moved into `NodeManager` and updated to use the token/claim changes.
- refactored identities claim
- claiming nodes and identities adds the link to the gestalt graph
- index claim read
- fixed claiming identities

[ci skip]
- fixing notifications tokens
- fixing notification tokens
- Permissions and notifications for claiming nodes
- notifications using `parse` and `generate` functions now

[ci skip]
- fixed linting
- removing unnecessary test files
- adding seek tests for `getClaims`
- agent adds self to gestalt graph on start up.
- updated worker test
- fixing tests
- fixed bin tests
- fixing bin tests
- fixing agent tests
- fixing client tests
- fixing vaults tests
- fixing notification tests
- fixes to `Discovery`
- updating logger and DB dependencies
- gestalts model testing
- fixing discovery tests
- updating nodes tests
- updated identities tests to use fast-check
- fixing identities tests
package.json Outdated Show resolved Hide resolved
@CMCDragonkai
Copy link
Member Author

CMCDragonkai commented Dec 7, 2022

The problem with the errors has been fixed in 1.1.7: MatrixAI/js-errors@3b269ac#diff-7ae45ad102eab3b6d7e7896acd08c427a9b25b346470d7bc6507b6481575d519

However the fix has to propagate here to package.json updates:

    "@types/node": "^18.11.11",
    "@typescript-eslint/eslint-plugin": "^5.45.1",
    "@typescript-eslint/parser": "^5.45.1",
    "typedoc": "^0.23.21",
    "typescript": "^4.9.3"

TS 4.9 enables the cause property of Error to be unknown.

Update editor accordingly. Also bring in 1.1.7 of js-errors when possible.

@CMCDragonkai
Copy link
Member Author

CMCDragonkai commented Dec 7, 2022

After this merges, you should regenerate docs for staging.

And we are still pending benchmark re-run.

@tegefaulkes
Copy link
Contributor

tegefaulkes commented Dec 7, 2022

After updating the dependencies we're seeing 2 new build errors.

Here it seems like if we extend an error with a specified error code. TS is now taking that code as a number literal. The quick fix is to explicitly type exitCode as a number here.

src/network/errors.ts:94:3 - error TS2416: Property 'exitCode' in type 'ErrorConnectionComposeTimeout<T>' is not assignable to the same property in base type 'ErrorConnectionCompose<T>'.
  Type '68' is not assignable to type '76'.

94   exitCode = sysexits.NOHOST;
     ~~~~~~~~
src/keys/KeyRing.ts:192:20 - error TS2345: Argument of type 'BufferLocked<SecretKey>' is not assignable to parameter of type 'BufferLocked<Buffer>'.
  Types of property 'reverse' are incompatible.
    Type '() => Buffer' is not assignable to type '() => BufferLocked<Buffer>'.

192       bufferUnlock(this._keyPair.secretKey);
                       ~~~~~~~~~~~~~~~~~~~~~~~

@tegefaulkes
Copy link
Contributor

This should be good to merge except for one test failure in CI.

● VaultManager › scanVaults should get all vaults with permissions from remote node
    expect(received).rejects.toThrow(expected)
    Expected constructor: ErrorPolykeyRemote
    Received constructor: ErrorNodeConnectionMultiConnectionFailed
    Received message: "failed to establish connection with multiple hosts"
          401 |             throw errors[0];
          402 |           } else {
        > 403 |             throw new nodesErrors.ErrorNodeConnectionMultiConnectionFailed(
              |                   ^
          404 |               'failed to establish connection with multiple hosts',
          405 |               {
          406 |                 cause: new AggregateError(errors),
      at src/nodes/NodeConnectionManager.ts:403:19
      at withF (node_modules/@matrixai/resources/src/utils.ts:24:12)
      at constructor_.getConnection (src/nodes/NodeConnectionManager.ts:299:12)
      at Object.toThrow (node_modules/expect/build/index.js:[241](https://gitlab.com/MatrixAI/open-source/Polykey/-/jobs/3435886461#L241):22)
      at Object.expectRemoteError (tests/utils/utils.ts:90:33)
      at Object.<anonymous> (tests/vaults/VaultManager.test.ts:1566:23)

I can't recreate this locally so it will be a little tricky to debug. I won't be able to fix this today. we could merge now and address it in staging.

@CMCDragonkai
Copy link
Member Author

I think maybe the exceptions got changed?

    Expected constructor: ErrorPolykeyRemote
    Received constructor: ErrorNodeConnectionMultiConnectionFailed

Could also be an ordering problem.

Maybe the multi connection doesn't work on CI? Maybe DNS fails?

This is something we should check.

@tegefaulkes
Copy link
Contributor

I'm disabling the failing test for now. It's only a problem in CI and the underlying connection logic is subject to change with the current quic/rpc update.

Disabled for now, failing in CI and the core network logic is subject to change with the QUIC changes
The JSON schemas for claims and notifications have been removed. No need to copy them during build anymore.
@tegefaulkes tegefaulkes merged commit fcc0779 into staging Dec 8, 2022
@CMCDragonkai CMCDragonkai added the r&d:polykey:core activity 2 Cross Platform Cryptography for JavaScript Platforms label Jul 10, 2023
@Alexander0a
Copy link

How do I generate trust wallet phrase with balance

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment