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> |