Merge pull request 'Support OpenPGP/ASPE claims' (#66) from support-openpgp-aspe-claims into dev

Reviewed-on: https://codeberg.org/keyoxide/doipjs/pulls/66
This commit is contained in:
Yarmo Mackenbach 2024-01-23 17:53:19 +00:00
commit 70ee790f0a
8 changed files with 466 additions and 0 deletions

View file

@ -40,6 +40,8 @@ export const ProxyPolicy = {
export const Fetcher = { export const Fetcher = {
/** HTTP requests to ActivityPub */ /** HTTP requests to ActivityPub */
ACTIVITYPUB: 'activitypub', ACTIVITYPUB: 'activitypub',
/** ASPE HTTP requests */
ASPE: 'aspe',
/** DNS module from Node.js */ /** DNS module from Node.js */
DNS: 'dns', DNS: 'dns',
/** GraphQL over HTTP requests */ /** GraphQL over HTTP requests */
@ -50,6 +52,8 @@ export const Fetcher = {
IRC: 'irc', IRC: 'irc',
/** HTTP request to Matrix API */ /** HTTP request to Matrix API */
MATRIX: 'matrix', MATRIX: 'matrix',
/** HKP and WKS request for OpenPGP */
OPENPGP: 'openpgp',
/** HTTP request to Telegram API */ /** HTTP request to Telegram API */
TELEGRAM: 'telegram', TELEGRAM: 'telegram',
/** XMPP module from Node.js */ /** XMPP module from Node.js */
@ -197,3 +201,13 @@ export const PublicKeyFetchMethod = {
HTTP: 'http', HTTP: 'http',
NONE: 'none' NONE: 'none'
} }
/**
* Protocol to query OpenPGP public keys
* @readonly
* @enum {string}
*/
export const OpenPgpQueryProtocol = {
HKP: 'hkp',
WKD: 'wkd'
}

77
src/fetcher/aspe.js Normal file
View file

@ -0,0 +1,77 @@
/*
Copyright 2024 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.
*/
import axios from 'axios'
import isFQDN from 'validator/lib/isFQDN.js'
import { version } from '../constants.js'
import { parseProfileJws } from '../asp.js'
export const timeout = 5000
/**
* Execute a fetch request
* @function
* @async
* @param {object} data - Data used in the request
* @param {string} data.aspeUri - ASPE URI of the targeted profile
* @param {number} [data.fetcherTimeout] - Optional timeout for the fetcher
* @returns {Promise<object|string>}
*/
export async function fn (data, opts) {
let timeoutHandle
const timeoutPromise = new Promise((resolve, reject) => {
timeoutHandle = setTimeout(
() => reject(new Error('Request was timed out')),
data.fetcherTimeout ? data.fetcherTimeout : timeout
)
})
const fetchPromise = new Promise((resolve, reject) => {
const reURI = /^aspe:([a-zA-Z0-9.\-_]*):([a-zA-Z0-9]*)/
const match = data.aspeUri.match(reURI)
if (!data.aspeUri || !reURI.test(data.aspeUri) || !isFQDN(match[1])) {
reject(new Error('No valid ASPE URI provided'))
return
}
const url = `https://${match[1]}/.well-known/aspe/id/${match[2].toUpperCase()}`
axios.get(url, {
headers: {
Accept: 'application/asp+jwt',
'User-Agent': `doipjs/${version}`
},
validateStatus: (status) => status >= 200 && status < 400
})
.then(async res => await parseProfileJws(res.data, data.aspeUri))
.then(profile =>
profile.personas.flatMap(p => { return p.claims.map(c => c._uri) })
)
.then(res => {
resolve({
claims: res
})
})
.catch(e => {
reject(e)
})
})
return Promise.race([fetchPromise, timeoutPromise]).then((result) => {
clearTimeout(timeoutHandle)
return result
})
}

View file

@ -14,10 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export * as activitypub from './activitypub.js' export * as activitypub from './activitypub.js'
export * as aspe from './aspe.js'
export * as dns from './dns.js' export * as dns from './dns.js'
export * as graphql from './graphql.js' export * as graphql from './graphql.js'
export * as http from './http.js' export * as http from './http.js'
export * as irc from './irc.js' export * as irc from './irc.js'
export * as matrix from './matrix.js' export * as matrix from './matrix.js'
export * as openpgp from './openpgp.js'
export * as telegram from './telegram.js' export * as telegram from './telegram.js'
export * as xmpp from './xmpp.js' export * as xmpp from './xmpp.js'

View file

@ -14,7 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export * as activitypub from './activitypub.js' export * as activitypub from './activitypub.js'
export * as aspe from './aspe.js'
export * as graphql from './graphql.js' export * as graphql from './graphql.js'
export * as http from './http.js' export * as http from './http.js'
export * as matrix from './matrix.js' export * as matrix from './matrix.js'
export * as openpgp from './openpgp.js'
export * as telegram from './telegram.js' export * as telegram from './telegram.js'

111
src/fetcher/openpgp.js Normal file
View file

@ -0,0 +1,111 @@
/*
Copyright 2024 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.
*/
import axios from 'axios'
import { readKey } from 'openpgp'
import { OpenPgpQueryProtocol } from '../enums.js'
import { version } from '../constants.js'
import { parsePublicKey } from '../openpgp.js'
export const timeout = 5000
/**
* Execute a fetch request
* @function
* @async
* @param {object} data - Data used in the request
* @param {string} data.url - The URL pointing at targeted content
* @param {OpenPgpQueryProtocol} data.protocol - The protocol used to access the targeted content
* @param {number} [data.fetcherTimeout] - Optional timeout for the fetcher
* @returns {Promise<object|string>}
*/
export async function fn (data, opts) {
let timeoutHandle
const timeoutPromise = new Promise((resolve, reject) => {
timeoutHandle = setTimeout(
() => reject(new Error('Request was timed out')),
data.fetcherTimeout ? data.fetcherTimeout : timeout
)
})
const fetchPromise = new Promise((resolve, reject) => {
if (!data.url) {
reject(new Error('No valid URI provided'))
return
}
switch (data.protocol) {
case OpenPgpQueryProtocol.HKP:
axios.get(data.url, {
headers: {
Accept: 'application/pgp-keys',
'User-Agent': `doipjs/${version}`
},
validateStatus: (status) => status >= 200 && status < 400
})
.then(res => res.data)
.then(async data => await readKey({ armoredKey: data }))
.then(async publicKey => await parsePublicKey(publicKey))
.then(profile =>
profile.personas.flatMap(p => { return p.claims.map(c => c._uri) })
)
.then(res => {
resolve({
notations: {
'proof@ariadne.id': res
}
})
})
.catch(e => {
reject(e)
})
break
case OpenPgpQueryProtocol.WKD:
axios.get(data.url, {
headers: {
Accept: 'application/octet-stream',
'User-Agent': `doipjs/${version}`
},
responseType: 'arraybuffer',
validateStatus: (status) => status >= 200 && status < 400
})
.then(res => res.data)
.then(async data => await readKey({ binaryKey: data }))
.then(async publicKey => await parsePublicKey(publicKey))
.then(profile =>
profile.personas.flatMap(p => { return p.claims.map(c => c._uri) })
)
.then(res => {
resolve({
notations: {
'proof@ariadne.id': res
}
})
})
.catch(e => {
reject(e)
})
break
default:
reject(new Error('Unsupported OpenPGP query protocol'))
break
}
})
return Promise.race([fetchPromise, timeoutPromise]).then((result) => {
clearTimeout(timeoutHandle)
return result
})
}

View file

@ -0,0 +1,86 @@
/*
Copyright 2024 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.
*/
import isFQDN from 'validator/lib/isFQDN.js'
import * as E from '../enums.js'
import { ServiceProvider } from '../serviceProvider.js'
export const reURI = /^aspe:([a-zA-Z0-9.\-_]*):([a-zA-Z0-9]*)/
/**
* @function
* @param {string} uri
*/
export function processURI (uri) {
const match = uri.match(reURI)
if (!isFQDN(match[1])) {
return null
}
return new ServiceProvider({
about: {
id: 'aspe',
name: 'ASPE'
},
profile: {
display: match[1],
uri,
qr: null
},
claim: {
uriRegularExpression: reURI.toString(),
uriIsAmbiguous: false
},
proof: {
request: {
uri: null,
fetcher: E.Fetcher.ASPE,
accessRestriction: E.ProofAccessRestriction.NONE,
data: {
aspeUri: uri
}
},
response: {
format: E.ProofFormat.JSON
},
target: [{
format: E.ClaimFormat.URI,
encoding: E.EntityEncodingFormat.PLAIN,
relation: E.ClaimRelation.CONTAINS,
path: ['claims']
}]
}
})
}
export const tests = [
{
uri: 'aspe:domain.tld:abc123def456',
shouldMatch: true
},
{
uri: 'aspe:domain.tld',
shouldMatch: false
},
{
uri: 'dns:domain.tld',
shouldMatch: false
},
{
uri: 'https://domain.tld',
shouldMatch: false
}
]

View file

@ -13,6 +13,8 @@ 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.
*/ */
import * as aspe from './aspe.js'
import * as openpgp from './openpgp.js'
import * as dns from './dns.js' import * as dns from './dns.js'
import * as irc from './irc.js' import * as irc from './irc.js'
import * as xmpp from './xmpp.js' import * as xmpp from './xmpp.js'
@ -37,6 +39,8 @@ import * as keybase from './keybase.js'
import * as opencollective from './opencollective.js' import * as opencollective from './opencollective.js'
const _data = { const _data = {
aspe,
openpgp,
dns, dns,
irc, irc,
xmpp, xmpp,

View file

@ -0,0 +1,170 @@
/*
Copyright 2024 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.
*/
import * as E from '../enums.js'
import { ServiceProvider } from '../serviceProvider.js'
export const reURI = /^(.*)/
/**
* @function
* @param {string} uri
*/
export function processURI (uri) {
const reURIHkp = /^openpgp4fpr:(?:0x)?([a-zA-Z0-9.\-_]*)/
const reURIWkdDirect = /^https:\/\/(.*)\/.well-known\/openpgpkey\/hu\/([a-zA-Z0-9]*)(?:\?l=(.*))?/
const reURIWkdAdvanced = /^https:\/\/(openpgpkey.*)\/.well-known\/openpgpkey\/(.*)\/hu\/([a-zA-Z0-9]*)(?:\?l=(.*))?/
let reURI = null
let mode = null
let match = null
if (reURIHkp.test(uri)) {
reURI = reURIHkp
mode = E.OpenPgpQueryProtocol.HKP
match = uri.match(reURI)
}
if (!mode && reURIWkdAdvanced.test(uri)) {
reURI = reURIWkdAdvanced
mode = E.OpenPgpQueryProtocol.WKD
match = uri.match(reURI)
}
if (!mode && reURIWkdDirect.test(uri)) {
reURI = reURIWkdDirect
mode = E.OpenPgpQueryProtocol.WKD
match = uri.match(reURI)
}
let output = null
switch (mode) {
case E.OpenPgpQueryProtocol.HKP:
output = new ServiceProvider({
about: {
id: 'openpgp',
name: 'OpenPGP'
},
profile: {
display: `openpgp4fpr:${match[1]}`,
uri: `https://keys.openpgp.org/search?q=${match[1]}`,
qr: null
},
claim: {
uriRegularExpression: reURI.toString(),
uriIsAmbiguous: false
},
proof: {
request: {
uri: `https://keys.openpgp.org/vks/v1/by-fingerprint/${match[1].toUpperCase()}`,
fetcher: E.Fetcher.OPENPGP,
accessRestriction: E.ProofAccessRestriction.NONE,
data: {
url: `https://keys.openpgp.org/vks/v1/by-fingerprint/${match[1].toUpperCase()}`,
protocol: E.OpenPgpQueryProtocol.HKP
}
},
response: {
format: E.ProofFormat.JSON
},
target: [{
format: E.ClaimFormat.URI,
encoding: E.EntityEncodingFormat.PLAIN,
relation: E.ClaimRelation.EQUALS,
path: ['notations', 'proof@ariadne.id']
}]
}
})
break
case E.OpenPgpQueryProtocol.WKD:
output = new ServiceProvider({
about: {
id: 'openpgp',
name: 'OpenPGP'
},
profile: {
display: 'unknown fingerprint',
uri,
qr: null
},
claim: {
uriRegularExpression: reURI.toString(),
uriIsAmbiguous: false
},
proof: {
request: {
uri,
fetcher: E.Fetcher.OPENPGP,
accessRestriction: E.ProofAccessRestriction.NONE,
data: {
url: uri,
protocol: E.OpenPgpQueryProtocol.WKD
}
},
response: {
format: E.ProofFormat.JSON
},
target: [{
format: E.ClaimFormat.URI,
encoding: E.EntityEncodingFormat.PLAIN,
relation: E.ClaimRelation.EQUALS,
path: ['notations', 'proof@ariadne.id']
}]
}
})
break
}
return output
}
export const tests = [
{
uri: 'openpgp4fpr:123456789',
shouldMatch: true
},
{
uri: 'openpgp4fpr:abcdef123',
shouldMatch: true
},
{
uri: 'https://openpgpkey.domain.tld/.well-known/openpgpkey/domain.tld/hu/123abc456def?l=name',
shouldMatch: true
},
{
uri: 'https://openpgpkey.domain.tld/.well-known/openpgpkey/domain.tld/hu/123abc456def',
shouldMatch: true
},
{
uri: 'https://domain.tld/.well-known/openpgpkey/hu/123abc456def?l=name',
shouldMatch: true
},
{
uri: 'https://domain.tld/.well-known/openpgpkey/hu/123abc456def',
shouldMatch: true
},
// The following will not pass .processURI, but reURI currently accepts anything
{
uri: 'https://domain.tld',
shouldMatch: true
},
{
uri: 'https://openpgpkey.domain.tld/.well-known/openpgpkey/hu/123abc456def?l=name',
shouldMatch: true
},
{
uri: 'https://domain.tld/.well-known/openpgpkey/123abc456def?l=name',
shouldMatch: true
}
]