Skip to content

Commit

Permalink
feat: add support for JWK x5c, x5t and x5t#S256
Browse files Browse the repository at this point in the history
  • Loading branch information
panva committed May 25, 2019
1 parent 2eae293 commit 9d46c48
Show file tree
Hide file tree
Showing 10 changed files with 262 additions and 51 deletions.
6 changes: 1 addition & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,12 @@ Won't implement:
- ✕ JWS embedded key / referenced verification
- one can decode the header and pass the (`x5c`, `jwk`) to `JWK.importKey` and validate with that
key, similarly the application can handle fetching and then instantiating the referenced `x5u`
or `jku` in its own code. This way you opt-in to these behaviours and for `x5c` specifically
the recipient is responsible for validating the certificate chain is trusted
or `jku` in its own code. This way you opt-in to these behaviours.
- ✕ JWS detached content
- one can remove/attach the payload after/before the respective operation
- ✕ "none" alg support
- no crypto, no use

Not Planned / PR | Use-Case | Discussion Welcome:
-`x5c`, `x5t`, `x5t#S256`, `x5u` etc `JWK.Key` fields

</details>

<br>
Expand Down
31 changes: 31 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ I can continue maintaining it and adding new features carefree. You may also don
- [key.alg](#keyalg)
- [key.use](#keyuse)
- [key.kid](#keykid)
- [key.x5c](#keyx5c)
- [key.x5t](#keyx5t)
- [key['x5t#S256']](#keyx5ts256)
- [key.key_ops](#keykey_ops)
- [key.thumbprint](#keythumbprint)
- [key.type](#keytype)
Expand Down Expand Up @@ -121,6 +124,34 @@ defined in [RFC7638][spec-thumbprint].

---

#### `key.x5c`

Returns the key's X.509 Certificate Chain Parameter if set

- `string[]`

---

#### `key.x5t`

Returns the key's X.509 Certificate SHA-1 Thumbprint Parameter if set. This
property can be either be set manually by the JWK producer or left to @panva/jose to compute based
on the first certificate in the key's `x5c`.

- `<string>`

---

#### `key['x5t#S256']`

Returns the key's X.509 Certificate SHA-256 Thumbprint Parameter if set. This
property can be either be set manually by the JWK producer or left to @panva/jose to compute based
on the first certificate in the key's `x5c`.

- `<string>`

---

#### `key.key_ops`

Returns the key's JWK Key Operations Parameter if set. If set the key can only be used for the
Expand Down
20 changes: 1 addition & 19 deletions lib/help/node_alg.js
Original file line number Diff line number Diff line change
@@ -1,19 +1 @@
module.exports = (alg) => {
switch (alg) {
case 'RS256':
case 'PS256':
case 'HS256':
case 'ES256':
return 'sha256'
case 'RS384':
case 'PS384':
case 'HS384':
case 'ES384':
return 'sha384'
case 'RS512':
case 'PS512':
case 'HS512':
case 'ES512':
return 'sha512'
}
}
module.exports = alg => `sha${alg.substr(-3)}`
50 changes: 30 additions & 20 deletions lib/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,24 @@ import { KeyObject, PrivateKeyInput, PublicKeyInput } from 'crypto'

type use = 'sig' | 'enc'
type keyOperation = 'sign' | 'verify' | 'encrypt' | 'decrypt' | 'wrapKey' | 'unwrapKey' | 'deriveKey'
interface KeyParameters {
interface BasicParameters {
alg?: string
use?: use
kid?: string
key_ops?: keyOperation[]
}
interface KeyParameters extends BasicParameters {
x5c?: string[]
x5t?: string
'x5t#S256'?: string
}
type ECCurve = 'P-256' | 'P-384' | 'P-521'
type OKPCurve = 'Ed25519' | 'Ed448' | 'X25519' | 'X448'
type keyType = 'RSA' | 'EC' | 'OKP' | 'oct'
type asymmetricKeyObjectTypes = 'private' | 'public'
type keyObjectTypes = asymmetricKeyObjectTypes | 'secret'

interface JWKOctKey extends KeyParameters {
interface JWKOctKey extends BasicParameters { // no x5c
kty: 'oct',
k?: string
}
Expand Down Expand Up @@ -73,6 +78,9 @@ export namespace JWK {
key_ops?: keyOperation[]
kid: string
thumbprint: string
x5c?: string[]
x5t?: string
'x5t#S256'?: string

toPEM(private?: boolean, encoding?: pemEncodingOptions): string

Expand Down Expand Up @@ -138,20 +146,22 @@ export namespace JWK {
export function importKey(jwk: JWKECKey): ECKey
export function importKey(jwk: JWKOKPKey): OKPKey

export function generate(kty: 'EC', crv?: ECCurve, parameters?: KeyParameters, private?: boolean): Promise<ECKey>
export function generate(kty: 'OKP', crv?: OKPCurve, parameters?: KeyParameters, private?: boolean): Promise<OKPKey>
export function generate(kty: 'RSA', bitlength?: number, parameters?: KeyParameters, private?: boolean): Promise<RSAKey>
export function generate(kty: 'oct', bitlength?: number, parameters?: KeyParameters): Promise<OctKey>
export function generate(kty: 'EC', crv?: ECCurve, parameters?: BasicParameters, private?: boolean): Promise<ECKey>
export function generate(kty: 'OKP', crv?: OKPCurve, parameters?: BasicParameters, private?: boolean): Promise<OKPKey>
export function generate(kty: 'RSA', bitlength?: number, parameters?: BasicParameters, private?: boolean): Promise<RSAKey>
export function generate(kty: 'oct', bitlength?: number, parameters?: BasicParameters): Promise<OctKey>

export function generateSync(kty: 'EC', crv?: ECCurve, parameters?: KeyParameters, private?: boolean): ECKey
export function generateSync(kty: 'OKP', crv?: OKPCurve, parameters?: KeyParameters, private?: boolean): OKPKey
export function generateSync(kty: 'RSA', bitlength?: number, parameters?: KeyParameters, private?: boolean): RSAKey
export function generateSync(kty: 'oct', bitlength?: number, parameters?: KeyParameters): OctKey
export function generateSync(kty: 'EC', crv?: ECCurve, parameters?: BasicParameters, private?: boolean): ECKey
export function generateSync(kty: 'OKP', crv?: OKPCurve, parameters?: BasicParameters, private?: boolean): OKPKey
export function generateSync(kty: 'RSA', bitlength?: number, parameters?: BasicParameters, private?: boolean): RSAKey
export function generateSync(kty: 'oct', bitlength?: number, parameters?: BasicParameters): OctKey
}

export namespace JWKS {
interface KeyQuery extends KeyParameters {
kty: keyType
interface KeyQuery extends BasicParameters {
kty?: keyType
x5t?: string
'x5t#S256'?: string
}

class KeyStore {
Expand All @@ -166,15 +176,15 @@ export namespace JWKS {

toJWKS(private?: boolean): JSONWebKeySet

generate(kty: 'EC', crv?: ECCurve, parameters?: KeyParameters, private?: boolean): void
generate(kty: 'OKP', crv?: OKPCurve, parameters?: KeyParameters, private?: boolean): void
generate(kty: 'RSA', bitlength?: number, parameters?: KeyParameters, private?: boolean): void
generate(kty: 'oct', bitlength?: number, parameters?: KeyParameters): void
generate(kty: 'EC', crv?: ECCurve, parameters?: BasicParameters, private?: boolean): void
generate(kty: 'OKP', crv?: OKPCurve, parameters?: BasicParameters, private?: boolean): void
generate(kty: 'RSA', bitlength?: number, parameters?: BasicParameters, private?: boolean): void
generate(kty: 'oct', bitlength?: number, parameters?: BasicParameters): void

generateSync(kty: 'EC', crv?: ECCurve, parameters?: KeyParameters, private?: boolean): void
generateSync(kty: 'OKP', crv?: OKPCurve, parameters?: KeyParameters, private?: boolean): void
generateSync(kty: 'RSA', bitlength?: number, parameters?: KeyParameters, private?: boolean): void
generateSync(kty: 'oct', bitlength?: number, parameters?: KeyParameters): void
generateSync(kty: 'EC', crv?: ECCurve, parameters?: BasicParameters, private?: boolean): void
generateSync(kty: 'OKP', crv?: OKPCurve, parameters?: BasicParameters, private?: boolean): void
generateSync(kty: 'RSA', bitlength?: number, parameters?: BasicParameters, private?: boolean): void
generateSync(kty: 'oct', bitlength?: number, parameters?: BasicParameters): void
}
}

Expand Down
10 changes: 9 additions & 1 deletion lib/jwk/import.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,15 @@ const OctKey = require('./key/oct')
const importable = new Set(['string', 'buffer', 'object'])

const mergedParameters = (target = {}, source = {}) => {
return Object.assign({}, { alg: source.alg, use: source.use, kid: source.kid, key_ops: source.key_ops }, target)
return Object.assign({}, {
alg: source.alg,
key_ops: source.key_ops,
kid: source.kid,
use: source.use,
x5c: source.x5c,
x5t: source.x5t,
'x5t#S256': source['x5t#S256']
}, target)
}

const importKey = (key, parameters) => {
Expand Down
69 changes: 67 additions & 2 deletions lib/jwk/key/base.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const { strict: assert } = require('assert')
const { createPublicKey } = require('crypto')
const { inspect } = require('util')

Expand All @@ -11,7 +12,7 @@ const thumbprint = require('../thumbprint')
const errors = require('../../errors')

class Key {
constructor (keyObject, { alg, use, kid, key_ops: ops } = {}) {
constructor (keyObject, { alg, use, kid, key_ops: ops, x5c, x5t, 'x5t#S256': x5t256 } = {}) {
if (use !== undefined) {
if (typeof use !== 'string' || !USES.has(use)) {
throw new TypeError('`use` must be either "sig" or "enc" string when provided')
Expand All @@ -31,7 +32,7 @@ class Key {
}

if (ops !== undefined) {
if (!Array.isArray(ops) || !ops.length || ops.some(x => typeof x !== 'string')) {
if (!Array.isArray(ops) || !ops.length || ops.some(o => typeof o !== 'string')) {
throw new TypeError('`key_ops` must be a non-empty array of strings when provided')
}
ops = Array.from(new Set(ops)).filter(x => OPS.has(x))
Expand All @@ -46,6 +47,33 @@ class Key {
}
}

if (x5c !== undefined) {
if (!Array.isArray(x5c) || !x5c.length || x5c.some(c => typeof c !== 'string')) {
throw new TypeError('`x5c` must be an array of one or more PKIX certificates when provided')
}

x5c.forEach((cert, i) => {
let publicKey
try {
publicKey = createPublicKey({
key: `-----BEGIN CERTIFICATE-----\n${cert}\n-----END CERTIFICATE-----`, format: 'pem'
})
} catch (err) {
throw new errors.JWKInvalid(`\`x5c\` member at index ${i} is not a valid base64-encoded DER PKIX certificate`)
}
if (i === 0) {
try {
assert.deepEqual(
publicKey.export({ type: 'spki', format: 'der' }),
(keyObject.type === 'public' ? keyObject : createPublicKey(keyObject)).export({ type: 'spki', format: 'der' })
)
} catch (err) {
throw new errors.JWKInvalid('The key in the first `x5c` certificate MUST match the public key represented by the JWK')
}
}
})
}

Object.defineProperties(this, {
[KEYOBJECT]: { value: isObject(keyObject) ? undefined : keyObject },
type: { value: keyObject.type },
Expand All @@ -54,6 +82,7 @@ class Key {
secret: { value: keyObject.type === 'secret' },
alg: { value: alg, enumerable: alg !== undefined },
use: { value: use, enumerable: use !== undefined },
x5c: { value: x5c, enumerable: x5c !== undefined },
key_ops: {
enumerable: ops !== undefined,
...(ops ? { get () { return [...ops] } } : { value: undefined })
Expand All @@ -68,6 +97,30 @@ class Key {
configurable: true
})
},
...(x5c ? {
x5t: {
enumerable: true,
...(x5t ? { value: x5t } : {
get () {
Object.defineProperty(this, 'x5t', { value: thumbprint.x5t(this.x5c[0]), configurable: false })
return this.x5t
},
configurable: true
})
}
} : undefined),
...(x5c ? {
'x5t#S256': {
enumerable: true,
...(x5t256 ? { value: x5t256 } : {
get () {
Object.defineProperty(this, 'x5t#S256', { value: thumbprint['x5t#S256'](this.x5c[0]), configurable: false })
return this['x5t#S256']
},
configurable: true
})
}
} : undefined),
thumbprint: {
get () {
Object.defineProperty(this, 'thumbprint', { value: thumbprint.kid(this[THUMBPRINT_MATERIAL]()), configurable: false })
Expand Down Expand Up @@ -131,6 +184,18 @@ class Key {
result.use = this.use
}

if (this.x5c) {
result.x5c = this.x5c
}

if (this.x5t) {
result.x5t = this.x5t
}

if (this['x5t#S256']) {
result['x5t#S256'] = this['x5t#S256']
}

return result
}

Expand Down
4 changes: 4 additions & 0 deletions lib/jwk/thumbprint.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,8 @@ const { createHash } = require('crypto')

const base64url = require('../help/base64url')

const xt5 = (hash, cert) => base64url.encodeBuffer(createHash(hash).update(Buffer.from(cert, 'base64')).digest())

module.exports.kid = components => base64url.encodeBuffer(createHash('sha256').update(JSON.stringify(components)).digest())
module.exports.x5t = xt5.bind(undefined, 'sha1')
module.exports['x5t#S256'] = xt5.bind(undefined, 'sha256')
23 changes: 20 additions & 3 deletions lib/jwks/keystore.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const Key = require('../jwk/key/base')
const importKey = require('../jwk/import')
const { USES_MAPPING } = require('../help/consts')

const keyscore = (key, { alg, kid, use, ops }) => {
const keyscore = (key, { alg, kid, use, ops, x5t, x5t256 }) => {
let score = 0

if (alg && key.alg) {
Expand All @@ -17,6 +17,14 @@ const keyscore = (key, { alg, kid, use, ops }) => {
score++
}

if (x5t && key.x5t) {
score++
}

if (x5t256 && key['x5t#S256']) {
score++
}

if (use && key.use) {
score++
}
Expand Down Expand Up @@ -52,11 +60,12 @@ class KeyStore {
return new KeyStore(...keys)
}

all ({ alg, kid, use, kty, key_ops: ops } = {}) {
all ({ alg, kid, use, kty, key_ops: ops, x5t, 'x5t#S256': x5t256 } = {}) {
if (ops !== undefined && (!Array.isArray(ops) || !ops.length || ops.some(x => typeof x !== 'string'))) {
throw new TypeError('`key_ops` must be a non-empty array of strings')
}

const search = { alg, kid, use, ops, x5t, x5t256 }
return [...this.#keys]
.filter((key) => {
let candidate = true
Expand All @@ -69,6 +78,14 @@ class KeyStore {
candidate = false
}

if (candidate && x5t !== undefined && key.x5t !== x5t) {
candidate = false
}

if (candidate && x5t256 !== undefined && key['x5t#S256'] !== x5t256) {
candidate = false
}

if (candidate && kty !== undefined && key.kty !== kty) {
candidate = false
}
Expand All @@ -91,7 +108,7 @@ class KeyStore {

return candidate
})
.sort((first, second) => keyscore(second, { alg, kid, use, ops }) - keyscore(first, { alg, kid, use, ops }))
.sort((first, second) => keyscore(second, search) - keyscore(first, search))
}

get (...args) {
Expand Down
Loading

0 comments on commit 9d46c48

Please sign in to comment.