Compare commits
29 Commits
c3a1571c2f
...
fcef84bc82
@ -0,0 +1,66 @@ |
|||||
|
# TimeSafari Docs |
||||
|
|
||||
|
## Generating PDF from Markdown on OSx |
||||
|
|
||||
|
This uses Pandoc and BasicTex (LaTeX) Installed through Homebrew. |
||||
|
|
||||
|
### Set Up |
||||
|
|
||||
|
```bash |
||||
|
# See https://daniel.feldroy.com/posts/setting-up-latex-on-mac-os-x |
||||
|
brew install pandoc |
||||
|
|
||||
|
brew install basictex |
||||
|
|
||||
|
pandoc keystore-migration.md -o keystore-migration.pdf |
||||
|
|
||||
|
# Setting up LaTex packages |
||||
|
|
||||
|
# First update tlmgr |
||||
|
sudo tlmgr update --self |
||||
|
|
||||
|
# Then install LaTex packages |
||||
|
sudo tlmgr install titlesec |
||||
|
sudo tlmgr install framed |
||||
|
sudo tlmgr install threeparttable |
||||
|
sudo tlmgr install wrapfig |
||||
|
sudo tlmgr install multirow |
||||
|
sudo tlmgr install enumitem |
||||
|
sudo tlmgr install bbding |
||||
|
sudo tlmgr install titling # Required for the fancy headers used |
||||
|
sudo tlmgr install tabu |
||||
|
sudo tlmgr install mdframed |
||||
|
sudo tlmgr install tcolorbox |
||||
|
sudo tlmgr install textpos |
||||
|
sudo tlmgr install import |
||||
|
sudo tlmgr install varwidth |
||||
|
sudo tlmgr install needspace |
||||
|
sudo tlmgr install tocloft # Required for \tableofcontents generation |
||||
|
sudo tlmgr install ntheorem |
||||
|
sudo tlmgr install environ |
||||
|
sudo tlmgr install trimspaces |
||||
|
sudo tlmgr install lastpage # Enables Page X of Y |
||||
|
sudo tlmgr install collection-fontsrecommended # And set up fonts |
||||
|
sudo tlmgr install libertine # The main font the doc uses |
||||
|
|
||||
|
|
||||
|
``` |
||||
|
|
||||
|
### Usage |
||||
|
|
||||
|
Use the `pandoc` command to generate a PDF. |
||||
|
|
||||
|
```bash |
||||
|
pandoc usage-guide.md -o usage-guide.pdf |
||||
|
``` |
||||
|
|
||||
|
And you can open the PDF with the `open` command. |
||||
|
|
||||
|
```bash |
||||
|
open usage-guide.pdf |
||||
|
``` |
||||
|
|
||||
|
Or use this one-liner |
||||
|
```bash |
||||
|
pandoc usage-guide.md -o usage-guide.pdf && open usage-guide.pdf |
||||
|
``` |
After Width: | Height: | Size: 61 KiB |
After Width: | Height: | Size: 40 KiB |
After Width: | Height: | Size: 77 KiB |
After Width: | Height: | Size: 140 KiB |
After Width: | Height: | Size: 4.6 KiB |
After Width: | Height: | Size: 62 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 40 KiB |
After Width: | Height: | Size: 40 KiB |
After Width: | Height: | Size: 46 KiB |
After Width: | Height: | Size: 32 KiB |
After Width: | Height: | Size: 53 KiB |
After Width: | Height: | Size: 23 KiB |
After Width: | Height: | Size: 19 KiB |
After Width: | Height: | Size: 34 KiB |
After Width: | Height: | Size: 463 KiB |
@ -0,0 +1,316 @@ |
|||||
|
--- |
||||
|
geometry: margin=1in |
||||
|
header-includes: |
||||
|
- \usepackage{graphicx} |
||||
|
- \usepackage{titling} |
||||
|
- \usepackage{fancyhdr} |
||||
|
- \usepackage{lastpage} |
||||
|
- \pagestyle{fancy} |
||||
|
- \fancyhead[L]{Time Safari Usage Guide} |
||||
|
- \fancyhead[C]{Page \thepage\ of \pageref{LastPage}} |
||||
|
- \fancyhead[R]{} |
||||
|
- \fancyfoot[L]{} |
||||
|
- \fancyfoot[C]{} |
||||
|
- \fancyfoot[R]{\includegraphics[width=1cm]{images/timesafari-logo-binoculars.png}} |
||||
|
- \usepackage{tocloft} |
||||
|
- \usepackage{libertine} |
||||
|
- \renewcommand{\familydefault}{\sfdefault} |
||||
|
- \fancypagestyle{tocstyle}{ |
||||
|
\fancyhead[L]{Time Safari Usage Guide} |
||||
|
\fancyhead[C]{Page \thepage\ of \pageref{LastPage}} |
||||
|
\fancyhead[R]{} |
||||
|
\fancyfoot[L]{} |
||||
|
\fancyfoot[C]{} |
||||
|
\fancyfoot[R]{\includegraphics[width=1cm]{images/timesafari-logo-binoculars.png}}} |
||||
|
--- |
||||
|
|
||||
|
\begin{titlepage} |
||||
|
\centering |
||||
|
\vspace*{\fill} |
||||
|
{\huge\textbf{TimeSafari Usage guide}} |
||||
|
|
||||
|
\vspace{1cm} |
||||
|
{\Large Signing up users, adding contacts, and adding gifts.} |
||||
|
|
||||
|
\vspace{1cm} |
||||
|
\includegraphics[width=0.5\textwidth]{images/timesafari-logo.png} |
||||
|
\vspace*{\fill} |
||||
|
|
||||
|
\vspace{1cm} |
||||
|
{\Large Trent Larson, Kent Bull} |
||||
|
|
||||
|
\vspace{0.5cm} |
||||
|
{\large 2024-06-25} |
||||
|
|
||||
|
\end{titlepage} |
||||
|
|
||||
|
\clearpage |
||||
|
|
||||
|
\begin{center} |
||||
|
\includegraphics[width=2cm]{images/timesafari-logo-binoculars.png} |
||||
|
\end{center} |
||||
|
\tableofcontents |
||||
|
|
||||
|
\clearpage |
||||
|
|
||||
|
|
||||
|
# Purpose of Document |
||||
|
|
||||
|
Both end-users and development team members need to know how to use TimeSafari. |
||||
|
This document serves to show how to use every feature of the TimeSafari platform. |
||||
|
|
||||
|
Sections of this document are geared specifically for software developers and quality assurance |
||||
|
team members. |
||||
|
|
||||
|
Companion videos will also describe end-to-end workflows for the end-user. |
||||
|
|
||||
|
# TimeSafari |
||||
|
|
||||
|
## Overview |
||||
|
|
||||
|
\pagebreak |
||||
|
|
||||
|
# 1 - End Users |
||||
|
|
||||
|
This section covers application usage for people who will use TimeSafari as intended. It is a |
||||
|
simplified guide illustrating how to gain value from using TimeSafari. |
||||
|
|
||||
|
\pagebreak |
||||
|
|
||||
|
# 2 - Software Developers |
||||
|
|
||||
|
This section is tailored for software developers seeking to use the application during development, |
||||
|
quality assurance, and testing. |
||||
|
|
||||
|
# Bootstrapping a local development environment |
||||
|
|
||||
|
The first concern a software developer has when working on TimeSafari is to set up a local |
||||
|
development environment. This section will guide you through the process. |
||||
|
|
||||
|
## Prerequisites |
||||
|
|
||||
|
1. Have the following installed on your local machine: |
||||
|
- Node.js and NPM |
||||
|
- A web browser. For this guide, we will use Google Chrome. |
||||
|
- Git |
||||
|
- A code editor |
||||
|
|
||||
|
2. Create an API key on Infura. This is necessary for the Endorser API to connect to the Ethereum |
||||
|
blockchain. |
||||
|
- You can create an account on Infura [here](https://infura.io/).\ |
||||
|
Click "CREATE NEW API KEY" and label the key. Then click "API Keys" in the top menu bar to |
||||
|
be taken back to the list of keys. |
||||
|
|
||||
|
Click "VIEW STATS" on the key you want to use. |
||||
|
|
||||
|
![](images/01_infura-api-keys.png){ width=550px } |
||||
|
|
||||
|
- Go to the key detail page. Then click "MANAGE API KEY". |
||||
|
|
||||
|
![](images/02-infura-key-detail.png){ width=550px } |
||||
|
|
||||
|
- Click the copy and paste button next to the string of alphanumeric characters.\ |
||||
|
This is your API, also known as your project ID. |
||||
|
|
||||
|
![](images/03-infura-api-key-id.png){width=550px } |
||||
|
|
||||
|
- Save this for later during the Endorser API setup. This will go in your `INFURA_PROJECT_ID` |
||||
|
environment variable. |
||||
|
|
||||
|
|
||||
|
## Setup steps |
||||
|
|
||||
|
### 1. Clone the following repositories from their respective Git hosts: |
||||
|
- [TimeSafari Frontend](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa)\ |
||||
|
This is a Progressive Web App (PWA) built with VueJS and TypeScript. |
||||
|
Note that the clone command here is different from the one you would use for GitHub. |
||||
|
|
||||
|
```bash |
||||
|
git clone git clone \ |
||||
|
ssh://git@gitea.anomalistdesign.com:222/trent_larson/crowd-funder-for-time-pwa.git |
||||
|
``` |
||||
|
|
||||
|
- [TimeSafari Backend - Endorser API](https://github.com/trentlarson/endorser-ch)\ |
||||
|
This is a NodeJS service providing the backend for TimeSafari. |
||||
|
|
||||
|
```bash |
||||
|
git clone git@github.com:trentlarson/endorser-ch.git |
||||
|
``` |
||||
|
|
||||
|
\pagebreak |
||||
|
|
||||
|
### 2. Database creation |
||||
|
|
||||
|
#### Alternative 1 - use test data |
||||
|
|
||||
|
To generate a development database and perform user setup you can run a local test with instructions |
||||
|
below to generate sample data. Then copy the test database, rename it to `-dev` as below:\ |
||||
|
`cp ../endorser-ch-test-local.sqlite3 ../endorser-ch-dev.sqlite3` \ |
||||
|
and rerun `npm run dev` to give yourself user #0 and others from the ETHR_CRED_DATA in [the endorser.ch test util file](https://github.com/trentlarson/endorser-ch/blob/master/test/util.js#L90) |
||||
|
|
||||
|
#### Alternative 2 - boostrap single seed user |
||||
|
|
||||
|
In this method you will end up with two accounts in the database, one for the first boostrap user, |
||||
|
and the second as the primary user you will use during testing. The first user will invite the |
||||
|
second user to the app. |
||||
|
|
||||
|
1. Install dependencies and environment variables.\ |
||||
|
In endorser-ch install dependencies and set up environment variables to allow starting it up in |
||||
|
development mode. |
||||
|
```bash |
||||
|
cd endorser-ch |
||||
|
npm clean install # or npm ci |
||||
|
cp .env.local .env |
||||
|
``` |
||||
|
Edit the .env file's INFURA_PROJECT_ID with the value you saved earlier in the |
||||
|
prerequisites.\ |
||||
|
Then create the SQLite database by running `npm run flyway migrate` with environment variables |
||||
|
set correctly to select the default SQLite development user as follows. |
||||
|
```bash |
||||
|
export NODE_ENV=dev |
||||
|
export DBUSER=sa |
||||
|
export DBPASS=sasa |
||||
|
npm run flyway migrate |
||||
|
``` |
||||
|
The first run of flyway migrate may take some time to complete because the entire Flyway |
||||
|
distribution must be downloaded prior to executing migrations. |
||||
|
|
||||
|
Successful output looks similar to the following: |
||||
|
|
||||
|
``` |
||||
|
Database: jdbc:sqlite:../endorser-ch-dev.sqlite3 (SQLite 3.41) |
||||
|
Schema history table "main"."flyway_schema_history" does not exist yet |
||||
|
Successfully validated 10 migrations (execution time 00:00.034s) |
||||
|
Creating Schema History table "main"."flyway_schema_history" ... |
||||
|
Current version of schema "main": << Empty Schema >> |
||||
|
Migrating schema "main" to version "1 - initial-anew" |
||||
|
Migrating schema "main" to version "2 - registration" |
||||
|
Migrating schema "main" to version "3 - plan project" |
||||
|
Migrating schema "main" to version "4 - offer gave" |
||||
|
Migrating schema "main" to version "5 - more confirmations" |
||||
|
Migrating schema "main" to version "6 - providers urls" |
||||
|
Migrating schema "main" to version "7 - hash nonce" |
||||
|
Migrating schema "main" to version "8 - project location" |
||||
|
Migrating schema "main" to version "9 - plan links" |
||||
|
Migrating schema "main" to version "10 - gift or trade" |
||||
|
Successfully applied 10 migrations to schema "main", now at version v10 (execution time 00:00.043s) |
||||
|
A Flyway report has been generated here: /Users/kbull/code/timesafari/endorser-ch/report.html |
||||
|
``` |
||||
|
|
||||
|
\pagebreak |
||||
|
|
||||
|
2. Generate the first user in TimeSafari PWA and bootstrap that user in Endorser's database.\ |
||||
|
As TimeSafari is an invite-only platform the first user must be manually bootstrapped since |
||||
|
no other users exist to be able to invite the first user. This first user must be added manually |
||||
|
to the SQLite database used by Endorser. In this setup you generate the first user from the PWA. |
||||
|
|
||||
|
This user is automatically generated on first usage of the TimeSafari PWA. Bootstrapping that |
||||
|
user is required so that this first user can register other users. |
||||
|
- Change directories into `crowd-funder-for-time-pwa` |
||||
|
|
||||
|
```bash |
||||
|
cd .. |
||||
|
cd crowd-funder-for-time-pwa |
||||
|
``` |
||||
|
|
||||
|
- Ensure the `.env.development` file exists and has the following values: |
||||
|
|
||||
|
```env |
||||
|
VITE_DEFAULT_ENDORSER_API_SERVER=http://127.0.0.1:3000 |
||||
|
``` |
||||
|
|
||||
|
- Install dependencies and run in dev mode. For now don't worry about configuring the app. All we |
||||
|
need is to generate the first root user and this happens automatically on app startup. |
||||
|
|
||||
|
```bash |
||||
|
npm clean install # or npm ci |
||||
|
npm run dev |
||||
|
``` |
||||
|
|
||||
|
- Open the app in a browser and go to the developer tools. It is recommended to use a completely |
||||
|
separate browser profile so you do not clear out your existing user account. We will be |
||||
|
completely resetting the PWA app state prior to generating the first user. |
||||
|
|
||||
|
In the Developer Tools go to the Application tab. |
||||
|
|
||||
|
![](images/04-pwa-chrome-devtools.png){width=350px} |
||||
|
|
||||
|
Click the "Clear site data" button and then refresh the page. |
||||
|
|
||||
|
- Click the account button in the bottom right corner of the page. |
||||
|
|
||||
|
![](images/05-pwa-account-button.png){width=150px} |
||||
|
|
||||
|
- This will take you to the account page titled "Your Identity" on which you can see your DID, |
||||
|
a `did:ethr` DID in this case. |
||||
|
|
||||
|
![](images/06-pwa-account-page.png){width=350px} |
||||
|
|
||||
|
- Copy the DID by selecting it and copying it to the clipboard or by clicking the copy and paste |
||||
|
button as shown in the image. |
||||
|
|
||||
|
![](images/07-pwa-did-copied.png){width=200px} |
||||
|
|
||||
|
In our case this DID is:\ |
||||
|
`did:ethr:0xe4B783c74c8B0e229524e44d0cD898D272E02CD6` |
||||
|
|
||||
|
- Add that DID to the following echoed SQL statement where it says `YOUR_DID` |
||||
|
|
||||
|
```bash |
||||
|
echo "INSERT INTO registration (did, maxClaims, maxRegs, epoch) |
||||
|
VALUES ('YOUR_DID', 100, 10000, 1719348718092);" |
||||
|
| sqlite3 ./endorser-ch-dev.sqlite3 |
||||
|
``` |
||||
|
|
||||
|
and run this command in the parent directory just above the `endorser-ch` directory. |
||||
|
|
||||
|
It needs to be the parent directory of your `endorser-ch` repository because when |
||||
|
`endorser-ch` creates the SQLite database it depends on it creates it in the parent directory |
||||
|
of `endorser-ch`. |
||||
|
|
||||
|
- You can verify with an SQL browser tool that your record has been added to the `registration` |
||||
|
table. |
||||
|
|
||||
|
![](images/08-endorser-sqlite-row-added.png){width=350px} |
||||
|
|
||||
|
3. Then start the Endorser service in development mode with the following commands. |
||||
|
|
||||
|
```bash |
||||
|
cd ./endorser-ch |
||||
|
export NODE_ENV=dev |
||||
|
npm run dev |
||||
|
``` |
||||
|
|
||||
|
This starts the Endorser service on port 3000. |
||||
|
4. Create the second user by opening up a separate browser profile or incognito session, opening the |
||||
|
TimeSafari PWA at `http://localhost:8080`. You will see the yellow banner stating "Someone must |
||||
|
register you before you can give or offer." |
||||
|
|
||||
|
![](images/09-pwa-second-profile-first-open.png){width=350px} |
||||
|
|
||||
|
- If you want to ensure you have a fresh user account then open the developer tools, clear the |
||||
|
Application data as before, and then refresh the page. This will generate a new user in the |
||||
|
browser's IndexedDB database. |
||||
|
5. Go to the second users' account page to copy the DID. |
||||
|
|
||||
|
![](images/10-pwa-second-user-did.png){width=350px} |
||||
|
|
||||
|
6. Copy the DID and put it in the text bar on the "Your Contacts" page for the first account |
||||
|
|
||||
|
![](images/11-pwa-first-user-add-contact.png){width=350px} |
||||
|
|
||||
|
7. Click the "+" plus icon to add the user. |
||||
|
|
||||
|
![](images/12-pwa-first-user-contact-added.png){width=350px} |
||||
|
|
||||
|
8. Then click the register button to register the second user. |
||||
|
|
||||
|
![](images/13-pwa-first-user-register-second-user-btn.png){width=350px} |
||||
|
|
||||
|
9. Click "YES" on the dialog that shows up. |
||||
|
|
||||
|
![](images/14-pwa-first-user-register-yes.png){width=350px} |
||||
|
|
||||
|
After this a notification will pop up indicating whether registration was successful or not. |
||||
|
|
||||
|
10. You have finished the initial set up of users. |
@ -0,0 +1,96 @@ |
|||||
|
import { Buffer } from "buffer/"; |
||||
|
import { decode as cborDecode } from "cbor-x"; |
||||
|
import { bytesToMultibase, multibaseToBytes } from "did-jwt"; |
||||
|
|
||||
|
import { getWebCrypto } from "@/libs/crypto/vc/passkeyHelpers"; |
||||
|
|
||||
|
export const PEER_DID_PREFIX = "did:peer:"; |
||||
|
const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0"; |
||||
|
|
||||
|
/** |
||||
|
* |
||||
|
* |
||||
|
* similar code is in crowd-funder-for-time-pwa libs/crypto/vc/passkeyDidPeer.ts verifyJwtWebCrypto |
||||
|
* |
||||
|
* @returns {Promise<boolean>} |
||||
|
*/ |
||||
|
export async function verifyPeerSignature( |
||||
|
payloadBytes: Buffer, |
||||
|
issuerDid: string, |
||||
|
signatureBytes: Uint8Array, |
||||
|
): Promise<boolean> { |
||||
|
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid); |
||||
|
|
||||
|
const WebCrypto = await getWebCrypto(); |
||||
|
const verifyAlgorithm = { |
||||
|
name: "ECDSA", |
||||
|
hash: { name: "SHA-256" }, |
||||
|
}; |
||||
|
const publicKeyJwk = cborToKeys(publicKeyBytes).publicKeyJwk; |
||||
|
const keyAlgorithm = { |
||||
|
name: "ECDSA", |
||||
|
namedCurve: publicKeyJwk.crv, |
||||
|
}; |
||||
|
const publicKeyCryptoKey = await WebCrypto.subtle.importKey( |
||||
|
"jwk", |
||||
|
publicKeyJwk, |
||||
|
keyAlgorithm, |
||||
|
false, |
||||
|
["verify"], |
||||
|
); |
||||
|
const verified = await WebCrypto.subtle.verify( |
||||
|
verifyAlgorithm, |
||||
|
publicKeyCryptoKey, |
||||
|
signatureBytes, |
||||
|
payloadBytes, |
||||
|
); |
||||
|
return verified; |
||||
|
} |
||||
|
|
||||
|
export function cborToKeys(publicKeyBytes: Uint8Array) { |
||||
|
const jwkObj = cborDecode(publicKeyBytes); |
||||
|
if ( |
||||
|
jwkObj[1] != 2 || // kty "EC"
|
||||
|
jwkObj[3] != -7 || // alg "ES256"
|
||||
|
jwkObj[-1] != 1 || // crv "P-256"
|
||||
|
jwkObj[-2].length != 32 || // x
|
||||
|
jwkObj[-3].length != 32 // y
|
||||
|
) { |
||||
|
throw new Error("Unable to extract key."); |
||||
|
} |
||||
|
const publicKeyJwk = { |
||||
|
alg: "ES256", |
||||
|
crv: "P-256", |
||||
|
kty: "EC", |
||||
|
x: arrayToBase64Url(jwkObj[-2]), |
||||
|
y: arrayToBase64Url(jwkObj[-3]), |
||||
|
}; |
||||
|
const publicKeyBuffer = Buffer.concat([ |
||||
|
Buffer.from(jwkObj[-2]), |
||||
|
Buffer.from(jwkObj[-3]), |
||||
|
]); |
||||
|
return { publicKeyJwk, publicKeyBuffer }; |
||||
|
} |
||||
|
|
||||
|
export function toBase64Url(anythingB64: string) { |
||||
|
return anythingB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
||||
|
} |
||||
|
|
||||
|
export function arrayToBase64Url(anything: Uint8Array) { |
||||
|
return toBase64Url(Buffer.from(anything).toString("base64")); |
||||
|
} |
||||
|
|
||||
|
export function peerDidToPublicKeyBytes(did: string) { |
||||
|
return multibaseToBytes(did.substring(PEER_DID_MULTIBASE_PREFIX.length)); |
||||
|
} |
||||
|
|
||||
|
export function createPeerDid(publicKeyBytes: Uint8Array) { |
||||
|
// https://github.com/decentralized-identity/veramo/blob/next/packages/did-provider-peer/src/peer-did-provider.ts#L67
|
||||
|
//const provider = new PeerDIDProvider({ defaultKms: LOCAL_KMS_NAME });
|
||||
|
const methodSpecificId = bytesToMultibase( |
||||
|
publicKeyBytes, |
||||
|
"base58btc", |
||||
|
"p256-pub", |
||||
|
); |
||||
|
return PEER_DID_MULTIBASE_PREFIX + methodSpecificId; |
||||
|
} |
@ -0,0 +1,112 @@ |
|||||
|
/** |
||||
|
* Verifiable Credential & DID functions, specifically for EndorserSearch.org tools |
||||
|
* |
||||
|
* The goal is to make this folder similar across projects, then move it to a library. |
||||
|
* Other projects: endorser-ch, image-api |
||||
|
* |
||||
|
*/ |
||||
|
|
||||
|
import * as didJwt from "did-jwt"; |
||||
|
import { JWTDecoded } from "did-jwt/lib/JWT"; |
||||
|
import { IIdentifier } from "@veramo/core"; |
||||
|
import * as u8a from "uint8arrays"; |
||||
|
|
||||
|
import { createDidPeerJwt } from "@/libs/crypto/vc/passkeyDidPeer"; |
||||
|
|
||||
|
export const ETHR_DID_PREFIX = "did:ethr:"; |
||||
|
|
||||
|
/** |
||||
|
* 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); |
||||
|
} |
@ -0,0 +1,531 @@ |
|||||
|
import { Buffer } from "buffer/"; |
||||
|
import { JWTPayload } from "did-jwt"; |
||||
|
import { DIDResolutionResult } from "did-resolver"; |
||||
|
import { sha256 } from "ethereum-cryptography/sha256.js"; |
||||
|
import { |
||||
|
startAuthentication, |
||||
|
startRegistration, |
||||
|
} from "@simplewebauthn/browser"; |
||||
|
import { |
||||
|
generateAuthenticationOptions, |
||||
|
generateRegistrationOptions, |
||||
|
verifyAuthenticationResponse, |
||||
|
verifyRegistrationResponse, |
||||
|
} from "@simplewebauthn/server"; |
||||
|
import { VerifyAuthenticationResponseOpts } from "@simplewebauthn/server/esm/authentication/verifyAuthenticationResponse"; |
||||
|
import { |
||||
|
Base64URLString, |
||||
|
PublicKeyCredentialCreationOptionsJSON, |
||||
|
PublicKeyCredentialRequestOptionsJSON, |
||||
|
} from "@simplewebauthn/types"; |
||||
|
|
||||
|
import { AppString } from "@/constants/app"; |
||||
|
import { unwrapEC2Signature } from "@/libs/crypto/vc/passkeyHelpers"; |
||||
|
import { |
||||
|
arrayToBase64Url, |
||||
|
cborToKeys, |
||||
|
peerDidToPublicKeyBytes, |
||||
|
verifyPeerSignature, |
||||
|
} from "@/libs/crypto/vc/didPeer"; |
||||
|
|
||||
|
export interface JWK { |
||||
|
kty: string; |
||||
|
crv: string; |
||||
|
x: string; |
||||
|
y: string; |
||||
|
} |
||||
|
|
||||
|
export async function registerCredential(passkeyName?: string) { |
||||
|
const options: PublicKeyCredentialCreationOptionsJSON = |
||||
|
await generateRegistrationOptions({ |
||||
|
rpName: AppString.APP_NAME, |
||||
|
rpID: window.location.hostname, |
||||
|
userName: passkeyName || AppString.APP_NAME + " User", |
||||
|
// Don't prompt users for additional information about the authenticator
|
||||
|
// (Recommended for smoother UX)
|
||||
|
attestationType: "none", |
||||
|
authenticatorSelection: { |
||||
|
// Defaults
|
||||
|
residentKey: "preferred", |
||||
|
userVerification: "preferred", |
||||
|
// Optional
|
||||
|
authenticatorAttachment: "platform", |
||||
|
}, |
||||
|
}); |
||||
|
// someday, instead of simplwebauthn, we'll go direct: navigator.credentials.create with PublicKeyCredentialCreationOptions
|
||||
|
// with pubKeyCredParams: { type: "public-key", alg: -7 }
|
||||
|
const attResp = await startRegistration(options); |
||||
|
const verification = await verifyRegistrationResponse({ |
||||
|
response: attResp, |
||||
|
expectedChallenge: options.challenge, |
||||
|
expectedOrigin: window.location.origin, |
||||
|
expectedRPID: window.location.hostname, |
||||
|
}); |
||||
|
|
||||
|
// references for parsing auth data and getting the public key
|
||||
|
// https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/parseAuthenticatorData.ts#L11
|
||||
|
// https://chatgpt.com/share/78a5c91d-099d-46dc-aa6d-fc0c916509fa
|
||||
|
// https://chatgpt.com/share/3c13f061-6031-45bc-a2d7-3347c1e7a2d7
|
||||
|
|
||||
|
const credIdBase64Url = verification.registrationInfo?.credentialID as string; |
||||
|
if (attResp.rawId !== credIdBase64Url) { |
||||
|
console.log("Warning! The raw ID does not match the credential ID."); |
||||
|
} |
||||
|
const credIdHex = Buffer.from( |
||||
|
base64URLStringToArrayBuffer(credIdBase64Url), |
||||
|
).toString("hex"); |
||||
|
const { publicKeyJwk } = cborToKeys( |
||||
|
verification.registrationInfo?.credentialPublicKey as Uint8Array, |
||||
|
); |
||||
|
|
||||
|
return { |
||||
|
authData: verification.registrationInfo?.attestationObject, |
||||
|
credIdHex: credIdHex, |
||||
|
publicKeyJwk: publicKeyJwk, |
||||
|
publicKeyBytes: verification.registrationInfo |
||||
|
?.credentialPublicKey as Uint8Array, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
export class PeerSetup { |
||||
|
public authenticatorData?: ArrayBuffer; |
||||
|
public challenge?: Uint8Array; |
||||
|
public clientDataJsonBase64Url?: Base64URLString; |
||||
|
public signature?: Base64URLString; |
||||
|
|
||||
|
public async createJwtSimplewebauthn( |
||||
|
issuerDid: string, |
||||
|
payload: object, |
||||
|
credIdHex: string, |
||||
|
expMinutes: number = 1, |
||||
|
) { |
||||
|
const credentialId = arrayBufferToBase64URLString( |
||||
|
Buffer.from(credIdHex, "hex").buffer, |
||||
|
); |
||||
|
const issuedAt = Math.floor(Date.now() / 1000); |
||||
|
const expiryTime = Math.floor(Date.now() / 1000) + expMinutes * 60; // some minutes from now
|
||||
|
const fullPayload = { |
||||
|
...payload, |
||||
|
exp: expiryTime, |
||||
|
iat: issuedAt, |
||||
|
iss: issuerDid, |
||||
|
}; |
||||
|
this.challenge = new Uint8Array(Buffer.from(JSON.stringify(fullPayload))); |
||||
|
// const payloadHash: Uint8Array = sha256(this.challenge);
|
||||
|
const options: PublicKeyCredentialRequestOptionsJSON = |
||||
|
await generateAuthenticationOptions({ |
||||
|
challenge: this.challenge, |
||||
|
rpID: window.location.hostname, |
||||
|
allowCredentials: [{ id: credentialId }], |
||||
|
}); |
||||
|
// console.log("simple authentication options", options);
|
||||
|
|
||||
|
const clientAuth = await startAuthentication(options); |
||||
|
// console.log("simple credential get", clientAuth);
|
||||
|
|
||||
|
const authenticatorDataBase64Url = clientAuth.response.authenticatorData; |
||||
|
this.authenticatorData = Buffer.from( |
||||
|
clientAuth.response.authenticatorData, |
||||
|
"base64", |
||||
|
).buffer; |
||||
|
this.clientDataJsonBase64Url = clientAuth.response.clientDataJSON; |
||||
|
// console.log("simple authenticatorData for signing", this.authenticatorData);
|
||||
|
this.signature = clientAuth.response.signature; |
||||
|
|
||||
|
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
|
||||
|
const header: JWTPayload = { typ: "JWANT", alg: "ES256" }; |
||||
|
const headerBase64 = Buffer.from(JSON.stringify(header)) |
||||
|
.toString("base64") |
||||
|
.replace(/\+/g, "-") |
||||
|
.replace(/\//g, "_") |
||||
|
.replace(/=+$/, ""); |
||||
|
|
||||
|
const dataInJwt = { |
||||
|
AuthenticationDataB64URL: authenticatorDataBase64Url, |
||||
|
ClientDataJSONB64URL: this.clientDataJsonBase64Url, |
||||
|
exp: expiryTime, |
||||
|
iat: issuedAt, |
||||
|
iss: issuerDid, |
||||
|
}; |
||||
|
const dataInJwtString = JSON.stringify(dataInJwt); |
||||
|
const payloadBase64 = Buffer.from(dataInJwtString) |
||||
|
.toString("base64") |
||||
|
.replace(/\+/g, "-") |
||||
|
.replace(/\//g, "_") |
||||
|
.replace(/=+$/, ""); |
||||
|
|
||||
|
const signature = clientAuth.response.signature; |
||||
|
|
||||
|
return headerBase64 + "." + payloadBase64 + "." + signature; |
||||
|
} |
||||
|
|
||||
|
public async createJwtNavigator( |
||||
|
issuerDid: string, |
||||
|
payload: object, |
||||
|
credIdHex: string, |
||||
|
expMinutes: number = 1, |
||||
|
) { |
||||
|
const issuedAt = Math.floor(Date.now() / 1000); |
||||
|
const expiryTime = Math.floor(Date.now() / 1000) + expMinutes * 60; // some minutes from now
|
||||
|
const fullPayload = { |
||||
|
...payload, |
||||
|
exp: expiryTime, |
||||
|
iat: issuedAt, |
||||
|
iss: issuerDid, |
||||
|
}; |
||||
|
const dataToSignString = JSON.stringify(fullPayload); |
||||
|
const dataToSignBuffer = Buffer.from(dataToSignString); |
||||
|
const credentialId = Buffer.from(credIdHex, "hex"); |
||||
|
|
||||
|
// console.log("lower credentialId", credentialId);
|
||||
|
this.challenge = new Uint8Array(dataToSignBuffer); |
||||
|
const options = { |
||||
|
publicKey: { |
||||
|
allowCredentials: [ |
||||
|
{ |
||||
|
id: credentialId, |
||||
|
type: "public-key" as const, |
||||
|
}, |
||||
|
], |
||||
|
challenge: this.challenge.buffer, |
||||
|
rpID: window.location.hostname, |
||||
|
userVerification: "preferred" as const, |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
const credential = await navigator.credentials.get(options); |
||||
|
// console.log("nav credential get", credential);
|
||||
|
|
||||
|
this.authenticatorData = credential?.response.authenticatorData; |
||||
|
const authenticatorDataBase64Url = arrayBufferToBase64URLString( |
||||
|
this.authenticatorData as ArrayBuffer, |
||||
|
); |
||||
|
|
||||
|
this.clientDataJsonBase64Url = arrayBufferToBase64URLString( |
||||
|
credential?.response.clientDataJSON, |
||||
|
); |
||||
|
|
||||
|
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
|
||||
|
const header: JWTPayload = { typ: "JWANT", alg: "ES256" }; |
||||
|
const headerBase64 = Buffer.from(JSON.stringify(header)) |
||||
|
.toString("base64") |
||||
|
.replace(/\+/g, "-") |
||||
|
.replace(/\//g, "_") |
||||
|
.replace(/=+$/, ""); |
||||
|
|
||||
|
const dataInJwt = { |
||||
|
AuthenticationDataB64URL: authenticatorDataBase64Url, |
||||
|
ClientDataJSONB64URL: this.clientDataJsonBase64Url, |
||||
|
exp: expiryTime, |
||||
|
iat: issuedAt, |
||||
|
iss: issuerDid, |
||||
|
}; |
||||
|
const dataInJwtString = JSON.stringify(dataInJwt); |
||||
|
const payloadBase64 = Buffer.from(dataInJwtString) |
||||
|
.toString("base64") |
||||
|
.replace(/\+/g, "-") |
||||
|
.replace(/\//g, "_") |
||||
|
.replace(/=+$/, ""); |
||||
|
|
||||
|
const origSignature = Buffer.from(credential?.response.signature).toString( |
||||
|
"base64", |
||||
|
); |
||||
|
this.signature = origSignature |
||||
|
.replace(/\+/g, "-") |
||||
|
.replace(/\//g, "_") |
||||
|
.replace(/=+$/, ""); |
||||
|
|
||||
|
const jwt = headerBase64 + "." + payloadBase64 + "." + this.signature; |
||||
|
return jwt; |
||||
|
} |
||||
|
|
||||
|
// To use this, add the asn1-ber library and add this import:
|
||||
|
// import asn1 from "asn1-ber";
|
||||
|
//
|
||||
|
// return a low-level signing function, similar to createJWS approach
|
||||
|
// async webAuthnES256KSigner(credentialID: string) {
|
||||
|
// return async (data: string | Uint8Array) => {
|
||||
|
// // get signature from WebAuthn
|
||||
|
// const signature = await this.generateWebAuthnSignature(data);
|
||||
|
//
|
||||
|
// // This converts from the browser ArrayBuffer to a Node.js Buffer, which is a requirement for the asn1 library.
|
||||
|
// const signatureBuffer = Buffer.from(signature);
|
||||
|
// console.log("lower signature inside signer", signature);
|
||||
|
// console.log("lower buffer signature inside signer", signatureBuffer);
|
||||
|
// console.log("lower base64 buffer signature inside signer", signatureBuffer.toString("base64"));
|
||||
|
// // Decode the DER-encoded signature to extract R and S values
|
||||
|
// const reader = new asn1.BerReader(signatureBuffer);
|
||||
|
// console.log("lower after reader");
|
||||
|
// reader.readSequence();
|
||||
|
// console.log("lower after read sequence");
|
||||
|
// const r = reader.readString(asn1.Ber.Integer, true);
|
||||
|
// console.log("lower after r");
|
||||
|
// const s = reader.readString(asn1.Ber.Integer, true);
|
||||
|
// console.log("lower after r & s");
|
||||
|
//
|
||||
|
// // Ensure R and S are 32 bytes each
|
||||
|
// const rBuffer = Buffer.from(r);
|
||||
|
// const sBuffer = Buffer.from(s);
|
||||
|
// console.log("lower after rBuffer & sBuffer", rBuffer, sBuffer);
|
||||
|
// const rWithoutPrefix = rBuffer.length > 32 ? rBuffer.slice(1) : rBuffer;
|
||||
|
// const sWithoutPrefix = sBuffer.length > 32 ? sBuffer.slice(1) : sBuffer;
|
||||
|
// const rPadded =
|
||||
|
// rWithoutPrefix.length < 32
|
||||
|
// ? Buffer.concat([Buffer.alloc(32 - rWithoutPrefix.length), rBuffer])
|
||||
|
// : rWithoutPrefix;
|
||||
|
// const sPadded =
|
||||
|
// rWithoutPrefix.length < 32
|
||||
|
// ? Buffer.concat([Buffer.alloc(32 - sWithoutPrefix.length), sBuffer])
|
||||
|
// : sWithoutPrefix;
|
||||
|
//
|
||||
|
// // Concatenate R and S to form the 64-byte array (ECDSA signature format expected by JWT)
|
||||
|
// const combinedSignature = Buffer.concat([rPadded, sPadded]);
|
||||
|
// console.log(
|
||||
|
// "lower combinedSignature",
|
||||
|
// combinedSignature.length,
|
||||
|
// combinedSignature,
|
||||
|
// );
|
||||
|
//
|
||||
|
// const combSig64 = combinedSignature.toString("base64");
|
||||
|
// console.log("lower combSig64", combSig64);
|
||||
|
// const combSig64Url = combSig64
|
||||
|
// .replace(/\+/g, "-")
|
||||
|
// .replace(/\//g, "_")
|
||||
|
// .replace(/=+$/, "");
|
||||
|
// console.log("lower combSig64Url", combSig64Url);
|
||||
|
// return combSig64Url;
|
||||
|
// };
|
||||
|
// }
|
||||
|
} |
||||
|
|
||||
|
export async function createDidPeerJwt( |
||||
|
did: string, |
||||
|
credIdHex: string, |
||||
|
payload: object, |
||||
|
): Promise<string> { |
||||
|
const peerSetup = new PeerSetup(); |
||||
|
const jwt = await peerSetup.createJwtNavigator(did, payload, credIdHex); |
||||
|
return jwt; |
||||
|
} |
||||
|
|
||||
|
// I'd love to use this but it doesn't verify.
|
||||
|
// Requires:
|
||||
|
// npm install @noble/curves
|
||||
|
// ... and this import:
|
||||
|
// import { p256 } from "@noble/curves/p256";
|
||||
|
export async function verifyJwtP256( |
||||
|
credIdHex: string, |
||||
|
issuerDid: string, |
||||
|
authenticatorData: ArrayBuffer, |
||||
|
challenge: Uint8Array, |
||||
|
clientDataJsonBase64Url: Base64URLString, |
||||
|
signature: Base64URLString, |
||||
|
) { |
||||
|
const authDataFromBase = Buffer.from(authenticatorData); |
||||
|
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64"); |
||||
|
const sigBuffer = Buffer.from(signature, "base64"); |
||||
|
const finalSigBuffer = unwrapEC2Signature(sigBuffer); |
||||
|
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid); |
||||
|
|
||||
|
// Hash the client data
|
||||
|
const hash = sha256(clientDataFromBase); |
||||
|
|
||||
|
// Construct the preimage
|
||||
|
const preimage = Buffer.concat([authDataFromBase, hash]); |
||||
|
|
||||
|
const isValid = p256.verify( |
||||
|
finalSigBuffer, |
||||
|
new Uint8Array(preimage), |
||||
|
publicKeyBytes, |
||||
|
); |
||||
|
return isValid; |
||||
|
} |
||||
|
|
||||
|
export async function verifyJwtSimplewebauthn( |
||||
|
credIdHex: string, |
||||
|
issuerDid: string, |
||||
|
authenticatorData: ArrayBuffer, |
||||
|
challenge: Uint8Array, |
||||
|
clientDataJsonBase64Url: Base64URLString, |
||||
|
signature: Base64URLString, |
||||
|
) { |
||||
|
const authData = arrayToBase64Url(Buffer.from(authenticatorData)); |
||||
|
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid); |
||||
|
const credId = arrayBufferToBase64URLString( |
||||
|
Buffer.from(credIdHex, "hex").buffer, |
||||
|
); |
||||
|
const authOpts: VerifyAuthenticationResponseOpts = { |
||||
|
authenticator: { |
||||
|
credentialID: credId, |
||||
|
credentialPublicKey: publicKeyBytes, |
||||
|
counter: 0, |
||||
|
}, |
||||
|
expectedChallenge: arrayToBase64Url(challenge), |
||||
|
expectedOrigin: window.location.origin, |
||||
|
expectedRPID: window.location.hostname, |
||||
|
response: { |
||||
|
authenticatorAttachment: "platform", |
||||
|
clientExtensionResults: {}, |
||||
|
id: credId, |
||||
|
rawId: credId, |
||||
|
response: { |
||||
|
authenticatorData: authData, |
||||
|
clientDataJSON: clientDataJsonBase64Url, |
||||
|
signature: signature, |
||||
|
}, |
||||
|
type: "public-key", |
||||
|
}, |
||||
|
}; |
||||
|
const verification = await verifyAuthenticationResponse(authOpts); |
||||
|
return verification.verified; |
||||
|
} |
||||
|
|
||||
|
// similar code is in endorser-ch util-crypto.ts verifyPeerSignature
|
||||
|
export async function verifyJwtWebCrypto( |
||||
|
credId: Base64URLString, |
||||
|
issuerDid: string, |
||||
|
authenticatorData: ArrayBuffer, |
||||
|
challenge: Uint8Array, |
||||
|
clientDataJsonBase64Url: Base64URLString, |
||||
|
signature: Base64URLString, |
||||
|
) { |
||||
|
const authDataFromBase = Buffer.from(authenticatorData); |
||||
|
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64"); |
||||
|
const sigBuffer = Buffer.from(signature, "base64"); |
||||
|
const finalSigBuffer = unwrapEC2Signature(sigBuffer); |
||||
|
|
||||
|
// Hash the client data
|
||||
|
const hash = sha256(clientDataFromBase); |
||||
|
|
||||
|
// Construct the preimage
|
||||
|
const preimage = Buffer.concat([authDataFromBase, hash]); |
||||
|
return verifyPeerSignature(preimage, issuerDid, finalSigBuffer); |
||||
|
} |
||||
|
|
||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> { |
||||
|
if (!did.startsWith("did:peer:0z")) { |
||||
|
throw new Error( |
||||
|
"This only verifies a peer DID, method 0, encoded base58btc.", |
||||
|
); |
||||
|
} |
||||
|
// this is basically hard-coded from https://www.w3.org/TR/did-core/#example-various-verification-method-types
|
||||
|
// (another reference is the @aviarytech/did-peer resolver)
|
||||
|
const id = did.split(":")[2]; |
||||
|
const multibase = id.slice(1); |
||||
|
const encnumbasis = multibase.slice(1); |
||||
|
const didDocument = { |
||||
|
"@context": [ |
||||
|
"https://www.w3.org/ns/did/v1", |
||||
|
"https://w3id.org/security/suites/jws-2020/v1", |
||||
|
], |
||||
|
assertionMethod: [did + "#" + encnumbasis], |
||||
|
authentication: [did + "#" + encnumbasis], |
||||
|
capabilityDelegation: [did + "#" + encnumbasis], |
||||
|
capabilityInvocation: [did + "#" + encnumbasis], |
||||
|
id: did, |
||||
|
keyAgreement: undefined, |
||||
|
service: undefined, |
||||
|
verificationMethod: [ |
||||
|
{ |
||||
|
controller: did, |
||||
|
id: did + "#" + encnumbasis, |
||||
|
publicKeyMultibase: multibase, |
||||
|
type: "EcdsaSecp256k1VerificationKey2019", |
||||
|
}, |
||||
|
], |
||||
|
}; |
||||
|
return { |
||||
|
didDocument, |
||||
|
didDocumentMetadata: {}, |
||||
|
didResolutionMetadata: { contentType: "application/did+ld+json" }, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// convert COSE public key to PEM format
|
||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
function COSEtoPEM(cose: Buffer) { |
||||
|
// const alg = cose.get(3); // Algorithm
|
||||
|
const x = cose[-2]; // x-coordinate
|
||||
|
const y = cose[-3]; // y-coordinate
|
||||
|
|
||||
|
// Ensure the coordinates are in the correct format
|
||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
|
// @ts-expect-error because it complains about the type of x and y
|
||||
|
const pubKeyBuffer = Buffer.concat([Buffer.from([0x04]), x, y]); |
||||
|
|
||||
|
// Convert to PEM format
|
||||
|
const pem = `-----BEGIN PUBLIC KEY-----
|
||||
|
${pubKeyBuffer.toString("base64")} |
||||
|
-----END PUBLIC KEY-----`;
|
||||
|
|
||||
|
return pem; |
||||
|
} |
||||
|
|
||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
function base64urlDecode(input: string) { |
||||
|
input = input.replace(/-/g, "+").replace(/_/g, "/"); |
||||
|
const pad = input.length % 4 === 0 ? "" : "====".slice(input.length % 4); |
||||
|
const str = atob(input + pad); |
||||
|
const bytes = new Uint8Array(str.length); |
||||
|
for (let i = 0; i < str.length; i++) { |
||||
|
bytes[i] = str.charCodeAt(i); |
||||
|
} |
||||
|
return bytes.buffer; |
||||
|
} |
||||
|
|
||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
function base64urlEncode(buffer: ArrayBuffer) { |
||||
|
const str = String.fromCharCode(...new Uint8Array(buffer)); |
||||
|
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
||||
|
} |
||||
|
|
||||
|
// from @simplewebauthn/browser
|
||||
|
function arrayBufferToBase64URLString(buffer: ArrayBuffer) { |
||||
|
const bytes = new Uint8Array(buffer); |
||||
|
let str = ""; |
||||
|
for (const charCode of bytes) { |
||||
|
str += String.fromCharCode(charCode); |
||||
|
} |
||||
|
const base64String = btoa(str); |
||||
|
return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); |
||||
|
} |
||||
|
|
||||
|
// from @simplewebauthn/browser
|
||||
|
function base64URLStringToArrayBuffer(base64URLString: string) { |
||||
|
const base64 = base64URLString.replace(/-/g, "+").replace(/_/g, "/"); |
||||
|
const padLength = (4 - (base64.length % 4)) % 4; |
||||
|
const padded = base64.padEnd(base64.length + padLength, "="); |
||||
|
const binary = atob(padded); |
||||
|
const buffer = new ArrayBuffer(binary.length); |
||||
|
const bytes = new Uint8Array(buffer); |
||||
|
for (let i = 0; i < binary.length; i++) { |
||||
|
bytes[i] = binary.charCodeAt(i); |
||||
|
} |
||||
|
return buffer; |
||||
|
} |
||||
|
|
||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
async function pemToCryptoKey(pem: string) { |
||||
|
const binaryDerString = atob( |
||||
|
pem |
||||
|
.split("\n") |
||||
|
.filter((x) => !x.includes("-----")) |
||||
|
.join(""), |
||||
|
); |
||||
|
const binaryDer = new Uint8Array(binaryDerString.length); |
||||
|
for (let i = 0; i < binaryDerString.length; i++) { |
||||
|
binaryDer[i] = binaryDerString.charCodeAt(i); |
||||
|
} |
||||
|
// console.log("binaryDer", binaryDer.buffer);
|
||||
|
return await window.crypto.subtle.importKey( |
||||
|
"spki", |
||||
|
binaryDer.buffer, |
||||
|
{ |
||||
|
name: "RSASSA-PKCS1-v1_5", |
||||
|
hash: "SHA-256", |
||||
|
}, |
||||
|
true, |
||||
|
["verify"], |
||||
|
); |
||||
|
} |
@ -0,0 +1,105 @@ |
|||||
|
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/unwrapEC2Signature.ts
|
||||
|
import { AsnParser } from "@peculiar/asn1-schema"; |
||||
|
import { ECDSASigValue } from "@peculiar/asn1-ecc"; |
||||
|
|
||||
|
/** |
||||
|
* In WebAuthn, EC2 signatures are wrapped in ASN.1 structure so we need to peel r and s apart. |
||||
|
* |
||||
|
* See https://www.w3.org/TR/webauthn-2/#sctn-signature-attestation-types
|
||||
|
*/ |
||||
|
export function unwrapEC2Signature(signature: Uint8Array): Uint8Array { |
||||
|
const parsedSignature = AsnParser.parse(signature, ECDSASigValue); |
||||
|
let rBytes = new Uint8Array(parsedSignature.r); |
||||
|
let sBytes = new Uint8Array(parsedSignature.s); |
||||
|
|
||||
|
if (shouldRemoveLeadingZero(rBytes)) { |
||||
|
rBytes = rBytes.slice(1); |
||||
|
} |
||||
|
|
||||
|
if (shouldRemoveLeadingZero(sBytes)) { |
||||
|
sBytes = sBytes.slice(1); |
||||
|
} |
||||
|
|
||||
|
const finalSignature = isoUint8ArrayConcat([rBytes, sBytes]); |
||||
|
|
||||
|
return finalSignature; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Determine if the DER-specific `00` byte at the start of an ECDSA signature byte sequence |
||||
|
* should be removed based on the following logic: |
||||
|
* |
||||
|
* "If the leading byte is 0x0, and the the high order bit on the second byte is not set to 0, |
||||
|
* then remove the leading 0x0 byte" |
||||
|
*/ |
||||
|
function shouldRemoveLeadingZero(bytes: Uint8Array): boolean { |
||||
|
return bytes[0] === 0x0 && (bytes[1] & (1 << 7)) !== 0; |
||||
|
} |
||||
|
|
||||
|
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoUint8Array.ts#L49
|
||||
|
/** |
||||
|
* Combine multiple Uint8Arrays into a single Uint8Array |
||||
|
*/ |
||||
|
export function isoUint8ArrayConcat(arrays: Uint8Array[]): Uint8Array { |
||||
|
let pointer = 0; |
||||
|
const totalLength = arrays.reduce((prev, curr) => prev + curr.length, 0); |
||||
|
|
||||
|
const toReturn = new Uint8Array(totalLength); |
||||
|
|
||||
|
arrays.forEach((arr) => { |
||||
|
toReturn.set(arr, pointer); |
||||
|
pointer += arr.length; |
||||
|
}); |
||||
|
|
||||
|
return toReturn; |
||||
|
} |
||||
|
|
||||
|
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/getWebCrypto.ts
|
||||
|
let webCrypto: { subtle: SubtleCrypto } | undefined = undefined; |
||||
|
export function getWebCrypto(): Promise<{ subtle: SubtleCrypto }> { |
||||
|
/** |
||||
|
* Hello there! If you came here wondering why this method is asynchronous when use of |
||||
|
* `globalThis.crypto` is not, it's to minimize a bunch of refactor related to making this |
||||
|
* synchronous. For example, `generateRegistrationOptions()` and `generateAuthenticationOptions()` |
||||
|
* become synchronous if we make this synchronous (since nothing else in that method is async) |
||||
|
* which represents a breaking API change in this library's core API. |
||||
|
* |
||||
|
* TODO: If it's after February 2025 when you read this then consider whether it still makes sense |
||||
|
* to keep this method asynchronous. |
||||
|
*/ |
||||
|
const toResolve: Promise<{ subtle: SubtleCrypto }> = new Promise( |
||||
|
(resolve, reject) => { |
||||
|
if (webCrypto) { |
||||
|
return resolve(webCrypto); |
||||
|
} |
||||
|
/** |
||||
|
* Naively attempt to access Crypto as a global object, which popular ESM-centric run-times |
||||
|
* support (and Node v20+) |
||||
|
*/ |
||||
|
const _globalThisCrypto = |
||||
|
_getWebCryptoInternals.stubThisGlobalThisCrypto(); |
||||
|
if (_globalThisCrypto) { |
||||
|
webCrypto = _globalThisCrypto; |
||||
|
return resolve(webCrypto); |
||||
|
} |
||||
|
// We tried to access it both in Node and globally, so bail out
|
||||
|
return reject(new MissingWebCrypto()); |
||||
|
}, |
||||
|
); |
||||
|
return toResolve; |
||||
|
} |
||||
|
class MissingWebCrypto extends Error { |
||||
|
constructor() { |
||||
|
const message = "An instance of the Crypto API could not be located"; |
||||
|
super(message); |
||||
|
this.name = "MissingWebCrypto"; |
||||
|
} |
||||
|
} |
||||
|
// Make it possible to stub return values during testing
|
||||
|
const _getWebCryptoInternals = { |
||||
|
stubThisGlobalThisCrypto: () => globalThis.crypto, |
||||
|
// Make it possible to reset the `webCrypto` at the top of the file
|
||||
|
setCachedCrypto: (newCrypto: { subtle: SubtleCrypto }) => { |
||||
|
webCrypto = newCrypto; |
||||
|
}, |
||||
|
}; |
@ -0,0 +1,101 @@ |
|||||
|
<template> |
||||
|
<QuickNav /> |
||||
|
<!-- CONTENT --> |
||||
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> |
||||
|
<!-- Breadcrumb --> |
||||
|
<div id="ViewBreadcrumb" class="mb-8"> |
||||
|
<h1 class="text-lg text-center font-light relative px-7"> |
||||
|
<!-- Back --> |
||||
|
<button |
||||
|
@click="$router.go(-1)" |
||||
|
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" |
||||
|
> |
||||
|
<fa icon="chevron-left" class="fa-fw" /> |
||||
|
</button> |
||||
|
Raw Claim |
||||
|
</h1> |
||||
|
</div> |
||||
|
|
||||
|
<div class="flex"> |
||||
|
<textarea rows="20" class="border-2 w-full" v-model="claimStr"></textarea> |
||||
|
</div> |
||||
|
<button |
||||
|
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md" |
||||
|
@click="submitClaim()" |
||||
|
> |
||||
|
Sign & Send |
||||
|
</button> |
||||
|
</section> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { IIdentifier } from "@veramo/core"; |
||||
|
import { Component, Vue } from "vue-facing-decorator"; |
||||
|
|
||||
|
import GiftedDialog from "@/components/GiftedDialog.vue"; |
||||
|
import { NotificationIface } from "@/constants/app"; |
||||
|
import { accountsDB, db } from "@/db/index"; |
||||
|
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; |
||||
|
import * as serverUtil from "@/libs/endorserServer"; |
||||
|
import QuickNav from "@/components/QuickNav.vue"; |
||||
|
import { Account } from "@/db/tables/accounts"; |
||||
|
|
||||
|
@Component({ |
||||
|
components: { GiftedDialog, QuickNav }, |
||||
|
}) |
||||
|
export default class ClaimAddRawView extends Vue { |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
accountIdentityStr: string = "null"; |
||||
|
activeDid = ""; |
||||
|
apiServer = ""; |
||||
|
claimStr = ""; |
||||
|
|
||||
|
async mounted() { |
||||
|
await db.open(); |
||||
|
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; |
||||
|
this.activeDid = settings?.activeDid || ""; |
||||
|
this.apiServer = settings?.apiServer || ""; |
||||
|
|
||||
|
this.claimStr = this.$route.query.claim; |
||||
|
try { |
||||
|
this.veriClaim = JSON.parse(this.claimStr); |
||||
|
this.claimStr = JSON.stringify(this.veriClaim, null, 2); |
||||
|
} catch (e) { |
||||
|
// ignore a parse |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async submitClaim() { |
||||
|
const fullClaim = JSON.parse(this.claimStr); |
||||
|
const result = await serverUtil.createAndSubmitClaim( |
||||
|
fullClaim, |
||||
|
this.activeDid, |
||||
|
this.apiServer, |
||||
|
this.axios, |
||||
|
); |
||||
|
if (result.type === "success") { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "success", |
||||
|
title: "Success", |
||||
|
text: "Claim submitted.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} else { |
||||
|
console.error("Got error submitting the claim:", result); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "There was a problem submitting the claim. See logs for more info.", |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |