// Copyright (C) 2024 Vaughn Nugent
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
import { usePkiConfig, type PkiPublicKey, useSession } from "@vnuge/vnlib.browser";
import { ArrayToHexString, Base64ToUint8Array } from "@vnuge/vnlib.browser/dist/binhelpers";
import { JsonObject } from "type-fest";
import { computed, shallowRef } from "vue";
import { JWK, SignJWT, importJWK } from "jose";
import { clone, isEmpty } from "lodash";
import { FeatureApi, BgRuntime, IFeatureExport, exportForegroundApi, optionsOnly } from "./framework";
import { AppSettings } from "./settings";
import { get, set, toRefs, useToggle, watchDebounced } from "@vueuse/core";
import { Watchable } from "./types";
import { waitForChangeFn } from "./util";
export interface EcKeyParams extends JsonObject {
readonly namedCurve: string
}
export interface PkiPubKey extends JsonObject, PkiPublicKey {
readonly kid: string,
readonly alg: string,
readonly use: string,
readonly kty: string,
readonly x: string,
readonly y: string,
readonly serial: string
readonly userName: string
}
export interface PkiApi extends FeatureApi, Watchable{
getAllKeys(): Promise
addOrUpdate(key: PkiPubKey): Promise
removeKey(kid: PkiPubKey): Promise
refresh(): Promise
}
export const usePkiApi = (): IFeatureExport => {
return{
background: ({ state } : BgRuntime):PkiApi =>{
const { loggedIn } = useSession()
const accountPath = computed(() => state.currentConfig.value.accountBasePath)
const pkiEndpoint = computed(() => `${accountPath.value}/pki`)
const [ onRefresh, refresh ] = useToggle()
//Compute config
const pkiConfig = usePkiConfig(pkiEndpoint);
const keys = shallowRef([])
//Refresh the config when the endpoint changes
watchDebounced([pkiEndpoint, loggedIn, onRefresh], async () => {
if(!loggedIn.value){
set(keys, [])
return
}
if(isEmpty(accountPath.value)){
return
}
const res = await pkiConfig.getAllKeys()
set(keys, res as PkiPubKey[])
}, {debounce: 100});
return{
waitForChange: waitForChangeFn([loggedIn, keys]),
getAllKeys: optionsOnly(() => {
return Promise.resolve(get(keys))
}),
removeKey: optionsOnly(async (key: PkiPubKey) => {
await pkiConfig.removeKey(key.kid)
}),
addOrUpdate: optionsOnly(async (key: PkiPubKey) => {
await pkiConfig.addOrUpdate(key)
}),
refresh() {
refresh()
return Promise.resolve()
}
}
},
foreground: exportForegroundApi([
'waitForChange',
'getAllKeys',
'addOrUpdate',
'removeKey',
'refresh'
])
}
}
interface PkiSettings {
userName: string,
privateKey:JWK | undefined
}
export interface LocalPkiApi extends FeatureApi {
regenerateKey: (userName:string, params: EcKeyParams) => Promise
getPubKey: () => Promise
generateOtp: () => Promise
}
export const useLocalPki = (): IFeatureExport => {
return{
//Setup registration
background: ({ state } : BgRuntime) =>{
const store = state.useStorageSlot('pki-settings', { userName: '', privateKey: undefined })
const { userName, privateKey } = toRefs(store)
const getPubKey = async (): Promise => {
if (!privateKey.value) {
return undefined
}
//Clone the private key, remove the private parts
const c = clone(privateKey.value)
delete c.d
delete c.p
delete c.q
delete c.dp
delete c.dq
delete c.qi
return {
...c,
userName: userName.value
} as PkiPubKey
}
return{
regenerateKey: optionsOnly(async (uname:string, params:EcKeyParams) => {
const p = {
...params,
name: "ECDSA",
}
//Generate a new key
const key = await window.crypto.subtle.generateKey(p, true, ['sign', 'verify'])
//Convert to jwk
const newKey = await window.crypto.subtle.exportKey('jwk', key.privateKey) as JWK;
//Convert to base64 so we can hash it easier
const b = btoa(newKey.x! + newKey.y!);
//take sha256 of the binary version of the coords
const digest = await crypto.subtle.digest('SHA-256', Base64ToUint8Array(b));
//Set the kid
newKey.kid = ArrayToHexString(digest);
//Serial number is random hex
const serial = new Uint8Array(32)
crypto.getRandomValues(serial)
newKey.serial = ArrayToHexString(serial);
//Set the username
set(userName, uname)
set(privateKey, newKey)
}),
getPubKey: optionsOnly(getPubKey),
generateOtp: optionsOnly(async () =>{
if (!privateKey.value) {
throw new Error('No key found')
}
const privKey = await importJWK(privateKey.value as JWK)
const random = new Uint8Array(32)
crypto.getRandomValues(random)
const jwt = new SignJWT({
'sub': userName.value,
'n': ArrayToHexString(random),
keyid: privateKey.value.kid,
serial: (privKey as any).serial
});
const token = await jwt.setIssuedAt()
.setProtectedHeader({ alg: privateKey.value.alg! })
.setIssuer(state.currentConfig.value.discoveryUrl)
.setExpirationTime('30s')
.sign(privKey)
return token
})
}
},
foreground: exportForegroundApi([
'regenerateKey',
'getPubKey',
'generateOtp'
])
}
}