Browse Source

add link directly into contact page to add a new contact via "contactJwt" query parameter

Trent Larson 2 months ago
parent
commit
2d316b67f9
  1. 7
      package-lock.json
  2. 1
      package.json
  3. 17
      src/App.vue
  4. 46
      src/libs/crypto/vc/did-eth-local-resolver.ts
  5. 84
      src/libs/crypto/vc/index.ts
  6. 16
      src/libs/crypto/vc/passkeyDidPeer.ts
  7. 11
      src/libs/crypto/vc/util.ts
  8. 19
      src/libs/endorserServer.ts
  9. 10
      src/views/ContactImportView.vue
  10. 43
      src/views/ContactQRScanShowView.vue
  11. 25
      src/views/ContactsView.vue
  12. 2
      src/views/NewEditProjectView.vue
  13. 1
      src/views/ShareMyContactInfoView.vue

7
package-lock.json

@ -37,6 +37,7 @@
"dexie": "^3.2.7", "dexie": "^3.2.7",
"dexie-export-import": "^4.1.1", "dexie-export-import": "^4.1.1",
"did-jwt": "^7.4.7", "did-jwt": "^7.4.7",
"did-resolver": "^4.1.0",
"ethereum-cryptography": "^2.1.3", "ethereum-cryptography": "^2.1.3",
"ethereumjs-util": "^7.1.5", "ethereumjs-util": "^7.1.5",
"jdenticon": "^3.2.0", "jdenticon": "^3.2.0",
@ -10954,7 +10955,8 @@
}, },
"node_modules/base64url": { "node_modules/base64url": {
"version": "3.0.1", "version": "3.0.1",
"license": "MIT", "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
"integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==",
"optional": true, "optional": true,
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@ -12422,7 +12424,8 @@
}, },
"node_modules/did-resolver": { "node_modules/did-resolver": {
"version": "4.1.0", "version": "4.1.0",
"license": "Apache-2.0" "resolved": "https://registry.npmjs.org/did-resolver/-/did-resolver-4.1.0.tgz",
"integrity": "sha512-S6fWHvCXkZg2IhS4RcVHxwuyVejPR7c+a4Go0xbQ9ps5kILa8viiYQgrM4gfTyeTjJ0ekgJH9gk/BawTpmkbZA=="
}, },
"node_modules/didyoumean": { "node_modules/didyoumean": {
"version": "1.2.2", "version": "1.2.2",

1
package.json

@ -41,6 +41,7 @@
"dexie": "^3.2.7", "dexie": "^3.2.7",
"dexie-export-import": "^4.1.1", "dexie-export-import": "^4.1.1",
"did-jwt": "^7.4.7", "did-jwt": "^7.4.7",
"did-resolver": "^4.1.0",
"ethereum-cryptography": "^2.1.3", "ethereum-cryptography": "^2.1.3",
"ethereumjs-util": "^7.1.5", "ethereumjs-util": "^7.1.5",
"jdenticon": "^3.2.0", "jdenticon": "^3.2.0",

17
src/App.vue

@ -379,6 +379,7 @@ import { Vue, Component } from "vue-facing-decorator";
import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app"; import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app";
import { retrieveSettingsForActiveAccount } from "@/db/index"; import { retrieveSettingsForActiveAccount } from "@/db/index";
import * as libsUtil from "@/libs/util"; import * as libsUtil from "@/libs/util";
import { urlBase64ToUint8Array } from "@/libs/crypto/vc/util";
interface ServiceWorkerMessage { interface ServiceWorkerMessage {
type: string; type: string;
@ -666,20 +667,6 @@ export default class App extends Vue {
}); });
} }
private urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/-/g, "+")
.replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
private subscribeToPush(): Promise<void> { private subscribeToPush(): Promise<void> {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
if (!("serviceWorker" in navigator && "PushManager" in window)) { if (!("serviceWorker" in navigator && "PushManager" in window)) {
@ -694,7 +681,7 @@ export default class App extends Vue {
return reject(new Error(errorMsg)); return reject(new Error(errorMsg));
} }
const applicationServerKey = this.urlBase64ToUint8Array(this.b64); const applicationServerKey = urlBase64ToUint8Array(this.b64);
const options: PushSubscriptionOptions = { const options: PushSubscriptionOptions = {
userVisibleOnly: true, userVisibleOnly: true,
applicationServerKey: applicationServerKey, applicationServerKey: applicationServerKey,

46
src/libs/crypto/vc/did-eth-local-resolver.ts

@ -0,0 +1,46 @@
/**
* This did:ethr resolver instructs the did-jwt machinery to use the
* EcdsaSecp256k1RecoveryMethod2020Uses verification method which adds the recovery bit to the
* signature to recover the DID's public key from a signature.
*
* This effectively hard codes the did:ethr DID resolver to use the address as the public key.
* @param did : string
* @returns {Promise<DIDResolutionResult>}
*
* Similar code resides in image-api
*/
export const didEthLocalResolver = async (did: string) => {
const didRegex = /^did:ethr:(0x[0-9a-fA-F]{40})$/;
const match = did.match(didRegex);
if (match) {
const address = match[1]; // Extract eth address: 0x...
const publicKeyHex = address; // Use the address directly as a public key placeholder
return {
didDocumentMetadata: {},
didResolutionMetadata: {
contentType: "application/did+ld+json",
},
didDocument: {
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/suites/secp256k1recovery-2020/v2",
],
id: did,
verificationMethod: [
{
id: `${did}#controller`,
type: "EcdsaSec256k1RecoveryMethod2020",
controller: did,
blockchainAccountId: "eip155:1:" + publicKeyHex,
},
],
authentication: [`${did}#controller`],
assertionMethod: [`${did}#controller`],
},
};
}
throw new Error(`Unsupported DID format: ${did}`);
};

84
src/libs/crypto/vc/index.ts

@ -6,14 +6,22 @@
* *
*/ */
import { Buffer } from "buffer/";
import * as didJwt from "did-jwt"; import * as didJwt from "did-jwt";
import { JWTVerified } from "did-jwt";
import { JWTDecoded } from "did-jwt/lib/JWT"; import { JWTDecoded } from "did-jwt/lib/JWT";
import { Resolver } from "did-resolver";
import { IIdentifier } from "@veramo/core"; import { IIdentifier } from "@veramo/core";
import * as u8a from "uint8arrays"; import * as u8a from "uint8arrays";
import { createDidPeerJwt } from "@/libs/crypto/vc/passkeyDidPeer"; import { didEthLocalResolver } from "./did-eth-local-resolver";
import { PEER_DID_PREFIX, verifyPeerSignature } from "./didPeer";
import { base64urlDecodeString, createDidPeerJwt } from "./passkeyDidPeer";
import { urlBase64ToUint8Array } from "./util";
export const ETHR_DID_PREFIX = "did:ethr:"; export const ETHR_DID_PREFIX = "did:ethr:";
export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED";
export const UNSUPPORTED_DID_METHOD_CODE = "UNSUPPORTED_DID_METHOD";
/** /**
* Meta info about a key * Meta info about a key
@ -33,6 +41,8 @@ export interface KeyMeta {
passkeyCredIdHex?: string; passkeyCredIdHex?: string;
} }
const resolver = new Resolver({ ethr: didEthLocalResolver });
/** /**
* Tell whether a key is from a passkey * Tell whether a key is from a passkey
* @param keyMeta contains info about the key, whose passkeyCredIdHex determines if the key is from a passkey * @param keyMeta contains info about the key, whose passkeyCredIdHex determines if the key is from a passkey
@ -107,6 +117,78 @@ function bytesToHex(b: Uint8Array): string {
return u8a.toString(b, "base16"); return u8a.toString(b, "base16");
} }
// We should be calling 'verify' in more places, showing warnings if it fails.
export function decodeEndorserJwt(jwt: string): JWTDecoded { export function decodeEndorserJwt(jwt: string): JWTDecoded {
return didJwt.decodeJWT(jwt); return didJwt.decodeJWT(jwt);
} }
// return Promise of at least { issuer, payload, verified boolean }
// ... and also if successfully verified by did-jwt (not JWANT): data, doc, signature, signer
export async function decodeAndVerifyJwt(
jwt: string,
): Promise<Omit<JWTVerified, "didResolutionResult" | "signer" | "jwt">> {
const pieces = jwt.split(".");
console.log("WTF decodeAndVerifyJwt", typeof jwt, jwt, pieces);
const header = JSON.parse(base64urlDecodeString(pieces[0]));
const payload = JSON.parse(base64urlDecodeString(pieces[1]));
console.log("WTF decodeAndVerifyJwt after", header, payload);
const issuerDid = payload.iss;
if (!issuerDid) {
return Promise.reject({
clientError: {
message: `Missing "iss" field in JWT.`,
},
});
}
if (issuerDid.startsWith(ETHR_DID_PREFIX)) {
try {
const verified = await didJwt.verifyJWT(jwt, { resolver });
return verified;
} catch (e: unknown) {
return Promise.reject({
clientError: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
message: `JWT failed verification: ` + e.toString(),
code: JWT_VERIFY_FAILED_CODE,
},
});
}
}
if (issuerDid.startsWith(PEER_DID_PREFIX) && header.typ === "JWANT") {
const verified = await verifyPeerSignature(
Buffer.from(payload),
issuerDid,
urlBase64ToUint8Array(pieces[2]),
);
if (!verified) {
return Promise.reject({
clientError: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
message: `JWT failed verification: ` + e.toString(),
code: JWT_VERIFY_FAILED_CODE,
},
});
} else {
return { issuer: issuerDid, payload: payload, verified: true };
}
}
if (issuerDid.startsWith(PEER_DID_PREFIX)) {
return Promise.reject({
clientError: {
message: `JWT with a PEER DID currently only supported with typ == JWANT. Contact us us for JWT suport since it should be straightforward.`,
},
});
}
return Promise.reject({
clientError: {
message: `Unsupported DID method ${issuerDid}`,
code: UNSUPPORTED_DID_METHOD_CODE,
},
});
}

16
src/libs/crypto/vc/passkeyDidPeer.ts

@ -470,8 +470,18 @@ ${pubKeyBuffer.toString("base64")}
return pem; return pem;
} }
// tried the base64url library but got an error using their Buffer
export function base64urlDecodeString(input: string) {
return atob(input.replace(/-/g, "+").replace(/_/g, "/"));
}
// tried the base64url library but got an error using their Buffer
export function base64urlEncodeString(input: string) {
return btoa(input).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
function base64urlDecode(input: string) { function base64urlDecodeArrayBuffer(input: string) {
input = input.replace(/-/g, "+").replace(/_/g, "/"); input = input.replace(/-/g, "+").replace(/_/g, "/");
const pad = input.length % 4 === 0 ? "" : "====".slice(input.length % 4); const pad = input.length % 4 === 0 ? "" : "====".slice(input.length % 4);
const str = atob(input + pad); const str = atob(input + pad);
@ -483,9 +493,9 @@ function base64urlDecode(input: string) {
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
function base64urlEncode(buffer: ArrayBuffer) { function base64urlEncodeArrayBuffer(buffer: ArrayBuffer) {
const str = String.fromCharCode(...new Uint8Array(buffer)); const str = String.fromCharCode(...new Uint8Array(buffer));
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); return base64urlEncodeString(str);
} }
// from @simplewebauthn/browser // from @simplewebauthn/browser

11
src/libs/crypto/vc/util.ts

@ -0,0 +1,11 @@
export function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}

19
src/libs/endorserServer.ts

@ -270,6 +270,14 @@ export interface ErrorResult extends ResultWithType {
export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult; export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult;
export interface UserInfo {
name: string;
publicEncKey: string;
registered: boolean;
profileImageUrl?: string;
nextPublicEncKeyHash?: string;
}
// This is used to check for hidden info. // This is used to check for hidden info.
// See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6 // See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6
const HIDDEN_DID = "did:none:HIDDEN"; const HIDDEN_DID = "did:none:HIDDEN";
@ -934,17 +942,12 @@ export async function generateEndorserJwtForAccount(
isRegistered?: boolean, isRegistered?: boolean,
name?: string, name?: string,
profileImageUrl?: string, profileImageUrl?: string,
// note that including the next key pushes QR codes to the next resolution smaller
includeNextKeyIfDerived?: boolean,
) { ) {
const publicKeyHex = account.publicKeyHex; const publicKeyHex = account.publicKeyHex;
const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64"); const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64");
interface UserInfo {
name: string;
publicEncKey: string;
registered: boolean;
profileImageUrl?: string;
nextPublicEncKeyHash?: string;
}
const contactInfo = { const contactInfo = {
iat: Date.now(), iat: Date.now(),
iss: account.did, iss: account.did,
@ -958,7 +961,7 @@ export async function generateEndorserJwtForAccount(
contactInfo.own.profileImageUrl = profileImageUrl; contactInfo.own.profileImageUrl = profileImageUrl;
} }
if (account?.mnemonic && account?.derivationPath) { if (includeNextKeyIfDerived && account?.mnemonic && account?.derivationPath) {
const newDerivPath = nextDerivationPath(account.derivationPath as string); const newDerivPath = nextDerivationPath(account.derivationPath as string);
const nextPublicHex = deriveAddress( const nextPublicHex = deriveAddress(
account.mnemonic as string, account.mnemonic as string,

10
src/views/ContactImportView.vue

@ -130,9 +130,7 @@ export default class ContactImportView extends Vue {
const importedContacts = const importedContacts =
((this.$route as Router).query["contacts"] as string) || "[]"; ((this.$route as Router).query["contacts"] as string) || "[]";
this.contactsImporting = JSON.parse(importedContacts); this.contactsImporting = JSON.parse(importedContacts);
this.contactsSelected = new Array(this.contactsImporting.length).fill( this.contactsSelected = new Array(this.contactsImporting.length).fill(true);
false,
);
await db.open(); await db.open();
const baseContacts = await db.contacts.toArray(); const baseContacts = await db.contacts.toArray();
@ -158,9 +156,9 @@ export default class ContactImportView extends Vue {
if (R.isEmpty(differences)) { if (R.isEmpty(differences)) {
this.sameCount++; this.sameCount++;
} }
} else {
// automatically import new data // don't automatically import previous data
this.contactsSelected[i] = true; this.contactsSelected[i] = false;
} }
} }
} }

43
src/views/ContactQRScanShowView.vue

@ -90,8 +90,6 @@
<script lang="ts"> <script lang="ts">
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { Buffer } from "buffer/";
import { sha256 } from "ethereum-cryptography/sha256.js";
import QRCodeVue3 from "qr-code-generator-vue3"; import QRCodeVue3 from "qr-code-generator-vue3";
import * as R from "ramda"; import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
@ -104,15 +102,8 @@ import { NotificationIface } from "@/constants/app";
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index"; import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { getContactPayloadFromJwtUrl } from "@/libs/crypto";
import { import {
deriveAddress,
getContactPayloadFromJwtUrl,
nextDerivationPath,
} from "@/libs/crypto";
import {
CONTACT_URL_PREFIX,
createEndorserJwtForDid,
ENDORSER_JWT_URL_LOCATION,
generateEndorserJwtForAccount, generateEndorserJwtForAccount,
isDid, isDid,
register, register,
@ -153,37 +144,6 @@ export default class ContactQRScanShow extends Vue {
const accounts = await accountsDB.accounts.toArray(); const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts); const account = R.find((acc) => acc.did === this.activeDid, accounts);
if (account) { if (account) {
const publicKeyHex = account.publicKeyHex;
const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64");
const contactInfo = {
iat: Date.now(),
iss: this.activeDid,
own: {
name:
(settings.firstName || "") +
(settings.lastName ? ` ${settings.lastName}` : ""), // lastName is deprecated, pre v 0.1.3
publicEncKey,
profileImageUrl: settings.profileImageUrl,
registered: settings.isRegistered,
},
};
if (account?.mnemonic && account?.derivationPath) {
const newDerivPath = nextDerivationPath(account.derivationPath);
const nextPublicHex = deriveAddress(account.mnemonic, newDerivPath)[2];
const nextPublicEncKey = Buffer.from(nextPublicHex, "hex");
const nextPublicEncKeyHash = sha256(nextPublicEncKey);
const nextPublicEncKeyHashBase64 =
Buffer.from(nextPublicEncKeyHash).toString("base64");
contactInfo.own.nextPublicEncKeyHash = nextPublicEncKeyHashBase64;
}
const vcJwt = await createEndorserJwtForDid(this.activeDid, contactInfo);
const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION;
viewPrefix + vcJwt;
const name = const name =
(settings.firstName || "") + (settings.firstName || "") +
(settings.lastName ? ` ${settings.lastName}` : ""); // lastName is deprecated, pre v 0.1.3 (settings.lastName ? ` ${settings.lastName}` : ""); // lastName is deprecated, pre v 0.1.3
@ -193,6 +153,7 @@ export default class ContactQRScanShow extends Vue {
!!settings.isRegistered, !!settings.isRegistered,
name, name,
settings.profileImageUrl, settings.profileImageUrl,
false,
); );
} }
} }

25
src/views/ContactsView.vue

@ -299,6 +299,7 @@ import {
} from "@/db/index"; } from "@/db/index";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
import { getContactPayloadFromJwtUrl } from "@/libs/crypto"; import { getContactPayloadFromJwtUrl } from "@/libs/crypto";
import { decodeEndorserJwt } from "@/libs/crypto/vc";
import { import {
CONTACT_CSV_HEADER, CONTACT_CSV_HEADER,
CONTACT_URL_PREFIX, CONTACT_URL_PREFIX,
@ -307,6 +308,7 @@ import {
isDid, isDid,
register, register,
setVisibilityUtil, setVisibilityUtil,
UserInfo,
} from "@/libs/endorserServer"; } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util"; import * as libsUtil from "@/libs/util";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
@ -374,6 +376,24 @@ export default class ContactsView extends Vue {
this.contacts = baseContacts.sort((a, b) => this.contacts = baseContacts.sort((a, b) =>
(a.name || "").localeCompare(b.name || ""), (a.name || "").localeCompare(b.name || ""),
); );
const importedContactJwt = (this.$route as Router).query[
"contactJwt"
] as string;
if (importedContactJwt) {
// really should fully verify
const { payload } = decodeEndorserJwt(importedContactJwt);
const userInfo = payload["own"] as UserInfo;
const newContact = {
did: payload["iss"],
name: userInfo.name,
nextPubKeyHashB64: userInfo.nextPublicEncKeyHash,
profileImageUrl: userInfo.profileImageUrl,
publicKeyBase64: userInfo.publicEncKey,
registered: userInfo.registered,
} as Contact;
this.addContact(newContact);
}
} }
private danger(message: string, title: string = "Error", timeout = 5000) { private danger(message: string, title: string = "Error", timeout = 5000) {
@ -891,7 +911,10 @@ export default class ContactsView extends Vue {
} }
return true; return true;
} else { } else {
console.error("Got strange result from setting visibility:", result); console.error(
"Got strange result from setting visibility. It can happen when setting visibility on oneself.",
result,
);
const message = const message =
(result.error as string) || "Could not set visibility on the server."; (result.error as string) || "Could not set visibility on the server.";
this.$notify( this.$notify(

2
src/views/NewEditProjectView.vue

@ -146,7 +146,7 @@
</div> </div>
<div <div
v-if="showGeneralAdvanced && includeLocation && false" v-if="showGeneralAdvanced && includeLocation"
class="items-center mb-4" class="items-center mb-4"
> >
<div class="flex"> <div class="flex">

1
src/views/ShareMyContactInfoView.vue

@ -77,6 +77,7 @@ export default class ShareMyContactInfoView extends Vue {
isRegistered, isRegistered,
givenName, givenName,
profileImageUrl, profileImageUrl,
true,
); );
useClipboard() useClipboard()
.copy(message) .copy(message)

Loading…
Cancel
Save