mirror of
https://codeberg.org/keyoxide/doipjs.git
synced 2024-12-22 14:39:28 -07:00
feat: support ASPE
This commit is contained in:
parent
73ae4b296f
commit
067c35a82c
8 changed files with 351 additions and 1 deletions
19
examples/fetch-profile-aspe.js
Normal file
19
examples/fetch-profile-aspe.js
Normal 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
154
src/asp.js
Normal 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
|
|
@ -280,7 +280,7 @@ class Claim {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fail safe verification result
|
// Fail safe verification result
|
||||||
this._verification = this._verification
|
this._verification = Object.keys(this._verification).length > 0
|
||||||
? this._verification
|
? this._verification
|
||||||
: {
|
: {
|
||||||
result: false,
|
result: false,
|
||||||
|
|
|
@ -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
|
See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
const Profile = require('./profile')
|
||||||
|
const Persona = require('./persona')
|
||||||
const Claim = require('./claim')
|
const Claim = require('./claim')
|
||||||
const claimDefinitions = require('./claimDefinitions')
|
const claimDefinitions = require('./claimDefinitions')
|
||||||
const proofs = require('./proofs')
|
const proofs = require('./proofs')
|
||||||
const keys = require('./keys')
|
const keys = require('./keys')
|
||||||
|
const asp = require('./asp')
|
||||||
const signatures = require('./signatures')
|
const signatures = require('./signatures')
|
||||||
const enums = require('./enums')
|
const enums = require('./enums')
|
||||||
const defaults = require('./defaults')
|
const defaults = require('./defaults')
|
||||||
|
@ -24,10 +27,13 @@ const utils = require('./utils')
|
||||||
const verifications = require('./verifications')
|
const verifications = require('./verifications')
|
||||||
const fetcher = require('./fetcher')
|
const fetcher = require('./fetcher')
|
||||||
|
|
||||||
|
exports.Profile = Profile
|
||||||
|
exports.Persona = Persona
|
||||||
exports.Claim = Claim
|
exports.Claim = Claim
|
||||||
exports.claimDefinitions = claimDefinitions
|
exports.claimDefinitions = claimDefinitions
|
||||||
exports.proofs = proofs
|
exports.proofs = proofs
|
||||||
exports.keys = keys
|
exports.keys = keys
|
||||||
|
exports.asp = asp
|
||||||
exports.signatures = signatures
|
exports.signatures = signatures
|
||||||
exports.enums = enums
|
exports.enums = enums
|
||||||
exports.defaults = defaults
|
exports.defaults = defaults
|
||||||
|
|
56
src/persona.js
Normal file
56
src/persona.js
Normal 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
52
src/profile.js
Normal 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
|
|
@ -58,6 +58,9 @@ const generateProxyURL = (type, data, opts) => {
|
||||||
const generateClaim = (fingerprint, format) => {
|
const generateClaim = (fingerprint, format) => {
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case E.ClaimFormat.URI:
|
case E.ClaimFormat.URI:
|
||||||
|
if (fingerprint.match(/^(openpgp4fpr|aspe):/)) {
|
||||||
|
return fingerprint
|
||||||
|
}
|
||||||
return `openpgp4fpr:${fingerprint}`
|
return `openpgp4fpr:${fingerprint}`
|
||||||
case E.ClaimFormat.FINGERPRINT:
|
case E.ClaimFormat.FINGERPRINT:
|
||||||
return fingerprint
|
return fingerprint
|
||||||
|
|
60
test/asp.test.js
Normal file
60
test/asp.test.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in a new issue