diff --git a/examples/fetch-profile-aspe.js b/examples/fetch-profile-aspe.js new file mode 100644 index 0000000..9b26b6c --- /dev/null +++ b/examples/fetch-profile-aspe.js @@ -0,0 +1,19 @@ +const doip = require('../src') + +const main = async () => { + // Fetch the profile using ASPE + const profile = await doip.asp.fetchASPE("aspe:keyoxide.org:6WJK26YKF6WUVPIZTS2I2BIT64") + console.log(profile); + + // Process every claim for every persona + profile.personas[0].claims.forEach(async claim => { + // Match the claim + claim.match() + + // Verify the claim + await claim.verify() + console.log(claim) + }) +} + +main() \ No newline at end of file diff --git a/src/asp.js b/src/asp.js new file mode 100644 index 0000000..433e5ac --- /dev/null +++ b/src/asp.js @@ -0,0 +1,154 @@ +/* +Copyright 2023 Yarmo Mackenbach + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +const axios = require('axios').default +const jose = require('jose') +const { base32, base64url } = require('rfc4648') +const Claim = require('./claim') +const Persona = require('./persona') +const Profile = require('./profile') + +const SupportedCryptoAlg = ['EdDSA', 'ES256', 'ES256K', 'ES384', 'ES512'] + +/** + * Functions related to Ariadne Signature Profiles + * @module asp + */ + +/** + * Fetch a public key using Web Key Directory + * @function + * @param {string} uri - ASPE URI + * @returns {Promise} + * @example + * const key = doip.aspe.fetchASPE('aspe:domain.tld:1234567890'); + */ +const fetchASPE = async uri => { + const re = /aspe:(.*):(.*)/ + + if (!re.test(uri)) { + throw new Error('Invalid ASPE URI') + } + + const matches = uri.match(re) + const domainPart = matches[1] + const localPart = matches[2].toUpperCase() + + const profileUrl = `https://${domainPart}/.well-known/aspe/id/${localPart}` + let profileJws + + try { + profileJws = await axios.get( + profileUrl, + { + responseType: 'text' + } + ) + .then((/** @type {import('axios').AxiosResponse} */ response) => { + if (response.status === 200) { + return response + } + }) + .then((/** @type {import('axios').AxiosResponse} */ response) => response.data) + } catch (e) { + throw new Error(`Error fetching Keybase key: ${e.message}`) + } + + return await parseProfileJws(profileJws, uri) +} + +/** + * Fetch a public key using Web Key Directory + * @function + * @param {string} profileJws - Compact-Serialized profile JWS + * @param {string} uri - The ASPE URI associated with the profile + * @returns {Promise} + * @example + * const key = doip.aspe.parseProfileJws('...'); + */ +const parseProfileJws = async (profileJws, uri) => { + const matches = uri.match(/aspe:(.*):(.*)/) + const localPart = matches[2].toUpperCase() + + // Decode the headers + const protectedHeader = jose.decodeProtectedHeader(profileJws) + + // Extract the JWK + if (!SupportedCryptoAlg.includes(protectedHeader.alg)) { + throw new Error('Invalid profile JWS: wrong key algorithm') + } + if (!protectedHeader.kid) { + throw new Error('Invalid profile JWS: missing key identifier') + } + if (!protectedHeader.jwk) { + throw new Error('Invalid profile JWS: missing key') + } + const publicKey = await jose.importJWK(protectedHeader.jwk, protectedHeader.alg) + + // Compute and verify the fingerprint + const fp = await computeJwkFingerprint(protectedHeader.jwk) + + if (fp !== protectedHeader.kid) { + throw new Error('Invalid profile JWS: wrong key') + } + if (localPart && fp !== localPart) { + throw new Error('Invalid profile JWS: wrong key') + } + + // Decode the payload + const { payload } = await jose.compactVerify(profileJws, publicKey) + const payloadJson = JSON.parse(new TextDecoder().decode(payload)) + + // Verify the payload + if (!(Object.prototype.hasOwnProperty.call(payloadJson, 'http://ariadne.id/type') && payloadJson['http://ariadne.id/type'] === 'profile')) { + throw new Error('Invalid profile JWS: JWS is not a profile') + } + if (!(Object.prototype.hasOwnProperty.call(payloadJson, 'http://ariadne.id/version') && payloadJson['http://ariadne.id/version'] === 0)) { + throw new Error('Invalid profile JWS: profile version not supported') + } + + // Extract data from the payload + /** @type {string} */ + const profileName = payloadJson['http://ariadne.id/name'] + /** @type {string} */ + const profileDescription = payloadJson['http://ariadne.id/description'] + /** @type {string[]} */ + const profileClaims = payloadJson['http://ariadne.id/claims'] + + const profileClaimsParsed = profileClaims.map(x => new Claim(x, uri)) + + const pe = new Persona(profileName, profileDescription || '', profileClaimsParsed) + const pr = new Profile([pe]) + pr.primaryPersona = 0 + + return pr +} + +/** + * Compute the fingerprint for JWK keys + * @function + * @param {jose.JWK} key + * @returns {Promise} + */ +const computeJwkFingerprint = async key => { + const thumbprint = await jose.calculateJwkThumbprint(key, 'sha512') + const fingerprintBytes = base64url.parse(thumbprint, { loose: true }).slice(0, 16) + const fingerprint = base32.stringify(fingerprintBytes, { pad: false }) + + return fingerprint +} + +exports.fetchASPE = fetchASPE +exports.parseProfileJws = parseProfileJws diff --git a/src/claim.js b/src/claim.js index 0340546..1a9bd6d 100644 --- a/src/claim.js +++ b/src/claim.js @@ -280,7 +280,7 @@ class Claim { } // Fail safe verification result - this._verification = this._verification + this._verification = Object.keys(this._verification).length > 0 ? this._verification : { result: false, diff --git a/src/index.js b/src/index.js index a647b7f..e30ab41 100644 --- a/src/index.js +++ b/src/index.js @@ -13,10 +13,13 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +const Profile = require('./profile') +const Persona = require('./persona') const Claim = require('./claim') const claimDefinitions = require('./claimDefinitions') const proofs = require('./proofs') const keys = require('./keys') +const asp = require('./asp') const signatures = require('./signatures') const enums = require('./enums') const defaults = require('./defaults') @@ -24,10 +27,13 @@ const utils = require('./utils') const verifications = require('./verifications') const fetcher = require('./fetcher') +exports.Profile = Profile +exports.Persona = Persona exports.Claim = Claim exports.claimDefinitions = claimDefinitions exports.proofs = proofs exports.keys = keys +exports.asp = asp exports.signatures = signatures exports.enums = enums exports.defaults = defaults diff --git a/src/persona.js b/src/persona.js new file mode 100644 index 0000000..3fe7b3b --- /dev/null +++ b/src/persona.js @@ -0,0 +1,56 @@ +/* +Copyright 2023 Yarmo Mackenbach + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +// eslint-disable-next-line +const Claim = require('./claim') + +/** + * A persona with identity claims + * @class + * @constructor + * @public + * @example + * const claim = Claim('https://alice.tld', '123'); + * const pers = Persona('Alice', 'About Alice', [claim]); + */ +class Persona { + /** + * @param {string} name + * @param {string} [description] + * @param {Claim[]} [claims] + */ + constructor (name, description, claims) { + /** + * Name to be displayed on the profile page + * @type {string} + * @public + */ + this.name = name + /** + * Description to be displayed on the profile page + * @type {string} + * @public + */ + this.description = description + /** + * List of identity claims + * @type {Array} + * @public + */ + this.claims = claims + } +} + +module.exports = Persona diff --git a/src/profile.js b/src/profile.js new file mode 100644 index 0000000..32f53bb --- /dev/null +++ b/src/profile.js @@ -0,0 +1,52 @@ +/* +Copyright 2023 Yarmo Mackenbach + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +// eslint-disable-next-line +const Persona = require('./persona') + +/** + * A profile of personas with identity claims + * @function + * @param {Array} personas + * @public + * @example + * const claim = Claim('https://alice.tld', '123'); + * const pers = Persona('Alice', 'About Alice', [claim]); + * const profile = Profile([pers]); + */ +class Profile { + /** + * Create a new profile + * @function + * @param {Array} personas + * @public + */ + constructor (personas) { + /** + * List of personas + * @type {Array} + * @public + */ + this.personas = personas || [] + /** + * Index of primary persona (to be displayed first or prominently) + * @type {Number} + * @public + */ + this.primaryPersona = -1 + } +} + +module.exports = Profile diff --git a/src/utils.js b/src/utils.js index e92ac18..9f6ad8b 100644 --- a/src/utils.js +++ b/src/utils.js @@ -58,6 +58,9 @@ const generateProxyURL = (type, data, opts) => { const generateClaim = (fingerprint, format) => { switch (format) { case E.ClaimFormat.URI: + if (fingerprint.match(/^(openpgp4fpr|aspe):/)) { + return fingerprint + } return `openpgp4fpr:${fingerprint}` case E.ClaimFormat.FINGERPRINT: return fingerprint diff --git a/test/asp.test.js b/test/asp.test.js new file mode 100644 index 0000000..75ee065 --- /dev/null +++ b/test/asp.test.js @@ -0,0 +1,60 @@ +/* +Copyright 2023 Yarmo Mackenbach + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +const chai = require('chai') +const expect = chai.expect +chai.use(require('chai-as-promised')) + +const doipjs = require('../src') +const { log } = require('console') + +const asp25519Uri = "aspe:domain.tld:QPRGVPJNWDXH4ESK2RYDTZJLTE" +const asp25519ProfileName = "test" +const asp25519ProfileClaims = ["https://domain.tld/user/test", "https://another.tld/test"] +const asp25519ProfileJws = "eyJ0eXAiOiJKV1QiLCJraWQiOiJRUFJHVlBKTldEWEg0RVNLMlJZRFRaSkxURSIsImp3ayI6eyJrdHkiOiJPS1AiLCJ1c2UiOiJzaWciLCJjcnYiOiJFZDI1NTE5IiwieCI6Il9fcG9TUXdOZWRvcGZMS1AzWmdNNkZYejlMSUpzekRaaDV3S2NvUUY3MVUifSwiYWxnIjoiRWREU0EifQ.eyJodHRwOi8vYXJpYWRuZS5pZC92ZXJzaW9uIjowLCJodHRwOi8vYXJpYWRuZS5pZC90eXBlIjoicHJvZmlsZSIsImh0dHA6Ly9hcmlhZG5lLmlkL25hbWUiOiJ0ZXN0IiwiaHR0cDovL2FyaWFkbmUuaWQvY2xhaW1zIjpbImh0dHBzOi8vZG9tYWluLnRsZC91c2VyL3Rlc3QiLCJodHRwczovL2Fub3RoZXIudGxkL3Rlc3QiXX0.yiBJbaB2oyprfRzYcmP-iz3C-5PGwV1Yc5iDSLW_2JFKVPKH3BKL2mUHE62VvyH1EiXDfWjpGae7jT1bM8PSAQ" +const asp25519ProfileJwk = { + "kty": "OKP", + "use": "sig", + "crv": "Ed25519", + "x": "__poSQwNedopfLKP3ZgM6FXz9LIJszDZh5wKcoQF71U" +} + +describe('asp.fetchASPE', () => { + it('should be a function (1 argument)', () => { + expect(doipjs.asp.fetchASPE).to.be.a('function') + expect(doipjs.asp.fetchASPE).to.have.length(1) + }) +}) + +describe('asp.parseProfileJws', () => { + it('should be a function (2 arguments)', () => { + expect(doipjs.asp.parseProfileJws).to.be.a('function') + expect(doipjs.asp.parseProfileJws).to.have.length(2) + }) + it('should return a valid Profile object when provided a valid JWS', async () => { + let profile = await doipjs.asp.parseProfileJws(asp25519ProfileJws, asp25519Uri) + + expect(profile).to.be.instanceOf(doipjs.Profile) + expect(profile.personas).to.be.length(1) + expect(profile.personas[0].name).to.be.equal(asp25519ProfileName) + expect(profile.personas[0].claims).to.be.length(2) + + expect(profile.personas[0].claims[0].uri).to.be.equal(asp25519ProfileClaims[0]) + expect(profile.personas[0].claims[0].fingerprint).to.be.equal(asp25519Uri) + + expect(profile.personas[0].claims[1].uri).to.be.equal(asp25519ProfileClaims[1]) + expect(profile.personas[0].claims[1].fingerprint).to.be.equal(asp25519Uri) + }) +})