Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add trusted operations for instance (reboot/stop, logs retrieval, ...) #42

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions src/components/pages/dashboard/manage/ManageInstance/cmp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default function ManageInstance() {
handleCopyConnect,
handleCopyIpv6,
handleDelete,
handleStop,
copyAndNotify,
mappedKeys,
} = useManageInstance()
Expand Down Expand Up @@ -70,16 +71,29 @@ export default function ManageInstance() {
)}
</StatusLabel>
</div>
<div>
<Button
size="regular"
variant="tertiary"
color="main2"
kind="neon"
onClick={handleDelete}
>
Delete
</Button>
<div tw="flex">
<div tw="mr-3">
<Button
size="regular"
variant="tertiary"
color="main2"
kind="neon"
onClick={handleStop}
>
Stop
</Button>
</div>
<div>
<Button
size="regular"
variant="tertiary"
color="main2"
kind="neon"
onClick={handleDelete}
>
Delete
</Button>
</div>
</div>
</div>

Expand Down
36 changes: 36 additions & 0 deletions src/helpers/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,39 @@ export const BlockchainDefaultABIUrl: Record<IndexerBlockchain, string> = {
[IndexerBlockchain.Ethereum]: ERC20AbiUrl,
[IndexerBlockchain.Bsc]: ERC20AbiUrl,
}

export const SignedPubkeyHeaderName = 'X-SignedPubKey'

export const SignedOperationHeaderName = 'X-SignedOperation'

export type SignedPubkeyHeaderType = {
payload: {
pubkey: JsonWebKey
address: string
expires: Date

// @note: Unused for now
domain: string
}
signature: string
}

export type SignedOperationHeaderType = {
payload: {
// @note: Time is used for replay protection
time: Date

path: string
method: 'POST' | 'GET'
}
signature: string
}

export type SignedHeaderType =
| SignedPubkeyHeaderType
| SignedOperationHeaderType

export type SignedWebHeaderType = {
payload: string
signature: string
}
33 changes: 32 additions & 1 deletion src/helpers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
StoreMessage,
} from 'aleph-sdk-ts/dist/messages/types'
import { MachineVolume } from 'aleph-sdk-ts/dist/messages/types'
import { EntityType } from './constants'
import { EntityType, SignedHeaderType, SignedWebHeaderType } from './constants'
import { SSHKey } from '../domain/ssh'
import { Instance } from '../domain/instance'
import { Volume } from '@/domain/volume'
Expand Down Expand Up @@ -362,3 +362,34 @@ export function toKebabCase(input: string): string {
export function toSnakeCase(input: string): string {
return toKebabCase(input).replace(/-/g, '_')
}

/**
* Returns an hex string to use for as a payload for arbitrary signing with a wallet
*/
export function getSignableBuffer(input: object): string {
return Buffer.from(JSON.stringify(input)).toString('hex')
}

/**
* Takes a signed token (with a JSON payload) and returns a token with a hex encoded payload
*/
export function toWebHeaderToken(token: SignedHeaderType): string {
const webToken: SignedWebHeaderType = {
payload: getSignableBuffer(token.payload),
signature: token.signature,
}

return JSON.stringify(webToken)
}

export const getECDSAKeyPair = async () => {
const keyPair = await crypto.subtle.generateKey(
{
name: 'ECDSA',
namedCurve: 'P-256',
},
true,
['sign', 'verify'],
)
return keyPair
}
97 changes: 97 additions & 0 deletions src/hooks/common/useAuthToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { getSignableBuffer, getECDSAKeyPair } from '@/helpers/utils'
import { useCallback, useState } from 'react'
import { useConnect } from './useConnect'
import {
SignedOperationHeaderType,
SignedPubkeyHeaderType,
} from '@/helpers/constants'

export const KEYPAIR_VALIDITY = 1000 * 60 * 60 * 2

/**
* This hook exposes a function which creates and stores a wallet-signed Json Web Publickey
*/
export function useAuthToken() {
const { account } = useConnect()

const [keypair, setKeypair] = useState<CryptoKeyPair | null>(null)
const [signedJWK, setSignedJWK] = useState<SignedPubkeyHeaderType | null>(
null,
)

if (!account) throw new Error('No account')

/**
* Returns a public key signed by the user's wallet that will be trusted by the API
*/
const getSignedPubkeyToken =
useCallback(async (): Promise<SignedPubkeyHeaderType> => {
console.log('called')
if (!crypto.subtle) {
// @todo: polyfill this?
throw new Error('CryptoSubtle not available')
}
if (!signedJWK || signedJWK.payload.expires.getTime() < Date.now()) {
const kp = await getECDSAKeyPair()
const pubKey = await crypto.subtle.exportKey('jwk', kp.publicKey)
const payload = {
pubkey: pubKey,
expires: new Date(new Date().getTime() + KEYPAIR_VALIDITY),
domain: 'console.aleph.im',
address: account.address,
}

const buf = getSignableBuffer(payload)
const signature = await window.ethereum.request({
method: 'personal_sign',
params: [buf, account.address],
})
const token: SignedPubkeyHeaderType = {
payload,
signature,
}

setKeypair(kp)
setSignedJWK(token)
return token
}

return signedJWK
}, [account, signedJWK, keypair])

/**
* Uses the key generated by getSignedPubkeyToken to sign an operation token
*/
const getSignedOperationToken = useCallback(
async (
path: string,
method: 'POST' | 'GET',
): Promise<SignedOperationHeaderType> => {
if (!keypair || !signedJWK) {
throw new Error('No available keypair, generate a signed pubkey first')
} else if (signedJWK.payload.expires.getTime() < Date.now()) {
// refresh the token
await getSignedPubkeyToken()
}
const payload = {
time: new Date(),
path,
method,
}

const signature = await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
keypair.privateKey,
Buffer.from(JSON.stringify(payload)),
)

return {
payload,
signature: Buffer.from(signature).toString('hex'),
}
},
[account, keypair, signedJWK],
)

return { getSignedPubkeyToken, getSignedOperationToken }
}
31 changes: 31 additions & 0 deletions src/hooks/common/useTrustedOperation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {
SignedOperationHeaderName,
SignedPubkeyHeaderName,
} from '@/helpers/constants'
import { useAuthToken } from './useAuthToken'
import { toWebHeaderToken } from '@/helpers/utils'

export function useTrustedOperation() {
const { getSignedOperationToken, getSignedPubkeyToken } = useAuthToken()

const stopMachine = async (hostname: string, vmHash: string) => {
const path = `https://[${hostname}]/control/machine/${vmHash}/stop}`
const pubKeyToken = await getSignedPubkeyToken()
const operationToken = await getSignedOperationToken(path, 'POST')

const headers = new Headers()
headers.append(SignedPubkeyHeaderName, toWebHeaderToken(pubKeyToken))
headers.append(SignedOperationHeaderName, toWebHeaderToken(operationToken))
headers.append('Content-Type', 'application/json')

// @todo: Use the proper request method
fetch(path, {
method: 'POST',
headers,
})
}

return {
stopMachine,
}
}
12 changes: 12 additions & 0 deletions src/hooks/pages/dashboard/manage/useManageInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ActionTypes } from '@/helpers/store'
import { useInstanceStatus } from '@/hooks/common/useInstanceStatus'
import { useSSHKeyManager } from '@/hooks/common/useManager/useSSHKeyManager'
import { SSHKey } from '@/domain/ssh'
import { useTrustedOperation } from '@/hooks/common/useTrustedOperation'

export type ManageInstance = {
instance?: Instance
Expand All @@ -18,6 +19,7 @@ export type ManageInstance = {
handleCopyConnect: () => void
handleCopyIpv6: () => void
handleDelete: () => void
handleStop: () => void
copyAndNotify: (text: string) => void
mappedKeys: (SSHKey | undefined)[]
}
Expand All @@ -36,6 +38,7 @@ export function useManageInstance(): ManageInstance {

const manager = useInstanceManager()
const sshKeyManager = useSSHKeyManager()
const { stopMachine } = useTrustedOperation()

useEffect(() => {
if (!instance || !sshKeyManager) return
Expand Down Expand Up @@ -83,13 +86,22 @@ export function useManageInstance(): ManageInstance {
}
}, [instance, manager, onLoad, dispatch, onSuccess, router, onError])

const handleStop = useCallback(async () => {
if (!instance) throw new Error('Invalid function')
if (!manager) throw new Error('Manager not ready')
if (!status?.vm_ipv6) throw new Error('Invalid VM IPv6 address')

stopMachine(status.vm_ipv6, instance.id)
}, [])

return {
instance,
status,
handleCopyHash,
handleCopyConnect,
handleCopyIpv6,
handleDelete,
handleStop,
copyAndNotify,
mappedKeys,
}
Expand Down