From 6ec6f9db719dabcfaf1771dffcaff8aa56077b88 Mon Sep 17 00:00:00 2001 From: Alex Wilson Date: Mon, 4 Dec 2017 18:26:05 -0800 Subject: [PATCH] joyent/node-sshpk#38 want support for more obscure DN OIDs Reviewed by: Cody Peter Mello Approved by: Cody Peter Mello --- README.md | 72 ++++++++++++++++++++++++- lib/identity.js | 90 +++++++++++++++++++++++++++++-- lib/index.js | 1 + package.json | 2 +- test/assets/double-title-cert.pem | 21 ++++++++ test/certs.js | 30 +++++++++++ test/identity.js | 29 ++++++++++ 7 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 test/assets/double-title-cert.pem diff --git a/README.md b/README.md index 310c2ee..e8d8c88 100644 --- a/README.md +++ b/README.md @@ -614,6 +614,44 @@ Parameters Returns an Identity instance. +### `identityFromArray(arr)` + +Constructs an Identity from an array of DN components (see `Identity#toArray()` +for the format). + +Parameters + + - `arr` -- an Array of Objects, DN components with `name` and `value` + +Returns an Identity instance. + + +Supported attributes in DNs: + +| Attribute name | OID | +| -------------- | --- | +| `cn` | `2.5.4.3` | +| `o` | `2.5.4.10` | +| `ou` | `2.5.4.11` | +| `l` | `2.5.4.7` | +| `s` | `2.5.4.8` | +| `c` | `2.5.4.6` | +| `sn` | `2.5.4.4` | +| `postalCode` | `2.5.4.17` | +| `serialNumber` | `2.5.4.5` | +| `street` | `2.5.4.9` | +| `x500UniqueIdentifier` | `2.5.4.45` | +| `role` | `2.5.4.72` | +| `telephoneNumber` | `2.5.4.20` | +| `description` | `2.5.4.13` | +| `dc` | `0.9.2342.19200300.100.1.25` | +| `uid` | `0.9.2342.19200300.100.1.1` | +| `mail` | `0.9.2342.19200300.100.1.3` | +| `title` | `2.5.4.12` | +| `gn` | `2.5.4.42` | +| `initials` | `2.5.4.43` | +| `pseudonym` | `2.5.4.65` | + ### `Identity#toString()` Returns the identity as an LDAP-style DN string. @@ -631,7 +669,39 @@ Set when `type` is `'host'`, `'user'`, or `'email'`, respectively. Strings. ### `Identity#cn` -The value of the first `CN=` in the DN, if any. +The value of the first `CN=` in the DN, if any. It's probably better to use +the `#get()` method instead of this property. + +### `Identity#get(name[, asArray])` + +Returns the value of a named attribute in the Identity DN. If there is no +attribute of the given name, returns `undefined`. If multiple components +of the DN contain an attribute of this name, an exception is thrown unless +the `asArray` argument is given as `true` -- then they will be returned as +an Array in the same order they appear in the DN. + +Parameters + + - `name` -- a String + - `asArray` -- an optional Boolean + +### `Identity#toArray()` + +Returns the Identity as an Array of DN component objects. This looks like: + +```js +[ { + "name": "cn", + "value": "Joe Bloggs" +}, +{ + "name": "o", + "value": "Organisation Ltd" +} ] +``` + +Each object has a `name` and a `value` property. The returned objects may be +safely modified. Errors ------ diff --git a/lib/identity.js b/lib/identity.js index 495b83a..7d75b66 100644 --- a/lib/identity.js +++ b/lib/identity.js @@ -24,9 +24,21 @@ oids.l = '2.5.4.7'; oids.s = '2.5.4.8'; oids.c = '2.5.4.6'; oids.sn = '2.5.4.4'; +oids.postalCode = '2.5.4.17'; +oids.serialNumber = '2.5.4.5'; +oids.street = '2.5.4.9'; +oids.x500UniqueIdentifier = '2.5.4.45'; +oids.role = '2.5.4.72'; +oids.telephoneNumber = '2.5.4.20'; +oids.description = '2.5.4.13'; oids.dc = '0.9.2342.19200300.100.1.25'; oids.uid = '0.9.2342.19200300.100.1.1'; oids.mail = '0.9.2342.19200300.100.1.3'; +oids.title = '2.5.4.12'; +oids.gn = '2.5.4.42'; +oids.initials = '2.5.4.43'; +oids.pseudonym = '2.5.4.65'; +oids.emailAddress = '1.2.840.113549.1.9.1'; var unoids = {}; Object.keys(oids).forEach(function (k) { @@ -113,10 +125,39 @@ function Identity(opts) { Identity.prototype.toString = function () { return (this.components.map(function (c) { - return (c.name.toUpperCase() + '=' + c.value); + var n = c.name.toUpperCase(); + /*JSSTYLED*/ + n = n.replace(/=/g, '\\='); + var v = c.value; + /*JSSTYLED*/ + v = v.replace(/,/g, '\\,'); + return (n + '=' + v); }).join(', ')); }; +Identity.prototype.get = function (name, asArray) { + assert.string(name, 'name'); + var arr = this.componentLookup[name]; + if (arr === undefined || arr.length === 0) + return (undefined); + if (!asArray && arr.length > 1) + throw (new Error('Multiple values for attribute ' + name)); + if (!asArray) + return (arr[0].value); + return (arr.map(function (c) { + return (c.value); + })); +}; + +Identity.prototype.toArray = function (idx) { + return (this.components.map(function (c) { + return ({ + name: c.name, + value: c.value + }); + })); +}; + /* * These are from X.680 -- PrintableString allowed chars are in section 37.4 * table 8. Spec for IA5Strings is "1,6 + SPACE + DEL" where 1 refers to @@ -224,17 +265,60 @@ Identity.forEmail = function (email) { Identity.parseDN = function (dn) { assert.string(dn, 'dn'); - var parts = dn.split(','); + var parts = ['']; + var idx = 0; + var rem = dn; + while (rem.length > 0) { + var m; + /*JSSTYLED*/ + if ((m = /^,/.exec(rem)) !== null) { + parts[++idx] = ''; + rem = rem.slice(m[0].length); + /*JSSTYLED*/ + } else if ((m = /^\\,/.exec(rem)) !== null) { + parts[idx] += ','; + rem = rem.slice(m[0].length); + /*JSSTYLED*/ + } else if ((m = /^\\./.exec(rem)) !== null) { + parts[idx] += m[0]; + rem = rem.slice(m[0].length); + /*JSSTYLED*/ + } else if ((m = /^[^\\,]+/.exec(rem)) !== null) { + parts[idx] += m[0]; + rem = rem.slice(m[0].length); + } else { + throw (new Error('Failed to parse DN')); + } + } var cmps = parts.map(function (c) { c = c.trim(); var eqPos = c.indexOf('='); - var name = c.slice(0, eqPos).toLowerCase(); + while (eqPos > 0 && c.charAt(eqPos - 1) === '\\') + eqPos = c.indexOf('=', eqPos + 1); + if (eqPos === -1) { + throw (new Error('Failed to parse DN')); + } + /*JSSTYLED*/ + var name = c.slice(0, eqPos).toLowerCase().replace(/\\=/g, '='); var value = c.slice(eqPos + 1); return ({ name: name, value: value }); }); return (new Identity({ components: cmps })); }; +Identity.fromArray = function (components) { + assert.arrayOfObject(components, 'components'); + components.forEach(function (cmp) { + assert.object(cmp, 'component'); + assert.string(cmp.name, 'component.name'); + if (!Buffer.isBuffer(cmp.value) && + !(typeof (cmp.value) === 'string')) { + throw (new Error('Invalid component value')); + } + }); + return (new Identity({ components: components })); +}; + Identity.parseAsn1 = function (der, top) { var components = []; der.readSequence(top); diff --git a/lib/index.js b/lib/index.js index cb8cd1a..f76db79 100644 --- a/lib/index.js +++ b/lib/index.js @@ -28,6 +28,7 @@ module.exports = { identityForHost: Identity.forHost, identityForUser: Identity.forUser, identityForEmail: Identity.forEmail, + identityFromArray: Identity.fromArray, /* errors */ FingerprintFormatError: errs.FingerprintFormatError, diff --git a/package.json b/package.json index e085195..36ea3f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sshpk", - "version": "1.14.2", + "version": "1.15.0", "description": "A library for finding and using SSH public keys", "main": "lib/index.js", "scripts": { diff --git a/test/assets/double-title-cert.pem b/test/assets/double-title-cert.pem new file mode 100644 index 0000000..c8e0cb3 --- /dev/null +++ b/test/assets/double-title-cert.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDhzCCAm+gAwIBAgIHWAIFM3wFkDANBgkqhkiG9w0BAQsFADCBrTERMA8GA1UE +DAwIY2VydC5rZXkxLTArBgNVBAMMJDJkYzc5ZDM2LWVhMDEtYzg1NS1lYWI1LWUy +YzdhMjRhYmJmNDEOMAwGA1UEKgwFem9uZTExNDAyBgoJkiaJk/IsZAEBDCRmZTAz +ZmY0MC1kYjEyLTExZTctYjQ4Yi02M2QxNTNhZjY5ODUxEjAQBgNVBAsMCWluc3Rh +bmNlczEPMA0GA1UECgwGdHJpdG9uMB4XDTE3MTIxNTIzMDUyMFoXDTE3MTIxNTIz +MDYyMFowgbYxFDASBgNVBAwMC2luLXpvbmUua2V5MS0wKwYDVQQDDCQyZGM3OWQz +Ni1lYTAxLWM4NTUtZWFiNS1lMmM3YTI0YWJiZjQxNDAyBgoJkiaJk/IsZAEBDCRm +ZTAzZmY0MC1kYjEyLTExZTctYjQ4Yi02M2QxNTNhZjY5ODUxEjAQBgNVBAsMCWRl +bGVnYXRlZDEPMA0GA1UECgwGdHJpdG9uMRQwEgYDVQQMDAtpbi16b25lLmtleTBZ +MBMGByqGSM49AgEGCCqGSM49AwEHA0IABMu/RgdeZ0/V9JqV3ByWW4t7/ncxcEnA +nsq1CBPwPIfb0aUhVy+FrJTYV+m+gStGJW9ZwE8uuU61rmxKdO+WWVSjbDBqMA8G +A1UdEwEB/wQFMAMBAQAwDgYDVR0PAQH/BAQDAgGIMBYGA1UdJQEB/wQMMAoGCCsG +AQUFBwMBMC8GA1UdEQQoMCaCJDJkYzc5ZDM2LWVhMDEtYzg1NS1lYWI1LWUyYzdh +MjRhYmJmNDANBgkqhkiG9w0BAQsFAAOCAQEAcRmBTD0bnI58nEjWXTTBPHYzBL8c +V6QBzfYOCNE6iXl1zzH3oR/x3+yIMRhTrw1tHv4A0lunvKZsz9fogy/8uTTQtOaT +xoXmjhvY7m1VZqb7W4+aG7DbJ2oZxIaZABXGEp+huKG5LPsmn0BGqJ2q8NxX3NKw +PPjyTZdwjVaxwSomkzIaGqjdWmRKeqxbkwL19SN9Rk9ZoytG1pjTrzWQW4yaYa+s +aycLMrrRMZE51qBPqFxf8YvXDHXk1gugQqJSRgmo4q/tFqloTwM+0D3tElzmAPCc +jPLGIkJ5/y+pQ81vTRl0HJmzndlg0ucbpiTRyZt9cpOe17KjvB8+tgy58w== +-----END CERTIFICATE----- diff --git a/test/certs.js b/test/certs.js index 68dbf75..0c10fef 100644 --- a/test/certs.js +++ b/test/certs.js @@ -229,6 +229,17 @@ test('napoleon cert (generalizedtime) (x509)', function (t) { console.log(cert.validFrom.getTime()); console.log(cert.validUntil.getTime()); t.ok(!cert.isExpired(new Date('1775-03-01T00:00Z'))); + t.deepEqual(cert.subjects[0].toArray(), [ + { name: 'c', value: 'FR' }, + { name: 's', value: 'Île-de-France' }, + { name: 'l', value: 'Paris' }, + { name: 'o', value: 'Hereditary Monarchy' }, + { name: 'ou', value: 'Head of State' }, + { name: 'emailAddress', value: 'nappi@greatfrenchempire.fr' }, + { name: 'cn', value: 'Emperor Napoleon I' }, + { name: 'sn', value: 'Bonaparte' }, + { name: 'gn', value: 'Napoleon' } + ]); t.end(); }); @@ -250,6 +261,8 @@ test('example cert: digicert (x509)', function (t) { t.strictEqual(cert.subjects[0].hostname, 'www.digicert.com'); t.strictEqual(cert.issuer.cn, 'DigiCert SHA2 Extended Validation Server CA'); + t.strictEqual(cert.issuer.get('c'), 'US'); + t.strictEqual(cert.issuer.get('o'), 'DigiCert Inc'); var cacert = sshpk.parseCertificate( fs.readFileSync(path.join(testDir, 'digicert-ca.crt')), 'x509'); @@ -356,3 +369,20 @@ test('example cert: ed25519 cert from curdle-pkix-04', function (t) { t.end(); }); + +test('cert with doubled-up DN attribute', function (t) { + var cert = sshpk.parseCertificate( + fs.readFileSync(path.join(testDir, 'double-title-cert.pem')), + 'pem'); + + var id = cert.subjects[0]; + t.throws(function () { + id.get('title'); + }); + t.deepEqual(id.get('title', true), ['in-zone.key', 'in-zone.key']); + + t.strictEqual(id.get('ou'), 'delegated'); + t.strictEqual(id.get('cn'), '2dc79d36-ea01-c855-eab5-e2c7a24abbf4'); + + t.end(); +}); diff --git a/test/identity.js b/test/identity.js index 995e72c..4a0c2cf 100644 --- a/test/identity.js +++ b/test/identity.js @@ -14,3 +14,32 @@ test('parsedn', function (t) { t.strictEqual(id.cn, 'Blah Corp'); t.end(); }); + +test('parsedn escapes', function (t) { + var id = sshpk.Identity.parseDN('cn=what\\,something,o=b==a,c=\\US'); + t.strictEqual(id.get('cn'), 'what,something'); + t.strictEqual(id.get('o'), 'b==a'); + t.strictEqual(id.get('c'), '\\US'); + id = sshpk.Identity.parseDN('cn\\=foo=bar'); + t.strictEqual(id.get('cn=foo'), 'bar'); + t.throws(function () { + sshpk.Identity.parseDN('cn\\\\=foo'); + }); + t.end(); +}); + +test('fromarray', function (t) { + var arr = [ + { name: 'ou', value: 'foo' }, + { name: 'ou', value: 'bar' }, + { name: 'cn', value: 'foobar,g=' } + ]; + var id = sshpk.identityFromArray(arr); + t.throws(function () { + id.get('ou'); + }); + t.deepEqual(id.get('ou', true), ['foo', 'bar']); + t.strictEqual(id.get('cn'), 'foobar,g='); + t.strictEqual(id.toString(), 'OU=foo, OU=bar, CN=foobar\\,g='); + t.end(); +});