cq

Distributed social media platform
git clone git://git.finwo.net/app/cq
Log | Files | Refs

commit 4e0bddd2b8e3186954ea3c1706a4f06a7a8306c7
parent a59f1968260a1c5c6c7d1cc7f58ec5133df3b4f7
Author: finwo <finwo@pm.me>
Date:   Sat, 13 Sep 2025 20:41:13 +0200

Device key creation and account selection

Diffstat:
Mpackage-lock.json | 22+++++++++++++++++++++-
Mpackages/app/package.json | 4+++-
Mpackages/app/src/component/screen-account-create.tsx | 96++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mpackages/app/src/component/screen-account-select.tsx | 33++++++++++++++++++---------------
Mpackages/app/src/component/screen-home.tsx | 4+++-
Mpackages/app/src/main.ts | 4++--
6 files changed, 128 insertions(+), 35 deletions(-)

diff --git a/package-lock.json b/package-lock.json @@ -548,6 +548,15 @@ "resolved": "packages/app", "link": true }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bip39": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/bip39/-/bip39-3.1.0.tgz", @@ -1104,6 +1113,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/supercop": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/supercop/-/supercop-3.0.2.tgz", + "integrity": "sha512-9ytkUBLsSLSnpy6SVZOXIhsz3ChEUAHO1KkyzDhCwGyaJDmySs4gC/b+pMoTH1oqZ6c/44lDB3xYGQQoyXIYUg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/finwo" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1141,9 +1159,11 @@ "version": "0.0.1", "license": "MIT", "dependencies": { + "base64url": "^3.0.1", "bip39": "^3.1.0", "lucide": "^0.544.0", - "mithril": "^2.3.7" + "mithril": "^2.3.7", + "supercop": "^3.0.2" }, "devDependencies": { "@types/mithril": "^2.2.7", diff --git a/packages/app/package.json b/packages/app/package.json @@ -20,8 +20,10 @@ "esbuild-plugins-node-modules-polyfill": "^1.7.1" }, "dependencies": { + "base64url": "^3.0.1", "bip39": "^3.1.0", "lucide": "^0.544.0", - "mithril": "^2.3.7" + "mithril": "^2.3.7", + "supercop": "^3.0.2" } } diff --git a/packages/app/src/component/screen-account-create.tsx b/packages/app/src/component/screen-account-create.tsx @@ -1,7 +1,10 @@ -import { generateMnemonic } from 'bip39'; +import { KeyPair } from 'supercop'; +import { generateMnemonic, mnemonicToSeed } from 'bip39'; import { Vnode } from 'mithril'; +import base64url from 'base64url'; import { getDirectoryHandle, getFileHandle } from '../util/opfs'; +import {HomeScreen} from './screen-home'; type _Vnode = Vnode<{}, { accountId : typeof AccountCreateScreen['accountId' ], @@ -12,6 +15,12 @@ type _Vnode = Vnode<{}, { handler : typeof AccountCreateScreen['handler' ], }>; +function randomString(length: number = 32) { + let output = ''; + while(output.length < length) output += Math.random().toString(36).slice(2); + return output.slice(0, length); +} + export const AccountCreateScreen = { routePath: '/account/create', @@ -28,7 +37,14 @@ export const AccountCreateScreen = { }; }, goto: (vnode: _Vnode, step) => { - return () => vnode.state.step = step; + return () => { + vnode.state.step = step; + if (step === 2) { + setTimeout(() => { + document.getElementById('phraseConfirm')?.focus(); + }, 10); + } + } }, confirmPhrase: (vnode: _Vnode) => { return async () => { @@ -39,17 +55,70 @@ export const AccountCreateScreen = { } vnode.state.step++; - const accountSeedHandle = await getFileHandle(`/accounts/${vnode.state.accountId}/seed.txt`, { create: true }) - const accountSeedWritable = await accountSeedHandle.createWritable(); - await accountSeedWritable.write(vnode.state.seedPhrase); - await accountSeedWritable.close(); + // // Store seed (TODO: don't) + // const accountSeedHandle = await getFileHandle(`/local/accounts/${vnode.state.accountId}/seed.txt`, { create: true }) + // const accountSeedWritable = await accountSeedHandle.createWritable(); + // await accountSeedWritable.write(vnode.state.seedPhrase); + // await accountSeedWritable.close(); - const accountConfigHandle = await getFileHandle(`/accounts/${vnode.state.accountId}/config.json`, { create: true }) + // Build basic config + const accountConfigHandle = await getFileHandle(`/local/accounts/${vnode.state.accountId}/config.json`, { create: true }) const accountConfigWritable = await accountConfigHandle.createWritable(); await accountConfigWritable.write(JSON.stringify({ - name: vnode.state.accountName + displayName: vnode.state.accountName }, null, 2)); await accountConfigWritable.close(); + + // Generate root key + const rootSeed = await mnemonicToSeed(vnode.state.seedPhrase); + const rootKey = await KeyPair.create(rootSeed.slice(0,32)); + + // Generate device key + const deviceSeed = Buffer.from(await crypto.getRandomValues(new Uint8Array(32))); + const deviceKey = await KeyPair.create(deviceSeed); + + // Build basic config + const deviceKeyHandle = await getFileHandle(`/local/accounts/${vnode.state.accountId}/device-key.json`, { create: true }) + const deviceKeyWritable = await deviceKeyHandle.createWritable(); + await deviceKeyWritable.write(JSON.stringify(deviceKey.toJSON(), null, 2)); + await deviceKeyWritable.close(); + + // Build root certificate + const rootCertificateId = randomString(32); + const rootCertificateMeta = base64url.encode(JSON.stringify({ + typ: 'certificate', + iat: Date.now(), + iss: rootCertificateId, + sub: rootCertificateId, + acc: vnode.state.accountId, + roles: ['root'], + })); + const rootCertificateBody = base64url.encode(rootKey.publicKey); + const rootCertificateSignature = base64url.encode(await rootKey.sign(`${rootCertificateMeta}.${rootCertificateBody}`)); + + // Build device certificate + const deviceCertificateId = randomString(32); + const deviceCertificateMeta = base64url.encode(JSON.stringify({ + typ: 'certificate', + iat: Date.now(), + iss: rootCertificateId, + sub: deviceCertificateId, + acc: vnode.state.accountId, + roles: ['device'], + })); + const deviceCertificateBody = base64url.encode(deviceKey.publicKey); + const deviceCertificateSignature = base64url.encode(await rootKey.sign(`${deviceCertificateMeta}.${deviceCertificateBody}`)); + + // Build keychain + const keychainHandle = await getFileHandle(`/remote/accounts/${vnode.state.accountId}/keychain`, { create: true }); + const keychainWritable = await keychainHandle.createWritable(); + await keychainWritable.write(`${rootCertificateMeta}.${rootCertificateBody}.${rootCertificateSignature}\n`); + await keychainWritable.write(`${deviceCertificateMeta}.${deviceCertificateBody}.${deviceCertificateSignature}\n`); + await keychainWritable.close(); + + // Select the account + localStorage.selectedAccount = vnode.state.accountId; + document.location.href=`#!${HomeScreen.routePath}`; } }, taGrow() { @@ -93,19 +162,16 @@ export const AccountCreateScreen = { ), (vnode: _Vnode) => ( <div class="main"> - Building account... + <p> + Building account... + </p> </div> ) ], async oninit(vnode: _Vnode) { vnode.state.seedPhrase = generateMnemonic(); - - vnode.state.accountId = ''; - while(vnode.state.accountId.length < 32) { - vnode.state.accountId += Math.random().toString(36).slice(2); - } - vnode.state.accountId = vnode.state.accountId.slice(0,32); + vnode.state.accountId = randomString(32); }, view(vnode: _Vnode) { diff --git a/packages/app/src/component/screen-account-select.tsx b/packages/app/src/component/screen-account-select.tsx @@ -4,22 +4,27 @@ import { AccountCreateScreen } from './screen-account-create.tsx'; import Trash from 'lucide/dist/esm/icons/trash-2.js'; import { getDirectoryHandle, getFileHandle } from '../util/opfs'; +import {HomeScreen} from './screen-home.js'; type _Vnode = Vnode<{}, typeof AccountSelectScreen>; export const AccountSelectScreen = { routePath: '/account/select', - accounts: [] as Array<{id:string,name:string}>, + accounts: [] as Array<{id:string,displayName:string}>, handler: { + selectAccount: (account: {id:string}) => () => { + localStorage.selectedAccount = account.id; + document.location.href = `#!${HomeScreen.routePath}`; + }, loadAccounts: async (vnode: _Vnode, redraw: boolean = false) => { vnode.state.accounts = []; - const accountsHandle = await getDirectoryHandle('/accounts'); + const accountsHandle = await getDirectoryHandle('/local/accounts'); for await (const [accountId, handle] of accountsHandle.entries()) { try { - const accountConfigHandle = await getFileHandle(`/accounts/${accountId}/config.json`) + const accountConfigHandle = await getFileHandle(`/local/accounts/${accountId}/config.json`) const accountConfig = JSON.parse(Buffer.from(await (await accountConfigHandle.getFile()).bytes())); vnode.state.accounts.push({ ...accountConfig, @@ -29,24 +34,22 @@ export const AccountSelectScreen = { // Intentionally empty } } - + if (!vnode.state.accounts.length) { + document.location.href = `#!${AccountCreateScreen.routePath}`; + return; + } if (redraw) m.redraw(); }, - deleteAccount: (vnode: _Vnode, account:{id:string,name:string}) => async () => { - if (!confirm(`Delete account ${account.name}?`)) return; - const accountsHandle = await getDirectoryHandle(`/accounts`) + deleteAccount: (vnode: _Vnode, account:{id:string,displayName:string}) => async () => { + if (!confirm(`Delete account ${account.displayName}?`)) return; + const accountsHandle = await getDirectoryHandle(`/local/accounts`) await accountsHandle.removeEntry(account.id, { recursive: true }) vnode.state.handler.loadAccounts(vnode, true); } }, async oninit(vnode: _Vnode) { - await vnode.state.handler.loadAccounts(vnode); - if (!vnode.state.accounts.length) { - document.location.href = `#!${AccountCreateScreen.routePath}`; - return; - } - m.redraw(); + await vnode.state.handler.loadAccounts(vnode, true); }, view(vnode: _Vnode) { @@ -55,8 +58,8 @@ export const AccountSelectScreen = { <div id="accountSelectList"> <center style="padding:1em;">Select account</center> {vnode.state.accounts.map(account => ( - <div style="padding:1em"> - {account.name} + <div style="padding:1em" onclick={vnode.state.handler.selectAccount(account)}> + {account.displayName} <svg ns="http://www.w3.org/2000/svg" fill="transparent" style="vertical-align:bottom;width:1em;height:1em;" class="accountListDeleteButton" onclick={vnode.state.handler.deleteAccount(vnode, account)}> {Trash.map(el => m(...el))} </svg> diff --git a/packages/app/src/component/screen-home.tsx b/packages/app/src/component/screen-home.tsx @@ -1,6 +1,8 @@ import { AccountSelectScreen } from "./screen-account-select"; -export default { +export const HomeScreen = { + routePath: '/', + oninit() { // Redirect to account selection if not there diff --git a/packages/app/src/main.ts b/packages/app/src/main.ts @@ -2,11 +2,11 @@ globalThis.m = require('mithril'); import { AccountCreateScreen } from './component/screen-account-create.tsx'; import { AccountSelectScreen } from './component/screen-account-select.tsx'; - +import { HomeScreen } from './component/screen-home.js'; m.route(document.body, "/", { - "/" : require('./component/screen-home.tsx' ).default, "/app" : require('./component/app.tsx' ).default, + [HomeScreen.routePath ]: HomeScreen, [AccountSelectScreen.routePath]: AccountSelectScreen, [AccountCreateScreen.routePath]: AccountCreateScreen, });