mirror of
https://codeberg.org/keyoxide/doipjs.git
synced 2024-12-22 06:29: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
|
||||
this._verification = this._verification
|
||||
this._verification = Object.keys(this._verification).length > 0
|
||||
? this._verification
|
||||
: {
|
||||
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
|
||||
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
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) => {
|
||||
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
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