feat: update claim, persona, profile classes

This commit is contained in:
Yarmo Mackenbach 2023-07-09 11:42:21 +02:00
parent fd8c760689
commit a30339272a
No known key found for this signature in database
GPG key ID: 3C57D093219103A3
3 changed files with 391 additions and 102 deletions

View file

@ -18,50 +18,30 @@ import { isUri } from 'valid-url'
import mergeOptions from 'merge-options' import mergeOptions from 'merge-options'
import { fetch } from './proofs.js' import { fetch } from './proofs.js'
import { run } from './verifications.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 { opts as _opts } from './defaults.js'
import { ClaimStatus } from './enums.js' import { ClaimStatus } from './enums.js'
/** /**
* @class * @class
* @classdesc OpenPGP-based identity claim * @classdesc Identity claim
* @property {string} uri - The claim's URI * @property {string} uri - The claim's URI
* @property {string} fingerprint - The fingerprint to verify the claim against * @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<object>} matches - The claim definitions matched against the URI * @property {Array<object>} matches - The claim definitions matched against the URI
* @property {object} verification - The result of the verification process
*/ */
export class Claim { export class Claim {
/** /**
* Initialize a Claim object * Initialize a Claim object
* @constructor * @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 * @param {string} [fingerprint] - The fingerprint of the OpenPGP key
* @example * @example
* const claim = doip.Claim(); * const claim = doip.Claim();
* const claim = doip.Claim('dns:domain.tld?type=TXT'); * const claim = doip.Claim('dns:domain.tld?type=TXT');
* const claim = doip.Claim('dns:domain.tld?type=TXT', '123abc123abc'); * const claim = doip.Claim('dns:domain.tld?type=TXT', '123abc123abc');
* const claimAlt = doip.Claim(JSON.stringify(claim));
*/ */
constructor (uri, fingerprint) { 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 // Verify validity of URI
if (uri && !isUri(uri)) { if (uri && !isUri(uri)) {
throw new Error('Invalid URI') throw new Error('Invalid URI')
@ -77,11 +57,59 @@ export class Claim {
} }
} }
/**
* @type {string}
*/
this._uri = uri || '' this._uri = uri || ''
/**
* @type {string}
*/
this._fingerprint = fingerprint || '' this._fingerprint = fingerprint || ''
/**
* @type {number}
*/
this._status = ClaimStatus.INIT this._status = ClaimStatus.INIT
/**
* @type {import('./serviceProvider.js').ServiceProvider[]}
*/
this._matches = [] 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 () { get uri () {
@ -103,13 +131,6 @@ export class Claim {
return this._matches 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) { set uri (uri) {
if (this._status !== ClaimStatus.INIT) { if (this._status !== ClaimStatus.INIT) {
throw new Error( throw new Error(
@ -143,10 +164,6 @@ export class Claim {
throw new Error("Cannot change a claim's matches") 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 * Match the claim's URI to candidate definitions
* @function * @function
@ -175,7 +192,7 @@ export class Claim {
return true return true
} }
if (candidate.match.isAmbiguous) { if (candidate.claim.uriIsAmbiguous) {
// Add to the possible candidates // Add to the possible candidates
this._matches.push(candidate) this._matches.push(candidate)
} else { } else {
@ -188,7 +205,7 @@ export class Claim {
return true 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) { if (this._status === ClaimStatus.INIT) {
throw new Error('This claim has not yet been matched') 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') throw new Error('This claim has already been verified')
} }
if (this._fingerprint.length === 0) { if (this._fingerprint.length === 0) {
@ -216,16 +233,14 @@ export class Claim {
// If there are no matches // If there are no matches
if (this._matches.length === 0) { if (this._matches.length === 0) {
this._verification = { this.status = ClaimStatus.NO_MATCHES
result: false,
completed: true,
proof: {},
errors: ['No matches for claim']
}
} }
// For each match // For each match
for (let index = 0; index < this._matches.length; index++) { 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 claimData = this._matches[index]
let verificationResult = null let verificationResult = null
@ -246,12 +261,12 @@ export class Claim {
this._fingerprint this._fingerprint
) )
verificationResult.proof = { verificationResult.proof = {
fetcher: proofData.fetcher, protocol: proofData.fetcher,
viaProxy: proofData.viaProxy viaProxy: proofData.viaProxy
} }
// Post process the data // Post process the data
const def = _data[claimData.serviceprovider.name] const def = _data[claimData.about.id]
if (def.functions?.postprocess) { if (def.functions?.postprocess) {
try { try {
({ claimData, proofData } = def.functions.postprocess(claimData, proofData)) ({ claimData, proofData } = def.functions.postprocess(claimData, proofData))
@ -272,25 +287,13 @@ export class Claim {
continue continue
} }
if (verificationResult.completed) { if (verificationResult.result) {
// Store the result, keep a single match and stop verifying this._status = verificationResult.proof.viaProxy ? ClaimStatus.VERIFIED_VIA_PROXY : ClaimStatus.VERIFIED
this._verification = verificationResult
this._matches = [claimData] this._matches = [claimData]
index = this._matches.length
} }
} }
// Fail safe verification result this._status = this._status >= 200 ? this._status : ClaimStatus.NO_PROOF_FOUND
this._verification = Object.keys(this._verification).length > 0
? this._verification
: {
result: false,
completed: true,
proof: {},
errors: []
}
this._status = ClaimStatus.VERIFIED
} }
/** /**
@ -307,7 +310,7 @@ export class Claim {
if (this._matches.length === 0) { if (this._matches.length === 0) {
throw new Error('The claim has no matches') 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} * @returns {object}
*/ */
toJSON () { 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 { return {
claimVersion: 1, claimVersion: 2,
uri: this._uri, uri: this._uri,
fingerprint: this._fingerprint, proofs: [this._fingerprint],
status: this._status,
matches: this._matches, 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
}

View file

@ -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 See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
// eslint-disable-next-line
import { Claim } from './claim.js'
/** /**
* A persona with identity claims * A persona with identity claims
* @class * @class
@ -28,10 +25,15 @@ import { Claim } from './claim.js'
export class Persona { export class Persona {
/** /**
* @param {string} name * @param {string} name
* @param {string} [description] * @param {import('./claim.js').Claim[]} claims
* @param {Claim[]} [claims]
*/ */
constructor (name, description, claims) { constructor (name, claims) {
/**
* Identifier of the persona
* @type {string | null}
* @public
*/
this.identifier = null
/** /**
* Name to be displayed on the profile page * Name to be displayed on the profile page
* @type {string} * @type {string}
@ -39,16 +41,98 @@ export class Persona {
*/ */
this.name = name this.name = name
/** /**
* Description to be displayed on the profile page * Email address of the persona
* @type {string} * @type {string | null}
* @public * @public
*/ */
this.description = description this.email = null
/**
* 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 * List of identity claims
* @type {Array<Claim>} * @type {import('./claim.js').Claim[]}
* @public * @public
*/ */
this.claims = claims 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())
}
} }
} }

View file

@ -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 See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
// eslint-disable-next-line import { PublicKeyFetchMethod, PublicKeyEncoding, PublicKeyType } from './enums.js'
import { Persona } from './persona.js'
/** /**
* A profile of personas with identity claims * A profile of personas with identity claims
* @function * @function
* @param {Array<Persona>} personas * @param {Array<import('./persona.js').Persona>} personas
* @public * @public
* @example * @example
* const claim = Claim('https://alice.tld', '123'); * const claim = Claim('https://alice.tld', '123');
@ -30,21 +29,150 @@ export class Profile {
/** /**
* Create a new profile * Create a new profile
* @function * @function
* @param {Array<Persona>} personas * @param {import('./enums.js').ProfileType} profileType
* @param {string} identifier
* @param {Array<import('./persona.js').Persona>} personas
* @public * @public
*/ */
constructor (personas) { constructor (profileType, identifier, personas) {
/**
* Profile version
* @type {number}
* @public
*/
this.profileVersion = 2
/**
* 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 * List of personas
* @type {Array<Persona>} * @type {Array<import('./persona.js').Persona>}
* @public * @public
*/ */
this.personas = personas || [] this.personas = personas || []
/** /**
* Index of primary persona (to be displayed first or prominently) * Index of primary persona (to be displayed first or prominently)
* @type {Number} * @type {number}
* @public * @public
*/ */
this.primaryPersona = -1 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
*/
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
}
} }
} }