diff --git a/src/claim.js b/src/claim.js index 494a484..b9867c1 100644 --- a/src/claim.js +++ b/src/claim.js @@ -18,50 +18,30 @@ import { isUri } from 'valid-url' import mergeOptions from 'merge-options' import { fetch } from './proofs.js' import { run } from './verifications.js' -import { list, data as _data } from './claimDefinitions/index.js' +import { list, data as _data } from './serviceProviders/index.js' import { opts as _opts } from './defaults.js' import { ClaimStatus } from './enums.js' /** * @class - * @classdesc OpenPGP-based identity claim + * @classdesc Identity claim * @property {string} uri - The claim's URI * @property {string} fingerprint - The fingerprint to verify the claim against - * @property {string} status - The current status of the claim + * @property {number} status - The current status code of the claim * @property {Array} matches - The claim definitions matched against the URI - * @property {object} verification - The result of the verification process */ export class Claim { /** * Initialize a Claim object * @constructor - * @param {string|object} [uri] - The URI of the identity claim or a JSONified Claim instance + * @param {string} [uri] - The URI of the identity claim * @param {string} [fingerprint] - The fingerprint of the OpenPGP key * @example * const claim = doip.Claim(); * const claim = doip.Claim('dns:domain.tld?type=TXT'); * const claim = doip.Claim('dns:domain.tld?type=TXT', '123abc123abc'); - * const claimAlt = doip.Claim(JSON.stringify(claim)); */ constructor (uri, fingerprint) { - // Import JSON - if (typeof uri === 'object' && 'claimVersion' in uri) { - const data = uri - switch (data.claimVersion) { - case 1: - this._uri = data.uri - this._fingerprint = data.fingerprint - this._status = data.status - this._matches = data.matches - this._verification = data.verification - break - - default: - throw new Error('Invalid claim version') - } - return - } - // Verify validity of URI if (uri && !isUri(uri)) { throw new Error('Invalid URI') @@ -77,11 +57,59 @@ export class Claim { } } + /** + * @type {string} + */ this._uri = uri || '' + /** + * @type {string} + */ this._fingerprint = fingerprint || '' + /** + * @type {number} + */ this._status = ClaimStatus.INIT + /** + * @type {import('./serviceProvider.js').ServiceProvider[]} + */ this._matches = [] - this._verification = {} + } + + /** + * @function + * @param {object} claimObject + * @example + * const claimAlt = doip.Claim(JSON.stringify(claim)); + */ + static fromJson (claimObject) { + /** @type {Claim} */ + let claim + let result + + if (typeof claimObject === 'object' && 'claimVersion' in claimObject) { + switch (claimObject.claimVersion) { + case 1: + result = importJsonClaimVersion1(claimObject) + if (result instanceof Error) { + throw result + } + claim = result + break + + case 2: + result = importJsonClaimVersion2(claimObject) + if (result instanceof Error) { + throw result + } + claim = result + break + + default: + throw new Error('Invalid claim version') + } + } + + return claim } get uri () { @@ -103,13 +131,6 @@ export class Claim { return this._matches } - get verification () { - if (this._status !== ClaimStatus.VERIFIED) { - throw new Error('This claim has not yet been verified') - } - return this._verification - } - set uri (uri) { if (this._status !== ClaimStatus.INIT) { throw new Error( @@ -143,10 +164,6 @@ export class Claim { throw new Error("Cannot change a claim's matches") } - set verification (anything) { - throw new Error("Cannot change a claim's verification result") - } - /** * Match the claim's URI to candidate definitions * @function @@ -175,7 +192,7 @@ export class Claim { return true } - if (candidate.match.isAmbiguous) { + if (candidate.claim.uriIsAmbiguous) { // Add to the possible candidates this._matches.push(candidate) } else { @@ -188,7 +205,7 @@ export class Claim { return true }) - this._status = ClaimStatus.MATCHED + this._status = this._matches.length === 0 ? ClaimStatus.NO_MATCHES : ClaimStatus.MATCHED } /** @@ -204,7 +221,7 @@ export class Claim { if (this._status === ClaimStatus.INIT) { throw new Error('This claim has not yet been matched') } - if (this._status === ClaimStatus.VERIFIED) { + if (this._status >= 200) { throw new Error('This claim has already been verified') } if (this._fingerprint.length === 0) { @@ -216,16 +233,14 @@ export class Claim { // If there are no matches if (this._matches.length === 0) { - this._verification = { - result: false, - completed: true, - proof: {}, - errors: ['No matches for claim'] - } + this.status = ClaimStatus.NO_MATCHES } // For each match for (let index = 0; index < this._matches.length; index++) { + // Continue if a result was already obtained + if (this._status >= 200) { continue } + let claimData = this._matches[index] let verificationResult = null @@ -246,12 +261,12 @@ export class Claim { this._fingerprint ) verificationResult.proof = { - fetcher: proofData.fetcher, + protocol: proofData.fetcher, viaProxy: proofData.viaProxy } // Post process the data - const def = _data[claimData.serviceprovider.name] + const def = _data[claimData.about.id] if (def.functions?.postprocess) { try { ({ claimData, proofData } = def.functions.postprocess(claimData, proofData)) @@ -272,25 +287,13 @@ export class Claim { continue } - if (verificationResult.completed) { - // Store the result, keep a single match and stop verifying - this._verification = verificationResult + if (verificationResult.result) { + this._status = verificationResult.proof.viaProxy ? ClaimStatus.VERIFIED_VIA_PROXY : ClaimStatus.VERIFIED this._matches = [claimData] - index = this._matches.length } } - // Fail safe verification result - this._verification = Object.keys(this._verification).length > 0 - ? this._verification - : { - result: false, - completed: true, - proof: {}, - errors: [] - } - - this._status = ClaimStatus.VERIFIED + this._status = this._status >= 200 ? this._status : ClaimStatus.NO_PROOF_FOUND } /** @@ -307,7 +310,7 @@ export class Claim { if (this._matches.length === 0) { throw new Error('The claim has no matches') } - return this._matches.length > 1 || this._matches[0].match.isAmbiguous + return this._matches.length > 1 || this._matches[0].claim.uriIsAmbiguous } /** @@ -317,13 +320,87 @@ export class Claim { * @returns {object} */ toJSON () { + let displayName = this._uri + let displayUrl = '' + let displayServiceProviderName = '' + + if (this._status >= 200 && this._status < 300) { + displayName = this._matches[0].profile.display + displayUrl = this._matches[0].profile.uri + displayServiceProviderName = this._matches[0].about.id + } + return { - claimVersion: 1, + claimVersion: 2, uri: this._uri, - fingerprint: this._fingerprint, - status: this._status, + proofs: [this._fingerprint], matches: this._matches, - verification: this._verification + status: this._status, + display: { + name: displayName, + url: displayUrl, + serviceProviderName: displayServiceProviderName + } } } } + +/** + * @param {object} claimObject + * @returns {Claim | Error} + */ +function importJsonClaimVersion1 (claimObject) { + if (!('claimVersion' in claimObject && claimObject.claimVersion === 1)) { + return new Error('Invalid claim') + } + + const claim = new Claim() + + claim._uri = claimObject.uri + claim._fingerprint = claimObject.fingerprint + claim._matches = claimObject.matches + + if (claimObject.status === 'init') { + claim._status = 100 + } + if (claimObject.status === 'matched') { + if (claimObject.matches.length === 0) { + claim._status = 301 + } + claim._status = 101 + } + + if (!('result' in claimObject.verification && 'errors' in claimObject.verification)) { + claim._status = 400 + } + if (claimObject.verification.errors.length > 0) { + claim._status = 400 + } + if (claimObject.verification.result && claimObject.verification.proof.viaProxy) { + claim._status = 201 + } + if (claimObject.verification.result && !claimObject.verification.proof.viaProxy) { + claim._status = 200 + } + + return claim +} + +/** + * @param {object} claimObject + * @returns {Claim | Error} + */ +function importJsonClaimVersion2 (claimObject) { + if (!('claimVersion' in claimObject && claimObject.claimVersion === 2)) { + return new Error('Invalid claim') + } + + const claim = new Claim() + + claim._uri = claimObject.uri + claim._fingerprint = claimObject.proofs[0] + claim._matches = claimObject.matches + claim._status = claimObject.status + + return claim +} diff --git a/src/persona.js b/src/persona.js index eae2db0..653fb2b 100644 --- a/src/persona.js +++ b/src/persona.js @@ -13,9 +13,6 @@ 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 -import { Claim } from './claim.js' - /** * A persona with identity claims * @class @@ -27,28 +24,115 @@ import { Claim } from './claim.js' */ export class Persona { /** - * @param {string} name - * @param {string} [description] - * @param {Claim[]} [claims] - */ - constructor (name, description, claims) { + * @param {string} name + * @param {import('./claim.js').Claim[]} claims + */ + constructor (name, claims) { /** - * Name to be displayed on the profile page - * @type {string} - * @public - */ + * Identifier of the persona + * @type {string | null} + * @public + */ + this.identifier = null + /** + * 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 + * Email address of the persona + * @type {string | null} + * @public + */ + this.email = null /** - * List of identity claims - * @type {Array} - * @public - */ + * Description to be displayed on the profile page + * @type {string | null} + * @public + */ + this.description = null + /** + * URL to an avatar image + * @type {string | null} + * @public + */ + this.avatarUrl = null + /** + * List of identity claims + * @type {import('./claim.js').Claim[]} + * @public + */ this.claims = claims + /** + * Has the persona been revoked + * @type {boolean} + * @public + */ + this.isRevoked = false + } + + /** + * @function + * @param {string} identifier + */ + setIdentifier (identifier) { + this.identifier = identifier + } + + /** + * @function + * @param {string} description + */ + setDescription (description) { + this.description = description + } + + /** + * @function + * @param {string} email + */ + setEmailAddress (email) { + this.email = email + } + + /** + * @function + * @param {string} avatarUrl + */ + setAvatarUrl (avatarUrl) { + this.avatarUrl = avatarUrl + } + + /** + * @function + * @param {import('./claim.js').Claim} claim + */ + addClaim (claim) { + this.claims.push(claim) + } + + /** + * @function + */ + revoke () { + this.isRevoked = true + } + + /** + * Get a JSON representation of the Profile object + * @function + * @returns {object} + */ + toJSON () { + return { + identifier: this.identifier, + name: this.name, + email: this.email, + description: this.description, + avatarUrl: this.avatarUrl, + isRevoked: this.isRevoked, + claims: this.claims.map(x => x.toJSON()) + } } } diff --git a/src/profile.js b/src/profile.js index 08b70c9..e816037 100644 --- a/src/profile.js +++ b/src/profile.js @@ -13,13 +13,12 @@ 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 -import { Persona } from './persona.js' +import { PublicKeyFetchMethod, PublicKeyEncoding, PublicKeyType } from './enums.js' /** * A profile of personas with identity claims * @function - * @param {Array} personas + * @param {Array} personas * @public * @example * const claim = Claim('https://alice.tld', '123'); @@ -28,23 +27,152 @@ import { Persona } from './persona.js' */ export class Profile { /** - * Create a new profile - * @function - * @param {Array} personas + * Create a new profile + * @function + * @param {import('./enums.js').ProfileType} profileType + * @param {string} identifier + * @param {Array} personas + * @public + */ + constructor (profileType, identifier, personas) { + /** + * Profile version + * @type {number} * @public */ - constructor (personas) { + this.profileVersion = 2 /** - * List of personas - * @type {Array} - * @public - */ + * Profile version + * @type {import('./enums.js').ProfileType} + * @public + */ + this.profileType = profileType + /** + * Identifier of the profile (fingerprint, email address, uri...) + * @type {string} + * @public + */ + this.identifier = identifier + /** + * List of personas + * @type {Array} + * @public + */ this.personas = personas || [] /** - * Index of primary persona (to be displayed first or prominently) - * @type {Number} + * Index of primary persona (to be displayed first or prominently) + * @type {number} + * @public + */ + this.primaryPersonaIndex = personas.length > 0 ? 0 : -1 + /** + * The cryptographic key associated with the profile + * @property {object} + * @public + */ + this.publicKey = { + /** + * The type of cryptographic key + * @type {PublicKeyType} + * @public + */ + keyType: PublicKeyType.NONE, + /** + * The encoding of the cryptographic key + * @type {PublicKeyEncoding} + * @public + */ + encoding: PublicKeyEncoding.NONE, + /** + * The raw cryptographic key + * @type {string | null} + * @public + */ + encodedKey: null, + /** + * The raw cryptographic key as object (to be removed during toJSON()) + * @type {import('openpgp').PublicKey | import('jose').KeyLike | null} + * @public + */ + key: null, + /** + * Details on how to fetch the public key + * @property {object} + * @public + */ + fetch: { + /** + * The method to fetch the key + * @type {PublicKeyFetchMethod} * @public */ - this.primaryPersona = -1 + method: PublicKeyFetchMethod.NONE, + /** + * The query to fetch the key + * @type {string | null} + * @public + */ + query: null, + /** + * The URL the method eventually resolved to + * @type {string | null} + * @public + */ + resolvedUrl: null + } + } + /** + * List of verifier URLs + * @type {{name: string, url: string}[]} + * @public + */ + this.verifiers = [] + } + + /** + * @function + * @param {string} name + * @param {string} url + */ + addVerifier (name, url) { + this.verifiers.push({ name, url }) + } + + /** + * @function + * @param {import('openpgp').PublicKey} publicKey + */ + setOpenPgpPublicKey (publicKey) {} + + /** + * @function + * @param {import('jose').KeyLike} publicKey + */ + setJwkPublicKey (publicKey) {} + + /** + * Get a JSON representation of the Profile object + * @function + * @returns {object} + */ + toJSON () { + return { + profileVersion: this.profileVersion, + profileType: this.profileType, + identifier: this.identifier, + personas: this.personas.map(x => x.toJSON()), + primaryPersonaIndex: this.primaryPersonaIndex, + publicKey: { + keyType: this.publicKey.keyType, + format: this.publicKey.format, + keyData: this.publicKey.keyData, + fetch: { + method: this.publicKey.fetch.method, + query: this.publicKey.fetch.query, + resolvedUrl: this.publicKey.fetch.resolvedUrl + } + }, + verifiers: this.verifiers + } } }