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:
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,
});