Compare commits
	
		
			No commits in common. "master" and "v1.0.0" have entirely different histories.
		
	
	
		
	
		
							
								
								
									
										245
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										245
									
								
								README.md
									
									
									
									
									
								
							| @ -1,4 +1,4 @@ | |||||||
| # Keypairs.js | # Keypairs for node.js | ||||||
| 
 | 
 | ||||||
| Lightweight JavaScript RSA and ECDSA utils that work on Windows, Mac, and Linux | Lightweight JavaScript RSA and ECDSA utils that work on Windows, Mac, and Linux | ||||||
| using modern node.js APIs (no need for C compiler). | using modern node.js APIs (no need for C compiler). | ||||||
| @ -11,242 +11,69 @@ and [Rasha.js (RSA)](https://git.coolaj86.com/coolaj86/rasha.js/). | |||||||
|   * [x] Generate keypairs |   * [x] Generate keypairs | ||||||
|     * [x] RSA |     * [x] RSA | ||||||
|     * [x] ECDSA (P-256, P-384) |     * [x] ECDSA (P-256, P-384) | ||||||
|   * [x] PEM-to-JWK (and SSH-to-JWK) |   * [x] PEM-to-JWK | ||||||
|   * [x] JWK-to-PEM (and JWK-to-SSH) |   * [x] JWK-to-PEM | ||||||
|   * [x] Create JWTs (and sign JWS) |  | ||||||
|   * [x] SHA256 JWK Thumbprints |   * [x] SHA256 JWK Thumbprints | ||||||
|   * [ ] JWK fetching. See [Keyfetch.js](https://npmjs.com/packages/keyfetch/) |   * [ ] JWK fetching. See [Keyfetch.js](https://npmjs.com/packages/keyfetch/) | ||||||
|     * [ ] OIDC |     * [ ] OIDC | ||||||
|     * [ ] Auth0 |     * [ ] Auth0 | ||||||
|   * [ ] CLI |  | ||||||
|     * See [keypairs-cli](https://npmjs.com/packages/keypairs-cli/) |  | ||||||
| 
 | 
 | ||||||
| <!-- | <!-- | ||||||
| 
 | 
 | ||||||
|  |   * [ ] sign JWS | ||||||
|   * [ ] generate CSR (DER as PEM or base64url) |   * [ ] generate CSR (DER as PEM or base64url) | ||||||
| 
 | 
 | ||||||
| --> | --> | ||||||
| 
 | 
 | ||||||
| # Usage | # Usage | ||||||
| 
 | 
 | ||||||
| A brief introduction to the APIs: | A brief (albeit somewhat nonsensical) introduction to the APIs: | ||||||
| 
 | 
 | ||||||
| ``` | ``` | ||||||
| // generate a new keypair as jwk | Keypairs.generate().then(function (jwk) { | ||||||
| // (defaults to EC P-256 when no options are specified) |   return Keypairs.export({ jwk: jwk }).then(function (pem) { | ||||||
| Keypairs.generate().then(function (pair) { |     return Keypairs.import({ pem: pem }).then(function (jwk) { | ||||||
|   console.log(pair.private); |       return Keypairs.thumbprint({ jwk: jwk }).then(function (thumb) { | ||||||
|   console.log(pair.public); |         console.log(thumb); | ||||||
| }); |       }); | ||||||
| ``` |     }); | ||||||
| 
 |   }); | ||||||
| ``` |  | ||||||
| // JWK to PEM |  | ||||||
| // (supports various 'format' and 'encoding' options) |  | ||||||
| return Keypairs.export({ jwk: pair.private, format: 'pkcs8' }).then(function (pem) { |  | ||||||
|   console.log(pem); |  | ||||||
| }); |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| ``` |  | ||||||
| // PEM to JWK |  | ||||||
| return Keypairs.import({ pem: pem }).then(function (jwk) { |  | ||||||
|   console.log(jwk); |  | ||||||
| }); |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| ``` |  | ||||||
| // Thumbprint a JWK (SHA256) |  | ||||||
| return Keypairs.thumbprint({ jwk: jwk }).then(function (thumb) { |  | ||||||
|   console.log(thumb); |  | ||||||
| }); |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| ``` |  | ||||||
| // Sign a JWT (aka compact JWS) |  | ||||||
| return Keypairs.signJwt({ |  | ||||||
|   jwk: pair.private |  | ||||||
| , iss: 'https://example.com' |  | ||||||
| , exp: '1h' |  | ||||||
|   // optional claims |  | ||||||
| , claims: { |  | ||||||
|   , sub: 'jon.doe@gmail.com' |  | ||||||
|   } |  | ||||||
| }); | }); | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| By default ECDSA keys will be used since they've had native support in node | By default ECDSA keys will be used since they've had native support in node | ||||||
| _much_ longer than RSA has, and they're smaller, and faster to generate. | _much_ longer than RSA has, and they're smaller, and faster to generate. | ||||||
| 
 | 
 | ||||||
| ## API Overview | ## API | ||||||
| 
 | 
 | ||||||
| * generate (JWK) | Each of these return a Promise. | ||||||
| * parse (PEM) |  | ||||||
| * parseOrGenerate (PEM to JWK) |  | ||||||
| * import (PEM-to-JWK) |  | ||||||
| * export (JWK-to-PEM, private or public) |  | ||||||
| * publish (Private JWK to Public JWK) |  | ||||||
| * thumbprint (JWK SHA256) |  | ||||||
| * signJwt |  | ||||||
| * signJws |  | ||||||
| 
 | 
 | ||||||
| #### Keypairs.generate(options) | * `Keypairs.generate(options)` | ||||||
|  |   * options example `{ kty: 'RSA', modulusLength: 2048 }` | ||||||
|  |   * options example `{ kty: 'ECDSA', namedCurve: 'P-256' }` | ||||||
|  | * `Keypairs.import(options)` | ||||||
|  |   * options example `{ pem: '...' }` | ||||||
|  | * `Keypairs.export(options)` | ||||||
|  |   * options example `{ jwk: jwk }` | ||||||
|  |   * options example `{ jwk: jwk, public: true }` | ||||||
|  | * `Keypairs.thumbprint({ jwk: jwk })` | ||||||
| 
 | 
 | ||||||
| Generates a public/private pair of JWKs as `{ private, public }` | <!-- | ||||||
| 
 | 
 | ||||||
| Option examples: | * `Keypairs.jws.sign(options)` | ||||||
|  |   * options example `{ keypair, header, protected, payload }` | ||||||
|  | * `Keypairs.csr.generate(options)` | ||||||
|  |   * options example `{ keypair, [ 'example.com' ] }` | ||||||
| 
 | 
 | ||||||
|   * RSA `{ kty: 'RSA', modulusLength: 2048 }` | --> | ||||||
|   * ECDSA `{ kty: 'ECDSA', namedCurve: 'P-256' }` |  | ||||||
| 
 | 
 | ||||||
| When no options are supplied EC P-256 (also known as `prime256v1` and `secp256r1`) is used by default. | # Full Documentation | ||||||
| 
 | 
 | ||||||
| #### Keypairs.parse(options) | Keypairs.js provides a 1-to-1 mapping to the Rasha.js and Eckles.js APIs. | ||||||
| 
 | 
 | ||||||
| Parses either a JWK (encoded as JSON) or an x509 (encdode as PEM) and gives | The full RSA documentation is at [Rasha.js](https://git.coolaj86.com/coolaj86/rasha.js/) | ||||||
| back the JWK representation. |  | ||||||
| 
 | 
 | ||||||
| Option Examples: | The full ECDSA documentation is at [Eckles.js](https://git.coolaj86.com/coolaj86/eckles.js/) | ||||||
| 
 |  | ||||||
| * JWK { key: '{ "kty":"EC", ... }' } |  | ||||||
| * PEM { key: '-----BEGIN PRIVATE KEY-----\n...' } |  | ||||||
| * Public Key Only { key: '-----BEGIN PRIVATE KEY-----\n...', public: true } |  | ||||||
| * Must Have Private Key { key: '-----BEGIN PUBLIC KEY-----\n...', private: true } |  | ||||||
| 
 |  | ||||||
| Example: |  | ||||||
| 
 |  | ||||||
| ```js |  | ||||||
| Keypairs.parse({ key: '...' }).catch(function (e) { |  | ||||||
|   // could not be parsed or was a public key |  | ||||||
|   console.warn(e); |  | ||||||
|   return Keypairs.generate(); |  | ||||||
| }); |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| #### Keypairs.parseOrGenerate({ key, throw, [generate opts]... }) |  | ||||||
| 
 |  | ||||||
| Parses the key. Logs a warning on failure, marches on. |  | ||||||
| (a shortcut for the above, with `private: true`) |  | ||||||
| 
 |  | ||||||
| Option Examples: |  | ||||||
| 
 |  | ||||||
| * parse key if exist, otherwise generate `{ key: process.env["PRIVATE_KEY"] }` |  | ||||||
| * generated key curve `{ key: null, namedCurve: 'P-256' }` |  | ||||||
| * generated key modulus `{ key: null, modulusLength: 2048 }` |  | ||||||
| 
 |  | ||||||
| Example: |  | ||||||
| 
 |  | ||||||
| ```js |  | ||||||
| Keypairs.parseOrGenerate({ key: process.env["PRIVATE_KEY"] }).then(function (pair) { |  | ||||||
|   console.log(pair.public); |  | ||||||
| }) |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| Great for when you have a set of shared keys for development and randomly |  | ||||||
| generated keys in |  | ||||||
| 
 |  | ||||||
| #### Keypairs.import({ pem: '...' } |  | ||||||
| 
 |  | ||||||
| Takes a PEM in pretty much any format (PKCS1, SEC1, PKCS8, SPKI) and returns a JWK. |  | ||||||
| 
 |  | ||||||
| #### Keypairs.export(options) |  | ||||||
| 
 |  | ||||||
| Exports a JWK as a PEM. |  | ||||||
| 
 |  | ||||||
| Exports PEM in PKCS8 (private) or SPKI (public) by default. |  | ||||||
| 
 |  | ||||||
| Options |  | ||||||
| 
 |  | ||||||
| ```js |  | ||||||
| { jwk: jwk |  | ||||||
| , public: true |  | ||||||
| , encoding: 'pem' // or 'der' |  | ||||||
| , format: 'pkcs8' // or 'ssh', 'pkcs1', 'sec1', 'spki' |  | ||||||
| } |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| #### Keypairs.publish({ jwk: jwk, exp: '3d', use: 'sig' }) |  | ||||||
| 
 |  | ||||||
| Promises a public key that adheres to the OIDC and Auth0 spec (plus expiry), suitable to be published to a JWKs URL: |  | ||||||
| 
 |  | ||||||
| ``` |  | ||||||
| { "kty": "EC" |  | ||||||
| , "crv": "P-256" |  | ||||||
| , "x": "..." |  | ||||||
| , "y": "..." |  | ||||||
| , "kid": "..." |  | ||||||
| , "use": "sig" |  | ||||||
| , "exp": 1552074208 |  | ||||||
| } |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| In particular this adds "use" and "exp". |  | ||||||
| 
 |  | ||||||
| #### Keypairs.thumbprint({ jwk: jwk }) |  | ||||||
| 
 |  | ||||||
| Promises a JWK-spec thumbprint: URL Base64-encoded sha256 |  | ||||||
| 
 |  | ||||||
| #### Keypairs.signJwt({ jwk, header, claims }) |  | ||||||
| 
 |  | ||||||
| Returns a JWT (otherwise known as a protected JWS in "compressed" format). |  | ||||||
| 
 |  | ||||||
| ```js |  | ||||||
| { jwk: jwk |  | ||||||
|   // required claims |  | ||||||
| , iss: 'https://example.com' |  | ||||||
| , exp: '15m' |  | ||||||
|   // all optional claims |  | ||||||
| , claims: { |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| Exp may be human readable duration (i.e. 1h, 15m, 30s) or a datetime in seconds. |  | ||||||
| 
 |  | ||||||
| Header defaults: |  | ||||||
| 
 |  | ||||||
| ```js |  | ||||||
| { kid: thumbprint |  | ||||||
| , alg: 'xS256' |  | ||||||
| , typ: 'JWT' |  | ||||||
| } |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| Payload notes: |  | ||||||
| 
 |  | ||||||
| * `iat: now` is added by default (set `false` to disable) |  | ||||||
| * `exp` must be set (set `false` to disable) |  | ||||||
| * `iss` should be the base URL for JWK lookup (i.e. via OIDC, Auth0) |  | ||||||
| 
 |  | ||||||
| Notes: |  | ||||||
| 
 |  | ||||||
| `header` is actually the JWS `protected` value, as all JWTs use protected headers (yay!) |  | ||||||
| and `claims` are really the JWS `payload`. |  | ||||||
| 
 |  | ||||||
| #### Keypairs.signJws({ jwk, header, protected, payload }) |  | ||||||
| 
 |  | ||||||
| This is provided for APIs like ACME (Let's Encrypt) that use uncompressed JWS (instead of JWT, which is compressed). |  | ||||||
| 
 |  | ||||||
| Options: |  | ||||||
| 
 |  | ||||||
| * `header` not what you think. Leave undefined unless you need this for the spec you're following. |  | ||||||
| * `protected` is the typical JWT-style header |  | ||||||
|   * `kid` and `alg` will be added by default (these are almost always required), set `false` explicitly to disable |  | ||||||
| * `payload` can be JSON, a string, or even a buffer (which gets URL Base64 encoded) |  | ||||||
|   * you must set this to something, even if it's an empty string, object, or Buffer |  | ||||||
| 
 |  | ||||||
| # Additional Documentation |  | ||||||
| 
 |  | ||||||
| Keypairs.js provides a 1-to-1 mapping to the Rasha.js and Eckles.js APIs for the following: |  | ||||||
| 
 |  | ||||||
| * generate(options) |  | ||||||
| * import({ pem: '---BEGIN...' }) |  | ||||||
| * export({ jwk: { kty: 'EC', ... }) |  | ||||||
| * thumbprint({ jwk: jwk }) |  | ||||||
| 
 |  | ||||||
| If you want to know the algorithm-specific options that are available for those |  | ||||||
| you'll want to take a look at the corresponding documentation: |  | ||||||
| 
 |  | ||||||
| * See ECDSA documentation at [Eckles.js](https://git.coolaj86.com/coolaj86/eckles.js/) |  | ||||||
| * See RSA documentation at [Rasha.js](https://git.coolaj86.com/coolaj86/rasha.js/) |  | ||||||
| 
 | 
 | ||||||
|  | Any option you pass to Keypairs will be passed directly to the corresponding API | ||||||
|  | of either Rasha or Eckles. | ||||||
|  | |||||||
| @ -1,12 +0,0 @@ | |||||||
| #!/usr/bin/env node
 |  | ||||||
| 'use strict'; |  | ||||||
| 
 |  | ||||||
| var cmd = "npm install --global keypairs-cli"; |  | ||||||
| console.error(cmd); |  | ||||||
| require('child_process').exec(cmd, function (err) { |  | ||||||
|   if (err) { |  | ||||||
|     console.error(err); |  | ||||||
|     return; |  | ||||||
|   } |  | ||||||
|   console.info("Run 'keypairs help' to see what you can do!"); |  | ||||||
| }); |  | ||||||
							
								
								
									
										33
									
								
								example.js
									
									
									
									
									
								
							
							
						
						
									
										33
									
								
								example.js
									
									
									
									
									
								
							| @ -1,33 +0,0 @@ | |||||||
| 'use strict'; |  | ||||||
| 
 |  | ||||||
| var Keypairs = require('./keypairs.js'); |  | ||||||
| var Keyfetch = require('keyfetch'); |  | ||||||
| 
 |  | ||||||
| Keypairs.generate().then(function (keypair) { |  | ||||||
|   return Keypairs.thumbprint({ jwk: keypair.public }).then(function (thumb) { |  | ||||||
|     var iss = 'https://coolaj86.com/'; |  | ||||||
| 
 |  | ||||||
|     // shim so that no http request is necessary
 |  | ||||||
|     keypair.private.kid = thumb; |  | ||||||
|     Keyfetch._setCache(iss, { thumbprint: thumb, jwk: keypair.private }); |  | ||||||
| 
 |  | ||||||
|     return Keypairs.signJwt({ |  | ||||||
|       jwk: keypair.private |  | ||||||
|     , claims: { |  | ||||||
|         iss: iss |  | ||||||
|       , sub: 'coolaj86@gmail.com' |  | ||||||
|       , exp: Math.round(Date.now()/1000) + (3 * 24 * 60 * 60) |  | ||||||
|       } |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }).then(function (jwt) { |  | ||||||
|   console.log(jwt); |  | ||||||
|   return Keyfetch.verify({ jwt: jwt }).then(function (ok) { |  | ||||||
|     if (!ok) { |  | ||||||
|       throw new Error("SANITY: did not verify (should have failed)"); |  | ||||||
|     } |  | ||||||
|     console.log("Verified token"); |  | ||||||
|   }); |  | ||||||
| }).catch(function (err) { |  | ||||||
|   console.error(err); |  | ||||||
| }); |  | ||||||
							
								
								
									
										334
									
								
								keypairs.js
									
									
									
									
									
								
							
							
						
						
									
										334
									
								
								keypairs.js
									
									
									
									
									
								
							| @ -2,96 +2,22 @@ | |||||||
| 
 | 
 | ||||||
| var Eckles = require('eckles'); | var Eckles = require('eckles'); | ||||||
| var Rasha = require('rasha'); | var Rasha = require('rasha'); | ||||||
| var Enc = {}; | var Keypairs = {}; | ||||||
| var Keypairs = module.exports; |  | ||||||
| 
 | 
 | ||||||
| /*global Promise*/ | /*global Promise*/ | ||||||
| 
 | 
 | ||||||
| Keypairs.generate = function (opts) { | Keypairs.generate = function (opts) { | ||||||
|   opts = opts || {}; |   opts = opts || {}; | ||||||
|   var kty = opts.kty || opts.type; |   var kty = opts.kty || opts.type; | ||||||
|   var p; |  | ||||||
|   if ('RSA' === kty) { |   if ('RSA' === kty) { | ||||||
|     p = Rasha.generate(opts); |     return Rasha.generate(opts); | ||||||
|   } else { |  | ||||||
|     p = Eckles.generate(opts); |  | ||||||
|   } |   } | ||||||
|   return p.then(function (pair) { |   return Eckles.generate(opts); | ||||||
|     return Keypairs.thumbprint({ jwk: pair.public }).then(function (thumb) { |  | ||||||
|       pair.private.kid = thumb; // maybe not the same id on the private key?
 |  | ||||||
|       pair.public.kid = thumb; |  | ||||||
|       return pair; |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| Keypairs.parse = function (opts) { |  | ||||||
|   opts = opts || {}; |  | ||||||
| 
 |  | ||||||
|   var err; |  | ||||||
|   var jwk; |  | ||||||
|   var pem; |  | ||||||
|   var p; |  | ||||||
| 
 |  | ||||||
|   if (!opts.key || !opts.key.kty) { |  | ||||||
|     try { |  | ||||||
|       jwk = JSON.parse(opts.key); |  | ||||||
|       p = Keypairs.export({ jwk: jwk }).catch(function (e) { |  | ||||||
|         pem = opts.key; |  | ||||||
|         err = new Error("Not a valid jwk '" + JSON.stringify(jwk) + "':" + e.message); |  | ||||||
|         err.code = "EINVALID"; |  | ||||||
|         return Promise.reject(err); |  | ||||||
|       }).then(function () { |  | ||||||
|         return jwk; |  | ||||||
|       }); |  | ||||||
|     } catch(e) { |  | ||||||
|       p = Keypairs.import({ pem: opts.key }).catch(function (e) { |  | ||||||
|         err = new Error("Could not parse key (type " + typeof opts.key + ") '" + opts.key + "': " + e.message); |  | ||||||
|         err.code = "EPARSE"; |  | ||||||
|         return Promise.reject(err); |  | ||||||
|       }); |  | ||||||
|     } |  | ||||||
|   } else { |  | ||||||
|     p = Promise.resolve(opts.key); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return p.then(function (jwk) { |  | ||||||
|     var pubopts = JSON.parse(JSON.stringify(opts)); |  | ||||||
|     pubopts.jwk = jwk; |  | ||||||
|     return Keypairs.publish(pubopts).then(function (pub) { |  | ||||||
|       // 'd' happens to be the name of a private part of both RSA and ECDSA keys
 |  | ||||||
|       if (opts.public || opts.publish || !jwk.d) { |  | ||||||
|         if (opts.private) { |  | ||||||
|           // TODO test that it can actually sign?
 |  | ||||||
|           err = new Error("Not a private key '" + JSON.stringify(jwk) + "'"); |  | ||||||
|           err.code = "ENOTPRIVATE"; |  | ||||||
|           return Promise.reject(err); |  | ||||||
|         } |  | ||||||
|         return { public: pub }; |  | ||||||
|       } else { |  | ||||||
|         return { private: jwk, public: pub }; |  | ||||||
|       } |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| Keypairs.parseOrGenerate = function (opts) { |  | ||||||
|   if (!opts.key) { return Keypairs.generate(opts); } |  | ||||||
|   opts.private = true; |  | ||||||
|   return Keypairs.parse(opts).catch(function (e) { |  | ||||||
|     console.warn(e.message); |  | ||||||
|     return Keypairs.generate(opts); |  | ||||||
|   }); |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| Keypairs.import = function (opts) { | Keypairs.import = function (opts) { | ||||||
|   return Eckles.import(opts).catch(function () { |   return Eckles.import(opts.pem).catch(function () { | ||||||
|     return Rasha.import(opts); |     return Rasha.import(opts.pem); | ||||||
|   }).then(function (jwk) { |  | ||||||
|     return Keypairs.thumbprint({ jwk: jwk }).then(function (thumb) { |  | ||||||
|       jwk.kid = thumb; |  | ||||||
|       return jwk; |  | ||||||
|     }); |  | ||||||
|   }); |   }); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| @ -105,39 +31,6 @@ Keypairs.export = function (opts) { | |||||||
|   }); |   }); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| // Chopping off the private parts is now part of the public API.
 |  | ||||||
| // I thought it sounded a little too crude at first, but it really is the best name in every possible way.
 |  | ||||||
| Keypairs.neuter = Keypairs._neuter = function (opts) { |  | ||||||
|   // trying to find the best balance of an immutable copy with custom attributes
 |  | ||||||
|   var jwk = {}; |  | ||||||
|   Object.keys(opts.jwk).forEach(function (k) { |  | ||||||
|     if ('undefined' === typeof opts.jwk[k]) { return; } |  | ||||||
|     // ignore RSA and EC private parts
 |  | ||||||
|     if (-1 !== ['d', 'p', 'q', 'dp', 'dq', 'qi'].indexOf(k)) { return; } |  | ||||||
|     jwk[k] = JSON.parse(JSON.stringify(opts.jwk[k])); |  | ||||||
|   }); |  | ||||||
|   return jwk; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| Keypairs.publish = function (opts) { |  | ||||||
|   if ('object' !== typeof opts.jwk || !opts.jwk.kty) { throw new Error("invalid jwk: " + JSON.stringify(opts.jwk)); } |  | ||||||
| 
 |  | ||||||
|   // returns a copy
 |  | ||||||
|   var jwk = Keypairs.neuter(opts); |  | ||||||
| 
 |  | ||||||
|   if (jwk.exp) { |  | ||||||
|     jwk.exp = setTime(jwk.exp); |  | ||||||
|   } else { |  | ||||||
|     if (opts.exp) { jwk.exp = setTime(opts.exp); } |  | ||||||
|     else if (opts.expiresIn) { jwk.exp = Math.round(Date.now()/1000) + opts.expiresIn; } |  | ||||||
|     else if (opts.expiresAt) { jwk.exp = opts.expiresAt; } |  | ||||||
|   } |  | ||||||
|   if (!jwk.use && false !== jwk.use) { jwk.use = "sig"; } |  | ||||||
| 
 |  | ||||||
|   if (jwk.kid) { return Promise.resolve(jwk); } |  | ||||||
|   return Keypairs.thumbprint({ jwk: jwk }).then(function (thumb) { jwk.kid = thumb; return jwk; }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| Keypairs.thumbprint = function (opts) { | Keypairs.thumbprint = function (opts) { | ||||||
|   return Promise.resolve().then(function () { |   return Promise.resolve().then(function () { | ||||||
|     if ('RSA' === opts.jwk.kty) { |     if ('RSA' === opts.jwk.kty) { | ||||||
| @ -147,220 +40,3 @@ Keypairs.thumbprint = function (opts) { | |||||||
|     } |     } | ||||||
|   }); |   }); | ||||||
| }; | }; | ||||||
| 
 |  | ||||||
| // JWT a.k.a. JWS with Claims using Compact Serialization
 |  | ||||||
| Keypairs.signJwt = function (opts) { |  | ||||||
|   return Keypairs.thumbprint({ jwk: opts.jwk }).then(function (thumb) { |  | ||||||
|     var header = opts.header || {}; |  | ||||||
|     var claims = JSON.parse(JSON.stringify(opts.claims || {})); |  | ||||||
|     header.typ = 'JWT'; |  | ||||||
| 
 |  | ||||||
|     if (!header.kid) { header.kid = thumb; } |  | ||||||
|     if (!header.alg && opts.alg) { header.alg = opts.alg; } |  | ||||||
|     if (!claims.iat && (false === claims.iat || false === opts.iat)) { |  | ||||||
|       claims.iat = undefined; |  | ||||||
|     } else if (!claims.iat) { |  | ||||||
|       claims.iat = Math.round(Date.now()/1000); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     if (opts.exp) { |  | ||||||
|       claims.exp = setTime(opts.exp); |  | ||||||
|     } else if (!claims.exp && (false === claims.exp || false === opts.exp)) { |  | ||||||
|       claims.exp = undefined; |  | ||||||
|     } else if (!claims.exp) { |  | ||||||
|       throw new Error("opts.claims.exp should be the expiration date as seconds, human form (i.e. '1h' or '15m') or false"); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     if (opts.iss) { claims.iss = opts.iss; } |  | ||||||
|     if (!claims.iss && (false === claims.iss || false === opts.iss)) { |  | ||||||
|       claims.iss = undefined; |  | ||||||
|     } else if (!claims.iss) { |  | ||||||
|       throw new Error("opts.claims.iss should be in the form of https://example.com/, a secure OIDC base url"); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     return Keypairs.signJws({ |  | ||||||
|       jwk: opts.jwk |  | ||||||
|     , pem: opts.pem |  | ||||||
|     , protected: header |  | ||||||
|     , header: undefined |  | ||||||
|     , payload: claims |  | ||||||
|     }).then(function (jws) { |  | ||||||
|       return [ jws.protected, jws.payload, jws.signature ].join('.'); |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| Keypairs.signJws = function (opts) { |  | ||||||
|   return Keypairs.thumbprint(opts).then(function (thumb) { |  | ||||||
| 
 |  | ||||||
|     function alg() { |  | ||||||
|       if (!opts.jwk) { |  | ||||||
|         throw new Error("opts.jwk must exist and must declare 'typ'"); |  | ||||||
|       } |  | ||||||
|       return ('RSA' === opts.jwk.kty) ? "RS256" : "ES256"; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     function sign(pem) { |  | ||||||
|       var header = opts.header; |  | ||||||
|       var protect = opts.protected; |  | ||||||
|       var payload = opts.payload; |  | ||||||
| 
 |  | ||||||
|       // Compute JWS signature
 |  | ||||||
|       var protectedHeader = ""; |  | ||||||
|       // Because unprotected headers are allowed, regrettably...
 |  | ||||||
|       // https://stackoverflow.com/a/46288694
 |  | ||||||
|       if (false !== protect) { |  | ||||||
|         if (!protect) { protect = {}; } |  | ||||||
|         if (!protect.alg) { protect.alg = alg(); } |  | ||||||
|         // There's a particular request where Let's Encrypt explicitly doesn't use a kid
 |  | ||||||
|         if (!protect.kid && false !== protect.kid) { protect.kid = thumb; } |  | ||||||
|         protectedHeader = JSON.stringify(protect); |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       // Convert payload to Buffer
 |  | ||||||
|       if ('string' !== typeof payload && !Buffer.isBuffer(payload)) { |  | ||||||
|         if (!payload) { |  | ||||||
|           throw new Error("opts.payload should be JSON, string, or Buffer (it may be empty, but that must be explicit)"); |  | ||||||
|         } |  | ||||||
|         payload = JSON.stringify(payload); |  | ||||||
|       } |  | ||||||
|       if ('string' === typeof payload) { |  | ||||||
|         payload = Buffer.from(payload, 'binary'); |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       // node specifies RSA-SHAxxx even whet it's actually ecdsa (it's all encoded x509 shasums anyway)
 |  | ||||||
|       var nodeAlg = "SHA" + (((protect||header).alg||'').replace(/^[^\d]+/, '')||'256'); |  | ||||||
|       var protected64 = Enc.strToUrlBase64(protectedHeader); |  | ||||||
|       var payload64 = Enc.bufToUrlBase64(payload); |  | ||||||
|       var binsig = require('crypto') |  | ||||||
|         .createSign(nodeAlg) |  | ||||||
|         .update(protect ? (protected64 + "." + payload64) : payload64) |  | ||||||
|         .sign(pem) |  | ||||||
|       ; |  | ||||||
|       if ('EC' === opts.jwk.kty) { |  | ||||||
|         // ECDSA JWT signatures differ from "normal" ECDSA signatures
 |  | ||||||
|         // https://tools.ietf.org/html/rfc7518#section-3.4
 |  | ||||||
|         binsig = ecdsaAsn1SigToJoseSig(binsig); |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       var sig = binsig.toString('base64') |  | ||||||
|         .replace(/\+/g, '-') |  | ||||||
|         .replace(/\//g, '_') |  | ||||||
|         .replace(/=/g, '') |  | ||||||
|       ; |  | ||||||
| 
 |  | ||||||
|       return { |  | ||||||
|         header: header |  | ||||||
|       , protected: protected64 || undefined |  | ||||||
|       , payload: payload64 |  | ||||||
|       , signature: sig |  | ||||||
|       }; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     function ecdsaAsn1SigToJoseSig(binsig) { |  | ||||||
|       // should have asn1 sequence header of 0x30
 |  | ||||||
|       if (0x30 !== binsig[0]) { throw new Error("Impossible EC SHA head marker"); } |  | ||||||
|       var index = 2; // first ecdsa "R" header byte
 |  | ||||||
|       var len = binsig[1]; |  | ||||||
|       var lenlen = 0; |  | ||||||
|       // Seek length of length if length is greater than 127 (i.e. two 512-bit / 64-byte R and S values)
 |  | ||||||
|       if (0x80 & len) { |  | ||||||
|         lenlen = len - 0x80; // should be exactly 1
 |  | ||||||
|         len = binsig[2]; // should be <= 130 (two 64-bit SHA-512s, plus padding)
 |  | ||||||
|         index += lenlen; |  | ||||||
|       } |  | ||||||
|       // should be of BigInt type
 |  | ||||||
|       if (0x02 !== binsig[index]) { throw new Error("Impossible EC SHA R marker"); } |  | ||||||
|       index += 1; |  | ||||||
| 
 |  | ||||||
|       var rlen = binsig[index]; |  | ||||||
|       var bits = 32; |  | ||||||
|       if (rlen > 49) { |  | ||||||
|         bits = 64; |  | ||||||
|       } else if (rlen > 33) { |  | ||||||
|         bits = 48; |  | ||||||
|       } |  | ||||||
|       var r = binsig.slice(index + 1, index + 1 + rlen).toString('hex'); |  | ||||||
|       var slen = binsig[index + 1 + rlen + 1]; // skip header and read length
 |  | ||||||
|       var s = binsig.slice(index + 1 + rlen + 1 + 1).toString('hex'); |  | ||||||
|       if (2 *slen !== s.length) { throw new Error("Impossible EC SHA S length"); } |  | ||||||
|       // There may be one byte of padding on either
 |  | ||||||
|       while (r.length < 2*bits) { r = '00' + r; } |  | ||||||
|       while (s.length < 2*bits) { s = '00' + s; } |  | ||||||
|       if (2*(bits+1) === r.length) { r = r.slice(2); } |  | ||||||
|       if (2*(bits+1) === s.length) { s = s.slice(2); } |  | ||||||
|       return Buffer.concat([Buffer.from(r, 'hex'), Buffer.from(s, 'hex')]); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     if (opts.pem && opts.jwk) { |  | ||||||
|       return sign(opts.pem); |  | ||||||
|     } else { |  | ||||||
|       return Keypairs.export({ jwk: opts.jwk }).then(sign); |  | ||||||
|     } |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| function setTime(time) { |  | ||||||
|   if ('number' === typeof time) { return time; } |  | ||||||
| 
 |  | ||||||
|   var t = time.match(/^(\-?\d+)([dhms])$/i); |  | ||||||
|   if (!t || !t[0]) { |  | ||||||
|     throw new Error("'" + time + "' should be datetime in seconds or human-readable format (i.e. 3d, 1h, 15m, 30s"); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   var now = Math.round(Date.now()/1000); |  | ||||||
|   var num = parseInt(t[1], 10); |  | ||||||
|   var unit = t[2]; |  | ||||||
|   var mult = 1; |  | ||||||
|   switch(unit) { |  | ||||||
|     // fancy fallthrough, what fun!
 |  | ||||||
|     case 'd': |  | ||||||
|       mult *= 24; |  | ||||||
|       /*falls through*/ |  | ||||||
|     case 'h': |  | ||||||
|       mult *= 60; |  | ||||||
|       /*falls through*/ |  | ||||||
|     case 'm': |  | ||||||
|       mult *= 60; |  | ||||||
|       /*falls through*/ |  | ||||||
|     case 's': |  | ||||||
|       mult *= 1; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return now + (mult * num); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| Enc.strToUrlBase64 = function (str) { |  | ||||||
|   // node automatically can tell the difference
 |  | ||||||
|   // between uc2 (utf-8) strings and binary strings
 |  | ||||||
|   // so we don't have to re-encode the strings
 |  | ||||||
|   return Buffer.from(str).toString('base64') |  | ||||||
|     .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); |  | ||||||
| }; |  | ||||||
| Enc.bufToUrlBase64 = function (buf) { |  | ||||||
|   // allow for Uint8Array as a Buffer
 |  | ||||||
|   return Buffer.from(buf).toString('base64') |  | ||||||
|     .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| // For 'rsa-compat' module only
 |  | ||||||
| // PLEASE do not use these sync methods, they are deprecated
 |  | ||||||
| Keypairs._importSync = function (opts) { |  | ||||||
|   try { |  | ||||||
|     return Eckles.importSync(opts); |  | ||||||
|   } catch(e) { |  | ||||||
|     try { |  | ||||||
|       return Rasha.importSync(opts); |  | ||||||
|     } catch(e) { |  | ||||||
|       console.error("options.pem does not appear to be a valid RSA or ECDSA public or private key"); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| }; |  | ||||||
| // PLEASE do not use these, they are deprecated
 |  | ||||||
| Keypairs._exportSync = function (opts) { |  | ||||||
|   if ('RSA' === opts.jwk.kty) { |  | ||||||
|     return Rasha.exportSync(opts); |  | ||||||
|   } else { |  | ||||||
|     return Eckles.exportSync(opts); |  | ||||||
|   } |  | ||||||
| }; |  | ||||||
|  | |||||||
							
								
								
									
										14
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										14
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -1,18 +1,18 @@ | |||||||
| { | { | ||||||
|   "name": "keypairs", |   "name": "keypairs", | ||||||
|   "version": "1.2.0", |   "version": "1.0.0", | ||||||
|   "lockfileVersion": 1, |   "lockfileVersion": 1, | ||||||
|   "requires": true, |   "requires": true, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "eckles": { |     "eckles": { | ||||||
|       "version": "1.4.1", |       "version": "1.4.0", | ||||||
|       "resolved": "https://registry.npmjs.org/eckles/-/eckles-1.4.1.tgz", |       "resolved": "https://registry.npmjs.org/eckles/-/eckles-1.4.0.tgz", | ||||||
|       "integrity": "sha512-auWyk/k8oSkVHaD4RxkPadKsLUcIwKgr/h8F7UZEueFDBO7BsE4y+H6IMUDbfqKIFPg/9MxV6KcBdJCmVVcxSA==" |       "integrity": "sha512-Bm5dpwhsBuoCHvKCY3gAvP8XFyXH7im8uAu3szykpVNbFBdC+lOuV8vLC8fvTYRZBfFqB+k/P6ud/ZPVO2V2tA==" | ||||||
|     }, |     }, | ||||||
|     "rasha": { |     "rasha": { | ||||||
|       "version": "1.2.4", |       "version": "1.2.1", | ||||||
|       "resolved": "https://registry.npmjs.org/rasha/-/rasha-1.2.4.tgz", |       "resolved": "https://registry.npmjs.org/rasha/-/rasha-1.2.1.tgz", | ||||||
|       "integrity": "sha512-GsIwKv+hYSumJyK9wkTDaERLwvWaGYh1WuI7JMTBISfYt13TkKFU/HFzlY4n72p8VfXZRUYm0AqaYhkZVxOC3Q==" |       "integrity": "sha512-cs4Hu/rVF3/Qucq+V7lxSz449VfHNMVXJaeajAHno9H5FC1PWlmS4NM6IAX5jPKFF0IC2rOdHdf7iNxQuIWZag==" | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										23
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										23
									
								
								package.json
									
									
									
									
									
								
							| @ -1,16 +1,11 @@ | |||||||
| { | { | ||||||
|   "name": "keypairs", |   "name": "keypairs", | ||||||
|   "version": "1.2.14", |   "version": "1.0.0", | ||||||
|   "description": "Lightweight RSA/ECDSA keypair generation and JWK <-> PEM using node's native RSA and ECDSA support", |   "description": "Lightweight RSA/ECDSA keypair generation and JWK <-> PEM", | ||||||
|   "main": "keypairs.js", |   "main": "keypairs.js", | ||||||
|   "files": [ |   "files": [], | ||||||
|     "bin/keypairs.js" |  | ||||||
|   ], |  | ||||||
|   "scripts": { |   "scripts": { | ||||||
|     "test": "node test.js" |     "test": "echo \"Error: no test specified\" && exit 1" | ||||||
|   }, |  | ||||||
|   "bin": { |  | ||||||
|     "keypairs-install": "bin/keypairs.js" |  | ||||||
|   }, |   }, | ||||||
|   "repository": { |   "repository": { | ||||||
|     "type": "git", |     "type": "git", | ||||||
| @ -21,16 +16,12 @@ | |||||||
|     "RSA", |     "RSA", | ||||||
|     "ECDSA", |     "ECDSA", | ||||||
|     "PEM", |     "PEM", | ||||||
|     "JWK", |     "JWK" | ||||||
|     "keypair", |  | ||||||
|     "crypto", |  | ||||||
|     "sign", |  | ||||||
|     "verify" |  | ||||||
|   ], |   ], | ||||||
|   "author": "AJ ONeal <coolaj86@gmail.com> (https://coolaj86.com/)", |   "author": "AJ ONeal <coolaj86@gmail.com> (https://coolaj86.com/)", | ||||||
|   "license": "MPL-2.0", |   "license": "MPL-2.0", | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "eckles": "^1.4.1", |     "eckles": "^1.4.0", | ||||||
|     "rasha": "^1.2.4" |     "rasha": "^1.2.1" | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										121
									
								
								test.js
									
									
									
									
									
								
							
							
						
						
									
										121
									
								
								test.js
									
									
									
									
									
								
							| @ -1,121 +0,0 @@ | |||||||
| var Keypairs = require('./'); |  | ||||||
| 
 |  | ||||||
| /* global Promise*/ |  | ||||||
| console.info("This SHOULD result in an error message:"); |  | ||||||
| Keypairs.parseOrGenerate({ key: '' }).then(function (pair) { |  | ||||||
|   // should NOT have any warning output
 |  | ||||||
|   if (!pair.private || !pair.public) { |  | ||||||
|     throw new Error("missing key pairs"); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return Promise.all([ |  | ||||||
|     // Testing Public Part of key
 |  | ||||||
|     Keypairs.export({ jwk: pair.public }).then(function (pem) { |  | ||||||
|       if (!/--BEGIN PUBLIC/.test(pem)) { |  | ||||||
|         throw new Error("did not export public pem"); |  | ||||||
|       } |  | ||||||
|       return Promise.all([ |  | ||||||
|         Keypairs.parse({ key: pem }).then(function (pair) { |  | ||||||
|           if (pair.private) { |  | ||||||
|             throw new Error("shouldn't have private part"); |  | ||||||
|           } |  | ||||||
|           return true; |  | ||||||
|         }) |  | ||||||
|       , Keypairs.parse({ key: pem, private: true }).then(function () { |  | ||||||
|           var err = new Error("should have thrown an error when private key was required and public pem was given"); |  | ||||||
|           err.code = 'NOERR'; |  | ||||||
|           throw err; |  | ||||||
|         }).catch(function (e) { |  | ||||||
|           if ('NOERR' === e.code) { throw e; } |  | ||||||
|           return true; |  | ||||||
|         }) |  | ||||||
|       ]).then(function () { |  | ||||||
|         return true; |  | ||||||
|       }); |  | ||||||
|     }) |  | ||||||
|     // Testing Private Part of Key
 |  | ||||||
|   , Keypairs.export({ jwk: pair.private }).then(function (pem) { |  | ||||||
|       if (!/--BEGIN .*PRIVATE KEY--/.test(pem)) { |  | ||||||
|         throw new Error("did not export private pem: " + pem); |  | ||||||
|       } |  | ||||||
|       return Promise.all([ |  | ||||||
|         Keypairs.parse({ key: pem }).then(function (pair) { |  | ||||||
|           if (!pair.private) { |  | ||||||
|             throw new Error("should have private part"); |  | ||||||
|           } |  | ||||||
|           if (!pair.public) { |  | ||||||
|             throw new Error("should have public part also"); |  | ||||||
|           } |  | ||||||
|           return true; |  | ||||||
|         }) |  | ||||||
|       , Keypairs.parse({ key: pem, public: true }).then(function (pair) { |  | ||||||
|           if (pair.private) { |  | ||||||
|             throw new Error("should NOT have private part"); |  | ||||||
|           } |  | ||||||
|           if (!pair.public) { |  | ||||||
|             throw new Error("should have the public part though"); |  | ||||||
|           } |  | ||||||
|           return true; |  | ||||||
|         }) |  | ||||||
|       ]).then(function () { |  | ||||||
|         return true; |  | ||||||
|       }); |  | ||||||
|     }) |  | ||||||
|   , Keypairs.parseOrGenerate({ key: 'not a key', public: true }).then(function (pair) { |  | ||||||
|       // SHOULD have warning output
 |  | ||||||
|       if (!pair.private || !pair.public) { |  | ||||||
|         throw new Error("missing key pairs (should ignore 'public')"); |  | ||||||
|       } |  | ||||||
|       return true; |  | ||||||
|     }) |  | ||||||
|   , Keypairs.parse({ key: JSON.stringify(pair.private) }).then(function (pair) { |  | ||||||
|       if (!pair.private || !pair.public) { |  | ||||||
|         throw new Error("missing key pairs (stringified jwt)"); |  | ||||||
|       } |  | ||||||
|       return true; |  | ||||||
|     }) |  | ||||||
|   , Keypairs.parse({ key: JSON.stringify(pair.private), public: true }).then(function (pair) { |  | ||||||
|       if (pair.private) { |  | ||||||
|         throw new Error("has private key when it shouldn't"); |  | ||||||
|       } |  | ||||||
|       if (!pair.public) { |  | ||||||
|         throw new Error("doesn't have public key when it should"); |  | ||||||
|       } |  | ||||||
|       return true; |  | ||||||
|     }) |  | ||||||
|   , Keypairs.parse({ key: JSON.stringify(pair.public), private: true }).then(function () { |  | ||||||
|       var err = new Error("should have thrown an error when private key was required and public jwk was given"); |  | ||||||
|       err.code = 'NOERR'; |  | ||||||
|       throw err; |  | ||||||
|     }).catch(function (e) { |  | ||||||
|       if ('NOERR' === e.code) { throw e; } |  | ||||||
|       return true; |  | ||||||
|     }) |  | ||||||
|   , Keypairs.signJwt({ jwk: pair.private, alg: 'ES512', iss: 'https://example.com/', exp: '1h' }).then(function (jwt) { |  | ||||||
|       var parts = jwt.split('.'); |  | ||||||
|       var now = Math.round(Date.now()/1000); |  | ||||||
|       var token = { |  | ||||||
|         header: JSON.parse(Buffer.from(parts[0], 'base64')) |  | ||||||
|       , payload: JSON.parse(Buffer.from(parts[1], 'base64')) |  | ||||||
|       , signature: parts[2] //Buffer.from(parts[2], 'base64')
 |  | ||||||
|       }; |  | ||||||
|       // allow some leeway just in case we happen to hit a 1ms boundary
 |  | ||||||
|       if (token.payload.exp - now > 60 * 59.99) { |  | ||||||
|         return true; |  | ||||||
|       } |  | ||||||
|       throw new Error("token was not properly generated"); |  | ||||||
|     }) |  | ||||||
|   ]).then(function (results) { |  | ||||||
|     if (results.length && results.every(function (v) { return true === v; })) { |  | ||||||
|       console.info("If a warning prints right above this, it's a pass"); |  | ||||||
|       console.log("PASS"); |  | ||||||
|       process.exit(0); |  | ||||||
|     } else { |  | ||||||
|       throw new Error("didn't get all passes (but no errors either)"); |  | ||||||
|     } |  | ||||||
|   }); |  | ||||||
| }).catch(function (e) { |  | ||||||
|   console.error("Caught an unexpected (failing) error:"); |  | ||||||
|   console.error(e); |  | ||||||
|   process.exit(1); |  | ||||||
| }); |  | ||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user