Browse Source

add QR scanner for adding a contact

qr-reader
Trent Larson 1 year ago
parent
commit
9bdd0743a3
  1. 54144
      package-lock.json
  2. 1
      package.json
  3. 3
      project.task.yaml
  4. 14
      src/constants/app.ts
  5. 2
      src/db/tables/contacts.ts
  6. 25
      src/libs/crypto/index.ts
  7. 5
      src/libs/endorserServer.ts
  8. 148
      src/libs/veramo/setup.ts
  9. 2
      src/test/index.ts
  10. 52
      src/views/ContactQRScanShowView.vue
  11. 105
      src/views/ContactsView.vue

54144
package-lock.json

File diff suppressed because it is too large

1
package.json

@ -52,6 +52,7 @@
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-axios": "^3.5.2", "vue-axios": "^3.5.2",
"vue-facing-decorator": "^2.1.20", "vue-facing-decorator": "^2.1.20",
"vue-qrcode-reader": "^5.3.4",
"vue-router": "^4.2.3", "vue-router": "^4.2.3",
"web-did-resolver": "^2.0.27" "web-did-resolver": "^2.0.27"
}, },

3
project.task.yaml

@ -8,6 +8,7 @@ tasks:
- 40 notifications : - 40 notifications :
- push, where we trigger a ServiceWorker(?) in the app to reach out and check for new data assignee:matthew - push, where we trigger a ServiceWorker(?) in the app to reach out and check for new data assignee:matthew
- .2 Rename repo to crowd-sourcing...
- 01 Replace Gifted/Give in ContactsView with GiftedDialog assignee:matthew - 01 Replace Gifted/Give in ContactsView with GiftedDialog assignee:matthew
- 01 fix the Discovery map display to not show on top of bottom icons (and any other UI tweaks on the map flow) assignee-group:ui - 01 fix the Discovery map display to not show on top of bottom icons (and any other UI tweaks on the map flow) assignee-group:ui
@ -27,6 +28,7 @@ tasks:
- 24 Move to Vite assignee:matthew - 24 Move to Vite assignee:matthew
- .5 Allow edit of a contact name (but not the DID)
- .2 Edit Plan does not have icons across the bottom assignee-group:ui - .2 Edit Plan does not have icons across the bottom assignee-group:ui
- .5 include the hash of the latest commit, and maybe a version - .5 include the hash of the latest commit, and maybe a version
- .5 add link to further project / people when a project pays ahead - .5 add link to further project / people when a project pays ahead
@ -38,6 +40,7 @@ tasks:
- .2 fix rate limit verbiage (with the new one-per-day allowance) assignee:trent - .2 fix rate limit verbiage (with the new one-per-day allowance) assignee:trent
- .1 remove the logic to exclude beforeId in list of plans after server has commit 26b25af605e715600d4f12b6416ed9fd7142d164 - .1 remove the logic to exclude beforeId in list of plans after server has commit 26b25af605e715600d4f12b6416ed9fd7142d164
- .2 in SeedBackupView, don't load the mnemonic and keep it in memory; only load it when they click "show" - .2 in SeedBackupView, don't load the mnemonic and keep it in memory; only load it when they click "show"
- .1 change default server in app.ts
- Discuss whether the remaining tasks are worthwhile before MVP release. - Discuss whether the remaining tasks are worthwhile before MVP release.

14
src/constants/app.ts

@ -1,8 +1,10 @@
/** /**
* Generic strings that could be used throughout the app. * Generic strings that could be used throughout the app.
*
* See also ../libs/veramo/setup.ts
*/ */
export enum AppString { export enum AppString {
APP_NAME = "Kick-Start with Time", APP_NAME = "Time Safari",
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch", PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch", TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
@ -10,3 +12,13 @@ export enum AppString {
DEFAULT_ENDORSER_API_SERVER = TEST_ENDORSER_API_SERVER, DEFAULT_ENDORSER_API_SERVER = TEST_ENDORSER_API_SERVER,
} }
/**
* See notiwind package
*/
export interface NotificationIface {
group: string;
type: string; // "toast" | "info" | "success" | "warning" | "danger"
title: string;
text: string;
}

2
src/db/tables/contacts.ts

@ -7,5 +7,5 @@ export interface Contact {
} }
export const ContactsSchema = { export const ContactsSchema = {
contacts: "++did, name, publicKeyBase64, registered, seesMe", contacts: "&did, name, publicKeyBase64, registered, seesMe",
}; };

25
src/libs/crypto/index.ts

@ -1,5 +1,4 @@
import { IIdentifier } from "@veramo/core"; import { IIdentifier } from "@veramo/core";
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
import { getRandomBytesSync } from "ethereum-cryptography/random"; import { getRandomBytesSync } from "ethereum-cryptography/random";
import { entropyToMnemonic } from "ethereum-cryptography/bip39"; import { entropyToMnemonic } from "ethereum-cryptography/bip39";
import { wordlist } from "ethereum-cryptography/bip39/wordlists/english"; import { wordlist } from "ethereum-cryptography/bip39/wordlists/english";
@ -7,6 +6,9 @@ import { HDNode } from "@ethersproject/hdnode";
import * as didJwt from "did-jwt"; import * as didJwt from "did-jwt";
import * as u8a from "uint8arrays"; import * as u8a from "uint8arrays";
import { ENDORSER_JWT_URL_LOCATION } from "@/libs/endorserServer";
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
export const DEFAULT_ROOT_DERIVATION_PATH = "m/76798669'/0'/0'/0'"; export const DEFAULT_ROOT_DERIVATION_PATH = "m/76798669'/0'/0'/0'";
/** /**
@ -150,3 +152,24 @@ export function fromJose(signature: string): {
export function bytesToHex(b: Uint8Array): string { export function bytesToHex(b: Uint8Array): string {
return u8a.toString(b, "base16"); return u8a.toString(b, "base16");
} }
/**
@return results of uportJwtPayload:
{ iat: number, iss: string (DID), own: { name, publicEncKey (base64-encoded key) } }
Note that similar code is also contained in time-safari
*/
export const getContactPayloadFromJwtUrl = (jwtUrlText: string) => {
let jwtText = jwtUrlText;
const endorserContextLoc = jwtText.indexOf(ENDORSER_JWT_URL_LOCATION);
if (endorserContextLoc > -1) {
jwtText = jwtText.substring(
endorserContextLoc + ENDORSER_JWT_URL_LOCATION.length,
);
}
// JWT format: { header, payload, signature, data }
const jwt = didJwt.decodeJWT(jwtText);
return jwt.payload;
};

5
src/libs/endorserServer.ts

@ -6,7 +6,12 @@ import { Axios, AxiosResponse } from "axios";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
export const SCHEMA_ORG_CONTEXT = "https://schema.org"; export const SCHEMA_ORG_CONTEXT = "https://schema.org";
// the object in RegisterAction claims
export const SERVICE_ID = "endorser.ch"; export const SERVICE_ID = "endorser.ch";
// the prefix for the contact URL
export const CONTACT_URL_PREFIX = "https://endorser.ch";
// the suffix for the contact URL
export const ENDORSER_JWT_URL_LOCATION = "/contact?jwt=";
export interface AgreeVerifiableCredential { export interface AgreeVerifiableCredential {
"@context": string; "@context": string;

148
src/libs/veramo/setup.ts

@ -1,151 +1,7 @@
// Created from the setup in https://veramo.io/docs/guides/react_native // see also ../constants/app.ts and
// Core interfaces
/* import {
createAgent,
IDIDManager,
IResolver,
IDataStore,
IKeyManager,
} from "@veramo/core";
*/
// Core identity manager plugin
//import { DIDManager } from "@veramo/did-manager";
// Ethr did identity provider
//import { EthrDIDProvider } from "@veramo/did-provider-ethr";
// Core key manager plugin
//import { KeyManager } from "@veramo/key-manager";
// Custom key management system for RN
//import { KeyManagementSystem } from '@veramo/kms-local-react-native'
// Custom resolver
// Custom resolvers
//import { DIDResolverPlugin } from "@veramo/did-resolver";
/* import { Resolver } from "did-resolver";
import { getResolver as ethrDidResolver } from "ethr-did-resolver";
import { getResolver as webDidResolver } from "web-did-resolver";
*/
// for VCs and VPs https://veramo.io/docs/api/credential-w3c
//import { CredentialIssuer } from '@veramo/credential-w3c'
// Storage plugin using TypeOrm
/* import {
Entities,
KeyStore,
DIDStore,
IDataStoreORM,
} from "@veramo/data-store";
*/
// TypeORM is installed with @veramo/typeorm
//import { createConnection } from 'typeorm'
//import * as R from "ramda";
/*
import { Contact } from '../entity/contact'
import { Settings } from '../entity/settings'
import { PrivateData } from '../entity/privateData'
import { Initial1616938713828 } from '../migration/1616938713828-initial'
import { SettingsContacts1616967972293 } from '../migration/1616967972293-settings-contacts'
import { EncryptedSeed1637856484788 } from '../migration/1637856484788-EncryptedSeed'
import { HomeScreenConfig1639947962124 } from '../migration/1639947962124-HomeScreenConfig'
import { HandlePublicKeys1652142819353 } from '../migration/1652142819353-HandlePublicKeys'
import { LastClaimsSeen1656811846836 } from '../migration/1656811846836-LastClaimsSeen'
import { ContactRegistered1662256903367 }from '../migration/1662256903367-ContactRegistered'
import { PrivateData1663080623479 } from '../migration/1663080623479-PrivateData'
const ALL_ENTITIES = Entities.concat([Contact, Settings, PrivateData])
// Create react native DB connection configured by ormconfig.js
export const dbConnection = createConnection({
database: 'endorser-mobile.sqlite',
entities: ALL_ENTITIES,
location: 'default',
logging: ['error', 'info', 'warn'],
migrations: [ Initial1616938713828, SettingsContacts1616967972293, EncryptedSeed1637856484788, HomeScreenConfig1639947962124, HandlePublicKeys1652142819353, LastClaimsSeen1656811846836, ContactRegistered1662256903367, PrivateData1663080623479 ],
migrationsRun: true,
type: 'react-native',
})
*/
function didProviderName(netName: string) { function didProviderName(netName: string) {
return "did:ethr" + (netName === "mainnet" ? "" : ":" + netName); return "did:ethr" + (netName === "mainnet" ? "" : ":" + netName);
} }
//const NETWORK_NAMES = ["mainnet", "rinkeby"]; export const DEFAULT_DID_PROVIDER_NAME = didProviderName("mainnet");
const DEFAULT_DID_PROVIDER_NETWORK_NAME = "mainnet";
export const DEFAULT_DID_PROVIDER_NAME = didProviderName(
DEFAULT_DID_PROVIDER_NETWORK_NAME,
);
export const HANDY_APP = false;
// this is used as the object in RegisterAction claims
export const SERVICE_ID = "endorser.ch";
//const INFURA_PROJECT_ID = "INFURA_PROJECT_ID";
/*
const providers = {}
NETWORK_NAMES.forEach((networkName) => {
providers[didProviderName(networkName)] = new EthrDIDProvider({
defaultKms: 'local',
network: networkName,
rpcUrl: 'https://' + networkName + '.infura.io/v3/' + INFURA_PROJECT_ID,
gas: 1000001,
ttl: 60 * 60 * 24 * 30 * 12 + 1,
})
})
const didManager = new DIDManager({
store: new DIDStore(dbConnection),
defaultProvider: DEFAULT_DID_PROVIDER_NAME,
providers: providers,
})
*/
/* const basicDidResolvers = NETWORK_NAMES.map((networkName) => [
networkName,
new Resolver({
ethr: ethrDidResolver({
networks: [
{
name: networkName,
rpcUrl:
"https://" + networkName + ".infura.io/v3/" + INFURA_PROJECT_ID,
},
],
}).ethr,
web: webDidResolver().web,
}),
]);
const basicResolverMap = R.fromPairs(basicDidResolvers)
export const DEFAULT_BASIC_RESOLVER = basicResolverMap[DEFAULT_DID_PROVIDER_NETWORK_NAME]
const agentDidResolvers = NETWORK_NAMES.map((networkName) => {
return new DIDResolverPlugin({
resolver: basicResolverMap[networkName],
})
})
let allPlugins = [
new CredentialIssuer(),
new KeyManager({
store: new KeyStore(dbConnection),
kms: {
local: new KeyManagementSystem(),
},
}),
didManager,
].concat(agentDidResolvers)
*/
//export const agent = createAgent<IDIDManager & IKeyManager & IDataStore & IDataStoreORM & IResolver>({ plugins: allPlugins })

2
src/test/index.ts

@ -2,7 +2,7 @@ import axios from "axios";
import * as didJwt from "did-jwt"; import * as didJwt from "did-jwt";
import { AppString } from "@/constants/app"; import { AppString } from "@/constants/app";
import { db } from "../db"; import { db } from "../db";
import { SERVICE_ID } from "../libs/veramo/setup"; import { SERVICE_ID } from "../libs/endorserServer";
import { deriveAddress, newIdentifier } from "../libs/crypto"; import { deriveAddress, newIdentifier } from "../libs/crypto";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";

52
src/views/ContactQRScanShowView.vue

@ -3,7 +3,7 @@
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24"> <section id="Content" class="p-6 pb-24">
<!-- Heading --> <!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> <h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
Your Contact Info Your Contact Info
</h1> </h1>
@ -17,12 +17,17 @@
:dotsOptions="{ type: 'square' }" :dotsOptions="{ type: 'square' }"
class="flex justify-center" class="flex justify-center"
/> />
<h1 class="text-4xl text-center font-light pt-4">Scan Contact Info</h1>
<qrcode-stream @detect="onScanDetect" @error="onScanError" />
</section> </section>
</template> </template>
<script lang="ts"> <script lang="ts">
import QRCodeVue3 from "qr-code-generator-vue3"; import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { QrcodeStream } from "vue-qrcode-reader";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import * as R from "ramda"; import * as R from "ramda";
@ -30,6 +35,10 @@ import { SimpleSigner } from "@/libs/crypto";
import * as didJwt from "did-jwt"; import * as didJwt from "did-jwt";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import { Account } from "@/db/tables/accounts"; import { Account } from "@/db/tables/accounts";
import {
CONTACT_URL_PREFIX,
ENDORSER_JWT_URL_LOCATION,
} from "@/libs/endorserServer";
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const Buffer = require("buffer/").Buffer; const Buffer = require("buffer/").Buffer;
@ -43,6 +52,7 @@ interface Notification {
@Component({ @Component({
components: { components: {
QrcodeStream,
QRCodeVue3, QRCodeVue3,
QuickNav, QuickNav,
}, },
@ -112,9 +122,47 @@ export default class ContactQRScanShow extends Vue {
issuer: identity.did, issuer: identity.did,
signer: signer, signer: signer,
}); });
const viewPrefix = "https://endorser.ch/contact?jwt="; const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION;
this.qrValue = viewPrefix + vcJwt; this.qrValue = viewPrefix + vcJwt;
} }
} }
/**
*
* @param content is the result of a QR scan, an array with one item with a rawValue property
*/
// Unfortunately, there are not typescript definitions for the qrcode-stream component yet.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onScanDetect(content: any) {
if (content[0]?.rawValue) {
console.log("onDetect", content[0].rawValue);
localStorage.setItem("contactEndorserUrl", content[0].rawValue);
this.$router.push({ name: "contacts" });
} else {
this.$notify(
{
group: "alert",
type: "warning",
title: "Invalid Contact QR Code",
text: "No QR code detected with contact information.",
},
-1,
);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onScanError(error: any) {
console.log("Scan was invalid:", error);
this.$notify(
{
group: "alert",
type: "warning",
title: "Invalid Scan",
text: "The scan was invalid.",
},
-1,
);
}
} }
</script> </script>

105
src/views/ContactsView.vue

@ -20,6 +20,11 @@
<!-- New Contact --> <!-- New Contact -->
<div class="mb-4 flex"> <div class="mb-4 flex">
<span class="self-center bg-slate-500 text-white px-1.5 py-1 rounded-md">
<router-link :to="{ name: 'contact-qr' }">
<fa icon="qrcode" class="fa-fw" />
</router-link>
</span>
<input <input
type="text" type="text"
placeholder="DID, Name, Public Key" placeholder="DID, Name, Public Key"
@ -211,11 +216,17 @@
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import * as didJwt from "did-jwt"; import * as didJwt from "did-jwt";
import * as R from "ramda"; import * as R from "ramda";
import { NotificationIface } from "@/constants/app";
import { IIdentifier } from "@veramo/core"; import { IIdentifier } from "@veramo/core";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } 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 { accessToken, SimpleSigner } from "@/libs/crypto"; import {
accessToken,
getContactPayloadFromJwtUrl,
SimpleSigner,
} from "@/libs/crypto";
import { import {
GiveServerRecord, GiveServerRecord,
GiveVerifiableCredential, GiveVerifiableCredential,
@ -229,22 +240,16 @@ import EntityIcon from "@/components/EntityIcon.vue";
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const Buffer = require("buffer/").Buffer; const Buffer = require("buffer/").Buffer;
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({ @Component({
components: { QuickNav, EntityIcon }, components: { QuickNav, EntityIcon },
}) })
export default class ContactsView extends Vue { export default class ContactsView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = ""; activeDid = "";
apiServer = ""; apiServer = "";
contacts: Array<Contact> = []; contacts: Array<Contact> = [];
contactEndorserUrl = localStorage.getItem("contactEndorserUrl") || "";
contactInput = ""; contactInput = "";
// { "did:...": concatenated-descriptions } entry for each contact // { "did:...": concatenated-descriptions } entry for each contact
givenByMeDescriptions: Record<string, string> = {}; givenByMeDescriptions: Record<string, string> = {};
@ -279,6 +284,12 @@ export default class ContactsView extends Vue {
(a: Contact, b) => (a.name || "").localeCompare(b.name || ""), (a: Contact, b) => (a.name || "").localeCompare(b.name || ""),
allContacts, allContacts,
); );
if (this.contactEndorserUrl) {
await this.newContactFromScan(this.contactEndorserUrl);
localStorage.removeItem("contactEndorserUrl");
this.contactEndorserUrl = "";
}
} }
public async getIdentity(activeDid: string) { public async getIdentity(activeDid: string) {
@ -414,6 +425,18 @@ export default class ContactsView extends Vue {
} }
async onClickNewContact(): Promise<void> { async onClickNewContact(): Promise<void> {
if (!this.contactInput) {
this.$notify(
{
group: "alert",
type: "warning",
title: "No Contact",
text: "There was no contact info to add.",
},
-1,
);
return;
}
let did = this.contactInput; let did = this.contactInput;
let name, publicKeyBase64; let name, publicKeyBase64;
const commaPos1 = this.contactInput.indexOf(","); const commaPos1 = this.contactInput.indexOf(",");
@ -432,12 +455,74 @@ export default class ContactsView extends Vue {
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64"); publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
} }
const newContact = { did, name, publicKeyBase64 }; const newContact = { did, name, publicKeyBase64 };
await db.contacts.add(newContact); return this.addContact(newContact);
}
async newContactFromScan(url: string): Promise<void> {
const payload = getContactPayloadFromJwtUrl(url);
if (!payload) {
this.$notify(
{
group: "alert",
type: "danger",
title: "No Contact Info",
text: "The contact info could not be parsed.",
},
-1,
);
return;
} else {
return this.addContact({
did: payload.iss,
name: payload.own.name,
publicKeyBase64: payload.own.publicEncKey,
} as Contact);
}
}
async addContact(newContact: Contact) {
if (!newContact.did) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Incomplete Contact",
text: "Cannot add a contact without a DID.",
},
-1,
);
return;
}
return db.contacts
.add(newContact)
.then(() => {
const allContacts = this.contacts.concat([newContact]); const allContacts = this.contacts.concat([newContact]);
this.contacts = R.sort( this.contacts = R.sort(
(a: Contact, b) => (a.name || "").localeCompare(b.name || ""), (a: Contact, b) => (a.name || "").localeCompare(b.name || ""),
allContacts, allContacts,
); );
this.$notify(
{
group: "alert",
type: "success",
title: "Contact added",
text: newContact.name + " was added.",
},
-1,
);
})
.catch((err) => {
console.error("Error when adding contact to storage:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Contact Not Added",
text: "An error prevented importing.",
},
-1,
);
});
} }
async deleteContact(contact: Contact) { async deleteContact(contact: Contact) {

Loading…
Cancel
Save