feat: support ASPE

This commit is contained in:
Yarmo Mackenbach 2023-07-03 10:39:23 +02:00
parent 73ae4b296f
commit 067c35a82c
8 changed files with 351 additions and 1 deletions

View file

@ -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()

154
src/asp.js Normal file
View file

@ -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<Profile>}
* @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<Profile>}
* @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<string>}
*/
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

View file

@ -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,

View file

@ -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

56
src/persona.js Normal file
View file

@ -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<Claim>}
* @public
*/
this.claims = claims
}
}
module.exports = Persona

52
src/profile.js Normal file
View file

@ -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<Persona>} 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<Persona>} personas
* @public
*/
constructor (personas) {
/**
* List of personas
* @type {Array<Persona>}
* @public
*/
this.personas = personas || []
/**
* Index of primary persona (to be displayed first or prominently)
* @type {Number}
* @public
*/
this.primaryPersona = -1
}
}
module.exports = Profile

View file

@ -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

60
test/asp.test.js Normal file
View file

@ -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)
})
})