Browse Source

move low-level DID-related create & decode into separate folder

pull/120/head
Trent Larson 4 months ago
parent
commit
cca9ec8009
  1. 2
      README.md
  2. 3
      src/constants/app.ts
  3. 55
      src/libs/crypto/index.ts
  4. 106
      src/libs/crypto/vc/index.ts
  5. 5
      src/libs/crypto/vc/passkeyDidPeer.ts
  6. 0
      src/libs/crypto/vc/passkeyHelpers.ts
  7. 37
      src/libs/endorserServer.ts
  8. 7
      src/libs/util.ts
  9. 6
      src/views/ContactQRScanShowView.vue
  10. 32
      src/views/HomeView.vue
  11. 21
      src/views/TestView.vue

2
README.md

@ -47,7 +47,7 @@ npm run lint
``` ```
# (Let's replace this with a .env.development or .env.staging file.) # (Let's replace this with a .env.development or .env.staging file.)
# The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there. # The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there.
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app npm run build TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app PASSKEYS_ENABLED=yep npm run build
``` ```
* Production * Production

3
src/constants/app.ts

@ -36,6 +36,9 @@ export const DEFAULT_PUSH_SERVER =
export const IMAGE_TYPE_PROFILE = "profile"; export const IMAGE_TYPE_PROFILE = "profile";
export const PASSKEYS_ENABLED =
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
/** /**
* The possible values for "group" and "type" are in App.vue. * The possible values for "group" and "type" are in App.vue.
* From the notiwind package * From the notiwind package

55
src/libs/crypto/index.ts

@ -3,14 +3,13 @@ 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";
import { HDNode } from "@ethersproject/hdnode"; import { HDNode } from "@ethersproject/hdnode";
import * as didJwt from "did-jwt";
import * as u8a from "uint8arrays";
import { import {
createEndorserJwt, createEndorserJwtForDid,
ENDORSER_JWT_URL_LOCATION, ENDORSER_JWT_URL_LOCATION,
} from "@/libs/endorserServer"; } from "@/libs/endorserServer";
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup"; import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
import { decodeEndorserJwt } from "@/libs/crypto/vc";
export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'"; export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'";
@ -95,58 +94,12 @@ export const accessToken = async (did?: string) => {
const nowEpoch = Math.floor(Date.now() / 1000); const nowEpoch = Math.floor(Date.now() / 1000);
const endEpoch = nowEpoch + 60; // add one minute const endEpoch = nowEpoch + 60; // add one minute
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did }; const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
return createEndorserJwt(did, tokenPayload); return createEndorserJwtForDid(did, tokenPayload);
} else { } else {
return ""; return "";
} }
}; };
/**
* Copied out of did-jwt since it's deprecated in that library.
*
* The SimpleSigner returns a configured function for signing data.
*
* @example
* const signer = SimpleSigner(import.meta.env.PRIVATE_KEY)
* signer(data, (err, signature) => {
* ...
* })
*
* @param {String} hexPrivateKey a hex encoded private key
* @return {Function} a configured signer function
*/
export function SimpleSigner(hexPrivateKey: string): didJwt.Signer {
const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true);
return async (data) => {
const signature = (await signer(data)) as string;
return fromJose(signature);
};
}
// from did-jwt/util; see SimpleSigner above
export function fromJose(signature: string): {
r: string;
s: string;
recoveryParam?: number;
} {
const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature);
if (signatureBytes.length < 64 || signatureBytes.length > 65) {
throw new TypeError(
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`,
);
}
const r = bytesToHex(signatureBytes.slice(0, 32));
const s = bytesToHex(signatureBytes.slice(32, 64));
const recoveryParam =
signatureBytes.length === 65 ? signatureBytes[64] : undefined;
return { r, s, recoveryParam };
}
// from did-jwt/util; see SimpleSigner above
export function bytesToHex(b: Uint8Array): string {
return u8a.toString(b, "base16");
}
/** /**
@return results of uportJwtPayload: @return results of uportJwtPayload:
{ iat: number, iss: string (DID), own: { name, publicEncKey (base64-encoded key) } } { iat: number, iss: string (DID), own: { name, publicEncKey (base64-encoded key) } }
@ -163,7 +116,7 @@ export const getContactPayloadFromJwtUrl = (jwtUrlText: string) => {
} }
// JWT format: { header, payload, signature, data } // JWT format: { header, payload, signature, data }
const jwt = didJwt.decodeJWT(jwtText); const jwt = decodeEndorserJwt(jwtText);
return jwt.payload; return jwt.payload;
}; };

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

@ -0,0 +1,106 @@
/**
* Verifiable Credential & DID functions, specifically for EndorserSearch.org tools
*/
import * as didJwt from "did-jwt";
import { IIdentifier } from "@veramo/core";
import * as u8a from "uint8arrays";
import { createDidPeerJwt } from "@/libs/crypto/vc/passkeyDidPeer";
import {JWTDecoded} from "did-jwt/lib/JWT";
/**
* Meta info about a key
*/
export interface KeyMeta {
/**
* Decentralized ID for the key
*/
did: string;
/**
* Stringified IIDentifier object from Veramo
*/
identity?: string;
/**
* The Webauthn credential ID in hex, if this is from a passkey
*/
passkeyCredIdHex?: string;
}
/**
* 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
*/
export function isFromPasskey(keyMeta?: KeyMeta): boolean {
return !!keyMeta?.passkeyCredIdHex;
}
export async function createEndorserJwtForKey(
account: KeyMeta,
payload: object,
) {
if (account?.identity) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const identity: IIdentifier = JSON.parse(account.identity!);
const privateKeyHex = identity.keys[0].privateKeyHex;
const signer = await SimpleSigner(privateKeyHex as string);
return didJwt.createJWT(payload, {
issuer: account.did,
signer: signer,
});
} else if (account?.passkeyCredIdHex) {
return createDidPeerJwt(account.did, account.passkeyCredIdHex, payload);
} else {
throw new Error("No identity data found to sign for DID " + account.did);
}
}
/**
* Copied out of did-jwt since it's deprecated in that library.
*
* The SimpleSigner returns a configured function for signing data.
*
* @example
* const signer = SimpleSigner(import.meta.env.PRIVATE_KEY)
* signer(data, (err, signature) => {
* ...
* })
*
* @param {String} hexPrivateKey a hex encoded private key
* @return {Function} a configured signer function
*/
function SimpleSigner(hexPrivateKey: string): didJwt.Signer {
const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true);
return async (data) => {
const signature = (await signer(data)) as string;
return fromJose(signature);
};
}
// from did-jwt/util; see SimpleSigner above
function fromJose(signature: string): {
r: string;
s: string;
recoveryParam?: number;
} {
const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature);
if (signatureBytes.length < 64 || signatureBytes.length > 65) {
throw new TypeError(
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`,
);
}
const r = bytesToHex(signatureBytes.slice(0, 32));
const s = bytesToHex(signatureBytes.slice(32, 64));
const recoveryParam =
signatureBytes.length === 65 ? signatureBytes[64] : undefined;
return {r, s, recoveryParam};
}
// from did-jwt/util; see SimpleSigner above
function bytesToHex(b: Uint8Array): string {
return u8a.toString(b, "base16");
}
export function decodeEndorserJwt(jwt: string): JWTDecoded {
return didJwt.decodeJWT(jwt);
}

5
src/libs/didPeer.ts → src/libs/crypto/vc/passkeyDidPeer.ts

@ -21,7 +21,10 @@ import {
} from "@simplewebauthn/types"; } from "@simplewebauthn/types";
import { AppString } from "@/constants/app"; import { AppString } from "@/constants/app";
import { getWebCrypto, unwrapEC2Signature } from "@/libs/crypto/passkeyHelpers"; import {
getWebCrypto,
unwrapEC2Signature,
} from "@/libs/crypto/vc/passkeyHelpers";
const PEER_DID_PREFIX = "did:peer:"; const PEER_DID_PREFIX = "did:peer:";
const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0"; const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0";

0
src/libs/crypto/passkeyHelpers.ts → src/libs/crypto/vc/passkeyHelpers.ts

37
src/libs/endorserServer.ts

@ -1,14 +1,13 @@
import { Axios, AxiosRequestConfig, AxiosResponse } from "axios"; import { Axios, AxiosRequestConfig, AxiosResponse } from "axios";
import * as didJwt from "did-jwt";
import { LRUCache } from "lru-cache"; import { LRUCache } from "lru-cache";
import * as R from "ramda"; import * as R from "ramda";
import { DEFAULT_IMAGE_API_SERVER } from "@/constants/app"; import { DEFAULT_IMAGE_API_SERVER } from "@/constants/app";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
import { accessToken, SimpleSigner } from "@/libs/crypto"; import { accessToken } from "@/libs/crypto";
import { NonsensitiveDexie } from "@/db/index"; import { NonsensitiveDexie } from "@/db/index";
import { createDidPeerJwt } from "@/libs/didPeer";
import { getAccount, getIdentity } from "@/libs/util"; import { getAccount, getIdentity } from "@/libs/util";
import { createEndorserJwtForKey, KeyMeta } from "@/libs/crypto/vc";
export const SCHEMA_ORG_CONTEXT = "https://schema.org"; export const SCHEMA_ORG_CONTEXT = "https://schema.org";
// the object in RegisterAction claims // the object in RegisterAction claims
@ -692,7 +691,7 @@ export async function createAndSubmitClaim(
}, },
}; };
const vcJwt: string = await createEndorserJwt(issuerDid, vcPayload); const vcJwt: string = await createEndorserJwtForDid(issuerDid, vcPayload);
// Make the xhr request payload // Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt }); const payload = JSON.stringify({ jwtEncoded: vcJwt });
@ -722,6 +721,14 @@ export async function createAndSubmitClaim(
} }
} }
export async function createEndorserJwtForDid(
issuerDid: string,
payload: object,
) {
const account = await getAccount(issuerDid);
return createEndorserJwtForKey(account as KeyMeta, payload);
}
/** /**
* An AcceptAction is when someone accepts some contract or pledge. * An AcceptAction is when someone accepts some contract or pledge.
* *
@ -937,25 +944,7 @@ export async function createEndorserJwtVcFromClaim(
credentialSubject: claim, credentialSubject: claim,
}, },
}; };
return createEndorserJwt(issuerDid, vcPayload); return createEndorserJwtForDid(issuerDid, vcPayload);
}
export async function createEndorserJwt(issuerDid: string, payload: object) {
const account = await getAccount(issuerDid);
if (account?.identity) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const identity = JSON.parse(account.identity!);
const privateKeyHex = identity.keys[0].privateKeyHex;
const signer = await SimpleSigner(privateKeyHex);
return didJwt.createJWT(payload, {
issuer: issuerDid,
signer: signer,
});
} else if (account?.passkeyCredIdHex) {
return createDidPeerJwt(issuerDid, account.passkeyCredIdHex, payload);
} else {
throw new Error("No identity data found to sign for DID " + issuerDid);
}
} }
export async function register( export async function register(
@ -980,7 +969,7 @@ export async function register(
}, },
}; };
// Create a signature using private key of identity // Create a signature using private key of identity
const vcJwt = await createEndorserJwt(activeDid, vcPayload); const vcJwt = await createEndorserJwtForDid(activeDid, vcPayload);
const url = apiServer + "/api/v2/claim"; const url = apiServer + "/api/v2/claim";
const resp = await axios.post(url, { jwtEncoded: vcJwt }); const resp = await axios.post(url, { jwtEncoded: vcJwt });

7
src/libs/util.ts

@ -11,9 +11,10 @@ import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto"; import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto";
import { GenericCredWrapper, containsHiddenDid } from "@/libs/endorserServer"; import { GenericCredWrapper, containsHiddenDid } from "@/libs/endorserServer";
import * as serverUtil from "@/libs/endorserServer"; import * as serverUtil from "@/libs/endorserServer";
import { createPeerDid, registerCredential } from "@/libs/didPeer"; import { createPeerDid, registerCredential } from "@/libs/crypto/vc/passkeyDidPeer";
import { Buffer } from "buffer"; import { Buffer } from "buffer";
import {KeyMeta} from "@/libs/crypto/vc";
export const PRIVACY_MESSAGE = export const PRIVACY_MESSAGE =
"The data you send will be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to them and others you explicitly allow."; "The data you send will be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to them and others you explicitly allow.";
@ -196,9 +197,11 @@ export function findAllVisibleToDids(
* *
**/ **/
export interface AccountKeyInfo extends Account, KeyMeta {}
export const getAccount = async ( export const getAccount = async (
activeDid: string, activeDid: string,
): Promise<Account | undefined> => { ): Promise<AccountKeyInfo | undefined> => {
await accountsDB.open(); await accountsDB.open();
const account = (await accountsDB.accounts const account = (await accountsDB.accounts
.where("did") .where("did")

6
src/views/ContactQRScanShowView.vue

@ -84,7 +84,6 @@ import { useClipboard } from "@vueuse/core";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts";
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 { import {
@ -93,8 +92,7 @@ import {
nextDerivationPath, nextDerivationPath,
} from "@/libs/crypto"; } from "@/libs/crypto";
import { import {
CONTACT_URL_PREFIX, CONTACT_URL_PREFIX, createEndorserJwtForDid,
createEndorserJwt,
ENDORSER_JWT_URL_LOCATION, ENDORSER_JWT_URL_LOCATION,
isDid, isDid,
register, register,
@ -161,7 +159,7 @@ export default class ContactQRScanShow extends Vue {
}, },
}; };
const vcJwt: string = await createEndorserJwt(identity.did, contactInfo); const vcJwt: string = await createEndorserJwtForDid(identity.did, contactInfo);
const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION; const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION;
this.qrValue = viewPrefix + vcJwt; this.qrValue = viewPrefix + vcJwt;
} }

32
src/views/HomeView.vue

@ -81,16 +81,29 @@
v-if="!activeDid" v-if="!activeDid"
class="bg-amber-200 rounded-md text-center px-4 py-3 mb-4" class="bg-amber-200 rounded-md text-center px-4 py-3 mb-4"
> >
<div v-if="PASSKEYS_ENABLED">
<p class="text-lg mb-3"> <p class="text-lg mb-3">
To recognize giving, have someone register you: Choose how to see info from your contacts or share contributions:
</p>
<div class="flex justify-between">
<button
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
@click="generateIdentifier()"
>
Let me start the easiest (with a passkey).
</button>
<router-link
:to="{ name: 'start' }"
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
>
Give me all the options.
</router-link>
</div>
</div>
<div v-else>
<p class="text-lg mb-3">
To recognize giving or collaborate, have someone register you:
</p> </p>
<div class="flex justify-center">
<!-- <button-->
<!-- class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"-->
<!-- @click="generateIdentifier()"-->
<!-- >-->
<!-- Let me start the easiest (with a passkey).-->
<!-- </button>-->
<router-link <router-link
:to="{ name: 'contact-qr' }" :to="{ name: 'contact-qr' }"
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md" class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
@ -327,7 +340,7 @@ import FeedFilters from "@/components/FeedFilters.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue"; import InfiniteScroll from "@/components/InfiniteScroll.vue";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue"; import TopMessage from "@/components/TopMessage.vue";
import { AppString, NotificationIface } from "@/constants/app"; import { AppString, NotificationIface, PASSKEYS_ENABLED } from "@/constants/app";
import { db, accountsDB } from "@/db/index"; import { db, accountsDB } from "@/db/index";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
import { import {
@ -383,6 +396,7 @@ export default class HomeView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
AppString = AppString; AppString = AppString;
PASSKEYS_ENABLED = PASSKEYS_ENABLED;
activeDid = ""; activeDid = "";
allContacts: Array<Contact> = []; allContacts: Array<Contact> = [];

21
src/views/TestView.vue

@ -246,14 +246,15 @@ import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import { AppString, NotificationIface } from "@/constants/app"; import { AppString, NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import * as vcLib from "@/libs/crypto/vc";
import { import {
PeerSetup, PeerSetup,
verifyJwtP256, verifyJwtP256,
verifyJwtSimplewebauthn, verifyJwtSimplewebauthn,
verifyJwtWebCrypto, verifyJwtWebCrypto,
} from "@/libs/didPeer"; } from "@/libs/crypto/vc/passkeyDidPeer";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import {AccountKeyInfo, getAccount, registerAndSavePasskey} from "@/libs/util";
import { registerAndSavePasskey } from "@/libs/util";
const inputFileNameRef = ref<Blob>(); const inputFileNameRef = ref<Blob>();
@ -360,6 +361,13 @@ export default class Help extends Vue {
} }
public async createJwtSimplewebauthn() { public async createJwtSimplewebauthn() {
const account: AccountKeyInfo | undefined = await getAccount(
this.activeDid || "",
);
if (!vcLib.isFromPasskey(account)) {
alert(`The DID ${this.activeDid} is not passkey-enabled.`);
return;
}
this.peerSetup = new PeerSetup(); this.peerSetup = new PeerSetup();
this.jwt = await this.peerSetup.createJwtSimplewebauthn( this.jwt = await this.peerSetup.createJwtSimplewebauthn(
this.activeDid as string, this.activeDid as string,
@ -370,6 +378,13 @@ export default class Help extends Vue {
} }
public async createJwtNavigator() { public async createJwtNavigator() {
const account: AccountKeyInfo | undefined = await getAccount(
this.activeDid || "",
);
if (!vcLib.isFromPasskey(account)) {
alert(`The DID ${this.activeDid} is not passkey-enabled.`);
return;
}
this.peerSetup = new PeerSetup(); this.peerSetup = new PeerSetup();
this.jwt = await this.peerSetup.createJwtNavigator( this.jwt = await this.peerSetup.createJwtNavigator(
this.activeDid as string, this.activeDid as string,

Loading…
Cancel
Save